paperclipai/paperclip · error

Durable PRP control plane is not listening.

Error message

Durable PRP control plane is not listening.

What it means

The connectUrl getter returns the ws://127.0.0.1:<port>/durableRecovery/connect URL for the control plane. It throws when the internal HTTP server has not been started (port is null), because there is no endpoint to connect to yet.

Source

Thrown at packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts:1532

  getCommand(commandId: string): DurableRecoveryCoreCommand | undefined {
    return (
      this.#store.state.commands.find(
        (command) => command.commandId === commandId,
      ) ??
      (this.#store.state.warmTransition?.command.commandId === commandId
        ? this.#store.state.warmTransition.command
        : undefined) ??
      (this.#store.state.completedWarmTransition?.command.commandId ===
      commandId
        ? this.#store.state.completedWarmTransition.command
        : undefined)
    );
  }

  get connectUrl(): string {
    if (this.#port === null) {
      throw new Error("Durable PRP control plane is not listening.");
    }
    return `ws://127.0.0.1:${this.#port}/durableRecovery/connect`;
  }

  async start(port = 0): Promise<void> {
    if (this.#server !== null) {
      throw new Error("Durable PRP control plane is already running.");
    }
    const server = createServer((_request, response) => {
      response.writeHead(404).end();
    });
    this.#server = server;
    server.on("upgrade", (request, socket, head) =>
      this.handleUpgrade(request, socket, "/durableRecovery/connect", head),
    );
    await new Promise<void>((resolveListen, rejectListen) => {
      server.once("error", rejectListen);
      server.listen(port, "127.0.0.1", () => {

View on GitHub (pinned to 01ad858492)

Solutions

  1. Call `await controlPlane.start()` (or start(port)) before reading connectUrl.
  2. Use the value returned by start()/the port it bound rather than caching connectUrl from before startup.
  3. Check start() errors: a bind failure (EADDRINUSE) leaves the plane not listening — pick a free port.
  4. Re-start the server after a stop() before handing the URL to recovery clients.

Example fix

// before
const url = controlPlane.connectUrl;
await controlPlane.start();
// after
await controlPlane.start();
const url = controlPlane.connectUrl;
Defensive patterns

Strategy: try-catch

Validate before calling

if (!controlPlane.isListening) throw new Error('call start() before obtaining connectUrl');

Try / catch

let url: string;
try {
  url = controlPlane.connectUrl;
} catch (e) {
  if (e.message.includes('not listening')) {
    await controlPlane.start();
    url = controlPlane.connectUrl;
  } else throw e;
}

Prevention

When it happens

Trigger: Reading `connectUrl` before calling start(), or after the server was stopped/failed to bind, while `#port` remains null.

Common situations: Client code constructs the control plane and reads connectUrl immediately instead of awaiting start(); start() threw on bind failure (e.g. port in use) so port was never set; server was stopped then connectUrl read.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10). Data as JSON: /api/errors/8cf6c11edb5ac423. Report an issue: GitHub.