@celilo/e2e 0.20.0 → 0.20.2

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.
@@ -3,7 +3,17 @@ import { readFileSync } from 'node:fs';
3
3
  import { hostname } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { type DockerReader, LiveStackError, findLiveE2eStack } from './live-stack';
6
+ import {
7
+ type DockerReader,
8
+ LiveStackError,
9
+ type ProcessProbe,
10
+ SHARED_ORPHAN_MIN_AGE_MS,
11
+ UNIT_ONLY_ENV_ENTRY,
12
+ environMarksUnitOnly,
13
+ findLiveE2eStack,
14
+ isUnitOnlyProcess,
15
+ looksLikeE2eRunCommand,
16
+ } from './live-stack';
7
17
  import type { LockStatus } from './run-lock';
8
18
  import { startupCleanup } from './shared-infra';
9
19
 
@@ -67,11 +77,24 @@ describe('findLiveE2eStack', () => {
67
77
  const { docker } = fakeDocker(
68
78
  'celilo-e2e-shared_namecheap-dns\trunning\ncelilo-e2e-1788769175864_fw-main\texited\n',
69
79
  );
70
- const refusal = findLiveE2eStack(docker, noLock);
80
+ // The default probe reads the REAL host process table. Under another
81
+ // session's live e2e run (celilo#1332: the bun 1.4.2 grind) the
82
+ // shared-only + live-runner branch fires instead of the unreadable-age
83
+ // branch pinned here, and the refusal names pid counts instead of the
84
+ // container. A test pinning a message shape injects its inputs: an empty
85
+ // probe makes the pinned shape deterministic.
86
+ const refusal = findLiveE2eStack(docker, noLock, () => []);
71
87
  expect(refusal).not.toBeNull();
72
88
  expect(refusal?.reason).toContain('celilo-e2e-shared_namecheap-dns');
73
89
  expect(refusal?.reason).not.toContain('celilo-e2e-1788769175864_fw-main');
74
- expect(refusal?.reason).toContain('cele2e down');
90
+ // The remedy must be a command that EXISTS on the host printing the
91
+ // message (celilo#1314): a bare `cele2e down` does not — forgejo job
92
+ // workspaces are ephemeral, so the binary only exists inside a checkout.
93
+ expect(refusal?.reason).toContain('docker rm -f');
94
+ expect(refusal?.reason).not.toContain('cele2e down');
95
+ // The refusal must be greppable as an environment problem, not a check
96
+ // failure (celilo#1314 direction 3).
97
+ expect(refusal?.reason).toContain('[infra-refusal]');
75
98
  expect(refusal?.runningContainers).toEqual(['celilo-e2e-shared_namecheap-dns']);
76
99
  });
77
100
 
@@ -131,6 +154,150 @@ describe('findLiveE2eStack', () => {
131
154
  });
132
155
  });
133
156
 
157
+ describe('unit-only exclusion (celilo#1320)', () => {
158
+ const environOf =
159
+ (byPid: Record<number, string | null>) =>
160
+ (pid: number): string | null =>
161
+ byPid[pid] ?? null;
162
+
163
+ test('an environ carrying the marker is unit-only', () => {
164
+ expect(
165
+ environMarksUnitOnly(['PATH=/usr/bin', UNIT_ONLY_ENV_ENTRY, 'HOME=/root'].join('\0')),
166
+ ).toBe(true);
167
+ });
168
+
169
+ test('the marker must be a whole entry, not a substring', () => {
170
+ expect(environMarksUnitOnly(['PATH=/x', 'SOMETHING_CELILO_UNIT_ONLY=12'].join('\0'))).toBe(
171
+ false,
172
+ );
173
+ expect(environMarksUnitOnly('CELILO_UNIT_ONLY=12')).toBe(false);
174
+ });
175
+
176
+ test('an unreadable environ is NOT unit-only (inconclusive never reaps)', () => {
177
+ expect(environMarksUnitOnly(null)).toBe(false);
178
+ });
179
+
180
+ test('isUnitOnlyProcess reads the environ of the matched pid', () => {
181
+ const environ = environOf({
182
+ 101: ['PATH=/x', UNIT_ONLY_ENV_ENTRY].join('\0'),
183
+ 102: 'PATH=/x',
184
+ 103: null,
185
+ });
186
+ expect(isUnitOnlyProcess(101, environ)).toBe(true);
187
+ expect(isUnitOnlyProcess(102, environ)).toBe(false);
188
+ expect(isUnitOnlyProcess(103, environ)).toBe(false);
189
+ });
190
+
191
+ test('the marker matches what test:unit actually exports', () => {
192
+ const rootPackageJson = JSON.parse(
193
+ readFileSync(join(DIR, '../../../package.json'), 'utf-8'),
194
+ ) as { scripts: Record<string, string> };
195
+ expect(rootPackageJson.scripts['test:unit']).toContain('CELILO_UNIT_ONLY=1');
196
+ });
197
+ });
198
+
199
+ describe('looksLikeE2eRunCommand', () => {
200
+ test('matches the cele2e CLI by any argv mention', () => {
201
+ expect(looksLikeE2eRunCommand('cele2e run smoke')).toBe(true);
202
+ expect(looksLikeE2eRunCommand('./node_modules/.bin/cele2e run smoke')).toBe(true);
203
+ expect(looksLikeE2eRunCommand('bun run packages/e2e/bin/cele2e.ts run --all')).toBe(true);
204
+ });
205
+
206
+ test('matches a direct bun test of this package, which has no cele2e in argv', () => {
207
+ expect(looksLikeE2eRunCommand('bun test packages/e2e/tests/dns-replication.test.ts')).toBe(
208
+ true,
209
+ );
210
+ expect(looksLikeE2eRunCommand('bun test e2e/tests/smoke.test.ts')).toBe(true);
211
+ });
212
+
213
+ test('does not match unrelated bun test runs, editors, or scripts', () => {
214
+ expect(looksLikeE2eRunCommand('bun test packages/mcp-server/src/tools.test.ts')).toBe(false);
215
+ expect(looksLikeE2eRunCommand('vi packages/e2e/src/live-stack.test.ts')).toBe(false);
216
+ expect(looksLikeE2eRunCommand('bun run packages/e2e/scripts/pack-celilo-packages.ts')).toBe(
217
+ false,
218
+ );
219
+ expect(looksLikeE2eRunCommand('bun build apps/celilo/src/index.ts')).toBe(false);
220
+ });
221
+ });
222
+
223
+ describe('shared-only orphan reap (celilo#1314)', () => {
224
+ // Docker's `{{.CreatedAt}}` format, e.g. "2026-09-07 18:49:14 +0000 UTC".
225
+ const dockerAge = (msAgo: number): string => {
226
+ const d = new Date(Date.now() - msAgo);
227
+ return `${d.toISOString().slice(0, 10)} ${d.toISOString().slice(11, 19)} +0000 UTC`;
228
+ };
229
+ const noProcesses: ProcessProbe = () => [];
230
+
231
+ test('REAPS the exact shape measured on the builder: shared-only stack, 11h old, no per-test project, no cele2e process', () => {
232
+ const ps = [
233
+ `celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(11 * 3_600_000)}`,
234
+ `celilo-e2e-shared_registry-1\trunning\t${dockerAge(11 * 3_600_000)}`,
235
+ ].join('\n');
236
+ const { docker, commands } = fakeDocker(ps);
237
+ // No containers at all is NOT the test: the guard must see the live shared
238
+ // containers and STILL clear the way, because every piece of run evidence
239
+ // is absent.
240
+ expect(findLiveE2eStack(docker, noLock, noProcesses)).toBeNull();
241
+ startupCleanup('/tmp/e2e', docker, noLock, noProcesses);
242
+ // The reap is a real teardown, not a refusal: the shared compose down ran.
243
+ expect(commands.some((c) => c.includes(' down '))).toBe(true);
244
+ });
245
+
246
+ test('must NOT reap a shared stack with a live per-test project beside it (the celilo#1297 shape)', () => {
247
+ const ps = [
248
+ `celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(11 * 3_600_000)}`,
249
+ `celilo-e2e-1788769175864_fw-main\trunning\t${dockerAge(5 * 60_000)}`,
250
+ ].join('\n');
251
+ const { docker, commands } = fakeDocker(ps);
252
+ const refusal = findLiveE2eStack(docker, noLock, noProcesses);
253
+ expect(refusal).not.toBeNull();
254
+ expect(refusal?.runningContainers).toContain('celilo-e2e-shared_namecheap-dns-1');
255
+ expect(refusal?.runningContainers).toContain('celilo-e2e-1788769175864_fw-main');
256
+ expect(() => startupCleanup('/tmp/e2e', docker, noLock, noProcesses)).toThrow(LiveStackError);
257
+ expect(commands.some((c) => c.includes(' down '))).toBe(false);
258
+ });
259
+
260
+ test('must NOT reap while any other cele2e process is alive — a run may be between suites', () => {
261
+ const ps = `celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(11 * 3_600_000)}`;
262
+ const { docker } = fakeDocker(ps);
263
+ const oneRunner: ProcessProbe = () => [424242];
264
+ const refusal = findLiveE2eStack(docker, noLock, oneRunner);
265
+ expect(refusal).not.toBeNull();
266
+ expect(refusal?.reason).toContain('424242');
267
+ });
268
+
269
+ test(`must NOT reap a stack younger than ${SHARED_ORPHAN_MIN_AGE_MS / 60_000}m — the evidence is not old enough to be conclusive`, () => {
270
+ const ps = `celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(5 * 60_000)}`;
271
+ const { docker } = fakeDocker(ps);
272
+ const refusal = findLiveE2eStack(docker, noLock, noProcesses);
273
+ expect(refusal).not.toBeNull();
274
+ expect(refusal?.reason).toContain('5m');
275
+ });
276
+
277
+ test('must NOT reap when the age cannot be read — inconclusive evidence refuses, it never reaps', () => {
278
+ // Two fields, no CreatedAt: the pre-#1314 fake shape, and anything docker
279
+ // might print that the parser does not recognize.
280
+ const { docker } = fakeDocker('celilo-e2e-shared_namecheap-dns\trunning\n');
281
+ const refusal = findLiveE2eStack(docker, noLock, noProcesses);
282
+ expect(refusal).not.toBeNull();
283
+ expect(refusal?.reason).toContain('age');
284
+ });
285
+
286
+ test('a foreign fresh lock still refuses a shared-only orphan — the reap never overrides the lock', () => {
287
+ const ps = `celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(11 * 3_600_000)}`;
288
+ const { docker } = fakeDocker(ps);
289
+ const refusal = findLiveE2eStack(docker, foreignLock(), noProcesses);
290
+ expect(refusal).not.toBeNull();
291
+ expect(refusal?.reason).toContain('polecat/ce-9999');
292
+ });
293
+
294
+ test('exited containers of the shared project alone still proceed (unchanged)', () => {
295
+ const ps = `celilo-e2e-shared_registry-1\texited\t${dockerAge(11 * 3_600_000)}`;
296
+ const { docker } = fakeDocker(ps);
297
+ expect(findLiveE2eStack(docker, noLock, noProcesses)).toBeNull();
298
+ });
299
+ });
300
+
134
301
  describe('startupCleanup', () => {
135
302
  test('refuses behind a live stack and removes NOTHING (celilo#1297)', () => {
136
303
  const { docker, commands } = fakeDocker('celilo-e2e-shared_namecheap-dns\trunning\n');
@@ -171,7 +338,7 @@ describe('wiring in shared-infra.ts', () => {
171
338
  SHARED_INFRA_SRC.indexOf('export function startupCleanup'),
172
339
  SHARED_INFRA_SRC.indexOf('nukeE2eResources(e2eDir, docker)'),
173
340
  );
174
- expect(cleanupBody).toContain('findLiveE2eStack(docker, lock)');
341
+ expect(cleanupBody).toContain('findLiveE2eStack(docker, lock, processes)');
175
342
  });
176
343
 
177
344
  test('the DNS-restart branch guards before tearing down the running stack', () => {
package/src/live-stack.ts CHANGED
@@ -19,10 +19,43 @@
19
19
  * Deliberately NOT guarded: the runner's end-of-run stopSharedInfra teardown.
20
20
  * The owner tearing down its own live stack is the normal exit path, and the
21
21
  * guard's container check would refuse it by definition.
22
+ *
23
+ * The shared-stack orphan reap (celilo#1314). The #1297 guard is keyed on
24
+ * "any live celilo-e2e-* container", and the shared stack is designed never to
25
+ * be touched by name-protecting cleanup — so one abnormal exit wedges the
26
+ * host permanently: the orphaned shared stack refuses every later cleanup, it
27
+ * is the one thing the guard structurally cannot resolve. Measured on the
28
+ * builder 2026-09-08: 13 celilo-e2e-shared-* containers, 11h old, no per-test
29
+ * project, no cele2e process, and every npm-consumer-smoke run red on a check
30
+ * that never executed. So a stack whose ONLY live containers belong to the
31
+ * shared project gets an evidence test instead of a blanket refusal: no
32
+ * foreign lock, no live run process, and an age past a threshold means the
33
+ * stack is garbage and cleanup may act. Any single piece of run evidence
34
+ * present, or any age that cannot be read, refuses — inconclusive evidence
35
+ * never reaps, because a false reap kills a real run while a false refusal
36
+ * costs the operator one docker command.
37
+ *
38
+ * The refusal itself names a command that exists on the host printing it (a
39
+ * bare `cele2e down` does not: forgejo job workspaces are ephemeral, so the
40
+ * binary only exists inside a checkout), and every refusal reason carries the
41
+ * `[infra-refusal]` marker with its exit code, so a log search distinguishes
42
+ * an environment problem from a check failure (celilo#1314 directions 2+3).
43
+ *
44
+ * The unit-only exclusion (celilo#1320). ci/validate runs `bun run test:unit`
45
+ * on the same persistent builder as npm-consumer-smoke, and every `bun test`
46
+ * child it spawns matched the probe's bun-test arm while managing no Docker
47
+ * at all — the smoke job's teardown then declined to clear a finished run's
48
+ * shared stack, and the next run inside the orphan hour refused. A process
49
+ * whose environ carries CELILO_UNIT_ONLY=1 (the root script exports it; every
50
+ * child inherits) is a unit-test run and is not run evidence. An unreadable
51
+ * environ keeps the match: the exclusion follows the same bias as the
52
+ * evidence test itself, inconclusive never reaps.
22
53
  */
23
54
 
24
55
  import type { ExecFileSyncOptions } from 'node:child_process';
25
56
  import { execFileSync } from 'node:child_process';
57
+ import { readFileSync } from 'node:fs';
58
+ import { SHARED_PROJECT_NAME } from './docker-compose-generator';
26
59
  import {
27
60
  type LockHolder,
28
61
  type LockStatus,
@@ -65,6 +98,193 @@ export class LiveStackError extends Error {
65
98
  }
66
99
  }
67
100
 
101
+ /**
102
+ * How old the youngest live shared-stack container must be before a
103
+ * shared-only stack with no other run evidence is treated as an orphan.
104
+ * One hour sits far above any window in which a run process could have died
105
+ * without its containers dying too, and far below the 11h the builder sat
106
+ * wedged (celilo#1314). A stack younger than this refuses even with no other
107
+ * evidence: the reap is the dangerous direction, so it waits for certainty.
108
+ */
109
+ export const SHARED_ORPHAN_MIN_AGE_MS = 60 * 60_000;
110
+
111
+ /**
112
+ * Live pids of processes that may own an e2e run. Injectable so the reap's
113
+ * evidence test is unit-testable without a process table.
114
+ */
115
+ export type ProcessProbe = () => number[];
116
+
117
+ /**
118
+ * The env entry `bun run test:unit` exports (root package.json). A process
119
+ * carrying it is a unit-test run: the e2e unit suites exercise Docker only
120
+ * through injected readers, so it never manages the shared stack.
121
+ */
122
+ export const UNIT_ONLY_ENV_ENTRY = 'CELILO_UNIT_ONLY=1';
123
+
124
+ /**
125
+ * Raw environment of one pid (NUL-separated entries), or null when it cannot
126
+ * be read — /proc is Linux-only, and a foreign-uid environ is unreadable even
127
+ * where /proc exists.
128
+ */
129
+ export type EnvironReader = (pid: number) => string | null;
130
+
131
+ export const realEnvironReader: EnvironReader = (pid) => {
132
+ try {
133
+ return readFileSync(`/proc/${pid}/environ`, 'utf8');
134
+ } catch {
135
+ return null;
136
+ }
137
+ };
138
+
139
+ /**
140
+ * Does a raw environ block mark its process as unit-only? Pure so the
141
+ * probe's exclusion is testable without a filesystem. A null (unreadable)
142
+ * environ is NOT unit-only: an unreadable answer is a missing answer, and the
143
+ * probe biases to refusing — a false refusal costs the operator one docker
144
+ * command, a false miss reaps a live run.
145
+ */
146
+ export function environMarksUnitOnly(environ: string | null): boolean {
147
+ if (environ === null) return false;
148
+ return environ.split('\0').includes(UNIT_ONLY_ENV_ENTRY);
149
+ }
150
+
151
+ /** The exclusion realProcessProbe applies to its matches; injectable for tests. */
152
+ export function isUnitOnlyProcess(
153
+ pid: number,
154
+ environ: EnvironReader = realEnvironReader,
155
+ ): boolean {
156
+ return environMarksUnitOnly(environ(pid));
157
+ }
158
+
159
+ /**
160
+ * Does this process command line look like an e2e run? Pure so the probe's
161
+ * reach is testable. Matches the two ways a run actually exists:
162
+ * - the cele2e CLI and anything whose argv names it (`cele2e run|up|down|...`)
163
+ * - a direct `bun test` of this package's suites, which manages the shared
164
+ * stack through ensureSharedInfra but has no cele2e in argv
165
+ * The bun-test arm requires bun AND e2e AND test in the command, so an
166
+ * unrelated `bun test` elsewhere only false-matches when its path names e2e —
167
+ * and a false match refuses (cheap), where a false miss reaps a live run
168
+ * (expensive). An editor with an e2e test file open does not match: it does
169
+ * not start with a bun invocation.
170
+ */
171
+ export function looksLikeE2eRunCommand(command: string): boolean {
172
+ if (command.includes('cele2e')) return true;
173
+ return (
174
+ /(^|[\\/])bun(\.exe)?\s/.test(command) && command.includes('e2e') && command.includes('test')
175
+ );
176
+ }
177
+
178
+ /** pids of this process's ancestors, self included, bounded at 64 levels. */
179
+ function familyOfSelf(): Set<number> {
180
+ const family = new Set<number>([process.pid]);
181
+ let pid: number | undefined = process.ppid;
182
+ for (let i = 0; pid !== undefined && pid > 1 && i < 64; i++) {
183
+ family.add(pid);
184
+ try {
185
+ const out = execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], {
186
+ encoding: 'utf-8',
187
+ stdio: ['ignore', 'pipe', 'ignore'],
188
+ }).trim();
189
+ const ppid = Number.parseInt(out, 10);
190
+ pid = Number.isFinite(ppid) ? ppid : undefined;
191
+ } catch {
192
+ pid = undefined;
193
+ }
194
+ }
195
+ return family;
196
+ }
197
+
198
+ /**
199
+ * The real probe: one `ps` listing, filtered by looksLikeE2eRunCommand, with
200
+ * this process and its ancestry removed — the cleanup runs INSIDE the run it
201
+ * would otherwise see as evidence, and the run's own launcher shell carries
202
+ * the same strings in its argv.
203
+ */
204
+ export const realProcessProbe: ProcessProbe = (): number[] => {
205
+ let listing: string;
206
+ try {
207
+ listing = execFileSync('ps', ['-axo', 'pid=,command='], {
208
+ encoding: 'utf-8',
209
+ stdio: ['ignore', 'pipe', 'ignore'],
210
+ });
211
+ } catch {
212
+ // A process table that cannot be read is missing evidence. The caller
213
+ // treats any probe failure conservatively; here that means reporting no
214
+ // matches, which the shared-only path then backstops with the age
215
+ // threshold and the unreadable-age refusal.
216
+ return [];
217
+ }
218
+ const family = familyOfSelf();
219
+ const pids: number[] = [];
220
+ for (const line of listing.split('\n')) {
221
+ const trimmed = line.trim();
222
+ if (!trimmed) continue;
223
+ const sep = trimmed.indexOf(' ');
224
+ if (sep <= 0) continue;
225
+ const pid = Number.parseInt(trimmed.slice(0, sep), 10);
226
+ if (!Number.isFinite(pid) || family.has(pid)) continue;
227
+ if (!looksLikeE2eRunCommand(trimmed.slice(sep + 1))) continue;
228
+ // celilo#1320: a unit-test run matches the command probe but manages no
229
+ // Docker. An unreadable environ keeps the match — inconclusive never
230
+ // reaps.
231
+ if (isUnitOnlyProcess(pid)) continue;
232
+ pids.push(pid);
233
+ }
234
+ return pids;
235
+ };
236
+
237
+ /**
238
+ * Every refusal goes through here so the printed reason is uniformly
239
+ * greppable as an infrastructure refusal (celilo#1314 direction 3) rather
240
+ * than reading as a check failure.
241
+ */
242
+ function makeRefusal(
243
+ detail: string,
244
+ runningContainers: string[],
245
+ holder: LockHolder | null,
246
+ ): LiveStackRefusal {
247
+ const reason = `[infra-refusal] refusing to clean up: ${detail}\n(this is an environment problem, not a test failure — the run exits 3)`;
248
+ return { reason, runningContainers, holder };
249
+ }
250
+
251
+ /**
252
+ * The remedy printed with a live-stack refusal. A raw docker removal, because
253
+ * `cele2e down` does not exist on the hosts that print this message: forgejo
254
+ * job workspaces are ephemeral, so the binary only exists inside a checkout
255
+ * (celilo#1314 direction 2). Removing the containers is what unblocks the
256
+ * guard; the next cleanup sweeps whatever name-prefix resources survive.
257
+ */
258
+ export const CLEAR_STACK_COMMAND = 'docker rm -f $(docker ps -aq --filter name=celilo-e2e)';
259
+
260
+ /** Docker's `{{.CreatedAt}}` format, e.g. "2026-09-07 18:49:14 +0000 UTC". */
261
+ function parseDockerCreatedAt(raw: string | undefined): Date | null {
262
+ if (raw === undefined) return null;
263
+ const m = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(?:\.(\d+))? ([+-])(\d{2})(\d{2})/.exec(
264
+ raw.trim(),
265
+ );
266
+ if (!m) return null;
267
+ const [, date, time, frac, sign, offH, offM] = m;
268
+ const utcMs = Date.parse(`${date}T${time}Z`);
269
+ if (Number.isNaN(utcMs)) return null;
270
+ const offsetMs = (sign === '-' ? -1 : 1) * (Number(offH) * 60 + Number(offM)) * 60_000;
271
+ const fracMs = frac ? Number(frac.padEnd(3, '0').slice(0, 3)) : 0;
272
+ return new Date(utcMs - offsetMs + fracMs);
273
+ }
274
+
275
+ /**
276
+ * Containers of the shared compose project. Compose names them
277
+ * `<project>_<service>_<n>` (or with `-` separators on newer compose), so the
278
+ * character right after the project name is the tell. Everything else live —
279
+ * per-test projects AND sim-created guests, whose names carry no project —
280
+ * is run evidence.
281
+ */
282
+ function isSharedStackContainer(name: string): boolean {
283
+ if (!name.startsWith(SHARED_PROJECT_NAME)) return false;
284
+ const sep = name[SHARED_PROJECT_NAME.length];
285
+ return sep === '_' || sep === '-';
286
+ }
287
+
68
288
  /**
69
289
  * Container states that mean the container cannot be holding a live stack.
70
290
  * Everything else (running, paused, restarting) is live: paused containers
@@ -90,17 +310,18 @@ function isOwnHolder(h: LockHolder): boolean {
90
310
  export function findLiveE2eStack(
91
311
  docker: DockerReader = realDocker,
92
312
  lock: () => LockStatus = lockStatus,
313
+ processes: ProcessProbe = realProcessProbe,
93
314
  ): LiveStackRefusal | null {
94
315
  const status = lock();
95
316
  if (!status.free && status.holder && !isOwnHolder(status.holder)) {
96
317
  const h = status.holder;
97
318
  const heartbeat =
98
319
  h.state === 'running' ? `, heartbeat ${formatAge(heartbeatAgeMs(h))} old` : '';
99
- return {
100
- reason: `refusing to clean up: the run lock is held by another session — ${formatBusy(h)}${heartbeat}`,
101
- runningContainers: [],
102
- holder: h,
103
- };
320
+ return makeRefusal(
321
+ `the run lock is held by another session — ${formatBusy(h)}${heartbeat}`,
322
+ [],
323
+ h,
324
+ );
104
325
  }
105
326
 
106
327
  const out = docker([
@@ -109,21 +330,67 @@ export function findLiveE2eStack(
109
330
  '--filter',
110
331
  'name=celilo-e2e',
111
332
  '--format',
112
- '{{.Names}}\t{{.State}}',
333
+ '{{.Names}}\t{{.State}}\t{{.CreatedAt}}',
113
334
  ]);
114
- const running = out
335
+ const live = out
115
336
  .split('\n')
116
337
  .filter(Boolean)
117
- .map((line) => line.split('\t'))
118
- .filter((parts) => parts.length === 2 && !DEAD_STATES.has(parts[1] ?? ''))
119
- .map((parts) => parts[0] ?? '');
120
- if (running.length > 0) {
121
- return {
122
- reason: `refusing to clean up: ${running.length} live celilo-e2e-* container(s):\n${running.map((n) => ` - ${n}`).join('\n')}\nA live stack owns these. Clear it with: cele2e down`,
123
- runningContainers: running,
124
- holder: null,
125
- };
338
+ .map((line) => {
339
+ const [name, state, createdAt] = line.split('\t');
340
+ return { name: name ?? '', state: state ?? '', createdAt: parseDockerCreatedAt(createdAt) };
341
+ })
342
+ .filter((c) => c.name !== '' && !DEAD_STATES.has(c.state));
343
+ if (live.length === 0) return null;
344
+
345
+ // A per-test container (or a guest, which carries no project name at all)
346
+ // means a run owns this host. Refuse regardless of the shared stack — this
347
+ // is the #1297 protection, unchanged.
348
+ const runOwned = live.filter((c) => !isSharedStackContainer(c.name));
349
+ if (runOwned.length > 0) {
350
+ return makeRefusal(
351
+ `${live.length} live celilo-e2e-* container(s):\n${live.map((c) => ` - ${c.name}`).join('\n')}\nA live stack owns these. If the run is gone and the stack is truly abandoned, clear it with:\n ${CLEAR_STACK_COMMAND}\n(there may be no cele2e binary outside a checkout — celilo#1314)`,
352
+ live.map((c) => c.name),
353
+ null,
354
+ );
126
355
  }
356
+
357
+ // celilo#1314: ONLY shared-stack containers are live. Refusing here
358
+ // unconditionally is what wedged the builder — an orphaned shared stack is
359
+ // the one state the guard could never resolve. Apply the orphan evidence
360
+ // test instead. Any inconclusive answer refuses.
361
+ const runners = processes();
362
+ if (runners.length > 0) {
363
+ return makeRefusal(
364
+ `a shared-only stack is live (${live.length} celilo-e2e-shared-* container(s)), but a run process is still alive (pid ${runners.join(', ')}) — it may be between suites and about to use the stack. If it is truly abandoned, clear it with:\n ${CLEAR_STACK_COMMAND}`,
365
+ live.map((c) => c.name),
366
+ null,
367
+ );
368
+ }
369
+
370
+ const youngest = Math.min(
371
+ ...live.map((c) => {
372
+ if (c.createdAt === null) return Number.NaN;
373
+ return c.createdAt.getTime();
374
+ }),
375
+ );
376
+ if (!Number.isFinite(youngest)) {
377
+ return makeRefusal(
378
+ `a shared-only stack is live (${live.map((c) => c.name).join(', ')}), but its container age cannot be read from docker — the orphan evidence is inconclusive. If it is truly abandoned, clear it with:\n ${CLEAR_STACK_COMMAND}`,
379
+ live.map((c) => c.name),
380
+ null,
381
+ );
382
+ }
383
+ const ageMs = Date.now() - youngest;
384
+ if (ageMs < SHARED_ORPHAN_MIN_AGE_MS) {
385
+ return makeRefusal(
386
+ `a shared-only stack is live (${live.length} celilo-e2e-shared-* container(s)), but its youngest container is only ${formatAge(ageMs)} old — under the ${SHARED_ORPHAN_MIN_AGE_MS / 60_000}m orphan threshold, so it may belong to a run the probe cannot see. If it is truly abandoned, clear it with:\n ${CLEAR_STACK_COMMAND}`,
387
+ live.map((c) => c.name),
388
+ null,
389
+ );
390
+ }
391
+
392
+ // Every piece of run evidence is absent, and the stack is old enough that
393
+ // no live run can be hiding from the probe. Provably dead: reap it.
127
394
  return null;
128
395
  }
129
396
 
@@ -137,8 +404,9 @@ export function findLiveE2eStack(
137
404
  export function refuseOnLiveStack(
138
405
  docker: DockerReader = realDocker,
139
406
  lock: () => LockStatus = lockStatus,
407
+ processes: ProcessProbe = realProcessProbe,
140
408
  ): void {
141
- const refusal = findLiveE2eStack(docker, lock);
409
+ const refusal = findLiveE2eStack(docker, lock, processes);
142
410
  if (!refusal) return;
143
411
  console.error(`\n${refusal.reason}\n`);
144
412
  process.exit(3);
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { describe, expect, test } from 'bun:test';
11
- import { parseModuleHost } from './module-host';
11
+ import { parseModuleHost, parseModuleWhere } from './module-host';
12
12
 
13
13
  /** How the CLI really prints it — clack gutter and ANSI included. */
14
14
  const withGutter = (line: string) => `\x1b[1mModule: caddy\x1b[0m\n│ Placement:\n│ ${line}\n`;
@@ -69,3 +69,57 @@ describe('parseModuleHost', () => {
69
69
  expect(parseModuleHost(output)?.hostname).toBe('web');
70
70
  });
71
71
  });
72
+
73
+ describe('parseModuleWhere', () => {
74
+ test('the inventory payload yields the recorded address', () => {
75
+ const payload = JSON.stringify({
76
+ module: 'caddy-internal',
77
+ systems: [
78
+ {
79
+ name: 'main',
80
+ hostname: 'caddy-internal',
81
+ ipv4_address: '10.226.30.14',
82
+ zone: 'dmz',
83
+ vmid: 2201,
84
+ infra_type: 'container_service',
85
+ placement: 'caddy-internal (vmid 2201) → pve1 (zone dmz)',
86
+ reachability: "firewall-segmented (dmz) — reach via the firewall's natIp DNAT",
87
+ },
88
+ ],
89
+ });
90
+
91
+ expect(parseModuleWhere(payload)).toEqual(['10.226.30.14']);
92
+ });
93
+
94
+ test('ANSI and whitespace around the payload do not break it', () => {
95
+ // The success path prints verbatim (celilo#698), but dev-mode shims have
96
+ // surprised us before, so the parse tolerates decoration.
97
+ const payload = `\x1b[1m${JSON.stringify({ systems: [{ ipv4_address: '10.226.30.15' }] })}\x1b[0m\n`;
98
+
99
+ expect(parseModuleWhere(payload)).toEqual(['10.226.30.15']);
100
+ });
101
+
102
+ test('a module with several systems yields them all, caller picks', () => {
103
+ const payload = JSON.stringify({
104
+ systems: [
105
+ { name: 'main', ipv4_address: '10.226.20.9' },
106
+ { name: 'replica', ipv4_address: '10.226.20.10' },
107
+ ],
108
+ });
109
+
110
+ expect(parseModuleWhere(payload)).toEqual(['10.226.20.9', '10.226.20.10']);
111
+ });
112
+
113
+ test('an API-only module yields an empty list, a real state', () => {
114
+ // namecheap has no host. Empty is not a parse failure — the caller decides
115
+ // whether "no recorded address" is expected for their module.
116
+ expect(parseModuleWhere(JSON.stringify({ systems: [] }))).toEqual([]);
117
+ });
118
+
119
+ test('a non-JSON answer yields an empty list, not a throw', () => {
120
+ // Crash text on stdout is a CLI failure and the harness method attaches
121
+ // the raw output to its error; the parser's contract is "addresses found",
122
+ // not "reasons why not".
123
+ expect(parseModuleWhere('Error: module not found: caddy\n')).toEqual([]);
124
+ });
125
+ });
@@ -70,3 +70,34 @@ export function parseModuleHost(statusOutput: string): ModuleHost | null {
70
70
 
71
71
  return null;
72
72
  }
73
+
74
+ /**
75
+ * The address(es) a module's deploy recorded, from `celilo module where --json`.
76
+ *
77
+ * The old source was `grep target_ip` over the module's `generated/` tree
78
+ * (celilo#1334). D4 of control-plane-stops-building-modules makes that tree
79
+ * ephemeral — a successful deploy deletes it — so the inventory is where the
80
+ * address lives now: `module_systems` is written by the deploy itself and
81
+ * survives it. Every current module declares exactly one system, so callers
82
+ * take the first address; a module with none is an API-only module or an
83
+ * undeployed one, and that distinction belongs to the caller.
84
+ */
85
+ export function parseModuleWhere(whereOutput: string): string[] {
86
+ // The success path prints the payload verbatim (celilo#698), but defensive
87
+ // ANSI stripping costs one line and dev-mode shims have surprised us before.
88
+ const stripped = whereOutput.replace(/\x1b\[[0-9;]*m/g, '').trim();
89
+
90
+ let parsed: { systems?: { ipv4_address?: string }[] };
91
+ try {
92
+ parsed = JSON.parse(stripped) as { systems?: { ipv4_address?: string }[] };
93
+ } catch {
94
+ // A non-JSON answer is a CLI failure (version drift, crash text) arriving
95
+ // on stdout. An empty list reads as "no systems", which is a real state a
96
+ // caller may legitimately handle — these are not the same problem.
97
+ return [];
98
+ }
99
+
100
+ return (parsed.systems ?? [])
101
+ .map((system) => system.ipv4_address ?? '')
102
+ .filter((address) => address !== '');
103
+ }