antechamber 1.0.35 → 1.0.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +7 -4
- package/dist/commands/doctor.js +9 -3
- package/dist/pi-rpc.d.ts +2 -12
- package/dist/pi-rpc.js +3 -29
- package/dist/proc-table-darwin.d.ts +23 -0
- package/dist/proc-table-darwin.js +93 -0
- package/dist/proc-table.d.ts +17 -0
- package/dist/proc-table.js +38 -0
- package/dist/session-hosts.d.ts +21 -0
- package/dist/session-hosts.js +122 -1
- package/extensions/omo-compat/package.json +1 -1
- package/extensions/omo-dag/package.json +1 -1
- package/extensions/omo-work/package.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project uses [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.37] - 2026-11-03
|
|
11
|
+
|
|
12
|
+
- feat(session-hosts): tear down private OmO hosts on macOS (#85)
|
|
13
|
+
|
|
14
|
+
## [1.0.36] - 2026-11-03
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Clean up after the agent on macOS. Disposal now records the agent's process group and re-identifies each member
|
|
19
|
+
before signalling it, so child processes that outlive the agent are stopped instead of being left behind. Linux
|
|
20
|
+
keeps reading `/proc`; macOS reads `/bin/ps`, because Node has neither `/proc` nor pidfd there. A process that has
|
|
21
|
+
already exited (zombie) never counts as a survivor.
|
|
22
|
+
|
|
23
|
+
- Tear down OmO's private task hosts on macOS. The connector finds the supervisors it started by the `--socket` session
|
|
24
|
+
shard, entry point and working directory, records every process in their groups, and signals each one by PID only
|
|
25
|
+
after re-checking its group and start time, escalating to `SIGKILL` after a grace window. A process that outlives its
|
|
26
|
+
supervisor is still stopped. `antechamber doctor` reports the check as PASS when `/bin/ps` and `/usr/sbin/lsof`
|
|
27
|
+
exist. Linux keeps the pidfd path.
|
|
28
|
+
|
|
10
29
|
## [1.0.35] - 2026-11-03
|
|
11
30
|
|
|
12
31
|
- test: resolve fixture paths with fileURLToPath (#86)
|
package/README.md
CHANGED
|
@@ -354,10 +354,13 @@ Hardening controls (full reference in [docs/configuration.md](docs/configuration
|
|
|
354
354
|
## Platform notes
|
|
355
355
|
|
|
356
356
|
Linux is the primary supported platform. OmO's private task-host teardown
|
|
357
|
-
(killing detached supervisor processes antechamber spawned)
|
|
358
|
-
pidfds (`os.pidfd_open`, Linux 5.3+) and `python3` on PATH
|
|
359
|
-
API
|
|
360
|
-
|
|
357
|
+
(killing detached supervisor processes antechamber spawned) uses Linux
|
|
358
|
+
pidfds (`os.pidfd_open`, Linux 5.3+) and `python3` on PATH, because Node has no pidfd
|
|
359
|
+
API; on Linux it never signals a numeric PID. macOS has no pidfd, so there it
|
|
360
|
+
reads `/bin/ps` and `/usr/sbin/lsof` and signals each owned process by PID after
|
|
361
|
+
re-checking its process group and start time (see
|
|
362
|
+
[the OmO backend notes](docs/backends/omo.md#private-task-host-teardown)). CI runs the suite on
|
|
363
|
+
macOS. On other platforms that teardown step is skipped with one warning instead of crashing.
|
|
361
364
|
|
|
362
365
|
## Limitations / non-goals
|
|
363
366
|
|
package/dist/commands/doctor.js
CHANGED
|
@@ -84,9 +84,15 @@ export function doctor(options = {}, ctx = createContext(), latestVersion) {
|
|
|
84
84
|
}
|
|
85
85
|
})
|
|
86
86
|
: undefined;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
if (ctx.platform === "darwin") {
|
|
88
|
+
const tools = ["/bin/ps", "/usr/sbin/lsof"];
|
|
89
|
+
const missing = tools.filter((tool) => !ctx.fs.existsSync(tool));
|
|
90
|
+
add("private-host-teardown", missing.length === 0 ? "PASS" : "WARN", missing.length === 0 ? `macOS teardown via ${tools.join(" and ")}` : `requires ${missing.join(" and ")}`);
|
|
91
|
+
}
|
|
92
|
+
else
|
|
93
|
+
add("private-host-teardown", python === undefined ? "WARN" : "PASS", python === undefined
|
|
94
|
+
? "requires Linux and python3 with working pidfd support"
|
|
95
|
+
: `Linux pidfd support via ${python}`);
|
|
90
96
|
const openchamber = resolveBinary("openchamber", ctx);
|
|
91
97
|
if (openchamber === undefined)
|
|
92
98
|
add("openchamber", "WARN", "openchamber is not on PATH");
|
package/dist/pi-rpc.d.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { EventEmitter } from "node:events";
|
|
11
11
|
import { type LifecycleClock } from "./lifecycle.js";
|
|
12
|
+
import { type ProcessTable } from "./proc-table.js";
|
|
13
|
+
export type { ProcessIdentity, ProcessTable } from "./proc-table.js";
|
|
12
14
|
export interface PiRpcCommand {
|
|
13
15
|
id?: string;
|
|
14
16
|
type: string;
|
|
@@ -30,17 +32,6 @@ export declare class UpstreamProtocolError extends Error {
|
|
|
30
32
|
* earlyoom take a workload before the adapter, whose death stops every session at once.
|
|
31
33
|
*/
|
|
32
34
|
export declare const AGENT_OOM_SCORE_ADJ_MARGIN = 300;
|
|
33
|
-
/** A process's group and kernel start time: with the PID, the identity that survives PID reuse checks. */
|
|
34
|
-
interface ProcessIdentity {
|
|
35
|
-
readonly pid: number;
|
|
36
|
-
readonly group: number;
|
|
37
|
-
readonly start: string;
|
|
38
|
-
}
|
|
39
|
-
/** Reads process identities so disposal can tell owned survivors from a reused PID. */
|
|
40
|
-
export interface ProcessTable {
|
|
41
|
-
identity(pid: number): ProcessIdentity | undefined;
|
|
42
|
-
groupMembers(group: number): ProcessIdentity[];
|
|
43
|
-
}
|
|
44
35
|
/** Why the agent stopped without antechamber disposing it on purpose (eviction, delete, shutdown). */
|
|
45
36
|
export type PiStopReason = {
|
|
46
37
|
readonly kind: "timeout";
|
|
@@ -143,4 +134,3 @@ export declare class PiRpcClient extends EventEmitter {
|
|
|
143
134
|
notify(cmd: Omit<PiRpcCommand, "id">): void;
|
|
144
135
|
dispose(): Promise<void>;
|
|
145
136
|
}
|
|
146
|
-
export {};
|
package/dist/pi-rpc.js
CHANGED
|
@@ -11,10 +11,11 @@ import { spawn } from "node:child_process";
|
|
|
11
11
|
import { StringDecoder } from "node:string_decoder";
|
|
12
12
|
import { EventEmitter } from "node:events";
|
|
13
13
|
import { randomUUID } from "node:crypto";
|
|
14
|
-
import {
|
|
14
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { constants as osConstants } from "node:os";
|
|
16
16
|
import { systemClock } from "./lifecycle.js";
|
|
17
17
|
import { agentBinary, envSetting } from "./config.js";
|
|
18
|
+
import { createProcessTable } from "./proc-table.js";
|
|
18
19
|
export const RPC_COMMAND_TIMEOUT_MS = 60_000;
|
|
19
20
|
/**
|
|
20
21
|
* `compact` answers only when the summary is written. OmO bounds one compaction by
|
|
@@ -50,34 +51,7 @@ function rankAboveAdapterForOom(pid) {
|
|
|
50
51
|
console.error(`[pi-rpc] could not raise the agent's OOM score: ${error instanceof Error ? error.message : error}`);
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
|
-
|
|
54
|
-
// verified, and an unverifiable process is never signalled: fail closed.
|
|
55
|
-
const procTable = {
|
|
56
|
-
identity(pid) {
|
|
57
|
-
let stat;
|
|
58
|
-
try {
|
|
59
|
-
stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
return undefined;
|
|
63
|
-
}
|
|
64
|
-
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
65
|
-
return { pid, group: Number(fields[2]), start: fields[19] ?? "" };
|
|
66
|
-
},
|
|
67
|
-
groupMembers(group) {
|
|
68
|
-
let names;
|
|
69
|
-
try {
|
|
70
|
-
names = readdirSync("/proc");
|
|
71
|
-
}
|
|
72
|
-
catch {
|
|
73
|
-
return [];
|
|
74
|
-
}
|
|
75
|
-
return names
|
|
76
|
-
.filter((name) => /^\d+$/.test(name))
|
|
77
|
-
.flatMap((name) => procTable.identity(Number(name)) ?? [])
|
|
78
|
-
.filter((member) => member.group === group);
|
|
79
|
-
},
|
|
80
|
-
};
|
|
54
|
+
const procTable = createProcessTable();
|
|
81
55
|
function duration(ms) {
|
|
82
56
|
if (ms % 60_000 === 0)
|
|
83
57
|
return `${ms / 60_000} min`;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ProcessIdentity, ProcessTable } from "./proc-table.js";
|
|
2
|
+
/**
|
|
3
|
+
* macOS process table, backed by `/bin/ps`. Used by disposal to identify owned survivors, so it must
|
|
4
|
+
* report the same three facts Linux reads from `/proc/<pid>/stat`: PID, process group, and a start
|
|
5
|
+
* time that survives PID reuse checks. `ps` exposes the latter as `lstart` (second granularity).
|
|
6
|
+
*/
|
|
7
|
+
/** Runs `/bin/ps` with the given arguments and returns stdout. Injected so tests never spawn. */
|
|
8
|
+
export type PsRunner = (args: readonly string[]) => string;
|
|
9
|
+
/**
|
|
10
|
+
* `execFileSync` with an absolute binary: no shell, and no PATH lookup that a user-controlled
|
|
11
|
+
* directory could shadow. `-ww` disables width truncation; the column set is fixed so every line
|
|
12
|
+
* has the same shape. LC_ALL is pinned because `lstart` is rendered in the current locale: two
|
|
13
|
+
* calls must agree with each other even if the user changes the system language mid-session.
|
|
14
|
+
*/
|
|
15
|
+
export declare function runPs(args: readonly string[]): string;
|
|
16
|
+
/** One `ps -A` row with its state and argv, for callers that match processes by command line. */
|
|
17
|
+
export interface PsProcess extends ProcessIdentity {
|
|
18
|
+
readonly state: string;
|
|
19
|
+
readonly args: string;
|
|
20
|
+
}
|
|
21
|
+
/** Every live (non-zombie) process with its argv. Throws when `ps` fails, so the caller decides. */
|
|
22
|
+
export declare function listDarwinProcesses(run?: PsRunner): PsProcess[];
|
|
23
|
+
export declare function createDarwinProcTable(run?: PsRunner): ProcessTable;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
const PS_TIMEOUT_MS = 2_000;
|
|
3
|
+
const PS_MAX_BUFFER = 8 * 1024 * 1024;
|
|
4
|
+
const PS_COLUMNS = "pid=,pgid=,stat=,lstart=";
|
|
5
|
+
/** `pid pgid stat lstart`, where lstart is "Sun Nov 1 15:55:48 2026" and may end in padding. */
|
|
6
|
+
const PS_LINE = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+?)\s*$/;
|
|
7
|
+
/**
|
|
8
|
+
* `execFileSync` with an absolute binary: no shell, and no PATH lookup that a user-controlled
|
|
9
|
+
* directory could shadow. `-ww` disables width truncation; the column set is fixed so every line
|
|
10
|
+
* has the same shape. LC_ALL is pinned because `lstart` is rendered in the current locale: two
|
|
11
|
+
* calls must agree with each other even if the user changes the system language mid-session.
|
|
12
|
+
*/
|
|
13
|
+
export function runPs(args) {
|
|
14
|
+
return execFileSync("/bin/ps", args, {
|
|
15
|
+
encoding: "utf8",
|
|
16
|
+
timeout: PS_TIMEOUT_MS,
|
|
17
|
+
maxBuffer: PS_MAX_BUFFER,
|
|
18
|
+
env: { ...process.env, LC_ALL: "C", LANG: "C" },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function parseLine(line) {
|
|
22
|
+
const match = PS_LINE.exec(line);
|
|
23
|
+
if (match === null)
|
|
24
|
+
return undefined;
|
|
25
|
+
const pid = Number(match[1]);
|
|
26
|
+
const group = Number(match[2]);
|
|
27
|
+
const state = match[3];
|
|
28
|
+
const start = match[4];
|
|
29
|
+
if (!Number.isInteger(pid) || !Number.isInteger(group) || state === undefined || start === undefined)
|
|
30
|
+
return undefined;
|
|
31
|
+
if (start === "")
|
|
32
|
+
return undefined;
|
|
33
|
+
return { pid, group, start, state };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A zombie has already exited; only its parent is left to reap it. Counting it as a survivor would
|
|
37
|
+
* make disposal wait out the full grace window for a process that can no longer run, so `Z` entries
|
|
38
|
+
* are treated as gone in both methods, as `session-hosts` already does on Linux.
|
|
39
|
+
*/
|
|
40
|
+
function alive(entry) {
|
|
41
|
+
return !entry.state.startsWith("Z");
|
|
42
|
+
}
|
|
43
|
+
// Under LC_ALL=C, lstart is a fixed 24-character field, so argv starts right after it even when it
|
|
44
|
+
// contains spaces.
|
|
45
|
+
const PS_ARGS_LINE = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.{24})\s?(.*)$/;
|
|
46
|
+
/** Every live (non-zombie) process with its argv. Throws when `ps` fails, so the caller decides. */
|
|
47
|
+
export function listDarwinProcesses(run = runPs) {
|
|
48
|
+
return run(["-ww", "-o", `${PS_COLUMNS},args=`, "-A"])
|
|
49
|
+
.split("\n")
|
|
50
|
+
.flatMap((line) => {
|
|
51
|
+
const match = PS_ARGS_LINE.exec(line);
|
|
52
|
+
if (match === null)
|
|
53
|
+
return [];
|
|
54
|
+
const entry = { pid: Number(match[1]), group: Number(match[2]), state: match[3] ?? "", start: match[4] ?? "" };
|
|
55
|
+
return alive(entry) ? [{ ...entry, args: match[5] ?? "" }] : [];
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function toIdentity(entry) {
|
|
59
|
+
return { pid: entry.pid, group: entry.group, start: entry.start };
|
|
60
|
+
}
|
|
61
|
+
export function createDarwinProcTable(run = runPs) {
|
|
62
|
+
return {
|
|
63
|
+
identity(pid) {
|
|
64
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
65
|
+
return undefined;
|
|
66
|
+
let output;
|
|
67
|
+
try {
|
|
68
|
+
output = run(["-ww", "-o", PS_COLUMNS, "-p", String(pid)]);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// The process is gone, or ps could not read it. An unverifiable identity fails closed.
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
const entry = parseLine(output.trim());
|
|
75
|
+
if (entry === undefined || entry.pid !== pid || !alive(entry))
|
|
76
|
+
return undefined;
|
|
77
|
+
return toIdentity(entry);
|
|
78
|
+
},
|
|
79
|
+
groupMembers(group) {
|
|
80
|
+
let output;
|
|
81
|
+
try {
|
|
82
|
+
output = run(["-ww", "-o", PS_COLUMNS, "-A"]);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
return output.split("\n").flatMap((line) => {
|
|
88
|
+
const entry = parseLine(line);
|
|
89
|
+
return entry !== undefined && entry.group === group && alive(entry) ? [toIdentity(entry)] : [];
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** A process's group and kernel start time: with the PID, the identity that survives PID reuse checks. */
|
|
2
|
+
export interface ProcessIdentity {
|
|
3
|
+
readonly pid: number;
|
|
4
|
+
readonly group: number;
|
|
5
|
+
readonly start: string;
|
|
6
|
+
}
|
|
7
|
+
/** Reads process identities so disposal can tell owned survivors from a reused PID. */
|
|
8
|
+
export interface ProcessTable {
|
|
9
|
+
identity(pid: number): ProcessIdentity | undefined;
|
|
10
|
+
groupMembers(group: number): ProcessIdentity[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The process table for this platform. Linux reads `/proc`; macOS reads `/bin/ps`, because Node has no
|
|
14
|
+
* `/proc` and no pidfd there. Every other platform keeps the Linux table so a missing `/proc` fails
|
|
15
|
+
* closed (an identity is never reported that cannot be verified).
|
|
16
|
+
*/
|
|
17
|
+
export declare function createProcessTable(platform?: NodeJS.Platform): ProcessTable;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { createDarwinProcTable } from "./proc-table-darwin.js";
|
|
3
|
+
// Linux /proc. Any read failure (gone, no permission, no /proc) means the identity cannot be
|
|
4
|
+
// verified, and an unverifiable process is never signalled: fail closed.
|
|
5
|
+
const linuxProcTable = {
|
|
6
|
+
identity(pid) {
|
|
7
|
+
let stat;
|
|
8
|
+
try {
|
|
9
|
+
stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
15
|
+
return { pid, group: Number(fields[2]), start: fields[19] ?? "" };
|
|
16
|
+
},
|
|
17
|
+
groupMembers(group) {
|
|
18
|
+
let names;
|
|
19
|
+
try {
|
|
20
|
+
names = readdirSync("/proc");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
return names
|
|
26
|
+
.filter((name) => /^\d+$/.test(name))
|
|
27
|
+
.flatMap((name) => linuxProcTable.identity(Number(name)) ?? [])
|
|
28
|
+
.filter((member) => member.group === group);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The process table for this platform. Linux reads `/proc`; macOS reads `/bin/ps`, because Node has no
|
|
33
|
+
* `/proc` and no pidfd there. Every other platform keeps the Linux table so a missing `/proc` fails
|
|
34
|
+
* closed (an identity is never reported that cannot be verified).
|
|
35
|
+
*/
|
|
36
|
+
export function createProcessTable(platform = process.platform) {
|
|
37
|
+
return platform === "darwin" ? createDarwinProcTable() : linuxProcTable;
|
|
38
|
+
}
|
package/dist/session-hosts.d.ts
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
import { type PsRunner } from "./proc-table-darwin.js";
|
|
1
2
|
/** Capability and warning state are captured once, before any shutdown work. */
|
|
2
3
|
export declare function createSessionHostStopper(platform?: NodeJS.Platform, probe?: () => string | undefined): (created: ReadonlyMap<string, string>) => Promise<void>;
|
|
3
4
|
export declare const stopSessionHosts: (created: ReadonlyMap<string, string>) => Promise<void>;
|
|
5
|
+
/** Injected so the darwin path can be tested without running ps, lsof or a signal. */
|
|
6
|
+
export interface DarwinHostDependencies {
|
|
7
|
+
readonly run: PsRunner;
|
|
8
|
+
readonly cwdOf: (pid: number) => string | undefined;
|
|
9
|
+
readonly realpath: (path: string) => string;
|
|
10
|
+
readonly signal: (pid: number, signal: NodeJS.Signals) => void;
|
|
11
|
+
readonly delay: (ms: number) => Promise<void>;
|
|
12
|
+
readonly graceMs: number;
|
|
13
|
+
readonly pollMs: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* macOS teardown without pidfd. Ownership is the Linux rule: a live group leader whose argv names the
|
|
17
|
+
* supervisor entry point and, as its `--socket` value, the shard of a session this connector created,
|
|
18
|
+
* and whose working directory is that session's directory. Every member of an owned group is recorded
|
|
19
|
+
* with its start time in the same scan, and each later signal goes to one PID only after a fresh scan
|
|
20
|
+
* still shows the same group and start time. Members are tracked individually, so a child that outlives
|
|
21
|
+
* its supervisor is still stopped. A PID can be recycled between that scan and the signal; the window
|
|
22
|
+
* is the same one `PiRpcClient.dispose()` accepts.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createDarwinHostStopper(overrides?: Partial<DarwinHostDependencies>): (created: ReadonlyMap<string, string>) => Promise<void>;
|
package/dist/session-hosts.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { once } from "node:events";
|
|
4
4
|
import { closeSync, constants, openSync, readFileSync, readdirSync, readlinkSync, realpathSync } from "node:fs";
|
|
5
5
|
import { basename } from "node:path";
|
|
6
6
|
import { findPidfdPython } from "./pidfd.js";
|
|
7
|
+
import { listDarwinProcesses, runPs } from "./proc-table-darwin.js";
|
|
8
|
+
/** How long a darwin teardown waits after SIGTERM before escalating, and how often it re-reads the table. */
|
|
9
|
+
const HOST_TERM_GRACE_MS = 5_000;
|
|
10
|
+
const HOST_POLL_MS = 100;
|
|
7
11
|
/** Capability and warning state are captured once, before any shutdown work. */
|
|
8
12
|
export function createSessionHostStopper(platform = process.platform, probe = findPidfdPython) {
|
|
13
|
+
if (platform === "darwin")
|
|
14
|
+
return createDarwinHostStopper();
|
|
9
15
|
const python = platform === "linux" ? probe() : undefined;
|
|
10
16
|
let warned = false;
|
|
11
17
|
return async (created) => {
|
|
@@ -22,6 +28,121 @@ export function createSessionHostStopper(platform = process.platform, probe = fi
|
|
|
22
28
|
};
|
|
23
29
|
}
|
|
24
30
|
export const stopSessionHosts = createSessionHostStopper();
|
|
31
|
+
/** macOS has no `/proc/<pid>/cwd`; lsof prints `p<pid>`, `fcwd`, `n<path>`. */
|
|
32
|
+
function cwdOfHost(pid) {
|
|
33
|
+
try {
|
|
34
|
+
const output = execFileSync("/usr/sbin/lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
timeout: 2_000,
|
|
37
|
+
maxBuffer: 1024 * 1024,
|
|
38
|
+
});
|
|
39
|
+
for (const line of output.split("\n"))
|
|
40
|
+
if (line.startsWith("n"))
|
|
41
|
+
return line.slice(1);
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// No permission, no process, or no lsof: the directory is unconfirmed and nothing is signalled.
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function signalHost(pid, signal) {
|
|
50
|
+
try {
|
|
51
|
+
process.kill(pid, signal);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const code = error instanceof Error && "code" in error ? error.code : undefined;
|
|
55
|
+
// Gone since the last scan, or not ours to signal: either way nothing was sent.
|
|
56
|
+
if (code === "ESRCH" || code === "EPERM")
|
|
57
|
+
return;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function socketName(sessionId) {
|
|
62
|
+
return `p-${createHash("sha256").update(`p:${sessionId}`).digest("hex").slice(0, 16)}.sock`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The value of `--socket`, as Linux reads it from argv. `ps` joins argv with spaces, so the value runs
|
|
66
|
+
* to the next ` --` flag (a socket directory may contain spaces). Anything else after it makes the
|
|
67
|
+
* basename miss every shard, which fails closed.
|
|
68
|
+
*/
|
|
69
|
+
function socketArgument(args) {
|
|
70
|
+
const match = /(?:^|\s)--socket\s+(.+?)(?=\s--|$)/.exec(args);
|
|
71
|
+
return match?.[1] === undefined ? undefined : basename(match[1].trimEnd());
|
|
72
|
+
}
|
|
73
|
+
function isSupervisor(args) {
|
|
74
|
+
const tokens = args.split(/\s+/);
|
|
75
|
+
return (tokens.includes("--internal-rpc-host-supervisor") || tokens.some((token) => token.endsWith("/host-lifecycle.js")));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* macOS teardown without pidfd. Ownership is the Linux rule: a live group leader whose argv names the
|
|
79
|
+
* supervisor entry point and, as its `--socket` value, the shard of a session this connector created,
|
|
80
|
+
* and whose working directory is that session's directory. Every member of an owned group is recorded
|
|
81
|
+
* with its start time in the same scan, and each later signal goes to one PID only after a fresh scan
|
|
82
|
+
* still shows the same group and start time. Members are tracked individually, so a child that outlives
|
|
83
|
+
* its supervisor is still stopped. A PID can be recycled between that scan and the signal; the window
|
|
84
|
+
* is the same one `PiRpcClient.dispose()` accepts.
|
|
85
|
+
*/
|
|
86
|
+
export function createDarwinHostStopper(overrides = {}) {
|
|
87
|
+
const deps = {
|
|
88
|
+
run: overrides.run ?? runPs,
|
|
89
|
+
cwdOf: overrides.cwdOf ?? cwdOfHost,
|
|
90
|
+
realpath: overrides.realpath ?? realpathSync,
|
|
91
|
+
signal: overrides.signal ?? signalHost,
|
|
92
|
+
delay: overrides.delay ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
93
|
+
graceMs: overrides.graceMs ?? HOST_TERM_GRACE_MS,
|
|
94
|
+
pollMs: overrides.pollMs ?? HOST_POLL_MS,
|
|
95
|
+
};
|
|
96
|
+
return async (created) => {
|
|
97
|
+
if (created.size === 0)
|
|
98
|
+
return;
|
|
99
|
+
const sockets = new Map([...created].map(([id, directory]) => [socketName(id), directory]));
|
|
100
|
+
const processes = listDarwinProcesses(deps.run);
|
|
101
|
+
const groups = new Set();
|
|
102
|
+
for (const row of processes) {
|
|
103
|
+
if (row.group !== row.pid || !isSupervisor(row.args))
|
|
104
|
+
continue;
|
|
105
|
+
const socket = socketArgument(row.args);
|
|
106
|
+
const directory = socket === undefined ? undefined : sockets.get(socket);
|
|
107
|
+
if (directory === undefined)
|
|
108
|
+
continue;
|
|
109
|
+
const cwd = deps.cwdOf(row.pid);
|
|
110
|
+
if (cwd === undefined)
|
|
111
|
+
continue;
|
|
112
|
+
let expected;
|
|
113
|
+
try {
|
|
114
|
+
expected = deps.realpath(directory);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (cwd === expected)
|
|
120
|
+
groups.add(row.group);
|
|
121
|
+
}
|
|
122
|
+
const members = processes.filter((row) => groups.has(row.group));
|
|
123
|
+
if (members.length === 0)
|
|
124
|
+
return;
|
|
125
|
+
const survivors = () => {
|
|
126
|
+
const now = new Map(listDarwinProcesses(deps.run).map((row) => [row.pid, row]));
|
|
127
|
+
return members
|
|
128
|
+
.filter((member) => {
|
|
129
|
+
const row = now.get(member.pid);
|
|
130
|
+
return row !== undefined && row.group === member.group && row.start === member.start;
|
|
131
|
+
})
|
|
132
|
+
.map((member) => member.pid);
|
|
133
|
+
};
|
|
134
|
+
for (const signal of ["SIGTERM", "SIGKILL"]) {
|
|
135
|
+
for (const pid of survivors())
|
|
136
|
+
deps.signal(pid, signal);
|
|
137
|
+
for (let waited = 0; waited < deps.graceMs; waited += deps.pollMs) {
|
|
138
|
+
await deps.delay(deps.pollMs);
|
|
139
|
+
if (survivors().length === 0)
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
throw new Error(`private OmO hosts survived shutdown: ${survivors().join(", ")}`);
|
|
144
|
+
};
|
|
145
|
+
}
|
|
25
146
|
function processInfo(pid) {
|
|
26
147
|
let fd;
|
|
27
148
|
try {
|