{"record":{"id":"fb01150a633b3958","repo":"paperclipai/paperclip","slug":"failed-to-terminate-local-service-target-liste","errorCode":null,"errorMessage":"Failed to terminate local service ${target}${listener}","messagePattern":"Failed to terminate local service (.+?)(.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"server/src/services/local-service-supervisor.ts","lineNumber":604,"sourceCode":"\n  if (await waitUntilGone(opts?.forceAfterMs ?? 2_000)) return;\n  try {\n    if (targetProcessGroup) {\n      process.kill(-record.processGroupId!, \"SIGKILL\");\n    } else {\n      process.kill(record.pid, \"SIGKILL\");\n    }\n  } catch {\n    // Ignore cleanup races.\n  }\n\n  if (await waitUntilGone(opts?.verifyAfterMs ?? 2_000)) return;\n\n  const target = targetProcessGroup\n    ? `process group ${record.processGroupId}`\n    : `process ${record.pid}`;\n  const listener = record.port ? ` and listener on port ${record.port}` : \"\";\n  throw new Error(`Failed to terminate local service ${target}${listener}`);\n}\n\nexport async function readLocalServicePortOwner(port: number) {\n  if (!Number.isInteger(port) || port <= 0) return null;\n  try {\n    if (process.platform === \"win32\") {\n      const { stdout } = await execFileAsync(\"netstat\", [\"-ano\", \"-p\", \"tcp\"]);\n      for (const line of stdout.split(/\\r?\\n/)) {\n        const columns = line.trim().split(/\\s+/);\n        if (columns.length < 5 || columns[0]?.toUpperCase() !== \"TCP\") continue;\n        const localAddress = columns[1] ?? \"\";\n        const separatorIndex = localAddress.lastIndexOf(\":\");\n        const localPort = Number.parseInt(localAddress.slice(separatorIndex + 1), 10);\n        const state = columns.at(-2)?.toUpperCase();\n        const pid = Number.parseInt(columns.at(-1) ?? \"\", 10);\n        if (localPort === port && state === \"LISTENING\" && Number.isInteger(pid) && pid > 0) {\n          return pid;\n        }","sourceCodeStart":586,"sourceCodeEnd":622,"githubUrl":"https://github.com/paperclipai/paperclip/blob/a7e689b3c35347b529cb9f54c9b9a8575a3dcab6/server/src/services/local-service-supervisor.ts#L586-L622","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","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)."],"exampleFix":"// before\nawait terminateLocalService({ pid: rec.pid, processGroupId: rec.processGroupId, port: rec.port }); // throws\n\n// after\ntry {\n  await terminateLocalService(rec, { forceAfterMs: 5000, verifyAfterMs: 5000 });\n} catch {\n  const owner = await readLocalServicePortOwner(rec.port);\n  if (owner && owner !== rec.pid) {\n    // port re-used by another process; original service is gone — safe to continue\n  } else {\n    process.kill(-rec.processGroupId, \"SIGKILL\"); // last-resort manual escalation\n  }\n}","handlingStrategy":"try-catch","validationCode":"const stillAlive = isPidAlive(rec.pid) || (rec.processGroupId ? isProcessGroupAlive(rec.processGroupId) : false);\nconst portOwner = rec.port ? await readLocalServicePortOwner(rec.port) : null;\nif (!stillAlive && (!portOwner || !(await isLocalServiceProcessOwnedBy(portOwner, rec.pid)))) { /* already gone; skip terminate */ }","typeGuard":null,"tryCatchPattern":"try {\n  await terminateLocalService(rec, { forceAfterMs: 5000, verifyAfterMs: 5000 });\n} catch (err) {\n  if (!/Failed to terminate local service/.test((err as Error).message)) throw err;\n  const owner = await readLocalServicePortOwner(rec.port);\n  if (owner && owner !== rec.pid) { /* port re-used by an unrelated process; original is gone */ }\n  else process.kill(rec.processGroupId ? -rec.processGroupId : rec.pid, \"SIGKILL\");\n}","preventionTips":["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."],"tags":["process-management","sigkill","port-in-use","supervisor","cleanup"],"backgroundTag":"process-kill-failed","analyzedSha":"a7e689b3c35347b529cb9f54c9b9a8575a3dcab6","analyzedAt":"2026-08-21T17:58:32.592Z","contentChangedAt":"2026-08-21T17:58:32.592Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}