feat(coding-agent/debug): added remote javascriptcore inspector support

- Implemented a bridge to the `bun:jsc` remote inspector API.
- Added a debug interface action to start the inspector and expose the connection endpoint.
- Included logic to dynamically reserve available ports and reliably probe the socket state due to inherent platform-dependent limitations in the underlying API.
This commit is contained in:
can1357
2026-06-18 17:40:40 +02:00
parent 8427e69e1e
commit 4d8b6a9614
2 changed files with 195 additions and 0 deletions
+44
View File
@@ -29,6 +29,7 @@ import { generateHeapSnapshotData, type ProfilerSession, startCpuProfile } from
import { buildSampleImage, ProtocolProbeComponent } from "./protocol-probe";
import { RawSseViewerComponent } from "./raw-sse";
import { resolveRawSseDebugBuffer } from "./raw-sse-buffer";
import { getRemoteDebugger, type RemoteDebuggerInfo, startRemoteDebuggerServer } from "./remote-debugger";
import { clearArtifactCache, createDebugLogSource, createReportBundle, getArtifactCacheStats } from "./report-bundle";
import { collectSystemInfo, formatSystemInfo } from "./system-info";
import { collectTerminalState, formatTerminalState } from "./terminal-info";
@@ -49,6 +50,11 @@ const DEBUG_MENU_ITEMS: SelectItem[] = [
description: "Styling, links, text sizing, graphics, notify",
},
{ value: "raw-sse", label: "View: raw SSE stream", description: "Show live provider SSE frames" },
{
value: "remote-debugger",
label: "Start: JS remote debugger",
description: "Expose JavaScriptCore inspector socket (experimental)",
},
{
value: "transcript",
label: "Export: TUI transcript",
@@ -122,6 +128,9 @@ export class DebugSelectorComponent extends Container {
case "raw-sse":
await this.#handleViewRawSse();
break;
case "remote-debugger":
await this.#handleStartRemoteDebugger();
break;
case "system":
await this.#handleViewSystemInfo();
break;
@@ -353,6 +362,41 @@ export class DebugSelectorComponent extends Container {
this.ctx.ui.requestRender();
}
async #handleStartRemoteDebugger(): Promise<void> {
const existing = getRemoteDebugger();
let info: RemoteDebuggerInfo;
try {
info = existing ?? (await startRemoteDebuggerServer());
} catch (err) {
this.ctx.showError(`Failed to start remote debugger: ${err instanceof Error ? err.message : String(err)}`);
return;
}
const block = new TranscriptBlock();
block.addChild(
new Text(
theme.fg(
"success",
`${theme.status.success} JavaScriptCore remote inspector ${existing ? "already running" : "started"}`,
),
1,
0,
),
);
block.addChild(new Text(theme.fg("dim", `Listening on ${info.host}:${info.port}`), 1, 0));
block.addChild(
new Text(
theme.fg(
"muted",
"Experimental WebKit RemoteInspectorServer socket (Bun marks it untested on macOS). One-way for this process — there is no stop. Attach a compatible WebKit/Safari Web Inspector client.",
),
1,
0,
),
);
this.ctx.present(block);
}
async #handleViewSystemInfo(): Promise<void> {
try {
const info = await collectSystemInfo();
@@ -0,0 +1,151 @@
/**
* Bun JavaScriptCore remote inspector control.
*
* Wraps `bun:jsc`'s `startRemoteDebugger`, which exposes JavaScriptCore's
* built-in WebKit RemoteInspectorServer over a raw socket. The API is one-shot
* and rough around the edges (Bun documents it as untested, "may not be
* supported yet on macOS"):
* - it returns `void` and has no stop handle, so we track the live endpoint
* at module scope and make starting idempotent;
* - it rejects port `0`, so "let the OS pick" is implemented by reserving a
* free port via `node:net` and handing the concrete number to Bun;
* - on macOS (Bun 1.3.x) it throws a spurious "port already in use" error
* even when the server binds fine, so success is decided by a loopback
* probe rather than by whether the call threw.
*/
import { startRemoteDebugger } from "bun:jsc";
import * as net from "node:net";
const DEFAULT_HOST = "127.0.0.1";
/** How long to keep probing for the inspector socket before giving up. */
const PROBE_DEADLINE_MS = 1000;
const PROBE_INTERVAL_MS = 50;
export interface RemoteDebuggerInfo {
host: string;
port: number;
}
let active: RemoteDebuggerInfo | null = null;
/** In-flight start, shared so concurrent callers coalesce onto one launch. */
let starting: Promise<RemoteDebuggerInfo> | null = null;
/** Underlying starter signature; tests inject a disposable listener in its place. */
export type RemoteDebuggerStarter = (host: string, port: number) => void;
export interface StartRemoteDebuggerOptions {
/** Explicit port; when omitted a free port is reserved automatically. */
port?: number;
/** Override the JSC starter. Defaults to `bun:jsc`'s `startRemoteDebugger`. */
start?: RemoteDebuggerStarter;
}
/** The live inspector endpoint for this process, or `null` if not started. */
export function getRemoteDebugger(): RemoteDebuggerInfo | null {
return active;
}
/** Reserve a free TCP port on `host` by binding to `0`, then releasing it. */
async function reserveFreePort(host: string): Promise<number> {
const server = net.createServer();
const listening = Promise.withResolvers<number>();
server.once("error", listening.reject);
server.listen(0, host, () => {
const addr = server.address();
if (addr && typeof addr === "object") listening.resolve(addr.port);
else listening.reject(new Error("Failed to reserve a debugger port"));
});
try {
return await listening.promise;
} finally {
const closed = Promise.withResolvers<void>();
server.close(() => closed.resolve());
await closed.promise;
}
}
/** Resolve once `host:port` accepts a TCP connection (within one attempt). */
function tryConnect(host: string, port: number, timeoutMs: number): Promise<boolean> {
const { promise, resolve } = Promise.withResolvers<boolean>();
const socket = net.createConnection({ host, port });
let settled = false;
const finish = (ok: boolean) => {
if (settled) return;
settled = true;
socket.destroy();
resolve(ok);
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => finish(true));
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
return promise;
}
/** Poll the inspector socket until it accepts a connection or the deadline passes. */
async function waitForListening(host: string, port: number): Promise<boolean> {
const deadline = Date.now() + PROBE_DEADLINE_MS;
do {
if (await tryConnect(host, port, PROBE_INTERVAL_MS)) return true;
await Bun.sleep(PROBE_INTERVAL_MS);
} while (Date.now() < deadline);
return false;
}
/**
* Start the JavaScriptCore remote inspector for this process and return its
* endpoint. Idempotent: the underlying API cannot be stopped or rebound, so a
* second call returns the existing endpoint instead of starting again. When
* `port` is omitted a free port is reserved automatically.
*
* Throws only when the socket never comes up; Bun's spurious bind error is
* swallowed and overridden by the loopback probe.
*/
export async function startRemoteDebuggerServer(options: StartRemoteDebuggerOptions = {}): Promise<RemoteDebuggerInfo> {
if (active) return active;
starting ??= launch(options);
try {
return await starting;
} finally {
starting = null;
}
}
async function launch({ port, start = startRemoteDebugger }: StartRemoteDebuggerOptions): Promise<RemoteDebuggerInfo> {
const host = DEFAULT_HOST;
const chosen = port ?? (await reserveFreePort(host));
// Something already on this port? Refuse up front: otherwise Bun throws a
// real bind error and our success probe would connect to that unrelated
// service, marking a bogus endpoint as the debugger.
if (await tryConnect(host, chosen, PROBE_INTERVAL_MS)) {
throw new Error(`Port ${host}:${chosen} is already in use; cannot start remote debugger there.`);
}
let thrown: unknown;
try {
start(host, chosen);
} catch (err) {
// Bun's startRemoteDebugger throws a spurious bind error even on success,
// so defer the verdict to the loopback probe below.
thrown = err;
}
if (await waitForListening(host, chosen)) {
active = { host, port: chosen };
return active;
}
throw thrown instanceof Error ? thrown : new Error(`Remote debugger socket never came up on ${host}:${chosen}`);
}
/**
* Test-only: forget the tracked endpoint so a fresh start can be exercised.
* Does not (and cannot) stop a real JSC inspector — callers in tests own the
* disposable listener they injected.
*/
export function __resetRemoteDebuggerForTests(): void {
active = null;
starting = null;
}