Repository navigation
Description
Version and environment
workerd 1.20261102.1, the current published release, from @cloudflare/workerd-darwin-arm64. Node.js v24.18.0, macOS ARM64.
Actual behavior
A dynamic Worker named a/b/c/0.0 runs normally, but its advertised inspector URL cannot be opened. /json/list returns:
{
"id": "LOADER:a/b/c/0.0",
"webSocketDebuggerUrl": "ws://127.0.0.1:<inspector-port>/LOADER:a/b/c/0.0"
}Connecting with Node's built-in WebSocket fires error, without open. The same process successfully opens the advertised URL for the main Worker. The standalone reproduction completed both probes, then terminated and reaped its own workerd child. All child stdio closed. This release did not emit an Unknown worker session log in the captured stderr.
Expected behavior
Every webSocketDebuggerUrl advertised by /json/list should connect to its target, including WorkerLoader names containing /.
Reproduction
Save these three files together, install workerd@1.20261102.1, and run:
npm install workerd@1.20261102.1
node repro.mjs "$(node -p 'require("workerd")')"The script uses ephemeral loopback ports and one WorkerLoader binding. These are the exact fixture sources used for the result below.
config.capnp
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [
(name = "main", worker = (
compatibilityDate = "2026-11-01",
compatibilityFlags = ["experimental"],
modules = [(name = "worker.mjs", esModule = embed "worker.mjs")],
bindings = [(name = "LOADER", workerLoader = ())],
)),
],
sockets = [
(name = "http", address = "127.0.0.1:0", http = (), service = "main"),
],
);worker.mjs
let dynamicWorker;
export default {
async fetch(request, env) {
dynamicWorker ??= env.LOADER.get("a/b/c/0.0", () => ({
compatibilityDate: "2026-11-01",
mainModule: "dynamic.js",
modules: {
"dynamic.js": 'export default { fetch() { return new Response("ok"); } };',
},
}));
return dynamicWorker.getEntrypoint().fetch(request);
},
};repro.mjs
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(new Error("Reproduction deadline exceeded")), 15000);
process.once("SIGTERM", () => controller.abort(new Error("Parent requested stop")));
function aborted() {
return new Promise((_, reject) => {
controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { once: true });
});
}
async function connect(url) {
const socket = new WebSocket(url);
const closed = new Promise((resolve) => socket.addEventListener("close", resolve, { once: true }));
const opened = await Promise.race([
new Promise((resolve) => {
socket.addEventListener("open", () => resolve(true), { once: true });
socket.addEventListener("error", () => resolve(false), { once: true });
}),
aborted(),
]);
if (opened) socket.close();
await Promise.race([closed, aborted()]);
return { opened };
}
const child = spawn(process.argv[2] ?? "workerd", [
"serve", fileURLToPath(new URL("./config.capnp", import.meta.url)),
"--experimental", "--inspector-addr=127.0.0.1:0", "--control-fd=3",
], { stdio: ["ignore", "pipe", "pipe", "pipe"] });
const closure = new Promise((resolve) => child.once("close", (code, signal) => resolve({ code, signal })));
let stderr = "";
child.stderr.on("data", (chunk) => { stderr += chunk; });
let resolvePorts;
let rejectPorts;
const ready = new Promise((resolve, reject) => { resolvePorts = resolve; rejectPorts = reject; });
child.once("error", rejectPorts);
const ports = {};
const control = createInterface({ input: child.stdio[3] });
control.on("line", (line) => {
const message = JSON.parse(line);
if (message.event === "listen" && message.socket === "http") ports.http = message.port;
if (message.event === "listen-inspector") ports.inspector = message.port;
if (ports.http && ports.inspector) resolvePorts();
});
let result;
try {
await Promise.race([ready, closure.then((value) => { throw new Error(`Runtime closed before ready: ${JSON.stringify(value)} ${stderr}`); }), aborted()]);
const response = await fetch(`http://127.0.0.1:${ports.http}/`, { headers: { connection: "close" }, signal: controller.signal });
assert.equal(await response.text(), "ok");
const list = await fetch(`http://127.0.0.1:${ports.inspector}/json/list`, { headers: { connection: "close" }, signal: controller.signal }).then((r) => r.json());
const dynamic = list.find((target) => target.webSocketDebuggerUrl.includes("a/b/c/0.0"));
const main = list.find((target) => target.id === "main");
assert(dynamic, `Dynamic target missing: ${JSON.stringify(list)}`);
assert(main, `Main target missing: ${JSON.stringify(list)}`);
result = {
version: "1.20261102.1",
targets: list.map(({ id, webSocketDebuggerUrl }) => ({ id, webSocketDebuggerUrl })),
slashTarget: await connect(dynamic.webSocketDebuggerUrl),
mainControl: await connect(main.webSocketDebuggerUrl),
};
} catch (error) {
result = { failure: String(error) };
process.exitCode = 1;
} finally {
child.kill("SIGKILL");
const terminal = await closure;
control.close();
clearTimeout(timeout);
console.log(JSON.stringify({ ...result, stderr, terminal, stdio: child.stdio.map((stream) => stream === null ? null : { destroyed: stream.destroyed, closed: stream.closed }) }, null, 2));
}Observed result
{
"slashTarget": { "opened": false },
"mainControl": { "opened": true }
}Source explanation and suggested fix
The same release tag stores the full name in the isolate map at worker-factory-rpc.c++:364, and appends it raw to the advertised URL at line 331. The WebSocket handler takes only the segment after the last slash at line 275, so this name becomes 0.0. Its failed lookup returns 404 Unknown worker session.
Percent-encode the whole session key when constructing the URL and decode it on lookup, or match the full path after the inspector routing prefix. The internal isolate key should retain its full name.
I searched existing issues and PRs for inspector, WorkerLoader, slash, webSocketDebuggerUrl, and Unknown worker session without finding the same report.
Prepared and reproduced by Codex using gpt-6.1-sol.
Activity