paperclipai/paperclip · error

Timed out acquiring sandbox callback bridge response lock.

Error message

Timed out acquiring sandbox callback bridge response lock.

What it means

Thrown by the Node-side writeResponseFile (createLocalSandboxCallbackBridgeQueueClient) after 600 lock-acquisition attempts spaced 50ms apart (~30s total). The lock is a mkdir-based mutex with a PID-liveness check: a stale lock whose holder process is dead is reclaimed, but a live holder blocks the writer until the attempt budget is exhausted. The retry budget is hardcoded and intentionally finite to surface deadlocks rather than hang silently.

Source

Thrown at packages/adapter-utils/src/sandbox-callback-bridge.ts:456

          } catch {
            // pid file missing or unreadable — treat as stale lock
          }
          let holderAlive = false;
          if (holderPid !== null) {
            try {
              process.kill(holderPid, 0);
              holderAlive = true;
            } catch {
              holderAlive = false;
            }
          }
          if (!holderAlive) {
            await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
            continue;
          }
          attempts += 1;
          if (attempts >= 600) {
            throw new Error("Timed out acquiring sandbox callback bridge response lock.");
          }
          await new Promise((resolve) => setTimeout(resolve, 50));
        }
      }

      try {
        if (options.requestPath) {
          const requestExists = await pathExists(options.requestPath);
          if (!requestExists) {
            return { wrote: false };
          }
        }
        const responseExists = await pathExists(responsePath);
        if (responseExists) {
          return { wrote: false };
        }
        await fs.writeFile(tempPath, body, "utf8");
        await fs.rename(tempPath, responsePath);

View on GitHub (pinned to 67001ec6eb)

Solutions

  1. Inspect the lockDir at ${responsePath}.paperclip-write.lock/pid — if the recorded PID is unrelated to a real bridge writer, remove the lockDir manually to unblock.
  2. Reduce concurrent writers: serialize handlers per responsePath so only one writer contends for the lock.
  3. Investigate the prior holder: a handler stuck in fs.writeFile/fs.rename is the usual culprit; check Node event-loop blocking or filesystem latency.
  4. If this recurs, instrument the retry loop to log holderPid and holderAlive transitions to distinguish stale-lock recovery failure from genuine contention.

Example fix

// before: two concurrent handlers writing the same response
await Promise.all([
  bridge.writeResponseFile(respPath, bodyA, { requestPath }),
  bridge.writeResponseFile(respPath, bodyB, { requestPath }),
]);

// after: serialize per response path
const writer = createResponseSerializer(respPath);
await writer.enqueue(() => bridge.writeResponseFile(respPath, bodyA, { requestPath }));
await writer.enqueue(() => bridge.writeResponseFile(respPath, bodyB, { requestPath }));
Defensive patterns

Strategy: try-catch

Validate before calling

async function preflightLockDir(responsePath: string): Promise<void> {
  const lockDir = `${responsePath}.paperclip-write.lock`;
  try {
    await fs.mkdir(lockDir);
    await fs.rmdir(lockDir);
  } catch (error) {
    const code = (error as NodeJS.ErrnoException)?.code;
    if (code === "EEXIST") {
      const pidRaw = await fs.readFile(`${lockDir}/pid`, "utf8").catch(() => "");
      const pid = Number.parseInt(pidRaw.trim(), 10);
      if (Number.isFinite(pid) && pid > 0) {
        try { process.kill(pid, 0); throw new Error(`Stale-but-live lock holder ${pid} for ${responsePath}`); } catch { /* dead holder — will be reclaimed */ }
    }
    } else if (code !== "ENOENT") {
      throw error;
    }
  }
}

Try / catch

try {
  await bridge.writeResponseFile(responsePath, body, { requestPath });
} catch (error) {
  if (error instanceof Error && error.message === "Timed out acquiring sandbox callback bridge response lock.") {
    // Best-effort recovery: clear the lock dir if the recorded holder is no longer alive, then retry once.
    const lockDir = `${responsePath}.paperclip-write.lock`;
    await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
    await bridge.writeResponseFile(responsePath, body, { requestPath });
  } else {
    throw error;
  }
}

Prevention

When it happens

Trigger: Two bridge writers (or a writer and a wedged holder) racing for the same responsePath, with the holder still alive (process.kill(holderPid, 0) succeeds) for the full 30s window. A long-running handler that holds the lock dir without releasing it, or a PID-reuse scenario where the holder PID was reassigned to a different live process, will both exhaust the budget.

Common situations: Concurrent bridge handlers writing the same response file (misconfigured deduplication), a previous writer that crashed mid-section but left a stale PID file pointing at a still-running process, or system load so high that the holder does not get CPU time to release the lock within 30s. PID reuse on systems that recycle PIDs aggressively can also pin the lock to an unrelated live process.

Understand the failure class

Related errors


AI-assisted analysis of paperclipai/paperclip@67001ec6eb (2026-08-12). Data as JSON: /api/errors/d29cf094e0b81519. Report an issue: GitHub.