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

    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.mozBackgroundRequest = true;
    xhr.open("GET", "http://rutracker.org/forum/index.php", true);
    xhr.timeout = 10000;
    xhr.channel.loadFlags |= Components.interfaces.nsIRequest.LOAD_BYPASS_CACHE;
    xhr.responseType = "document";
    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.mozBackgroundRequest = true;
    xhr.open("GET", "http://rutracker.org/forum/index.php", true);
    xhr.timeout = 10000;
    xhr.channel.loadFlags |= Components.interfaces.nsIRequest.LOAD_BYPASS_CACHE;
    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 characters\n\n" +
    	
    	elm_nodes + "\telement nodes\n" +
    	txt_nodes + "\ttext nodes\n" +
    	com_nodes + "\tcomment nodes\n" +
    	all_nodes + "\tall nodes\n\n" +
    	
    	max_nst_lv_nodes + " nodes in the " + max_nst_lv + " maximum nesting level\n"
    );
    


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

    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 как есть, что, конечно, будет проявлением неуважительности к чужому времени.

    Комментарии 38

      +2
      Плюсанул… молча, просто потому что нет слов.
      p.s. проблема воспроизводится только у firefox? что по поводу chromum? opera/ie greasemonkey?
        0
        Спасибо. Сейчас попробую потестировать на других версиях firefox и в других браузерах. По ходу дела постараюсь отчитываться.
          +1
          Firfox 13 — та же проблема (и не работает параметр mozBackgroundRequest, вызывает сбой).
          Firfox 14 — та же проблема.
          Firfox 15 — та же проблема.

          Internet Explorer, Opera, Safari (WebKit) — судя по списку совместимости, нет поддержки responseType = 'document'.

          На последнем хроме сейчас потестирую.
            0
            Как оказалось, сбой на Firfox 13 из-за того, что mozBackgroundRequest нужно определять до open() (на последующих версиях это сбоя не вызывает, однако и поведения не изменяет в нужную сторону). Подправил код, теперь и 13-версии должно работать без сбоев (однако с таймаутами).
            0
            Не уверен, правильно ли я проверял в Chrome. Поскольку я не нашёл возможности запустить код в контексте браузера, пришлось открыть страницу трекера в браузере, чтобы не получать предупреждений о кроссбраузерных запросах, и тогда уже запускать код из консоли (нужно было только удалить две специфические строчки для Firefox: с mozBackgroundRequest и channel.loadFlags).

            На последнем Chrome никакий таймаутов не происходит.
            0
            А ОС у вас 32-бит? Может какие-то ограничения всплывают из-за этого?
              0
              Да, 32 бита. Это возможно, но было бы странно, потому что ведь в браузере всё рендерится, а нагрузка должна быть при этом намного больше. Конечно, по логике, упомянутой администратором forums.mozilla.org (мол, мало кто использует responseType = «document» в XMLHttpRequest), могли бы урезать ресурсы по сравнению с рендерингом, но как-то уж сильно урезали и без всякого упоминания об этом. Надеюсь, всё же непреднамеренный баг.
              • НЛО прилетело и опубликовало эту надпись здесь
                  0
                  Спасибо вам большое. Это важное добавление, а у меня самого нет возможности перепроверить на 64.

                  Да, mozBackgroundRequest, наверное, запустили начиная с 14-й версии, у меня тоже вылетало на 13-й, хотя на сайте разработчиков не удалось найти информации о совместимости этого свойства (оно нестандартное, но здесь его применять логично).
                    0
                    Чуть выше уточнил причину. Да и на MDN уже дополнили информацию.
              0
              Quick and dirty: почистить документ от скриптов через регулярки…
                0
                Дело в том, что при responseType = 'document' невозможно получить доступ к responseText и работать с кодом напрямую. А если сначала получить свойство responseText, всё равно это ничего не даст, потому что оно только для чтения. А если бы и удалось его изменить, responseType = 'document' нельзя потом задать задним числом и получить DOM изменённого кода. Две стратегии расходятся с самого начала.

                Можно, конечно, получить код, исправить его и отослать скрытому браузеру для парсинга через data: URI, но в этом нет нужды, так как скрытый браузер и без чистки хорошо справляется, этот как раз один из тех хитрых способов по ссылке в начале статьи.
                  +1
                  А создать DIV, тупо выдрать из .responseText содержимое BODY (поиск строки), засунуть в innerHTML — вот тебе и DOM структура?
                  Возможно придется выдирать еще теги скриптов, чтобы не срабатывали. Приходилось в одном проекте так делать без новомодных штучек, все работало даже в IE6(не говоря про остальной зоопарк).
                    0
                    Да, приблизительно такими аферами я и занимался раньше. Но это сопряжено с такой путаницей (дополнительные скрытые браузеры или iframe для парсинга по URL с обратными вызовами, повешенными на окончание загрузки кода; непредсказуемое поведение при скармливании всяким div-ам полного или рискованно обрезанного кода; передача скрытым браузерам responseText через data: URI опять таки с обратными вызовами на завершение загрузки). Всё это опасно шатается и трещит по швам. Так хотелось чистоты и порядка)
                      +1
                      Я думаю вас спасёт — DOMParser и что бы не мучались с Data:URI, лучше использовать Blob

                      HTML5 в конце концов :)
                        0
                        Спасибо, он и правда работает. Но ведь при этом грузит изображения и остальные ресурсы (css и т. д.). При этом у него неудобная обработка ошибок. Вот только что тестировал, на одном из сайтов с редиректом он мало того что обрывается с сообщением об ошибке в консоль, он ещё и алерт непрошенный выдаёт с ответом сервера о редиректе. Как запасное средство подойдёт, но всё же хорошо бы доработали XMLHttpRequest.
                          +1
                          DOMPaser не должен выполнять не скрипты, не стили, не тем более HTTP запросы. Если это происходит, то это явный баг. В wiki и в спецификации указано, что он не должен этим заниматься. Если вы говорите про Blob, то в него нужно запихивать потом. Blob это грубо говоря sandbox, или файл в памяти браузера. Из этой памяти можно строить странички например, картинки или скрипты.

                          Только, что проверил. В стабильной Лисе работает правильно.
                            0
                            Я именно про DOMPaser. Я не думаю, что он их выполняет. Однако расширения, мониторящие HTTP активность, показывают, что он загружает почему-то эти ресурсы, тогда как при чистом XMLHttpRequest никаких дополнительных запросов не происходит.

                            В ночной сборке тоже работает. Можно страховаться: если XHR с парсингом выдаёт таймаут, можно запрашивать повторно код и парсить при помощи DOMPaser. Но это ведь всё лишний код и лишние запросы.

                            Интересно, механизмы у DOMPaser и парсера XHR одни и те же или нет? Почему же такая разница…
                              0
                              С DOMPaser это был баг, причём критический, затрагивающий безопасность. Вчера пофиксили для ночных сборок, но когда патч доберётся до авроры/беты/релиза, я не знаю.
                  +5
                  У вас достатчно информации, чтобы зарепортить баг.
                    0
                    Я запостил на русской багзилле (почти полностью повторив статью). Но чтобы постить на английской, мне бы хотелось как-то сузить проблему, чтобы перевести только существенное, выжимку (у меня не очень хорошо получается писать на английском). Если за несколько дней больше ничего не прояснится, попробую составить английский багрепорт из того, что есть.
                      +2
                      Я думаю, вам ни к чему переводить всю статью. В багрепорте достаточно будет указать код, которым загружается страница, код для генерации тестовой страницы и комментарии о том, какие изменения в тестовой странице приводят к каким результатам.
                        0
                        Хорошо, я попробую. Спасибо.
                    +1
                    Читал, как детектив! Однако финал автор пока оставил на усмотрение читателя :)
                      +2
                      Сообщения на багзиллах: русской, английской.
                        +1
                        На счёт протестить их патч — протестить не получится естественно в автоматических сборках. Что бы протестить нужно сказать и сбилдить mozilla-central, а потом пропатчить diff из багзиллы.
                          0
                          Спасибо большое. А в каком случае и как скоро подобные патчи входят в бинарные ночные сборки?
                            +1
                            Я думаю сразу после того, как статус станет RESOLVED, а потом VERIFIED в баге. Хотя я на самом деле не уверен. Многие баги фиксят и патчат в hg Мозилловский, но не включают в билды по некоторым причинам. Если баг посчитают актуальным, а «фичу» не эксперементальной, то видимо, постараются сразу как смогут.

                            Вообще, раз Nightly билдится каждую ночь по нескольку раз, видимо туда попадаются все фиксы которые уже в репозитории. Ведь это же dev сборки.
                              0
                              Спасибо за объяснения. Там появилась ссылка на тестовые бинарники, попробую протестировать. Я обновляюсь на ночной сборке каждую ночь автоматически (браузер сам обновляется), просто не уверен что такие дежурные тестовые фиксы тоже вносятся в эти обновления.

                              Фича вряд ли экспериментальная, она ведь в спецификации XHR внесена. И по идее должна работать с любыми страницами. Жаль только что, похоже, никто баг не может подтвердить на багзилле. Боюсь, уж не явилась ли моя ситуация фатальным стечением неповторимых обстоятельств, которых я так и не распутаю.
                                0
                                Надо же, на тестовом бинарнике никаких зависаний! Насколько я понял из списка различий, изменения касались в основном более надёжного запрета на исполнение скриптов, хотя я плохо разбираюсь в том коде.

                                Остаётся лишь надеяться, что патч применят ко всем версиям начиная с 13-й.
                                  0
                                  Врятли в 13ой применят. У разработчиком точно есть план работы и всё такое. То есть текущую версию фиксят только по критичским проблемам, например безопасности. Думаю просто нужно дождаться 14ой версии.
                                    0
                                    К сожалению, автора патча надеется только на 16-ю версию, как можно понять из его комментария. Ну что ж, если и так, ждать не очень долго)
                          +1
                          А если так

                          xhr.onreadystatechange = function (aEvt) {
                              if (xhr.readyState == 4) {
                                  if(xhr.status == 200){
                                      var xmldoc = xhr.responseXML;
                                      var urlList = xmldoc.getElementsByTagName('title');
                                  }else{
                                      ...
                                  }
                              }
                          };
                          

                          , то тоже валится?
                            0
                            Добавил к коду:

                            xhr.onreadystatechange = function (aEvt) {
                            	if (xhr.readyState == 4) {
                            		if(xhr.status == 200){
                            			var xmldoc = xhr.responseXML;
                            			var elmList = xmldoc.getElementsByTagName('title');
                            			Components.classes["<hh user=mozilla>.org/consoleservice;1"].
                            				getService(Components.interfaces.nsIConsoleService).logStringMessage(elmList[0].textContent);
                            		}else{
                            			Components.classes["<hh user=mozilla>.org/consoleservice;1"].
                            				getService(Components.interfaces.nsIConsoleService).logStringMessage(xhr.status + "!");
                            		}
                            	}
                            	else {
                            		Components.classes["<hh user=mozilla>.org/consoleservice;1"].
                            			getService(Components.interfaces.nsIConsoleService).logStringMessage(xhr.readyState);
                            	}
                            };
                            


                            На рабочих страницах выдаёт: 1-2-3-3-3-3-3-3-3-title(в консоль и в alert). На проблемной выдаёт: 1-2-3-3-3-3-3-3-3-3-3-3-timeout в alert и ошибку в консоль «xmldoc is null»
                              0
                              хабрапарсер заменил

                              "СОБАКАmozilla.org/consoleservice;1"
                              


                              на

                              "<hh user=mozilla>.org/consoleservice;1"
                              

                                0
                                Значит на проблемной станции отвалилось по таймауту, а т.к. при этом xmldoc превращается в null и readyState превращается в 4 то и происходит описанное поведение.

                                Видимо на проблемной станции какой-то индивидуальный глюк со стеком TCP\IP.

                                Там никаких расширений для браузера ещё не стоит, может быть какой-нибудь фильтр или что-нибудь ещё? В FF можно же даже с сокетами общаться напрямую, может стоит что-нибудь такое, специфическое, рубящее все попытки получить тот XML?

                                да, и вот эта строчка
                                xhr.overrideMimeType('text/html; charset=windows-1251');
                                она точно нужна?
                                  0
                                  Сомневаюсь, что проблема на таком низком уровне, ведь код без парсинга получается без проблем, вина именно на стороне браузера.

                                  Без этой строчки та же проблема, я тестировал и так, и так. Просто без неё почему-то в отдельных случаях браузер выводит кракозабры (то ли сервер забывает включать charset в код, то ли забывает отсылать его в заголовках).

                                  На багзилле выложили временно пропатченный вариант бинарника, в нём нет никаких таймаутов. Насколько я понял, патч заключался в более последовательном запрете на исполнение скриптов при парсинге, которые по идее и так не должны были исполнятся в подобном случае, но выходил какой-то сбой в этой области. Так что скорее всего в этом дело. Жаль только, что даже в случае одобрения патч скорее всего войдёт лишь в 16-й релиз.
                            0
                            Сегодня официально пофиксили, но когда патч распространится на все ветки разработки, я не знаю.

                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                            Самое читаемое