paperclipai/paperclip · error

Failed to terminate local service ${target}${listener}

Error message

Failed to terminate local service ${target}${listener}

What it means

Thrown by terminateLocalService after escalation failed: it sends SIGTERM, waits up to forceAfterMs (default 2s), sends SIGKILL to the pid or process group, then waits verifyAfterMs (default 2s) more; if the target is still alive or the recorded port is still owned by the target, it throws (server/src/services/local-service-supervisor.ts:604). Note the port check passes only if the port owner belongs to the target pid/pgid — an unrelated process squatting the port also triggers the throw.

Source

Thrown at server/src/services/local-service-supervisor.ts:604

  if (await waitUntilGone(opts?.forceAfterMs ?? 2_000)) return;
  try {
    if (targetProcessGroup) {
      process.kill(-record.processGroupId!, "SIGKILL");
    } else {
      process.kill(record.pid, "SIGKILL");
    }
  } catch {
    // Ignore cleanup races.
  }

  if (await waitUntilGone(opts?.verifyAfterMs ?? 2_000)) return;

  const target = targetProcessGroup
    ? `process group ${record.processGroupId}`
    : `process ${record.pid}`;
  const listener = record.port ? ` and listener on port ${record.port}` : "";
  throw new Error(`Failed to terminate local service ${target}${listener}`);
}

export async function readLocalServicePortOwner(port: number) {
  if (!Number.isInteger(port) || port <= 0) return null;
  try {
    if (process.platform === "win32") {
      const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"]);
      for (const line of stdout.split(/\r?\n/)) {
        const columns = line.trim().split(/\s+/);
        if (columns.length < 5 || columns[0]?.toUpperCase() !== "TCP") continue;
        const localAddress = columns[1] ?? "";
        const separatorIndex = localAddress.lastIndexOf(":");
        const localPort = Number.parseInt(localAddress.slice(separatorIndex + 1), 10);
        const state = columns.at(-2)?.toUpperCase();
        const pid = Number.parseInt(columns.at(-1) ?? "", 10);
        if (localPort === port && state === "LISTENING" && Number.isInteger(pid) && pid > 0) {
          return pid;
        }

View on GitHub (pinned to a7e689b3c3)

Solutions

  1. Identify what still holds the target: check ps for the pid/pgid and run readLocalServicePortOwner(port) to see who owns the listener.
  2. If an unrelated process owns the port, kill that process (or reconfigure ports) — the original service is already gone and the throw is a false alarm from the port check.
  3. Manually kill stragglers: kill -9 <pid> or kill -9 -<pgid>, then re-run termination to clean the registry record.
  4. For slow-to-die services, pass larger opts.forceAfterMs/verifyAfterMs so the graceful window is realistic.
  5. Fix services that spawn detached children so the whole process group is targeted (record processGroupId).

Example fix

// before
await terminateLocalService({ pid: rec.pid, processGroupId: rec.processGroupId, port: rec.port }); // throws

// after
try {
  await terminateLocalService(rec, { forceAfterMs: 5000, verifyAfterMs: 5000 });
} catch {
  const owner = await readLocalServicePortOwner(rec.port);
  if (owner && owner !== rec.pid) {
    // port re-used by another process; original service is gone — safe to continue
  } else {
    process.kill(-rec.processGroupId, "SIGKILL"); // last-resort manual escalation
  }
}
Defensive patterns

Strategy: try-catch

Validate before calling

const stillAlive = isPidAlive(rec.pid) || (rec.processGroupId ? isProcessGroupAlive(rec.processGroupId) : false);
const portOwner = rec.port ? await readLocalServicePortOwner(rec.port) : null;
if (!stillAlive && (!portOwner || !(await isLocalServiceProcessOwnedBy(portOwner, rec.pid)))) { /* already gone; skip terminate */ }

Try / catch

try {
  await terminateLocalService(rec, { forceAfterMs: 5000, verifyAfterMs: 5000 });
} catch (err) {
  if (!/Failed to terminate local service/.test((err as Error).message)) throw err;
  const owner = await readLocalServicePortOwner(rec.port);
  if (owner && owner !== rec.pid) { /* port re-used by an unrelated process; original is gone */ }
  else process.kill(rec.processGroupId ? -rec.processGroupId : rec.pid, "SIGKILL");
}

Prevention

When it happens

Trigger: Calling terminateLocalService({ pid, processGroupId, port }) where the process ignores SIGTERM and survives SIGKILL (stuck in uninterruptible I/O, zombie not reaped by its parent), or where the port is now owned by a different process (PID reuse, another service binding the same port after the old one died).

Common situations: Child of the service holding the port after the group leader died; process stuck in D state on NFS; zombie pid whose parent is not reaping; a second service started on the same port; Windows/netstat parsing edge cases making the owner lookup return a stale pid.

Related errors


AI-assisted analysis of paperclipai/paperclip@a7e689b3c3 (2026-08-21). Data as JSON: /api/errors/fb01150a633b3958. Report an issue: GitHub.