История одного расследования о странном поведении XMLHttpRequest в новых версиях Firefox

в 22:53, , рубрики: ajax, dom, Firefox, html, javascript, rutracker.org, xhr, xmlhttprequest, метки: , , , , , , ,

I. Суть проблемы.

В список основных предназначений XMLHttpRequest, конечно, не входит запрос HTML, чаще этот инструмент взаимодействует с XML, JSON или простым текстом.

Однако связка XMLHttpRequest + HTML хорошо работает при создании расширений к браузеру, которые в фоновом режиме опрашивают на предмет новостей сайты, не предоставляющие для этого почтовую подписку, RSS или другие экономные API или предоставляющие эти сервисы с какими-то ограничениями.

При создании нескольких расширений для Firefox я сталкивался с такой необходимостью. Работать с полученным от XMLHttpRequest кодом HTML при помощи регулярных расширений — способ очень ненадёжный и громоздкий. Получить DOM от XMLHttpRequest можно было лишь для правильного XML. Поэтому приходилось следовать хитрым советам на сайте разработчиков. Однако начиная с Firefox 11 появилась возможность непосредственного получения DOM от XMLHttpRequest, а в Firefox 12 была добавлена обработка таймаутов.

Я испытал новую возможность на создании мини-индикаторов новых топиков для двух небольших форумов, и это оказалось очень удобным (50 строчек кода плюс расширение CustomButtons — вот и готовый индикатор за пять минут, с опросами по таймеру и четырьмя состояниями: нет новостей, есть новости, ошибка и таймаут). Всё работало как часы.

Поэтому я попытался убрать из кода своих расширений все прежние костыли и ввести туда новый удобный парсинг. Однако при работе с сайтом rutracker.org возникла странная проблема (тестирование проходит на последней ночной сборке под Windows XP; очень прошу прощения за все косяки в коде и формулировках: у меня нет программистского образования и опыт мой в этой сфере, к сожалению, очень невелик.).

Нижеследующий упрощённый пример кода почти всё время уходит в таймаут (для проверки нужно авторизоваться на сайте — далее станет понятно, почему это существенно):

var xhr = new XMLHttpRequest();
xhr.open("GET", "http://rutracker.org/forum/index.php", true);
xhr.mozBackgroundRequest = true;
xhr.timeout = 10000;
xhr.channel.loadFlags |= Components.interfaces.nsIRequest.LOAD_BYPASS_CACHE;
xhr.responseType = "document";
xhr.overrideMimeType('text/html; charset=windows-1251');
xhr.onload = function() {
	alert(this.responseXML.title);
}
xhr.onerror = function() {
	alert("Error!");
}
xhr.ontimeout = function() {
	alert("Timeout!");
}
xhr.send(null);

Причём загвоздка именно в парсинге HTML в DOM, потому что сайт отдаёт страницу без задержки и, например, следующий код без парсинга работает без запинок:

var xhr = new XMLHttpRequest();
xhr.open("GET", "http://rutracker.org/forum/index.php", true);
xhr.mozBackgroundRequest = true;
xhr.timeout = 10000;
xhr.channel.loadFlags |= Components.interfaces.nsIRequest.LOAD_BYPASS_CACHE;
xhr.overrideMimeType('text/html; charset=windows-1251');
xhr.onload = function() {
	alert(this.responseText.match(/<title>.+?</title>/i)[0]);
}
xhr.onerror = function() {
	alert("Error!");
}
xhr.ontimeout = function() {
	alert("Timeout!");
}
xhr.send(null);

Спецификация XMLHttpRequest утверждает, что при парсинге HTML/XML в DOM scripts in the resulting document tree will not be executed, resources referenced will not be loaded and no associated XSLT will be applied, то есть скрипты не отрабатываются и никакие ресурсы не загружаются (что подтверждается мониторингом HTTP активности при описанных запросах), так что с этих сторон задержки быть не может. Единственная загвоздка может быть только в структуре самого DOM: парсинг почему-то зависает и создаёт псевдо-таймаут.

II. Дополнительные наблюдения.

Тогда я создал небольшой скрипт для DOM-статистики и стал при его помощи анализировать проблемную страницу.

var doc = content.document;
var root = doc.documentElement;

var text_char = root.textContent.length;

var elm_nodes = doc.evaluate(".//*", root, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength;
var txt_nodes = doc.evaluate(".//text()", root, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength;
var com_nodes = doc.evaluate(".//comment()", root, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength;
var all_nodes = doc.evaluate(".//node()", root, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength;

var max_nst_lv = 0;
var max_nst_lv_nodes = 0;
for (var level = 1, pattern = "./node()"; level <= 50; level++, pattern += "/node()") {
	var elm_num = doc.evaluate(pattern,root,null,XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null).snapshotLength;
	if (elm_num) {
		max_nst_lv = level;
		max_nst_lv_nodes = elm_num;
	}
}

alert(
	text_char + "ttext charactersnn" +
	
	elm_nodes + "telement nodesn" +
	txt_nodes + "ttext nodesn" +
	com_nodes + "tcomment nodesn" +
	all_nodes + "tall nodesnn" +
	
	max_nst_lv_nodes + " nodes in the " + max_nst_lv + " maximum nesting leveln"
);

Вот некоторые ещё более озадачившие меня данные.

1. Заглавная страница форума с отключённым JavaScript имеет: 49677 знаков в текстовых узлах, 4192 HTML элементов, 4285 текстовых узлов, 77 комментариев, всего 8554 узлов; 577 узлов на максимальном 25-м уровне вложенности узлов.

2. Если выйти из форума и загрузить страницу для неавторизованных пользователей, получится такая статистика: 47831 знаков в текстовых узлах, 3336 HTML элементов, 4094 текстовых узлов, 73 комментариев, всего 7503 узлов; 1136 узлов на максимальном 24-м уровне вложенности узлов. Структура явно проще и если испробовать проблемный код, выйдя из форума (то есть на этой странице для неавторизованных пользователей), то никаких таймаутов не происходит.

3. Попробовал загружать проблемную страницу на испытательный сайт и понемногу упрощать её структуру. Например, если удалить все элементы td с классом row1 (заголовки форумов и субфорумов в таблице на заглавной странице) и больше ничего не менять, получим такую статистику: 20450 знаков в текстовых узлах, 1355 HTML элементов, 1726 текстовых узлов, 77 комментариев, всего 3158 узлов; 8 узлов на максимальном 25-м уровне вложенности узлов. И опять-таки данная страница за очень редким исключением не даёт таймаутов.

4. Очень странное значение имеют элементы script. На заглавной странице их 19 (в head и body вместе взятых, загружаемых и встроенных). Если удалить только эти элементы, страница перестаёт давать таймауты. Причём если удалять от конца до начала, нужно удалять все (даже если оставить первый загружаемый скрипт в head, таймауты продолжаются). А если удалять от начала до конца, таймауты прекращаются после удаления скрипта, встроенного в элемент p класса forum_desc hidden в разделе «Правила, основные инструкции, FAQ-и», после него можно оставить ещё 6 скриптов, и таймауты всё равно прекратятся (причём удаление только этого скрипта проблему не решает). Причём если все 19 скриптов заменить пустыми элементами script без кода и без атрибута src, таймауты остаются. Но если эти пустые элементы заменить на такие же пустые элементы style в том же количестве, таймауты сразу пропадают.

5. При помощи скрипта на PERL попробовал создать тестовый HTML с более-менее сложной структурой (но без элементов script). Получился файл размером почти в 10 мегабайт со следующей статистикой: 9732505 знаков в текстовых узлах, 25004 HTML элементов, 25002 текстовых узлов, 1000 комментариев, всего 51006 узлов; 1000 узлов на максимальном 27-м уровне вложенности. Вроде бы структура объёмнее и сложнее проблемной страницы, однако никаких таймаутов она не вызывает. Стало очевидным, что дело в каком-то неоднозначном сочетании объёма/сложности/специфики элементов.

6. Стоило только добавить к этой смоделированной странице элементы script, таймауты вернулись (хоть порог таймаута я увеличил в этом сложном случае до минуты).

III. Создание легко воспроизводимого прецедента.

У меня получилось достичь некоторого критического минимума проблемности структуры, соизмеримой со структурой заглавной страницы трекера, при помощи такого скрипта на PERL:

use strict;
use warnings;

open(OUTPUT, '>:raw:encoding(UTF-8)', "test.html") or die "Cannot write to test.html: $!n";
print OUTPUT
	"<!DOCTYPE html PUBLIC '-//W3C//DTD HTML 4.01 Transitional//EN' 'http://www.w3.org/TR/html4/loose.dtd'>n" .
	"<html><head><meta http-equiv='Content-Type' content='text/html; charset=UTF-8'><title>Test</title></head><body>" .
	 (("<div class='abcd'>abcd" x 25 . "</div>" x 25 ) x 10 . "<script type='text/javascript'>var a = 1234;</script>") x 20 .
	"</body></html>n";
close(OUTPUT);
 

Статистика страницы: 20265 знаков в текстовых узлах, 5024 HTML элементов, 5022 текстовых узлов, 0 комментариев, всего 10046 узлов; 200 узлов на максимальном 27-м уровне вложенности узлов. В том числе 20 простейших элементов script. Получаем 10 таймаутов из 10 попыток.

При разных попытках упростить структуру или сократить объём вероятность таймаутов снижается, но довольно непредсказуемым образом (ни одно из указанных упрощений не накладывалось на другое, перед каждым скрипт возвращался к исходному коду):

— перемещение всех элементов script в конец кода (при том, что больше ничего не меняется и статистика остаётся прежней): 0 таймаутов из 10 попыток.
— замена элементов script на элементы span с одним атрибутом и тем же текстовым содержимым (без перемещения в конец): 0 таймаутов из 10 попыток.
— сокращения текста скрипта на 3 знака: 7 таймаутов из 10.
— удаления всего содержимого скрипта (остаётся только пустой тег): 6 таймаутов из 10 попыток.
— сокращение текста элементов div до одного знака: 5 таймаутов из 10 попыток.
— полное удаление текста элементов div (получается пустая страница): 7 таймаутов из 10 попыток.
— сокращение атрибута class элементов div до одного знака: 8 таймаутов из 10 попыток.
— удаление атрибута class элементов div: 1 таймаут из 10 попыток.
— сокращение количества элементов script до 2 (в середине кода и в конце): опять 10 таймаутов из 10 попыток.
— сокращение количества элементов script до 1 (в начале кода): всё те же 10 таймаутов из 10 попыток (но если этот элемент переместить в конец кода, таймауты пропадают совершенно).
— сокращение количества элементов div (и соответственно текстовых узлов) наполовину с сохранением максимального уровня вложенности: 3 таймаута из 10 попыток.
— сокращение максимального уровня вложенности наполовину (общее количество элементов и текстовых узлов остаётся почти тем же, но вдвое вырастает количество элементов на максимальном уровне вложенности): 7 таймаутов из 10 попыток.
— сокращение максимального уровня вложенности всего до 3 (body/div/текст или body/script/текст) с сохранением общего количества элементов: 8 таймаутов из 10 попыток.

IV. Предварительные выводы.

Во всех описанных случаях никакой перегрузки процессора не наблюдалось, так что нет оснований винить в зависаниях аппаратную часть (как и задержки в сети: код получается за доли секунды, в браузере страница рендерится за время значительно меньшее таймаутов). Очевидно, в XMLHttpRequest под парсинг HTML в DOM выделяются какие-то ограниченные ресурсы, которые исчерпываются разным сочетанием параметров. Причём загадочную роль играют элементы script (которые даже не исполняются) и особенно их порядок в коде. Если этот так, стоит увеличить эти ресурсы, поскольку проблема отнюдь не надуманная и возникает в ходе обычной разработки расширений.

V. Что дальше.

Когда я только начинал анализировать проблему и спросил совета на нескольких сайтах, на forums.mozilla.org администратор предположил баг в сфере производительности и посоветовал отослать сообщение на bugzilla.mozilla.org в раздел Core::DOM с описанием воспроизводимой ситуации. Тогда я имел ещё очень мало данных, да и сейчас они очень неясны. Поэтому буду благодарен за любые соображения, позволяющие конкретизировать проблему и внятно её сформулировать. Иначе ведь придётся переводить всю эту простыню на английский (что мне с моим уровнем владения языком будет сделать очень непросто) и постить на bugzilla.mozilla.org как есть, что, конечно, очень неудобно.

Автор: vmb

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js