Skip to content

feat(inspector): expose the V8 inspector over the Chrome DevTools Protocol - #416

Open
danielgatis wants to merge 1 commit into
TooTallNate:mainfrom
danielgatis:feat/v8-inspector
Open

feat(inspector): expose the V8 inspector over the Chrome DevTools Protocol#416
danielgatis wants to merge 1 commit into
TooTallNate:mainfrom
danielgatis:feat/v8-inspector

Conversation

@danielgatis

@danielgatis danielgatis commented Aug 26, 2026

Copy link
Copy Markdown

Closes nothing yet — opening this alongside the idea rather than after it, so
the shape can be argued with before it settles.

What it does

Switch.inspector.start({ port: 9229 });

…and chrome://inspect attaches to the console. Breakpoints, stepping, call
frames and scopes, Runtime.evaluate in the console, the CPU and heap
profilers — the DevTools window, against a Switch.

{ wait: true } blocks startup until a debugger attaches and then breaks, the
equivalent of --inspect-brk, for anything that happens too early to catch
otherwise.

Why the transport is native

This is the part worth reviewing, because the obvious alternative looks better
than it is.

Switch.listen() already exists, so the WebSocket server could have been
written in TypeScript with a thin $.inspector* binding underneath. It cannot,
and the reason is one line of the V8 contract: when a breakpoint hits, V8 calls
runMessageLoopOnPause() and expects the embedder to block inside it,
servicing the debugger, until the protocol resumes. JavaScript is stopped for
that whole time — including the socket handler that would have read
Debugger.resume. The app would stop at the first breakpoint and never move
again.

Node solves this with a dedicated thread. That turned out not to be necessary
here: tcp.cc deliberately speaks to sockets as raw file descriptors, so the
pause loop can poll() its own fd directly. The inspector socket is kept off
the libuv loop for the same reason — uv_run() is driven by the main loop,
which is not running while paused.

Three things this got wrong first

Each of these produced a debugger that connected and looked broken rather than
one that failed loudly, and each was found by attaching a real debugger.

The inspector is created during init, before the app's script is compiled.
Created on demand in start() instead, it could evaluate expressions but could
not see — or breakpoint — a single line the app had written: V8 only tracks
scripts parsed while an inspector exists. Sources was empty and nothing
explained why.

/json has to advertise devtoolsFrontendUrl. Without it chrome://inspect
lists the target and the inspect button does nothing at all. And
webSocketDebuggerUrl echoes the request's Host header rather than the
address the console bound to, which no client can dial.

A client that closes without completing the handshake is not a detaching
debugger.
Treating it as one meant the target list, which polls /json,
released every app that was waiting for a debugger.

Verification

Built for aarch64 with devkitPro and driven on device (Eden) with a CDP
client, plus Chrome DevTools by hand.

result
Debugger.pauseDebugger.paused isolate stops
Runtime.evaluate while paused returns 42
Debugger.resumeDebugger.resumed isolate runs again
setBreakpointByUrl on romfs:/main.js hits in work() at line 34
Debugger.evaluateOnCallFrame reads the local from the paused frame
wait: true app blocked before running; released on attach; Break on start
chrome://inspect Sources lists romfs:/main.js; breakpoints work

That resume arriving while the isolate is stopped is the whole design in one
line: it can only have been delivered by a transport that is not JavaScript.

Open questions

  • Cost when unused. The V8Inspector is constructed during init so scripts
    are tracked from the start; nothing listens until start(). If that is too
    much for applet mode, it could hang off a config flag — happy to do that.

  • wait: true cannot break before the app starts. The pause is requested
    from inside start(), so everything above that call has already run. What it
    does break on is the next statement V8 reaches afterwards — in practice the
    first function call in user code:

    reason: Break on start
    call stack:
      log           romfs:/main.js:2
      (top level)   romfs:/main.js:24
    

    Useful, but not --inspect-brk. Breaking before the app's script is compiled
    needs a hook further up in the runtime — happy to add one if you would rather
    this matched Node.

  • One debugger at a time. A second connection is accepted and closed.
    A 503 with a reason would be friendlier if that matters.

  • Port and wait are arguments today. If these belong in nxjs.ini next to
    the other runtime knobs instead, that is an easy change.

V8 ships the inspector; what an embedder has to supply is a transport
and somewhere for execution to stop. `Switch.inspector.start({ port })`
supplies both, and chrome://inspect, the Node DevTools window and editor
debuggers attach to it: breakpoints, stepping, call frames and scopes,
Runtime.evaluate in the console, and the profilers.

The transport is native rather than built on Switch.listen(). When a
breakpoint hits, V8 calls runMessageLoopOnPause() and expects the
embedder to block inside it, servicing the debugger, until the protocol
resumes — and JavaScript is stopped for that whole time, so a transport
written in JavaScript could not read the message that would release it.
Node uses a dedicated thread for this; that is not needed here, because
nx.js already speaks to sockets as raw file descriptors, so the pause
loop polls its own fd directly. The socket is kept off the libuv loop
for the same reason: uv_run() is not running while paused.

Three things this got wrong first, each found by attaching a real
debugger rather than by reading:

The inspector is created during init, before the app's script is
compiled. Built on demand instead, it could evaluate expressions but
could not see — or breakpoint — a single line the app had written,
because V8 only tracks scripts parsed while an inspector exists.

/json advertises devtoolsFrontendUrl. Without it chrome://inspect lists
the target and the inspect button does nothing, and webSocketDebuggerUrl
echoes the request's Host header rather than the address the console
bound to, which no client can dial.

A client that closes without completing the WebSocket handshake is not a
detaching debugger. Treating it as one meant the target list polling
/json released every app that was waiting for a debugger.

Verified on device (Eden, aarch64 build of this branch):

  Debugger.paused / evaluate while paused / Debugger.resumed
  setBreakpointByUrl on romfs:/main.js -> hit in work() at line 34,
    local read from the paused frame, resumed
  wait: true -> app blocked before running, released on attach,
    paused with reason "Break on start"
@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

@danielgatis is attempting to deploy a commit to the TooTallNate's Team Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot

changeset-bot Bot commented Aug 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1cb7e87

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@nx.js/runtime Minor
@nx.js/nro Minor
@nx.js/nsp Minor
create-nxjs-app Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@danielgatis

Copy link
Copy Markdown
Author

@TooTallNate Any chance we can move forward with this PR?

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.

1 participant