@volter/world-machine 2.0.0

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/src/gc.ts ADDED
@@ -0,0 +1,129 @@
1
+ // Snapshot retention: a snapshot is kept while any existing branch's machine ledger (its whole view,
2
+ // the parent's history included) names it; everything else in the store goes. Each snapshot directory
3
+ // is self-contained (its memory and disk are reflink copies, never references to another directory), so
4
+ // deleting one never breaks another.
5
+ //
6
+ // The store is shared by every World whose machine runs on this host, so each `serve` registers its
7
+ // World in the store (`roots.json`: the World's branch directory, `<app>/.volter/worlds`, or the
8
+ // service root itself when it is not laid out that way). A collection reads every registered World's
9
+ // branches (a registration whose directory is gone is dropped), and deletes:
10
+ // - snapshot directories no ledger names, unless a checkpoint is still writing them (`.pending`) or a
11
+ // slot's machine was loaded from them;
12
+ // - warm bases no kept snapshot was built on (on the container backend, none a branch's head was not taken on: its
13
+ // snapshots never read their base), unless it is this configuration's base, a slot's machine was loaded from it,
14
+ // or it is still being built;
15
+ // - the container backend's filled trees (tree-<hash>) but this configuration's and those a kept base was made from;
16
+ // then trims the store's filesystem so the space goes back to the host. A ledger that cannot be read
17
+ // stops the collection: nothing is deleted on a partial view.
18
+ import { existsSync, readdirSync, statSync } from 'node:fs';
19
+ import { basename, join } from 'node:path';
20
+ import { wholeLog } from '@volter/world-core';
21
+ import type { MachineConfig } from './config.ts';
22
+ import { type HostSession, q } from './host.ts';
23
+ import { headSnapshot, LEDGER } from './ledger.ts';
24
+ import { containerHashes } from './container.ts';
25
+ import { baseHash } from './machine.ts';
26
+ import { thinned } from './thin.ts';
27
+
28
+ /** What `serve` registers for a service root: the World's branch directory when the root is a branch's
29
+ * service data directory (`<app>/.volter/worlds/<branch>/data/<service>`), else the root itself. */
30
+ export function registration(root: string): string {
31
+ const m = /^(.*\/\.volter\/worlds)\/[^/]+\/data\/[^/]+\/?$/.exec(root);
32
+ return m ? m[1]! : root;
33
+ }
34
+
35
+ /** Every machine service root a registration covers. */
36
+ function machineRoots(registered: string): string[] {
37
+ if (basename(registered) === 'worlds') {
38
+ return readdirSync(registered)
39
+ .filter((b) => !b.startsWith('.'))
40
+ .map((b) => join(registered, b, 'data', LEDGER))
41
+ .filter((r) => existsSync(r) && statSync(r).isDirectory());
42
+ }
43
+ return [registered];
44
+ }
45
+
46
+ export async function register(session: HostSession, root: string): Promise<void> {
47
+ await session.call(`wm_register ${q(registration(root))}`);
48
+ }
49
+
50
+ export type GcReport = {
51
+ roots: string[]; dropped: string[]; branches: number; live: number;
52
+ deletedSnapshots: string[]; deletedBases: string[]; deletedTrees: string[]; expired: string[]; kept: { pending: string[]; loaded: string[] };
53
+ usedBefore: number; usedAfter: number; freedBytes: number; trimmed: string;
54
+ };
55
+
56
+ export async function collect(config: MachineConfig, session: HostSession, own?: string): Promise<GcReport> {
57
+ const store = await session.call<{ roots: string[]; snapshots: string[]; bases: string[]; trees: string[]; baseTrees: Record<string, string>; pending: string[]; loaded: string[] }>('wm_store_list');
58
+ const roots = store.roots.filter((r) => existsSync(r));
59
+ const dropped = store.roots.filter((r) => !roots.includes(r));
60
+ const liveDirs = new Set<string>();
61
+ const liveBases = new Set<string>();
62
+ const heads = new Set<string>();
63
+ const ownDirs = new Set<string>();
64
+ const othersDirs = new Set<string>();
65
+ const keepDirs = new Set<string>();
66
+ let branches = 0;
67
+ for (const registered of roots) {
68
+ const mine = own !== undefined && registered === registration(own);
69
+ for (const r of machineRoots(registered)) {
70
+ branches++;
71
+ let log;
72
+ try { log = wholeLog(LEDGER, r); } catch (error) {
73
+ throw new Error(`world-machine gc: cannot read the machine ledger at ${r} (${(error as Error).message}); nothing was deleted`);
74
+ }
75
+ for (const e of log) {
76
+ if (e.op !== 'set' || e.subject.type !== 'snapshot') continue;
77
+ const f = e.fields as { dir?: string; base?: string };
78
+ if (f.dir) { liveDirs.add(basename(f.dir)); (mine ? ownDirs : othersDirs).add(basename(f.dir)); }
79
+ if (f.dir && (e.fields as { keep?: boolean }).keep) keepDirs.add(basename(f.dir));
80
+ // a Firecracker snapshot is kept with the base it was built on; a container snapshot never reads its base (a
81
+ // restore reads the snapshot's own tree and dump, and a machine with no head the configured base), so there only
82
+ // the configured base, heads' bases (below) and a running machine's stay
83
+ if (f.base && config.backend !== 'container') liveBases.add(f.base);
84
+ }
85
+ const head = headSnapshot(r);
86
+ if (head) heads.add(basename(head.dir));
87
+ // on the container backend the base a branch's head was taken on stays (another World on this store, configured
88
+ // otherwise, starts a branch with no head from it); a base only older heads named goes once each moves on
89
+ if (head?.base && config.backend === 'container') liveBases.add(head.base);
90
+ }
91
+ }
92
+ // Retention (config `retain`): every branch's head stays (a World resumes at it), and the newest `retain` others (with
93
+ // `thin`, also the newest of each hour for a day and of each day for a month, and every snapshot taken with `keep`); the
94
+ // rest are let go though a ledger names them, and recorded so that a restore of one says it expired
95
+ // Only the calling World's own snapshots are counted: `retain` is its configuration, and another World on the store
96
+ // keeps its own, including a snapshot both name (a World's ledgers copied from another). They are ranked by when the host took them (the time in the snapshot's id), never by the World's
97
+ // clock, which a World can freeze (every mark would tie).
98
+ const expired: string[] = [];
99
+ if (config.retain && own) {
100
+ const present = new Set(store.snapshots);
101
+ const taken = (d: string): number => { const m = /^snap-([0-9a-z]+)-/.exec(d); return m ? parseInt(m[1]!, 36) : 0; };
102
+ const others = [...ownDirs].filter((d) => !heads.has(d) && !othersDirs.has(d) && present.has(d)).sort((a, b) => taken(b) - taken(a) || b.localeCompare(a));
103
+ // thinned with age (thin.ts), or the newest `retain` only
104
+ const stay = config.thin ? thinned(others.map((d) => ({ dir: d, takenMs: taken(d), keep: keepDirs.has(d) })), Date.now(), { retain: config.retain }) : new Set(others.slice(0, config.retain));
105
+ for (const d of others) if (!stay.has(d)) { liveDirs.delete(d); expired.push(d); }
106
+ }
107
+ const loaded = new Set(store.loaded.map((p) => basename(p.replace(/\/+$/, ''))));
108
+ const pending = new Set(store.pending);
109
+ const hashes = config.backend === 'container' ? await containerHashes(config, session) : { base: await baseHash(config, session), tree: '' };
110
+ const configured = `base-${hashes.base}`;
111
+ const deletedSnapshots = store.snapshots.filter((s) => !liveDirs.has(s) && !pending.has(s) && !loaded.has(s));
112
+ const deletedBases = store.bases.filter((b) => !liveBases.has(b) && b !== configured && !loaded.has(b) && !b.endsWith('.building'));
113
+ // the container backend's filled trees (tree-<hash>): only this configuration's is kept (a base's own root.img shares
114
+ // its blocks, so a tree whose image or fill moved on holds only what nothing else does)
115
+ // (bases being built included: each names its tree from its first step; a tree being saved is tree-<hash>.part-<slot>)
116
+ const keptTrees = new Set(store.bases.filter((b) => !deletedBases.includes(b)).map((b) => store.baseTrees[b]).filter(Boolean));
117
+ const treeOf = (t: string): string => t.replace(/\.part-[a-z0-9-]+$/, '');
118
+ const deletedTrees = store.trees.filter((t) => treeOf(t) !== `tree-${hashes.tree}` && !keptTrees.has(treeOf(t)));
119
+ const paths = [...deletedSnapshots.map((s) => `snapshots/${s}`), ...deletedBases, ...deletedTrees];
120
+ const expiring = expired.filter((d) => deletedSnapshots.includes(d));
121
+ const done = await session.call<{ usedBefore: number; usedAfter: number; trimmed: string }>(
122
+ `${expiring.length ? `printf '%s\\n' ${expiring.map(q).join(' ')} >> "$WM_STORE/expired.list"\n` : ''}wm_gc_delete ${q(JSON.stringify(dropped))} ${paths.map(q).join(' ')}`);
123
+ return {
124
+ roots, dropped, branches, live: liveDirs.size, deletedSnapshots, deletedBases, deletedTrees, expired: expiring,
125
+ kept: { pending: [...pending], loaded: [...loaded] },
126
+ usedBefore: done.usedBefore, usedAfter: done.usedAfter, freedBytes: Math.max(0, done.usedBefore - done.usedAfter), trimmed: done.trimmed,
127
+ };
128
+ }
129
+
@@ -0,0 +1,21 @@
1
+ // The guest's copy of the injector is a hand list of files (GUEST_INJECT): every file the injector and the files it
2
+ // loads `require` by relative path must be on it, or every Node process in a machine fails at its preload (the §9
3
+ // review of the app-route move, 2026-09-27). Checked by loading the list, as the guest does, in an empty directory.
4
+ import { expect, test } from 'bun:test';
5
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { dirname, join } from 'node:path';
8
+ import { GUEST_INJECT } from './host.ts';
9
+
10
+ test('the guest injector loads from exactly the files written into the guest', async () => {
11
+ const dir = mkdtempSync(join(tmpdir(), 'guest-inject-'));
12
+ for (const [file, body] of Object.entries(GUEST_INJECT)) {
13
+ mkdirSync(dirname(join(dir, file)), { recursive: true });
14
+ writeFileSync(join(dir, file), body);
15
+ }
16
+ const child = Bun.spawn(['node', '--require', join(dir, 'inject.cjs'), '-e', 'console.log("loaded")'], { stdout: 'pipe', stderr: 'pipe', env: { PATH: process.env.PATH ?? '' } });
17
+ const [out, err] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]);
18
+ await child.exited;
19
+ expect(err).toBe('');
20
+ expect(out.trim()).toBe('loaded');
21
+ }, 30_000);
package/src/host.sh ADDED
@@ -0,0 +1,302 @@
1
+ # The machine host's half of @volter/world-machine: sourced once into the persistent shell the service
2
+ # holds on the machine host (the Linux box itself, or the Lima VM on a Mac). Every function prints its
3
+ # result as one `@{json}` line; diagnostics go to stderr, which the session folds into the output.
4
+ # The caller sets WM_STORE, WM_SLOT, WM_FC, WM_SE, WM_KEY, WM_TAP, WM_HOSTIP, WM_GUESTIP before sourcing.
5
+ WM_SLOTDIR="$WM_STORE/slots/$WM_SLOT"
6
+ WM_SOCK="$WM_SLOTDIR/api.sock"
7
+ WM_AGENT_PORT=7070
8
+
9
+ wm_ns() { date +%s%N; }
10
+ # wm_timed <name> <command...>: runs it and reports `<name>Ms`
11
+ wm_timed() { local n="$1" s="$EPOCHREALTIME" rc e; shift; "$@"; rc=$?; e="$EPOCHREALTIME"; printf '@{"%sMs":%s}\n' "$n" "$(( (${e/./} - ${s/./}) / 1000 ))"; return $rc; }
12
+ wm_ms_since() { echo $(( ($(date +%s%N) - $1) / 1000000 )); }
13
+ wm_fail() { printf '@{"error":%s}\n' "$(printf '%s' "$*" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"; return 1; }
14
+
15
+ # A job backgrounded from a function of this session holds the copies bash keeps of the session's own descriptors while
16
+ # the function runs with redirections (the service's pipes, the slot lock): `world-machine base` stayed until its tree's
17
+ # fill ended (23 s measured). Such a job closes them first; one that execs a program does not need to (they close on exec).
18
+ # (listed first, and closed with no redirection of its own: one would be saved to a high descriptor the loop then closes,
19
+ # leaving stderr lost)
20
+ wm_closefds() { local fds=(/proc/$BASHPID/fd/*) f n; for f in "${fds[@]}"; do n=${f##*/}; case "$n" in 0|1|2|*[!0-9]*) ;; *) eval "exec $n>&-" ;; esac; done; return 0; }
21
+
22
+ wm_init() {
23
+ mkdir -p "$WM_STORE/snapshots" "$WM_SLOTDIR" || return 1
24
+ printf '@{"home":"%s","store":"%s"}\n' "$HOME" "$WM_STORE"
25
+ }
26
+
27
+ # A reflink-capable store is a precondition, never a preference: a whole-image copy dominated every
28
+ # pause it was part of (1–13 s measured), so there is no fallback to one.
29
+ wm_reflink_check() {
30
+ local probe="$WM_STORE/.reflink-probe"
31
+ head -c 4096 /dev/zero > "$probe" 2>/dev/null || { wm_fail "cannot write the store $WM_STORE"; return 1; }
32
+ if ! cp --reflink=always "$probe" "$probe.clone" 2>/dev/null; then
33
+ rm -f "$probe" "$probe.clone"
34
+ wm_fail "the store $WM_STORE is on $(stat -f -c %T "$WM_STORE") and cannot reflink; world-machine needs XFS (reflink=1) or btrfs there and never falls back to a full copy (run \`world-machine setup --make-store <size>\`)"
35
+ return 1
36
+ fi
37
+ rm -f "$probe" "$probe.clone"
38
+ wm_store_dio
39
+ printf '@{"reflink":true,"fs":"%s"}\n' "$(stat -f -c %T "$WM_STORE")"
40
+ }
41
+
42
+ # A store that is a loop image (setup --make-store) reads and writes its backing file with direct I/O: through the
43
+ # page cache every page was cached twice, and a mark's 1.3 GB dump waited on writeback with the memory full
44
+ # (measured: 7.5 s paused, 0.7-1.0 s with direct I/O). fstab cannot ask for it, so it is set here, on each use.
45
+ wm_store_dio() {
46
+ local dev; dev=$(findmnt -no SOURCE --target "$WM_STORE" 2>/dev/null); dev=${dev%%[*}
47
+ case "$dev" in /dev/loop*) [ "$(cat "/sys/block/${dev#/dev/}/loop/dio" 2>/dev/null)" = 1 ] || sudo -n losetup --direct-io=on "$dev" 2>/dev/null || true ;; esac
48
+ }
49
+
50
+ # The guest's one link: a tap with the host end at WM_HOSTIP, forwarding from it dropped.
51
+ wm_net() {
52
+ [ -n "$WM_NET_READY" ] && { printf '@{"tap":"%s"}\n' "$WM_TAP"; return 0; }
53
+ if ! ip link show "$WM_TAP" >/dev/null 2>&1; then
54
+ sudo -n ip tuntap add "$WM_TAP" mode tap user "$(id -un)" || { wm_fail "cannot create tap $WM_TAP (needs sudo; run world-machine setup)"; return 1; }
55
+ sudo -n ip addr add "$WM_HOSTIP/30" dev "$WM_TAP" && sudo -n ip link set "$WM_TAP" up || { wm_fail "cannot address tap $WM_TAP"; return 1; }
56
+ fi
57
+ if command -v iptables >/dev/null && ! sudo -n iptables -C FORWARD -i "$WM_TAP" -j DROP 2>/dev/null; then
58
+ sudo -n iptables -I FORWARD -i "$WM_TAP" -j DROP 2>/dev/null || true
59
+ fi
60
+ WM_NET_READY=1
61
+ printf '@{"tap":"%s"}\n' "$WM_TAP"
62
+ }
63
+
64
+ wm_api() {
65
+ local code
66
+ code=$(curl -s --unix-socket "$WM_SOCK" -X "$1" "http://localhost/$2" -H 'Content-Type: application/json' ${3:+-d "$3"} -o "$WM_SLOTDIR/api.out" -w '%{http_code}')
67
+ case "$code" in 2*) return 0 ;; esac
68
+ wm_fail "firecracker $1 /$2 answered $code: $(cat "$WM_SLOTDIR/api.out" 2>/dev/null)"
69
+ }
70
+
71
+ wm_fc_pid() {
72
+ local pid
73
+ pid=$(cat "$WM_SLOTDIR/fc.pid" 2>/dev/null) || return 1
74
+ [ -n "$pid" ] && [ -r "/proc/$pid/comm" ] && case "$(cat /proc/$pid/comm)" in firecracker*) echo "$pid"; return 0 ;; esac
75
+ return 1
76
+ }
77
+
78
+ wm_fc_stop() {
79
+ local pid i
80
+ if pid=$(wm_fc_pid); then
81
+ kill -KILL "$pid" 2>/dev/null
82
+ for i in $(seq 1 400); do [ -d "/proc/$pid" ] || break; sleep 0.005; done
83
+ fi
84
+ rm -f "$WM_SLOTDIR/fc.pid" "$WM_SOCK"
85
+ }
86
+
87
+ wm_fc_start() {
88
+ local i
89
+ rm -f "$WM_SOCK"
90
+ # the pid is written by the process that becomes firecracker (setsid may fork, so $! can be its parent).
91
+ # `exec`: the backgrounded `cd && …` list is a forked copy of this session's shell, holding the session's
92
+ # stdout; without it that copy waits on Firecracker for the machine's whole life, so the service's pipe to
93
+ # this shell never closes (on Linux `world-machine base` then never exits; over Lima's ssh it was hidden)
94
+ ( cd "$WM_SLOTDIR" && exec setsid bash -c 'echo $$ > "$1"; shift; exec "$@"' _ "$WM_SLOTDIR/fc.pid" "$WM_FC" --api-sock "$WM_SOCK" > "$WM_SLOTDIR/fc.log" 2>&1 < /dev/null & )
95
+ for i in $(seq 1 1000); do [ -S "$WM_SOCK" ] && return 0; sleep 0.002; done
96
+ wm_fail "firecracker did not open $WM_SOCK: $(tail -3 "$WM_SLOTDIR/fc.log")"
97
+ }
98
+
99
+ wm_running() {
100
+ local pid state='stopped'
101
+ if pid=$(wm_fc_pid); then
102
+ state=$(curl -s --unix-socket "$WM_SOCK" http://localhost/ 2>/dev/null | python3 -c 'import json,sys
103
+ try: print(json.load(sys.stdin).get("state","unknown"))
104
+ except Exception: print("unknown")')
105
+ fi
106
+ printf '@{"fc":"%s","owner":%s}\n' "$state" "$(cat "$WM_SLOTDIR/owner.json" 2>/dev/null || echo null)"
107
+ }
108
+
109
+ wm_own() { printf '%s\n' "$1" > "$WM_SLOTDIR/owner.json"; }
110
+ wm_release() { rm -f "$WM_SLOTDIR/owner.json"; }
111
+
112
+ # wm_boot <kernel> <disk-source> <vcpus> <mem-mib> <boot-args>: a cold boot on a reflink clone of the source disk.
113
+ wm_boot() {
114
+ wm_fc_stop
115
+ cp --reflink=always "$2" "$WM_SLOTDIR/disk.ext4" || { wm_fail "cannot reflink $2 into the slot"; return 1; }
116
+ wm_fc_start || return 1
117
+ wm_api PUT machine-config "{\"vcpu_count\":$3,\"mem_size_mib\":$4,\"track_dirty_pages\":true}" || return 1
118
+ wm_api PUT boot-source "{\"kernel_image_path\":\"$1\",\"boot_args\":\"$5\"}" || return 1
119
+ wm_api PUT drives/rootfs "{\"drive_id\":\"rootfs\",\"path_on_host\":\"$WM_SLOTDIR/disk.ext4\",\"is_root_device\":true,\"is_read_only\":false}" || return 1
120
+ wm_api PUT network-interfaces/eth0 "{\"iface_id\":\"eth0\",\"host_dev_name\":\"$WM_TAP\",\"guest_mac\":\"06:00:AC:10:00:02\"}" || return 1
121
+ wm_api PUT actions '{"action_type":"InstanceStart"}' || return 1
122
+ printf '@{"booted":true}\n'
123
+ }
124
+
125
+ # wm_snapshot <dir> <Full|Diff>: the pause. Memory (all of it, or the pages dirtied since the last
126
+ # snapshot or load) and the device state are written, the disk is reflink-cloned beside them, and the
127
+ # guest runs again. A diff and the device state are written to memory (/dev/shm) and moved into the
128
+ # store after the guest resumes: written into the store they made the pause (447 ms for 13 MB measured
129
+ # on a loaded Mac). The disk's dirty pages are flushed before pausing, since a clone flushes them first.
130
+ wm_snapshot() {
131
+ local d="$1" mem state t0 tw tp shm="/dev/shm/wm-$WM_SLOT-$$"
132
+ mkdir -p "$d" || return 1
133
+ if [ "$2" = Diff ]; then mem="$shm.diff"; state="$shm.vmstate"; else mem="$d/mem"; state="$d/vmstate"; fi
134
+ sync "$WM_SLOTDIR/disk.ext4"
135
+ t0=$(wm_ns)
136
+ wm_api PATCH vm '{"state":"Paused"}' || return 1
137
+ tp=$(wm_ns)
138
+ if ! wm_api PUT snapshot/create "{\"snapshot_type\":\"$2\",\"snapshot_path\":\"$state\",\"mem_file_path\":\"$mem\"}"; then wm_api PATCH vm '{"state":"Resumed"}'; return 1; fi
139
+ tw=$(wm_ns)
140
+ if ! cp --reflink=always "$WM_SLOTDIR/disk.ext4" "$d/disk.ext4"; then wm_api PATCH vm '{"state":"Resumed"}'; wm_fail "cannot reflink the disk into $d"; return 1; fi
141
+ wm_api PATCH vm '{"state":"Resumed"}' || return 1
142
+ printf '@{"pauseMs":%s,"memWriteMs":%s,"diskCloneMs":%s,"memBytes":%s}\n' \
143
+ "$(( ($(wm_ns) - t0) / 1000000 ))" "$(( (tw - tp) / 1000000 ))" "$(( ($(wm_ns) - tw) / 1000000 ))" "$(du -B1 "$mem" | cut -f1)"
144
+ WM_LAST_DIFF="$mem"
145
+ if [ "$2" = Diff ]; then mv "$state" "$d/vmstate" || { rm -f "$state" "$mem"; wm_fail "cannot move the device state into $d"; return 1; }; fi
146
+ }
147
+
148
+ # wm_materialize <parent-mem> <dir>: one full memory file per entry, so a restore loads one file:
149
+ # a reflink copy of the parent's, with the diff wm_snapshot left in /dev/shm applied by Firecracker's
150
+ # snapshot-editor.
151
+ wm_materialize() {
152
+ local t0; t0=$(wm_ns)
153
+ cp --reflink=always "$1" "$2/mem.tmp" || { rm -f "$WM_LAST_DIFF"; wm_fail "cannot reflink $1"; return 1; }
154
+ if ! "$WM_SE" edit-memory rebase --memory-path "$2/mem.tmp" --diff-path "$WM_LAST_DIFF" >/dev/null; then rm -f "$WM_LAST_DIFF"; wm_fail "snapshot-editor rebase failed for $2"; return 1; fi
155
+ mv "$2/mem.tmp" "$2/mem" && rm -f "$WM_LAST_DIFF"
156
+ printf '@{"materializeMs":%s}\n' "$(wm_ms_since "$t0")"
157
+ }
158
+
159
+ # wm_restore <dir>: in place. The slot's process is replaced, the disk becomes a reflink clone of the
160
+ # snapshot's, and the memory file is loaded as the backing of guest memory, resumed.
161
+ wm_restore() {
162
+ local d="$1" t0 tl
163
+ [ -f "$d/vmstate" ] && [ -f "$d/mem" ] && [ -f "$d/disk.ext4" ] || { wm_fail "snapshot $d is incomplete"; return 1; }
164
+ t0=$(wm_ns)
165
+ wm_fc_stop
166
+ cp --reflink=always "$d/disk.ext4" "$WM_SLOTDIR/disk.ext4" || { wm_fail "cannot reflink the disk of $d"; return 1; }
167
+ wm_fc_start || return 1
168
+ tl=$(wm_ns)
169
+ wm_api PUT snapshot/load "{\"snapshot_path\":\"$d/vmstate\",\"mem_backend\":{\"backend_type\":\"File\",\"backend_path\":\"$d/mem\"},\"track_dirty_pages\":true,\"resume_vm\":true}" || return 1
170
+ printf '%s' "$d" > "$WM_SLOTDIR/loaded" # what the collector must keep while this machine runs
171
+ printf '@{"loadMs":%s,"restoreMs":%s}\n' "$(wm_ms_since "$tl")" "$(wm_ms_since "$t0")"
172
+ }
173
+
174
+ # Retention (gc.ts). The store's registry of Worlds, kept under a lock since every serve writes it.
175
+ wm_register() {
176
+ ( flock 9; python3 - "$WM_STORE/roots.json" "$1" <<'PY'
177
+ import json, os, sys
178
+ path, root = sys.argv[1:3]
179
+ roots = json.load(open(path)) if os.path.exists(path) else []
180
+ if root not in roots: roots.append(root)
181
+ tmp = path + '.tmp'; json.dump(roots, open(tmp, 'w'), indent=1); os.replace(tmp, path)
182
+ PY
183
+ ) 9> "$WM_STORE/.roots.lock" || { wm_fail "cannot register $1 in $WM_STORE/roots.json"; return 1; }
184
+ printf '@{"registered":%s}\n' "$(printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"
185
+ }
186
+
187
+ # The store as the collector sees it: registered Worlds, snapshot directories (those a checkpoint is still
188
+ # writing marked pending), warm bases, and what each slot's machine was loaded from.
189
+ wm_store_list() {
190
+ python3 - "$WM_STORE" <<'PY'
191
+ import json, os, sys
192
+ s = sys.argv[1]
193
+ ls = lambda d: sorted(os.listdir(d)) if os.path.isdir(d) else []
194
+ snaps = [n for n in ls(os.path.join(s, 'snapshots')) if os.path.isdir(os.path.join(s, 'snapshots', n))]
195
+ loaded = []
196
+ for slot in ls(os.path.join(s, 'slots')):
197
+ # only a slot whose Firecracker is running holds what it was loaded from
198
+ f, pid = os.path.join(s, 'slots', slot, 'loaded'), os.path.join(s, 'slots', slot, 'fc.pid')
199
+ try: running = open('/proc/%s/comm' % open(pid).read().strip()).read().startswith('firecracker')
200
+ except OSError: running = False
201
+ if running:
202
+ try: loaded.append(open(f).read().strip())
203
+ except OSError: pass
204
+ # a container slot being restored or prepared holds the snapshot it is reading (ct.sh wm_ct_restore, container.ts
205
+ # prepare), and a prepared slot the snapshot it hands over, until the branch adopting it names it in its ledger; a
206
+ # container slot holds what its machine was restored from (ct-loaded) until that machine's service stops
207
+ for name in ('restoring', 'preparing', 'ct-loaded', 'comparing'): # comparing: ct.sh wm_ct_compare's mount
208
+ try: loaded.append(open(os.path.join(s, 'slots', slot, name)).read().strip())
209
+ except OSError: pass
210
+ try: handover = json.load(open(os.path.join(s, 'slots', slot, 'owner.json'))).get('handover')
211
+ except (OSError, ValueError, AttributeError): handover = None
212
+ if handover: loaded.append(handover)
213
+ roots = json.load(open(os.path.join(s, 'roots.json'))) if os.path.exists(os.path.join(s, 'roots.json')) else []
214
+ # each container base's (or base build's) filled tree; one deleted during the scan names none
215
+ base_trees = {}
216
+ for n in ls(s):
217
+ if not n.startswith('base-'): continue
218
+ try: base_trees[n] = open(os.path.join(s, n, 'tree')).read().strip()
219
+ except OSError: pass
220
+ print('@' + json.dumps({'roots': roots, 'snapshots': snaps, 'pending': [n for n in snaps if os.path.exists(os.path.join(s, 'snapshots', n, '.pending'))],
221
+ 'bases': [n for n in ls(s) if n.startswith('base-')], 'trees': [n for n in ls(s) if n.startswith('tree-')],
222
+ 'baseTrees': base_trees, 'loaded': loaded}))
223
+ PY
224
+ }
225
+
226
+ # wm_gc_delete <dropped-roots-json> <store-relative path...>: delete, drop the gone registrations, give the
227
+ # space back to the host (fstrim on the store's filesystem), and report the store's use before and after.
228
+ wm_gc_delete() {
229
+ local dropped="$1" before after p mnt trimmed=""; shift
230
+ sync -f "$WM_STORE"; before=$(df -B1 --output=used "$WM_STORE" | tail -1 | tr -d ' ')
231
+ for p in "$@"; do case "$p" in snapshots/snap-*|base-*|tree-*) rm -rf -- "${WM_STORE:?}/$p" ;; *) wm_fail "refusing to delete $p"; return 1 ;; esac; done
232
+ ( flock 9; python3 - "$WM_STORE/roots.json" "$dropped" <<'PY'
233
+ import json, os, sys
234
+ path, dropped = sys.argv[1], set(json.loads(sys.argv[2]))
235
+ if os.path.exists(path):
236
+ roots = [r for r in json.load(open(path)) if r not in dropped]
237
+ tmp = path + '.tmp'; json.dump(roots, open(tmp, 'w'), indent=1); os.replace(tmp, path)
238
+ PY
239
+ ) 9> "$WM_STORE/.roots.lock"
240
+ mnt=$(findmnt -n -o TARGET --target "$WM_STORE")
241
+ # the deletes committed first: btrfs frees their extents only then, and a trim before it gave nothing back (measured:
242
+ # 1.66 GB deleted, the store's image unchanged until a later trim returned 1.45 GB)
243
+ sync -f "$WM_STORE"
244
+ trimmed=$(sudo -n fstrim -v "$mnt" 2>&1 | tail -1)
245
+ sync -f "$WM_STORE"; after=$(df -B1 --output=used "$WM_STORE" | tail -1 | tr -d ' ')
246
+ printf '@{"usedBefore":%s,"usedAfter":%s,"trimmed":%s}\n' "$before" "$after" "$(printf '%s' "$trimmed" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"
247
+ }
248
+
249
+ # wm_room <bytes> [path...]: refused (wm_fail) unless every filesystem a store write lands on has <bytes> free: the
250
+ # store, the file backing it when it is a loop image (a sparse XFS image promises room its backing filesystem may not
251
+ # have), and each path named (on a Mac, the home the Lima VM mounts: its free space is the Mac's, beneath the VM's own
252
+ # sparse disk). A write the lowest layer cannot hold fails as an I/O error in the layers above it, which is how a
253
+ # store is corrupted; a mark refused first is only a mark not taken.
254
+ wm_room() {
255
+ local need="$1" p dev back line av tg least="" at=""; shift
256
+ set -- "$WM_STORE" "$@"
257
+ dev=$(findmnt -no SOURCE --target "$WM_STORE" 2>/dev/null); dev=${dev%%[*} # btrfs names a subvolume: /dev/loop0[/sub]
258
+ case "$dev" in /dev/loop*) back=$(losetup -nO BACK-FILE "$dev" 2>/dev/null | tr -d ' '); [ -n "$back" ] && set -- "$@" "$(dirname "$back")" ;; esac
259
+ for p in "$@"; do
260
+ [ -e "$p" ] || continue
261
+ line=$(df -B1 --output=avail,target "$p" 2>/dev/null | tail -1) || continue
262
+ read -r av tg <<<"$line"
263
+ if [ -z "$least" ] || [ "$av" -lt "$least" ]; then least=$av; at=$tg; fi
264
+ done
265
+ [ -n "$least" ] || { wm_fail "cannot read the free space under $WM_STORE"; return 1; }
266
+ [ "$least" -ge "$need" ] || { wm_fail "no room for a mark: $at has $((least / 1048576)) MB free and a mark needs about $((need / 1048576)) MB there (its estimate and the reserve); nothing was written. Free space there, or collect the snapshots no branch names (world-machine gc)"; return 1; }
267
+ printf '@{"room":%s,"at":"%s"}\n' "$least" "$at"
268
+ }
269
+
270
+ # the store's used bytes, after a syncfs: XFS frees an unlinked file's blocks in the background, and
271
+ # a df read without it counted a just-deleted memory diff (+43 MB measured)
272
+ wm_used() { sync -f "$WM_STORE"; printf '@{"used":%s}\n' "$(df -B1 --output=used "$WM_STORE" | tail -1 | tr -d ' ')"; }
273
+
274
+ # wm_egress <host-port>=<target-host>:<target-port> ...: the guest's only way out. Each stable
275
+ # guest-visible port on the tap's host address forwards to one twin of the World that owns the slot.
276
+ wm_egress() {
277
+ local pid m
278
+ # unchanged and all alive: nothing to re-point
279
+ if [ "$*" = "$(cat "$WM_SLOTDIR/egress.spec" 2>/dev/null)" ] && [ -s "$WM_SLOTDIR/egress.pids" ]; then
280
+ m=0; for pid in $(cat "$WM_SLOTDIR/egress.pids"); do [ "$(cat /proc/$pid/comm 2>/dev/null)" = socat ] || m=1; done
281
+ [ "$m" = 0 ] && { printf '@{"forwarders":%s,"unchanged":true}\n' "$#"; return 0; }
282
+ fi
283
+ for pid in $(cat "$WM_SLOTDIR/egress.pids" 2>/dev/null); do
284
+ [ -r "/proc/$pid/comm" ] && [ "$(cat /proc/$pid/comm)" = socat ] && kill "$pid" 2>/dev/null
285
+ done
286
+ : > "$WM_SLOTDIR/egress.pids"
287
+ for m in "$@"; do
288
+ # to this host's loopback, from 127.0.0.2: the World's proxy knows a machine's connection by it and keeps the
289
+ # machine off this host's own network (world-runtime redirect-proxy.ts isGuestSource)
290
+ local src=""; case "${m#*=}" in 127.*|localhost:*) src=",bind=127.0.0.2" ;; esac
291
+ # 9>&-: the container backend's slot lock (host.ts) is never held by a forwarder that outlives the shell
292
+ setsid bash -c 'echo $$ >> "$1"; shift; exec "$@"' _ "$WM_SLOTDIR/egress.pids" socat "TCP-LISTEN:${m%%=*},bind=$WM_HOSTIP,fork,reuseaddr" "TCP:${m#*=}$src" > /dev/null 2>&1 < /dev/null 9>&- &
293
+ done
294
+ printf '%s' "$*" > "$WM_SLOTDIR/egress.spec"
295
+ printf '@{"forwarders":%s}\n' "$#"
296
+ }
297
+
298
+ # The guest agent (agent.py, inside the guest): the clock and the World's env file.
299
+ wm_agent() { python3 "$WM_SLOTDIR/agentc.py" "$WM_GUESTIP" "$WM_AGENT_PORT" "$@"; }
300
+
301
+ # bounded: under load a guest ssh login has taken 18 s
302
+ wm_gssh() { timeout "${WM_GSSH_TIMEOUT:-20}" ssh -q -i "$WM_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=2 -o BatchMode=yes "root@$WM_GUESTIP" "$@"; }
package/src/host.ts ADDED
@@ -0,0 +1,161 @@
1
+ // The machine host: where Firecracker runs. On Linux it is this box; on a Mac it is a Lima (>= 2) VM
2
+ // with nested virtualization, reached over Lima's own ssh config. Either way the service holds ONE
3
+ // persistent bash session on it (a new ssh or bash per operation cost 75-125 ms on a loaded Mac) with
4
+ // host.sh sourced, and every operation is one round trip over that session.
5
+ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { readFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import type { MachineConfig } from './config.ts';
11
+
12
+ const HOST_LIB = readFileSync(new URL('./host.sh', import.meta.url), 'utf8');
13
+ /** The container backend's functions, sourced after HOST_LIB when the config names that backend. */
14
+ const CT_LIB = readFileSync(new URL('./ct.sh', import.meta.url), 'utf8');
15
+ const AGENT_CLIENT = readFileSync(new URL('./agentc.py', import.meta.url), 'utf8');
16
+ /** The container backend's page composer (compose.py), run detached on the host after each mark. */
17
+ const COMPOSER = readFileSync(new URL('./compose.py', import.meta.url), 'utf8');
18
+ /** The container backend's port publisher (publish.py), run detached on the host while the machine runs. */
19
+ const PUBLISHER = readFileSync(new URL('./publish.py', import.meta.url), 'utf8');
20
+ export const GUEST_AGENT = readFileSync(new URL('./agent.py', import.meta.url), 'utf8');
21
+
22
+ /** world-core's zero-edit redirect (a real SDK's vendor host to its twin, by the *_TWIN_URL variables) and the files it
23
+ * loads, written into the guest at GUEST_INJECT_DIR at every start and restore: an app in the machine reaches the twins
24
+ * as `volter world run` does. */
25
+ export const GUEST_INJECT_DIR = '/opt/volter-world';
26
+ const core = (spec: string): string => readFileSync(fileURLToPath(import.meta.resolve(`@volter/world-core/${spec}`)), 'utf8');
27
+ export const GUEST_INJECT: Record<string, string> = {
28
+ 'inject.cjs': core('inject'), 'network-policy.cjs': core('network-policy'), 'app-route.cjs': core('app-route'),
29
+ 'vendor-hosts.cjs': core('vendor-hosts'),
30
+ 'generated/pack-facts.json': core('generated/pack-facts.json'),
31
+ };
32
+ /** Every login shell in the guest exports the World's env file (KEY=value lines, written at each start and restore). */
33
+ export const GUEST_PROFILE = `[ -r /etc/world.env ] && while IFS= read -r l; do case "$l" in ''|'#'*) ;; *=*) export "$l" ;; esac; done < /etc/world.env`;
34
+
35
+ /** A sub-millisecond wall clock on the World's host. */
36
+ export const hostNowMs = (): number => performance.timeOrigin + performance.now();
37
+
38
+ /** Single-quote a string for bash. */
39
+ export const q = (s: string): string => `'${s.replace(/'/g, `'\\''`)}'`;
40
+
41
+ export class HostSession {
42
+ private child: ChildProcessWithoutNullStreams | undefined;
43
+ private buffer = '';
44
+ private waiter: { marker: string; resolve: (v: { status: number; out: string }) => void; reject: (e: Error) => void } | undefined;
45
+ private queue: Promise<unknown> = Promise.resolve();
46
+ /** The machine host's clock minus this host's, in seconds; measured at open and before each clock step. */
47
+ offsetS = 0;
48
+
49
+ constructor(private readonly config: MachineConfig) {}
50
+
51
+ // Its own process group: the World stops a service by signalling its group, and the session must
52
+ // outlive that signal long enough to release the slot (it was cut mid-script, leaving the slot
53
+ // claimed). It still ends with this process: its stdin closes and the shell reads EOF.
54
+ private spawnShell(): ChildProcessWithoutNullStreams {
55
+ const h = this.config.host;
56
+ if (h.kind === 'linux') return spawn('bash', ['--noprofile', '--norc'], { stdio: 'pipe', detached: true });
57
+ const cfg = join(h.limaHome, h.instance, 'ssh.config');
58
+ return spawn('ssh', ['-F', cfg, '-T', '-o', 'ServerAliveInterval=10', `lima-${h.instance}`, 'bash --noprofile --norc'], { stdio: 'pipe', detached: true });
59
+ }
60
+
61
+ private async open(): Promise<void> {
62
+ const child = this.spawnShell();
63
+ this.child = child;
64
+ child.stdout.setEncoding('utf8');
65
+ child.stderr.setEncoding('utf8');
66
+ child.stdout.on('data', (chunk: string) => { this.buffer += chunk; this.drain(); });
67
+ child.stderr.on('data', (chunk: string) => { this.buffer += chunk; });
68
+ child.on('exit', (code) => {
69
+ if (this.child === child) this.child = undefined;
70
+ const w = this.waiter; this.waiter = undefined;
71
+ w?.reject(new Error(`the machine host session ended (exit ${code}): ${this.buffer.slice(-400)}`));
72
+ });
73
+ const c = this.config;
74
+ const vars: Record<string, string> = { WM_STORE: c.store, WM_SLOT: c.slot, WM_FC: c.firecracker, WM_SE: c.snapshotEditor, WM_KEY: c.sshKey, WM_TAP: c.tap, WM_HOSTIP: c.hostIp, WM_GUESTIP: c.guestIp };
75
+ if (c.backend === 'container') { vars.WM_BINDS = JSON.stringify(c.binds); if (c.export) vars.WM_EXPORT = JSON.stringify(c.export); }
76
+ // `~/` in a configured path is the machine host's home, expanded there.
77
+ const assign = Object.entries(vars).map(([k, v]) => `${k}=${v.startsWith('~/') ? `"$HOME"/${q(v.slice(2))}` : q(v)}`).join('\n');
78
+ await this.rawRun(`${assign}\n${HOST_LIB}\n${c.backend === 'container' ? `${CT_LIB}\n` : ''}wm_init && mkdir -p "$WM_SLOTDIR" && ${c.backend === 'container' ? 'exec 9>>"$WM_SLOTDIR/slot.lock" && WM_LOCKED=1 && ' : ''}cat > "$WM_SLOTDIR/agentc.py" <<'__WM_AGENTC__'\n${AGENT_CLIENT}\n__WM_AGENTC__\n${c.backend === 'container' ? `cat > "$WM_SLOTDIR/compose.py.$$" <<'__WM_COMPOSE__' && mv -f "$WM_SLOTDIR/compose.py.$$" "$WM_SLOTDIR/compose.py"\n${COMPOSER}\n__WM_COMPOSE__\ncat > "$WM_SLOTDIR/publish.py.$$" <<'__WM_PUBLISH__' && mv -f "$WM_SLOTDIR/publish.py.$$" "$WM_SLOTDIR/publish.py"\n${PUBLISHER}\n__WM_PUBLISH__\n` : ''}`);
79
+ await this.measureOffset();
80
+ }
81
+
82
+ private drain(): void {
83
+ const w = this.waiter;
84
+ if (!w) return;
85
+ const at = this.buffer.indexOf(w.marker);
86
+ if (at < 0) return;
87
+ const rest = this.buffer.slice(at + w.marker.length);
88
+ const nl = rest.indexOf('\n');
89
+ if (nl < 0) return;
90
+ const status = Number(rest.slice(0, nl).trim());
91
+ const out = this.buffer.slice(0, at);
92
+ this.buffer = rest.slice(nl + 1);
93
+ this.waiter = undefined;
94
+ w.resolve({ status, out });
95
+ }
96
+
97
+ /** Set while the store-wide collection runs (gc): it is no operation on the slot, and must not wait on one. */
98
+ unlocked = false;
99
+
100
+ private rawRun(script: string): Promise<{ status: number; out: string }> {
101
+ const child = this.child;
102
+ if (!child) return Promise.reject(new Error('no machine host session'));
103
+ const marker = `__WM_DONE_${randomBytes(6).toString('hex')}__`;
104
+ return new Promise((resolve, reject) => {
105
+ this.waiter = { marker, resolve, reject };
106
+ this.buffer = '';
107
+ // a function, so a script may `return` its status
108
+ // With the slot's lock open (the container backend, from init on), one operation at a time across every service on
109
+ // the slot: a World's branch runs the machine being left and the branch's own machine side by side (a retake's
110
+ // prepare, then adopt), and each must see the slot as the other left it. The lock is taken for the operation and
111
+ // released after it, however it ends; a background process that inherited it does not keep it.
112
+ child.stdin.write(`__wm_op() {\n${script}\n}\nif [ -n "\${WM_LOCKED:-}" ] && [ -z "${this.unlocked ? '1' : ''}" ]; then flock -w 120 9 || { echo '@{"error":"the machine slot stayed busy for 120 s (another World operating it)"}'; false; }; fi && __wm_op 2>&1 < /dev/null; __wm_rc=$?; [ -n "\${WM_LOCKED:-}" ] && flock -u 9; printf '\\n%s %d\\n' ${marker} $__wm_rc\n`);
113
+ });
114
+ }
115
+
116
+ /** Run a script on the machine host, one at a time; resolves with its status and output. */
117
+ run(script: string): Promise<{ status: number; out: string }> {
118
+ const next = this.queue.then(async () => {
119
+ if (!this.child) await this.open();
120
+ return this.rawRun(script);
121
+ });
122
+ this.queue = next.catch(() => undefined);
123
+ return next;
124
+ }
125
+
126
+ /** Run host.sh calls, each printing an `@{json}` result line; the results merge in order. An
127
+ * `error` field, or a nonzero status, is thrown with the output. */
128
+ async call<T = Record<string, unknown>>(script: string): Promise<T> {
129
+ const { status, out } = await this.run(script);
130
+ const results = out.split('\n').filter((l) => l.startsWith('@{')).map((l) => JSON.parse(l.slice(1)) as Record<string, unknown>);
131
+ const failed = results.find((r) => typeof r.error === 'string');
132
+ if (failed) throw new Error(failed.error as string);
133
+ if (status !== 0 || !results.length) throw new Error(`machine host: \`${script.split('\n')[0]}\` exited ${status}: ${out.trim().slice(-600)}`);
134
+ return Object.assign({}, ...results) as T;
135
+ }
136
+
137
+ /** The machine host's clock against this host's: the exchange with the shortest round trip of
138
+ * five bounds the error by half that round trip. */
139
+ async measureOffset(): Promise<{ offsetMs: number; rttMs: number }> {
140
+ let best: { offset: number; rtt: number } | undefined;
141
+ for (let i = 0; i < 5; i++) {
142
+ const t0 = hostNowMs();
143
+ const { out } = await this.rawRun('date +%s%N');
144
+ const t1 = hostNowMs();
145
+ const remote = Number(out.trim().split('\n').pop()) / 1e6;
146
+ const sample = { offset: remote - (t0 + t1) / 2, rtt: t1 - t0 };
147
+ if (!best || sample.rtt < best.rtt) best = sample;
148
+ }
149
+ this.offsetS = best!.offset / 1000;
150
+ return { offsetMs: Math.round(best!.offset * 100) / 100, rttMs: Math.round(best!.rtt * 100) / 100 };
151
+ }
152
+
153
+ /** measureOffset, serialized with every other operation. */
154
+ offset(): Promise<{ offsetMs: number; rttMs: number }> {
155
+ const next = this.queue.then(async () => { if (!this.child) await this.open(); return this.measureOffset(); });
156
+ this.queue = next.catch(() => undefined);
157
+ return next;
158
+ }
159
+
160
+ close(): void { this.child?.stdin.end(); this.child?.kill(); this.child = undefined; }
161
+ }