Skip to content

[Bug]: Transport frame corruption under Timeout.timeout crashes driver; crash then hangs forever because Node >= 16 error signature is not detected #392

Description

@maximenoel8

Summary

Two compounding bugs in Playwright::Transport (verified on v1.60.0; main is identical):

  1. 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
    
  2. 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.

Environment

  • playwright-ruby-client 1.60.0 (also inspected main: lib/playwright/transport.rb identical)
  • capybara-playwright-driver 0.5.9
  • Ruby 3.4.10, Node.js 22.22.0, Playwright driver 1.60.0
  • 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-driver
loop do
  Timeout.timeout(rand(0.01..0.5)) do
    page.has_text?('something that is not there', wait: 10)
  end
rescue Timeout::Error
  retry
end

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).

reproduce_playwright_hang.rb (self-contained, run: ruby reproduce_playwright_hang.rb both)
#!/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 both

require 'json'
require 'timeout'

PLAYWRIGHT_CLI = ENV.fetch('PLAYWRIGHT_CLI_EXECUTABLE_PATH', '/usr/local/bin/playwright')
MODE = ARGV.first || 'both'
unless %w[unpatched patched both].include?(MODE)
  abort "Unknown mode '#{MODE}'. Usage: ruby #{__FILE__} [unpatched|patched|both]"
end

if MODE == 'both'
  # Separate child processes so the monkey-patches cannot bleed between legs.
  %w[unpatched patched].each do |leg|
    puts "\n########## #{leg.upcase} ##########"
    system(RbConfig.ruby, __FILE__, leg) || puts("(child exited non-zero)")
  end
  exit 0
end

require 'playwright'

if MODE == '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.
  module WidenedRaceWindow
    def send_message(message)
      msg = JSON.dump(message)
      @mutex.synchronize {
        @stdin.write([msg.bytes.length].pack('V')) # write 1: length header (stock behavior)
        sleep 0.3                                  # widened: stock gap is the same, just ~us wide
        @stdin.write(msg)                          # write 2: payload (stock behavior)
      }
    rescue Errno::EPIPE, IOError
      raise Playwright::Transport::AlreadyDisconnectedError.new('send_message failed')
    end
  end
  Playwright::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.)
  module Playwright
    class Transport
      def send_message(message)
        msg = JSON.dump(message)
        frame = [msg.bytesize].pack('V') + msg.b
        Thread.handle_interrupt(Object => :never) do
          @mutex.synchronize { @stdin.write(frame) }
        end
      rescue Errno::EPIPE, IOError
        raise AlreadyDisconnectedError.new('send_message failed')
      end

      private

      def handle_stdout(packet_size: 32_768)
        while chunk = @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.rewind
          obj = 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 hanging
      rescue IOError
        @on_driver_closed&.call
      end

      def handle_stderr
        while err = @stderr.read
          # 'undefined:1' is the Node <= 14 banner; Node >= 16 prints '<anonymous_script>:1'.
          if err.include?('undefined:1') || err.include?('<anonymous_script>:1')
            $stderr.write(err)
            @on_driver_crashed&.call
            break
          end
          $stderr.write(err)
        end
      rescue IOError
        @on_driver_closed&.call
      end
    end
  end
end

def snipe_during(delay_range)
  main = Thread.current
  sniper = Thread.new do
    sleep rand(delay_range)
    main.raise(Timeout::Error, 'async timeout, as delivered by Timeout.timeout')
  end
  yield
  sniper.kill
  false # sniper did not fire during the call
rescue Timeout::Error
  true  # sniper fired mid-call
end

Playwright.create(playwright_cli_executable_path: PLAYWRIGHT_CLI) do |pw|
  browser = pw.chromium.launch(headless: true)
  page = browser.new_page
  page.goto('about:blank')
  puts '  initial page.goto OK'

  if MODE == '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') }
    puts fired ? '  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 = 0
    200.times do
      fired_count += 1 if snipe_during(0.0..0.003) { page.evaluate('1 + 1') }
    end
    puts "  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.
  begin
    Timeout.timeout(10) do
      3.times { page.evaluate('1') rescue nil }
      puts "  Recovery OK -- driver still healthy (page.title = '#{page.title}')"
    end
  rescue Timeout::Error
    puts '  HUNG: subsequent Playwright calls never returned (bug reproduced)'
    exit 1
  rescue Playwright::DriverCrashedError, Playwright::Transport::AlreadyDisconnectedError => e
    puts "  FAILED FAST (no hang): #{e.class} -- driver crash was detected and surfaced"
    exit 1
  ensure
    begin
      Timeout.timeout(5) { browser.close }
    rescue StandardError, Timeout::Error
      nil
    end
  end
end

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.

Unpatched (Ruby 3.4.10, Node 22.22.0, driver 1.60.0):

  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:

concurrent-ruby-1.3.8/.../promises.rb:785:in 'Thread::ConditionVariable#wait'
concurrent-ruby-1.3.8/.../promises.rb:773:in 'Concurrent::Promises::AbstractEventFuture#wait_until_resolved'
concurrent-ruby-1.3.8/.../promises.rb:1488:in 'Concurrent::Promises::ResolvableFuture#value!'
playwright-ruby-client-1.60.0/lib/playwright/connection.rb:125:in 'Playwright::Connection#send_message_to_server'
playwright-ruby-client-1.60.0/lib/playwright/channel.rb:35:in 'block in Playwright::Channel#send_message_to_server_result'

Patched leg, same environment:

  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:

def send_message(message)
  debug_send_message(message) if @debug
  msg = JSON.dump(message)
  frame = [msg.bytesize].pack('V') + msg.b
  Thread.handle_interrupt(Object => :never) do
    @mutex.synchronize { @stdin.write(frame) }
  end
rescue Errno::EPIPE, IOError
  raise AlreadyDisconnectedError.new('send_message failed')
end

For bug 2:

# handle_stderr: recognize modern Node crash banners too
if err.include?('undefined:1') || err.include?('<anonymous_script>:1') ||
   err.include?("is not valid JSON")
  @on_driver_crashed&.call
  break
end

# handle_stdout: signal closure on clean EOF, not only on IOError
def handle_stdout(packet_size: 32_768)
  while chunk = @stdout.read(4)
    # ... unchanged ...
  end
  @on_driver_closed&.call   # <- EOF path
rescue IOError
  @on_driver_closed&.call
end

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions