В The Conversation мы недавно получили в свои руки новую красивую восьмиъядерную машину, которая сидит в углу и выступает в качестве нашего сервера сборки. Это машина высшего класса: быстрые процессоры, SSD — без излишеств. Мы назвали это «Танк». Распараллелив наш тестовый набор Selenium, мы сократили время сборки с двадцати минут до четырех. Это зрелище: восемь окон Firefox одновременно запускаются и щелкают по вашему приложению. Это пятикратное улучшение казалось слишком хорошим, чтобы быть правдой, и, действительно, очень быстро дьявол проявил себя:
Errno::EBADF: Bad file descriptor # .../lib/selenium/webdriver/firefox/socket_lock.rb:57:in `initialize' # .../lib/selenium/webdriver/firefox/socket_lock.rb:57:in `new' # .../lib/selenium/webdriver/firefox/socket_lock.rb:57:in `can_lock?' # .../lib/selenium/webdriver/firefox/socket_lock.rb:43:in `lock' # .../lib/selenium/webdriver/firefox/socket_lock.rb:29:in `locked' # .../lib/selenium/webdriver/firefox/launcher.rb:32:in `launch' # .../:21:in `initialize'
Случайно провал спецификации. Бич существования любого разработчика. Мы получили бы эту ошибку примерно в 30% случаев. Не круто. Трассировка стека указывает на веб-драйвер селена в качестве виновника; давайте погрузимся и посмотрим, сможем ли мы выяснить, что происходит.
Дайвинг в
Это сложная проблема, так как нет очевидного воспроизводимого контрольного примера. Повторный запуск сборки не прекратит ее — если она сработает, мы не узнаем, было ли это нашим изменением или только один раз, когда это сработало. Поэтому наша первая задача будет заключаться в том, чтобы сузить нарушающий код и извлечь тестовый пример, который позволит нам экспериментировать. Давайте посмотрим на код источника трассировки стека.
Мы использовали более старую версию гема, которая в настоящее время выпущена — проблема не была исправлена и в более новых версиях, но ради фокуса я начал свое исследование с той же старой версии. Обычно вы можете найти тег git или удобное сообщение коммита («Bump to 0.1.2»), чтобы найти правильную версию кода, но в этом случае проще было просто распаковать гем.
gem unpack selenium-webdriver -v 0.1.2 cd selenium-webdriver-0.1.2
Оттуда мы можем открыть оскорбительный метод:
# lib/selenium/webdriver/firefox/socket_lock.rb def can_lock? @server = TCPServer.new(Platform.localhost, @port) ChildProcess.close_on_exec @server true rescue SocketError, Errno::EADDRINUSE => ex $stderr.puts "#{self}: #{ex.message}" if $DEBUG false end
Класс исключений Errno
из операционной системы , в данном случае из-за открытия нового сервера, прослушивающего данный порт. Учитывая, что этот код, вероятно, будет выполняться одновременно несколькими процессами (когда мы запускаем по одному для каждого из восьми ядер), и одновременная загрузка кажется вероятным кандидатом для создания странных ошибок, это хорошее место для начала. Давайте ужесточим наш цикл обратной связи, превратив этот код в автономный сценарий Ruby, который мы можем использовать для концентрированного исследования параллельного поведения, вместо того, чтобы повторно запускать нашу сборку каждый раз, когда мы хотим протестировать изменение. Это важный первый шаг в любом процессе отладки: минимизируйте время между внесением изменения и возможностью увидеть результат этого изменения.
require 'socket' 50.times do fork do 1000.times do sleep (rand / 1000.0) begin server = TCPServer.new('127.0.0.1', 5678) server.close rescue SocketError, Errno::EADDRINUSE => e end end end end
Пятьдесят процессов, все пытаются использовать один и тот же порт одновременно. Если это не выявит ошибку, ничего не будет. К сожалению, он работал нормально на моей локальной машине OSX. Воодушевленный, я вспомнил важный второй шаг отладки: всегда проверяйте предположения как можно ближе к целевой среде. Моя локальная машина работает под управлением OSX, но Tank работает под управлением Ubuntu Linux, который, вероятно, будет иметь другое поведение для операций на уровне сокетов. Я заскочил в Танк и запустил вышеуказанный скрипт. Конечно же, плохие файловые дескрипторы были разбросаны повсюду.
Но что это значит? Является ли эта ошибка «ожидаемой» так же, как Errno::EADDRINUSE
, или она вызовет сбои в can_lock?
если мы молча can_lock?
ее внутри can_lock?
метод. Чтобы ответить на этот вопрос, нам нужно больше контекста из окружающего кода. Еще несколько методов из класса SocketLock
:
# lib/selenium/webdriver/firefox/socket_lock.rb def locked(&blk) lock begin yield ensure release end end private def lock max_time = Time.now + @timeout until can_lock? || Time.now >= max_time sleep 0.1 end unless did_lock? raise Error::WebDriverError, "unable to bind to locking port #{@port} within #{@timeout} seconds" end end
Метод lock
будет can_lock?
попытку can_lock?
до тех пор, пока происходит сбой, это говорит о том, что мы должны иметь возможность просто повторить попытку подключения с ошибочным дескриптором файла. locked
— это просто оболочка для автоматического снятия блокировки после выполнения блока. Двигаясь вверх по трассе стека, чтобы подтвердить, почему мы блокируем, мы находим этот прекрасно самодокументирующийся метод:
# lib/selenium/webdriver/firefox/launcher.rb def launch socket_lock.locked do find_free_port create_profile start_silent_and_wait start connect_until_stable end self end
Драйвер использует порт в качестве мьютекса, чтобы гарантировать, что только один процесс одновременно сканирует свободный порт. Он создает соединение на только что найденном порте, затем освобождает заблокированный порт, что позволяет другому процессу найти свободный порт. Поскольку все восемь процессов запускаются с портом селена «по умолчанию», все они будут стоять в очереди друг за другом (непрерывно вращаясь по can_lock?
— воссоздавая ситуацию, воспроизведенную в нашем минимальном тестовом примере выше), и сканировать по одному за раз через потенциальные порты.
Мне было любопытно обосновать эту логику, поэтому я решил попытать счастья в IRC. Я догадывался, что канал #selenium
будет существовать, и в нем действительно было многообещающие 58 участников. Вопрос о том, с кем я могу поговорить о веб-драйвере Ruby, привел меня к дискуссии с jarib
который указал мне на вики , в котором jarib
следующие обоснования:
Был выбран подход использования сокета как мьютекса, потому что он позволяет
один и тот же алгоритм, который будет использоваться различными JVM или даже языками на
в то же время, не опасаясь запуска нескольких экземпляров Firefox все слушают
на тот же порт (который выглядит ужасно, я могу заверить вас 🙂 Механизм
достаточно прост, так что большинство языков могут быть использованы для его реализации без
требующие каких-либо специальных библиотек.
Это кажется справедливым. Он также содержит описание процесса запуска, которое соответствует тому, что мы выяснили выше, хотя и с более подробной информацией.
Это исследование позволяет сделать два вывода. Во-первых, что молча спасает Errno::EBADF
в can_lock?
метод безопасен и желателен. Во-вторых, такое явное указание уникальных портов для каждого процесса, скорее всего, позволит избежать проблемы в целом, а также даст более быстрое (хотя на практике, вероятно, незначительное) время запуска.
Завершение
То, что на первый взгляд казалось неприятным, трудно найти из глубин наших низкоуровневых библиотек, оказалось довольно просто отследить. Это одна из основных сильных сторон Ruby: сторонний код прозрачен и легко разбирается в нем. Тривиально исследовать и поиграть со всем, кроме самого низкого уровня кода (в библиотеках C).
Чтобы отточить свои навыки, попробуйте заменить SocketLock
на более простую блокировку файла (существует ли уникальный файл или нет?) И посмотреть, как это повлияет на selenium-webdriver
кода selenium-webdriver
.
(Эта проблема не была исправлена в селене на момент написания этой статьи — если она влияет, вы можете следить за статусом в этом билете )
Настройтесь на следующую неделю для более захватывающих приключений в джунглях кода!