zig-js

Inspector protocol

On this page 4

zig-js exposes an embedder-transported inspector protocol through include/zig-js/Extensions.h. Public JavaScriptCore inspectability remains opt-in: JSGlobalContextSetInspectable(ctx, true) must run before a ZJSInspectorSessionCreate succeeds.

Transport and trust boundary

The protocol version is zig-js-inspector/0.1. A session receives JSON messages through a synchronous callback and accepts JSON requests through ZJSInspectorSessionDispatch. Message bytes are borrowed only for the duration of the callback. zig-js does not open a socket, choose an origin, authenticate a client, or authorize commands. The embedder owns those transport and security decisions and should expose dispatch only to an authenticated debugger peer.

Sessions and their context group are thread-affine. A session retains its global context until ZJSInspectorSessionRelease, supports multiple simultaneous sessions, and is detached deterministically when inspectability is disabled. Both context and worker session refs are nonzero random capabilities rather than dereferenceable allocator addresses. The process registry compares the exact live token and transport kind under its mutex before accessing session state, with token entropy independent from secure bucket placement. Null, forged, wrong-transport, foreign-thread, duplicate-release, and retired tokens are rejected without dereference; invalid context dispatch returns false and an invalid worker pump reports closed. Physical removal consumes the exact token, and the empty registry releases its storage and hash context before reuse. At each stop, the first enabled session (or the session that requested the pending pause/step) owns continuation. Observer sessions receive and may inspect the paused snapshot before the owner callback runs, but their resume/step commands receive a deterministic error. The owner receives the pause last and must continue synchronously; step ownership carries into the resulting stop. Releasing the owner from inside its callback is an explicit detach-and-resume; remaining observers receive Debugger.resumed. Session release from any pause, response, or Inspector.detached callback is lifetime-guarded and physical teardown waits until the enclosing inspector operation unwinds. Because transport callbacks are synchronous and contexts are thread-affine, a client that receives Debugger.paused must dispatch Debugger.resume from that callback. If it returns without a continuation command, zig-js aborts that evaluation with a deterministic JavaScript Error instead of silently running while claiming to be paused.

static void receive(const char* json, size_t length, void* userData);

JSGlobalContextSetInspectable(ctx, true);
ZJSInspectorSessionRef session =
    ZJSInspectorSessionCreate(ctx, receive, userData);

const char request[] =
    "{\"id\":1,\"method\":\"Runtime.evaluate\","
    "\"params\":{\"expression\":\"6 * 7\"}}";
ZJSInspectorSessionDispatch(session, request, sizeof(request) - 1);
ZJSInspectorSessionRelease(session);

Version 0.1 domains

  • Schema.getDomains
  • Runtime.enable, Runtime.disable, Runtime.evaluate, Runtime.getProperties, Runtime.releaseObject, and Runtime.releaseObjectGroup
  • Debugger.enable, Debugger.disable, Debugger.pause, Debugger.resume, Debugger.stepInto, Debugger.stepOver, Debugger.stepOut, Debugger.evaluateOnCallFrame, Debugger.setPauseOnExceptions, Debugger.getScriptSource, Debugger.setBreakpoint, Debugger.setBreakpointByUrl, and Debugger.removeBreakpoint
  • Inspector.attached, Inspector.detached, Runtime.executionContextCreated, Debugger.scriptParsed, Debugger.breakpointResolved, Debugger.paused, and Debugger.resumed, and Debugger.exceptionThrown events

Every evaluated C-API script receives a context-owned, monotonically increasing unsigned integer scriptId, even before the first inspector session or Debugger.enable. Debugger.enable publishes that retained history in evaluation order with exact source, URL, zero-based starting line, and source length. Destroying the last session does not renumber scripts; a later session sees the same IDs and sources. Successful direct and indirect eval parses receive their own script identity at parse time rather than inheriting the caller's ID. A trailing //# sourceURL= or //@ sourceURL= directive supplies the URL; otherwise the protocol uses direct-eval or indirect-eval. Repeated evaluation of the same bytes intentionally creates a fresh monotonically increasing scriptId, so each execution has an unambiguous breakpoint/location history. URL breakpoints apply to every matching eval instance. Function, GeneratorFunction, AsyncFunction, and AsyncGeneratorFunction constructor parses likewise publish their canonical generated source before creating the function object. An inline sourceURL wins for JavaScript constructors; otherwise their kind name is the URL. JSObjectMakeFunction uses its explicit sourceURL and starting-line arguments, so C embedders receive adjusted generated-body locations and retrievable source without relying on a comment directive. Every successfully parsed JavaScript module in an evaluated graph is registered under its canonical host path before linking. The per-evaluation module cache deduplicates a dependency imported more than once, while a later independent graph evaluation receives fresh monotonic IDs. Entry and dependency modules publish exact source, resolve URL breakpoints, pause/step in their own module environment, and report module-path exception locations. Statement locations retain byte offsets plus adjusted line/column coordinates; a debugger statement pauses with reason debuggerStatement. An explicit Debugger.pause request pauses at the next statement boundary. Debug-enabled execution deliberately uses the tree walker, including ordinary synchronous functions parsed from that script, exposes no native-code owner, and retains no ordinary-function bytecode entry, so baseline/native execution cannot skip these boundaries. This policy is asserted directly and through the real C host. Suspendable generator and async-function chunks retain the same statement map inside the VM; stepping survives yield/await suspension and VM quick paths are disabled for those debug chunks. The optimizing JIT does not exist yet and therefore cannot be claimed as an inspected tier (tracked by issue #146).

Chunks compiled before attachment retain latent source-node checkpoints while normal execution keeps the hook null. Once Debugger is enabled, the context withholds its native-code owner and those existing bytecode checkpoints become live without recompiling the function or changing its upvalue/frame layout. Ordinary VM activations publish named local slots as a live inspector scope; evaluate-on-call-frame writes are synchronized back into the slots before the paused statement resumes. Breakpoints and stepping therefore enter functions warmed before the first session, including functions that had reached a native candidate before attachment.

Script breakpoints identify a scriptId; URL breakpoints apply to every matching present or future script. A requested location resolves deterministically to the first statement at or after its zero-based line/column, emits Debugger.breakpointResolved, and reports its id in paused.hitBreakpoints. Removing a breakpoint removes all of its resolved locations. Breakpoints are shared execution controls for the context, so every enabled session observes their resolution, pause, and resume events.

stepInto pauses at the next executed statement. stepOver ignores statements in deeper ordinary-function calls and stops at the next statement at the current or a shallower logical call depth. stepOut stops after control returns to a shallower call depth and is rejected at top level. A step completion pauses with reason step; a debugger statement or breakpoint encountered first takes precedence. Each continuation command emits Debugger.resumed.

Every Debugger.paused event includes callFrames ordered from the current invocation outward to the global script. A frame has a pause-local numeric callFrameId, function name, exact script location, this, and its live lexical scope chain. Declarative block/local scopes include their current bindings; global scopes publish a binding count without expanding the realm's full builtin table into every pause transcript. Tree-walker frames and suspendable generator/async VM frames use the same representation. These frames reference the actual activation environments, and the collector traces every paused caller environment and this value until execution continues.

Debugger.evaluateOnCallFrame accepts a callFrameId from the current pause and an expression. It evaluates synchronously against that frame's real lexical environment, this, and strictness, so assignments are visible when execution resumes. Debugger-authored evaluation cannot recursively pause; the runtime restores the suspended program's control state and any pre-existing exception after returning a structured result or exceptionDetails response. Frame IDs expire as soon as the pause resumes.

Object-valued evaluation results, this values, scope bindings, and accessor functions carry a session-owned numeric objectId. Runtime.getProperties returns own data/accessor descriptors without invoking getters; scope objectIds expand the corresponding live environment. Evaluation accepts an optional objectGroup. Runtime.releaseObject and Runtime.releaseObjectGroup deterministically unprotect handles. Value handles remain rooted across precise GC until release, while the backtrace handles attached to paused frames/scopes are released automatically on resume. IDs cannot be used by another session, after their group/session is released, or (for scopes) outside their originating pause.

Worker isolation and future targets

JSWorkerCreate* creates an independent context on a new owner thread and does not accept a parent JSContextRef. Version 0.1 therefore does not invent a child relationship or pause workers when an unrelated context stops. Worker message delivery and termination remain live during a context pause and release stays under the worker handle's owner-thread rules. Every worker now has a process-wide non-zero target ID, script/module kind, and atomic starting, running, closing, or closed state exposed through ZJSWorkerGetInspectorTargetInfo; IDs never encode or reuse raw pointers. ZJSWorkerInspectorSessionCreate explicitly attaches to one such target. Dispatch copies commands into a synchronized worker queue; the runtime executes them only on its own thread, including while paused. Responses and events are copied back and ZJSWorkerInspectorSessionPump invokes exactly one callback on the JSWorkerRef owner thread, returning message, timeout, or closed. A pump callback may enqueue the continuation for a paused event. Release detaches and waits for the runtime-side session/root cleanup before freeing the owner handle. Event delivery failure and runtime detachment are separate states; if allocating the ordinary detach command fails, an embedded client request wakes the runtime without another allocation. Releasing the whole worker first drains or rejects accepted traffic, releases each backend/root once, and leaves its session handle safe to pump to closed and then release. Focused transcripts cover script and module graphs, active message-handler pauses (including a first-statement debugger;), deterministic continuation ownership across two sessions, owner-callback detach, and termination while paused. The worker transport also has end-to-end coverage for URL breakpoints, stepping, caught-exception policy/events, live call frames and scopes, frame evaluation, and retained remote-object property inspection. A concurrency transcript holds two workers paused while an independently inspected main context pauses/resumes, then resumes each worker separately. The completed evidence matrix is recorded in issue #156.

setPauseOnExceptions accepts none, uncaught, or all. all pauses at the original throwing statement even when a surrounding catch handles the value; uncaught pauses only after propagation reaches the C-API evaluation boundary. Origin events cover explicit throws, engine-created Error/DOMException/parser errors, and fallback detection at the nearest catch/host boundary. Each selected throw emits Debugger.exceptionThrown followed by a paused event with reason exception, an exceptionId, the source location, and an uncaught flag.

scriptId and breakpointId are unsigned JSON integers in this protocol (they are not opaque strings). All protocol line and column fields are zero-based; the byteOffset field is zero-based UTF-8 source bytes.

Requests require an integer id and string method. Responses use JSON-RPC/CDP style result or error objects. Evaluation exceptions include an exceptionDetails object. Malformed requests receive deterministic protocol errors.

The machine-readable 0.1 command/event inventory names both context-synchronous and worker-owner-pump transports plus all 20 commands and 8 events with transcript evidence. Every listed command is implemented; an unlisted method receives -32601 and is never silently accepted.

Current debugger boundary

Version 0.1 establishes real attachment, lifecycle, concurrent sessions, live runtime evaluation, stable scripts, statement-boundary pause/resume, breakpoints, ordinary-call stepping, exception-pause policy, live call frames, lexical/global scope chains, frame evaluation, and expandable remote objects with deterministic GC-safe lifetime. Worker targets remain tracked by issue #156. Unsupported commands return -32601; there are no silently accepted debugger stubs. The completed origin/teardown matrix is recorded in issue #155. Context-owned history and dynamic registration are shared across sessions. Every enabled session receives the same scriptParsed identity; destroying the last session keeps that history, reattachment republishes it unchanged, and disabling inspectability detaches sessions without leaving the registration callback pointing at released protocol state.