You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[Bug]: Transport frame corruption under Timeout.timeout crashes driver; crash then hangs forever because Node >= 16 error signature is not detected #392
Two compounding bugs in Playwright::Transport (verified on v1.60.0; main is identical):
Transport#send_message writes each protocol frame with two separate IO#write calls (4-byte length header, then JSON payload). An asynchronous exception delivered via Thread#raise — most commonly Ruby's Timeout.timeout, which Capybara/Cucumber suites use pervasively — can land between (or inside) those writes. The @mutex does not help: it guards against other threads, not against an async exception on the writing thread itself. The result is a torn frame: the driver reads a length prefix whose payload never fully arrives, misinterprets subsequent bytes as a new length header, and crashes:
<anonymous_script>:1
�
^
SyntaxError: Unexpected token '�', "�{"id":"... is not valid JSON
at JSON.parse (<anonymous>)
at transport.onmessage (.../playwright-core/lib/coreBundle.js:...)
Node.js v22.22.0
When that crash happens, the client hangs forever instead of failing. Transport#handle_stderr detects a driver crash only via err.include?('undefined:1'), which is the Node <= 14 error banner (the exact trace is quoted in a comment inside handle_stderr, and matches old issue Hang with 'undefined:1' #37). Node >= 16 prints <anonymous_script>:1 instead, so @on_driver_crashed never fires and Connection never rejects pending callbacks with DriverCrashedError. Additionally, handle_stdout only fires @on_driver_closed on IOError; at a clean EOF (dead driver) the while chunk = @stdout.read(4) loop simply exits and no callback fires. Every in-flight and subsequent call then blocks forever on a promise that will never resolve.
We hit this in the Uyuni test suite (Cucumber + Capybara + capybara-playwright-driver) after migrating from Selenium: a Timeout.timeout firing after 250 s mid-send_message reliably preceded the � crash, after which the entire suite hung indefinitely.
Linux (openSUSE/SLES), stdio pipe transport (Playwright.create(playwright_cli_executable_path: ...))
Reproduction sketch
Any code of this shape will eventually tear a frame (probability scales with call rate and timeout frequency):
require'timeout'# Capybara session backed by capybara-playwright-driverloopdoTimeout.timeout(rand(0.01..0.5))dopage.has_text?('something that is not there',wait: 10)endrescueTimeout::Errorretryend
The window is the gap between the header write and the payload write in send_message; Thread#raise from the Timeout watchdog thread lands there sooner or later. After the driver crashes, any subsequent Playwright call blocks forever (bug 2).
#!/usr/bin/env ruby# frozen_string_literal: true## Reproduces the playwright-ruby-client torn-frame bug: Transport#send_message writes each# wire frame as TWO IO#write calls ([4-byte LE length], then JSON payload). An asynchronous# Timeout::Error (Thread#raise, as delivered by Ruby's Timeout.timeout) landing between those# writes leaves a torn frame on the driver's stdin. The driver then misparses the stream# ("SyntaxError: Unexpected token '�' ... is not valid JSON" on Node >= 16, which prints# '<anonymous_script>:1' -- NOT matched by handle_stderr's 'undefined:1' check), and because# neither on_driver_crashed nor on_driver_closed fires, every subsequent call hangs forever.## The race window is microseconds wide, so this script widens it deterministically in the# UNPATCHED leg: a prepended module reproduces the stock two-write send_message with a sleep# in the gap -- the same gap production code has, just stretched so the async raise reliably# lands inside it. The PATCHED leg (single-buffer write + Thread.handle_interrupt shield)# has no gap to widen, so it is stress-tested instead: rapid calls under tight randomized# Timeout snipers, then a liveness check.## ruby reproduce_playwright_hang.rb unpatched # expect: HUNG (bug reproduced)# ruby reproduce_playwright_hang.rb patched # expect: Recovery OK# ruby reproduce_playwright_hang.rb both # runs each in its own child process## PLAYWRIGHT_CLI_EXECUTABLE_PATH=/usr/local/bin/playwright ruby reproduce_playwright_hang.rb bothrequire'json'require'timeout'PLAYWRIGHT_CLI=ENV.fetch('PLAYWRIGHT_CLI_EXECUTABLE_PATH','/usr/local/bin/playwright')MODE=ARGV.first || 'both'unless%w[unpatchedpatchedboth].include?(MODE)abort"Unknown mode '#{MODE}'. Usage: ruby #{__FILE__} [unpatched|patched|both]"endifMODE == 'both'# Separate child processes so the monkey-patches cannot bleed between legs.%w[unpatchedpatched].eachdo |leg|
puts"\n########## #{leg.upcase} ##########"system(RbConfig.ruby,__FILE__,leg) || puts("(child exited non-zero)")endexit0endrequire'playwright'ifMODE == 'unpatched'# Reproduce the STOCK two-write send_message verbatim, with the inter-write gap widened# from microseconds to 300 ms so the asynchronous raise deterministically lands in it.# This does not create the bug -- it magnifies the window the stock code already has.moduleWidenedRaceWindowdefsend_message(message)msg=JSON.dump(message)@mutex.synchronize{@stdin.write([msg.bytes.length].pack('V'))# write 1: length header (stock behavior)sleep0.3# widened: stock gap is the same, just ~us wide@stdin.write(msg)# write 2: payload (stock behavior)}rescueErrno::EPIPE,IOErrorraisePlaywright::Transport::AlreadyDisconnectedError.new('send_message failed')endendPlaywright::Transport.prepend(WidenedRaceWindow)else# Proposed fix: single-buffer frame, written once, shielded from async exceptions so a# pending Timeout::Error is delivered only after the frame is fully on the wire.# (Plus EOF/crash signaling fixes so a dead driver fails fast instead of hanging.)modulePlaywrightclassTransportdefsend_message(message)msg=JSON.dump(message)frame=[msg.bytesize].pack('V') + msg.bThread.handle_interrupt(Object=>:never)do@mutex.synchronize{@stdin.write(frame)}endrescueErrno::EPIPE,IOErrorraiseAlreadyDisconnectedError.new('send_message failed')endprivatedefhandle_stdout(packet_size: 32_768)whilechunk=@stdout.read(4)length=chunk.unpack1('V')buffer=StringIO.new(length / packet_size).to_i.times{buffer << @stdout.read(packet_size)}buffer << @stdout.read(length % packet_size)buffer.rewindobj=JSON.parse(buffer.read)debug_recv_message(obj)if@debug@on_message&.call(obj)end@on_driver_closed&.call# EOF: driver gone; reject pending callbacks instead of hangingrescueIOError@on_driver_closed&.callenddefhandle_stderrwhileerr=@stderr.read# 'undefined:1' is the Node <= 14 banner; Node >= 16 prints '<anonymous_script>:1'.iferr.include?('undefined:1') || err.include?('<anonymous_script>:1')
$stderr.write(err)@on_driver_crashed&.callbreakend
$stderr.write(err)endrescueIOError@on_driver_closed&.callendendendenddefsnipe_during(delay_range)main=Thread.currentsniper=Thread.newdosleeprand(delay_range)main.raise(Timeout::Error,'async timeout, as delivered by Timeout.timeout')endyieldsniper.killfalse# sniper did not fire during the callrescueTimeout::Errortrue# sniper fired mid-callendPlaywright.create(playwright_cli_executable_path: PLAYWRIGHT_CLI)do |pw|
browser=pw.chromium.launch(headless: true)page=browser.new_pagepage.goto('about:blank')puts' initial page.goto OK'ifMODE == 'unpatched'# One call is enough: the raise lands in the widened gap between header and payload.fired=snipe_during(0.05..0.05){page.evaluate('1 + 1')}putsfired ? ' Timeout::Error landed inside send_message (frame torn)' :
' sniper missed -- rerun (should not happen with the widened gap)'else# No gap exists in the patched write; hammer it to show the shield holds statistically.fired_count=0200.timesdofired_count += 1ifsnipe_during(0.0..0.003){page.evaluate('1 + 1')}endputs" 200 sniped calls done (#{fired_count} raises landed mid-call, all deferred past the write)"end# Liveness check. Push a couple of frames so a misaligned driver actually consumes# enough bytes to hit its JSON parse error rather than waiting silently.beginTimeout.timeout(10)do3.times{page.evaluate('1')rescuenil}puts" Recovery OK -- driver still healthy (page.title = '#{page.title}')"endrescueTimeout::Errorputs' HUNG: subsequent Playwright calls never returned (bug reproduced)'exit1rescuePlaywright::DriverCrashedError,Playwright::Transport::AlreadyDisconnectedError=>eputs" FAILED FAST (no hang): #{e.class} -- driver crash was detected and surfaced"exit1ensurebeginTimeout.timeout(5){browser.close}rescueStandardError,Timeout::Errornilendendend
Observed results (reproduction script attached)
The attached script runs two legs. The unpatched leg reproduces the stock two-write send_message with the inter-write gap widened from microseconds to 300 ms (race-widening only — the gap itself is stock behavior), so the async raise deterministically lands inside it. The patched leg uses the proposed single-buffer, interrupt-shielded write and hammers it with 200 rapid calls under randomized async Timeout::Error snipers.
initial page.goto OK
Timeout::Error landed inside send_message (frame torn)
<anonymous_script>:1
~
^
SyntaxError: Unexpected token '~', "~{"id":"... is not valid JSON
at JSON.parse (<anonymous>)
at transport.onmessage (.../playwright-core/lib/coreBundle.js:64275:73)
Node.js v22.22.0
The stray leading character is the torn frame's length-prefix byte parsed as JSON (here 0x7E = ~, i.e. a 126-byte follow-up payload; with other payload sizes it shows as a non-printable �). Note the crash banner is <anonymous_script>:1, so handle_stderr's 'undefined:1' check does not match and on_driver_crashed never fires.
After the crash, the client hangs indefinitely; interrupting it shows the exact park point — a promise that can never resolve, because driver EOF is never signaled either:
initial page.goto OK
200 sniped calls done (73 raises landed mid-call, all deferred past the write)
Recovery OK -- driver still healthy (page.title = '')
Across repeated runs, ~250 async raises landed mid-call with the shielded single write and produced zero corruption; the raises are deferred by Thread.handle_interrupt until the frame is fully written, then delivered normally.
Proposed fix
For bug 1, build the frame in a single buffer, write it once, and shield the write from asynchronous exceptions so a deferred Timeout::Error is delivered only after the frame is fully on the wire:
# handle_stderr: recognize modern Node crash banners tooiferr.include?('undefined:1') || err.include?('<anonymous_script>:1') ||
err.include?("is not valid JSON")@on_driver_crashed&.callbreakend# handle_stdout: signal closure on clean EOF, not only on IOErrordefhandle_stdout(packet_size: 32_768)whilechunk=@stdout.read(4)# ... unchanged ...end@on_driver_closed&.call# <- EOF pathrescueIOError@on_driver_closed&.callend
We are running these as a monkey-patch in our suite.
Related
Hang with 'undefined:1' #37 (closed) — same crash symptom on old Node; its undefined:1 signature is what handle_stderr still matches today.
Summary
Two compounding bugs in
Playwright::Transport(verified on v1.60.0;mainis identical):Transport#send_messagewrites each protocol frame with two separateIO#writecalls (4-byte length header, then JSON payload). An asynchronous exception delivered viaThread#raise— most commonly Ruby'sTimeout.timeout, which Capybara/Cucumber suites use pervasively — can land between (or inside) those writes. The@mutexdoes not help: it guards against other threads, not against an async exception on the writing thread itself. The result is a torn frame: the driver reads a length prefix whose payload never fully arrives, misinterprets subsequent bytes as a new length header, and crashes:When that crash happens, the client hangs forever instead of failing.
Transport#handle_stderrdetects a driver crash only viaerr.include?('undefined:1'), which is the Node <= 14 error banner (the exact trace is quoted in a comment insidehandle_stderr, and matches old issue Hang with 'undefined:1' #37). Node >= 16 prints<anonymous_script>:1instead, so@on_driver_crashednever fires andConnectionnever rejects pending callbacks withDriverCrashedError. Additionally,handle_stdoutonly fires@on_driver_closedonIOError; at a clean EOF (dead driver) thewhile chunk = @stdout.read(4)loop simply exits and no callback fires. Every in-flight and subsequent call then blocks forever on a promise that will never resolve.We hit this in the Uyuni test suite (Cucumber + Capybara + capybara-playwright-driver) after migrating from Selenium: a
Timeout.timeoutfiring after 250 s mid-send_messagereliably preceded the�crash, after which the entire suite hung indefinitely.Environment
main:lib/playwright/transport.rbidentical)Playwright.create(playwright_cli_executable_path: ...))Reproduction sketch
Any code of this shape will eventually tear a frame (probability scales with call rate and timeout frequency):
The window is the gap between the header write and the payload write in
send_message;Thread#raisefrom the Timeout watchdog thread lands there sooner or later. After the driver crashes, any subsequent Playwright call blocks forever (bug 2).reproduce_playwright_hang.rb (self-contained, run: ruby reproduce_playwright_hang.rb both)
Observed results (reproduction script attached)
The attached script runs two legs. The unpatched leg reproduces the stock two-write
send_messagewith the inter-write gap widened from microseconds to 300 ms (race-widening only — the gap itself is stock behavior), so the async raise deterministically lands inside it. The patched leg uses the proposed single-buffer, interrupt-shielded write and hammers it with 200 rapid calls under randomized asyncTimeout::Errorsnipers.Unpatched (Ruby 3.4.10, Node 22.22.0, driver 1.60.0):
The stray leading character is the torn frame's length-prefix byte parsed as JSON (here
0x7E=~, i.e. a 126-byte follow-up payload; with other payload sizes it shows as a non-printable�). Note the crash banner is<anonymous_script>:1, sohandle_stderr's'undefined:1'check does not match andon_driver_crashednever fires.After the crash, the client hangs indefinitely; interrupting it shows the exact park point — a promise that can never resolve, because driver EOF is never signaled either:
Patched leg, same environment:
Across repeated runs, ~250 async raises landed mid-call with the shielded single write and produced zero corruption; the raises are deferred by
Thread.handle_interruptuntil the frame is fully written, then delivered normally.Proposed fix
For bug 1, build the frame in a single buffer, write it once, and shield the write from asynchronous exceptions so a deferred
Timeout::Erroris delivered only after the frame is fully on the wire:For bug 2:
We are running these as a monkey-patch in our suite.
Related
undefined:1signature is whathandle_stderrstill matches today.