@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/README.md +339 -0
- package/dist/src/agent.py +92 -0
- package/dist/src/agentc.py +81 -0
- package/dist/src/base.d.ts +9 -0
- package/dist/src/base.js +61 -0
- package/dist/src/cli.d.ts +3 -0
- package/dist/src/cli.js +319 -0
- package/dist/src/compose.py +82 -0
- package/dist/src/config.d.ts +74 -0
- package/dist/src/config.js +120 -0
- package/dist/src/container.d.ts +73 -0
- package/dist/src/container.js +436 -0
- package/dist/src/ct.sh +675 -0
- package/dist/src/gc.d.ts +25 -0
- package/dist/src/gc.js +131 -0
- package/dist/src/host.d.ts +49 -0
- package/dist/src/host.js +163 -0
- package/dist/src/host.sh +302 -0
- package/dist/src/ledger.d.ts +25 -0
- package/dist/src/ledger.js +38 -0
- package/dist/src/machine.d.ts +82 -0
- package/dist/src/machine.js +367 -0
- package/dist/src/publish.py +134 -0
- package/dist/src/thin.d.ts +20 -0
- package/dist/src/thin.js +30 -0
- package/package.json +33 -0
- package/src/agent.py +92 -0
- package/src/agentc.py +81 -0
- package/src/base.ts +67 -0
- package/src/cli.ts +283 -0
- package/src/compose.py +82 -0
- package/src/config.ts +165 -0
- package/src/container.ts +411 -0
- package/src/ct.sh +675 -0
- package/src/gc.ts +129 -0
- package/src/guest-inject.test.ts +21 -0
- package/src/host.sh +302 -0
- package/src/host.ts +161 -0
- package/src/ledger.ts +61 -0
- package/src/machine.ts +314 -0
- package/src/publish.py +134 -0
- package/src/thin.test.ts +62 -0
- package/src/thin.ts +48 -0
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
|
+
}
|