@runuai/host 0.9.78 → 0.9.79
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/images/standard/container/uai-init +76 -4
- package/lib/vnode-pressure.ts +103 -0
- package/package.json +1 -1
- package/src/cli.ts +138 -1
- package/src/index.ts +17 -0
- package/src/main.ts +6 -0
|
@@ -419,9 +419,25 @@ install_folder_deps() {
|
|
|
419
419
|
|
|
420
420
|
reject_workspace_runtime_shadow || exit 78
|
|
421
421
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
422
|
+
# ADR-122: dependency installs leave the task-start critical path. The loop
|
|
423
|
+
# below runs in a DETACHED background process so uai-init (and with it
|
|
424
|
+
# task-up) returns as soon as the cheap, provable work is done — the editor
|
|
425
|
+
# and the agents start immediately, warm or cold. Progress is observable in
|
|
426
|
+
# the workspace itself:
|
|
427
|
+
#
|
|
428
|
+
# $WORKSPACE/.uai/init/deps-status running | done | failed | authority-violation
|
|
429
|
+
# $WORKSPACE/.uai/init/deps.log the full install transcript
|
|
430
|
+
#
|
|
431
|
+
# The runtime-authority posture narrows deliberately: the synchronous
|
|
432
|
+
# workspace-shadow proofs above and below still exit 78 (task-up
|
|
433
|
+
# quarantines), but a shadow that APPEARS MID-INSTALL can no longer stop the
|
|
434
|
+
# task — agents are already running by then, and an agent could author the
|
|
435
|
+
# same shadow a second later anyway. The runner still refuses to run any
|
|
436
|
+
# further dependency command under a shadowed root and records
|
|
437
|
+
# `authority-violation`; the shared asdf volume stays read-only-proven
|
|
438
|
+
# regardless.
|
|
439
|
+
run_workspace_installs() {
|
|
440
|
+
local seen_runtime_projects="|" project_slug folder git_marker
|
|
425
441
|
while IFS= read -r project_slug || [ -n "$project_slug" ]; do
|
|
426
442
|
if [[ ! "$project_slug" =~ ^[a-z0-9-]{1,64}$ ]]; then
|
|
427
443
|
log "warning: unsafe runtime-project allowlist entry — skipping"
|
|
@@ -441,11 +457,67 @@ if [ -d "$WORKSPACE" ] && [ -f "$RUNTIME_PROJECTS" ] \
|
|
|
441
457
|
log "warning: allowlisted project $project_slug is not a safe Git worktree — skipping"
|
|
442
458
|
continue
|
|
443
459
|
fi
|
|
444
|
-
|
|
460
|
+
if ! reject_workspace_runtime_shadow; then
|
|
461
|
+
printf 'authority-violation\n' > "$deps_status_file"
|
|
462
|
+
return 78
|
|
463
|
+
fi
|
|
445
464
|
# Run in a subshell so a `cd` (or a failing command under the relaxed
|
|
446
465
|
# error mode) in one folder never leaks into the next.
|
|
447
466
|
( install_folder_deps "$folder" )
|
|
448
467
|
done < "$RUNTIME_PROJECTS"
|
|
468
|
+
return 0
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if [ -d "$WORKSPACE" ] && [ -f "$RUNTIME_PROJECTS" ] \
|
|
472
|
+
&& [ ! -L "$RUNTIME_PROJECTS" ]; then
|
|
473
|
+
deps_state_dir="$WORKSPACE/.uai/init"
|
|
474
|
+
deps_status_file="$deps_state_dir/deps-status"
|
|
475
|
+
deps_log_file="$deps_state_dir/deps.log"
|
|
476
|
+
deps_lock_dir="$deps_state_dir/deps.lock.d"
|
|
477
|
+
mkdir -p "$deps_state_dir"
|
|
478
|
+
|
|
479
|
+
# One runner at a time. uai-init is re-runnable (task-up retry, recovery),
|
|
480
|
+
# and each re-run SHOULD refresh dependencies — but never concurrently.
|
|
481
|
+
# mkdir is the portable atomic lock; a pid that no longer answers marks a
|
|
482
|
+
# crashed runner whose lock is stale and may be stolen.
|
|
483
|
+
deps_spawn=1
|
|
484
|
+
if mkdir "$deps_lock_dir" 2>/dev/null; then
|
|
485
|
+
# Hold the lock as ourselves until the runner's pid replaces it, so a
|
|
486
|
+
# concurrent uai-init never reads an empty pid file as a stale lock.
|
|
487
|
+
printf '%s\n' "$$" > "$deps_lock_dir/pid"
|
|
488
|
+
else
|
|
489
|
+
deps_holder=$(cat "$deps_lock_dir/pid" 2>/dev/null || true)
|
|
490
|
+
if [ -n "$deps_holder" ] && kill -0 "$deps_holder" 2>/dev/null; then
|
|
491
|
+
log "dependency install already running (pid $deps_holder) — leaving it be"
|
|
492
|
+
deps_spawn=0
|
|
493
|
+
else
|
|
494
|
+
rm -rf "$deps_lock_dir"
|
|
495
|
+
if mkdir "$deps_lock_dir" 2>/dev/null; then
|
|
496
|
+
printf '%s\n' "$$" > "$deps_lock_dir/pid"
|
|
497
|
+
else
|
|
498
|
+
log "warning: could not acquire the dependency install lock — skipping installs"
|
|
499
|
+
deps_spawn=0
|
|
500
|
+
fi
|
|
501
|
+
fi
|
|
502
|
+
fi
|
|
503
|
+
if [ "$deps_spawn" -eq 1 ]; then
|
|
504
|
+
printf 'running\n' > "$deps_status_file"
|
|
505
|
+
log "installing workspace dependencies in the background (status: $deps_status_file, log: $deps_log_file)"
|
|
506
|
+
(
|
|
507
|
+
trap 'rm -rf "$deps_lock_dir"' EXIT
|
|
508
|
+
deps_rc=0
|
|
509
|
+
run_workspace_installs || deps_rc=$?
|
|
510
|
+
if [ "$deps_rc" -eq 0 ]; then
|
|
511
|
+
printf 'done\n' > "$deps_status_file"
|
|
512
|
+
log "workspace dependency install complete"
|
|
513
|
+
elif [ "$deps_rc" -ne 78 ]; then
|
|
514
|
+
printf 'failed\n' > "$deps_status_file"
|
|
515
|
+
log "workspace dependency install failed (exit $deps_rc)"
|
|
516
|
+
fi
|
|
517
|
+
) >>"$deps_log_file" 2>&1 </dev/null &
|
|
518
|
+
printf '%s\n' "$!" > "$deps_lock_dir/pid"
|
|
519
|
+
disown
|
|
520
|
+
fi
|
|
449
521
|
elif [ ! -d "$WORKSPACE" ]; then
|
|
450
522
|
log "workspace $WORKSPACE missing — scratchpad/no-project task, skipping installs"
|
|
451
523
|
else
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-118 follow-up: the macOS vnode-table pressure sensor.
|
|
3
|
+
*
|
|
4
|
+
* Apple's Virtualization.framework virtiofs pins one host vnode per distinct
|
|
5
|
+
* guest-touched file for the life of the task VM (~12k per task after
|
|
6
|
+
* ADR-118 moved the pnpm store off virtiofs; ~130k+ before it). The table
|
|
7
|
+
* ceiling (`kern.maxvnodes`) is a boot-time default the host cannot raise
|
|
8
|
+
* without root, and AT the ceiling unrelated host software starts failing
|
|
9
|
+
* with ENFILE — the suspected 2026-08-24 kernel-panic precursor.
|
|
10
|
+
*
|
|
11
|
+
* Three surfaces ride this module:
|
|
12
|
+
* - `readVnodePressure()` — the sensor (cached; null off-macOS).
|
|
13
|
+
* - `vnodeAdmissionProblem()` — task-up guard: refuse to start ANOTHER
|
|
14
|
+
* apple-container VM above the threshold instead of letting the kernel
|
|
15
|
+
* hit the wall. Existing tasks keep running.
|
|
16
|
+
* - `logVnodePressure()` — periodic observability, warn-level near the
|
|
17
|
+
* ceiling so host logs explain refusals before they happen.
|
|
18
|
+
*
|
|
19
|
+
* Raising the ceiling needs root once: `sudo uai-host tune` (see cli.ts)
|
|
20
|
+
* installs a LaunchDaemon that reasserts the sysctls at every boot —
|
|
21
|
+
* sysctl writes alone are ephemeral.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFile } from "node:child_process";
|
|
25
|
+
import { promisify } from "node:util";
|
|
26
|
+
|
|
27
|
+
const exec = promisify(execFile);
|
|
28
|
+
|
|
29
|
+
/** Refuse NEW apple task VMs above this fraction of the vnode ceiling. */
|
|
30
|
+
const ADMISSION_THRESHOLD = 0.9;
|
|
31
|
+
/** Warn-log above this fraction. */
|
|
32
|
+
const WARN_THRESHOLD = 0.8;
|
|
33
|
+
const CACHE_MS = 15_000;
|
|
34
|
+
|
|
35
|
+
export interface VnodePressure {
|
|
36
|
+
current: number;
|
|
37
|
+
max: number;
|
|
38
|
+
ratio: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let cached: { at: number; value: VnodePressure | null } | null = null;
|
|
42
|
+
|
|
43
|
+
export async function readVnodePressure(): Promise<VnodePressure | null> {
|
|
44
|
+
if (process.platform !== "darwin") return null;
|
|
45
|
+
if (cached && Date.now() - cached.at < CACHE_MS) return cached.value;
|
|
46
|
+
let value: VnodePressure | null = null;
|
|
47
|
+
try {
|
|
48
|
+
const { stdout } = await exec(
|
|
49
|
+
"/usr/sbin/sysctl",
|
|
50
|
+
["-n", "kern.num_vnodes", "kern.maxvnodes"],
|
|
51
|
+
{ timeout: 5_000 },
|
|
52
|
+
);
|
|
53
|
+
const parts = stdout.trim().split(/\s+/).map(Number);
|
|
54
|
+
const current = parts[0] ?? Number.NaN;
|
|
55
|
+
const max = parts[1] ?? Number.NaN;
|
|
56
|
+
if (Number.isFinite(current) && Number.isFinite(max) && max > 0) {
|
|
57
|
+
value = { current, max, ratio: current / max };
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Sensor failure is never a task failure; admission simply has no data.
|
|
61
|
+
}
|
|
62
|
+
cached = { at: Date.now(), value };
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Non-null = refuse to start another apple-container task VM right now. */
|
|
67
|
+
export async function vnodeAdmissionProblem(): Promise<string | null> {
|
|
68
|
+
const pressure = await readVnodePressure();
|
|
69
|
+
if (!pressure || pressure.ratio < ADMISSION_THRESHOLD) return null;
|
|
70
|
+
return (
|
|
71
|
+
`the macOS vnode table is at ${Math.round(pressure.ratio * 100)}% ` +
|
|
72
|
+
`(${pressure.current}/${pressure.max}); starting another task VM risks ` +
|
|
73
|
+
"system-wide file-table exhaustion. Close a running task, or raise the " +
|
|
74
|
+
"ceiling once with: sudo uai-host tune"
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let lastLoggedBand: "ok" | "warn" | "critical" = "ok";
|
|
79
|
+
|
|
80
|
+
/** Log on band TRANSITIONS only, so a host sitting near the ceiling does not
|
|
81
|
+
* spam its own log every interval. */
|
|
82
|
+
export async function logVnodePressure(): Promise<void> {
|
|
83
|
+
const pressure = await readVnodePressure();
|
|
84
|
+
if (!pressure) return;
|
|
85
|
+
const band =
|
|
86
|
+
pressure.ratio >= ADMISSION_THRESHOLD
|
|
87
|
+
? "critical"
|
|
88
|
+
: pressure.ratio >= WARN_THRESHOLD
|
|
89
|
+
? "warn"
|
|
90
|
+
: "ok";
|
|
91
|
+
if (band === lastLoggedBand) return;
|
|
92
|
+
lastLoggedBand = band;
|
|
93
|
+
const detail = `${pressure.current}/${pressure.max} (${Math.round(pressure.ratio * 100)}%)`;
|
|
94
|
+
if (band === "critical") {
|
|
95
|
+
console.warn(
|
|
96
|
+
`[vnode] table at ${detail} — new apple task VMs will be refused; run: sudo uai-host tune`,
|
|
97
|
+
);
|
|
98
|
+
} else if (band === "warn") {
|
|
99
|
+
console.warn(`[vnode] table filling: ${detail}`);
|
|
100
|
+
} else {
|
|
101
|
+
console.log(`[vnode] table pressure back to normal: ${detail}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -22,7 +22,7 @@ import "./load-env";
|
|
|
22
22
|
|
|
23
23
|
import { spawn, spawnSync } from "node:child_process";
|
|
24
24
|
import { createHash, randomBytes } from "node:crypto";
|
|
25
|
-
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
26
|
import { homedir, hostname } from "node:os";
|
|
27
27
|
import { dirname, join, resolve } from "node:path";
|
|
28
28
|
|
|
@@ -137,6 +137,8 @@ async function main(): Promise<void> {
|
|
|
137
137
|
return cmdLogs(follow);
|
|
138
138
|
case "runtime":
|
|
139
139
|
return cmdRuntime(rest);
|
|
140
|
+
case "tune":
|
|
141
|
+
return cmdTune();
|
|
140
142
|
case "update":
|
|
141
143
|
return cmdUpdate(rest);
|
|
142
144
|
case "rollback":
|
|
@@ -498,6 +500,139 @@ async function boundedResponseJson(
|
|
|
498
500
|
|
|
499
501
|
// --- runtime ---------------------------------------------------------------
|
|
500
502
|
|
|
503
|
+
// --- tune (ADR-118 follow-up: vnode/file-table ceilings) ---------------------
|
|
504
|
+
|
|
505
|
+
const TUNE_DAEMON_LABEL = "com.runuai.tune";
|
|
506
|
+
const TUNE_DAEMON_PLIST = `/Library/LaunchDaemons/${TUNE_DAEMON_LABEL}.plist`;
|
|
507
|
+
/** Floors, not targets — an operator-raised ceiling is never lowered. Each
|
|
508
|
+
* apple-container task VM pins host vnodes for its life (~12k after ADR-118,
|
|
509
|
+
* far more during dependency churn), and the boot defaults sit low enough
|
|
510
|
+
* that two heavy tasks have filled the table live (2026-08-25). ~2M vnodes
|
|
511
|
+
* is roughly half a GB of kernel memory IF fully populated. */
|
|
512
|
+
const TUNE_MAXVNODES_FLOOR = 2_097_152;
|
|
513
|
+
const TUNE_MAXFILES_FLOOR = 2_097_152;
|
|
514
|
+
const TUNE_MAXFILESPERPROC_FLOOR = 1_048_576;
|
|
515
|
+
|
|
516
|
+
function readSysctlNumber(name: string): number | null {
|
|
517
|
+
const res = spawnSync("/usr/sbin/sysctl", ["-n", name], {
|
|
518
|
+
encoding: "utf8",
|
|
519
|
+
});
|
|
520
|
+
const value = Number((res.stdout ?? "").trim());
|
|
521
|
+
return res.status === 0 && Number.isFinite(value) ? value : null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function tunePlistContent(settings: Array<[string, number]>): string {
|
|
525
|
+
const args = ["/usr/sbin/sysctl", ...settings.map(([k, v]) => `${k}=${v}`)]
|
|
526
|
+
.map((a) => ` <string>${a}</string>`)
|
|
527
|
+
.join("\n");
|
|
528
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
529
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
530
|
+
<plist version="1.0">
|
|
531
|
+
<dict>
|
|
532
|
+
<key>Label</key>
|
|
533
|
+
<string>${TUNE_DAEMON_LABEL}</string>
|
|
534
|
+
<key>ProgramArguments</key>
|
|
535
|
+
<array>
|
|
536
|
+
${args}
|
|
537
|
+
</array>
|
|
538
|
+
<key>RunAtLoad</key>
|
|
539
|
+
<true/>
|
|
540
|
+
</dict>
|
|
541
|
+
</plist>
|
|
542
|
+
`;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function cmdTune(): Promise<void> {
|
|
546
|
+
if (process.platform !== "darwin") {
|
|
547
|
+
console.error(red("tune is macOS-only (apple-container task VMs)"));
|
|
548
|
+
process.exitCode = 1;
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const current = {
|
|
552
|
+
maxvnodes: readSysctlNumber("kern.maxvnodes"),
|
|
553
|
+
maxfiles: readSysctlNumber("kern.maxfiles"),
|
|
554
|
+
maxfilesperproc: readSysctlNumber("kern.maxfilesperproc"),
|
|
555
|
+
};
|
|
556
|
+
if (
|
|
557
|
+
current.maxvnodes === null ||
|
|
558
|
+
current.maxfiles === null ||
|
|
559
|
+
current.maxfilesperproc === null
|
|
560
|
+
) {
|
|
561
|
+
console.error(red("could not read the current kernel limits via sysctl"));
|
|
562
|
+
process.exitCode = 1;
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
// Order matters at apply time: maxfilesperproc must stay <= maxfiles.
|
|
566
|
+
const settings: Array<[string, number]> = [
|
|
567
|
+
["kern.maxfiles", Math.max(current.maxfiles, TUNE_MAXFILES_FLOOR)],
|
|
568
|
+
[
|
|
569
|
+
"kern.maxfilesperproc",
|
|
570
|
+
Math.max(current.maxfilesperproc, TUNE_MAXFILESPERPROC_FLOOR),
|
|
571
|
+
],
|
|
572
|
+
["kern.maxvnodes", Math.max(current.maxvnodes, TUNE_MAXVNODES_FLOOR)],
|
|
573
|
+
];
|
|
574
|
+
const desiredPlist = tunePlistContent(settings);
|
|
575
|
+
const plistCurrent = existsSync(TUNE_DAEMON_PLIST)
|
|
576
|
+
? readFileSync(TUNE_DAEMON_PLIST, "utf8")
|
|
577
|
+
: null;
|
|
578
|
+
const changes = settings.filter(
|
|
579
|
+
([key, value]) => value !== current[key.slice("kern.".length) as keyof typeof current],
|
|
580
|
+
);
|
|
581
|
+
if (changes.length === 0 && plistCurrent === desiredPlist) {
|
|
582
|
+
const summary = settings.map(([k, v]) => `${k}=${v}`).join(" ");
|
|
583
|
+
console.log(`already tuned (${summary}); boot persistence in place`);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (typeof process.getuid !== "function" || process.getuid() !== 0) {
|
|
588
|
+
console.log(`${bold("uai-host tune")} will (needs sudo):`);
|
|
589
|
+
for (const [key, value] of changes) {
|
|
590
|
+
console.log(
|
|
591
|
+
` raise ${key}: ${current[key.slice("kern.".length) as keyof typeof current]} -> ${value}`,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
console.log(
|
|
595
|
+
` install ${TUNE_DAEMON_PLIST} so the limits survive reboots`,
|
|
596
|
+
);
|
|
597
|
+
console.log(`\nrun: ${cyan("sudo uai-host tune")}`);
|
|
598
|
+
process.exitCode = 1;
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
for (const [key, value] of settings) {
|
|
603
|
+
const res = spawnSync("/usr/sbin/sysctl", [`${key}=${value}`], {
|
|
604
|
+
encoding: "utf8",
|
|
605
|
+
});
|
|
606
|
+
if (res.status !== 0) {
|
|
607
|
+
console.error(
|
|
608
|
+
red(`sysctl ${key}=${value} failed: ${(res.stderr ?? "").trim()}`),
|
|
609
|
+
);
|
|
610
|
+
process.exitCode = 1;
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
writeFileSync(TUNE_DAEMON_PLIST, desiredPlist, { mode: 0o644 });
|
|
615
|
+
spawnSync("/usr/sbin/chown", ["root:wheel", TUNE_DAEMON_PLIST]);
|
|
616
|
+
// Re-bootstrap so launchd owns the daemon under its current definition.
|
|
617
|
+
spawnSync("/bin/launchctl", ["bootout", `system/${TUNE_DAEMON_LABEL}`]);
|
|
618
|
+
const bootstrap = spawnSync(
|
|
619
|
+
"/bin/launchctl",
|
|
620
|
+
["bootstrap", "system", TUNE_DAEMON_PLIST],
|
|
621
|
+
{ encoding: "utf8" },
|
|
622
|
+
);
|
|
623
|
+
if (bootstrap.status !== 0) {
|
|
624
|
+
console.error(
|
|
625
|
+
red(
|
|
626
|
+
`limits applied, but launchctl bootstrap failed (${(bootstrap.stderr ?? "").trim()}) — they will not survive a reboot`,
|
|
627
|
+
),
|
|
628
|
+
);
|
|
629
|
+
process.exitCode = 1;
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const summary = settings.map(([k, v]) => `${k}=${v}`).join(" ");
|
|
633
|
+
console.log(`tuned: ${summary} (persisted via ${TUNE_DAEMON_PLIST})`);
|
|
634
|
+
}
|
|
635
|
+
|
|
501
636
|
async function cmdRuntime(rest: string[]): Promise<void> {
|
|
502
637
|
if (rest[0] === "begin-install") {
|
|
503
638
|
return cmdBeginRuntimeInstall(rest.slice(1));
|
|
@@ -1882,6 +2017,8 @@ function printHelp(): void {
|
|
|
1882
2017
|
status connection, service info, active tasks (same as the UI)
|
|
1883
2018
|
logs [--follow] tail the service log
|
|
1884
2019
|
runtime recheck probe Docker again and refresh cloud capabilities
|
|
2020
|
+
tune (macOS, sudo) raise kernel vnode/file-table ceilings for
|
|
2021
|
+
apple-container task VMs and persist them across boots
|
|
1885
2022
|
setup --cloud <wss-url> --enroll <token>
|
|
1886
2023
|
claim this machine via an enrollment token from the web
|
|
1887
2024
|
app (mints + writes the host credential); on an
|
package/src/index.ts
CHANGED
|
@@ -70,10 +70,12 @@ import {
|
|
|
70
70
|
containerRuntimeTeardownProblem,
|
|
71
71
|
ensureContainerRuntimeForTask,
|
|
72
72
|
initializeContainerRuntime,
|
|
73
|
+
pinnedContainerRuntimeProvider,
|
|
73
74
|
reprobeContainerRuntimeMachineIdentity,
|
|
74
75
|
suspendContainerRuntime,
|
|
75
76
|
waitForContainerRuntimeOperational,
|
|
76
77
|
} from "../lib/container-runtime";
|
|
78
|
+
import { vnodeAdmissionProblem } from "../lib/vnode-pressure";
|
|
77
79
|
import {
|
|
78
80
|
provisionTaskEnvironment,
|
|
79
81
|
reconstructPersistedTaskEnvironment,
|
|
@@ -264,6 +266,21 @@ export const hostCommands: HostCommands = {
|
|
|
264
266
|
if (readyAdmissionFailure) return readyAdmissionFailure;
|
|
265
267
|
const readyRuntimeFailure = runtimeUnavailable();
|
|
266
268
|
if (readyRuntimeFailure) return readyRuntimeFailure;
|
|
269
|
+
// ADR-118 follow-up: each apple-container task is its own VM, and each
|
|
270
|
+
// VM pins host vnodes for its life. Refuse to start ANOTHER one when the
|
|
271
|
+
// table is nearly full — a clear, retryable refusal now beats ENFILE
|
|
272
|
+
// storms (and the suspected panic path) for the whole machine later.
|
|
273
|
+
if (pinnedContainerRuntimeProvider() === "apple-container") {
|
|
274
|
+
const vnodeProblem = await vnodeAdmissionProblem();
|
|
275
|
+
if (vnodeProblem) {
|
|
276
|
+
return {
|
|
277
|
+
ok: false,
|
|
278
|
+
code: HostErrorCode.HostUnavailable,
|
|
279
|
+
message: vnodeProblem,
|
|
280
|
+
retryable: true,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
267
284
|
const orchestrator = getOrchestrator();
|
|
268
285
|
return orchestrator.runTaskLifecycle(input.task.id, async () => {
|
|
269
286
|
// A direct duplicate teardown may still be draining operations that
|
package/src/main.ts
CHANGED
|
@@ -110,6 +110,7 @@ import {
|
|
|
110
110
|
publishActivationPhase,
|
|
111
111
|
} from "../lib/container-runtime";
|
|
112
112
|
import { reconstructPersistedTaskEnvironment } from "../lib/task-environment";
|
|
113
|
+
import { logVnodePressure } from "../lib/vnode-pressure";
|
|
113
114
|
import { resolveAppleTunnelRoute } from "./apple-tunnel-route";
|
|
114
115
|
import {
|
|
115
116
|
configureBundledContainerRuntime,
|
|
@@ -516,6 +517,11 @@ void connect();
|
|
|
516
517
|
// Local browser UI (ADR-028) — same single process, alongside the WSS client.
|
|
517
518
|
// Best-effort: a UI bind failure must not take the host service down.
|
|
518
519
|
void startLocalUi();
|
|
520
|
+
// ADR-118 follow-up: vnode-pressure observability (macOS/apple-container
|
|
521
|
+
// hosts). Logs only on band transitions; the sensor caches its sysctl read.
|
|
522
|
+
const vnodePressureTimer = setInterval(() => void logVnodePressure(), 60_000);
|
|
523
|
+
vnodePressureTimer.unref?.();
|
|
524
|
+
void logVnodePressure();
|
|
519
525
|
|
|
520
526
|
async function startLocalUi(): Promise<void> {
|
|
521
527
|
try {
|