Skip to content

Comments

Support multi-process debugging: sync breakpoints and coordinate instances #1172

Draft
st0012 wants to merge 4 commits intoruby:masterfrom
Shopify:support-multi-process-breakpoints
Draft

Support multi-process debugging: sync breakpoints and coordinate instances #1172
st0012 wants to merge 4 commits intoruby:masterfrom
Shopify:support-multi-process-breakpoints

Conversation

@st0012
Copy link
Member

@st0012 st0012 commented Feb 21, 2026

Problem 1: Breakpoints not shared between forked processes (#714)

Closes #714

Summary

When a Ruby app forks workers (Puma, Unicorn), breakpoints set in the debugger only fire in some workers. Users must toggle breakpoints multiple times to get them to register, and hits are inconsistent.

Cause

In fork_mode: :both (the default), after fork() each process gets an independent copy of @bps (the breakpoints hash). The existing ProcessGroup flock only serializes which process talks to the debugger client — it never synchronizes breakpoint state. When VSCode sends setBreakpoints or a user types break, only the process currently holding the flock receives the update. Other processes never learn about it.

Solution

Store serialized breakpoint specs in a shared JSON tempfile alongside the existing flock tempfile. Publish on subsession leave (before lock release), check on subsession enter (after lock acquire). Also sync in the DAP recv_request retry path and console process can't-read path. Newly forked children sync via the fork child hook.

  • Breakpoints define to_sync_data for serialization. Only LineBreakpoint and CatchBreakpoint are synced (as descriptors — path, line, condition). WatchIVarBreakpoint is skipped since object_id is process-local.
  • Writes are atomic (tmp file + File.rename).
  • Non-active processes wait 50ms before checking the sync file, giving the message-consuming process time to publish.

Tests

6 tests in test/console/fork_bp_sync_test.rb:

  • Breakpoint set after fork reaches child
  • Breakpoint deleted after fork is removed from child
  • Multiple children receive synced breakpoints
  • Catch breakpoint syncs to child
  • Late-forked child catches up
  • Stress test with multiple children

Problem 2: Multiple debugger instances competing for STDIN

Summary

When running tests with parallel process workers (parallel_tests, ci-queue, Rails parallelize), all workers that hit a debugger statement enter the debug prompt simultaneously. Output is clobbered, input is swallowed by random processes, and the debugger is unusable.

Cause

Parallel test runners fork workers before the debugger loads. Each worker independently creates its own SESSION and ProcessGroup when it first hits debugger. There is no shared ProcessGroup, no flock coordination — every worker thinks it's the only debugger instance. They all call wait_command_loop and compete for the same STDIN (inherited from the parent via fork).

Solution

Add a well-known lock file that all sibling debugger instances discover automatically, keyed by process group ID: /tmp/ruby-debug-{uid}-pgrp-{getpgrp}.lock.

On enter_subsession, acquire the lock with blocking flock(LOCK_EX). If another debugger is active, the process waits until it finishes, then enters the debugger normally. Users debug each process in sequence by typing c.

  • Process.getpgrp groups all processes spawned from the same command — works across nested forks
  • While blocked in flock(), no prompt is shown and IRB/Reline never reads STDIN — no input competition
  • Skipped when MultiProcessGroup is active (fork_mode: :both already handles coordination)
  • Lazy initialization — zero cost if no breakpoint is hit
  • Auto-releases on process exit (flock semantics)

Tests

3 tests in test/console/wk_lock_test.rb:

  • Single-process debugging is unaffected
  • fork_mode: :both uses existing ProcessGroup, not the well-known lock
  • Independent workers (debugger loaded after fork) are serialized — each enters the debugger in sequence

…ances

Two fixes for debugging multi-process Ruby applications:

1. Breakpoint synchronization across forked processes (fixes ruby#714):
   Store serialized breakpoint specs in a shared JSON tempfile alongside
   the existing flock tempfile. Publish on subsession leave, check on
   subsession enter, and in the socket reader retry paths for both DAP
   and console protocols. Breakpoints define to_sync_data for
   serialization. Only LineBreakpoint and CatchBreakpoint are synced.

2. Coordination of independent debugger instances:
   When parallel test runners fork workers before the debugger loads,
   each worker gets its own SESSION with no coordination. Add a
   well-known lock file keyed by process group ID
   (/tmp/ruby-debug-{uid}-pgrp-{getpgrp}.lock) that all sibling
   instances discover automatically. On enter_subsession, acquire
   the lock (blocking flock) so only one process enters the debugger
   at a time. While blocked, no prompt is shown and IRB/Reline never
   reads STDIN.
@launchable-app
Copy link

launchable-app bot commented Feb 21, 2026

2/707 Tests Failed

/home/runner/work/debug/debug/test/protocol/hover_raw_dap_test.rb#test_hover_works_correctly
-------------------------
| All Protocol Messages |
-------------------------

V>D {"seq":1,"command":"initialize","arguments":{"clientID":"vscode","clientName":"Visual Studio Code","adapterID":"rdbg","pathFormat":"path","linesStartAt1":true,"columnsStartAt1":true,"supportsVariableType":true,"supportsVariablePaging":true,"supportsRunInTerminalRequest":true,"locale":"en-us","supportsProgressReporting":true,"supportsInvalidatedEvent":true,"supportsMemoryReferences":true},"type":"request"}
V>D {"seq":2,"command":"attach","arguments":{"type":"rdbg","name":"Attach with rdbg","request":"attach","rdbgPath":"/home/runner/work/debug/debug/exe/rdbg","debugPort":"/var/folders/kv/w1k6nh1x5fl7vx47b2pd005w0000gn/T/ruby-debug-sock-501/ruby-debug-naotto-8845","autoAttach":true,"__sessionId":"141d9c79-3669-43ec-ac1f-e62598c5a65a"},"type":"request"}
V>D {"seq":3,"command":"setFunctionBreakpoints","arguments":{"breakpoints":[]},"type":"request"}
V>D {"seq":4,"command":"setExceptionBreakpoints","arguments":{"filters":[],"filterOptions":[{"filterId":"RuntimeError"}]},"type":"request"}
V>D {"seq":5,"command":"configurationDone","type":"request"}
V<D {"type":"response","command":"initialize","request_seq":1,"success":true,"message":"Success","body":{"supportsConfigurationDoneRequest":true,"supportsFunctionBreakpoints":true,"supportsConditionalBreakpoints":true,"supportTerminateDebuggee":true,"supportsTerminateRequest":true,"exceptionBreakpointFilters":[{"filter":"any","label":"rescue any exception","supportsCondition":true},{"filter":"RuntimeError","label":"rescue RuntimeError","supportsCondition":true}],"supportsExceptionFilterOptions":true,"supportsStepBack":true,"supportsEvaluateForHovers":true,"supportsCompletionsRequest":true},"seq":1}
V<D {"type":"event","event":"initialized","seq":2}
V<D {"type":"event","event":"output","body":{"category":"console","output":"Ruby REPL: You can run any Ruby expression here.\nNote that output to the STDOUT/ERR printed on the TERMINAL.\n[experimental]\n  `,COMMAND` runs `COMMAND` debug command (ex: `,info`).\n  `,help` to list all debug commands.\n"},"seq":3}
V<D {"type":"response","command":"attach","request_seq":2,"success":true,"message":"Success","seq":4}
V<D {"type":"response","command":"setFunctionBreakpoints","request_seq":3,"success":true,"message":"Success","seq":5}
V<D {"type":"response","command":"setExceptionBreakpoints","request_seq":4,"success":true,"message":"Success","body":{"breakpoints":[{"verified":true,"message":"#<DEBUGGER__::CatchBreakpoint:0x00007fc4f413ae00 @pat=\"RuntimeError\", @key=[:catch, \"RuntimeError\"], @last_exc=nil, @deleted=false, @cond=nil, @command=nil, @path=nil, @tp=#<TracePoint:enabled>>"}]},"seq":6}
V<D {"type":"response","command":"configurationDone","request_seq":5,"success":true,"message":"Success","seq":7}
V<D {"type":"event","event":"stopped","body":{"reason":"pause","threadId":1,"allThreadsStopped":true},"seq":8}
V>D {"seq":6,"command":"threads","type":"request"}
V<D {"type":"response","command":"threads","request_seq":6,"success":true,"message":"Success","body":{"threads":[{"id":1,"name":"#1 /tmp/debug-20260222-2412-5caih4.rb:1:in `<main>'"}]},"seq":9}
V>D {"seq":7,"command":"threads","type":"request"}
V<D {"type":"response","command":"threads","request_seq":7,"success":true,"message":"Success","body":{"threads":[{"id":1,"name":"#1 /tmp/debug-20260222-2412-5caih4.rb:1:in `<main>'"}]},"seq":10}
V>D {"seq":8,"command":"stackTrace","arguments":{"threadId":1,"startFrame":0,"levels":20},"type":"request"}
V<D {"type":"response","command":"stackTrace","request_seq":8,"success":true,"message":"Success","body":{"stackFrames":[{"id":1,"name":"<main>","line":1,"column":1,"source":{"name":"debug-20260222-2412-5caih4.rb","path":"/tmp/debug-20260222-2412-5caih4.rb","sourceReference":0}}],"totalFrames":1},"seq":11}
V>D {"seq":9,"command":"scopes","arguments":{"frameId":1},"type":"request"}
V<D {"type":"response","command":"scopes","request_seq":9,"success":true,"message":"Success","body":{"scopes":[{"name":"Local variables","presentationHint":"locals","namedVariables":5,"indexedVariables":0,"expensive":false,"variablesReference":2},{"name":"Global variables","presentationHint":"globals","variablesReference":1,"namedVariables":41,"indexedVariables":0,"expensive":false}]},"seq":12}
V>D {"seq":10,"command":"variables","arguments":{"variablesReference":2},"type":"request"}
V<D {"type":"response","command":"variables","request_seq":10,"success":true,"message":"Success","body":{"variables":[{"name":"%self","value":"main","type":"Object","variablesReference":3,"indexedVariables":0,"namedVariables":1},{"name":"a","value":"nil","type":"NilClass","variablesReference":4,"indexedVariables":0,"namedVariables":1},{"name":"b","value":"nil","type":"NilClass","variablesReference":5,"indexedVariables":0,"namedVariables":1},{"name":"c","value":"nil","type":"NilClass","variablesReference":6,"indexedVariables":0,"namedVariables":1},{"name":"d","value":"nil","type":"NilClass","variablesReference":7,"indexedVariables":0,"namedVariables":1},{"name":"e","value":"nil","type":"NilClass","variablesReference":8,"indexedVariables":0,"namedVariables":1}]},"seq":13}
V>D {"seq":11,"command":"setBreakpoints","arguments":{"source":{"name":"target.rb","path":"/tmp/debug-20260222-2412-5caih4.rb","sourceReference":0},"lines":[4],"breakpoints":[{"line":4}],"sourceModified":false},"type":"request"}
V<D {"type":"response","command":"setBreakpoints","request_seq":11,"success":true,"message":"Success","body":{"breakpoints":[{"verified":true}]},"seq":14}
V>D {"seq":12,"command":"continue","arguments":{"threadId":1},"type":"request"}
V<D {"type":"response","command":"continue","request_seq":12,"success":true,"message":"Success","body":{"allThreadsContinued":true},"seq":15}
V<D {"type":"event","event":"stopped","body":{"reason":"breakpoint","description":" BP - Line  /tmp/debug-20260222-2412-5caih4.rb:4 (line)","text":" BP - Line  /tmp/debug-20260222-2412-5caih4.rb:4 (line)","threadId":1,"allThreadsStopped":true},"seq":16}
V>D {"seq":13,"command":"threads","type":"request"}
V<D {"type":"response","command":"threads","request_seq":13,"success":true,"message":"Success","body":{"threads":[{"id":1,"name":"#1 /tmp/debug-20260222-2412-5caih4.rb:4:in `<main>'"}]},"seq":17}
V>D {"seq":14,"command":"stackTrace","arguments":{"threadId":1,"startFrame":0,"levels":20},"type":"request"}
V<D {"type":"response","command":"stackTrace","request_seq":14,"success":true,"message":"Success","body":{"stackFrames":[{"id":2,"name":"<main>","line":4,"column":1,"source":{"name":"debug-20260222-2412-5caih4.rb","path":"/tmp/debug-20260222-2412-5caih4.rb","sourceReference":0}}],"totalFrames":1},"seq":18}
V>D {"seq":15,"command":"scopes","arguments":{"frameId":2},"type":"request"}
V<D {"type":"response","command":"scopes","request_seq":15,"success":true,"message":"Success","body":{"scopes":[{"name":"Local variables","presentationHint":"locals","namedVariables":5,"indexedVariables":0,"expensive":false,"variablesReference":9},{"name":"Global variables","presentationHint":"globals","variablesReference":1,"namedVariables":41,"indexedVariables":0,"expensive":false}]},"seq":19}
V>D {"seq":16,"command":"variables","arguments":{"variablesReference":9},"type":"request"}
V<D {"type":"response","command":"variables","request_seq":16,"success":true,"message":"Success","body":{"variables":[{"name":"%self","value":"main","type":"Object","variablesReference":10,"indexedVariables":0,"namedVariables":1},{"name":"a","value":"1","type":"Integer","variablesReference":11,"indexedVariables":0,"namedVariables":1},{"name":"b","value":"2","type":"Integer","variablesReference":12,"indexedVariables":0,"namedVariables":1},{"name":"c","value":"3","type":"Integer","variablesReference":13,"indexedVariables":0,"namedVariables":1},{"name":"d","value":"nil","type":"NilClass","variablesReference":14,"indexedVariables":0,"namedVariables":1},{"name":"e","value":"nil","type":"NilClass","variablesReference":15,"indexedVariables":0,"namedVariables":1}]},"seq":20}
V>D {"seq":17,"command":"evaluate","arguments":{"expression":"b","frameId":2,"context":"hover"},"type":"request"}
V<D {"type":"response","command":"evaluate","request_seq":17,"success":true,"message":"Success","body":{"result":"2","type":"Integer","variablesReference":16,"indexedVariables":0,"namedVariables":1},"seq":21}
V>D {"seq":18,"command":"variables","arguments":{"variablesReference":16},"type":"request"}
V<D {"type":"response","command":"variables","request_seq":18,"success":true,"message":"Success","body":{"variables":[{"name":"#class","value":"Integer","type":"Class","variablesReference":17,"indexedVariables":0,"namedVariables":1}]},"seq":22}
V>D {"seq":19,"command":"variables","arguments":{"variablesReference":17},"type":"request"}
V<D {"type":"response","command":"variables","request_seq":19,"success":true,"message":"Success","body":{"variables":[{"name":"#class","value":"Class","type":"Class","variablesReference":18,"indexedVariables":0,"namedVariables":1},{"name":"%ancestors","value":"[JSON::Ext::Generator::GeneratorMethods::Integer, Numeric, Comparable, #<Module:0x000055777c184540>, Object, JSON::Ext::Generator::GeneratorMethods::Object, DEBUGGER__::TrapIntercep...","type":"Array","variablesReference":19,"indexedVariables":11,"namedVariables":0}]},"seq":23}

--------------------------
| Last Protocol Messages |
--------------------------

{
  "seq": 18,
  "command": "variables",
  "arguments": {
    "variablesReference": 16
  },
  "type": "request"
}
{
  "type": "response",
  "command": "variables",
  "request_seq": 18,
  "success": true,
  "message": "Success",
  "body": {
    "variables": [
      {
        "name": "#class",
        "value": "Integer",
        "type": "Class",
        "variablesReference": 17,
        "indexedVariables": 0,
        "namedVariables": 1
      }
    ]
  },
  "seq": 22
}
{
  "seq": 19,
  "command": "variables",
  "arguments": {
    "variablesReference": 17
  },
  "type": "request"
}
{
  "type": "response",
  "command": "variables",
  "request_seq": 19,
  "success": true,
  "message": "Success",
  "body": {
    "variables": [
      {
        "name": "#class",
        "value": "Class",
        "type": "Class",
        "variablesReference": 18,
        "indexedVariables": 0,
        "namedVariables": 1
      },
      {
        "name": "%ancestors",
        "value": "[JSON::Ext::Generator::GeneratorMethods::Integer, Numeric, Comparable, #<Module:0x000055777c184540>, Object, JSON::Ext::Generator::GeneratorMethods::Object, DEBUGGER__::TrapIntercep...",
        "type": "Array",
        "variablesReference": 19,
        "indexedVariables": 11,
        "namedVariables": 0
      }
    ]
  },
  "seq": 23
}

--------------------
| Debuggee Session |
--------------------

> DEBUGGER: Debugger can attach via UNIX domain socket (/run/user/1001/rdbg-2412-33)
> DEBUGGER: wait for debugger connection...
> DEBUGGER: Connected.


-------------------
| Failure Message |
-------------------

expected:
{
  "type": "response",
  "command": "variables",
  "request_seq": 19,
  "success": true,
  "message": "Success",
  "body": {
    "variables": [
      {
        "name": "#class",
        "value": "Class",
        "type": "Class",
        "variablesReference": 18,
        "indexedVariables": 0,
        "namedVariables": "(?-mix:\\d+)"
      },
      {
        "name": "%ancestors",
        "value": "(?-mix:JSON::Ext::Generator::GeneratorMethods::Integer)",
        "type": "Array",
        "variablesReference": 19,
        "indexedVariables": "(?-mix:(9|10))",
        "namedVariables": "(?-mix:\\d+)"
      }
    ]
  }
}

result:
{
  "type": "response",
  "command": "variables",
  "request_seq": 19,
  "success": true,
  "message": "Success",
  "body": {
    "variables": [
      {
        "name": "#class",
        "value": "Class",
        "type": "Class",
        "variablesReference": 18,
        "indexedVariables": 0,
        "namedVariables": 1
      },
      {
        "name": "%ancestors",
        "value": "[JSON::Ext::Generator::GeneratorMethods::Integer, Numeric, Comparable, #<Module:0x000055777c184540>, Object, JSON::Ext::Generator::GeneratorMethods::Object, DEBUGGER__::TrapIntercep...",
        "type": "Array",
        "variablesReference": 19,
        "indexedVariables": 11,
        "namedVariables": 0
      }
    ]
  },
  "seq": 23
}.
</(9|10)/> was expected to be =~
<"11">.
/home/runner/work/debug/debug/test/protocol/hover_raw_dap_test.rb#test_1641198331
-------------------------
| All Protocol Messages |
-------------------------

V>D {"seq":1,"command":"initialize","arguments":{"clientID":"vscode","clientName":"Visual Studio Code","adapterID":"rdbg","pathFormat":"path","linesStartAt1":true,"columnsStartAt1":true,"supportsVariableType":true,"supportsVariablePaging":true,"supportsRunInTerminalRequest":true,"locale":"en-us","supportsProgressReporting":true,"supportsInvalidatedEvent":true,"supportsMemoryReferences":true},"type":"request"}
V>D {"seq":2,"command":"attach","arguments":{"type":"rdbg","name":"Attach with rdbg","request":"attach","rdbgPath":"/home/runner/work/debug/debug/exe/rdbg","debugPort":"/var/folders/kv/w1k6nh1x5fl7vx47b2pd005w0000gn/T/ruby-debug-sock-501/ruby-debug-naotto-8845","autoAttach":true,"__sessionId":"141d9c79-3669-43ec-ac1f-e62598c5a65a"},"type":"request"}
V>D {"seq":3,"command":"setFunctionBreakpoints","arguments":{"breakpoints":[]},"type":"request"}
V>D {"seq":4,"command":"setExceptionBreakpoints","arguments":{"filters":[],"filterOptions":[{"filterId":"RuntimeError"}]},"type":"request"}
V>D {"seq":5,"command":"configurationDone","type":"request"}
V<D {"type":"response","command":"initialize","request_seq":1,"success":true,"message":"Success","body":{"supportsConfigurationDoneRequest":true,"supportsFunctionBreakpoints":true,"supportsConditionalBreakpoints":true,"supportTerminateDebuggee":true,"supportsTerminateRequest":true,"exceptionBreakpointFilters":[{"filter":"any","label":"rescue any exception","supportsCondition":true},{"filter":"RuntimeError","label":"rescue RuntimeError","supportsCondition":true}],"supportsExceptionFilterOptions":true,"supportsStepBack":true,"supportsEvaluateForHovers":true,"supportsCompletionsRequest":true},"seq":1}
V<D {"type":"event","event":"initialized","seq":2}
V<D {"type":"event","event":"output","body":{"category":"console","output":"Ruby REPL: You can run any Ruby expression here.\nNote that output to the STDOUT/ERR printed on the TERMINAL.\n[experimental]\n  `,COMMAND` runs `COMMAND` debug command (ex: `,info`).\n  `,help` to list all debug commands.\n"},"seq":3}
V<D {"type":"response","command":"attach","request_seq":2,"success":true,"message":"Success","seq":4}
V<D {"type":"response","command":"setFunctionBreakpoints","request_seq":3,"success":true,"message":"Success","seq":5}
V<D {"type":"response","command":"setExceptionBreakpoints","request_seq":4,"success":true,"message":"Success","body":{"breakpoints":[{"verified":true,"message":"#<DEBUGGER__::CatchBreakpoint:0x00007f4644157bb0 @pat=\"RuntimeError\", @key=[:catch, \"RuntimeError\"], @last_exc=nil, @deleted=false, @cond=nil, @command=nil, @path=nil, @tp=#<TracePoint:enabled>>"}]},"seq":6}
V<D {"type":"response","command":"configurationDone","request_seq":5,"success":true,"message":"Success","seq":7}
V<D {"type":"event","event":"stopped","body":{"reason":"pause","threadId":1,"allThreadsStopped":true},"seq":8}
V>D {"seq":6,"command":"threads","type":"request"}
V<D {"type":"response","command":"threads","request_seq":6,"success":true,"message":"Success","body":{"threads":[{"id":1,"name":"#1 /tmp/debug-20260222-2412-1txb69u.rb:1:in `<main>'"}]},"seq":9}
V>D {"seq":7,"command":"threads","type":"request"}
V<D {"type":"response","command":"threads","request_seq":7,"success":true,"message":"Success","body":{"threads":[{"id":1,"name":"#1 /tmp/debug-20260222-2412-1txb69u.rb:1:in `<main>'"}]},"seq":10}
V>D {"seq":8,"command":"stackTrace","arguments":{"threadId":1,"startFrame":0,"levels":20},"type":"request"}
V<D {"type":"response","command":"stackTrace","request_seq":8,"success":true,"message":"Success","body":{"stackFrames":[{"id":1,"name":"<main>","line":1,"column":1,"source":{"name":"debug-20260222-2412-1txb69u.rb","path":"/tmp/debug-20260222-2412-1txb69u.rb","sourceReference":0}}],"totalFrames":1},"seq":11}
V>D {"seq":9,"command":"scopes","arguments":{"frameId":1},"type":"request"}
V<D {"type":"response","command":"scopes","request_seq":9,"success":true,"message":"Success","body":{"scopes":[{"name":"Local variables","presentationHint":"locals","namedVariables":1,"indexedVariables":0,"expensive":false,"variablesReference":2},{"name":"Global variables","presentationHint":"globals","variablesReference":1,"namedVariables":41,"indexedVariables":0,"expensive":false}]},"seq":12}
V>D {"seq":10,"command":"variables","arguments":{"variablesReference":2},"type":"request"}
V<D {"type":"response","command":"variables","request_seq":10,"success":true,"message":"Success","body":{"variables":[{"name":"%self","value":"main","type":"Object","variablesReference":3,"indexedVariables":0,"namedVariables":1},{"name":"ghi","value":"nil","type":"NilClass","variablesReference":4,"indexedVariables":0,"namedVariables":1}]},"seq":13}
V>D {"seq":11,"command":"setBreakpoints","arguments":{"source":{"name":"target.rb","path":"/tmp/debug-20260222-2412-1txb69u.rb","sourceReference":0},"lines":[29],"breakpoints":[{"line":29}],"sourceModified":false},"type":"request"}
V<D {"type":"response","command":"setBreakpoints","request_seq":11,"success":true,"message":"Success","body":{"breakpoints":[{"verified":true}]},"seq":14}
V>D {"seq":12,"command":"continue","arguments":{"threadId":1},"type":"request"}
V<D {"type":"response","command":"continue","request_seq":12,"success":true,"message":"Success","body":{"allThreadsContinued":true},"seq":15}
V<D {"type":"event","event":"stopped","body":{"reason":"breakpoint","description":" BP - Line  /tmp/debug-20260222-2412-1txb69u.rb:29 (line)","text":" BP - Line  /tmp/debug-20260222-2412-1txb69u.rb:29 (line)","threadId":1,"allThreadsStopped":true},"seq":16}
V>D {"seq":13,"command":"threads","type":"request"}
V<D {"type":"response","command":"threads","request_seq":13,"success":true,"message":"Success","body":{"threads":[{"id":1,"name":"#1 /tmp/debug-20260222-2412-1txb69u.rb:29:in `<main>'"}]},"seq":17}
V>D {"seq":14,"command":"stackTrace","arguments":{"threadId":1,"startFrame":0,"levels":20},"type":"request"}
V<D {"type":"response","command":"stackTrace","request_seq":14,"success":true,"message":"Success","body":{"stackFrames":[{"id":2,"name":"<main>","line":29,"column":1,"source":{"name":"debug-20260222-2412-1txb69u.rb","path":"/tmp/debug-20260222-2412-1txb69u.rb","sourceReference":0}}],"totalFrames":1},"seq":18}
V>D {"seq":15,"command":"scopes","arguments":{"frameId":2},"type":"request"}
V<D {"type":"response","command":"scopes","request_seq":15,"success":true,"message":"Success","body":{"scopes":[{"name":"Local variables","presentationHint":"locals","namedVariables":1,"indexedVariables":0,"expensive":false,"variablesReference":5},{"name":"Global variables","presentationHint":"globals","variablesReference":1,"namedVariables":41,"indexedVariables":0,"expensive":false}]},"seq":19}
V>D {"seq":16,"command":"variables","arguments":{"variablesReference":5},"type":"request"}
V<D {"type":"response","command":"variables","request_seq":16,"success":true,"message":"Success","body":{"variables":[{"name":"%self","value":"main","type":"Object","variablesReference":6,"indexedVariables":0,"namedVariables":1},{"name":"ghi","value":"nil","type":"NilClass","variablesReference":7,"indexedVariables":0,"namedVariables":1}]},"seq":20}
V>D {"seq":17,"command":"evaluate","arguments":{"expression":"Abc","frameId":2,"context":"hover"},"type":"request"}
V<D {"type":"response","command":"evaluate","request_seq":17,"success":true,"message":"Success","body":{"result":"Abc","type":"Module","variablesReference":8,"indexedVariables":0,"namedVariables":1},"seq":21}
V>D {"seq":18,"command":"variables","arguments":{"variablesReference":8},"type":"request"}
V<D {"type":"response","command":"variables","request_seq":18,"success":true,"message":"Success","body":{"variables":[{"name":"#class","value":"Module","type":"Class","variablesReference":9,"indexedVariables":0,"namedVariables":1},{"name":"%ancestors","value":"[]","type":"Array","variablesReference":0,"indexedVariables":0,"namedVariables":0}]},"seq":22}
V>D {"seq":19,"command":"evaluate","arguments":{"expression":"Abc::Def123","frameId":2,"context":"hover"},"type":"request"}
V<D {"type":"response","command":"evaluate","request_seq":19,"success":true,"message":"Success","body":{"result":"Abc::Def123","type":"Class","variablesReference":10,"indexedVariables":0,"namedVariables":1},"seq":23}
V>D {"seq":20,"command":"variables","arguments":{"variablesReference":10},"type":"request"}
V<D {"type":"response","command":"variables","request_seq":20,"success":true,"message":"Success","body":{"variables":[{"name":"#class","value":"Class","type":"Class","variablesReference":11,"indexedVariables":0,"namedVariables":1},{"name":"%ancestors","value":"[#<Module:0x000055b6b8fa4550>, Object, JSON::Ext::Generator::GeneratorMethods::Object, DEBUGGER__::TrapInterceptor, DEBUGGER__::ForkInterceptor, PP::ObjectMixin, Kernel, BasicObject...","type":"Array","variablesReference":12,"indexedVariables":8,"namedVariables":0}]},"seq":24}

--------------------------
| Last Protocol Messages |
--------------------------

{
  "seq": 19,
  "command": "evaluate",
  "arguments": {
    "expression": "Abc::Def123",
    "frameId": 2,
    "context": "hover"
  },
  "type": "request"
}
{
  "type": "response",
  "command": "evaluate",
  "request_seq": 19,
  "success": true,
  "message": "Success",
  "body": {
    "result": "Abc::Def123",
    "type": "Class",
    "variablesReference": 10,
    "indexedVariables": 0,
    "namedVariables": 1
  },
  "seq": 23
}
{
  "seq": 20,
  "command": "variables",
  "arguments": {
    "variablesReference": 10
  },
  "type": "request"
}
{
  "type": "response",
  "command": "variables",
  "request_seq": 20,
  "success": true,
  "message": "Success",
  "body": {
    "variables": [
      {
        "name": "#class",
        "value": "Class",
        "type": "Class",
        "variablesReference": 11,
        "indexedVariables": 0,
        "namedVariables": 1
      },
      {
        "name": "%ancestors",
        "value": "[#<Module:0x000055b6b8fa4550>, Object, JSON::Ext::Generator::GeneratorMethods::Object, DEBUGGER__::TrapInterceptor, DEBUGGER__::ForkInterceptor, PP::ObjectMixin, Kernel, BasicObject...",
        "type": "Array",
        "variablesReference": 12,
        "indexedVariables": 8,
        "namedVariables": 0
      }
    ]
  },
  "seq": 24
}

--------------------
| Debuggee Session |
--------------------

> DEBUGGER: Debugger can attach via UNIX domain socket (/run/user/1001/rdbg-2412-34)
> DEBUGGER: wait for debugger connection...
> DEBUGGER: Connected.


-------------------
| Failure Message |
-------------------

expected:
{
  "type": "response",
  "command": "variables",
  "request_seq": 20,
  "success": true,
  "message": "Success",
  "body": {
    "variables": [
      {
        "name": "#class",
        "value": "Class",
        "type": "Class",
        "variablesReference": 11,
        "indexedVariables": 0,
        "namedVariables": "(?-mix:\\d+)"
      },
      {
        "name": "%ancestors",
        "value": "(?-mix:Object)",
        "type": "Array",
        "variablesReference": 12,
        "indexedVariables": "(?-mix:(6|7))",
        "namedVariables": "(?-mix:\\d+)"
      }
    ]
  }
}

result:
{
  "type": "response",
  "command": "variables",
  "request_seq": 20,
  "success": true,
  "message": "Success",
  "body": {
    "variables": [
      {
        "name": "#class",
        "value": "Class",
        "type": "Class",
        "variablesReference": 11,
        "indexedVariables": 0,
        "namedVariables": 1
      },
      {
        "name": "%ancestors",
        "value": "[#<Module:0x000055b6b8fa4550>, Object, JSON::Ext::Generator::GeneratorMethods::Object, DEBUGGER__::TrapInterceptor, DEBUGGER__::ForkInterceptor, PP::ObjectMixin, Kernel, BasicObject...",
        "type": "Array",
        "variablesReference": 12,
        "indexedVariables": 8,
        "namedVariables": 0
      }
    ]
  },
  "seq": 24
}.
</(6|7)/> was expected to be =~
<"8">.

[-> View Test suite health in main branch]

Tests for breakpoint sync (fork_bp_sync_test.rb):
- Breakpoint set/deleted after fork syncs to child
- Multiple children receive synced breakpoints
- Catch breakpoint syncs to child
- Late-forked child catches up
- Stress test with binding.break

Tests for well-known lock (wk_lock_test.rb):
- Single-process debugging unaffected
- fork_mode: :both uses ProcessGroup not well-known lock
- Independent workers serialized by well-known lock
@st0012 st0012 force-pushed the support-multi-process-breakpoints branch from 1395d43 to cdd7e8e Compare February 21, 2026 15:40
- Fix version counter drift: read file version before writing to
  prevent processes from missing each other's updates
- Add MethodBreakpoint sync support (to_sync_data + reconciliation)
- Fix CatchBreakpoint sync to preserve command and path attributes
- Add syncable? predicate to avoid unnecessary hash allocation
- Add type validation in create_bp_from_spec for defense-in-depth
- Use Dir.tmpdir instead of hardcoded /tmp for portability
- Set explicit 0600 permissions on temp state file writes
- Broaden error handling to SystemCallError in read/write state
- Add error handling to ensure_wk_lock! for disk-full/read-only
- Publish breakpoint changes on DAP disconnect
When multiple independent workers share the well-known lock, releasing
it on step/next/finish allowed a sibling worker to grab the lock before
the stepping worker could re-enter its subsession. This caused the user
to need 2 next commands to actually advance — the first one would
inadvertently drive the other worker.

Only release wk_lock on :continue, which is expected to run for an
extended period. Step commands hold the lock so the same worker
immediately re-enters without yielding.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Line breakpoints aren't shared between processes

1 participant