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.