Files
PaperClipAI/server/src/services/local-service-supervisor.ts
T
14027df09e fix(workspaces): read process cwd on macOS so port-owner adoption works (#11763)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A workspace can run a shared local service, such as a dev server, on
an automatic port
> - Paperclip adopts a live service again after it loses the runtime
registry state
> - Paperclip must first prove that the port owner runs inside the
workspace
> - Linux reads the process working directory from `/proc/<pid>/cwd`
> - macOS has no `/proc`, so the check returned `null` and adoption
always failed
> - This pull request reads the process working directory with `lsof` on
macOS
> - The benefit is that macOS keeps a healthy live service after startup
reconciliation, instead of recording it as stopped

## Linked Issues or Issue Description

Closes #9911. That pull request reports the same defect and was opened
first, on 2026-07-20. Its checks have been red since that day, because
its inline issue description does not use the label format the gate
parses. It has had no author activity since. This pull request keeps
that author's test-fixture commit, with the author unchanged, and adds
NUL-delimited parsing, adoption-boundary tests, and fail-closed Darwin
registry handling. Maintainers may prefer to land #9911 instead. I will
close this one again if they do.

This pull request replaces #11600, which I closed earlier as a
duplicate. It carries the same work, rebased onto current `master`, with
the review feedback from that pull request applied.

No public issue exists. The problem follows.

**What happened?**

On macOS, `readLocalServiceProcessCwd` returned `null`. Startup
reconciliation found a live port owner, but it could not verify the
working directory. It rejected the candidate and recorded the live
service as stopped.

**Expected behavior**

Paperclip adopts a healthy port owner when the working directory is
inside the requested workspace. Paperclip rejects the process when the
working directory is outside the workspace, or when it cannot be read.

**Steps to reproduce**

1. Build Paperclip from source on macOS.
2. Start a shared workspace runtime service on an automatic port.
3. Remove the runtime registry state while the service stays alive.
4. Run startup reconciliation.
5. Read the result. Unpatched `master` reports `adopted: 0` and
`stopped: 1`.

**Paperclip version or commit**

This branch is based on `master` at
`7c8064da1b35527865c1d523c9f0016e304ae46d`.

**Deployment mode**

Local development from source.

**Installation method**

Built from source with pnpm.

**Operating system**

macOS 26.4, Darwin 25.4.0, arm64.

**Node.js version**

Node.js 22.22.2 on macOS. Node.js 24.19.0 on Linux. pnpm 9.15.4.

## Darwin Registry Adoption Now Fails Closed

This pull request changes one existing Darwin registry-adoption behavior
in addition to enabling port-owner adoption.

Before this change, `readLocalServiceProcessCwd` always returned `null`
on Darwin. `isLocalServiceRegistryCwdCompatible` treated a null cwd as
compatible on every non-Linux platform, so a service with an existing
registry record could still be adopted when its port owner, process
group, and command matched, even though Paperclip had not verified the
process's real working directory.

Darwin can now inspect the process cwd through `lsof`. If that
inspection returns `null` — including a missing `lsof`, a command
failure, or missing cwd output — registry-backed adoption now fails
closed and the stale registry record is removed.

This is a deliberate behavior change. It prevents a failed Darwin cwd
probe from silently falling back to trusting stored registry metadata.
The no-registry port-owner path already rejected a null cwd before this
pull request, so its failure behavior has not changed.

## What Changed

- Add a Darwin branch to `readLocalServiceProcessCwd`.
- Run `lsof -a -d cwd -p <pid> -F0n` to read the process working
directory.
- Parse the NUL-delimited field output.
- Do not trim the path. Do not split it on newlines. A directory name
can contain a trailing space or a newline, and a changed path would name
a different directory.
- Keep the Linux `/proc/<pid>/cwd` path unchanged.
- Return `null` for an invalid pid, a missing `lsof`, a command error,
or missing output.
- Reject a Darwin registry record when the working directory cannot be
read. Darwin can now read it, so a failed read means the check failed.
It no longer means the platform has no way to check.
- Keep the registry fallback only on platforms that cannot read a
process working directory.
- Run the existing foreign-workspace rejection test on macOS.
- Add a test: Paperclip adopts a port owner inside the workspace when no
registry record exists.
- Add a test: Paperclip rejects a listener in a sibling directory that
differs only by a trailing space.
- Add helper tests for newline and whitespace parsing, an invalid pid,
and a missing `lsof` binary.
- Resolve the branch-containment temporary repository root before the
path comparison. This test-only commit comes from #9911 and keeps its
author.

## Verification

Head of this branch: `2be1b74746d8a0db4b680062f0c57995a6ff3912`.

**Linux, on this head**

```sh
pnpm --filter @paperclipai/server exec vitest run \
  src/__tests__/workspace-runtime.test.ts \
  src/__tests__/heartbeat-workspace-branch-containment.test.ts
```

Result: 138/138 pass. `workspace-runtime.test.ts` is 132/132.
`heartbeat-workspace-branch-containment.test.ts` is 6/6.

**macOS, on this head**

macOS 26.4, Darwin 25.4.0, arm64, Node.js 22.22.2, pnpm 9.15.4.

- Controlled baseline: `workspace runtime startup reconciliation >
adopts a live auto-port shared service after runtime state is reset`
fails on the rebase base `7c8064da1b35527865c1d523c9f0016e304ae46d` and
reports `adopted: 0`, `stopped: 1`. The same test passes on this head.
That test uses the normal managed start path, which starts the service
detached.
- Focused working-directory, registry, adoption, and boundary tests: 8/8
pass.
- `heartbeat-workspace-branch-containment.test.ts`: 6/6 pass. Two
assertions failed before the fixture change, because `/var/...` and
`/private/var/...` name the same macOS directory.
- Server typecheck: pass.
- Full `workspace-runtime.test.ts`: 131/132 pass.

The one failure is `realizeExecutionWorkspace > records teardown and
cleanup operations when a recorder is provided`:

```text
expected: /var/folders/...
received: /private/var/folders/...
```

I ran that same test alone on the rebase base `7c8064da`, with no patch
applied, and got the identical failure. It is a pre-existing macOS
fixture that builds a path from `os.tmpdir()` and compares it against a
realpath. It does not run the changed adoption path. This description
does not claim the whole file is green on macOS.

**macOS listener evidence**

In the `adopts a port owner running inside the workspace when the
registry record is gone` scenario, the auto-port listener bound port
`54360`:

```text
COMMAND  PID   USER          FD   TYPE  DEVICE              SIZE/OFF NODE NAME
node    6808   <local-user>  12u  IPv4  0xee4e36b2c8c094cf  0t0      TCP 127.0.0.1:54360 (LISTEN)
```

To hold the listener open long enough to capture this, that one
diagnostic run added a temporary pause, which exceeded the Vitest
timeout. The pause was reverted, the unmodified test was run again on
this head, and it passed 1/1. The process and the port were then
released.

Note for maintainers: an existing test already covered this defect. That
test never runs on macOS, because CI runs on Linux. A macOS job would
have caught it in July.

## Risks

Low risk.

- Linux keeps the existing procfs implementation.
- Other platforms keep the existing registry fallback.
- macOS makes one extra `lsof` call, and only when it must read a
process working directory.
- A probe failure returns `null`.
- Darwin port-owner adoption and Darwin registry adoption both fail
closed.
- The parser keeps significant whitespace and embedded newlines.
- There is no database migration and no API change.

## Model Used

Claude Opus 5 (`claude-opus-5`), with extended thinking, tool use, and
code execution. It wrote the original implementation and the adoption
tests, reviewed the branch, ran the Linux test suite, rebased onto
current `master`, and prepared this text. OpenAI GPT-5.6-sol, through
Hermes Agent, added the failure-mode coverage and ran the macOS checks.
A human reviewed the change and controls publication.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (see Verification for the
one disclosed macOS baseline failure)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes (this
change affects an internal helper and tests only)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: tim <tf00185077@i-mps.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: scbailey-build <scott@bequall.com>
2026-08-21 16:03:43 -07:00

711 lines
24 KiB
TypeScript

import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { promisify } from "node:util";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
const execFileAsync = promisify(execFile);
export interface LocalServiceRegistryRecord {
version: 1;
serviceKey: string;
profileKind: string;
serviceName: string;
command: string;
cwd: string;
envFingerprint: string;
port: number | null;
url: string | null;
pid: number;
processGroupId: number | null;
provider: "local_process";
runtimeServiceId: string | null;
reuseKey: string | null;
startedAt: string;
lastSeenAt: string;
metadata: Record<string, unknown> | null;
}
export interface LocalServiceIdentityInput {
profileKind: string;
serviceName: string;
cwd: string;
command: string;
envFingerprint: string;
port: number | null;
scope: Record<string, unknown> | null;
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
}
if (value && typeof value === "object") {
const rec = value as Record<string, unknown>;
return `{${Object.keys(rec).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(rec[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function sanitizeServiceKeySegment(value: string, fallback: string): string {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
return normalized || fallback;
}
function getRuntimeServicesDir() {
return path.resolve(resolvePaperclipInstanceRoot(), "runtime-services");
}
function getRuntimeServiceLogsDir() {
return path.resolve(resolvePaperclipInstanceRoot(), "runtime-service-logs");
}
export function resolveLocalServiceLogPath(serviceKey: string) {
if (!/^[a-z0-9._-]+$/.test(serviceKey)) {
throw new Error("Invalid local service key for log path");
}
return path.resolve(getRuntimeServiceLogsDir(), `${serviceKey}.log`);
}
/**
* Open a managed service's durable append-only output file.
*
* The returned descriptor is intended to be passed directly to spawn(). The
* child receives its own duplicate, so the caller can close this handle as soon
* as spawn returns without tying the service's stdio lifetime to Paperclip's.
*/
export async function openLocalServiceLogFile(serviceKey: string) {
await fs.mkdir(getRuntimeServiceLogsDir(), { recursive: true });
const logPath = resolveLocalServiceLogPath(serviceKey);
const handle = await fs.open(logPath, "a+", 0o600);
const startOffset = (await handle.stat()).size;
return { handle, logPath, startOffset };
}
function getRuntimeServiceRegistryPath(serviceKey: string) {
return path.resolve(getRuntimeServicesDir(), `${serviceKey}.json`);
}
function normalizeRegistryRecord(raw: unknown): LocalServiceRegistryRecord | null {
if (!raw || typeof raw !== "object") return null;
const rec = raw as Record<string, unknown>;
if (
rec.version !== 1 ||
typeof rec.serviceKey !== "string" ||
typeof rec.profileKind !== "string" ||
typeof rec.serviceName !== "string" ||
typeof rec.command !== "string" ||
typeof rec.cwd !== "string" ||
typeof rec.envFingerprint !== "string" ||
typeof rec.pid !== "number"
) {
return null;
}
return {
version: 1,
serviceKey: rec.serviceKey,
profileKind: rec.profileKind,
serviceName: rec.serviceName,
command: rec.command,
cwd: rec.cwd,
envFingerprint: rec.envFingerprint,
port: typeof rec.port === "number" ? rec.port : null,
url: typeof rec.url === "string" ? rec.url : null,
pid: rec.pid,
processGroupId: typeof rec.processGroupId === "number" ? rec.processGroupId : null,
provider: "local_process",
runtimeServiceId: typeof rec.runtimeServiceId === "string" ? rec.runtimeServiceId : null,
reuseKey: typeof rec.reuseKey === "string" ? rec.reuseKey : null,
startedAt: typeof rec.startedAt === "string" ? rec.startedAt : new Date().toISOString(),
lastSeenAt: typeof rec.lastSeenAt === "string" ? rec.lastSeenAt : new Date().toISOString(),
metadata:
rec.metadata && typeof rec.metadata === "object" && !Array.isArray(rec.metadata)
? (rec.metadata as Record<string, unknown>)
: null,
};
}
async function safeReadRegistryRecord(filePath: string) {
try {
const raw = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
return normalizeRegistryRecord(raw);
} catch {
return null;
}
}
export function createLocalServiceKey(input: LocalServiceIdentityInput) {
const digest = createHash("sha256")
.update(
stableStringify({
profileKind: input.profileKind,
serviceName: input.serviceName,
cwd: path.resolve(input.cwd),
command: input.command,
envFingerprint: input.envFingerprint,
port: input.port,
scope: input.scope ?? null,
}),
)
.digest("hex")
.slice(0, 24);
return `${sanitizeServiceKeySegment(input.profileKind, "service")}-${sanitizeServiceKeySegment(input.serviceName, "service")}-${digest}`;
}
export async function writeLocalServiceRegistryRecord(record: LocalServiceRegistryRecord) {
await fs.mkdir(getRuntimeServicesDir(), { recursive: true });
await fs.writeFile(
getRuntimeServiceRegistryPath(record.serviceKey),
`${JSON.stringify(record, null, 2)}\n`,
"utf8",
);
}
export async function removeLocalServiceRegistryRecord(serviceKey: string) {
await fs.rm(getRuntimeServiceRegistryPath(serviceKey), { force: true });
}
export async function readLocalServiceRegistryRecord(serviceKey: string) {
return await safeReadRegistryRecord(getRuntimeServiceRegistryPath(serviceKey));
}
export async function listLocalServiceRegistryRecords(filter?: {
profileKind?: string;
metadata?: Record<string, unknown>;
}) {
try {
const entries = await fs.readdir(getRuntimeServicesDir(), { withFileTypes: true });
const records = await Promise.all(
entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
.map((entry) => safeReadRegistryRecord(path.resolve(getRuntimeServicesDir(), entry.name))),
);
return records
.filter((record): record is LocalServiceRegistryRecord => record !== null)
.filter((record) => {
if (filter?.profileKind && record.profileKind !== filter.profileKind) return false;
if (!filter?.metadata) return true;
return Object.entries(filter.metadata).every(([key, value]) => record.metadata?.[key] === value);
})
.sort((left, right) => left.serviceKey.localeCompare(right.serviceKey));
} catch {
return [];
}
}
export async function findLocalServiceRegistryRecordByRuntimeServiceId(input: {
runtimeServiceId: string;
profileKind?: string;
}) {
const records = await listLocalServiceRegistryRecords(
input.profileKind ? { profileKind: input.profileKind } : undefined,
);
const record = records.find((entry) => entry.runtimeServiceId === input.runtimeServiceId) ?? null;
if (!record) return null;
let candidate = record;
if (!isPidAlive(candidate.pid)) {
const ownerPid = candidate.port ? await readLocalServicePortOwner(candidate.port) : null;
if (!ownerPid) {
await removeLocalServiceRegistryRecord(candidate.serviceKey);
return null;
}
candidate = {
...candidate,
pid: ownerPid,
processGroupId: candidate.processGroupId && isPidAlive(candidate.processGroupId) ? candidate.processGroupId : ownerPid,
lastSeenAt: new Date().toISOString(),
};
await writeLocalServiceRegistryRecord(candidate);
}
if (!(await isLikelyMatchingCommand(candidate))) {
await removeLocalServiceRegistryRecord(record.serviceKey);
return null;
}
if (!(await doesLocalServiceRecordMatchCwd(candidate))) {
await removeLocalServiceRegistryRecord(record.serviceKey);
return null;
}
return candidate;
}
export function isPidAlive(pid: number) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export function isProcessGroupAlive(processGroupId: number | null | undefined) {
if (process.platform === "win32") return false;
if (typeof processGroupId !== "number" || !Number.isInteger(processGroupId) || processGroupId <= 0) return false;
try {
process.kill(-processGroupId, 0);
} catch {
return false;
}
if (process.platform === "linux") {
const liveMember = readLinuxProcessGroupActivity(processGroupId);
if (liveMember !== null) return liveMember;
}
return true;
}
function readLinuxProcessGroupActivity(processGroupId: number): boolean | null {
let entries: fsSync.Dirent[];
try {
entries = fsSync.readdirSync("/proc", { withFileTypes: true });
} catch {
return null;
}
let foundMember = false;
for (const entry of entries) {
if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
try {
const stat = fsSync.readFileSync(`/proc/${entry.name}/stat`, "utf8");
const commandEnd = stat.lastIndexOf(")");
if (commandEnd < 0) continue;
const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
const state = fields[0];
const memberProcessGroupId = Number.parseInt(fields[2] ?? "", 10);
if (memberProcessGroupId !== processGroupId) continue;
foundMember = true;
if (state !== "Z" && state !== "X") return true;
} catch {
// The process can exit while /proc is scanned.
}
}
// kill(-pgid, 0) also succeeds for a group that contains only zombies. Such
// processes cannot run or own a listener and are waiting only for their
// parent to reap them, so termination is complete for service-control use.
return foundMember ? false : null;
}
function tokenizeCommandLine(value: string) {
return value.match(/"(?:\\.|[^"\\])*"|'[^']*'|\S+/g) ?? [];
}
function normalizeCommandToken(value: string) {
const unquoted = value.replace(/^["']|["']$/g, "");
const basename = path.basename(unquoted.replace(/\\/g, "/"));
const launcher = basename.replace(/\.(?:cjs|mjs|js|cmd|exe)$/i, "");
return /^(?:bun|node|nodejs|npm|npx|pnpm|yarn)$/i.test(launcher) ? launcher : unquoted;
}
/**
* Return whether the configured shell command has a stable argv that can be
* compared with the operating system's process command line.
*
* Managed local services are started through `shell -lc`. Once a command uses
* shell control syntax, the surviving process-group leader can be the result of
* that program rather than the configured shell expression. In that case a
* literal argv comparison is not evidence that the process belongs to a
* different service; adoption instead relies on the listener, process group,
* and workspace cwd checks.
*/
export function isLocalServiceCommandLineComparable(recordedCommand: string) {
let quote: "'" | '"' | null = null;
let escaped = false;
for (const character of recordedCommand) {
if (escaped) {
escaped = false;
continue;
}
if (character === "\\" && quote !== "'") {
escaped = true;
continue;
}
if (quote) {
if (character === quote) quote = null;
continue;
}
if (character === "'" || character === '"') {
quote = character;
continue;
}
if ([";", "|", "&", "<", ">", "\n"].includes(character)) {
return false;
}
}
return true;
}
/**
* Compare a configured service command with the argv exposed by the OS.
*
* Package-manager launchers commonly replace `pnpm dev` with
* `node /path/to/pnpm.cjs dev` after the shell starts. A literal substring
* check rejects that surviving process even though the executable and all
* configured arguments are still present. Normalize executable paths and
* script extensions, then require the configured argv to remain contiguous.
*/
export function doesLocalServiceCommandLineMatch(input: {
commandLine: string;
recordedCommand: string;
serviceName: string;
}) {
const normalize = (value: string) => value.replace(/["']/g, "").replace(/\s+/g, " ").trim();
const normalizedCommandLine = normalize(input.commandLine);
const normalizedRecordedCommand = normalize(input.recordedCommand);
if (
normalizedCommandLine.includes(normalizedRecordedCommand)
|| normalizedCommandLine.includes(input.serviceName)
) {
return true;
}
const actualTokens = tokenizeCommandLine(input.commandLine).map(normalizeCommandToken);
const recordedTokens = tokenizeCommandLine(input.recordedCommand).map(normalizeCommandToken);
if (recordedTokens.length === 0 || recordedTokens.length > actualTokens.length) return false;
return actualTokens.some((_, start) => recordedTokens.every(
(token, offset) => actualTokens[start + offset] === token,
));
}
async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) {
if (process.platform === "win32") return true;
if (!isLocalServiceCommandLineComparable(record.command)) return true;
try {
const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]);
const commandLine = stdout.trim();
if (!commandLine) return false;
return doesLocalServiceCommandLineMatch({
commandLine,
recordedCommand: record.command,
serviceName: record.serviceName,
});
} catch {
return true;
}
}
export async function findAdoptableLocalService(input: {
serviceKey: string;
profileKind?: string | null;
serviceName?: string | null;
command?: string | null;
cwd?: string | null;
envFingerprint?: string | null;
port?: number | null;
url?: string | null;
}) {
const record =
await readLocalServiceRegistryRecord(input.serviceKey)
?? await adoptLocalServiceFromPortOwner(input);
if (!record) return null;
if (!isPidAlive(record.pid)) {
await removeLocalServiceRegistryRecord(input.serviceKey);
return null;
}
if (!(await isLikelyMatchingCommand(record))) {
await removeLocalServiceRegistryRecord(input.serviceKey);
return null;
}
if (!(await doesLocalServiceRecordMatchCwd(record))) {
await removeLocalServiceRegistryRecord(input.serviceKey);
return null;
}
if (input.command && record.command !== input.command) return null;
if (input.cwd && path.resolve(record.cwd) !== path.resolve(input.cwd)) return null;
if (input.envFingerprint && record.envFingerprint !== input.envFingerprint) return null;
if (input.port !== undefined && input.port !== null && record.port !== input.port) return null;
return record;
}
export async function readLocalServiceProcessGroupId(pid: number) {
if (process.platform === "win32") return null;
try {
const { stdout } = await execFileAsync("ps", ["-o", "pgid=", "-p", String(pid)]);
const parsed = Number.parseInt(stdout.trim(), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
} catch {
return null;
}
}
export async function isLocalServiceProcessOwnedBy(pid: number, ownerProcessId: number) {
if (pid === ownerProcessId) return true;
if (process.platform !== "win32") {
return (await readLocalServiceProcessGroupId(pid)) === ownerProcessId;
}
try {
const script = [
`$currentProcessId = ${pid}`,
"while ($currentProcessId -gt 0) {",
" $process = Get-CimInstance Win32_Process -Filter \"ProcessId = $currentProcessId\" -ErrorAction SilentlyContinue",
" if ($null -eq $process) { break }",
" $parentProcessId = [int]$process.ParentProcessId",
" Write-Output $parentProcessId",
" if ($parentProcessId -eq $currentProcessId) { break }",
" $currentProcessId = $parentProcessId",
"}",
].join("\n");
const { stdout } = await execFileAsync("powershell.exe", [
"-NoProfile",
"-NonInteractive",
"-Command",
script,
]);
return stdout
.split(/\r?\n/)
.map((line) => Number.parseInt(line.trim(), 10))
.some((ancestorPid) => ancestorPid === ownerProcessId);
} catch {
return false;
}
}
async function adoptLocalServiceFromPortOwner(input: {
serviceKey: string;
profileKind?: string | null;
serviceName?: string | null;
command?: string | null;
cwd?: string | null;
envFingerprint?: string | null;
port?: number | null;
url?: string | null;
}) {
if (!input.port) return null;
const ownerPid = await readLocalServicePortOwner(input.port);
if (!ownerPid) return null;
if (input.cwd) {
const ownerCwd = await readLocalServiceProcessCwd(ownerPid);
if (!ownerCwd || !(await isLocalServiceProcessInWorkspace(ownerCwd, input.cwd))) {
return null;
}
}
const processGroupId = await readLocalServiceProcessGroupId(ownerPid);
const pid = processGroupId && isPidAlive(processGroupId) ? processGroupId : ownerPid;
const now = new Date().toISOString();
const record: LocalServiceRegistryRecord = {
version: 1,
serviceKey: input.serviceKey,
profileKind: input.profileKind ?? "workspace-runtime",
serviceName: input.serviceName ?? "service",
command: input.command ?? input.serviceName ?? "service",
cwd: input.cwd ?? process.cwd(),
envFingerprint: input.envFingerprint ?? "",
port: input.port,
url: input.url ?? null,
pid,
processGroupId: processGroupId ?? pid,
provider: "local_process",
runtimeServiceId: null,
reuseKey: input.envFingerprint ?? null,
startedAt: now,
lastSeenAt: now,
metadata: null,
};
if (!(await isLikelyMatchingCommand(record))) return null;
await writeLocalServiceRegistryRecord(record);
return record;
}
export async function touchLocalServiceRegistryRecord(
serviceKey: string,
patch?: Partial<Omit<LocalServiceRegistryRecord, "serviceKey" | "version">>,
) {
const existing = await readLocalServiceRegistryRecord(serviceKey);
if (!existing) return null;
const next: LocalServiceRegistryRecord = {
...existing,
...patch,
version: 1,
serviceKey,
lastSeenAt: patch?.lastSeenAt ?? new Date().toISOString(),
};
await writeLocalServiceRegistryRecord(next);
return next;
}
export async function terminateLocalService(
record: Pick<LocalServiceRegistryRecord, "pid" | "processGroupId"> &
Partial<Pick<LocalServiceRegistryRecord, "port">>,
opts?: { signal?: NodeJS.Signals; forceAfterMs?: number; verifyAfterMs?: number },
) {
const signal = opts?.signal ?? "SIGTERM";
const targetProcessGroup = process.platform !== "win32" && record.processGroupId && record.processGroupId > 0;
const targetIsGone = async () => {
const targetAlive = targetProcessGroup
? isProcessGroupAlive(record.processGroupId)
: isPidAlive(record.pid);
if (targetAlive) return false;
if (!record.port) return true;
const portOwnerPid = await readLocalServicePortOwner(record.port);
if (!portOwnerPid) return true;
const ownerProcessId = targetProcessGroup ? record.processGroupId! : record.pid;
return !(await isLocalServiceProcessOwnedBy(portOwnerPid, ownerProcessId));
};
const waitUntilGone = async (timeoutMs: number) => {
const deadline = Date.now() + timeoutMs;
do {
if (await targetIsGone()) return true;
await delay(100);
} while (Date.now() < deadline);
return await targetIsGone();
};
try {
if (targetProcessGroup) {
process.kill(-record.processGroupId!, signal);
} else {
process.kill(record.pid, signal);
}
} catch {
if (await targetIsGone()) return;
}
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;
}
}
return null;
}
const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]);
const firstPid = stdout
.split("\n")
.map((line) => Number.parseInt(line.trim(), 10))
.find((value) => Number.isInteger(value) && value > 0);
return firstPid ?? null;
} catch {
return null;
}
}
/**
* Resolve a running process's working directory.
*
* Linux reads it straight off procfs. macOS has no procfs, so it asks `lsof`
* for the process's `cwd` descriptor — the same tool this module already shells
* out to for port ownership, so this adds no new dependency. `-d cwd` narrows
* the output to the working directory.
*
* `-F0n` terminates each field with NUL instead of a newline. The parser does
* not trim the path or split it on newlines. That matters because the caller
* compares this value against a workspace root: a directory name may contain
* leading or trailing spaces, or even a newline, and changing the path would
* report a different directory than the one the process runs in.
*
* Returning a real path on macOS is what lets `adoptLocalServiceFromPortOwner`
* verify a listener actually belongs to the workspace. While this returned
* null off Linux, that check could never pass, so port-owner adoption always
* failed there and still-running services were reconciled to `stopped`.
*/
export async function readLocalServiceProcessCwd(pid: number) {
if (!Number.isInteger(pid) || pid <= 0) return null;
if (process.platform === "linux") {
try {
return await fs.readlink(`/proc/${pid}/cwd`);
} catch {
return null;
}
}
if (process.platform === "darwin") {
try {
const { stdout } = await execFileAsync("lsof", ["-a", "-d", "cwd", "-p", String(pid), "-F0n"]);
// Each field ends with NUL. The newline that ends a field set carries
// into the next field, so drop it before reading the `n` tag; anything
// after the tag is the path exactly as lsof reported it.
const cwdField = stdout
.split("\0")
.map((field) => field.replace(/^\n+/, ""))
.find((field) => field.startsWith("n"));
return cwdField ? cwdField.slice(1) || null : null;
} catch {
return null;
}
}
return null;
}
export async function isLocalServiceProcessInWorkspace(processCwd: string, workspaceCwd: string) {
try {
const [resolvedProcessCwd, resolvedWorkspaceCwd] = await Promise.all([
fs.realpath(processCwd),
fs.realpath(workspaceCwd),
]);
const relativePath = path.relative(resolvedWorkspaceCwd, resolvedProcessCwd);
return relativePath === "" || (!relativePath.startsWith(`..${path.sep}`) && relativePath !== "..");
} catch {
return false;
}
}
export async function isLocalServiceRegistryCwdCompatible(processCwd: string | null, workspaceCwd: string) {
if (!processCwd) return process.platform !== "linux" && process.platform !== "darwin";
return isLocalServiceProcessInWorkspace(processCwd, workspaceCwd);
}
async function doesLocalServiceRecordMatchCwd(record: LocalServiceRegistryRecord) {
if (!record.port) return true;
const ownerPid = await readLocalServicePortOwner(record.port);
if (!ownerPid) return false;
if (!(await isLocalServiceProcessOwnedBy(ownerPid, record.processGroupId ?? record.pid))) {
return false;
}
const ownerCwd = await readLocalServiceProcessCwd(ownerPid);
return isLocalServiceRegistryCwdCompatible(ownerCwd, record.cwd);
}