@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,738 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// golden-scale-test.mjs
|
|
3
|
+
//
|
|
4
|
+
// SCALE TEST: find the empirical "agents-per-host" number for the forkable-golden
|
|
5
|
+
// model on THIS Mac (Apple Silicon, HVF). Cold-boot ONE forkable golden, provision
|
|
6
|
+
// a warm base (toolchain + small project + npm install), then ramp concurrent forks
|
|
7
|
+
// N = 1,2,4,8,16,24,32,48,64 off that single golden and measure at each N:
|
|
8
|
+
// - fork wall time + per-fork latency distribution (p50/p95)
|
|
9
|
+
// - concurrent light-job completion time
|
|
10
|
+
// - RAM: sum RSS of `smol-vmm _boot-vm` procs (ps) + host free/used (vm_stat)
|
|
11
|
+
// BEFORE and AFTER the fork wave -> COW marginal-RSS-per-fork
|
|
12
|
+
// - EAGAIN (os error 35) retries triggered (instrumented helper)
|
|
13
|
+
// - native crash / connection-closed / instability (caught + recorded)
|
|
14
|
+
// Tear down the N forks before the next stage (delete()); keep the golden.
|
|
15
|
+
//
|
|
16
|
+
// Fork pattern reused from /tmp/smol-rt-132/fork-proof.mjs:
|
|
17
|
+
// Machine.create({ forkable:true, network:true }) golden -> golden.fork(name)
|
|
18
|
+
// The golden is exec-DEAD after forking, so we ONLY fork it (never exec post-fork);
|
|
19
|
+
// all exec jobs run IN the forks. EAGAIN/os-error-35 retry+backoff on every
|
|
20
|
+
// exec AND fork.
|
|
21
|
+
//
|
|
22
|
+
// HARD HYGIENE: exclusive HVF. Synchronous. ALWAYS delete() in finally. No lingering
|
|
23
|
+
// background processes. NEVER pkill golden-*. No git. Confirm 0 leaked smol-vmm at exit.
|
|
24
|
+
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import { execSync } from 'node:child_process';
|
|
28
|
+
|
|
29
|
+
const rtDir = '/tmp/smol-rt-132';
|
|
30
|
+
const require = createRequire(join(rtDir, 'package.json'));
|
|
31
|
+
const { Machine } = require('smolmachines');
|
|
32
|
+
|
|
33
|
+
// ----- config ---------------------------------------------------------------
|
|
34
|
+
const STAGES = [1, 2, 4, 8, 16, 24, 32, 48, 64];
|
|
35
|
+
const PROJECT = '/root/project';
|
|
36
|
+
const FORK_CPUS = 2;
|
|
37
|
+
const FORK_MEM_MB = 2048;
|
|
38
|
+
const MIN_FREE_HEADROOM_GB = 8; // stop ramping if host free RAM < this after a wave
|
|
39
|
+
const FORK_STAGGER_MS = 60; // pace fork launches to respect control-plane EAGAIN
|
|
40
|
+
const PAGE_BYTES = 16384; // Apple Silicon vm_stat page size (verified below)
|
|
41
|
+
// CPU-saturating per-fork job: a fixed-size busy loop. Each fork runs the SAME
|
|
42
|
+
// amount of CPU work, so as N exceeds available cores the per-job wall time grows
|
|
43
|
+
// (jobs queue behind cores). The point where per-job time departs from the N<=cores
|
|
44
|
+
// baseline is the empirical CPU ceiling -- the real "active agents per host".
|
|
45
|
+
const CPU_JOB = `node -e "const t=Date.now();let x=0;for(let i=0;i<8e8;i++){x+=Math.sqrt(i)%7;}console.log(Date.now()-t,x|0)"`;
|
|
46
|
+
|
|
47
|
+
const t = () => Date.now();
|
|
48
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
49
|
+
const log = (...a) => console.log('[scale]', ...a);
|
|
50
|
+
const GB = (bytes) => (bytes / 1024 / 1024 / 1024).toFixed(2);
|
|
51
|
+
const MB = (bytes) => (bytes / 1024 / 1024).toFixed(0);
|
|
52
|
+
|
|
53
|
+
// ----- EAGAIN-aware retry helpers (instrumented) ----------------------------
|
|
54
|
+
let EAGAIN_RETRIES = 0; // global; snapshotted per stage
|
|
55
|
+
const isEagain = (e) =>
|
|
56
|
+
/os error 35|temporarily unavailable|EAGAIN|resource temporarily/i.test(
|
|
57
|
+
String(e?.stack || e)
|
|
58
|
+
);
|
|
59
|
+
const isInstability = (e) =>
|
|
60
|
+
/connection closed|connection reset|ECONNRESET|EPIPE|broken pipe|hangup|panic|signal|abort/i.test(
|
|
61
|
+
String(e?.stack || e)
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
async function withRetry(label, fn, { tries = 8, base = 150 } = {}) {
|
|
65
|
+
let lastErr;
|
|
66
|
+
for (let attempt = 0; attempt < tries; attempt++) {
|
|
67
|
+
try {
|
|
68
|
+
return await fn();
|
|
69
|
+
} catch (e) {
|
|
70
|
+
lastErr = e;
|
|
71
|
+
if (isEagain(e)) {
|
|
72
|
+
EAGAIN_RETRIES++;
|
|
73
|
+
await sleep(base * (attempt + 1));
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
throw e; // non-EAGAIN (incl. instability) bubbles up to be classified
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
throw lastErr;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function sh(m, label, cmd, timeout = 240) {
|
|
83
|
+
return withRetry(label, async () => {
|
|
84
|
+
const r = await m.exec(['/bin/sh', '-c', cmd], { timeout });
|
|
85
|
+
return {
|
|
86
|
+
code: r.exitCode,
|
|
87
|
+
out: (r.stdout || '').trim(),
|
|
88
|
+
err: (r.stderr || '').trim(),
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ----- host measurement -----------------------------------------------------
|
|
94
|
+
function sysctlStr(key) {
|
|
95
|
+
try {
|
|
96
|
+
return execSync(`sysctl -n ${key}`, { encoding: 'utf8' }).trim();
|
|
97
|
+
} catch {
|
|
98
|
+
return '?';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Free RAM (bytes) that's actually reclaimable: free + inactive + speculative.
|
|
103
|
+
// Apple's vm_stat counts inactive/speculative as available under pressure.
|
|
104
|
+
function hostMem() {
|
|
105
|
+
let pageBytes = PAGE_BYTES;
|
|
106
|
+
let free = 0,
|
|
107
|
+
inactive = 0,
|
|
108
|
+
speculative = 0,
|
|
109
|
+
wired = 0,
|
|
110
|
+
active = 0,
|
|
111
|
+
compressed = 0;
|
|
112
|
+
try {
|
|
113
|
+
const out = execSync('vm_stat', { encoding: 'utf8' });
|
|
114
|
+
const m = out.match(/page size of (\d+) bytes/);
|
|
115
|
+
if (m) pageBytes = Number(m[1]);
|
|
116
|
+
const grab = (re) => {
|
|
117
|
+
const mm = out.match(re);
|
|
118
|
+
return mm ? Number(mm[1]) : 0;
|
|
119
|
+
};
|
|
120
|
+
free = grab(/Pages free:\s+(\d+)/);
|
|
121
|
+
active = grab(/Pages active:\s+(\d+)/);
|
|
122
|
+
inactive = grab(/Pages inactive:\s+(\d+)/);
|
|
123
|
+
speculative = grab(/Pages speculative:\s+(\d+)/);
|
|
124
|
+
wired = grab(/Pages wired down:\s+(\d+)/);
|
|
125
|
+
compressed = grab(/Pages occupied by compressor:\s+(\d+)/);
|
|
126
|
+
} catch {}
|
|
127
|
+
const avail = (free + inactive + speculative) * pageBytes;
|
|
128
|
+
const used = (active + wired + compressed) * pageBytes;
|
|
129
|
+
return {
|
|
130
|
+
availBytes: avail,
|
|
131
|
+
usedBytes: used,
|
|
132
|
+
freePagesBytes: free * pageBytes,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Sum RSS (bytes) of all `smol-vmm _boot-vm` processes, and count them.
|
|
137
|
+
function vmmRss() {
|
|
138
|
+
try {
|
|
139
|
+
// ps rss is in KB. Match the boot-vm worker that backs each live VM.
|
|
140
|
+
const out = execSync(
|
|
141
|
+
"ps -axo rss,command | grep 'smol-vmm' | grep '_boot-vm' | grep -v grep",
|
|
142
|
+
{ encoding: 'utf8' }
|
|
143
|
+
);
|
|
144
|
+
const lines = out.split('\n').filter((l) => l.trim());
|
|
145
|
+
let sum = 0;
|
|
146
|
+
for (const l of lines) {
|
|
147
|
+
const kb = Number(l.trim().split(/\s+/)[0]);
|
|
148
|
+
if (Number.isFinite(kb)) sum += kb * 1024;
|
|
149
|
+
}
|
|
150
|
+
return { count: lines.length, rssBytes: sum };
|
|
151
|
+
} catch {
|
|
152
|
+
return { count: 0, rssBytes: 0 };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function countLeakedVmm() {
|
|
157
|
+
try {
|
|
158
|
+
const out = execSync(
|
|
159
|
+
"ps -axo pid,command | grep 'smol-vmm' | grep -v grep",
|
|
160
|
+
{
|
|
161
|
+
encoding: 'utf8',
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
return out.split('\n').filter((l) => l.trim()).length;
|
|
165
|
+
} catch {
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function percentile(arr, p) {
|
|
171
|
+
if (!arr.length) return 0;
|
|
172
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
173
|
+
const idx = Math.min(s.length - 1, Math.floor((p / 100) * s.length));
|
|
174
|
+
return s[idx];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ----- one-time wedged-runtime recovery -------------------------------------
|
|
178
|
+
async function maybeUnwedge() {
|
|
179
|
+
// If the runtime is wedged, a first exec returns connection-closed. Probe once
|
|
180
|
+
// with a throwaway non-forkable micro-VM; if it fails, kill stray boot-vms and
|
|
181
|
+
// clear the vms cache ONCE, then let it re-extract.
|
|
182
|
+
log('probe: booting a throwaway micro-VM to check runtime health ...');
|
|
183
|
+
let probe;
|
|
184
|
+
try {
|
|
185
|
+
probe = await Machine.create({
|
|
186
|
+
name: `probe-${process.pid}`,
|
|
187
|
+
resources: { cpus: 1, memoryMb: 512 },
|
|
188
|
+
});
|
|
189
|
+
const r = await probe.exec(['/bin/sh', '-c', 'echo healthy'], {
|
|
190
|
+
timeout: 60,
|
|
191
|
+
});
|
|
192
|
+
log(`probe exec -> "${(r.stdout || '').trim()}" (exit ${r.exitCode})`);
|
|
193
|
+
await probe.delete().catch(() => {});
|
|
194
|
+
return true;
|
|
195
|
+
} catch (e) {
|
|
196
|
+
log(
|
|
197
|
+
`probe FAILED (${String(e).slice(0, 120)}). Attempting one-time unwedge ...`
|
|
198
|
+
);
|
|
199
|
+
try {
|
|
200
|
+
if (probe) await probe.delete().catch(() => {});
|
|
201
|
+
} catch {}
|
|
202
|
+
try {
|
|
203
|
+
execSync(
|
|
204
|
+
"ps -axo pid,command | grep smol-vmm | grep _boot-vm | grep -v grep | awk '{print $1}' | xargs -r kill -9",
|
|
205
|
+
{
|
|
206
|
+
encoding: 'utf8',
|
|
207
|
+
}
|
|
208
|
+
);
|
|
209
|
+
} catch {}
|
|
210
|
+
try {
|
|
211
|
+
execSync('rm -rf ~/Library/Caches/smolvm/vms/*', {
|
|
212
|
+
encoding: 'utf8',
|
|
213
|
+
shell: '/bin/zsh',
|
|
214
|
+
});
|
|
215
|
+
} catch {}
|
|
216
|
+
log(
|
|
217
|
+
'unwedge done (killed stray boot-vms + cleared vms cache). Re-probing ...'
|
|
218
|
+
);
|
|
219
|
+
await sleep(2000);
|
|
220
|
+
try {
|
|
221
|
+
probe = await Machine.create({
|
|
222
|
+
name: `probe2-${process.pid}`,
|
|
223
|
+
resources: { cpus: 1, memoryMb: 512 },
|
|
224
|
+
});
|
|
225
|
+
const r = await probe.exec(['/bin/sh', '-c', 'echo healthy'], {
|
|
226
|
+
timeout: 90,
|
|
227
|
+
});
|
|
228
|
+
log(`re-probe exec -> "${(r.stdout || '').trim()}" (exit ${r.exitCode})`);
|
|
229
|
+
await probe.delete().catch(() => {});
|
|
230
|
+
return true;
|
|
231
|
+
} catch (e2) {
|
|
232
|
+
log(`re-probe STILL failing: ${String(e2).slice(0, 160)}`);
|
|
233
|
+
try {
|
|
234
|
+
if (probe) await probe.delete().catch(() => {});
|
|
235
|
+
} catch {}
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ----------------------------------------------------------------------------
|
|
242
|
+
const created = new Set(); // track everything for finally cleanup
|
|
243
|
+
let golden;
|
|
244
|
+
const rows = [];
|
|
245
|
+
|
|
246
|
+
const HOST = {
|
|
247
|
+
ncpu: sysctlStr('hw.ncpu'),
|
|
248
|
+
physicalcpu: sysctlStr('hw.physicalcpu'),
|
|
249
|
+
memsize: Number(sysctlStr('hw.memsize')) || 0,
|
|
250
|
+
hv_support: sysctlStr('kern.hv_support'),
|
|
251
|
+
brand: sysctlStr('machdep.cpu.brand_string'),
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
async function main() {
|
|
255
|
+
console.log('\n==================== HOST FACTS ====================');
|
|
256
|
+
console.log(`cpu.brand_string : ${HOST.brand}`);
|
|
257
|
+
console.log(
|
|
258
|
+
`hw.ncpu : ${HOST.ncpu} (physical ${HOST.physicalcpu})`
|
|
259
|
+
);
|
|
260
|
+
console.log(
|
|
261
|
+
`hw.memsize : ${HOST.memsize} bytes (${GB(HOST.memsize)} GB)`
|
|
262
|
+
);
|
|
263
|
+
console.log(`kern.hv_support : ${HOST.hv_support}`);
|
|
264
|
+
console.log(`smolmachines : 1.3.2 (runtime ${rtDir})`);
|
|
265
|
+
console.log(
|
|
266
|
+
`fork resources : cpus=${FORK_CPUS} memoryMb=${FORK_MEM_MB} per fork`
|
|
267
|
+
);
|
|
268
|
+
console.log('====================================================\n');
|
|
269
|
+
|
|
270
|
+
const healthy = await maybeUnwedge();
|
|
271
|
+
if (!healthy) {
|
|
272
|
+
console.error(
|
|
273
|
+
'[scale] runtime did not become healthy after one unwedge; aborting.'
|
|
274
|
+
);
|
|
275
|
+
process.exitCode = 1;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 1) Cold-boot ONE forkable golden + provision warm base ------------------
|
|
280
|
+
log('cold-booting ONE forkable golden (network:true) ...');
|
|
281
|
+
const tBoot = t();
|
|
282
|
+
golden = await withRetry('golden create', () =>
|
|
283
|
+
Machine.create({
|
|
284
|
+
name: `golden-${process.pid}`,
|
|
285
|
+
resources: { cpus: FORK_CPUS, memoryMb: FORK_MEM_MB, network: true },
|
|
286
|
+
forkable: true,
|
|
287
|
+
})
|
|
288
|
+
);
|
|
289
|
+
created.add(golden);
|
|
290
|
+
const goldenBootMs = t() - tBoot;
|
|
291
|
+
log(`golden booted forkable in ${goldenBootMs}ms`);
|
|
292
|
+
|
|
293
|
+
log('provisioning warm base (toolchain + small project + npm install) ...');
|
|
294
|
+
const tProv = t();
|
|
295
|
+
await sh(
|
|
296
|
+
golden,
|
|
297
|
+
'apk',
|
|
298
|
+
'apk add --no-cache ca-certificates nodejs npm git >/dev/null 2>&1 && update-ca-certificates >/dev/null 2>&1 || true',
|
|
299
|
+
600
|
|
300
|
+
);
|
|
301
|
+
await sh(
|
|
302
|
+
golden,
|
|
303
|
+
'project',
|
|
304
|
+
[
|
|
305
|
+
`mkdir -p ${PROJECT} && cd ${PROJECT}`,
|
|
306
|
+
'git init -q && git config user.email a@b.c && git config user.name base',
|
|
307
|
+
'printf "# scale base\\n" > README.md',
|
|
308
|
+
'npm init -y >/dev/null 2>&1',
|
|
309
|
+
'npm install --no-fund --no-audit is-odd >/dev/null 2>&1 || true',
|
|
310
|
+
'git add -A && git commit -q -m "pristine base"',
|
|
311
|
+
].join(' && '),
|
|
312
|
+
600
|
|
313
|
+
);
|
|
314
|
+
const baseCommit = (
|
|
315
|
+
await sh(
|
|
316
|
+
golden,
|
|
317
|
+
'base commit',
|
|
318
|
+
`cd ${PROJECT} && git rev-parse --short HEAD`
|
|
319
|
+
)
|
|
320
|
+
).out;
|
|
321
|
+
const baseNode = (await sh(golden, 'base node', 'node --version')).out;
|
|
322
|
+
const provMs = t() - tProv;
|
|
323
|
+
log(
|
|
324
|
+
`golden provisioned in ${provMs}ms (commit=${baseCommit}, node=${baseNode})`
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
// Baseline RAM with golden up, no forks.
|
|
328
|
+
const goldenVmm = vmmRss();
|
|
329
|
+
const goldenMem = hostMem();
|
|
330
|
+
log(
|
|
331
|
+
`baseline: vmm procs=${goldenVmm.count} sumRSS=${MB(goldenVmm.rssBytes)}MB host avail=${GB(
|
|
332
|
+
goldenMem.availBytes
|
|
333
|
+
)}GB`
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// NOTE: golden is exec-DEAD after the first fork. We never exec it again.
|
|
337
|
+
let goldenForked = false;
|
|
338
|
+
|
|
339
|
+
// 2) Ramp the stages -----------------------------------------------------
|
|
340
|
+
for (const N of STAGES) {
|
|
341
|
+
log(`\n---------- STAGE N=${N} ----------`);
|
|
342
|
+
const eagainBefore = EAGAIN_RETRIES;
|
|
343
|
+
const memBefore = hostMem();
|
|
344
|
+
const vmmBefore = vmmRss();
|
|
345
|
+
let status = 'ok';
|
|
346
|
+
let note = '';
|
|
347
|
+
const forks = [];
|
|
348
|
+
const forkLatencies = [];
|
|
349
|
+
let forkWall = 0;
|
|
350
|
+
let workMs = 0;
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
// ---- fork wave (paced, EAGAIN-retried, concurrent-ish) ----
|
|
354
|
+
const tFork = t();
|
|
355
|
+
const forkPromises = [];
|
|
356
|
+
for (let i = 1; i <= N; i++) {
|
|
357
|
+
// small stagger so we don't slam the control plane (EAGAIN source)
|
|
358
|
+
await sleep(FORK_STAGGER_MS);
|
|
359
|
+
const name = `f-${N}-${i}-${process.pid}`;
|
|
360
|
+
forkPromises.push(
|
|
361
|
+
(async () => {
|
|
362
|
+
const tf = t();
|
|
363
|
+
const child = await withRetry(`fork ${name}`, () =>
|
|
364
|
+
golden.fork(name)
|
|
365
|
+
);
|
|
366
|
+
const lat = t() - tf;
|
|
367
|
+
goldenForked = true;
|
|
368
|
+
created.add(child);
|
|
369
|
+
forks.push(child);
|
|
370
|
+
forkLatencies.push(lat);
|
|
371
|
+
return child;
|
|
372
|
+
})()
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
await Promise.all(forkPromises);
|
|
376
|
+
forkWall = t() - tFork;
|
|
377
|
+
log(
|
|
378
|
+
`forked ${forks.length}/${N} in ${forkWall}ms (p50=${percentile(
|
|
379
|
+
forkLatencies,
|
|
380
|
+
50
|
|
381
|
+
)}ms p95=${percentile(forkLatencies, 95)}ms)`
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
// ---- RAM after fork wave (settle a beat for COW pages to fault in) ----
|
|
385
|
+
await sleep(800);
|
|
386
|
+
const vmmAfter = vmmRss();
|
|
387
|
+
const memAfter = hostMem();
|
|
388
|
+
|
|
389
|
+
// ---- job in each fork, concurrently ----
|
|
390
|
+
// Each fork: light I/O (write+commit) + a CPU-saturating busy loop. The CPU
|
|
391
|
+
// loop is what exposes the core ceiling: per-job in-VM ms is captured so we
|
|
392
|
+
// can see queueing once N > cores.
|
|
393
|
+
const tWork = t();
|
|
394
|
+
const perJobMs = [];
|
|
395
|
+
const workResults = await Promise.allSettled(
|
|
396
|
+
forks.map((child, idx) =>
|
|
397
|
+
(async () => {
|
|
398
|
+
await sh(
|
|
399
|
+
child,
|
|
400
|
+
`io${idx}`,
|
|
401
|
+
[
|
|
402
|
+
`cd ${PROJECT}`,
|
|
403
|
+
`printf "fork ${idx} active\\n" > fork-${idx}.txt`,
|
|
404
|
+
`git add -A && git commit -q -m "fork ${idx}"`,
|
|
405
|
+
].join(' && '),
|
|
406
|
+
180
|
|
407
|
+
);
|
|
408
|
+
const r = await sh(child, `cpu${idx}`, CPU_JOB, 300);
|
|
409
|
+
const ms = Number((r.out || '').split(/\s+/)[0]);
|
|
410
|
+
if (Number.isFinite(ms)) perJobMs.push(ms);
|
|
411
|
+
})()
|
|
412
|
+
)
|
|
413
|
+
);
|
|
414
|
+
workMs = t() - tWork;
|
|
415
|
+
const jobP50 = percentile(perJobMs, 50);
|
|
416
|
+
const jobP95 = percentile(perJobMs, 95);
|
|
417
|
+
const workOk = workResults.filter((r) => r.status === 'fulfilled').length;
|
|
418
|
+
const workFail = workResults.length - workOk;
|
|
419
|
+
if (workFail > 0) {
|
|
420
|
+
status = 'degraded';
|
|
421
|
+
note += `${workFail}/${forks.length} jobs failed; `;
|
|
422
|
+
const firstErr = workResults.find((r) => r.status === 'rejected');
|
|
423
|
+
if (firstErr && isInstability(firstErr.reason)) {
|
|
424
|
+
status = 'crash';
|
|
425
|
+
note += `instability: ${String(firstErr.reason).slice(0, 80)}; `;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
log(`concurrent work: ${workOk}/${forks.length} ok in ${workMs}ms`);
|
|
429
|
+
|
|
430
|
+
// ---- COW / marginal RSS accounting ----
|
|
431
|
+
const deltaVmmRss = vmmAfter.rssBytes - vmmBefore.rssBytes;
|
|
432
|
+
const newVmms = vmmAfter.count - vmmBefore.count;
|
|
433
|
+
const marginalPerFork = newVmms > 0 ? deltaVmmRss / newVmms : 0;
|
|
434
|
+
const hostUsedDelta = memBefore.availBytes - memAfter.availBytes; // host RAM consumed by the wave
|
|
435
|
+
const marginalHostPerFork =
|
|
436
|
+
forks.length > 0 ? hostUsedDelta / forks.length : 0;
|
|
437
|
+
|
|
438
|
+
const eagainThisStage = EAGAIN_RETRIES - eagainBefore;
|
|
439
|
+
|
|
440
|
+
rows.push({
|
|
441
|
+
N,
|
|
442
|
+
forked: forks.length,
|
|
443
|
+
forkWall,
|
|
444
|
+
p50: percentile(forkLatencies, 50),
|
|
445
|
+
p95: percentile(forkLatencies, 95),
|
|
446
|
+
marginalRssMB: Number(MB(marginalPerFork)),
|
|
447
|
+
marginalHostMB: Number(MB(marginalHostPerFork)),
|
|
448
|
+
totalVmmRssGB: Number(GB(vmmAfter.rssBytes)),
|
|
449
|
+
vmmCount: vmmAfter.count,
|
|
450
|
+
hostAvailGB: Number(GB(memAfter.availBytes)),
|
|
451
|
+
eagain: eagainThisStage,
|
|
452
|
+
workMs,
|
|
453
|
+
jobP50,
|
|
454
|
+
jobP95,
|
|
455
|
+
workOk,
|
|
456
|
+
status,
|
|
457
|
+
note: note.trim(),
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
log(
|
|
461
|
+
`CPU job in-VM ms: p50=${jobP50} p95=${jobP95} (baseline ~single-fork time => >cores means queueing)`
|
|
462
|
+
);
|
|
463
|
+
log(
|
|
464
|
+
`RAM: vmm sumRSS ${MB(vmmBefore.rssBytes)}->${MB(vmmAfter.rssBytes)}MB (+${MB(
|
|
465
|
+
deltaVmmRss
|
|
466
|
+
)}MB for ${newVmms} new vmm) -> marginal ${MB(marginalPerFork)}MB/fork (proc) / ${MB(
|
|
467
|
+
marginalHostPerFork
|
|
468
|
+
)}MB/fork (host)`
|
|
469
|
+
);
|
|
470
|
+
log(
|
|
471
|
+
`host avail ${GB(memBefore.availBytes)}->${GB(memAfter.availBytes)}GB EAGAIN(stage)=${eagainThisStage} status=${status}`
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
// ---- tear down THIS stage's forks (keep golden) ----
|
|
475
|
+
log(`tearing down ${forks.length} forks ...`);
|
|
476
|
+
for (const child of forks) {
|
|
477
|
+
try {
|
|
478
|
+
await withRetry('delete', () => child.delete(), {
|
|
479
|
+
tries: 5,
|
|
480
|
+
base: 120,
|
|
481
|
+
});
|
|
482
|
+
} catch {
|
|
483
|
+
try {
|
|
484
|
+
await child.stop();
|
|
485
|
+
} catch {}
|
|
486
|
+
}
|
|
487
|
+
created.delete(child);
|
|
488
|
+
}
|
|
489
|
+
await sleep(1500); // let teardown settle before next stage measures baseline
|
|
490
|
+
|
|
491
|
+
// ---- stop conditions ----
|
|
492
|
+
if (status === 'crash') {
|
|
493
|
+
log(`STOP: native/connection instability at N=${N}.`);
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
if (memAfter.availBytes / 1024 / 1024 / 1024 < MIN_FREE_HEADROOM_GB) {
|
|
497
|
+
log(
|
|
498
|
+
`STOP: host free RAM (${GB(memAfter.availBytes)}GB) dropped below ${MIN_FREE_HEADROOM_GB}GB headroom.`
|
|
499
|
+
);
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
} catch (e) {
|
|
503
|
+
// A stage-level throw = fork wave or measurement failed hard.
|
|
504
|
+
const crash = isInstability(e);
|
|
505
|
+
status = crash ? 'crash' : 'degraded';
|
|
506
|
+
note += String(e?.stack || e).slice(0, 140);
|
|
507
|
+
const memAfter = hostMem();
|
|
508
|
+
rows.push({
|
|
509
|
+
N,
|
|
510
|
+
forked: forks.length,
|
|
511
|
+
forkWall,
|
|
512
|
+
p50: percentile(forkLatencies, 50),
|
|
513
|
+
p95: percentile(forkLatencies, 95),
|
|
514
|
+
marginalRssMB: NaN,
|
|
515
|
+
marginalHostMB: NaN,
|
|
516
|
+
totalVmmRssGB: Number(GB(vmmRss().rssBytes)),
|
|
517
|
+
vmmCount: vmmRss().count,
|
|
518
|
+
hostAvailGB: Number(GB(memAfter.availBytes)),
|
|
519
|
+
eagain: EAGAIN_RETRIES - eagainBefore,
|
|
520
|
+
workMs,
|
|
521
|
+
jobP50: 0,
|
|
522
|
+
jobP95: 0,
|
|
523
|
+
workOk: 0,
|
|
524
|
+
status,
|
|
525
|
+
note: note.trim(),
|
|
526
|
+
});
|
|
527
|
+
log(`STAGE N=${N} FAILED (${status}): ${note.slice(0, 140)}`);
|
|
528
|
+
// best-effort teardown of whatever forked
|
|
529
|
+
for (const child of forks) {
|
|
530
|
+
try {
|
|
531
|
+
await child.delete();
|
|
532
|
+
} catch {
|
|
533
|
+
try {
|
|
534
|
+
await child.stop();
|
|
535
|
+
} catch {}
|
|
536
|
+
}
|
|
537
|
+
created.delete(child);
|
|
538
|
+
}
|
|
539
|
+
break; // stop ramping past the failure ceiling
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
printReport(goldenBootMs, provMs, baseNode);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function printReport(goldenBootMs, provMs, baseNode) {
|
|
547
|
+
const pad = (s, n) => String(s).padStart(n);
|
|
548
|
+
console.log('\n\n==================== SCALE TABLE ====================');
|
|
549
|
+
console.log(
|
|
550
|
+
[
|
|
551
|
+
pad('N', 3),
|
|
552
|
+
pad('forkWall', 9),
|
|
553
|
+
pad('p50', 6),
|
|
554
|
+
pad('p95', 6),
|
|
555
|
+
pad('mRSS/fk', 8),
|
|
556
|
+
pad('totVmmGB', 9),
|
|
557
|
+
pad('hostFreeGB', 11),
|
|
558
|
+
pad('EAGAIN', 7),
|
|
559
|
+
pad('cpuJobp50', 10),
|
|
560
|
+
pad('cpuJobp95', 10),
|
|
561
|
+
pad('allMs', 7),
|
|
562
|
+
pad('status', 9),
|
|
563
|
+
].join(' | ')
|
|
564
|
+
);
|
|
565
|
+
console.log('-'.repeat(110));
|
|
566
|
+
for (const r of rows) {
|
|
567
|
+
console.log(
|
|
568
|
+
[
|
|
569
|
+
pad(r.N, 3),
|
|
570
|
+
pad(`${r.forkWall}ms`, 9),
|
|
571
|
+
pad(`${r.p50}`, 6),
|
|
572
|
+
pad(`${r.p95}`, 6),
|
|
573
|
+
pad(`${r.marginalRssMB}MB`, 8),
|
|
574
|
+
pad(`${r.totalVmmRssGB}`, 9),
|
|
575
|
+
pad(`${r.hostAvailGB}`, 11),
|
|
576
|
+
pad(`${r.eagain}`, 7),
|
|
577
|
+
pad(`${r.jobP50}ms`, 10),
|
|
578
|
+
pad(`${r.jobP95}ms`, 10),
|
|
579
|
+
pad(`${r.workMs}`, 7),
|
|
580
|
+
pad(`${r.status}`, 9),
|
|
581
|
+
].join(' | ')
|
|
582
|
+
);
|
|
583
|
+
if (r.note) console.log(` note(N=${r.N}): ${r.note}`);
|
|
584
|
+
}
|
|
585
|
+
console.log('====================================================');
|
|
586
|
+
|
|
587
|
+
// ---- SUMMARY: binding constraint analysis ----
|
|
588
|
+
const okRows = rows.filter((r) => r.status === 'ok');
|
|
589
|
+
const maxStable = okRows.length ? Math.max(...okRows.map((r) => r.N)) : 0;
|
|
590
|
+
const crashRow = rows.find((r) => r.status === 'crash');
|
|
591
|
+
const degradedRow = rows.find((r) => r.status === 'degraded');
|
|
592
|
+
|
|
593
|
+
// COW marginal: average of valid marginal-RSS figures across stages.
|
|
594
|
+
const validMarg = rows
|
|
595
|
+
.map((r) => r.marginalRssMB)
|
|
596
|
+
.filter((x) => Number.isFinite(x) && x > 0);
|
|
597
|
+
const avgMarginalMB = validMarg.length
|
|
598
|
+
? validMarg.reduce((a, b) => a + b, 0) / validMarg.length
|
|
599
|
+
: NaN;
|
|
600
|
+
const validHostMarg = rows
|
|
601
|
+
.map((r) => r.marginalHostMB)
|
|
602
|
+
.filter((x) => Number.isFinite(x) && x > 0);
|
|
603
|
+
const avgHostMarginalMB = validHostMarg.length
|
|
604
|
+
? validHostMarg.reduce((a, b) => a + b, 0) / validHostMarg.length
|
|
605
|
+
: NaN;
|
|
606
|
+
|
|
607
|
+
const cores = Number(HOST.ncpu) || 0;
|
|
608
|
+
const totalRamGB = HOST.memsize / 1024 / 1024 / 1024;
|
|
609
|
+
// RAM-bound cap = (totalRAM - reserve(golden+host headroom)) / marginal-per-fork
|
|
610
|
+
const reserveGB = 16; // golden + macOS + headroom
|
|
611
|
+
const ramCap =
|
|
612
|
+
Number.isFinite(avgHostMarginalMB) && avgHostMarginalMB > 0
|
|
613
|
+
? Math.floor(((totalRamGB - reserveGB) * 1024) / avgHostMarginalMB)
|
|
614
|
+
: NaN;
|
|
615
|
+
// CPU-bound cap for CPU-active forks ~= cores / FORK_CPUS (oversubscribe up to ~cores)
|
|
616
|
+
const cpuCapActive = Math.floor(cores / FORK_CPUS);
|
|
617
|
+
const cpuCapBurst = cores;
|
|
618
|
+
|
|
619
|
+
// Empirical CPU ceiling: per-job CPU time vs the N=1 baseline. When N exceeds
|
|
620
|
+
// available cores, jobs queue and per-job wall time inflates. Find the largest N
|
|
621
|
+
// where per-job p50 stayed within ~1.5x of baseline (the "no meaningful queueing"
|
|
622
|
+
// throughput frontier).
|
|
623
|
+
const baseJob = rows.length ? rows[0].jobP50 || 0 : 0;
|
|
624
|
+
let cpuFrontier = 0;
|
|
625
|
+
let cpuKneeN = 0;
|
|
626
|
+
for (const r of rows) {
|
|
627
|
+
if (!baseJob) break;
|
|
628
|
+
if ((r.jobP50 || 0) <= baseJob * 1.5) cpuFrontier = r.N;
|
|
629
|
+
else if (!cpuKneeN) cpuKneeN = r.N;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Determine binding constraint.
|
|
633
|
+
let binding = 'undetermined';
|
|
634
|
+
if (crashRow)
|
|
635
|
+
binding = `native/control-plane stability (broke at N=${crashRow.N})`;
|
|
636
|
+
else if (degradedRow && degradedRow.eagain > 0)
|
|
637
|
+
binding = 'control-plane EAGAIN saturation';
|
|
638
|
+
else if (cpuKneeN && cpuKneeN <= maxStable)
|
|
639
|
+
binding = `CPU cores (per-job time knees up at N=${cpuKneeN}; throughput frontier N~${cpuFrontier} vs ${cores} cores)`;
|
|
640
|
+
else if (Number.isFinite(ramCap) && ramCap <= maxStable)
|
|
641
|
+
binding = 'RAM (memory headroom / marginal-RSS-per-fork)';
|
|
642
|
+
else
|
|
643
|
+
binding =
|
|
644
|
+
'none reached (control plane + CPU + RAM all had headroom through max N tested)';
|
|
645
|
+
|
|
646
|
+
console.log('\n==================== SUMMARY ====================');
|
|
647
|
+
console.log(
|
|
648
|
+
`host : ${HOST.brand}, ${cores} cores, ${GB(HOST.memsize)}GB RAM, HVF=${HOST.hv_support}`
|
|
649
|
+
);
|
|
650
|
+
console.log(
|
|
651
|
+
`golden cold-boot : ${goldenBootMs}ms; provision warm base: ${provMs}ms; node in fork: ${baseNode}`
|
|
652
|
+
);
|
|
653
|
+
console.log(
|
|
654
|
+
`max STABLE concurrent N : ${maxStable}${crashRow ? ` (broke/degraded at N=${crashRow.N})` : ''}`
|
|
655
|
+
);
|
|
656
|
+
console.log(
|
|
657
|
+
`COW marginal RAM / fork : ~${Number.isFinite(avgMarginalMB) ? avgMarginalMB.toFixed(0) : '?'}MB (vmm proc RSS) / ~${
|
|
658
|
+
Number.isFinite(avgHostMarginalMB) ? avgHostMarginalMB.toFixed(0) : '?'
|
|
659
|
+
}MB (host) vs ${FORK_MEM_MB}MB full VM => COW sharing ${
|
|
660
|
+
Number.isFinite(avgHostMarginalMB) && avgHostMarginalMB < FORK_MEM_MB
|
|
661
|
+
? `${(100 - (avgHostMarginalMB / FORK_MEM_MB) * 100).toFixed(0)}% saved`
|
|
662
|
+
: 'NOT observed'
|
|
663
|
+
}`
|
|
664
|
+
);
|
|
665
|
+
console.log(`EAGAIN total (all stages) : ${EAGAIN_RETRIES}`);
|
|
666
|
+
console.log(
|
|
667
|
+
`CPU per-job p50 (baseline ${baseJob}ms): ${rows.map((r) => `N${r.N}=${r.jobP50}ms`).join(' ')}`
|
|
668
|
+
);
|
|
669
|
+
console.log(
|
|
670
|
+
`CPU throughput frontier : N~${cpuFrontier} within 1.5x baseline (knee at N=${cpuKneeN || 'none<=maxN'})`
|
|
671
|
+
);
|
|
672
|
+
console.log(`BINDING CONSTRAINT : ${binding}`);
|
|
673
|
+
console.log(
|
|
674
|
+
` - CPU-bound cap (active) : ~${cpuCapActive} forks (cores/${FORK_CPUS}); burst up to ~${cpuCapBurst}`
|
|
675
|
+
);
|
|
676
|
+
console.log(
|
|
677
|
+
` - RAM-bound cap : ~${Number.isFinite(ramCap) ? ramCap : '?'} forks ((${totalRamGB.toFixed(
|
|
678
|
+
0
|
|
679
|
+
)}GB - ${reserveGB}GB reserve) / ${Number.isFinite(avgHostMarginalMB) ? avgHostMarginalMB.toFixed(0) : '?'}MB)`
|
|
680
|
+
);
|
|
681
|
+
// Recommended cap for CPU-ACTIVE forks = empirical CPU throughput frontier
|
|
682
|
+
// (where per-job time stays ~baseline), bounded by stability + RAM.
|
|
683
|
+
const candidates = [
|
|
684
|
+
cpuFrontier || cpuCapBurst,
|
|
685
|
+
maxStable,
|
|
686
|
+
Number.isFinite(ramCap) ? ramCap : Infinity,
|
|
687
|
+
].filter((x) => Number.isFinite(x) && x > 0);
|
|
688
|
+
const recommended = candidates.length ? Math.min(...candidates) : maxStable;
|
|
689
|
+
console.log(
|
|
690
|
+
`RECOMMENDED concurrent-ACTIVE-fork cap (THIS Mac): ~${recommended}`
|
|
691
|
+
);
|
|
692
|
+
console.log(
|
|
693
|
+
` scaling rule: CPU-bound active work -> cap ~= cores (${cores}); long-lived idle forks -> cap = (RAM_headroom_GB*1024)/marginalMB (~${
|
|
694
|
+
Number.isFinite(ramCap) ? ramCap : '?'
|
|
695
|
+
}).`
|
|
696
|
+
);
|
|
697
|
+
console.log(
|
|
698
|
+
`smol 1.3.2 stability : ${crashRow ? `BROKE at N=${crashRow.N} (${crashRow.note})` : `STABLE through N=${maxStable}`}`
|
|
699
|
+
);
|
|
700
|
+
console.log('================================================');
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ----------------------------------------------------------------------------
|
|
704
|
+
main()
|
|
705
|
+
.catch((e) => {
|
|
706
|
+
console.error('[scale] FATAL:', e?.stack || String(e));
|
|
707
|
+
process.exitCode = 1;
|
|
708
|
+
})
|
|
709
|
+
.finally(async () => {
|
|
710
|
+
log('\nfinal cleanup: deleting all tracked machines (forks + golden) ...');
|
|
711
|
+
for (const m of [...created]) {
|
|
712
|
+
try {
|
|
713
|
+
await m.delete();
|
|
714
|
+
} catch {
|
|
715
|
+
try {
|
|
716
|
+
await m.stop();
|
|
717
|
+
} catch {}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// give the runtime a moment to reap boot-vm workers
|
|
721
|
+
await sleep(2500);
|
|
722
|
+
const leaked = countLeakedVmm();
|
|
723
|
+
console.log(
|
|
724
|
+
`[scale] leaked smol-vmm processes at exit: ${leaked} (expect 0)`
|
|
725
|
+
);
|
|
726
|
+
if (leaked > 0) {
|
|
727
|
+
console.log(
|
|
728
|
+
'[scale] WARNING: leaked vmm detected; listing (not killing golden-* per hygiene):'
|
|
729
|
+
);
|
|
730
|
+
try {
|
|
731
|
+
console.log(
|
|
732
|
+
execSync('ps -axo pid,command | grep smol-vmm | grep -v grep', {
|
|
733
|
+
encoding: 'utf8',
|
|
734
|
+
})
|
|
735
|
+
);
|
|
736
|
+
} catch {}
|
|
737
|
+
}
|
|
738
|
+
});
|