Skip to content

[Draft] X2WinRpcAdapter: remote Windows debugging over FlatBuffers RPC - #1174

Draft
Weitao-Sun wants to merge 14 commits into
devfrom
test_X2Win_debugger
Draft

[Draft] X2WinRpcAdapter: remote Windows debugging over FlatBuffers RPC#1174
Weitao-Sun wants to merge 14 commits into
devfrom
test_X2Win_debugger

Conversation

@Weitao-Sun

Copy link
Copy Markdown

Draft PR — not ready to merge. Opening early for visibility while the
remaining known issues below get fixed and Jenkins CI status is confirmed.

What's here

X2WinRpcAdapter (BN-core) + x2winstub (remote Windows stub), talking over
a FlatBuffers RPC protocol, for debugging Windows targets on a separate box.

Supported: Server-mode and Target-mode connect, launch/attach/detach/quit,
full execution control (go/step into/over/return/break-into), software +
hardware breakpoints, memory read/write + memory map, register read/write,
thread list/switch/suspend/resume, module list, call stack, target arch.

Not supported: reverse step-over, Time Travel Debugging (TTD).

Known issues (see STATUS.md for details)

  1. Detach can kill a multi-threaded target instead of detaching cleanly, if a
    shared breakpoint is hit by more than one thread at once.
  2. Breakpoints can leak onto an unrelated process if a stub connection is
    reused across Attach/Launch cycles (not yet observed, but reachable).
  3. X2WinRpcAdapter::Go() doesn't post a ResumeEventType, so the BN UI
    doesn't show the target as running until it next stops.

Build status

Builds/runs against the remote Windows dev box. Not yet confirmed to pass
this repo's Jenkins CI build.

🤖 Generated with Claude Code

Weitao-Sun and others added 14 commits July 21, 2026 16:41
Introduces a new cross-platform X2WIN_RPC debug adapter that will talk
to a Windows-side stub (x2winstub, WIN32-only, scaffolded but not yet
implemented) over a custom TCP RPC protocol, to support debugging
Windows targets from macOS/Linux without depending on lldb-server's
immature Windows support or DbgEng's Windows-only client library.

Lifecycle (Attach/Connect/Execute/Detach/Quit) and GetTargetArchitecture
are implemented against the wire protocol; the remaining DebugAdapter
methods are placeholder stubs to keep the class concrete while the
protocol and stub are built out incrementally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes the connect/reconnect crash risk (ConnectSocket now no-ops if
already connected instead of reassigning a live thread), reads the
stub address from adapter settings instead of a hardcoded value, and
adds the attach.pid setting the built-in Attach-to-Process flow relies
on internally to carry the selected pid.

Also wires up the TargetStopped event end-to-end: Detach/Quit now post
DetachedEventType/TargetExitedEventType so DebuggerController's
connection-state tracking and WaitForAdapterStop() don't get stuck, and
ReaderLoop() decodes the stub's stop-reason byte into a real
DebugStopReason instead of dropping Event frames on the floor.

Verified end-to-end against a throwaway Python stub: connect, list
fake processes, attach, receive the stopped notification, detach, and
attach again all work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the hand-rolled frame format (manual FrameType/MethodId enums and
byte-packing helpers) with a single protobuf Envelope message using a oneof
to distinguish requests/responses/events, defined in protocol/x2win.proto
(replacing the empty placeholder). This removes an entire class of manual
encode/decode bugs and gives the not-yet-written Windows stub an unambiguous
schema to implement against instead of reverse-engineering byte offsets.

Protobuf is wired into core/CMakeLists.txt the same way LLDB already is: an
externally-built dependency located via a PROTOBUF_PATH environment variable
with a platform-appropriate default, not vendored or fetched by the build.
build.md documents building it from source as a static lib (so debuggercore
doesn't pick up a runtime dependency on a system-installed Protobuf); the
CMAKE_CXX_STANDARD=20 flag in those instructions is required to avoid an
Abseil ABI mismatch between its installed headers and compiled binaries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Go() and both AddBreakpoint() overloads were still stub returns that never
touched the wire; Resume and SetBreakpoint requests silently did nothing.
ConnectToDebugServer() was unimplemented entirely. All three now round-trip
through CallSync() the same way Attach()/Detach() already did.

ReaderLoop() also stashes the reason/address from each TargetStoppedEvent
into new atomic members so StopReason()/GetInstructionOffset() can report
real values instead of hardcoded UnknownReason/0 -- needed for
DebuggerController's stop-reason-driven resume logic to behave correctly.

AddBreakpoint(ModuleNameAndOffset&) needed ResolveModuleAddress(), which was
declared but never defined; added it following LldbAdapter's pattern.

protocol/x2win.proto gains the corresponding ConnectServerRequest/Response,
GoRequest/Response, SetBreakpointRequest/Response + BreakpointType, and an
address field on TargetStoppedEvent plus STOP_REASON_INITIAL_BREAKPOINT.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Protocol:
- Finish the switch from protobuf to flatbuffers (vendor/flatbuffers submodule,
  protocol/x2win.fbs replaces x2win.proto) and bring x2winstub's local mirror
  fully up to date (main.cpp, net/, debug/WindowsDebugEngine port, x2win_session).
- Add StepIntoRequest/StepOverRequest/BreakIntoRequest/RemoveBreakpointRequest
  message pairs, and a size field on ModuleEntry.

core/adapters/x2winrpcadapter.cpp:
- Wire StepInto()/StepOver()/BreakInto()/RemoveBreakpoint() over the new RPCs,
  same CallSync pattern as Go().
- GetBreakpointList() now serves from a locally-maintained cache (kept in sync
  by AddBreakpoint()/RemoveBreakpoint()) instead of a live RPC, since the base
  class declares it const and CallSync() can't be called from a const method.
- GetModuleList() extracts the module basename itself (recognizing both '/'
  and '\\') before storing it as short_name -- DebugModule::GetPathBaseName()
  only recognizes '\\' when compiled for Windows, which broke module-name
  matching (and therefore auto-rebase) since X2WinRpcAdapter is the first
  adapter where BN core can run on a different OS than the Windows debug target.
- common.inputFile is now auto-populated from the BinaryView's file path
  (GenerateDefaultAdapterSettings, same convention as every other adapter),
  fixing the same rebase-matching path from the other side.
- Go()/StepInto()/StepOver() now post ResumeEventType/StepIntoEventType/
  StepOverEventType on success, which is what actually drives
  DebuggerState::IsRunning() -- previously always false for this adapter,
  which also meant CanResumeTarget() never blocked a second Go/Step while one
  was already in flight.

core/debuggercontroller.cpp:
- ApplyOwnStateForEvent: add StepOverEventType alongside Resume/StepIntoEventType
  so it also flips execution status to Running (additive only -- no existing
  adapter ever posts this event, so no behavior change for anyone else).

x2winstub/CMakeLists.txt:
- Add NOMINMAX/WIN32_LEAN_AND_MEAN so <Windows.h>'s max/min macros stop
  mangling flatbuffers' std::numeric_limits<T>::max() calls -- this was only
  surfacing on a genuinely clean build; incremental builds had been silently
  reusing stale .obj files for main.cpp/net/connection.cpp/x2win_session.cpp
  across several rounds of protocol changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…cAdapter

Register read/write:
- protocol/x2win.fbs gains ReadAllRegistersRequest/Response,
  ReadRegisterRequest/Response, WriteRegisterRequest/Response, and a
  RegisterEntry table (name/value/width/register_index). Values are uint64 --
  X2Win only ever targets x86/x64 Windows.
- X2WinRpcAdapter::ReadAllRegisters()/ReadRegister()/WriteRegister() were
  stub returns; now round-trip through CallSync() like the other RPCs.

Breakpoint resync after (re)connect:
- DebuggerBreakpoints::Apply() replays every known breakpoint from
  CreateDebugAdapter(), which runs before Attach()/ExecuteWithArgs()/
  Connect() has actually opened the socket -- AddBreakpoint() used to just
  fail silently in that window, so breakpoints never made it to a freshly
  (re)connected stub. AddBreakpoint(ModuleNameAndOffset&) now stages into
  m_pendingBreakpoints when not yet connected (or when the module isn't
  resolvable yet), and the new ApplyBreakPoints() flushes it once connected
  and again on every TargetStoppedEvent -- same shape as
  LldbAdapter::ApplyBreakpoints()'s pending-breakpoint handling.
- RemoveBreakpoint() now also checks m_pendingBreakpoints first, so removing
  a breakpoint that hadn't been flushed yet doesn't silently no-op and then
  reappear on the next flush.
- TeardownConnection() now clears m_breakpoints -- entries from a dead
  connection aren't trustworthy after a reconnect (fresh stub session, or a
  resend from DebuggerBreakpoints::Apply() racing a stale cached entry into
  a duplicate/ghost breakpoint).

GetProcessList() no longer self-connects:
- It used to call ConnectFromSettings() itself, independent of the
  controller's Launch/Attach/Connect/ConnectToDebugServer lifecycle. In
  target mode this could open a connection to a stub that immediately pushes
  an unsolicited TargetStoppedEvent on accept, which could drive
  DetectLoadedModule()/autoRebase through a path that never ran
  CreateDebuggerBinaryView() -- crashing on a null memory accessor. Now it
  just checks m_connected, matching GdbAdapter (unimplemented) and
  LldbAdapter (only ever queries an already-live backend session).

Launch/Restart:
- launch.executablePath/workingDirectory/commandLineArguments were never
  registered as adapter settings, so DebuggerState::GetExecutablePath()
  always returned "" and any Launch (including Restart's Quit-then-Launch)
  sent an empty path to the stub. Settings added, deliberately without a
  local file-picker uiSelectionAction since the path is a remote Windows
  path, not a local one.
- ExecuteWithArgs() now refuses immediately (before touching the network)
  when the last successful connection was via Connect() (the target-mode
  entry point, UI: "Connect to Remote Process") -- a target-mode stub only
  ever owns the one debuggee it was started with, same as plain gdbserver
  vs gdbserver --multi. Without this, Restart in target mode would Quit the
  debuggee (causing the stub to exit, per its reconnect-loop design) and
  then hang trying to reconnect to a stub that no longer exists.

Also drops x2winstub/engine_port_task.md and read_memory_task.md, superseded
by the x2winstub/instruction_note/ task-doc workflow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SupportFeature() always returned false, so DebuggerController's
StepOverAndWaitInternal() never used the already-wired native StepOver
RPC and instead fell back to software step-over emulation. Report
StepOver and Modules as supported since both are implemented over RPC;
StepReturn, StepOverReverse, Threads, and TTD remain false since the
stub doesn't support them yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
protocol/x2win.fbs gains WriteMemoryRequest/WriteMemoryResponse, mirroring
ReadMemoryRequest/Response's synchronous request/response shape (address +
byte vector in, success bool out, no separate async event).

X2WinRpcAdapter::WriteMemory() was a stub returning false; now round-trips
through CallSync() like ReadMemory()/WriteRegister(). This is what backs
DebuggerFileAccessor::Write() (core/debuggerfileaccessor.cpp), i.e. editing
bytes in the hex view or bv.write() against the live process view during a
debug session.

Verified end-to-end against the stub (write + read-back).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…worker deadlock in X2WinRpcAdapter

Thread management:
- protocol/x2win.fbs gains GetThreadListRequest/Response (ThreadEntry:
  tid/rip/is_frozen), GetActiveThreadIdRequest/Response,
  SetActiveThreadIdRequest/Response, SuspendThreadRequest/Response, and
  ResumeThreadRequest/Response.
- X2WinRpcAdapter::GetThreadList()/GetActiveThread()/GetActiveThreadId()/
  SetActiveThread()/SetActiveThreadId()/SuspendThread()/ResumeThread() were
  stub returns; now round-trip through CallSync(). GetActiveThread() derives
  rip from GetInstructionOffset() (the last reported stop) rather than a
  separate RPC, since BN only ever stops the whole process, never a single
  thread.
- SupportFeature() now reports DebugAdapterSupportThreads.

Hardware breakpoints:
- protocol/x2win.fbs gains SetHardwareBreakpointRequest/Response and
  RemoveHardwareBreakpointRequest/Response (address/type/size triple, not an
  allocated id -- mirrors a debug register slot's own identity rule).
- The 4 AddHardwareBreakpoint()/RemoveHardwareBreakpoint() overloads
  (absolute address and ModuleNameAndOffset) always returned false; now wire
  through CallSync(), reusing core's PendingHardwareBreakpoint to stage
  before the adapter is connected -- DebuggerBreakpoints::Apply() calls
  these unconditionally from CreateDebugAdapter(), same pre-connect timing
  problem AddBreakpoint(ModuleNameAndOffset&) already had to solve.
  ApplyBreakPoints() now flushes both the software and hardware pending
  lists.

Report process exit over the wire (fixes a worker-thread deadlock):
- StopReason gains EXITED, and TargetStoppedEvent gains exit_code. Stub-side
  process exit was previously invisible to BN core entirely -- the stub
  detects it (WindowsDebugEngine posts an internal TargetExited event) but
  nothing on the wire ever reported it, so DebuggerController::
  WaitForAdapterStop() (an untimed condition_variable::wait) would block
  forever after a Go() whose target ran to completion on its own, and the
  real Detach()/Quit() RPC -- queued behind that stuck worker op -- would
  never even reach the stub. Only the out-of-band RequestInterrupt() ->
  BreakInto() (fired once per Detach/Quit click, on its own thread) made it
  onto the wire, uselessly, since the process was already gone.
- ReaderLoop() now branches on StopReason_EXITED: caches the exit code,
  sets m_lastStopReason to ProcessExited, and posts TargetExitedEventType
  instead of AdapterStoppedEventType (skipping the ApplyBreakPoints() resync
  -- nothing to resend to). ExitCode() now returns the cached value instead
  of a hardcoded 0.
- BreakInto() skips the RPC round trip entirely when m_lastStopReason is
  already ProcessExited, instead of logging a "stub reported failure" that
  isn't telling us anything new (RequestInterrupt() calls it unconditionally
  before every Detach()/Quit(), regardless of whether the target is still
  running).

Also strips a stray trailing "\n" from one LogWarn call (Log already
appends its own newline).

Corresponding stub-side changes (x2win_session.cpp HandleRequest cases for
the new thread/hardware-breakpoint RPCs, and OnEngineEvent forwarding
TargetExited) delivered separately via x2winstub/instruction_note/ task
docs, per the BN-core/stub split -- see x2winrpcadapter-task-doc-workflow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Call stacks:
- protocol/x2win.fbs gains FrameEntry (index/pc/sp/fp/function_name/
  function_start/module -- mirrors BN's DebugFrame) and
  GetFramesOfThreadRequest/Response.
- X2WinRpcAdapter::GetFramesOfThread() fell back to DebugAdapter's default
  (always {}), so the Stack Trace sidebar was always empty; now round-trips
  through CallSync() like GetThreadList(). WindowsDebugEngine::
  GetFramesOfThread() (StackWalk64-based, ported from WindowsNativeAdapter)
  already did the actual unwinding, just wasn't wired through the proto
  surface.

StepReturn:
- protocol/x2win.fbs gains StepReturnRequest/Response (no fields, mirrors
  StepIntoRequest/StepOverRequest's shape).
- X2WinRpcAdapter::StepReturn() was unimplemented (same default-false
  fallback), now wired the same way. WindowsDebugEngine::StepReturn()
  already existed and uses the newly-wired GetFramesOfThread() internally
  (direct C++ call, not a second RPC round trip) to find the caller's
  return address and set a temporary breakpoint there.
- SupportFeature() now reports DebugAdapterSupportStepReturn.

Verified end-to-end against a multi-threaded test binary: call stacks
correctly unwind through user code -> CRT startup -> kernel32/ntdll thread
trampolines for every thread, and StepReturn correctly stops at the return
address in the caller rather than single-stepping.

Corresponding stub-side changes (x2win_session.cpp HandleRequest cases for
the two new RPCs) delivered separately via x2winstub/instruction_note/ task
docs, per the BN-core/stub split -- see x2winrpcadapter-task-doc-workflow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GetStackPointer:
- X2WinRpcAdapter didn't override this, so it fell back to DebugAdapter's
  default (always 0). No new RPC needed -- same trick as
  GdbMiAdapter::GetStackPointer(): reuse the already-wired ReadRegister()
  and read rsp/esp (X2Win only ever targets x86/x64 Windows, so no need for
  GdbMiAdapter's fuller architecture-name switch).

GetMemoryMap:
- protocol/x2win.fbs gains MemoryRegionEntry (start/size/name/read/write/
  execute/shared -- mirrors BN's DebugMemoryRegion) and
  GetMemoryMapRequest/Response.
- X2WinRpcAdapter::GetMemoryMap() fell back to DebugAdapter's default
  (always {}), so the Memory Map sidebar was always empty; now round-trips
  through CallSync() like GetModuleList(). WindowsDebugEngine::
  GetMemoryMap() (ported from WindowsNativeAdapter) already did the actual
  region enumeration, just wasn't wired through the proto surface.

Verified end-to-end: SP now shows a real value in the register view instead
of 0, and the Memory Map sidebar populates with the target's regions.

Corresponding stub-side change (x2win_session.cpp's Body_GetMemoryMapRequest
case) delivered separately via x2winstub/instruction_note/ task docs, per
the BN-core/stub split -- see x2winrpcadapter-task-doc-workflow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…in X2WinRpcAdapter

- DisconnectDebugServer(): send QuitRequest and tear down the connection,
  mirroring the Server-mode counterpart to ConnectToDebugServer.
- Detach()/Quit(): only fully TeardownConnection() for target-mode
  connections; for server-mode, reset session state instead so the
  underlying socket connection to the stub survives (it can still be
  reused for a subsequent Launch()/Attach()).
- Factor the breakpoint/stop-state clearing out of TeardownConnection()
  into a shared ResetSessionState(), and extend it to also clear pending
  (hardware) breakpoints and last-stop/exit-code state.
This monorepo's x2winstub/ mirror had fallen behind the actual X2WinStub
repo checked out on the remote Windows box (10.42.4.10), which is where
it's actually built/run/debugged and carries its own git history. Pulled
the current tracked state of that repo (branch
wire-get-memory-map-and-fix-arg-parsing, 75fec60) into this mirror, i.e.
everything its own .gitignore doesn't exclude (build/, clangdLoc/,
.claude/, .vscode/, instruction_note/) and skipping testBinaries/ (also
untracked there) and vendor/flatbuffers (a real git submodule there,
building standalone; this mirror instead reuses this repo's own
vendor/flatbuffers via the nested add_subdirectory(x2winstub) path, so it
doesn't need its own copy -- see x2winstub/CMakeLists.txt's
`if(NOT TARGET x2win_fbs)` guard).

Covers remote's last several commits, wiring up over RPC: StepInto/
StepOver, BreakInto/RemoveBreakpoint, GetProcessList (+ restricting
Attach to Server mode), registers, WriteMemory, thread management,
hardware breakpoints/watchpoints, TargetExited forwarding,
GetFramesOfThread/GetMemoryMap/StepReturn, and a target-mode
reconnect/--ip/--port argument-parsing fix -- matching the BN-core side
already wired in this repo's own recent commits.

Also pulled over KNOWN_ISSUES.md (untracked on remote, not yet committed
there either) and debug/debug_loop.{cpp,h}.superseded, the pre-port
WinAPI debug loop kept there for reference (superseded by
windows_debug_engine.cpp).

Verified: debuggercore still builds clean locally (x2winstub itself is
Windows-only and can't be built on this machine).
Fix build.md: describe FlatBuffers, not stale Protobuf/Abseil wording.
The wire protocol switched from Protobuf to FlatBuffers a while back (see
protocol/x2win.fbs, vendor/flatbuffers), but this doc's build instructions
never got updated to match -- it still described a two-submodule
Protobuf+Abseil setup. Found while sweeping the repo for leftover
protobuf references (everything else -- PROTOBUF_PATH, find_package(Protobuf),
.proto/.pb.h/.pb.cc, vendor/protobuf submodule entries -- was already clean).

Rename x2winstub/KNOWN_ISSUES.md to x2winstub/STATUS.md and expand it:
- Add a top-level summary of what X2WinRpcAdapter/x2winstub currently
  supports and doesn't.
- Note that build/run against the remote Windows dev box is confirmed,
  but passing this repo's Jenkins CI build is not yet confirmed.
- Add known issue: X2WinRpcAdapter::Go() never posts a ResumeEventType,
  so the Binary Ninja UI doesn't reflect the target running until the
  next stop event arrives.
- Drop the --ip/--port known issue (fixed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Aug 12, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

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.

2 participants