@jaggerxtrm/pi-extensions 0.7.22 → 0.7.23

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.
@@ -1,23 +1,56 @@
1
1
  /**
2
- * serena-pool — shared Serena daemon per repo root
2
+ * serena-pool — shared Serena daemon per repo root with ownership-based cleanup
3
3
  *
4
- * Sets SERENA_MCP_PORT to a deterministic port derived from the git root before
5
- * pi-serena-tools session_start fires. If no Serena server is already listening
6
- * on that port, spawns one (detached, persists after session ends).
4
+ * On session_start:
5
+ * 1. Resolve repo root from cwd deterministic port via hash.
6
+ * 2. If a Serena is already listening on that port reuse it, set SERENA_MCP_PORT.
7
+ * 3. Otherwise acquire a per-port file lock and:
8
+ * - Read the recorded state (pid, pgid, startTime, instanceId) from /tmp.
9
+ * - If the previously-recorded Serena process is dead → kill its process
10
+ * group (SIGTERM then SIGKILL) to reap orphaned LSP children.
11
+ * - Spawn a fresh Serena (detached → new process group, pgid == pid).
12
+ * - Persist new state to /tmp.
13
+ * - Wait until the port answers, then release the lock.
7
14
  *
8
- * pi-serena-tools reads SERENA_MCP_PORT lazily in resolveSerenaPort(), finds the
9
- * server already running, skips its own spawn, and stopServer() is a no-op since
10
- * it never held serverProcess. No patches to pi-serena-tools needed.
15
+ * pi-serena-tools reads SERENA_MCP_PORT lazily and reuses the daemon; its
16
+ * own stopServer() is a no-op because it never held the child handle.
17
+ *
18
+ * Orphan cleanup is by process-group ownership only — never by path matching.
19
+ * We only signal processes whose pgid matches the recorded pgid AND whose
20
+ * controlling Serena is verifiably dead (pid+startTime check). Process-group
21
+ * kills are bounded to ours, so editor LSPs / tests / hooks are never touched.
11
22
  */
12
23
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
13
- import { spawn } from "node:child_process";
24
+ import { spawn, execFileSync } from "node:child_process";
14
25
  import { connect } from "node:net";
15
- import { execSync } from "node:child_process";
26
+ import { existsSync, mkdirSync, openSync, writeSync, closeSync, readFileSync, unlinkSync, realpathSync } from "node:fs";
27
+ import { tmpdir } from "node:os";
28
+ import { join } from "node:path";
29
+ import { randomUUID } from "node:crypto";
16
30
 
17
31
  const POOL_PORT_MIN = 40000;
18
- const POOL_PORT_RANGE = 5000; // 40000–44999
32
+ const POOL_PORT_RANGE = 5000; // ports 40000–44999
19
33
  const STARTUP_TIMEOUT_MS = 15_000;
20
34
  const POLL_INTERVAL_MS = 300;
35
+ const LOCK_TIMEOUT_MS = 10_000;
36
+ const ORPHAN_TERM_GRACE_MS = 2_000;
37
+
38
+ export const STATE_DIR = join(tmpdir(), "serena-pool");
39
+
40
+ const DEBUG_ENABLED = (process.env.DEBUG ?? "").split(/[\s,]+/).includes("serena-pool");
41
+ function debug(msg: string, ...args: unknown[]): void {
42
+ if (DEBUG_ENABLED) console.error(`[serena-pool] ${msg}`, ...args);
43
+ }
44
+
45
+ type PoolState = {
46
+ pid: number;
47
+ pgid: number;
48
+ startTime: string | null;
49
+ instanceId: string;
50
+ projectRoot: string;
51
+ port: number;
52
+ spawnedAt: number;
53
+ };
21
54
 
22
55
  function hashToPort(s: string): number {
23
56
  let h = 5381;
@@ -28,18 +61,175 @@ function hashToPort(s: string): number {
28
61
  return POOL_PORT_MIN + (h % POOL_PORT_RANGE);
29
62
  }
30
63
 
64
+ function ensureStateDir(): void {
65
+ if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
66
+ }
67
+
68
+ function stateFilePath(port: number): string {
69
+ return join(STATE_DIR, `pool-${port}.json`);
70
+ }
71
+
72
+ function lockFilePath(port: number): string {
73
+ return join(STATE_DIR, `pool-${port}.lock`);
74
+ }
75
+
31
76
  function getRepoRoot(cwd: string): string {
77
+ let root = cwd;
32
78
  try {
33
- return execSync("git rev-parse --show-toplevel", {
79
+ const out = execFileSync("git", ["rev-parse", "--show-toplevel"], {
34
80
  cwd,
35
81
  encoding: "utf8",
36
82
  stdio: ["ignore", "pipe", "ignore"],
37
83
  }).trim();
84
+ if (out) root = out;
85
+ } catch {
86
+ /* not a git repo — use cwd */
87
+ }
88
+ try {
89
+ return realpathSync(root);
90
+ } catch {
91
+ return root;
92
+ }
93
+ }
94
+
95
+ function isPidAlive(pid: number): boolean {
96
+ if (!pid || pid <= 0) return false;
97
+ try {
98
+ process.kill(pid, 0);
99
+ return true;
100
+ } catch (err: unknown) {
101
+ const code = (err as NodeJS.ErrnoException)?.code;
102
+ if (code === "EPERM") return true; // exists, just not signalable by us
103
+ return false;
104
+ }
105
+ }
106
+
107
+ /** Linux: /proc/<pid>/stat field 22 (starttime). macOS/other: `ps -o lstart=`. */
108
+ function getProcessStartTime(pid: number): string | null {
109
+ if (!pid || pid <= 0) return null;
110
+ try {
111
+ if (process.platform === "linux") {
112
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
113
+ // pid (comm) state ... — comm may contain spaces/parens; split after last ')'
114
+ const lastParen = stat.lastIndexOf(")");
115
+ if (lastParen < 0) return null;
116
+ const fields = stat.slice(lastParen + 2).split(" ");
117
+ // After (comm), fields[0]=state, fields[19]=starttime (clock ticks since boot)
118
+ return fields[19] ?? null;
119
+ }
120
+ const out = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
121
+ encoding: "utf8",
122
+ stdio: ["ignore", "pipe", "ignore"],
123
+ }).trim();
124
+ return out || null;
38
125
  } catch {
39
- return cwd;
126
+ return null;
40
127
  }
41
128
  }
42
129
 
130
+ /** Verify that `pid` is alive AND its start time still matches `recorded`. */
131
+ function isSameProcess(pid: number, recordedStartTime: string | null): boolean {
132
+ if (!isPidAlive(pid)) return false;
133
+ if (recordedStartTime == null) return true; // we never captured one — trust pid only
134
+ const current = getProcessStartTime(pid);
135
+ return current != null && current === recordedStartTime;
136
+ }
137
+
138
+ /** Enumerate all processes whose pgid matches the given value (excluding us). */
139
+ function findProcessesByPgid(pgid: number): Array<{ pid: number; comm: string }> {
140
+ if (!pgid || pgid <= 0) return [];
141
+ try {
142
+ const out = execFileSync("ps", ["-e", "-o", "pid=,pgid=,comm="], {
143
+ encoding: "utf8",
144
+ stdio: ["ignore", "pipe", "ignore"],
145
+ maxBuffer: 4 * 1024 * 1024,
146
+ });
147
+ const result: Array<{ pid: number; comm: string }> = [];
148
+ for (const line of out.split("\n")) {
149
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
150
+ if (!m) continue;
151
+ const pid = Number(m[1]);
152
+ const procPgid = Number(m[2]);
153
+ if (procPgid === pgid && pid !== process.pid) {
154
+ result.push({ pid, comm: m[3].trim() });
155
+ }
156
+ }
157
+ return result;
158
+ } catch {
159
+ return [];
160
+ }
161
+ }
162
+
163
+ function tryKill(pid: number, signal: NodeJS.Signals): void {
164
+ try {
165
+ process.kill(pid, signal);
166
+ } catch {
167
+ /* already gone */
168
+ }
169
+ }
170
+
171
+ function readState(port: number): PoolState | null {
172
+ try {
173
+ const raw = readFileSync(stateFilePath(port), "utf8");
174
+ const parsed = JSON.parse(raw) as PoolState;
175
+ if (typeof parsed.pid === "number" && typeof parsed.pgid === "number") return parsed;
176
+ return null;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ function writeState(state: PoolState): void {
183
+ ensureStateDir();
184
+ const path = stateFilePath(state.port);
185
+ const fd = openSync(path, "w", 0o600);
186
+ try {
187
+ writeSync(fd, JSON.stringify(state, null, 2));
188
+ } finally {
189
+ closeSync(fd);
190
+ }
191
+ }
192
+
193
+ function removeState(port: number): void {
194
+ try {
195
+ unlinkSync(stateFilePath(port));
196
+ } catch {
197
+ /* ignore */
198
+ }
199
+ }
200
+
201
+ type Lock = { release: () => void };
202
+
203
+ async function acquireLock(port: number, timeoutMs: number): Promise<Lock> {
204
+ ensureStateDir();
205
+ const path = lockFilePath(port);
206
+ const deadline = Date.now() + timeoutMs;
207
+ while (Date.now() < deadline) {
208
+ try {
209
+ const fd = openSync(path, "wx", 0o600);
210
+ writeSync(fd, String(process.pid));
211
+ closeSync(fd);
212
+ return {
213
+ release: () => {
214
+ try { unlinkSync(path); } catch { /* ignore */ }
215
+ },
216
+ };
217
+ } catch (err: unknown) {
218
+ const code = (err as NodeJS.ErrnoException)?.code;
219
+ if (code !== "EEXIST") throw err;
220
+ // Held by someone — check if that someone is still alive
221
+ let heldByPid = 0;
222
+ try { heldByPid = parseInt(readFileSync(path, "utf8").trim(), 10) || 0; } catch { /* ignore */ }
223
+ if (heldByPid > 0 && !isPidAlive(heldByPid)) {
224
+ try { unlinkSync(path); } catch { /* race ok */ }
225
+ continue;
226
+ }
227
+ await new Promise((r) => setTimeout(r, 100));
228
+ }
229
+ }
230
+ throw new Error(`serena-pool: could not acquire lock ${path} within ${timeoutMs}ms`);
231
+ }
232
+
43
233
  function isPortListening(port: number): Promise<boolean> {
44
234
  return new Promise((resolve) => {
45
235
  const socket = connect({ host: "127.0.0.1", port }, () => {
@@ -54,8 +244,8 @@ function isPortListening(port: number): Promise<boolean> {
54
244
  });
55
245
  }
56
246
 
57
- async function waitForPort(port: number): Promise<boolean> {
58
- const deadline = Date.now() + STARTUP_TIMEOUT_MS;
247
+ async function waitForPort(port: number, timeoutMs: number): Promise<boolean> {
248
+ const deadline = Date.now() + timeoutMs;
59
249
  while (Date.now() < deadline) {
60
250
  if (await isPortListening(port)) return true;
61
251
  await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
@@ -63,10 +253,25 @@ async function waitForPort(port: number): Promise<boolean> {
63
253
  return false;
64
254
  }
65
255
 
66
- async function ensureSerenaRunning(projectRoot: string, port: number): Promise<void> {
67
- if (await isPortListening(port)) return;
256
+ /**
257
+ * Reap orphaned LSP children left by a dead Serena daemon.
258
+ * Safety: we only signal processes whose pgid matches the recorded one AND
259
+ * whose controlling Serena (pid+startTime) is verifiably dead. The kernel
260
+ * guarantees nothing else can be in that process group unless it was a
261
+ * descendant of our spawned Serena.
262
+ */
263
+ async function reapOrphansIfDaemonDead(state: PoolState): Promise<void> {
264
+ if (isSameProcess(state.pid, state.startTime)) return; // daemon still alive — abort
265
+ const candidates = findProcessesByPgid(state.pgid);
266
+ if (candidates.length === 0) return;
267
+ for (const c of candidates) tryKill(c.pid, "SIGTERM");
268
+ await new Promise((r) => setTimeout(r, ORPHAN_TERM_GRACE_MS));
269
+ const stragglers = findProcessesByPgid(state.pgid);
270
+ for (const s of stragglers) tryKill(s.pid, "SIGKILL");
271
+ }
68
272
 
69
- const proc = spawn(
273
+ async function spawnSerena(projectRoot: string, port: number, instanceId: string): Promise<number> {
274
+ const child = spawn(
70
275
  "uvx",
71
276
  [
72
277
  "--from", "git+https://github.com/oraios/serena",
@@ -78,33 +283,121 @@ async function ensureSerenaRunning(projectRoot: string, port: number): Promise<v
78
283
  ],
79
284
  {
80
285
  cwd: projectRoot,
81
- env: { ...process.env },
286
+ env: {
287
+ ...process.env,
288
+ SERENA_POOL_INSTANCE_ID: instanceId,
289
+ SERENA_POOL_ROOT: projectRoot,
290
+ SERENA_POOL_PORT: String(port),
291
+ },
82
292
  stdio: "ignore",
83
293
  detached: true,
84
294
  },
85
295
  );
86
- proc.unref();
296
+ child.unref();
297
+ if (!child.pid) throw new Error("serena-pool: spawn returned no pid");
298
+ return child.pid;
299
+ }
300
+
301
+ export async function ensureSerenaForRoot(projectRoot: string): Promise<number | null> {
302
+ const port = hashToPort(projectRoot);
303
+ debug(`ensureSerenaForRoot root=${projectRoot} port=${port}`);
304
+
305
+ // Fast path: someone already serving on this port. Trust it.
306
+ if (await isPortListening(port)) {
307
+ debug(`fast path: port ${port} already listening`);
308
+ return port;
309
+ }
310
+
311
+ debug(`acquiring lock for port ${port}`);
312
+ const lock = await acquireLock(port, LOCK_TIMEOUT_MS);
313
+ try {
314
+ // Re-check under the lock — another concurrent spawner may have won.
315
+ if (await isPortListening(port)) {
316
+ debug(`raced: port ${port} now listening under lock`);
317
+ return port;
318
+ }
319
+
320
+ // Reap orphans from a previously-recorded dead daemon, if any.
321
+ const prior = readState(port);
322
+ if (prior) {
323
+ debug(`prior state found pid=${prior.pid} pgid=${prior.pgid}`);
324
+ const alive = isSameProcess(prior.pid, prior.startTime);
325
+ if (alive) {
326
+ debug(`prior daemon still alive — skipping reap (port may be transient down)`);
327
+ } else {
328
+ const candidates = findProcessesByPgid(prior.pgid);
329
+ debug(`prior daemon dead; reaping ${candidates.length} orphan(s) in pgid=${prior.pgid}`);
330
+ await reapOrphansIfDaemonDead(prior);
331
+ }
332
+ removeState(port);
333
+ } else {
334
+ debug(`no prior state`);
335
+ }
336
+
337
+ // Spawn fresh Serena. Detached → new process group, pgid == pid.
338
+ const instanceId = randomUUID();
339
+ const pid = await spawnSerena(projectRoot, port, instanceId);
340
+ const startTime = getProcessStartTime(pid);
341
+ debug(`spawned Serena pid=${pid} pgid=${pid} startTime=${startTime}`);
342
+ writeState({
343
+ pid,
344
+ pgid: pid, // detached process is a new group leader
345
+ startTime,
346
+ instanceId,
347
+ projectRoot,
348
+ port,
349
+ spawnedAt: Date.now(),
350
+ });
87
351
 
88
- const ready = await waitForPort(port);
89
- if (!ready) {
90
- console.warn(`[serena-pool] Serena did not start on port ${port} within ${STARTUP_TIMEOUT_MS}ms`);
352
+ const ready = await waitForPort(port, STARTUP_TIMEOUT_MS);
353
+ if (!ready) {
354
+ console.warn(`[serena-pool] Serena did not become healthy on port ${port} within ${STARTUP_TIMEOUT_MS}ms (pid=${pid})`);
355
+ return null;
356
+ }
357
+ debug(`ready on port ${port}`);
358
+ return port;
359
+ } finally {
360
+ lock.release();
361
+ debug(`released lock for port ${port}`);
91
362
  }
92
363
  }
93
364
 
365
+ // Internals exported for testing.
366
+ export const __internals = {
367
+ hashToPort,
368
+ isPortListening,
369
+ isPidAlive,
370
+ isSameProcess,
371
+ getProcessStartTime,
372
+ findProcessesByPgid,
373
+ getRepoRoot,
374
+ readState,
375
+ writeState,
376
+ removeState,
377
+ stateFilePath,
378
+ lockFilePath,
379
+ };
380
+ export type { PoolState };
381
+
94
382
  export default function registerSerenaPool(pi: ExtensionAPI) {
95
383
  pi.on("session_start", async (_event: unknown, ctx: any) => {
96
384
  const cwd: string = ctx?.cwd ?? process.cwd();
97
- const projectRoot = getRepoRoot(cwd);
98
- const port = hashToPort(projectRoot);
385
+ let projectRoot: string;
386
+ try {
387
+ projectRoot = getRepoRoot(cwd);
388
+ } catch (err) {
389
+ console.warn("[serena-pool] failed to resolve repo root:", err);
390
+ return;
391
+ }
99
392
 
100
393
  try {
101
- await ensureSerenaRunning(projectRoot, port);
102
- process.env.SERENA_MCP_PORT = String(port);
394
+ const port = await ensureSerenaForRoot(projectRoot);
395
+ if (port != null) process.env.SERENA_MCP_PORT = String(port);
103
396
  } catch (err) {
104
- console.warn("[serena-pool] Error ensuring Serena running:", err);
397
+ console.warn("[serena-pool] failed to ensure Serena running:", err);
105
398
  }
106
399
  });
107
400
 
108
- // session_shutdown intentionally absent server persists as daemon across sessions.
109
- // Kill manually with: kill $(lsof -ti:PORT) or pkill -f "serena start-mcp-server"
401
+ // No session_shutdown handlerSerena persists as a daemon across sessions.
402
+ // Next session_start that finds the port dead will reap orphans via PGID.
110
403
  }
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ /**
3
+ * E2E driver for serena-pool. Exercises the state machine without Pi.
4
+ *
5
+ * Usage:
6
+ * DEBUG=serena-pool npx tsx extensions/serena-pool/test/e2e.ts
7
+ * DEBUG=serena-pool bun extensions/serena-pool/test/e2e.ts
8
+ *
9
+ * Requires: `uvx` on PATH (the real Serena spawn path is exercised).
10
+ *
11
+ * Five scenarios:
12
+ * 1. cold start — clean slate → spawn, port listens, state written
13
+ * 2. warm reuse — second call → same pid, same state
14
+ * 3. dead recovery — kill Serena → next call spawns fresh
15
+ * 4. synthetic orphans — fake state pointing at a detached `sleep` group
16
+ * with dead leader → cleanup kills group members
17
+ * 5. concurrent spawn — 5 parallel calls → exactly one Serena alive
18
+ */
19
+ import { spawn, spawnSync } from "node:child_process";
20
+ import { mkdtempSync, rmSync, existsSync, readdirSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { setTimeout as sleep } from "node:timers/promises";
24
+
25
+ import {
26
+ ensureSerenaForRoot,
27
+ STATE_DIR,
28
+ __internals as I,
29
+ type PoolState,
30
+ } from "../index.ts";
31
+
32
+ let testsPassed = 0;
33
+ let testsFailed = 0;
34
+
35
+ function assert(cond: unknown, msg: string): void {
36
+ if (cond) {
37
+ console.log(` ✓ ${msg}`);
38
+ testsPassed++;
39
+ } else {
40
+ console.log(` ✗ ${msg}`);
41
+ testsFailed++;
42
+ }
43
+ }
44
+
45
+ function header(name: string): void {
46
+ console.log(`\n── ${name} ──`);
47
+ }
48
+
49
+ function cleanState(): void {
50
+ if (!existsSync(STATE_DIR)) return;
51
+ for (const f of readdirSync(STATE_DIR)) {
52
+ try { rmSync(join(STATE_DIR, f), { force: true }); } catch { /* ignore */ }
53
+ }
54
+ }
55
+
56
+ function killSerenaForPort(port: number): void {
57
+ const state = I.readState(port);
58
+ if (!state) return;
59
+ try { process.kill(-state.pgid, "SIGKILL"); } catch { /* ignore */ }
60
+ try { process.kill(state.pid, "SIGKILL"); } catch { /* ignore */ }
61
+ I.removeState(port);
62
+ }
63
+
64
+ function preflight(): boolean {
65
+ const uvx = spawnSync("which", ["uvx"], { encoding: "utf8" });
66
+ if (uvx.status !== 0 || !uvx.stdout.trim()) {
67
+ console.error("✗ uvx not on PATH — install uv first: https://docs.astral.sh/uv/");
68
+ return false;
69
+ }
70
+ console.log(`✓ uvx: ${uvx.stdout.trim()}`);
71
+ return true;
72
+ }
73
+
74
+ async function withTestRoot(fn: (root: string, port: number) => Promise<void>): Promise<void> {
75
+ const root = mkdtempSync(join(tmpdir(), "serena-pool-test-"));
76
+ const port = I.hashToPort(root);
77
+ try {
78
+ cleanState();
79
+ await fn(root, port);
80
+ } finally {
81
+ killSerenaForPort(port);
82
+ rmSync(root, { recursive: true, force: true });
83
+ }
84
+ }
85
+
86
+ async function test1_coldStart(): Promise<void> {
87
+ header("1. cold start");
88
+ await withTestRoot(async (root, expectedPort) => {
89
+ const port = await ensureSerenaForRoot(root);
90
+ assert(port === expectedPort, `port returned (${port}) === hashToPort(root) (${expectedPort})`);
91
+ assert(port != null && (await I.isPortListening(port)), "port is listening");
92
+ const state = I.readState(port!);
93
+ assert(state != null, "state file written");
94
+ assert(state != null && I.isPidAlive(state.pid), "recorded Serena pid is alive");
95
+ assert(state != null && state.pgid === state.pid, "pgid == pid (detached)");
96
+ });
97
+ }
98
+
99
+ async function test2_warmReuse(): Promise<void> {
100
+ header("2. warm reuse");
101
+ await withTestRoot(async (root) => {
102
+ const port1 = await ensureSerenaForRoot(root);
103
+ const state1 = I.readState(port1!)!;
104
+
105
+ const port2 = await ensureSerenaForRoot(root);
106
+ const state2 = I.readState(port2!)!;
107
+
108
+ assert(port1 === port2, "same port across calls");
109
+ assert(state1.pid === state2.pid, `same pid (no respawn) — pid=${state1.pid}`);
110
+ });
111
+ }
112
+
113
+ async function test3_deadDaemonRecovery(): Promise<void> {
114
+ header("3. dead-daemon recovery");
115
+ await withTestRoot(async (root) => {
116
+ const port = await ensureSerenaForRoot(root);
117
+ const before = I.readState(port!)!;
118
+ assert(I.isPidAlive(before.pid), `Serena up (pid=${before.pid})`);
119
+
120
+ // Kill the whole process group ungracefully
121
+ try { process.kill(-before.pgid, "SIGKILL"); } catch { /* ignore */ }
122
+ try { process.kill(before.pid, "SIGKILL"); } catch { /* ignore */ }
123
+ await sleep(800);
124
+ assert(!I.isPidAlive(before.pid), "Serena killed");
125
+ assert(!(await I.isPortListening(port!)), "port no longer listening");
126
+
127
+ const port2 = await ensureSerenaForRoot(root);
128
+ const after = I.readState(port2!)!;
129
+ assert(after.pid !== before.pid, `fresh spawn — new pid=${after.pid}`);
130
+ assert(I.isPidAlive(after.pid), "fresh Serena alive");
131
+ assert(await I.isPortListening(port2!), "port listening again");
132
+ });
133
+ }
134
+
135
+ async function test4_syntheticOrphanCleanup(): Promise<void> {
136
+ header("4. synthetic orphan cleanup");
137
+ await withTestRoot(async (root, port) => {
138
+ // Spawn `bash -c "sleep 9999 & sleep 9999 & wait"` detached so bash is a new
139
+ // group leader (pgid == bash pid). The two sleeps inherit that pgid.
140
+ const fake = spawn(
141
+ "bash",
142
+ ["-c", "sleep 9999 & sleep 9999 & wait"],
143
+ { detached: true, stdio: "ignore" },
144
+ );
145
+ fake.unref();
146
+ if (!fake.pid) throw new Error("could not spawn fake daemon");
147
+ const fakePid = fake.pid;
148
+ const fakePgid = fakePid;
149
+ await sleep(300);
150
+
151
+ const orphansBefore = I.findProcessesByPgid(fakePgid);
152
+ assert(orphansBefore.length >= 2, `2+ children in fake pgid before — found ${orphansBefore.length}`);
153
+
154
+ // Inject fake state matching this group, then kill the parent bash so
155
+ // children become orphans (reparented to PID 1, pgid retained).
156
+ const fakeStart = I.getProcessStartTime(fakePid);
157
+ const state: PoolState = {
158
+ pid: fakePid, pgid: fakePgid, startTime: fakeStart,
159
+ instanceId: "synthetic-test", projectRoot: root, port,
160
+ spawnedAt: Date.now(),
161
+ };
162
+ I.writeState(state);
163
+ process.kill(fakePid, "SIGKILL");
164
+ await sleep(500);
165
+ assert(!I.isPidAlive(fakePid), "fake daemon killed");
166
+ const stillOrphaned = I.findProcessesByPgid(fakePgid);
167
+ assert(stillOrphaned.length >= 2, `children survived parent death — orphans=${stillOrphaned.length}`);
168
+
169
+ // Trigger cleanup via the real code path (spawns a real Serena afterwards).
170
+ await ensureSerenaForRoot(root);
171
+ await sleep(500); // allow SIGTERM/SIGKILL fan-out to land
172
+
173
+ const orphansAfter = I.findProcessesByPgid(fakePgid);
174
+ assert(orphansAfter.length === 0, `orphans reaped — remaining=${orphansAfter.length}`);
175
+ });
176
+ }
177
+
178
+ async function test5_concurrentSpawn(): Promise<void> {
179
+ header("5. concurrent spawn");
180
+ await withTestRoot(async (root) => {
181
+ const results = await Promise.all(
182
+ Array.from({ length: 5 }, () => ensureSerenaForRoot(root)),
183
+ );
184
+ const unique = new Set(results);
185
+ assert(unique.size === 1, `all 5 calls returned the same port — got ${unique.size} unique`);
186
+
187
+ // Count actual Serena processes for this port.
188
+ const port = results[0]!;
189
+ const state = I.readState(port)!;
190
+ assert(I.isPidAlive(state.pid), "one Serena alive");
191
+
192
+ // Count actual TCP listeners on the port — uvx spawns a python child, so a
193
+ // command-line pattern match would over-count. Only one process binds LISTEN.
194
+ const ssOut = spawnSync("ss", ["-tlnpH"], { encoding: "utf8" }).stdout;
195
+ const listeners = ssOut.split("\n").filter((l) => l.includes(`:${port} `)).length;
196
+ assert(listeners === 1, `exactly one TCP listener on port ${port} — found ${listeners}`);
197
+ });
198
+ }
199
+
200
+ async function main(): Promise<void> {
201
+ console.log("serena-pool e2e driver\n");
202
+ if (!preflight()) process.exit(2);
203
+
204
+ const tests = [
205
+ test1_coldStart,
206
+ test2_warmReuse,
207
+ test3_deadDaemonRecovery,
208
+ test4_syntheticOrphanCleanup,
209
+ test5_concurrentSpawn,
210
+ ];
211
+
212
+ for (const t of tests) {
213
+ try {
214
+ await t();
215
+ } catch (err) {
216
+ console.error(` ✗ test threw: ${err instanceof Error ? err.message : err}`);
217
+ testsFailed++;
218
+ }
219
+ }
220
+
221
+ console.log(`\n────────────────────────────────`);
222
+ console.log(`passed: ${testsPassed} failed: ${testsFailed}`);
223
+ process.exit(testsFailed > 0 ? 1 : 0);
224
+ }
225
+
226
+ main().catch((err) => {
227
+ console.error("fatal:", err);
228
+ process.exit(2);
229
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaggerxtrm/pi-extensions",
3
- "version": "0.7.22",
3
+ "version": "0.7.23",
4
4
  "description": "Unified Pi extension entrypoint for xtrm-managed extensions",
5
5
  "type": "module",
6
6
  "publishConfig": {