Skip to content

Inspector cannot attach to WorkerLoader names containing '/' #8142

Open

Description

@JustYannicc

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions