@celilo/e2e 0.20.0 → 0.20.1

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.
@@ -33,6 +33,7 @@ import {
33
33
  stageWebsiteDist,
34
34
  } from '../src/stage-simulator-inputs';
35
35
  import {
36
+ CONSUMER_FINGERPRINT_PREFIX,
36
37
  PUBLISHED_FINGERPRINT_PREFIX,
37
38
  SOURCE_LABEL,
38
39
  } from '../src/source-fingerprint';
@@ -209,8 +210,25 @@ function simStampFromManifest(): string {
209
210
  * whether the CLI under test is the code in the working tree. See
210
211
  * `src/source-fingerprint.ts` for why that has to be measured, not remembered.
211
212
  */
213
+ /**
214
+ * `celilo --version` prints "celilo <semver>", and `docker commit --change
215
+ * "LABEL name=value"` splits on whitespace, so stamping the raw output fails
216
+ * the commit with `Syntax error - can't find = in "2.2.0"`. Take the last
217
+ * field, so the label carries the version alone (celilo#1318).
218
+ */
219
+ function labelSafeVersion(version: string): string {
220
+ return version.trim().split(/\s+/).pop() || 'unknown';
221
+ }
222
+
212
223
  function sourceStamp(published: boolean, version: string): string {
213
- if (published) return `${PUBLISHED_FINGERPRINT_PREFIX}${version || 'unknown'}`;
224
+ if (published) return `${PUBLISHED_FINGERPRINT_PREFIX}${labelSafeVersion(version)}`;
225
+ // Consumer mode: restageSimulatorInputs is the only writer of
226
+ // pack-manifest.json and it is skipped without a checkout, so there is no
227
+ // tree fingerprint to read — and none to record, since the tree does not
228
+ // exist. Stamp the version the bake just installed and verified (celilo#1318).
229
+ if (!findMonorepoRoot(PACKAGE_ROOT)) {
230
+ return `${CONSUMER_FINGERPRINT_PREFIX}${labelSafeVersion(version)}`;
231
+ }
214
232
  return simStampFromManifest();
215
233
  }
216
234
 
@@ -229,9 +247,16 @@ function sourceStamp(published: boolean, version: string): string {
229
247
  function restageSimulatorInputs(): void {
230
248
  const repoRoot = findMonorepoRoot(PACKAGE_ROOT);
231
249
  if (!repoRoot) {
232
- throw new Error(
233
- 'The default bake installs the monorepo DEV cli via the sim registry — it needs a celilo checkout. For a published-CLI bake use --published.',
234
- );
250
+ // Consumer mode (no checkout): there is no tree to repack, and nothing to
251
+ // correct. stageFromPublic already fetched THIS run's tarballs and site
252
+ // dist into the sim caches, and the sim images were built from them before
253
+ // the bake started. celilo#1299 is a monorepo-mode hazard — there the bake
254
+ // would otherwise reinstall the previous build's cached CLI — so the
255
+ // restage is redundant here, not impossible-but-required. Throwing broke
256
+ // npm-consumer-smoke, whose whole point is a bake with no monorepo source
257
+ // (celilo#1318).
258
+ console.log(' restage ................ skipped (consumer mode: inputs staged this run)');
259
+ return;
235
260
  }
236
261
  stageWebsiteDist(repoRoot, PACKAGE_ROOT);
237
262
  packNpmRegistryTarballs(repoRoot, PACKAGE_ROOT);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1,10 +1,20 @@
1
1
  import { afterEach, beforeEach, expect, test } from 'bun:test';
2
- import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
3
11
  import { tmpdir } from 'node:os';
4
12
  import { join } from 'node:path';
5
13
  import { gzipSync } from 'node:zlib';
6
14
  import {
7
15
  assertGzipValid,
16
+ bakeManagement,
17
+ reportBakeChildFailure,
8
18
  stageNetappsFromRegistry,
9
19
  verifyNetapp,
10
20
  verifyStagedNetapps,
@@ -95,3 +105,74 @@ test('verifyStagedNetapps accepts a valid .netapp and refuses a truncated one',
95
105
  expect(() => verifyNetapp(join(dir, 'technitium.netapp'))).toThrow(/technitium\.netapp/s);
96
106
  expect(() => assertGzipValid(join(dir, 'technitium.netapp'))).toThrow(/unexpected end of file/s);
97
107
  });
108
+
109
+ /**
110
+ * Stub process.exit and console.error around a call expected to terminate.
111
+ * Returns the exit codes collected and everything written to stderr.
112
+ */
113
+ function captureTerminalExit(run: () => void): { exitCodes: unknown[]; stderr: string } {
114
+ const realExit = process.exit;
115
+ const realError = console.error;
116
+ const exitCodes: unknown[] = [];
117
+ const stderrLines: string[] = [];
118
+ (process as unknown as { exit: (code?: number) => never }).exit = ((code?: number) => {
119
+ exitCodes.push(code);
120
+ throw new Error(`process.exit(${code})`);
121
+ }) as typeof process.exit;
122
+ console.error = (...args: unknown[]) => {
123
+ stderrLines.push(args.map(String).join(' '));
124
+ };
125
+ try {
126
+ try {
127
+ run();
128
+ } catch (err) {
129
+ // The stubbed exit throws to unwind; anything else is a real failure.
130
+ if (!String(err).startsWith('Error: process.exit(')) throw err;
131
+ }
132
+ } finally {
133
+ process.exit = realExit;
134
+ console.error = realError;
135
+ }
136
+ return { exitCodes, stderr: stderrLines.join('\n') };
137
+ }
138
+
139
+ // celilo#1302: a lock-free live stack on the builder made the bake child's
140
+ // startup cleanup refuse (exit 3), and the parent reported "Bake step failed"
141
+ // with three install.sh causes for a step that never executed.
142
+ test('a refusal exit (3) from the bake child reports the bake did not run, not a bake failure', () => {
143
+ const { exitCodes, stderr } = captureTerminalExit(() => reportBakeChildFailure(3, 0));
144
+ expect(exitCodes).toEqual([3]);
145
+ expect(stderr).toContain('did not run');
146
+ expect(stderr).toContain('cele2e down');
147
+ // The wrong hint must not appear: these causes are all false when the bake
148
+ // never started.
149
+ expect(stderr).not.toContain('Likely causes');
150
+ expect(stderr).not.toContain('install.sh regressed');
151
+ });
152
+
153
+ test('a genuine bake failure (exit 1) keeps the bake-failure report and its likely causes', () => {
154
+ const { exitCodes, stderr } = captureTerminalExit(() => reportBakeChildFailure(1, 12));
155
+ expect(exitCodes).toEqual([1]);
156
+ expect(stderr).toContain('Bake step failed after 12s');
157
+ expect(stderr).toContain('Likely causes');
158
+ expect(stderr).not.toContain('did not run');
159
+ });
160
+
161
+ test('bakeManagement classifies a real child exit 3 end to end and exits 3', () => {
162
+ // A fake bake script that behaves like the real one does when the startup
163
+ // cleanup refuses: print the refusal to stderr, exit 3.
164
+ const pkgDir = mkdtempSync(join(tmpdir(), 'bake-child-'));
165
+ mkdirSync(join(pkgDir, 'bin'), { recursive: true });
166
+ writeFileSync(
167
+ join(pkgDir, 'bin', 'e2e-bake-management'),
168
+ 'console.error("refusing to clean up"); process.exit(3);\n',
169
+ );
170
+ try {
171
+ const { exitCodes, stderr } = captureTerminalExit(() => bakeManagement(pkgDir, false));
172
+ expect(exitCodes).toEqual([3]);
173
+ expect(stderr).toContain('did not run');
174
+ expect(stderr).not.toContain('Likely causes');
175
+ } finally {
176
+ rmSync(pkgDir, { recursive: true, force: true });
177
+ }
178
+ });
package/src/cli/build.ts CHANGED
@@ -684,7 +684,7 @@ function tagVanillaManagement(): void {
684
684
  * build-infra so the operator can't accidentally proceed with a
685
685
  * non-functional default management image.
686
686
  */
687
- function bakeManagement(pkgDir: string, published: boolean): void {
687
+ export function bakeManagement(pkgDir: string, published: boolean): void {
688
688
  const bakeScript = join(pkgDir, 'bin', 'e2e-bake-management');
689
689
  if (!existsSync(bakeScript)) {
690
690
  console.error(
@@ -705,16 +705,43 @@ function bakeManagement(pkgDir: string, published: boolean): void {
705
705
  const elapsed = Math.round((Date.now() - start) / 1000);
706
706
 
707
707
  if (result.status !== 0) {
708
- console.error(`\n${red}Bake step failed after ${elapsed}s${reset}`);
709
- console.error(
710
- `${dim}Likely causes: install.sh regressed, npm-registry-sim doesn't have the expected @celilo/* tarballs, or celilo-website-sim isn't serving install.sh. Run \`cele2e run install-sh\` for a focused reproducer with cleaner output.${reset}`,
711
- );
712
- process.exit(1);
708
+ console.log(`${red}✗${reset} ${dim}${elapsed}s${reset}`);
709
+ // status is null when the child died to a signal — that is a genuine
710
+ // failure, not a refusal, so it falls through to the exit-1 report.
711
+ reportBakeChildFailure(result.status ?? 1, elapsed);
713
712
  }
714
713
 
715
714
  console.log(`\n${green}Baked in ${elapsed}s${reset}\n`);
716
715
  }
717
716
 
717
+ /**
718
+ * Report a non-zero bake-child exit and terminate. Exit 3 is the live-stack
719
+ * refusal convention (celilo#1297 guard; the run runner keys on the same code
720
+ * in its `refused` field): the bake child's startup cleanup found a live
721
+ * celilo-e2e-* stack or a foreign run lock and refused before the bake did
722
+ * any work. Reporting that as a bake failure named three causes — install.sh
723
+ * rot, registry tarballs, website sim — for a step that never executed
724
+ * (celilo#1302: 9 of ~111 smoke runs, every refusal diagnosed as install.sh
725
+ * rot). A refusal is not a bake failure, so it gets its own message and exits
726
+ * 3 so callers can tell the two apart.
727
+ */
728
+ export function reportBakeChildFailure(status: number, elapsed: number): never {
729
+ if (status === 3) {
730
+ console.error(
731
+ `\n${red}Bake step did not run — the startup cleanup refused: a live e2e stack is in the way.${reset}`,
732
+ );
733
+ console.error(
734
+ `${dim}A refusal is not a bake failure; install.sh was never exercised. Clear the stack with \`cele2e down\` (or free the foreign run lock) and re-run \`cele2e build-infra\`.${reset}`,
735
+ );
736
+ process.exit(3);
737
+ }
738
+ console.error(`\n${red}Bake step failed after ${elapsed}s${reset}`);
739
+ console.error(
740
+ `${dim}Likely causes: install.sh regressed, npm-registry-sim doesn't have the expected @celilo/* tarballs, or celilo-website-sim isn't serving install.sh. Run \`cele2e run install-sh\` for a focused reproducer with cleaner output.${reset}`,
741
+ );
742
+ process.exit(1);
743
+ }
744
+
718
745
  /**
719
746
  * Pre-seed heavy application images (authentik server, postgres, redis) into
720
747
  * the app-zone preload cache. Without this the `docker-image-cache` is empty on
@@ -3,7 +3,14 @@ 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
+ findLiveE2eStack,
12
+ looksLikeE2eRunCommand,
13
+ } from './live-stack';
7
14
  import type { LockStatus } from './run-lock';
8
15
  import { startupCleanup } from './shared-infra';
9
16
 
@@ -71,7 +78,14 @@ describe('findLiveE2eStack', () => {
71
78
  expect(refusal).not.toBeNull();
72
79
  expect(refusal?.reason).toContain('celilo-e2e-shared_namecheap-dns');
73
80
  expect(refusal?.reason).not.toContain('celilo-e2e-1788769175864_fw-main');
74
- expect(refusal?.reason).toContain('cele2e down');
81
+ // The remedy must be a command that EXISTS on the host printing the
82
+ // message (celilo#1314): a bare `cele2e down` does not — forgejo job
83
+ // workspaces are ephemeral, so the binary only exists inside a checkout.
84
+ expect(refusal?.reason).toContain('docker rm -f');
85
+ expect(refusal?.reason).not.toContain('cele2e down');
86
+ // The refusal must be greppable as an environment problem, not a check
87
+ // failure (celilo#1314 direction 3).
88
+ expect(refusal?.reason).toContain('[infra-refusal]');
75
89
  expect(refusal?.runningContainers).toEqual(['celilo-e2e-shared_namecheap-dns']);
76
90
  });
77
91
 
@@ -131,6 +145,108 @@ describe('findLiveE2eStack', () => {
131
145
  });
132
146
  });
133
147
 
148
+ describe('looksLikeE2eRunCommand', () => {
149
+ test('matches the cele2e CLI by any argv mention', () => {
150
+ expect(looksLikeE2eRunCommand('cele2e run smoke')).toBe(true);
151
+ expect(looksLikeE2eRunCommand('./node_modules/.bin/cele2e run smoke')).toBe(true);
152
+ expect(looksLikeE2eRunCommand('bun run packages/e2e/bin/cele2e.ts run --all')).toBe(true);
153
+ });
154
+
155
+ test('matches a direct bun test of this package, which has no cele2e in argv', () => {
156
+ expect(looksLikeE2eRunCommand('bun test packages/e2e/tests/dns-replication.test.ts')).toBe(
157
+ true,
158
+ );
159
+ expect(looksLikeE2eRunCommand('bun test e2e/tests/smoke.test.ts')).toBe(true);
160
+ });
161
+
162
+ test('does not match unrelated bun test runs, editors, or scripts', () => {
163
+ expect(looksLikeE2eRunCommand('bun test packages/mcp-server/src/tools.test.ts')).toBe(false);
164
+ expect(looksLikeE2eRunCommand('vi packages/e2e/src/live-stack.test.ts')).toBe(false);
165
+ expect(looksLikeE2eRunCommand('bun run packages/e2e/scripts/pack-celilo-packages.ts')).toBe(
166
+ false,
167
+ );
168
+ expect(looksLikeE2eRunCommand('bun build apps/celilo/src/index.ts')).toBe(false);
169
+ });
170
+ });
171
+
172
+ describe('shared-only orphan reap (celilo#1314)', () => {
173
+ // Docker's `{{.CreatedAt}}` format, e.g. "2026-09-07 18:49:14 +0000 UTC".
174
+ const dockerAge = (msAgo: number): string => {
175
+ const d = new Date(Date.now() - msAgo);
176
+ return `${d.toISOString().slice(0, 10)} ${d.toISOString().slice(11, 19)} +0000 UTC`;
177
+ };
178
+ const noProcesses: ProcessProbe = () => [];
179
+
180
+ test('REAPS the exact shape measured on the builder: shared-only stack, 11h old, no per-test project, no cele2e process', () => {
181
+ const ps = [
182
+ `celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(11 * 3_600_000)}`,
183
+ `celilo-e2e-shared_registry-1\trunning\t${dockerAge(11 * 3_600_000)}`,
184
+ ].join('\n');
185
+ const { docker, commands } = fakeDocker(ps);
186
+ // No containers at all is NOT the test: the guard must see the live shared
187
+ // containers and STILL clear the way, because every piece of run evidence
188
+ // is absent.
189
+ expect(findLiveE2eStack(docker, noLock, noProcesses)).toBeNull();
190
+ startupCleanup('/tmp/e2e', docker, noLock, noProcesses);
191
+ // The reap is a real teardown, not a refusal: the shared compose down ran.
192
+ expect(commands.some((c) => c.includes(' down '))).toBe(true);
193
+ });
194
+
195
+ test('must NOT reap a shared stack with a live per-test project beside it (the celilo#1297 shape)', () => {
196
+ const ps = [
197
+ `celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(11 * 3_600_000)}`,
198
+ `celilo-e2e-1788769175864_fw-main\trunning\t${dockerAge(5 * 60_000)}`,
199
+ ].join('\n');
200
+ const { docker, commands } = fakeDocker(ps);
201
+ const refusal = findLiveE2eStack(docker, noLock, noProcesses);
202
+ expect(refusal).not.toBeNull();
203
+ expect(refusal?.runningContainers).toContain('celilo-e2e-shared_namecheap-dns-1');
204
+ expect(refusal?.runningContainers).toContain('celilo-e2e-1788769175864_fw-main');
205
+ expect(() => startupCleanup('/tmp/e2e', docker, noLock, noProcesses)).toThrow(LiveStackError);
206
+ expect(commands.some((c) => c.includes(' down '))).toBe(false);
207
+ });
208
+
209
+ test('must NOT reap while any other cele2e process is alive — a run may be between suites', () => {
210
+ const ps = `celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(11 * 3_600_000)}`;
211
+ const { docker } = fakeDocker(ps);
212
+ const oneRunner: ProcessProbe = () => [424242];
213
+ const refusal = findLiveE2eStack(docker, noLock, oneRunner);
214
+ expect(refusal).not.toBeNull();
215
+ expect(refusal?.reason).toContain('424242');
216
+ });
217
+
218
+ 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`, () => {
219
+ const ps = `celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(5 * 60_000)}`;
220
+ const { docker } = fakeDocker(ps);
221
+ const refusal = findLiveE2eStack(docker, noLock, noProcesses);
222
+ expect(refusal).not.toBeNull();
223
+ expect(refusal?.reason).toContain('5m');
224
+ });
225
+
226
+ test('must NOT reap when the age cannot be read — inconclusive evidence refuses, it never reaps', () => {
227
+ // Two fields, no CreatedAt: the pre-#1314 fake shape, and anything docker
228
+ // might print that the parser does not recognize.
229
+ const { docker } = fakeDocker('celilo-e2e-shared_namecheap-dns\trunning\n');
230
+ const refusal = findLiveE2eStack(docker, noLock, noProcesses);
231
+ expect(refusal).not.toBeNull();
232
+ expect(refusal?.reason).toContain('age');
233
+ });
234
+
235
+ test('a foreign fresh lock still refuses a shared-only orphan — the reap never overrides the lock', () => {
236
+ const ps = `celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(11 * 3_600_000)}`;
237
+ const { docker } = fakeDocker(ps);
238
+ const refusal = findLiveE2eStack(docker, foreignLock(), noProcesses);
239
+ expect(refusal).not.toBeNull();
240
+ expect(refusal?.reason).toContain('polecat/ce-9999');
241
+ });
242
+
243
+ test('exited containers of the shared project alone still proceed (unchanged)', () => {
244
+ const ps = `celilo-e2e-shared_registry-1\texited\t${dockerAge(11 * 3_600_000)}`;
245
+ const { docker } = fakeDocker(ps);
246
+ expect(findLiveE2eStack(docker, noLock, noProcesses)).toBeNull();
247
+ });
248
+ });
249
+
134
250
  describe('startupCleanup', () => {
135
251
  test('refuses behind a live stack and removes NOTHING (celilo#1297)', () => {
136
252
  const { docker, commands } = fakeDocker('celilo-e2e-shared_namecheap-dns\trunning\n');
@@ -171,7 +287,7 @@ describe('wiring in shared-infra.ts', () => {
171
287
  SHARED_INFRA_SRC.indexOf('export function startupCleanup'),
172
288
  SHARED_INFRA_SRC.indexOf('nukeE2eResources(e2eDir, docker)'),
173
289
  );
174
- expect(cleanupBody).toContain('findLiveE2eStack(docker, lock)');
290
+ expect(cleanupBody).toContain('findLiveE2eStack(docker, lock, processes)');
175
291
  });
176
292
 
177
293
  test('the DNS-restart branch guards before tearing down the running stack', () => {
package/src/live-stack.ts CHANGED
@@ -19,10 +19,32 @@
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).
22
43
  */
23
44
 
24
45
  import type { ExecFileSyncOptions } from 'node:child_process';
25
46
  import { execFileSync } from 'node:child_process';
47
+ import { SHARED_PROJECT_NAME } from './docker-compose-generator';
26
48
  import {
27
49
  type LockHolder,
28
50
  type LockStatus,
@@ -65,6 +87,146 @@ export class LiveStackError extends Error {
65
87
  }
66
88
  }
67
89
 
90
+ /**
91
+ * How old the youngest live shared-stack container must be before a
92
+ * shared-only stack with no other run evidence is treated as an orphan.
93
+ * One hour sits far above any window in which a run process could have died
94
+ * without its containers dying too, and far below the 11h the builder sat
95
+ * wedged (celilo#1314). A stack younger than this refuses even with no other
96
+ * evidence: the reap is the dangerous direction, so it waits for certainty.
97
+ */
98
+ export const SHARED_ORPHAN_MIN_AGE_MS = 60 * 60_000;
99
+
100
+ /**
101
+ * Live pids of processes that may own an e2e run. Injectable so the reap's
102
+ * evidence test is unit-testable without a process table.
103
+ */
104
+ export type ProcessProbe = () => number[];
105
+
106
+ /**
107
+ * Does this process command line look like an e2e run? Pure so the probe's
108
+ * reach is testable. Matches the two ways a run actually exists:
109
+ * - the cele2e CLI and anything whose argv names it (`cele2e run|up|down|...`)
110
+ * - a direct `bun test` of this package's suites, which manages the shared
111
+ * stack through ensureSharedInfra but has no cele2e in argv
112
+ * The bun-test arm requires bun AND e2e AND test in the command, so an
113
+ * unrelated `bun test` elsewhere only false-matches when its path names e2e —
114
+ * and a false match refuses (cheap), where a false miss reaps a live run
115
+ * (expensive). An editor with an e2e test file open does not match: it does
116
+ * not start with a bun invocation.
117
+ */
118
+ export function looksLikeE2eRunCommand(command: string): boolean {
119
+ if (command.includes('cele2e')) return true;
120
+ return (
121
+ /(^|[\\/])bun(\.exe)?\s/.test(command) && command.includes('e2e') && command.includes('test')
122
+ );
123
+ }
124
+
125
+ /** pids of this process's ancestors, self included, bounded at 64 levels. */
126
+ function familyOfSelf(): Set<number> {
127
+ const family = new Set<number>([process.pid]);
128
+ let pid: number | undefined = process.ppid;
129
+ for (let i = 0; pid !== undefined && pid > 1 && i < 64; i++) {
130
+ family.add(pid);
131
+ try {
132
+ const out = execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], {
133
+ encoding: 'utf-8',
134
+ stdio: ['ignore', 'pipe', 'ignore'],
135
+ }).trim();
136
+ const ppid = Number.parseInt(out, 10);
137
+ pid = Number.isFinite(ppid) ? ppid : undefined;
138
+ } catch {
139
+ pid = undefined;
140
+ }
141
+ }
142
+ return family;
143
+ }
144
+
145
+ /**
146
+ * The real probe: one `ps` listing, filtered by looksLikeE2eRunCommand, with
147
+ * this process and its ancestry removed — the cleanup runs INSIDE the run it
148
+ * would otherwise see as evidence, and the run's own launcher shell carries
149
+ * the same strings in its argv.
150
+ */
151
+ export const realProcessProbe: ProcessProbe = (): number[] => {
152
+ let listing: string;
153
+ try {
154
+ listing = execFileSync('ps', ['-axo', 'pid=,command='], {
155
+ encoding: 'utf-8',
156
+ stdio: ['ignore', 'pipe', 'ignore'],
157
+ });
158
+ } catch {
159
+ // A process table that cannot be read is missing evidence. The caller
160
+ // treats any probe failure conservatively; here that means reporting no
161
+ // matches, which the shared-only path then backstops with the age
162
+ // threshold and the unreadable-age refusal.
163
+ return [];
164
+ }
165
+ const family = familyOfSelf();
166
+ const pids: number[] = [];
167
+ for (const line of listing.split('\n')) {
168
+ const trimmed = line.trim();
169
+ if (!trimmed) continue;
170
+ const sep = trimmed.indexOf(' ');
171
+ if (sep <= 0) continue;
172
+ const pid = Number.parseInt(trimmed.slice(0, sep), 10);
173
+ if (!Number.isFinite(pid) || family.has(pid)) continue;
174
+ if (looksLikeE2eRunCommand(trimmed.slice(sep + 1))) pids.push(pid);
175
+ }
176
+ return pids;
177
+ };
178
+
179
+ /**
180
+ * Every refusal goes through here so the printed reason is uniformly
181
+ * greppable as an infrastructure refusal (celilo#1314 direction 3) rather
182
+ * than reading as a check failure.
183
+ */
184
+ function makeRefusal(
185
+ detail: string,
186
+ runningContainers: string[],
187
+ holder: LockHolder | null,
188
+ ): LiveStackRefusal {
189
+ const reason = `[infra-refusal] refusing to clean up: ${detail}\n(this is an environment problem, not a test failure — the run exits 3)`;
190
+ return { reason, runningContainers, holder };
191
+ }
192
+
193
+ /**
194
+ * The remedy printed with a live-stack refusal. A raw docker removal, because
195
+ * `cele2e down` does not exist on the hosts that print this message: forgejo
196
+ * job workspaces are ephemeral, so the binary only exists inside a checkout
197
+ * (celilo#1314 direction 2). Removing the containers is what unblocks the
198
+ * guard; the next cleanup sweeps whatever name-prefix resources survive.
199
+ */
200
+ export const CLEAR_STACK_COMMAND = 'docker rm -f $(docker ps -aq --filter name=celilo-e2e)';
201
+
202
+ /** Docker's `{{.CreatedAt}}` format, e.g. "2026-09-07 18:49:14 +0000 UTC". */
203
+ function parseDockerCreatedAt(raw: string | undefined): Date | null {
204
+ if (raw === undefined) return null;
205
+ const m = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(?:\.(\d+))? ([+-])(\d{2})(\d{2})/.exec(
206
+ raw.trim(),
207
+ );
208
+ if (!m) return null;
209
+ const [, date, time, frac, sign, offH, offM] = m;
210
+ const utcMs = Date.parse(`${date}T${time}Z`);
211
+ if (Number.isNaN(utcMs)) return null;
212
+ const offsetMs = (sign === '-' ? -1 : 1) * (Number(offH) * 60 + Number(offM)) * 60_000;
213
+ const fracMs = frac ? Number(frac.padEnd(3, '0').slice(0, 3)) : 0;
214
+ return new Date(utcMs - offsetMs + fracMs);
215
+ }
216
+
217
+ /**
218
+ * Containers of the shared compose project. Compose names them
219
+ * `<project>_<service>_<n>` (or with `-` separators on newer compose), so the
220
+ * character right after the project name is the tell. Everything else live —
221
+ * per-test projects AND sim-created guests, whose names carry no project —
222
+ * is run evidence.
223
+ */
224
+ function isSharedStackContainer(name: string): boolean {
225
+ if (!name.startsWith(SHARED_PROJECT_NAME)) return false;
226
+ const sep = name[SHARED_PROJECT_NAME.length];
227
+ return sep === '_' || sep === '-';
228
+ }
229
+
68
230
  /**
69
231
  * Container states that mean the container cannot be holding a live stack.
70
232
  * Everything else (running, paused, restarting) is live: paused containers
@@ -90,17 +252,18 @@ function isOwnHolder(h: LockHolder): boolean {
90
252
  export function findLiveE2eStack(
91
253
  docker: DockerReader = realDocker,
92
254
  lock: () => LockStatus = lockStatus,
255
+ processes: ProcessProbe = realProcessProbe,
93
256
  ): LiveStackRefusal | null {
94
257
  const status = lock();
95
258
  if (!status.free && status.holder && !isOwnHolder(status.holder)) {
96
259
  const h = status.holder;
97
260
  const heartbeat =
98
261
  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
- };
262
+ return makeRefusal(
263
+ `the run lock is held by another session — ${formatBusy(h)}${heartbeat}`,
264
+ [],
265
+ h,
266
+ );
104
267
  }
105
268
 
106
269
  const out = docker([
@@ -109,21 +272,67 @@ export function findLiveE2eStack(
109
272
  '--filter',
110
273
  'name=celilo-e2e',
111
274
  '--format',
112
- '{{.Names}}\t{{.State}}',
275
+ '{{.Names}}\t{{.State}}\t{{.CreatedAt}}',
113
276
  ]);
114
- const running = out
277
+ const live = out
115
278
  .split('\n')
116
279
  .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
- };
280
+ .map((line) => {
281
+ const [name, state, createdAt] = line.split('\t');
282
+ return { name: name ?? '', state: state ?? '', createdAt: parseDockerCreatedAt(createdAt) };
283
+ })
284
+ .filter((c) => c.name !== '' && !DEAD_STATES.has(c.state));
285
+ if (live.length === 0) return null;
286
+
287
+ // A per-test container (or a guest, which carries no project name at all)
288
+ // means a run owns this host. Refuse regardless of the shared stack — this
289
+ // is the #1297 protection, unchanged.
290
+ const runOwned = live.filter((c) => !isSharedStackContainer(c.name));
291
+ if (runOwned.length > 0) {
292
+ return makeRefusal(
293
+ `${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)`,
294
+ live.map((c) => c.name),
295
+ null,
296
+ );
297
+ }
298
+
299
+ // celilo#1314: ONLY shared-stack containers are live. Refusing here
300
+ // unconditionally is what wedged the builder — an orphaned shared stack is
301
+ // the one state the guard could never resolve. Apply the orphan evidence
302
+ // test instead. Any inconclusive answer refuses.
303
+ const runners = processes();
304
+ if (runners.length > 0) {
305
+ return makeRefusal(
306
+ `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}`,
307
+ live.map((c) => c.name),
308
+ null,
309
+ );
310
+ }
311
+
312
+ const youngest = Math.min(
313
+ ...live.map((c) => {
314
+ if (c.createdAt === null) return Number.NaN;
315
+ return c.createdAt.getTime();
316
+ }),
317
+ );
318
+ if (!Number.isFinite(youngest)) {
319
+ return makeRefusal(
320
+ `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}`,
321
+ live.map((c) => c.name),
322
+ null,
323
+ );
126
324
  }
325
+ const ageMs = Date.now() - youngest;
326
+ if (ageMs < SHARED_ORPHAN_MIN_AGE_MS) {
327
+ return makeRefusal(
328
+ `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}`,
329
+ live.map((c) => c.name),
330
+ null,
331
+ );
332
+ }
333
+
334
+ // Every piece of run evidence is absent, and the stack is old enough that
335
+ // no live run can be hiding from the probe. Provably dead: reap it.
127
336
  return null;
128
337
  }
129
338
 
@@ -137,8 +346,9 @@ export function findLiveE2eStack(
137
346
  export function refuseOnLiveStack(
138
347
  docker: DockerReader = realDocker,
139
348
  lock: () => LockStatus = lockStatus,
349
+ processes: ProcessProbe = realProcessProbe,
140
350
  ): void {
141
- const refusal = findLiveE2eStack(docker, lock);
351
+ const refusal = findLiveE2eStack(docker, lock, processes);
142
352
  if (!refusal) return;
143
353
  console.error(`\n${refusal.reason}\n`);
144
354
  process.exit(3);
@@ -22,8 +22,10 @@ import {
22
22
  import {
23
23
  type DockerReader,
24
24
  LiveStackError,
25
+ type ProcessProbe,
25
26
  findLiveE2eStack,
26
27
  realDocker,
28
+ realProcessProbe,
27
29
  refuseOnLiveStack,
28
30
  } from './live-stack';
29
31
  import { ensureRegistryServerBundle, ensureTerraformFakeBundle } from './registry-bundle';
@@ -101,8 +103,9 @@ export function startupCleanup(
101
103
  e2eDir: string,
102
104
  docker: DockerReader = realDocker,
103
105
  lock: () => LockStatus = lockStatus,
106
+ processes: ProcessProbe = realProcessProbe,
104
107
  ): void {
105
- const refusal = findLiveE2eStack(docker, lock);
108
+ const refusal = findLiveE2eStack(docker, lock, processes);
106
109
  if (refusal) throw new LiveStackError(refusal);
107
110
  console.log(
108
111
  '[progress:start] cleaning up stale shared infrastructure | shared infra cleanup complete',
@@ -173,7 +176,9 @@ export async function ensureSharedInfra(): Promise<void> {
173
176
  // DNS check failed — restart shared infra. The restart tears down the
174
177
  // running stack, so the live-stack guard fires first (celilo#1297): a
175
178
  // stack that is up is never force-removed, even when its DNS is wedged.
176
- // The operator clears it with `cele2e down` and re-runs. This retires
179
+ // The operator clears it with the docker removal named in the refusal
180
+ // (a bare `cele2e down` does not exist on ephemeral CI hosts —
181
+ // celilo#1314) and re-runs. This retires
177
182
  // the automatic DNS-restart self-heal by decision (peba, 2026-09-07):
178
183
  // fail loudly naming the holder beats silently clobbering a stack that
179
184
  // might be another session's — the #1297 incident rode exactly this
@@ -50,6 +50,17 @@ export const SOURCE_LABEL = 'computer.celilo.e2e.source';
50
50
  */
51
51
  export const PUBLISHED_FINGERPRINT_PREFIX = 'published:';
52
52
 
53
+ /**
54
+ * Value stamped by a CONSUMER-mode bake: `cele2e build-infra` run from an
55
+ * installed `@celilo/e2e` with no monorepo checkout. The CLI comes from the sim
56
+ * registry's freshly staged tarballs, so it is neither real npm nor this tree,
57
+ * and no tree fingerprint exists to record. The version is the honest
58
+ * attribution. Deliberately NOT the published prefix: that one tells the
59
+ * freshness check there is nothing to be stale against, which would suppress a
60
+ * real warning if such an image were later inspected from a checkout.
61
+ */
62
+ export const CONSUMER_FINGERPRINT_PREFIX = 'consumer:';
63
+
53
64
  /**
54
65
  * Repo-relative paths whose content reaches the baked CLI.
55
66
  *