@linzumi/cli 1.0.6 → 1.0.8
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/README.md +32 -1
- package/dist/index.js +413 -319
- package/dist/mcp-server.mjs +174 -0
- package/package.json +2 -1
- package/scripts/build.mjs +34 -3
- package/scripts/golden-chaos-harness.mjs +1420 -0
- package/scripts/golden-lifecycle-e2e.mjs +969 -0
- package/scripts/golden-scale-test.mjs +738 -0
- package/scripts/vm-provision-proof.mjs +151 -0
- package/scripts/vm-smoke.mjs +241 -0
|
@@ -0,0 +1,1420 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// PR #2111 - LIVE-HVF CHAOS HARNESS (families C, D1, E1, E2 - the [H] ones)
|
|
4
|
+
// =============================================================================
|
|
5
|
+
// ONE script that exercises the REAL golden + fork + job lifecycle on REAL VMs
|
|
6
|
+
// (smolmachines 1.3.2, Apple Hypervisor) WHILE INJECTING FAULTS, and asserts
|
|
7
|
+
// human-readable chaos properties each WITH TEETH. Non-zero exit on ANY
|
|
8
|
+
// property violation; a per-property PASS/FAIL table with plain-English names +
|
|
9
|
+
// real numbers (kill timings, EAGAIN counts, orphan counts).
|
|
10
|
+
//
|
|
11
|
+
// It exercises OUR SHIPPED CODE (not raw smolmachines): the deterministic golden
|
|
12
|
+
// name (goldenBuildMachineName), the toolchain provision script
|
|
13
|
+
// (guestProvisionCommand), and the supervisor fork-from-golden path WITH its
|
|
14
|
+
// EAGAIN/os-error-35 backoff (createMachineFromGolden), all bundled via esbuild
|
|
15
|
+
// from packages/linzumi-cli/src/vmSandbox - the same approach as
|
|
16
|
+
// golden-lifecycle-e2e.mjs.
|
|
17
|
+
//
|
|
18
|
+
// PROPERTIES (each has a BREAK_* knob that makes EXACTLY that property go red):
|
|
19
|
+
// C1 fork killed mid-work (SIGKILL the fork's smol-vmm): no orphan smol-vmm
|
|
20
|
+
// after; golden still forks; siblings unaffected; host clean.
|
|
21
|
+
// C2 builder killed mid-build (SIGKILL during provision/install): golden NOT
|
|
22
|
+
// marked ready; reused machine not corrupt; a fresh build then succeeds.
|
|
23
|
+
// C4 fork dies mid-turn: committed-to-a-(local-bare)-repo work survives the
|
|
24
|
+
// kill; uncommitted work in the killed fork is gone; no leak.
|
|
25
|
+
// C5 kill-at-every-stage (parameterized): for stage in {boot, provision,
|
|
26
|
+
// npm-install, mid-work, mid-commit, teardown} kill there and assert no
|
|
27
|
+
// corruption, no orphan VM, no false-success, next attempt recovers.
|
|
28
|
+
// C6 contention: N forks + M concurrent builds racing -> all converge, EAGAIN
|
|
29
|
+
// was retried (count>=0 acceptable, never surfaced as a failure), no
|
|
30
|
+
// cross-contamination, no deadlock (bounded wall time).
|
|
31
|
+
// D1 durable boundary under kill: committed+pushed survives a kill at any
|
|
32
|
+
// moment; uncommitted dies; git is source of truth.
|
|
33
|
+
// E1 isolation under concurrency: fork A's files invisible in B/C and golden.
|
|
34
|
+
// E2 secret scrub on kill: a fake secret in guest tmpfs (/dev/shm/linzumi)
|
|
35
|
+
// is gone after SIGKILL (tmpfs discarded) and never on the persistent disk.
|
|
36
|
+
//
|
|
37
|
+
// HARD HYGIENE (a prior agent caused outages - do NOT repeat):
|
|
38
|
+
// - run VMs SYNCHRONOUSLY (bounded contention; never an unbounded fan-out).
|
|
39
|
+
// - ALWAYS delete every created machine in a finally (even SIGKILLed forks:
|
|
40
|
+
// the vmm process dies on kill but the vms/<hash> disk dir lingers until
|
|
41
|
+
// the SDK delete() reclaims it - so delete() in finally is mandatory).
|
|
42
|
+
// - NEVER spawn a lingering background shell, and NEVER a watcher that pkills
|
|
43
|
+
// golden-* (that killed concurrent runs + leaked VMs). All process kills
|
|
44
|
+
// here target a SPECIFIC fork's smol-vmm PID, identified by diffing the
|
|
45
|
+
// vmm process set across the fork call - never a name-pattern pkill.
|
|
46
|
+
// - At start: kill stray `smol-vmm _boot-vm` and clear the smolvm vms cache
|
|
47
|
+
// ONCE iff wedged, then let it re-extract.
|
|
48
|
+
// - Confirm 0 leaked smol-vmm at exit.
|
|
49
|
+
// - Do NOT run git on the host (all git is in-guest).
|
|
50
|
+
//
|
|
51
|
+
// Known facts (probed + confirmed on this Mac):
|
|
52
|
+
// - golden is exec-dead after the first fork: NEVER exec the golden post-fork;
|
|
53
|
+
// probe post-fork golden state via a WITNESS fork.
|
|
54
|
+
// - EAGAIN/os-error-35 (and the equally-transient "did not respond to ping" /
|
|
55
|
+
// "connection closed") on rapid fork/exec -> retry + backoff.
|
|
56
|
+
// - fork ~45ms; native SIGKILL = process gone (catch via ps, not JS).
|
|
57
|
+
// =============================================================================
|
|
58
|
+
|
|
59
|
+
import { createRequire } from 'node:module';
|
|
60
|
+
import { fileURLToPath } from 'node:url';
|
|
61
|
+
import {
|
|
62
|
+
mkdtempSync,
|
|
63
|
+
rmSync,
|
|
64
|
+
writeFileSync,
|
|
65
|
+
existsSync,
|
|
66
|
+
readdirSync,
|
|
67
|
+
} from 'node:fs';
|
|
68
|
+
import { tmpdir, homedir } from 'node:os';
|
|
69
|
+
import { dirname, join } from 'node:path';
|
|
70
|
+
import { execFileSync } from 'node:child_process';
|
|
71
|
+
|
|
72
|
+
process.on('uncaughtException', (e) => {
|
|
73
|
+
console.error('[chaos] UNCAUGHT:', e?.stack || String(e));
|
|
74
|
+
});
|
|
75
|
+
process.on('unhandledRejection', (e) => {
|
|
76
|
+
console.error('[chaos] UNHANDLED-REJECTION:', e?.stack || String(e));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
80
|
+
const __dirname = dirname(__filename);
|
|
81
|
+
const cliRoot = join(__dirname, '..');
|
|
82
|
+
|
|
83
|
+
const RUNTIME_DIR =
|
|
84
|
+
process.env.LINZUMI_VM_RUNTIME_DIR &&
|
|
85
|
+
process.env.LINZUMI_VM_RUNTIME_DIR.trim() !== ''
|
|
86
|
+
? process.env.LINZUMI_VM_RUNTIME_DIR.trim()
|
|
87
|
+
: '/tmp/smol-rt-132';
|
|
88
|
+
|
|
89
|
+
const VMS_DIR = join(homedir(), 'Library/Caches/smolvm/vms');
|
|
90
|
+
const PROJECT = '/root/project'; // in-guest checkout root
|
|
91
|
+
const SECRETS_DIR = '/dev/shm/linzumi'; // guest tmpfs (== vmGuestProvision guestSecretsDir)
|
|
92
|
+
const PERSIST_PROBE = '/root/persist-probe'; // a persistent-disk dir we scan for secret leakage
|
|
93
|
+
const N_CONTENTION_FORKS = Number(process.env.CHAOS_N_FORKS || 8);
|
|
94
|
+
const N_CONTENTION_BUILDS = Number(process.env.CHAOS_N_BUILDS || 2);
|
|
95
|
+
const CONTENTION_WALL_BUDGET_MS = Number(
|
|
96
|
+
process.env.CHAOS_WALL_BUDGET_MS || 180000
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const now = () => Date.now();
|
|
100
|
+
const log = (...a) => console.log('[chaos]', ...a);
|
|
101
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// TEETH KNOBS. Each BREAK_* deliberately defeats ONE property so its row goes
|
|
105
|
+
// red, proving the assertion has teeth. Default (all unset) is the real lifecycle.
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
const BREAK = {
|
|
108
|
+
C1_orphan: process.env.BREAK_C1_ORPHAN === '1', // don't reap the killed fork's disk -> orphan leak
|
|
109
|
+
C2_falseReady: process.env.BREAK_C2_FALSE_READY === '1', // mark golden ready despite a killed build
|
|
110
|
+
C4_uncommittedSurvives: process.env.BREAK_C4_UNCOMMITTED === '1', // pretend uncommitted survived the kill
|
|
111
|
+
C5_corruption: process.env.BREAK_C5_CORRUPTION === '1', // a kill stage leaves corruption / no recovery
|
|
112
|
+
C6_crossContam: process.env.BREAK_C6_CONTAM === '1', // leak one fork's file into another under load
|
|
113
|
+
C6_eagainFatal: process.env.BREAK_C6_EAGAIN_FATAL === '1', // surface a transient EAGAIN as a hard failure
|
|
114
|
+
D1_committedLost: process.env.BREAK_D1_COMMITTED_LOST === '1', // committed+pushed work does NOT survive
|
|
115
|
+
E1_isolation: process.env.BREAK_E1_ISOLATION === '1', // a fork's file becomes visible in a sibling
|
|
116
|
+
E2_secretPersists: process.env.BREAK_E2_SECRET_PERSISTS === '1', // write the secret to the persistent disk
|
|
117
|
+
};
|
|
118
|
+
const anyBreak = Object.values(BREAK).some(Boolean);
|
|
119
|
+
|
|
120
|
+
// =============================================================================
|
|
121
|
+
// HOST PROCESS / DISK INTROSPECTION (the chaos primitives)
|
|
122
|
+
// =============================================================================
|
|
123
|
+
// A smol-vmm microVM process is exactly: `smol-vmm _boot-vm <...>/vms/<hash>/boot-config.json`.
|
|
124
|
+
// We match ONLY lines carrying that vms/<hash> path so our own node cmdline (which
|
|
125
|
+
// contains the runtime path string) is never miscounted as a VM.
|
|
126
|
+
function vmmProcs() {
|
|
127
|
+
let out = '';
|
|
128
|
+
try {
|
|
129
|
+
out = execFileSync('ps', ['-axww', '-o', 'pid=,command='], {
|
|
130
|
+
encoding: 'utf8',
|
|
131
|
+
});
|
|
132
|
+
} catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
136
|
+
.split('\n')
|
|
137
|
+
.map((l) => l.trim())
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
.map((l) => {
|
|
140
|
+
const m = l.match(/^(\d+)\s+(.*)$/);
|
|
141
|
+
if (!m) return null;
|
|
142
|
+
const hm = m[2].match(/smol-vmm\s+_boot-vm\s+.*\/vms\/([a-f0-9]+)\//);
|
|
143
|
+
return hm ? { pid: Number(m[1]), hash: hm[1] } : null;
|
|
144
|
+
})
|
|
145
|
+
.filter(Boolean);
|
|
146
|
+
}
|
|
147
|
+
function vmmCount() {
|
|
148
|
+
return vmmProcs().length;
|
|
149
|
+
}
|
|
150
|
+
function vmsDirs() {
|
|
151
|
+
try {
|
|
152
|
+
return readdirSync(VMS_DIR);
|
|
153
|
+
} catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// A SIGKILLed vmm becomes a Z(ombie) <defunct> until its parent (this node proc)
|
|
158
|
+
// reaps it on exit; a zombie still answers kill(pid,0) and still shows in a bare
|
|
159
|
+
// `ps -p`, so neither is a valid "running VM" test. The authoritative test is:
|
|
160
|
+
// the pid is no longer a RUNNING smol-vmm _boot-vm (it dropped out of vmmProcs),
|
|
161
|
+
// which is true the instant the microVM is torn down. We treat zombie == gone.
|
|
162
|
+
function vmmRunning(pid) {
|
|
163
|
+
return vmmProcs().some((p) => p.pid === pid);
|
|
164
|
+
}
|
|
165
|
+
function isZombie(pid) {
|
|
166
|
+
try {
|
|
167
|
+
const stat = execFileSync('ps', ['-o', 'stat=', '-p', String(pid)], {
|
|
168
|
+
encoding: 'utf8',
|
|
169
|
+
}).trim();
|
|
170
|
+
return stat.startsWith('Z');
|
|
171
|
+
} catch {
|
|
172
|
+
return true; // not in ps at all => gone
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// =============================================================================
|
|
177
|
+
// esbuild-bundle OUR shipped CLI helpers, then import them (exercise real code).
|
|
178
|
+
// =============================================================================
|
|
179
|
+
async function loadCliHelpers() {
|
|
180
|
+
const esbuild = createRequire(join(cliRoot, 'package.json'))('esbuild');
|
|
181
|
+
const goldenBuildTs = JSON.stringify(
|
|
182
|
+
join(cliRoot, 'src/vmSandbox/vmGoldenBuild.ts')
|
|
183
|
+
);
|
|
184
|
+
const supervisorTs = JSON.stringify(
|
|
185
|
+
join(cliRoot, 'src/vmSandbox/vmCodexSupervisor.ts')
|
|
186
|
+
);
|
|
187
|
+
const guestProvisionTs = JSON.stringify(
|
|
188
|
+
join(cliRoot, 'src/vmSandbox/vmGuestProvision.ts')
|
|
189
|
+
);
|
|
190
|
+
const adapterSrc = `
|
|
191
|
+
export {
|
|
192
|
+
goldenBuildMachineName,
|
|
193
|
+
} from ${goldenBuildTs};
|
|
194
|
+
export {
|
|
195
|
+
createMachineFromGolden,
|
|
196
|
+
loadSmolmachines,
|
|
197
|
+
} from ${supervisorTs};
|
|
198
|
+
export { guestProvisionCommand } from ${guestProvisionTs};
|
|
199
|
+
`;
|
|
200
|
+
const tmp = mkdtempSync(join(tmpdir(), 'chaos-bundle-'));
|
|
201
|
+
const adapterPath = join(tmp, 'adapter.ts');
|
|
202
|
+
writeFileSync(adapterPath, adapterSrc);
|
|
203
|
+
const outFile = join(tmp, 'bundle.mjs');
|
|
204
|
+
await esbuild.build({
|
|
205
|
+
entryPoints: [adapterPath],
|
|
206
|
+
bundle: true,
|
|
207
|
+
format: 'esm',
|
|
208
|
+
platform: 'node',
|
|
209
|
+
target: 'node20',
|
|
210
|
+
outfile: outFile,
|
|
211
|
+
logLevel: 'silent',
|
|
212
|
+
external: ['node:*', 'smolmachines'],
|
|
213
|
+
});
|
|
214
|
+
const mod = await import(outFile);
|
|
215
|
+
return { mod, cleanup: () => rmSync(tmp, { recursive: true, force: true }) };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// =============================================================================
|
|
219
|
+
// Guest exec with the SAME EAGAIN backoff the supervisor uses, plus a host
|
|
220
|
+
// wall-clock guard so a wedged vsock exec can NEVER hang the whole harness.
|
|
221
|
+
// Counts EAGAIN/transient retries globally (for the C6 "EAGAIN was retried,
|
|
222
|
+
// never surfaced as a failure" assertion).
|
|
223
|
+
// =============================================================================
|
|
224
|
+
const stats = { eagainRetries: 0, forkRetries: 0 };
|
|
225
|
+
|
|
226
|
+
function withHostTimeout(promise, ms, label) {
|
|
227
|
+
let timer;
|
|
228
|
+
const guard = new Promise((_, reject) => {
|
|
229
|
+
timer = setTimeout(
|
|
230
|
+
() => reject(new Error(`host-timeout after ${ms}ms: ${label}`)),
|
|
231
|
+
ms
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
return Promise.race([promise, guard]).finally(() => clearTimeout(timer));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const TRANSIENT_RE =
|
|
238
|
+
/os error 35|temporarily unavailable|eagain|connection closed|io operation failed|did not respond to ping/i;
|
|
239
|
+
|
|
240
|
+
async function sh(m, label, cmd, timeout = 240) {
|
|
241
|
+
let lastErr;
|
|
242
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
243
|
+
try {
|
|
244
|
+
const r = await withHostTimeout(
|
|
245
|
+
m.exec(['/bin/sh', '-c', cmd], { timeout }),
|
|
246
|
+
Math.min(timeout * 1000 + 5000, 120000),
|
|
247
|
+
label
|
|
248
|
+
);
|
|
249
|
+
return {
|
|
250
|
+
code: r.exitCode,
|
|
251
|
+
out: (r.stdout || '').trim(),
|
|
252
|
+
stderr: (r.stderr || '').trim(),
|
|
253
|
+
};
|
|
254
|
+
} catch (e) {
|
|
255
|
+
lastErr = e;
|
|
256
|
+
if (!TRANSIENT_RE.test(String(e))) throw e;
|
|
257
|
+
stats.eagainRetries += 1;
|
|
258
|
+
await sleep(200 * (attempt + 1));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
throw lastErr;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Fork via OUR shipped createMachineFromGolden (its real EAGAIN/os-error-35
|
|
265
|
+
// backoff runs INSIDE it). The freshly-forked clone's vsock can additionally
|
|
266
|
+
// return the equally-transient "did not respond to ping" / "connection closed",
|
|
267
|
+
// which the shipped path rethrows; we retry THOSE here so a transient never
|
|
268
|
+
// surfaces as a failure (C6), counting every retry.
|
|
269
|
+
async function forkViaShipped(cli, sdk, bootConfig, createConfig) {
|
|
270
|
+
let lastErr;
|
|
271
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
272
|
+
try {
|
|
273
|
+
return await withHostTimeout(
|
|
274
|
+
cli.createMachineFromGolden(sdk, bootConfig, createConfig),
|
|
275
|
+
90000,
|
|
276
|
+
`fork ${createConfig.name}`
|
|
277
|
+
);
|
|
278
|
+
} catch (e) {
|
|
279
|
+
lastErr = e;
|
|
280
|
+
if (!TRANSIENT_RE.test(String(e))) throw e;
|
|
281
|
+
stats.forkRetries += 1;
|
|
282
|
+
if (BREAK.C6_eagainFatal) throw e; // TEETH C6: surface a transient as fatal
|
|
283
|
+
await sleep(300 * (attempt + 1));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
throw lastErr;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// =============================================================================
|
|
290
|
+
// Registry of every created machine for the finally teardown. We also stamp
|
|
291
|
+
// whether a machine was deliberately SIGKILLed (its vmm is gone but the SDK
|
|
292
|
+
// delete() still reclaims its disk dir, which we MUST call).
|
|
293
|
+
// =============================================================================
|
|
294
|
+
const created = []; // { name, machine, killed }
|
|
295
|
+
function track(name, machine) {
|
|
296
|
+
const rec = { name, machine, killed: false };
|
|
297
|
+
created.push(rec);
|
|
298
|
+
return rec;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Identify a specific fork's smol-vmm by diffing the vmm pid set across the
|
|
302
|
+
// fork, then SIGKILL it natively (process gone, caught via ps - not JS).
|
|
303
|
+
async function killForkVmm(beforePids, label) {
|
|
304
|
+
// Allow the new vmm to register.
|
|
305
|
+
let target = null;
|
|
306
|
+
for (let i = 0; i < 20; i++) {
|
|
307
|
+
target = vmmProcs().find((p) => !beforePids.has(p.pid));
|
|
308
|
+
if (target) break;
|
|
309
|
+
await sleep(100);
|
|
310
|
+
}
|
|
311
|
+
if (!target) throw new Error(`could not identify fork vmm for ${label}`);
|
|
312
|
+
const tKill = now();
|
|
313
|
+
process.kill(target.pid, 'SIGKILL');
|
|
314
|
+
// "Gone" == no longer a RUNNING smol-vmm (it may briefly linger as a reaped-on-
|
|
315
|
+
// exit zombie, which holds NO VM resources). Catch it via ps, not JS.
|
|
316
|
+
let gone = false;
|
|
317
|
+
for (let i = 0; i < 60; i++) {
|
|
318
|
+
if (!vmmRunning(target.pid) || isZombie(target.pid)) {
|
|
319
|
+
gone = true;
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
await sleep(50);
|
|
323
|
+
}
|
|
324
|
+
const killMs = now() - tKill;
|
|
325
|
+
return { pid: target.pid, hash: target.hash, gone, killMs };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// =============================================================================
|
|
329
|
+
// PROPERTY RESULT TABLE
|
|
330
|
+
// =============================================================================
|
|
331
|
+
const props = {}; // id -> { pass, name, detail }
|
|
332
|
+
function record(id, name, pass, detail) {
|
|
333
|
+
props[id] = { id, name, pass: !!pass, detail };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Boot a fresh forkable golden + provision toolchain + a known project with a
|
|
337
|
+
// REAL npm install. Returns the golden handle (PRE-fork; exec it only before
|
|
338
|
+
// any fork is taken off it). `label` keys the deterministic golden name.
|
|
339
|
+
async function buildGolden(cli, sdk, label, { withProject = true } = {}) {
|
|
340
|
+
const projectCwd = `/private/tmp/chaos-${label}-${process.pid}-${Math.random()
|
|
341
|
+
.toString(36)
|
|
342
|
+
.slice(2, 8)}`;
|
|
343
|
+
const goldenName = cli.goldenBuildMachineName(projectCwd);
|
|
344
|
+
const golden = await sdk.Machine.create({
|
|
345
|
+
name: goldenName,
|
|
346
|
+
resources: { cpus: 2, memoryMb: 2048, network: true },
|
|
347
|
+
forkable: true,
|
|
348
|
+
});
|
|
349
|
+
track(goldenName, golden);
|
|
350
|
+
await sh(
|
|
351
|
+
golden,
|
|
352
|
+
`${label} apk`,
|
|
353
|
+
'apk add --no-cache ca-certificates nodejs npm git >/dev/null && update-ca-certificates >/dev/null 2>&1 || true',
|
|
354
|
+
600
|
|
355
|
+
);
|
|
356
|
+
if (withProject) {
|
|
357
|
+
await sh(
|
|
358
|
+
golden,
|
|
359
|
+
`${label} project+install`,
|
|
360
|
+
[
|
|
361
|
+
`mkdir -p ${PROJECT} && cd ${PROJECT}`,
|
|
362
|
+
'git init -q && git config user.email a@b.c && git config user.name base',
|
|
363
|
+
'printf "# chaos base\\n" > README.md',
|
|
364
|
+
`printf '%s' '{"name":"chaos","version":"1.0.0","private":true,"dependencies":{"is-odd":"3.0.1"}}' > package.json`,
|
|
365
|
+
'npm install --no-fund --no-audit --no-progress >/dev/null 2>&1',
|
|
366
|
+
'git add -A && git commit -q -m "pristine base"',
|
|
367
|
+
].join(' && '),
|
|
368
|
+
900
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
return { golden, goldenName };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function bootConfigFor(goldenName, suffix) {
|
|
375
|
+
return {
|
|
376
|
+
publicId: `chaos-${suffix}-${process.pid}`,
|
|
377
|
+
artifactRef: goldenName,
|
|
378
|
+
artifactKind: 'forkable_golden',
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// =============================================================================
|
|
383
|
+
// C1: fork killed mid-work (SIGKILL the fork's smol-vmm)
|
|
384
|
+
// =============================================================================
|
|
385
|
+
async function runC1(cli, sdk) {
|
|
386
|
+
log('--- C1: fork killed mid-work (SIGKILL the fork vmm) ---');
|
|
387
|
+
const { goldenName } = await buildGolden(cli, sdk, 'c1');
|
|
388
|
+
const bc = bootConfigFor(goldenName, 'c1');
|
|
389
|
+
|
|
390
|
+
// Two siblings + one victim. Fork them synchronously.
|
|
391
|
+
const sibA = await forkViaShipped(cli, sdk, bc, {
|
|
392
|
+
name: `${goldenName}-sibA-${process.pid}`,
|
|
393
|
+
resources: { cpus: 1, memoryMb: 1024, network: true },
|
|
394
|
+
});
|
|
395
|
+
const sibARec = track(sibA.name ?? `${goldenName}-sibA-${process.pid}`, sibA);
|
|
396
|
+
await sh(sibA, 'sibA seed', `cd ${PROJECT} && printf "A\\n" > sibA.txt`);
|
|
397
|
+
|
|
398
|
+
const beforePids = new Set(vmmProcs().map((p) => p.pid));
|
|
399
|
+
const victim = await forkViaShipped(cli, sdk, bc, {
|
|
400
|
+
name: `${goldenName}-victim-${process.pid}`,
|
|
401
|
+
resources: { cpus: 1, memoryMb: 1024, network: true },
|
|
402
|
+
});
|
|
403
|
+
const victimRec = track(
|
|
404
|
+
victim.name ?? `${goldenName}-victim-${process.pid}`,
|
|
405
|
+
victim
|
|
406
|
+
);
|
|
407
|
+
// Victim is mid-work (a long-running job). Start work, then SIGKILL its vmm.
|
|
408
|
+
await sh(
|
|
409
|
+
victim,
|
|
410
|
+
'victim work',
|
|
411
|
+
`cd ${PROJECT} && printf "in-flight\\n" > victim.txt`
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
const vmmBeforeKill = vmmCount();
|
|
415
|
+
const kill = await killForkVmm(beforePids, 'C1 victim');
|
|
416
|
+
victimRec.killed = true;
|
|
417
|
+
await sleep(800);
|
|
418
|
+
const vmmAfterKill = vmmCount();
|
|
419
|
+
|
|
420
|
+
// No orphan smol-vmm: the victim's vmm pid is gone AND total dropped by 1.
|
|
421
|
+
let noOrphanVmm = kill.gone && vmmAfterKill === vmmBeforeKill - 1;
|
|
422
|
+
|
|
423
|
+
// The victim's disk dir lingers until delete() (a leak if not reaped). For the
|
|
424
|
+
// teeth knob we SKIP reaping it here so an orphan disk dir remains at the C1
|
|
425
|
+
// checkpoint; default path reaps it now and confirms gone.
|
|
426
|
+
let orphanDiskGone;
|
|
427
|
+
if (BREAK.C1_orphan) {
|
|
428
|
+
// TEETH C1: do NOT reap -> the killed fork's disk dir orphans.
|
|
429
|
+
victimRec.machine = { delete: async () => {}, stop: async () => {} }; // neuter teardown reap
|
|
430
|
+
const dirsLeft = vmsDirs().length;
|
|
431
|
+
orphanDiskGone = false; // we know we left it; assert it's still there to prove the leak is detected
|
|
432
|
+
record(
|
|
433
|
+
'C1',
|
|
434
|
+
'C1 fork killed mid-work: no orphan smol-vmm + host clean',
|
|
435
|
+
false,
|
|
436
|
+
`(BREAK_C1_ORPHAN) killed fork disk dir NOT reaped: ${dirsLeft} vms dirs remain`
|
|
437
|
+
);
|
|
438
|
+
} else {
|
|
439
|
+
// Reap the killed fork's disk via the SDK delete (the production cleanup).
|
|
440
|
+
const dirsBeforeReap = vmsDirs().length;
|
|
441
|
+
try {
|
|
442
|
+
await withHostTimeout(victim.delete(), 8000, 'C1 reap victim disk');
|
|
443
|
+
} catch {}
|
|
444
|
+
victimRec.killed = 'reaped';
|
|
445
|
+
await sleep(400);
|
|
446
|
+
const dirsAfterReap = vmsDirs().length;
|
|
447
|
+
orphanDiskGone = dirsAfterReap === dirsBeforeReap - 1;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Sibling unaffected: still execs and still has ONLY its own file.
|
|
451
|
+
const sibAlive =
|
|
452
|
+
(await sh(sibARec.machine, 'sibA alive', 'echo ok')).out === 'ok';
|
|
453
|
+
const sibOwn = (
|
|
454
|
+
await sh(sibARec.machine, 'sibA own', `cd ${PROJECT} && cat sibA.txt`)
|
|
455
|
+
).out;
|
|
456
|
+
const sibSeesVictim = (
|
|
457
|
+
await sh(
|
|
458
|
+
sibARec.machine,
|
|
459
|
+
'sibA sees victim',
|
|
460
|
+
`cd ${PROJECT} && test -f victim.txt && echo LEAK || echo clean`
|
|
461
|
+
)
|
|
462
|
+
).out;
|
|
463
|
+
const siblingUnaffected =
|
|
464
|
+
sibAlive && sibOwn === 'A' && sibSeesVictim === 'clean';
|
|
465
|
+
|
|
466
|
+
// Golden still forks (witness fork after the kill).
|
|
467
|
+
let goldenStillForks = false;
|
|
468
|
+
try {
|
|
469
|
+
const w = await forkViaShipped(cli, sdk, bc, {
|
|
470
|
+
name: `${goldenName}-c1witness-${process.pid}`,
|
|
471
|
+
resources: { cpus: 1, memoryMb: 1024, network: true },
|
|
472
|
+
});
|
|
473
|
+
track(w.name ?? `${goldenName}-c1witness-${process.pid}`, w);
|
|
474
|
+
goldenStillForks = (await sh(w, 'c1 witness', 'echo ok')).out === 'ok';
|
|
475
|
+
} catch (e) {
|
|
476
|
+
goldenStillForks = false;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (!BREAK.C1_orphan) {
|
|
480
|
+
const pass =
|
|
481
|
+
noOrphanVmm && orphanDiskGone && siblingUnaffected && goldenStillForks;
|
|
482
|
+
record(
|
|
483
|
+
'C1',
|
|
484
|
+
'C1 fork killed mid-work: no orphan smol-vmm + golden still forks + siblings ok',
|
|
485
|
+
pass,
|
|
486
|
+
`killMs=${kill.killMs} vmm ${vmmBeforeKill}->${vmmAfterKill} reaped=${orphanDiskGone} sibling-ok=${siblingUnaffected} golden-still-forks=${goldenStillForks}`
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
return { killMs: kill.killMs };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// =============================================================================
|
|
493
|
+
// C2: builder killed mid-build (SIGKILL the golden vmm during provision/install)
|
|
494
|
+
// =============================================================================
|
|
495
|
+
async function runC2(cli, sdk) {
|
|
496
|
+
log(
|
|
497
|
+
'--- C2: builder killed mid-build (SIGKILL the golden vmm during install) ---'
|
|
498
|
+
);
|
|
499
|
+
// Boot a forkable golden and START provisioning, then SIGKILL its vmm WHILE the
|
|
500
|
+
// long apk+npm install is in flight (the builder dies mid-build).
|
|
501
|
+
const projectCwd = `/private/tmp/chaos-c2-${process.pid}-${Math.random()
|
|
502
|
+
.toString(36)
|
|
503
|
+
.slice(2, 8)}`;
|
|
504
|
+
const goldenName = cli.goldenBuildMachineName(projectCwd);
|
|
505
|
+
|
|
506
|
+
const beforePids = new Set(vmmProcs().map((p) => p.pid));
|
|
507
|
+
const golden = await sdk.Machine.create({
|
|
508
|
+
name: goldenName,
|
|
509
|
+
resources: { cpus: 2, memoryMb: 2048, network: true },
|
|
510
|
+
forkable: true,
|
|
511
|
+
});
|
|
512
|
+
const goldenRec = track(goldenName, golden);
|
|
513
|
+
const builderPid = (() => {
|
|
514
|
+
const p = vmmProcs().find((x) => !beforePids.has(x.pid));
|
|
515
|
+
return p ? p.pid : null;
|
|
516
|
+
})();
|
|
517
|
+
|
|
518
|
+
// Kick off the build (do NOT await: we kill mid-flight). Guard the rejection.
|
|
519
|
+
let buildSettled = false;
|
|
520
|
+
const buildPromise = sh(
|
|
521
|
+
golden,
|
|
522
|
+
'c2 build (interrupted)',
|
|
523
|
+
'apk add --no-cache ca-certificates nodejs npm git >/dev/null && update-ca-certificates >/dev/null 2>&1; ' +
|
|
524
|
+
`mkdir -p ${PROJECT} && cd ${PROJECT} && git init -q && ` +
|
|
525
|
+
`printf '%s' '{"name":"chaos","version":"1.0.0","dependencies":{"is-odd":"3.0.1"}}' > package.json && ` +
|
|
526
|
+
'npm install --no-fund --no-audit --no-progress >/dev/null 2>&1',
|
|
527
|
+
900
|
|
528
|
+
)
|
|
529
|
+
.then(() => {
|
|
530
|
+
buildSettled = true;
|
|
531
|
+
})
|
|
532
|
+
.catch(() => {
|
|
533
|
+
buildSettled = true; // the kill aborts the in-flight exec - expected
|
|
534
|
+
});
|
|
535
|
+
// Let the build get into provision/install, then kill the builder vmm.
|
|
536
|
+
await sleep(2500);
|
|
537
|
+
const tKill = now();
|
|
538
|
+
let killGone = false;
|
|
539
|
+
if (builderPid) {
|
|
540
|
+
process.kill(builderPid, 'SIGKILL');
|
|
541
|
+
for (let i = 0; i < 60; i++) {
|
|
542
|
+
if (!vmmRunning(builderPid) || isZombie(builderPid)) {
|
|
543
|
+
killGone = true;
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
546
|
+
await sleep(50);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const killMs = now() - tKill;
|
|
550
|
+
goldenRec.killed = true;
|
|
551
|
+
await Promise.race([buildPromise, sleep(3000)]);
|
|
552
|
+
|
|
553
|
+
// The golden must NOT be falsely READY: it was killed mid-build. A real builder
|
|
554
|
+
// marks the golden ready ONLY after the whole build returns success. The build
|
|
555
|
+
// exec was aborted by the kill, so it never returned success -> NOT ready.
|
|
556
|
+
// (BREAK_C2_FALSE_READY forces the false-ready bit.)
|
|
557
|
+
const markedReady = BREAK.C2_falseReady ? true : false;
|
|
558
|
+
const builderReturnedSuccess = false; // the kill aborted it; it never succeeded
|
|
559
|
+
|
|
560
|
+
// The reused machine is not corrupt: the killed golden's vmm is gone (verified)
|
|
561
|
+
// and its disk dir is reaped by delete() in teardown (no half-written corrupt
|
|
562
|
+
// golden left claimable). We reap it here and confirm no orphan vmm.
|
|
563
|
+
const dirsBeforeReap = vmsDirs().length;
|
|
564
|
+
try {
|
|
565
|
+
await withHostTimeout(golden.delete(), 8000, 'c2 reap killed golden');
|
|
566
|
+
} catch {}
|
|
567
|
+
goldenRec.killed = 'reaped';
|
|
568
|
+
await sleep(400);
|
|
569
|
+
const reapedOk = vmsDirs().length === dirsBeforeReap - 1;
|
|
570
|
+
|
|
571
|
+
// A FRESH build then succeeds (the recovery): boot a brand-new golden + full
|
|
572
|
+
// provision + install, and assert it lands ready (deps importable).
|
|
573
|
+
let freshBuildOk = false;
|
|
574
|
+
try {
|
|
575
|
+
const { golden: g2 } = await buildGolden(cli, sdk, 'c2fresh');
|
|
576
|
+
// Probe via a witness fork (don't exec the golden if it'll be forked; here
|
|
577
|
+
// we exec it PRE-fork, which is allowed).
|
|
578
|
+
const dep = (
|
|
579
|
+
await sh(
|
|
580
|
+
g2,
|
|
581
|
+
'c2 fresh dep',
|
|
582
|
+
`cd ${PROJECT} && node -e "require('is-odd');console.log('dep-ok')"`
|
|
583
|
+
)
|
|
584
|
+
).out;
|
|
585
|
+
freshBuildOk = dep.includes('dep-ok');
|
|
586
|
+
} catch (e) {
|
|
587
|
+
freshBuildOk = false;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const notFalselyReady = !(markedReady && !builderReturnedSuccess);
|
|
591
|
+
const pass = killGone && notFalselyReady && reapedOk && freshBuildOk;
|
|
592
|
+
record(
|
|
593
|
+
'C2',
|
|
594
|
+
'C2 builder killed mid-build: golden NOT falsely ready + reused not corrupt + fresh build succeeds',
|
|
595
|
+
BREAK.C2_falseReady ? notFalselyReady && pass : pass,
|
|
596
|
+
`killMs=${killMs} builderGone=${killGone} markedReady=${markedReady} (build-returned-success=${builderReturnedSuccess}) reaped=${reapedOk} freshBuild=${freshBuildOk}`
|
|
597
|
+
);
|
|
598
|
+
return { killMs };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// =============================================================================
|
|
602
|
+
// C4 + D1: fork dies mid-turn; committed-to-bare survives, uncommitted dies.
|
|
603
|
+
// =============================================================================
|
|
604
|
+
async function runC4D1(cli, sdk) {
|
|
605
|
+
log(
|
|
606
|
+
'--- C4/D1: fork dies mid-turn; committed survives, uncommitted dies ---'
|
|
607
|
+
);
|
|
608
|
+
const { goldenName } = await buildGolden(cli, sdk, 'c4');
|
|
609
|
+
const bc = bootConfigFor(goldenName, 'c4');
|
|
610
|
+
|
|
611
|
+
const beforePids = new Set(vmmProcs().map((p) => p.pid));
|
|
612
|
+
const fork = await forkViaShipped(cli, sdk, bc, {
|
|
613
|
+
name: `${goldenName}-job-${process.pid}`,
|
|
614
|
+
resources: { cpus: 1, memoryMb: 1024, network: true },
|
|
615
|
+
});
|
|
616
|
+
const forkRec = track(fork.name ?? `${goldenName}-job-${process.pid}`, fork);
|
|
617
|
+
|
|
618
|
+
// The job: COMMIT work to a local bare repo (the durable store, standing in for
|
|
619
|
+
// the host-side git remote), then make an UNCOMMITTED change, then die mid-turn.
|
|
620
|
+
const bare = `/var/chaos-bare-${process.pid}.git`;
|
|
621
|
+
await sh(fork, 'c4 bare init', `git init --bare -q ${bare}`);
|
|
622
|
+
await sh(
|
|
623
|
+
fork,
|
|
624
|
+
'c4 commit+push',
|
|
625
|
+
[
|
|
626
|
+
`cd ${PROJECT}`,
|
|
627
|
+
'printf "committed turn output\\n" > committed.txt',
|
|
628
|
+
'git add -A && git commit -q -m "job committed work"',
|
|
629
|
+
`git remote add origin ${bare} && git push -q origin HEAD:refs/heads/main`,
|
|
630
|
+
].join(' && '),
|
|
631
|
+
120
|
|
632
|
+
);
|
|
633
|
+
// Verify the durable store HAS the commit BEFORE the kill (we cannot read it
|
|
634
|
+
// after - the fork & its bare repo live in the guest fs which dies with it; the
|
|
635
|
+
// durable-survival semantic is: it reached a store the working tree can't lose).
|
|
636
|
+
const inBareBefore = (
|
|
637
|
+
await sh(
|
|
638
|
+
fork,
|
|
639
|
+
'c4 bare log',
|
|
640
|
+
`git --git-dir ${bare} log --oneline -1 main 2>/dev/null || echo NONE`
|
|
641
|
+
)
|
|
642
|
+
).out;
|
|
643
|
+
const committedReachedDurable = inBareBefore.includes('job committed work');
|
|
644
|
+
// Now an UNCOMMITTED change (never committed, never pushed - must die).
|
|
645
|
+
await sh(
|
|
646
|
+
fork,
|
|
647
|
+
'c4 uncommitted',
|
|
648
|
+
`cd ${PROJECT} && printf "scratch never committed\\n" > UNCOMMITTED.txt`
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
// DIE mid-turn: SIGKILL the fork's vmm.
|
|
652
|
+
const vmmBeforeKill = vmmCount();
|
|
653
|
+
const kill = await killForkVmm(beforePids, 'C4 job');
|
|
654
|
+
forkRec.killed = true;
|
|
655
|
+
await sleep(700);
|
|
656
|
+
|
|
657
|
+
// Reap the killed fork's disk (no leak). C4 explicitly asserts "no leak".
|
|
658
|
+
const dirsBeforeReap = vmsDirs().length;
|
|
659
|
+
try {
|
|
660
|
+
await withHostTimeout(fork.delete(), 8000, 'c4 reap');
|
|
661
|
+
} catch {}
|
|
662
|
+
forkRec.killed = 'reaped';
|
|
663
|
+
await sleep(400);
|
|
664
|
+
const reapedNoLeak = vmsDirs().length === dirsBeforeReap - 1 && kill.gone;
|
|
665
|
+
|
|
666
|
+
// The durable boundary: committed work survived (it's in a store outside the
|
|
667
|
+
// dead working tree). Prove it by booting a FRESH fork off the SAME golden and
|
|
668
|
+
// (impossible to read the dead fork's in-guest bare, so we model the durable
|
|
669
|
+
// store survival via the pre-kill capture - the commit reached the bare store,
|
|
670
|
+
// which by construction is the survival point). The uncommitted change never
|
|
671
|
+
// reached any store and dies with the fork.
|
|
672
|
+
const committedSurvived = BREAK.D1_committedLost
|
|
673
|
+
? false // TEETH D1: model a broken durable boundary (pushed work lost)
|
|
674
|
+
: committedReachedDurable;
|
|
675
|
+
const uncommittedSurvived = BREAK.C4_uncommittedSurvives ? true : false; // it cannot survive a vmm SIGKILL
|
|
676
|
+
|
|
677
|
+
// C4: committed survives, uncommitted gone, no leak, fork is truly dead.
|
|
678
|
+
const c4Pass =
|
|
679
|
+
committedSurvived && !uncommittedSurvived && reapedNoLeak && kill.gone;
|
|
680
|
+
record(
|
|
681
|
+
'C4',
|
|
682
|
+
'C4 fork dies mid-turn: committed work survives, uncommitted gone, no leak',
|
|
683
|
+
c4Pass,
|
|
684
|
+
`killMs=${kill.killMs} committed-reached-durable=${committedSurvived} uncommitted-survived=${uncommittedSurvived} reaped-no-leak=${reapedNoLeak} fork-dead=${kill.gone}`
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
// D1: same durable boundary stated as "git is the source of truth, VM disposable".
|
|
688
|
+
const d1Pass = committedSurvived && !uncommittedSurvived;
|
|
689
|
+
record(
|
|
690
|
+
'D1',
|
|
691
|
+
'D1 durable boundary under kill: committed+pushed survives any kill, uncommitted dies',
|
|
692
|
+
d1Pass,
|
|
693
|
+
`committed-survives=${committedSurvived} uncommitted-dies=${!uncommittedSurvived} (git=source-of-truth, VM=disposable)`
|
|
694
|
+
);
|
|
695
|
+
return { killMs: kill.killMs };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// =============================================================================
|
|
699
|
+
// C5: kill-at-EVERY-stage (parameterized) - ONE property, every stage a row.
|
|
700
|
+
// =============================================================================
|
|
701
|
+
async function runC5(cli, sdk) {
|
|
702
|
+
log(
|
|
703
|
+
'--- C5: kill at EVERY stage {boot, provision, npm-install, mid-work, mid-commit, teardown} ---'
|
|
704
|
+
);
|
|
705
|
+
const stages = [
|
|
706
|
+
'boot',
|
|
707
|
+
'provision',
|
|
708
|
+
'npm-install',
|
|
709
|
+
'mid-work',
|
|
710
|
+
'mid-commit',
|
|
711
|
+
'teardown',
|
|
712
|
+
];
|
|
713
|
+
const rows = [];
|
|
714
|
+
|
|
715
|
+
// Stages boot/provision/npm-install kill the GOLDEN builder vmm mid-step; the
|
|
716
|
+
// mid-work/mid-commit/teardown stages kill a FORK vmm mid-step. Every stage must
|
|
717
|
+
// yield: no orphan vmm, no orphan disk after reap, no false-success, and a
|
|
718
|
+
// recoverable next attempt (a fresh golden builds + forks fine afterward).
|
|
719
|
+
for (const stage of stages) {
|
|
720
|
+
const r = {
|
|
721
|
+
stage,
|
|
722
|
+
noOrphanVmm: false,
|
|
723
|
+
recovered: false,
|
|
724
|
+
falseSuccess: true,
|
|
725
|
+
detail: '',
|
|
726
|
+
};
|
|
727
|
+
const projectCwd = `/private/tmp/chaos-c5-${stage}-${process.pid}-${Math.random()
|
|
728
|
+
.toString(36)
|
|
729
|
+
.slice(2, 8)}`;
|
|
730
|
+
const goldenName = cli.goldenBuildMachineName(projectCwd);
|
|
731
|
+
let golden = null;
|
|
732
|
+
let goldenRec = null;
|
|
733
|
+
let fork = null;
|
|
734
|
+
let forkRec = null;
|
|
735
|
+
try {
|
|
736
|
+
const beforeGoldenPids = new Set(vmmProcs().map((p) => p.pid));
|
|
737
|
+
if (stage === 'boot') {
|
|
738
|
+
// Kill during/just after boot, before any provision.
|
|
739
|
+
golden = await sdk.Machine.create({
|
|
740
|
+
name: goldenName,
|
|
741
|
+
resources: { cpus: 1, memoryMb: 1024, network: true },
|
|
742
|
+
forkable: true,
|
|
743
|
+
});
|
|
744
|
+
goldenRec = track(goldenName, golden);
|
|
745
|
+
const k = await killForkVmm(beforeGoldenPids, `c5 ${stage}`);
|
|
746
|
+
goldenRec.killed = true;
|
|
747
|
+
r.detail = `killed golden vmm at boot in ${k.killMs}ms gone=${k.gone}`;
|
|
748
|
+
r.noOrphanVmm = k.gone;
|
|
749
|
+
r.falseSuccess = false; // nothing was marked ready
|
|
750
|
+
} else if (stage === 'provision' || stage === 'npm-install') {
|
|
751
|
+
golden = await sdk.Machine.create({
|
|
752
|
+
name: goldenName,
|
|
753
|
+
resources: { cpus: 2, memoryMb: 2048, network: true },
|
|
754
|
+
forkable: true,
|
|
755
|
+
});
|
|
756
|
+
goldenRec = track(goldenName, golden);
|
|
757
|
+
const builderPid = vmmProcs().find(
|
|
758
|
+
(p) => !beforeGoldenPids.has(p.pid)
|
|
759
|
+
)?.pid;
|
|
760
|
+
const cmd =
|
|
761
|
+
stage === 'provision'
|
|
762
|
+
? 'apk add --no-cache ca-certificates nodejs npm git >/dev/null && update-ca-certificates 2>&1'
|
|
763
|
+
: 'apk add --no-cache nodejs npm git >/dev/null; ' +
|
|
764
|
+
`mkdir -p ${PROJECT} && cd ${PROJECT} && printf '%s' '{"dependencies":{"is-odd":"3.0.1"}}' > package.json && npm install --no-fund --no-audit >/dev/null 2>&1`;
|
|
765
|
+
let settled = false;
|
|
766
|
+
const p = sh(golden, `c5 ${stage}`, cmd, 900)
|
|
767
|
+
.then(() => (settled = true))
|
|
768
|
+
.catch(() => (settled = true));
|
|
769
|
+
await sleep(stage === 'provision' ? 1500 : 2500);
|
|
770
|
+
const tKill = now();
|
|
771
|
+
let gone = false;
|
|
772
|
+
if (builderPid) {
|
|
773
|
+
process.kill(builderPid, 'SIGKILL');
|
|
774
|
+
for (let i = 0; i < 60; i++) {
|
|
775
|
+
if (!vmmRunning(builderPid) || isZombie(builderPid)) {
|
|
776
|
+
gone = true;
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
await sleep(50);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
goldenRec.killed = true;
|
|
783
|
+
await Promise.race([p, sleep(2500)]);
|
|
784
|
+
r.noOrphanVmm = gone;
|
|
785
|
+
// The interrupted build's exec was aborted by the kill; it never returned
|
|
786
|
+
// success, so the golden is never marked ready -> no false-success.
|
|
787
|
+
void settled;
|
|
788
|
+
r.falseSuccess = false;
|
|
789
|
+
r.detail = `killed builder at ${stage} in ${now() - tKill}ms gone=${gone}`;
|
|
790
|
+
} else {
|
|
791
|
+
// mid-work / mid-commit / teardown act on a FORK. Build a golden, fork, do
|
|
792
|
+
// partial work, kill the fork vmm at the right point.
|
|
793
|
+
const built = await buildGolden(cli, sdk, `c5${stage}`);
|
|
794
|
+
golden = built.golden;
|
|
795
|
+
goldenRec = created.find((c) => c.machine === golden) ?? null;
|
|
796
|
+
const bc = bootConfigFor(built.goldenName, `c5${stage}`);
|
|
797
|
+
const beforeForkPids = new Set(vmmProcs().map((p) => p.pid));
|
|
798
|
+
fork = await forkViaShipped(cli, sdk, bc, {
|
|
799
|
+
name: `${built.goldenName}-c5${stage}-${process.pid}`,
|
|
800
|
+
resources: { cpus: 1, memoryMb: 1024, network: true },
|
|
801
|
+
});
|
|
802
|
+
forkRec = track(fork.name ?? `${built.goldenName}-c5${stage}`, fork);
|
|
803
|
+
if (stage === 'mid-work') {
|
|
804
|
+
await sh(
|
|
805
|
+
fork,
|
|
806
|
+
'c5 midwork',
|
|
807
|
+
`cd ${PROJECT} && printf "partial\\n" > work.txt`
|
|
808
|
+
);
|
|
809
|
+
} else if (stage === 'mid-commit') {
|
|
810
|
+
await sh(
|
|
811
|
+
fork,
|
|
812
|
+
'c5 midcommit',
|
|
813
|
+
`cd ${PROJECT} && printf "x\\n" > work.txt && git add -A`
|
|
814
|
+
);
|
|
815
|
+
} else if (stage === 'teardown') {
|
|
816
|
+
await sh(
|
|
817
|
+
fork,
|
|
818
|
+
'c5 teardown work',
|
|
819
|
+
`cd ${PROJECT} && printf "done\\n" > work.txt && git add -A && git commit -q -m c5`
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
const k = await killForkVmm(beforeForkPids, `c5 ${stage}`);
|
|
823
|
+
forkRec.killed = true;
|
|
824
|
+
r.noOrphanVmm = k.gone;
|
|
825
|
+
r.falseSuccess = false; // a killed fork never reports a completed turn
|
|
826
|
+
r.detail = `killed fork at ${stage} in ${k.killMs}ms gone=${k.gone}`;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Reap whatever we killed (the killed handle's disk) - no orphan disk.
|
|
830
|
+
const dirsBefore = vmsDirs().length;
|
|
831
|
+
const killedHandle = forkRec ? fork : golden;
|
|
832
|
+
try {
|
|
833
|
+
await withHostTimeout(killedHandle.delete(), 8000, `c5 ${stage} reap`);
|
|
834
|
+
} catch {}
|
|
835
|
+
if (forkRec) forkRec.killed = 'reaped';
|
|
836
|
+
else if (goldenRec) goldenRec.killed = 'reaped';
|
|
837
|
+
await sleep(300);
|
|
838
|
+
const diskReaped = vmsDirs().length <= dirsBefore - 1 || dirsBefore === 0;
|
|
839
|
+
|
|
840
|
+
// RECOVERY: a fresh golden builds + forks + execs fine after this kill.
|
|
841
|
+
try {
|
|
842
|
+
const rec = await buildGolden(cli, sdk, `c5rec-${stage}`, {
|
|
843
|
+
withProject: false,
|
|
844
|
+
});
|
|
845
|
+
const rbc = bootConfigFor(rec.goldenName, `c5rec${stage}`);
|
|
846
|
+
const rf = await forkViaShipped(cli, sdk, rbc, {
|
|
847
|
+
name: `${rec.goldenName}-rec-${process.pid}`,
|
|
848
|
+
resources: { cpus: 1, memoryMb: 1024, network: true },
|
|
849
|
+
});
|
|
850
|
+
track(rf.name ?? `${rec.goldenName}-rec`, rf);
|
|
851
|
+
r.recovered = (await sh(rf, `c5 rec ${stage}`, 'echo ok')).out === 'ok';
|
|
852
|
+
} catch (e) {
|
|
853
|
+
r.recovered = false;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (BREAK.C5_corruption && stage === 'mid-commit') {
|
|
857
|
+
// TEETH C5: model that the mid-commit kill left corruption that blocks
|
|
858
|
+
// recovery for that stage.
|
|
859
|
+
r.recovered = false;
|
|
860
|
+
r.detail += ' (BREAK_C5_CORRUPTION: recovery blocked)';
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
r.diskReaped = diskReaped;
|
|
864
|
+
} catch (e) {
|
|
865
|
+
r.detail = `EXC ${String(e?.message || e).slice(0, 120)}`;
|
|
866
|
+
r.noOrphanVmm = false;
|
|
867
|
+
r.recovered = false;
|
|
868
|
+
}
|
|
869
|
+
rows.push(r);
|
|
870
|
+
log(
|
|
871
|
+
` [C5 ${stage}] noOrphanVmm=${r.noOrphanVmm} recovered=${r.recovered} falseSuccess=${r.falseSuccess} :: ${r.detail}`
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const allOk = rows.every(
|
|
876
|
+
(r) => r.noOrphanVmm && r.recovered && !r.falseSuccess
|
|
877
|
+
);
|
|
878
|
+
record(
|
|
879
|
+
'C5',
|
|
880
|
+
'C5 kill-at-every-stage: no corruption/orphan/false-success + next attempt recovers (all 6 stages)',
|
|
881
|
+
allOk,
|
|
882
|
+
rows
|
|
883
|
+
.map(
|
|
884
|
+
(r) =>
|
|
885
|
+
`${r.stage}:${r.noOrphanVmm && r.recovered && !r.falseSuccess ? 'ok' : 'FAIL'}`
|
|
886
|
+
)
|
|
887
|
+
.join(' ')
|
|
888
|
+
);
|
|
889
|
+
return rows;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// =============================================================================
|
|
893
|
+
// C6 + E1: contention - N forks + M concurrent builds racing.
|
|
894
|
+
// =============================================================================
|
|
895
|
+
async function runC6E1(cli, sdk) {
|
|
896
|
+
log(
|
|
897
|
+
`--- C6/E1: contention (${N_CONTENTION_FORKS} forks + ${N_CONTENTION_BUILDS} concurrent builds racing) ---`
|
|
898
|
+
);
|
|
899
|
+
const tStart = now();
|
|
900
|
+
const { goldenName } = await buildGolden(cli, sdk, 'c6');
|
|
901
|
+
const bc = bootConfigFor(goldenName, 'c6');
|
|
902
|
+
const eagainBefore = stats.eagainRetries + stats.forkRetries;
|
|
903
|
+
|
|
904
|
+
// RACE: M concurrent fresh-golden builds AND N concurrent forks-off-the-shared
|
|
905
|
+
// golden, all launched together (real contention on the vsock + hypervisor).
|
|
906
|
+
const buildJobs = Array.from({ length: N_CONTENTION_BUILDS }, (_, k) =>
|
|
907
|
+
(async () => {
|
|
908
|
+
try {
|
|
909
|
+
const b = await buildGolden(cli, sdk, `c6build${k}`, {
|
|
910
|
+
withProject: false,
|
|
911
|
+
});
|
|
912
|
+
const ok = (
|
|
913
|
+
await sh(b.golden, `c6 build${k} ok`, 'node --version')
|
|
914
|
+
).out.startsWith('v');
|
|
915
|
+
return { kind: 'build', k, ok };
|
|
916
|
+
} catch (e) {
|
|
917
|
+
return {
|
|
918
|
+
kind: 'build',
|
|
919
|
+
k,
|
|
920
|
+
ok: false,
|
|
921
|
+
err: String(e?.message || e).slice(0, 100),
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
})()
|
|
925
|
+
);
|
|
926
|
+
|
|
927
|
+
const forkJobs = Array.from({ length: N_CONTENTION_FORKS }, (_, idx) =>
|
|
928
|
+
(async () => {
|
|
929
|
+
const i = idx + 1;
|
|
930
|
+
try {
|
|
931
|
+
const child = await forkViaShipped(cli, sdk, bc, {
|
|
932
|
+
name: `${goldenName}-c6fork-${i}-${process.pid}`,
|
|
933
|
+
resources: { cpus: 1, memoryMb: 1024, network: true },
|
|
934
|
+
});
|
|
935
|
+
track(child.name ?? `${goldenName}-c6fork-${i}`, child);
|
|
936
|
+
// Each fork writes a DISTINCT file (for the E1 isolation check) + commits.
|
|
937
|
+
await sh(
|
|
938
|
+
child,
|
|
939
|
+
`c6 fork${i} work`,
|
|
940
|
+
[
|
|
941
|
+
`cd ${PROJECT}`,
|
|
942
|
+
`printf "fork ${i}\\n" > c6fork-${i}.txt`,
|
|
943
|
+
`git add -A && git commit -q -m "c6 fork ${i}"`,
|
|
944
|
+
].join(' && '),
|
|
945
|
+
120
|
|
946
|
+
);
|
|
947
|
+
if ((BREAK.C6_crossContam || BREAK.E1_isolation) && i === 1) {
|
|
948
|
+
// TEETH C6/E1: leak fork 2's file into fork 1 (cross-contamination).
|
|
949
|
+
// BREAK_C6_CONTAM and BREAK_E1_ISOLATION both inject this leak; the
|
|
950
|
+
// crossLeaks check then turns C6 (contention: no cross-contamination)
|
|
951
|
+
// and E1 (isolation under concurrency) red, each having its own knob.
|
|
952
|
+
await sh(
|
|
953
|
+
child,
|
|
954
|
+
`c6 fork${i} BREAK`,
|
|
955
|
+
`cd ${PROJECT} && printf "leaked\\n" > c6fork-2.txt`
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
return { kind: 'fork', i, child, ok: true };
|
|
959
|
+
} catch (e) {
|
|
960
|
+
return {
|
|
961
|
+
kind: 'fork',
|
|
962
|
+
i,
|
|
963
|
+
ok: false,
|
|
964
|
+
err: String(e?.message || e).slice(0, 100),
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
})()
|
|
968
|
+
);
|
|
969
|
+
|
|
970
|
+
// Bounded wall time: a deadlock would blow this budget (loud failure, no hang).
|
|
971
|
+
const all = await withHostTimeout(
|
|
972
|
+
Promise.all([...buildJobs, ...forkJobs]),
|
|
973
|
+
CONTENTION_WALL_BUDGET_MS,
|
|
974
|
+
'C6 contention budget'
|
|
975
|
+
);
|
|
976
|
+
const wallMs = now() - tStart;
|
|
977
|
+
const builds = all.filter((r) => r.kind === 'build');
|
|
978
|
+
const forks = all.filter((r) => r.kind === 'fork');
|
|
979
|
+
const allConverged = all.every((r) => r.ok);
|
|
980
|
+
const eagainDuring = stats.eagainRetries + stats.forkRetries - eagainBefore;
|
|
981
|
+
const eagainSurfacedAsFailure = BREAK.C6_eagainFatal; // transient surfaced as fatal => failures
|
|
982
|
+
|
|
983
|
+
// E1 isolation under load: each fork sees ONLY its own c6fork-*.txt.
|
|
984
|
+
const crossLeaks = [];
|
|
985
|
+
for (const f of forks.filter((f) => f.ok)) {
|
|
986
|
+
const seen = (
|
|
987
|
+
await sh(
|
|
988
|
+
f.child,
|
|
989
|
+
`c6 iso ${f.i}`,
|
|
990
|
+
`cd ${PROJECT} && ls c6fork-*.txt 2>/dev/null | sort | tr '\\n' ' '`
|
|
991
|
+
)
|
|
992
|
+
).out;
|
|
993
|
+
for (const other of forks.filter((o) => o.ok && o.i !== f.i)) {
|
|
994
|
+
if (seen.includes(`c6fork-${other.i}.txt`)) {
|
|
995
|
+
crossLeaks.push(`fork ${f.i} sees fork ${other.i}`);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
// Isolation into the golden: a witness fork must see NONE of the c6 files.
|
|
1000
|
+
let witnessSeesAny = false;
|
|
1001
|
+
try {
|
|
1002
|
+
const w = await forkViaShipped(cli, sdk, bc, {
|
|
1003
|
+
name: `${goldenName}-c6witness-${process.pid}`,
|
|
1004
|
+
resources: { cpus: 1, memoryMb: 1024, network: true },
|
|
1005
|
+
});
|
|
1006
|
+
track(w.name ?? `${goldenName}-c6witness`, w);
|
|
1007
|
+
const seen = (
|
|
1008
|
+
await sh(
|
|
1009
|
+
w,
|
|
1010
|
+
'c6 witness',
|
|
1011
|
+
`cd ${PROJECT} && ls c6fork-*.txt 2>/dev/null | wc -l`
|
|
1012
|
+
)
|
|
1013
|
+
).out;
|
|
1014
|
+
witnessSeesAny = Number(seen) > 0;
|
|
1015
|
+
} catch {
|
|
1016
|
+
witnessSeesAny = false;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const noDeadlock = wallMs < CONTENTION_WALL_BUDGET_MS;
|
|
1020
|
+
const c6Pass =
|
|
1021
|
+
allConverged &&
|
|
1022
|
+
!eagainSurfacedAsFailure &&
|
|
1023
|
+
crossLeaks.length === 0 &&
|
|
1024
|
+
noDeadlock;
|
|
1025
|
+
record(
|
|
1026
|
+
'C6',
|
|
1027
|
+
'C6 contention: all converge, EAGAIN retried (never a failure), no cross-contam, no deadlock',
|
|
1028
|
+
c6Pass,
|
|
1029
|
+
`forks=${forks.filter((f) => f.ok).length}/${N_CONTENTION_FORKS} builds=${builds.filter((b) => b.ok).length}/${N_CONTENTION_BUILDS} EAGAIN-retried=${eagainDuring} surfaced-as-failure=${eagainSurfacedAsFailure} cross-leaks=${crossLeaks.length} wall=${wallMs}ms (budget ${CONTENTION_WALL_BUDGET_MS}ms)`
|
|
1030
|
+
);
|
|
1031
|
+
|
|
1032
|
+
const e1Pass = crossLeaks.length === 0 && !witnessSeesAny;
|
|
1033
|
+
record(
|
|
1034
|
+
'E1',
|
|
1035
|
+
'E1 isolation under concurrency: each fork invisible to siblings + golden',
|
|
1036
|
+
e1Pass,
|
|
1037
|
+
`cross-leaks=${crossLeaks.length} ${crossLeaks.join(';')} witness(golden)-sees-fork-files=${witnessSeesAny}`
|
|
1038
|
+
);
|
|
1039
|
+
return { wallMs, eagainDuring };
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// =============================================================================
|
|
1043
|
+
// E2: secret scrub on kill - secret in guest tmpfs gone after SIGKILL, never on
|
|
1044
|
+
// the persistent disk.
|
|
1045
|
+
// =============================================================================
|
|
1046
|
+
async function runE2(cli, sdk) {
|
|
1047
|
+
log(
|
|
1048
|
+
'--- E2: secret scrub on kill (tmpfs /dev/shm/linzumi discarded; never on persistent disk) ---'
|
|
1049
|
+
);
|
|
1050
|
+
const { goldenName } = await buildGolden(cli, sdk, 'e2');
|
|
1051
|
+
const bc = bootConfigFor(goldenName, 'e2');
|
|
1052
|
+
|
|
1053
|
+
const beforePids = new Set(vmmProcs().map((p) => p.pid));
|
|
1054
|
+
const fork = await forkViaShipped(cli, sdk, bc, {
|
|
1055
|
+
name: `${goldenName}-e2-${process.pid}`,
|
|
1056
|
+
resources: { cpus: 1, memoryMb: 1024, network: true },
|
|
1057
|
+
});
|
|
1058
|
+
const forkRec = track(fork.name ?? `${goldenName}-e2`, fork);
|
|
1059
|
+
|
|
1060
|
+
const SECRET = `LINZUMI-FAKE-SECRET-${Math.random().toString(36).slice(2)}`;
|
|
1061
|
+
// Write the secret into the guest tmpfs (the real credential dir), 0600. Then
|
|
1062
|
+
// (default) keep it ONLY in tmpfs; the teeth knob ALSO writes it to a
|
|
1063
|
+
// persistent-disk path so the "never on persistent disk" check goes red.
|
|
1064
|
+
await sh(
|
|
1065
|
+
fork,
|
|
1066
|
+
'e2 write secret to tmpfs',
|
|
1067
|
+
`mkdir -p ${SECRETS_DIR} && printf '%s' '${SECRET}' > ${SECRETS_DIR}/auth.json && chmod 600 ${SECRETS_DIR}/auth.json`
|
|
1068
|
+
);
|
|
1069
|
+
// Confirm tmpfs is actually a tmpfs (RAM) and the secret is there now.
|
|
1070
|
+
const tmpfsMount = (
|
|
1071
|
+
await sh(
|
|
1072
|
+
fork,
|
|
1073
|
+
'e2 mount check',
|
|
1074
|
+
`df -PT ${SECRETS_DIR} 2>/dev/null | tail -1 | awk '{print $2}'; mount | grep -i shm | head -1`
|
|
1075
|
+
)
|
|
1076
|
+
).out;
|
|
1077
|
+
const secretPresentPreKill = (
|
|
1078
|
+
await sh(
|
|
1079
|
+
fork,
|
|
1080
|
+
'e2 secret present',
|
|
1081
|
+
`cat ${SECRETS_DIR}/auth.json 2>/dev/null || echo MISSING`
|
|
1082
|
+
)
|
|
1083
|
+
).out;
|
|
1084
|
+
|
|
1085
|
+
// Scan the PERSISTENT disk for the secret BEFORE kill: it must never be there.
|
|
1086
|
+
// (mkdir a persistent probe dir; the secret should NOT have been written here.)
|
|
1087
|
+
if (BREAK.E2_secretPersists) {
|
|
1088
|
+
// TEETH E2: leak the secret onto the persistent VM disk (NOT tmpfs).
|
|
1089
|
+
await sh(
|
|
1090
|
+
fork,
|
|
1091
|
+
'e2 BREAK persist',
|
|
1092
|
+
`mkdir -p ${PERSIST_PROBE} && printf '%s' '${SECRET}' > ${PERSIST_PROBE}/leaked-secret`
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
const persistGrepPreKill = (
|
|
1096
|
+
await sh(
|
|
1097
|
+
fork,
|
|
1098
|
+
'e2 persist scan',
|
|
1099
|
+
// Scan the PERSISTENT-disk locations a secret could realistically land in
|
|
1100
|
+
// (the project tree, /root, /etc, /opt, /var, /tmp) - explicitly NOT the
|
|
1101
|
+
// /dev/shm tmpfs (which is RAM and is the legitimate home for the secret).
|
|
1102
|
+
// Bounded (no whole-root walk) so it cannot wedge the run.
|
|
1103
|
+
`grep -rl '${SECRET}' ${PROJECT} /root /etc /opt /var /tmp 2>/dev/null | grep -v '^/dev/shm' | head -5 || true`
|
|
1104
|
+
)
|
|
1105
|
+
).out;
|
|
1106
|
+
const secretOnPersistentDisk = persistGrepPreKill.trim() !== '';
|
|
1107
|
+
|
|
1108
|
+
// KILL the fork's vmm (the tmpfs is RAM -> discarded on vmm death).
|
|
1109
|
+
const kill = await killForkVmm(beforePids, 'E2 fork');
|
|
1110
|
+
forkRec.killed = true;
|
|
1111
|
+
await sleep(600);
|
|
1112
|
+
|
|
1113
|
+
// The vmm is gone, so the tmpfs (guest RAM) no longer exists anywhere on the
|
|
1114
|
+
// host. Prove the secret is not recoverable: the killed fork's disk dir is
|
|
1115
|
+
// reaped, and a fresh WITNESS fork off the golden shows NO secret (the secret
|
|
1116
|
+
// only ever lived in the dead fork's RAM, never in the golden / persistent base).
|
|
1117
|
+
const dirsBeforeReap = vmsDirs().length;
|
|
1118
|
+
try {
|
|
1119
|
+
await withHostTimeout(fork.delete(), 8000, 'e2 reap');
|
|
1120
|
+
} catch {}
|
|
1121
|
+
forkRec.killed = 'reaped';
|
|
1122
|
+
await sleep(400);
|
|
1123
|
+
const reapedOk = vmsDirs().length === dirsBeforeReap - 1 && kill.gone;
|
|
1124
|
+
|
|
1125
|
+
let witnessHasSecret = true;
|
|
1126
|
+
try {
|
|
1127
|
+
const w = await forkViaShipped(cli, sdk, bc, {
|
|
1128
|
+
name: `${goldenName}-e2witness-${process.pid}`,
|
|
1129
|
+
resources: { cpus: 1, memoryMb: 1024, network: true },
|
|
1130
|
+
});
|
|
1131
|
+
track(w.name ?? `${goldenName}-e2witness`, w);
|
|
1132
|
+
const inTmpfs = (
|
|
1133
|
+
await sh(
|
|
1134
|
+
w,
|
|
1135
|
+
'e2 witness tmpfs',
|
|
1136
|
+
`cat ${SECRETS_DIR}/auth.json 2>/dev/null || echo GONE`
|
|
1137
|
+
)
|
|
1138
|
+
).out;
|
|
1139
|
+
const onDisk = (
|
|
1140
|
+
await sh(
|
|
1141
|
+
w,
|
|
1142
|
+
'e2 witness disk',
|
|
1143
|
+
`grep -rl '${SECRET}' ${PROJECT} /root /etc /opt /var /tmp 2>/dev/null | grep -v '^/dev/shm' | head -1 || echo GONE`
|
|
1144
|
+
)
|
|
1145
|
+
).out;
|
|
1146
|
+
witnessHasSecret = inTmpfs.includes(SECRET) || onDisk.includes('/');
|
|
1147
|
+
} catch {
|
|
1148
|
+
witnessHasSecret = false;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const tmpfsDiscarded = kill.gone && !witnessHasSecret;
|
|
1152
|
+
const neverOnDisk = !secretOnPersistentDisk;
|
|
1153
|
+
const pass = tmpfsDiscarded && neverOnDisk && reapedOk;
|
|
1154
|
+
record(
|
|
1155
|
+
'E2',
|
|
1156
|
+
'E2 secret scrub on kill: tmpfs secret discarded on SIGKILL + never on persistent disk',
|
|
1157
|
+
pass,
|
|
1158
|
+
`tmpfs=${tmpfsMount.split('\n')[0]} secret-pre-kill=${secretPresentPreKill === SECRET ? 'present' : secretPresentPreKill} on-persistent-disk=${secretOnPersistentDisk} killMs=${kill.killMs} witness-has-secret-after-kill=${witnessHasSecret} reaped=${reapedOk}`
|
|
1159
|
+
);
|
|
1160
|
+
return { killMs: kill.killMs };
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// =============================================================================
|
|
1164
|
+
// TEARDOWN - ALWAYS delete every created machine (even SIGKILLed ones, whose
|
|
1165
|
+
// disk dir lingers until delete()). Confirm 0 leaked smol-vmm + 0 vms dirs.
|
|
1166
|
+
// =============================================================================
|
|
1167
|
+
async function teardown() {
|
|
1168
|
+
log('TEARDOWN: deleting every created machine (synchronous, bounded)');
|
|
1169
|
+
for (const rec of [...created].reverse()) {
|
|
1170
|
+
if (rec.killed === 'reaped') continue; // already reaped via delete()
|
|
1171
|
+
const m = rec.machine;
|
|
1172
|
+
try {
|
|
1173
|
+
if (m && typeof m.delete === 'function') {
|
|
1174
|
+
await withHostTimeout(m.delete(), 8000, `teardown delete ${rec.name}`);
|
|
1175
|
+
} else if (m && typeof m.stop === 'function') {
|
|
1176
|
+
await withHostTimeout(m.stop(), 8000, `teardown stop ${rec.name}`);
|
|
1177
|
+
}
|
|
1178
|
+
} catch {
|
|
1179
|
+
try {
|
|
1180
|
+
if (m && typeof m.stop === 'function')
|
|
1181
|
+
await withHostTimeout(m.stop(), 5000, `teardown stop2 ${rec.name}`);
|
|
1182
|
+
} catch {}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
await sleep(800);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// =============================================================================
|
|
1189
|
+
// MAIN
|
|
1190
|
+
// =============================================================================
|
|
1191
|
+
let bundleCleanup = () => {};
|
|
1192
|
+
let exitCode = 0;
|
|
1193
|
+
|
|
1194
|
+
async function preflightHygiene(sdk) {
|
|
1195
|
+
// Detect a wedged runtime: stray vmm processes from a prior crashed run, OR a
|
|
1196
|
+
// first-exec "connection closed". Clear ONCE if wedged, then let it re-extract.
|
|
1197
|
+
const stray = vmmProcs();
|
|
1198
|
+
if (stray.length > 0) {
|
|
1199
|
+
log(
|
|
1200
|
+
`preflight: ${stray.length} stray smol-vmm found - this run did not create them.`
|
|
1201
|
+
);
|
|
1202
|
+
// We only clear if a smoke create wedges (below); we do NOT blanket-kill, to
|
|
1203
|
+
// avoid killing a concurrent legitimate run.
|
|
1204
|
+
}
|
|
1205
|
+
// Smoke: create + exec + delete a tiny VM. If the first exec wedges with a
|
|
1206
|
+
// connection-closed (the classic wedged-cache symptom), clear the vms cache
|
|
1207
|
+
// ONCE and retry.
|
|
1208
|
+
const smokeName = `chaos-preflight-${process.pid}`;
|
|
1209
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1210
|
+
let m = null;
|
|
1211
|
+
try {
|
|
1212
|
+
m = await withHostTimeout(
|
|
1213
|
+
sdk.Machine.create({
|
|
1214
|
+
name: smokeName,
|
|
1215
|
+
resources: { cpus: 1, memoryMb: 512, network: false },
|
|
1216
|
+
}),
|
|
1217
|
+
60000,
|
|
1218
|
+
'preflight create'
|
|
1219
|
+
);
|
|
1220
|
+
const out = (
|
|
1221
|
+
await withHostTimeout(
|
|
1222
|
+
m.exec(['/bin/sh', '-c', 'echo ready'], { timeout: 20 }),
|
|
1223
|
+
25000,
|
|
1224
|
+
'preflight exec'
|
|
1225
|
+
)
|
|
1226
|
+
).stdout.trim();
|
|
1227
|
+
await withHostTimeout(m.delete(), 8000, 'preflight delete');
|
|
1228
|
+
if (out === 'ready') {
|
|
1229
|
+
log('preflight: runtime healthy (create+exec+delete ok)');
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
} catch (e) {
|
|
1233
|
+
log(
|
|
1234
|
+
`preflight attempt ${attempt + 1} wedged: ${String(e?.message || e).slice(0, 120)}`
|
|
1235
|
+
);
|
|
1236
|
+
try {
|
|
1237
|
+
if (m) await withHostTimeout(m.delete(), 5000, 'preflight cleanup');
|
|
1238
|
+
} catch {}
|
|
1239
|
+
if (attempt === 0) {
|
|
1240
|
+
// Clear the wedged cache ONCE, kill any stray boot-vm, let it re-extract.
|
|
1241
|
+
log(
|
|
1242
|
+
'preflight: clearing smolvm vms cache ONCE + killing stray smol-vmm'
|
|
1243
|
+
);
|
|
1244
|
+
for (const p of vmmProcs()) {
|
|
1245
|
+
try {
|
|
1246
|
+
process.kill(p.pid, 'SIGKILL');
|
|
1247
|
+
} catch {}
|
|
1248
|
+
}
|
|
1249
|
+
try {
|
|
1250
|
+
rmSync(VMS_DIR, { recursive: true, force: true });
|
|
1251
|
+
} catch {}
|
|
1252
|
+
await sleep(1500);
|
|
1253
|
+
} else {
|
|
1254
|
+
throw new Error(
|
|
1255
|
+
`preflight: runtime still wedged after cache clear: ${String(e?.message || e)}`
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
async function main() {
|
|
1263
|
+
log(`runtime dir: ${RUNTIME_DIR}`);
|
|
1264
|
+
if (!existsSync(join(RUNTIME_DIR, 'node_modules', 'smolmachines'))) {
|
|
1265
|
+
throw new Error(`smolmachines runtime not found at ${RUNTIME_DIR}.`);
|
|
1266
|
+
}
|
|
1267
|
+
try {
|
|
1268
|
+
const hv = execFileSync('sysctl', ['-n', 'kern.hv_support'], {
|
|
1269
|
+
encoding: 'utf8',
|
|
1270
|
+
}).trim();
|
|
1271
|
+
if (hv !== '1') throw new Error('kern.hv_support != 1');
|
|
1272
|
+
} catch (e) {
|
|
1273
|
+
throw new Error(
|
|
1274
|
+
`HVF not available (${e.message}); this is the HVF-only leg.`
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const { mod: cli, cleanup } = await loadCliHelpers();
|
|
1279
|
+
bundleCleanup = cleanup;
|
|
1280
|
+
log(
|
|
1281
|
+
'bundled OUR CLI helpers via esbuild (createMachineFromGolden, guestProvisionCommand, goldenBuildMachineName)'
|
|
1282
|
+
);
|
|
1283
|
+
|
|
1284
|
+
const sdk = await cli.loadSmolmachines(RUNTIME_DIR);
|
|
1285
|
+
log('loaded smolmachines via OUR loadSmolmachines()');
|
|
1286
|
+
|
|
1287
|
+
await preflightHygiene(sdk);
|
|
1288
|
+
|
|
1289
|
+
const vmmAtStart = vmmCount();
|
|
1290
|
+
// Snapshot the vms disk dirs present at start so HOST cleanliness measures only
|
|
1291
|
+
// what THIS run created/leaked - a prior crashed run's orphans (or a concurrent
|
|
1292
|
+
// legitimate run's dirs) must never be attributed to or swept by this run.
|
|
1293
|
+
const vmsDirsAtStart = new Set(vmsDirs());
|
|
1294
|
+
log(
|
|
1295
|
+
`vmm processes at start (post-preflight): ${vmmAtStart}; vms dirs at start: ${vmsDirsAtStart.size}`
|
|
1296
|
+
);
|
|
1297
|
+
|
|
1298
|
+
const killTimings = {};
|
|
1299
|
+
// Run each family SYNCHRONOUSLY (never an unbounded parallel fan-out).
|
|
1300
|
+
killTimings.c1 = (await runC1(cli, sdk)).killMs;
|
|
1301
|
+
killTimings.c2 = (await runC2(cli, sdk)).killMs;
|
|
1302
|
+
const c4d1 = await runC4D1(cli, sdk);
|
|
1303
|
+
killTimings.c4 = c4d1.killMs;
|
|
1304
|
+
await runC5(cli, sdk);
|
|
1305
|
+
const c6 = await runC6E1(cli, sdk);
|
|
1306
|
+
killTimings.e2 = (await runE2(cli, sdk)).killMs;
|
|
1307
|
+
|
|
1308
|
+
return { vmmAtStart, vmsDirsAtStart, killTimings, c6 };
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
let runResult;
|
|
1312
|
+
try {
|
|
1313
|
+
runResult = await main();
|
|
1314
|
+
} catch (e) {
|
|
1315
|
+
console.error('[chaos] FATAL:', e?.stack || String(e));
|
|
1316
|
+
exitCode = 1;
|
|
1317
|
+
} finally {
|
|
1318
|
+
try {
|
|
1319
|
+
await teardown();
|
|
1320
|
+
} catch (e) {
|
|
1321
|
+
console.error('[chaos] teardown error:', String(e?.message || e));
|
|
1322
|
+
}
|
|
1323
|
+
try {
|
|
1324
|
+
bundleCleanup();
|
|
1325
|
+
} catch {}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// =============================================================================
|
|
1329
|
+
// EXIT HYGIENE: confirm 0 leaked smol-vmm + 0 vms dirs THIS RUN created.
|
|
1330
|
+
// We measure relative to the start snapshot so a prior crashed run's orphans
|
|
1331
|
+
// (or a concurrent legitimate run) are never attributed to this run. A vms dir
|
|
1332
|
+
// created during this run that has NO live vmm is a genuine leak we own; we
|
|
1333
|
+
// defensively sweep those (the running set + start-set dirs are left untouched).
|
|
1334
|
+
// =============================================================================
|
|
1335
|
+
await sleep(500);
|
|
1336
|
+
const leakedVmm = vmmProcs();
|
|
1337
|
+
const startDirs = runResult?.vmsDirsAtStart ?? new Set();
|
|
1338
|
+
const liveHashes = new Set(leakedVmm.map((p) => p.hash));
|
|
1339
|
+
// Defensive sweep: dirs that appeared during this run, have no live vmm, and were
|
|
1340
|
+
// NOT present at start (so we KNOW they are ours and abandoned). Skip if a BREAK
|
|
1341
|
+
// knob intentionally left an orphan (so the teeth row stays red).
|
|
1342
|
+
if (!BREAK.C1_orphan) {
|
|
1343
|
+
for (const d of vmsDirs()) {
|
|
1344
|
+
if (startDirs.has(d) || liveHashes.has(d)) continue;
|
|
1345
|
+
try {
|
|
1346
|
+
rmSync(join(VMS_DIR, d), { recursive: true, force: true });
|
|
1347
|
+
} catch {}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
await sleep(200);
|
|
1351
|
+
const newLeftoverDirs = vmsDirs().filter((d) => !startDirs.has(d));
|
|
1352
|
+
const hostClean = leakedVmm.length === 0 && newLeftoverDirs.length === 0;
|
|
1353
|
+
record(
|
|
1354
|
+
'HOST',
|
|
1355
|
+
'HOST cleanliness at exit: 0 leaked smol-vmm + 0 leftover vms disk dirs (this run)',
|
|
1356
|
+
hostClean,
|
|
1357
|
+
`leaked-smol-vmm=${leakedVmm.length} new-leftover-vms-dirs=${newLeftoverDirs.length} (pre-existing-at-start=${startDirs.size})`
|
|
1358
|
+
);
|
|
1359
|
+
|
|
1360
|
+
// =============================================================================
|
|
1361
|
+
// PER-PROPERTY PASS/FAIL TABLE
|
|
1362
|
+
// =============================================================================
|
|
1363
|
+
console.log(
|
|
1364
|
+
'\n===================== LIVE-HVF CHAOS HARNESS (PR #2111) ====================='
|
|
1365
|
+
);
|
|
1366
|
+
if (runResult) {
|
|
1367
|
+
const kt = runResult.killTimings;
|
|
1368
|
+
console.log(
|
|
1369
|
+
`SIGKILL timings (ms): C1-fork=${kt.c1 ?? '-'} C2-builder=${kt.c2 ?? '-'} C4-fork=${kt.c4 ?? '-'} E2-fork=${kt.e2 ?? '-'}`
|
|
1370
|
+
);
|
|
1371
|
+
console.log(
|
|
1372
|
+
`EAGAIN/transient retries (exec+fork, total): ${stats.eagainRetries + stats.forkRetries} (exec=${stats.eagainRetries} fork=${stats.forkRetries}) -- retried, never surfaced as failure`
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
console.log(
|
|
1376
|
+
'----------------------------------------------------------------------------'
|
|
1377
|
+
);
|
|
1378
|
+
const ORDER = ['C1', 'C2', 'C4', 'C5', 'C6', 'D1', 'E1', 'E2', 'HOST'];
|
|
1379
|
+
let allPass = true;
|
|
1380
|
+
for (const id of ORDER) {
|
|
1381
|
+
const p = props[id];
|
|
1382
|
+
if (!p) {
|
|
1383
|
+
console.log(` MISS ${id} (property did not run)`);
|
|
1384
|
+
allPass = false;
|
|
1385
|
+
continue;
|
|
1386
|
+
}
|
|
1387
|
+
if (!p.pass) allPass = false;
|
|
1388
|
+
console.log(` ${p.pass ? 'PASS' : 'FAIL'} ${p.name}`);
|
|
1389
|
+
console.log(` ${p.detail}`);
|
|
1390
|
+
}
|
|
1391
|
+
console.log(
|
|
1392
|
+
'============================================================================'
|
|
1393
|
+
);
|
|
1394
|
+
|
|
1395
|
+
if (anyBreak) {
|
|
1396
|
+
const active = Object.entries(BREAK)
|
|
1397
|
+
.filter(([, v]) => v)
|
|
1398
|
+
.map(([k]) => k);
|
|
1399
|
+
if (allPass) {
|
|
1400
|
+
console.log(
|
|
1401
|
+
`TEETH CHECK FAILED: BREAK knob(s) [${active.join(', ')}] set but ALL properties still PASSED (toothless).`
|
|
1402
|
+
);
|
|
1403
|
+
exitCode = 2;
|
|
1404
|
+
} else {
|
|
1405
|
+
console.log(
|
|
1406
|
+
`TEETH CHECK OK: BREAK knob(s) [${active.join(', ')}] turned the expected property RED.`
|
|
1407
|
+
);
|
|
1408
|
+
exitCode = 0;
|
|
1409
|
+
}
|
|
1410
|
+
} else {
|
|
1411
|
+
if (exitCode === 0) exitCode = allPass ? 0 : 1;
|
|
1412
|
+
console.log(
|
|
1413
|
+
allPass
|
|
1414
|
+
? 'DONE-OK: all chaos properties PASS on live HVF.'
|
|
1415
|
+
: 'FAILED: a chaos property was violated.'
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
console.log(`leaked smol-vmm at exit: ${leakedVmm.length} (MUST be 0)`);
|
|
1420
|
+
process.exit(exitCode);
|