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

30 августа в 10:17 два процесса наложились по времени. Один увёл вкладку у другого, у второго оборвался websocket с WinError 10053, тело статьи не вставилось, публикация не состоялась. Я закрыл это самым очевидным способом — эксклюзивным файловым замком: кто первый взял, тот и работает, остальные ждут четыре минуты и отменяются в расчёте на догоняющий запуск.

Через час владелец системы написал: «Я часто публикую что-то своё мимо расписания, и надо, чтобы оно не перебивалось».

То есть защита защищала не того. Инструмент, который я закрыл замком, — это рабочее место человека, и очередь из процессов ставит его в конец собственной очереди.

Пришлось разбираться, что в общем браузере действительно общее.

Оказалось — почти ничего. Но по дороге вылезло поведение Chrome, из-за которого «параллельная работа» вполне могла оказаться показухой: процессы ходят одновременно, отчёты зелёные, а редактор внутри вкладки почти стоит. Дальше — как это выглядело в замерах, чем лечится и какие две вещи в этом лечении неочевидны настолько, что я поймал их только на третьем заходе. Отдельно расскажу, как мой собственный самотест на пять проверок оказался слепым ровно к той поломке, ради которой писался, и что пришлось изменить, чтобы он начал краснеть.

Что я считал общим и что оказалось на самом деле

Когда я обосновывал эксклюзивный замок, у меня был список из трёх пунктов: окно одно на все вкладки, буфер обмена один, фокус один. Список выглядел убедительно. Проверен из него был ноль пунктов.

Замер занял двадцать минут и дал такую картину:

что считал общим

как на самом деле

передний план нужен для работы

не нужен: в скрытой вкладке идут печать, клавиши, клик мышью, снимок экрана и даже вставка из системного буфера

окно одно на все вкладки

размер задаётся через Emulation.setDeviceMetricsOverride — только своей вкладке, соседние не трогает

буфер обмена один

правда, и он один такой

Проверка первого пункта — это просто скрипт на websocket к CDP. Открываем две вкладки, вторая забирает передний план, работаем в первой:

ws = websocket.create_connection(target["webSocketDebuggerUrl"],
                                 timeout=25, suppress_origin=True)
cmd("Runtime.evaluate", {"expression": "document.getElementById('e').focus()"})
cmd("Input.insertText", {"text": "привет из фона"})

Текст встал.

Клавиши через Input.dispatchKeyEvent — тоже. Клик по координатам через Input.dispatchMouseEvent — тоже. Page.captureScreenshot фоновой вкладки отдал картинку за секунду. Даже Ctrl+V с картинкой в системном буфере сработал в скрытой вкладке, чего я не ожидал совсем: почему-то казалось, что вставка обязана требовать настоящего фокуса окна. Не требует — CDP отправляет событие прямо в рендерер нужной цели, и операционная система в этой цепочке не участвует вовсе.

Отдельно проверил, что эмуляция размера не задевает соседей: своей вкладке поставил 1680×1020, у соседней осталось 1664×891.

Замер, который перевернул решение

Дальше я померил то, о чём сначала вообще не подумал: с какой скоростью в фоновой вкладке тикают таймеры. Метод примитивный — setInterval на 100 мс, считаем срабатывания за восемь секунд. Без удушения ожидаем около восьмидесяти.

c.ev("window.__n=0; window.__t=setInterval(()=>window.__n++,100); 1")
time.sleep(8)
print(c.ev("clearInterval(window.__t); window.__n"))

Получил 5 тиков из 80. То есть в шестнадцать раз меньше.

Это меняет всё. Вкладка жива, DOM отвечает, скриншот снимается — а редактор внутри еле шевелится: автосохранение, дебаунсы ввода, ожидание ответов, всё завязано на таймеры. Параллельная работа в таком виде была бы обманом: формально идёт, фактически стоит.

Лечится двумя командами:

c.cmd("Emulation.setFocusEmulationEnabled", {"enabled": True})
c.cmd("Page.setWebLifecycleState", {"state": "active"})

После них document.hasFocus() возвращает истину, visibilityState становится visible, а счётчик — 79 из 80.

Дальше начались тонкости, которых в документации нет.

Разовая команда не работает, потому что состояния ещё нет

Первая версия ставила эмуляцию один раз — при открытии вкладки. Логика казалась правильной: открыли, сказали «не спи», работаем.

Но вкладка рождается активной. Команда «не спи», отданная в этот момент, относится к состоянию, которого ещё нет. Душить начинают позже, когда вкладку уводят в фон, и к этому моменту наш окрик давно прозвучал.

Я поставил подкачку раз в пятнадцать секунд, прогнал самотест несколько раз, получил 45, 39, 29 тиков и написал в описании правки, что провал повторить не удалось. Провал повторился на первом же прогоне рабочей копии: снова 7 из 50.

Окно уязвимости было ровно до первого окрика — вкладку успевали задушить раньше.

Сейчас так:

def keep_awake(sock, seq=[3]):
    while True:
        time.sleep(5)
        seq[0] += 1
        sock.send(json.dumps({"id": seq[0],
                              "method": "Page.setWebLifecycleState",
                              "params": {"state": "active"}}))
        sock.recv()

Пять прогонов подряд после правки: 26, 37, 39, 26, 32. Ни одного в полосе удушения. Полоса эта, кстати, узкая и хорошо отделённая: задушенная вкладка даёт 5–10 из 50, рабочая — 26–50. Поэтому порог в самотесте стоит на двадцати, а не «почти 50»: мерка шумит от загрузки машины, и строгий порог краснел бы сам по себе, а такие проверки быстро перестают читать.

Отдельное соединение для этой подкачки держится специально голым — ни один домен CDP не включён. Иначе в сокет, который никто не читает, копились бы события Page.* за всю публикацию.

Единственное, что действительно общее

После всего этого эксклюзив остался ровно на одной вещи — на системном буфере обмена. Один публикатор кладёт туда обложку и жмёт Ctrl+V через десятки секунд; если в этот зазор кто-то скопирует своё, в пост уедет чужое. 28 августа так и случилось: в черновик статьи уехал кусок постороннего текста, лежавший в буфере от прошлой работы.

Теперь буфер занимается вплотную к нажатию и чужое содержимое возвращается на место — и текст, и картинка. Захват — через os.open(..., O_CREAT | O_EXCL), а не «прочитал, записал, перечитал»: у второго варианта есть щель, в которую пролезают двое.

Сохранение чужого буфера сначала шло через трубу PowerShell, и это была ошибка. Труба несёт байты в кодировке консоли, русский текст на этом пути превращается в «╨а╨Ю╨Ь╨Р╨Э╨Ю╨Т», и «вернули на место» мы бы кашу. Сейчас содержимое уходит в файл с явным UTF-8 в обе стороны:

[System.Windows.Forms.Clipboard]::GetText() |
  Set-Content -LiteralPath $keep -Encoding UTF8 -NoNewline

Картинку сохраняем рядом в PNG и возвращаем через Clipboard::SetImage. Честное ограничение здесь одно: если человек держал в буфере что-то, чего мы не умеем сериализовать, вернуть это нечем — но текст и картинка покрывают всё, что реально случается.

Эксклюзив держится секунды. В замке лежат владелец, идентификатор процесса и срок; если процесс умер или просрочил время, следующий желающий снимает замок сам. Раньше срок был пятнадцать минут, и это означало, что зависший публикатор останавливает конвейер до вмешательства человека; теперь максимум, что он может испортить, — две минуты чужого ожидания.

Что не сработало

Зелёный самотест ничего не значил. Я написал проверку из пяти пунктов, она зеленела. Потом сломал механизм нарочно — снял эмуляцию — и проверка осталась зелёной. В ней было две дыры. Первая: измерение шло, пока вкладка ещё на переднем плане, то есть мерился лёгкий случай, где с эмуляцией и без неё результат одинаковый. Вторая: очередь за общим ресурсом проверялась гонкой двух процессов, и «ждал 6.2 секунды» получилось случайно — под мутацией та же проверка показала «ждал 0.0» и покраснела на ровном месте, хотя ломал я совсем другое.

После починки мутация даёт ровно 5 тиков из 50 и красный итог.

Теперь перебор мутаций — отдельный шаг: четыре искусственные поломки, каждая обязана покраснеть. Одна из них, кстати, пережила первую версию тестов и заставила дописать проверку: я проверял, что утренняя публикация не закрывает дневной слот, а настоящая дыра была зеркальной — поздняя публикация закрывала задним числом пропущенный ранний слот, и провал исчезал из отчёта.

Широкий замок прятал настоящую гонку. Когда я его снял, вылезло, что файл с идентификатором «своей вкладки» был один на все процессы: второй запустившийся публикатор перезаписывал его, и первый следующим же обращением уходил работать в чужую вкладку. Пока эксклюзив не пускал никого второго, дефект не проявлялся ни разу.

Слепой перебор кнопок обошёлся дорого. 31 августа в соседней задаче я искал в редакторе элемент загрузки картинки и нажимал подряд все семнадцать подходящих по размеру кнопок. Четырнадцатой в списке оказалась «Помощь»: владельцу системы вместо редактора открылась форма поддержки сервиса, и он прислал скриншот с вопросом, что происходит.

Перечислять кандидатов и бить по одному опознанному. Не перебирать.

Чем всё кончилось

Два уровня доступа вместо одного замка. Первый — своя фоновая вкладка, ожидания нет вообще, работают все одновременно, включая человека за тем же браузером. Второй — короткий эксклюзив на секунды и только на буфер обмена.

Команды Page.bringToFront и Browser.setWindowBounds запрещены проверкой в сборке: первая выдёргивает окно человека на себя посреди его работы, вторая меняет окно целиком — из-за неё у нас однажды не вышла дневная публикация, потому что в узком окне пропала нужная кнопка. Проверка заодно тестирует сама себя: подкладывает нарушение и убеждается, что видит его.

Живой прогон: два процесса одновременно, каждый видит свой текст в своей вкладке; тяжёлый редактор в фоне отрисовался за шесть секунд; на восьмой минуте фоновой жизни вкладка по-прежнему даёт 49 тиков из 50 и принимает ввод.

Числа проверялись на Chrome 151 под Windows 10.

Главный вывод для меня не про Chrome. Прежде чем закрывать общий ресурс замком, стоит выписать, что в нём общего, и проверить каждый пункт опытом. У меня из трёх пунктов подтвердился один, и эксклюзив сжался с пятнадцати минут на весь браузер до нескольких секунд на буфер обмена.