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
- Identify what still holds the target: check ps for the pid/pgid and run readLocalServicePortOwner(port) to see who owns the listener.
- 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.
- Manually kill stragglers: kill -9 <pid> or kill -9 -<pgid>, then re-run termination to clean the registry record.
- For slow-to-die services, pass larger opts.forceAfterMs/verifyAfterMs so the graceful window is realistic.
- 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
- Record processGroupId for services that spawn children so termination targets the whole group.
- Give graceful shutdown realistic windows via forceAfterMs for slow-draining services.
- Assign unique ports per service to avoid the port-owner false-positive path.
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
- [paperclip] sandbox callback bridge failed to abort queued r
- Refusing embedded PostgreSQL recovery because the data direc
- ${errorMessage(error)} Cleanup also failed for newly-created
- Could not clean managed GitHub launchers
AI-assisted analysis of paperclipai/paperclip@a7e689b3c3 (2026-08-21).
Data as JSON: /api/errors/fb01150a633b3958.
Report an issue: GitHub.