@braedonsaunders/appkit-desk 0.8.0 → 0.9.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 +71 -1
- package/backend.d.ts +4 -0
- package/backend.d.ts.map +1 -1
- package/backend.js +92 -6
- package/backend.js.map +1 -1
- package/host.d.ts +24 -1
- package/host.d.ts.map +1 -1
- package/host.js +17 -4
- package/host.js.map +1 -1
- package/machine.d.ts +10 -1
- package/machine.d.ts.map +1 -1
- package/machine.js +55 -0
- package/machine.js.map +1 -1
- package/package.json +1 -1
- package/plan.d.ts +63 -0
- package/plan.d.ts.map +1 -1
- package/plan.js +133 -3
- package/plan.js.map +1 -1
- package/virtualization.d.ts.map +1 -1
- package/virtualization.js +9 -1
- package/virtualization.js.map +1 -1
- package/vz/Sources/appkit-desk-vz/Helper.swift +45 -0
package/README.md
CHANGED
|
@@ -257,7 +257,77 @@ DESK_CHECK_IMAGE_ROOT=/data/desk-image DESK_CHECK_BASE_IMAGE=/data/desk-image/ba
|
|
|
257
257
|
DESK_CHECK_TMPDIR=/data/tmp pnpm --filter @braedonsaunders/appkit-desk exec tsx scripts/check-kvm.mts
|
|
258
258
|
```
|
|
259
259
|
|
|
260
|
-
Set `DESK_CHECK_KERNEL_CMDLINE` for images that require a custom root device. The check creates and removes a disposable VM, overlay, snapshot, TAP and firewall rules. It verifies a guest HTTP stream before and after memory restore; it does not verify the proxy or cross-CPU migration. Choose addresses that do not overlap existing routes.
|
|
260
|
+
Set `DESK_CHECK_KERNEL_CMDLINE` for images that require a custom root device. The check creates and removes a disposable VM, overlay, snapshot, TAP and firewall rules. It verifies a guest HTTP stream before and after memory restore; it does not verify the proxy or cross-CPU migration. Choose addresses that do not overlap existing routes. Set `DESK_CHECK_VIRTIOFSD` to a `virtiofsd` binary to also check a read-only share: the guest mounts it, a write fails, and a file held open across the park reads back after the restore.
|
|
261
|
+
|
|
262
|
+
## Host directory shares
|
|
263
|
+
|
|
264
|
+
A desk can mount host directories over virtio-fs. Pass `shares` to
|
|
265
|
+
`host.start` (or `buildDeskLaunchPlan`):
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
const desk = await host.start({
|
|
269
|
+
deskId: 'agent-7',
|
|
270
|
+
baseImage: '/data/agent-disks/base.img',
|
|
271
|
+
overlayPath: '/data/agent-disks/overlays/agent-7.img',
|
|
272
|
+
shares: [
|
|
273
|
+
{ tag: 'tools', path: '/srv/desk-tools', readOnly: true },
|
|
274
|
+
{ tag: 'build', path: '/srv/builds/agent-7', readOnly: false },
|
|
275
|
+
],
|
|
276
|
+
})
|
|
277
|
+
await desk.exec({ command: '/bin/sh', args: ['-c', 'mkdir -p /opt/tools && mount -t virtiofs tools /opt/tools'] })
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
A `tag` is 1–36 lowercase letters, digits or hyphens, starting with a letter or
|
|
281
|
+
digit, and unique within the desk; it is the name the guest mounts. `path` is an
|
|
282
|
+
absolute path to an existing host directory. `readOnly` is required. A read-only
|
|
283
|
+
share is enforced by the host's file server, so remounting it read-write in the
|
|
284
|
+
guest does not make it writable. The guest kernel needs virtio-fs
|
|
285
|
+
(`CONFIG_VIRTIO_FS`; Debian's cloud kernels ship it as the `virtiofs` module).
|
|
286
|
+
|
|
287
|
+
On Cloud Hypervisor, each share is served by its own `virtiofsd` (the Rust
|
|
288
|
+
implementation, 1.13 or later). `virtiofsdPath` on the host options names the
|
|
289
|
+
binary; without it the first `virtiofsd` on `PATH` is used, then
|
|
290
|
+
`/usr/libexec/virtiofsd`. A desk without shares does not need `virtiofsd`. Each
|
|
291
|
+
daemon runs as the launcher identity with:
|
|
292
|
+
|
|
293
|
+
```sh
|
|
294
|
+
virtiofsd --socket-path=<runtimeDir>/desk-<deskId>.<tag>.fs.sock --shared-dir=<path> \
|
|
295
|
+
--sandbox=<namespace|chroot> --cache=auto \
|
|
296
|
+
--migration-mode=find-paths --migration-on-error=guest-error [--readonly]
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
and the VMM gets `--fs tag=<tag>,socket=<socket>` with its memory as
|
|
300
|
+
`size=<n>M,shared=on`, which vhost-user devices require; a desk without shares
|
|
301
|
+
keeps private memory. `--cache=auto` lets the guest cache for about a second,
|
|
302
|
+
so files the host changes under a share appear in running guests.
|
|
303
|
+
`virtiofsdSandbox` picks how the daemon confines itself to the directory:
|
|
304
|
+
`namespace` (the default) needs CAP_SYS_ADMIN when the launcher is root and
|
|
305
|
+
unprivileged user namespaces otherwise; `chroot` needs root and suits a
|
|
306
|
+
container that cannot create namespaces. Both prevent symlink escapes. The
|
|
307
|
+
backend starts every daemon and waits for its socket before starting the VMM,
|
|
308
|
+
on a boot and on a restore, and stops the daemons after the VMM. A daemon that
|
|
309
|
+
exits while its VMM runs stops the VMM, so the desk is lost rather than left
|
|
310
|
+
with a share that no longer answers. `virtiofsd` also exits when its VMM
|
|
311
|
+
disconnects. The share socket path must fit in 107 bytes.
|
|
312
|
+
|
|
313
|
+
On Virtualization.framework, each share is a
|
|
314
|
+
`VZVirtioFileSystemDeviceConfiguration` with a `VZSingleDirectoryShare`, and the
|
|
315
|
+
framework enforces `readOnly`. `virtiofsdPath` and `virtiofsdSandbox` are
|
|
316
|
+
refused there. When Virtualization.framework reports that a configuration
|
|
317
|
+
cannot be saved, the desk parks by shutting down, as for any other device.
|
|
318
|
+
|
|
319
|
+
Parking keeps a desk's shares with its memory. The backend records them in
|
|
320
|
+
`shares.json` beside the snapshot, and a restore uses that record instead of the
|
|
321
|
+
start options: a snapshot holds its devices, so a share added since joins the
|
|
322
|
+
desk at its next cold boot (`host.shutdown` forces one), and a backend refuses a
|
|
323
|
+
restore plan whose shares differ from the record. A recorded share whose
|
|
324
|
+
directory no longer exists fails the restore; the host discards the snapshot,
|
|
325
|
+
cold boots with the current shares, and reports it in `lastError`. A snapshot
|
|
326
|
+
holds no share contents. On Cloud Hypervisor it carries each daemon's state,
|
|
327
|
+
which the new daemon loads on restore by reopening every file and directory the
|
|
328
|
+
guest held by its path, so open files survive. Paths are the link: a file the
|
|
329
|
+
host unlinks or moves while the desk is parked returns errors to the guest for
|
|
330
|
+
that file only, and a file the host puts at its path is the one the guest gets.
|
|
261
331
|
|
|
262
332
|
## macOS Virtualization.framework backend
|
|
263
333
|
|
package/backend.d.ts
CHANGED
|
@@ -184,6 +184,10 @@ export interface CloudHypervisorBackendOptions {
|
|
|
184
184
|
reconnectRetryDelayMs?: number;
|
|
185
185
|
killGraceMs?: number;
|
|
186
186
|
guestAgentPort?: number;
|
|
187
|
+
/** Whether a share daemon's socket exists yet; injectable for tests. */
|
|
188
|
+
pathExists?: (path: string) => boolean;
|
|
189
|
+
/** Removes a stale share socket before its daemon starts; injectable for tests. */
|
|
190
|
+
removeFile?: (path: string) => Promise<void>;
|
|
187
191
|
}
|
|
188
192
|
export declare function createCloudHypervisorBackend(options?: CloudHypervisorBackendOptions): DeskBackend;
|
|
189
193
|
/**
|
package/backend.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"backend.d.ts","sourceRoot":"","sources":["../src/backend.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"backend.d.ts","sourceRoot":"","sources":["../src/backend.ts"],"names":[],"mappings":"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"}
|
package/backend.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { rm } from 'node:fs/promises';
|
|
2
4
|
import { request as httpRequest } from 'node:http';
|
|
3
5
|
import { GUEST_AGENT_VSOCK_PORT } from './protocol.js';
|
|
4
|
-
import { DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_CONNECT_RETRY_DELAY_MS, DEFAULT_HANDSHAKE_TIMEOUT_MS, DEFAULT_RECONNECT_WINDOW_MS, DEFAULT_RECONNECT_RETRY_DELAY_MS, DEFAULT_KILL_GRACE_MS, RECONNECT_BACKOFF_CEILING, connectWithRetry, createGuestMachine, defaultLauncher, defaultConnect, drainOutput, watchExit, terminate, runToCompletion, errorMessage, } from './machine.js';
|
|
5
|
-
import { DeskError } from './plan.js';
|
|
6
|
+
import { DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_CONNECT_RETRY_DELAY_MS, DEFAULT_HANDSHAKE_TIMEOUT_MS, DEFAULT_RECONNECT_WINDOW_MS, DEFAULT_RECONNECT_RETRY_DELAY_MS, DEFAULT_KILL_GRACE_MS, RECONNECT_BACKOFF_CEILING, connectWithRetry, createGuestMachine, defaultLauncher, defaultConnect, drainOutput, watchExit, terminate, runToCompletion, errorMessage, assertShareManifest, writeShareManifest, } from './machine.js';
|
|
7
|
+
import { DeskError, } from './plan.js';
|
|
6
8
|
/**
|
|
7
9
|
* A request that was in flight when the guest connection dropped.
|
|
8
10
|
*
|
|
@@ -31,6 +33,63 @@ export function createCloudHypervisorBackend(options = {}) {
|
|
|
31
33
|
const reconnectRetryDelayMs = options.reconnectRetryDelayMs ?? DEFAULT_RECONNECT_RETRY_DELAY_MS;
|
|
32
34
|
const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
|
|
33
35
|
const guestAgentPort = options.guestAgentPort ?? GUEST_AGENT_VSOCK_PORT;
|
|
36
|
+
const pathExists = options.pathExists ?? existsSync;
|
|
37
|
+
const removeFile = options.removeFile ?? ((path) => rm(path, { force: true }));
|
|
38
|
+
/**
|
|
39
|
+
* Start one `virtiofsd` per share and wait until every socket exists.
|
|
40
|
+
*
|
|
41
|
+
* The VMM connects to these at boot and at restore alike, so they come up
|
|
42
|
+
* first. A stale socket is removed beforehand, or its presence would read as
|
|
43
|
+
* a daemon that is listening. Existence is the readiness signal rather than
|
|
44
|
+
* a probe connection, because `virtiofsd` serves exactly one connection and
|
|
45
|
+
* exits when it closes; the moment between bind and listen is covered by
|
|
46
|
+
* Cloud Hypervisor retrying a refused vhost-user connect.
|
|
47
|
+
*/
|
|
48
|
+
async function startShareDaemons(plan) {
|
|
49
|
+
const daemons = [];
|
|
50
|
+
const stop = async () => {
|
|
51
|
+
await Promise.all(daemons.map((daemon) => terminate(daemon.child, daemon.exit, killGraceMs)));
|
|
52
|
+
};
|
|
53
|
+
try {
|
|
54
|
+
for (const share of plan.shares) {
|
|
55
|
+
await removeFile(share.socketPath);
|
|
56
|
+
const child = launcher(share.daemon.command, share.daemon.args, { identity: plan.launcherIdentity });
|
|
57
|
+
daemons.push({ tag: share.tag, child, exit: watchExit(child), log: drainOutput(child) });
|
|
58
|
+
}
|
|
59
|
+
const deadline = now() + connectTimeoutMs;
|
|
60
|
+
for (;;) {
|
|
61
|
+
const exited = daemons.find((daemon) => daemon.exit.done);
|
|
62
|
+
if (exited) {
|
|
63
|
+
const said = exited.log.tail();
|
|
64
|
+
throw new DeskError(`virtiofsd for share ${exited.tag} exited before it listened${said === '' ? '.' : `: ${said}`}`);
|
|
65
|
+
}
|
|
66
|
+
const waiting = plan.shares.filter((share) => !pathExists(share.socketPath));
|
|
67
|
+
if (waiting.length === 0)
|
|
68
|
+
break;
|
|
69
|
+
if (now() >= deadline) {
|
|
70
|
+
throw new DeskError(`virtiofsd did not create ${waiting.map((share) => share.socketPath).join(', ')} in ${connectTimeoutMs}ms.`);
|
|
71
|
+
}
|
|
72
|
+
// A held timer: the wait is bounded by the deadline, and nothing else may be keeping
|
|
73
|
+
// the process alive while a daemon starts.
|
|
74
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, connectRetryDelayMs));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
await stop();
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
stop,
|
|
83
|
+
// The VMM cannot serve a share whose daemon is gone; neither can it be
|
|
84
|
+
// handed a new one mid-run. So a daemon that dies stops the VMM, and the
|
|
85
|
+
// desk is lost loudly instead of hanging every guest access to the share.
|
|
86
|
+
supervise: (vmm, vmmExit) => {
|
|
87
|
+
for (const daemon of daemons) {
|
|
88
|
+
void daemon.exit.promise.then(() => terminate(vmm, vmmExit, killGraceMs));
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
34
93
|
async function boot(plan, hooks) {
|
|
35
94
|
if (plan.hypervisor !== 'cloud-hypervisor')
|
|
36
95
|
throw new DeskError('Cloud Hypervisor requires a cloud-hypervisor plan.');
|
|
@@ -42,9 +101,35 @@ export function createCloudHypervisorBackend(options = {}) {
|
|
|
42
101
|
identity: plan.launcherIdentity,
|
|
43
102
|
}), `overlay creation for desk ${plan.deskId}`);
|
|
44
103
|
}
|
|
104
|
+
if (plan.snapshot.restoring) {
|
|
105
|
+
try {
|
|
106
|
+
await assertShareManifest(plan.snapshot.dir, plan.shares);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
throw new DeskError(`Desk ${plan.deskId} could not be restored from ${plan.snapshot.dir}: ${errorMessage(error)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
let shares;
|
|
113
|
+
try {
|
|
114
|
+
shares = await startShareDaemons(plan);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
throw new DeskError(`Desk ${plan.deskId} could not start its shares: ${errorMessage(error)}`);
|
|
118
|
+
}
|
|
45
119
|
const child = launcher(plan.vmm.command, plan.vmm.args, { identity: plan.launcherIdentity });
|
|
46
120
|
const log = drainOutput(child);
|
|
47
121
|
const exit = watchExit(child);
|
|
122
|
+
shares.supervise(child, exit);
|
|
123
|
+
// The VMM first: a daemon outliving it is harmless, while a daemon stopped
|
|
124
|
+
// under a running VMM is a share failing in the guest.
|
|
125
|
+
const stop = async () => {
|
|
126
|
+
try {
|
|
127
|
+
await terminate(child, exit, killGraceMs);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
await shares.stop();
|
|
131
|
+
}
|
|
132
|
+
};
|
|
48
133
|
// A restored guest comes back PAUSED (see the `resume=false` reasoning in
|
|
49
134
|
// the plan). Three things have to happen in this order and nowhere else:
|
|
50
135
|
// prove the restore finished, let the caller destroy the snapshot while the
|
|
@@ -56,7 +141,7 @@ export function createCloudHypervisorBackend(options = {}) {
|
|
|
56
141
|
await vmmApi(plan.api.socketPath, '/api/v1/vm.resume');
|
|
57
142
|
}
|
|
58
143
|
catch (error) {
|
|
59
|
-
await
|
|
144
|
+
await stop();
|
|
60
145
|
const said = log.tail();
|
|
61
146
|
const detail = said === '' ? '' : ` The VMM said: ${said}`;
|
|
62
147
|
throw new DeskError(`Desk ${plan.deskId} could not be restored from ${plan.snapshot.dir}: ${errorMessage(error)}${detail}`);
|
|
@@ -76,7 +161,7 @@ export function createCloudHypervisorBackend(options = {}) {
|
|
|
76
161
|
});
|
|
77
162
|
}
|
|
78
163
|
catch (error) {
|
|
79
|
-
await
|
|
164
|
+
await stop();
|
|
80
165
|
const said = log.tail();
|
|
81
166
|
const detail = said === '' ? '' : ` The VMM said: ${said}`;
|
|
82
167
|
throw error instanceof DeskError
|
|
@@ -107,14 +192,15 @@ export function createCloudHypervisorBackend(options = {}) {
|
|
|
107
192
|
now,
|
|
108
193
|
idFactory,
|
|
109
194
|
requestTimeoutMs,
|
|
110
|
-
stop
|
|
195
|
+
stop,
|
|
111
196
|
park: async (snapshotDir) => {
|
|
112
197
|
try {
|
|
113
198
|
await vmmApi(plan.api.socketPath, '/api/v1/vm.pause');
|
|
114
199
|
await vmmApi(plan.api.socketPath, '/api/v1/vm.snapshot', { destination_url: `file://${snapshotDir}` });
|
|
200
|
+
await writeShareManifest(snapshotDir, plan.shares);
|
|
115
201
|
}
|
|
116
202
|
finally {
|
|
117
|
-
await
|
|
203
|
+
await stop();
|
|
118
204
|
}
|
|
119
205
|
},
|
|
120
206
|
});
|
package/backend.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"backend.js","sourceRoot":"","sources":["../src/backend.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomUUID } from 'node:crypto'\nimport { request as httpRequest } from 'node:http'\nimport { GUEST_AGENT_VSOCK_PORT } from './protocol'\nimport {\n DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_CONNECT_RETRY_DELAY_MS,\n DEFAULT_HANDSHAKE_TIMEOUT_MS, DEFAULT_RECONNECT_WINDOW_MS, DEFAULT_RECONNECT_RETRY_DELAY_MS,\n DEFAULT_KILL_GRACE_MS, RECONNECT_BACKOFF_CEILING, connectWithRetry, createGuestMachine,\n defaultLauncher, defaultConnect, drainOutput, watchExit, terminate, runToCompletion, errorMessage,\n type ExitWatcher,\n} from './machine'\n/**\n * The backend port.\n *\n * `DeskBackend` is the seam that keeps the package testable without a\n * hypervisor: the host asks a backend to boot a `DeskLaunchPlan` and gets a\n * `DeskMachine` — a request/response channel to the in-guest agent plus an\n * event subscription. CI and non-KVM developer machines inject an in-memory\n * fake; production uses `cloudHypervisorBackend` on Linux or\n * `createVirtualizationBackend` on an Apple silicon Mac, whose process glue is\n * deliberately thin because everything that can be pure lives in the plan\n * builder, the protocol module, and the shared guest channel.\n */\nimport type { ChildProcess } from 'node:child_process'\nimport type { Duplex } from 'node:stream'\nimport { DeskError, type DeskHypervisor, type DeskLaunchPlan, type DeskLauncherIdentity } from './plan'\nimport type { GuestCommand, GuestEventMessage } from './protocol'\n\n/**\n * A request that was in flight when the guest connection dropped.\n *\n * Distinct from a plain `DeskError` because the caller's next move is\n * different: this request may ALREADY HAVE RUN. The frame reached the guest,\n * and whether the guest executed it before the channel went away is not\n * knowable from the host. An `exec` may have sent mail; a `click` may have\n * landed. So the transport refuses to guess — it names the ambiguity and lets\n * the caller decide whether the operation is safe to repeat. Anything that\n * rejects with a plain `DeskError` instead never reached the guest at all.\n */\nexport class DeskRequestFateUnknownError extends DeskError {\n override readonly name: string = 'DeskRequestFateUnknownError'\n}\n\n/**\n * What happened to the transport underneath a machine.\n *\n * `reconnected` means the host lost the guest channel mid-lease and got it\n * back; the desk is usable again. It is NOT a promise that the guest is\n * unchanged — see the note on `onConnectionChange`. `lost` means it will not\n * come back on this VM and the desk needs a fresh boot.\n */\nexport type DeskConnectionChange =\n | {\n state: 'reconnected'\n deskId: string\n /** How the previous connection went away. */\n reason: string\n /** Wall-clock time the desk spent with no channel to its guest. */\n downtimeMs: number\n /** Reconnects this machine has survived, including this one. */\n reconnects: number\n }\n | {\n state: 'lost'\n deskId: string\n /** Why it is gone for good: the VMM exited, or the window ran out. */\n reason: string\n }\n\n/** A booted microVM as the host sees it: one desk, one guest-agent channel. */\nexport interface DeskMachine {\n readonly deskId: string\n /**\n * Send one request to the in-guest agent and await its correlated result.\n *\n * Rejects with `DeskError` when the guest reports an error, the request\n * times out, or the connection is gone for good — in that last case the\n * request never left the host, so repeating it is safe.\n *\n * A connection lost mid-lease is re-established rather than fatal: a call\n * that arrives while that is happening WAITS for it instead of failing.\n * A call that was already in flight when the channel dropped rejects with\n * `DeskRequestFateUnknownError`, because the host cannot tell whether the\n * guest ran it.\n */\n request(command: GuestCommand, options?: { timeoutMs?: number }): Promise<unknown>\n /**\n * Subscribe to unsolicited guest events (job exits, frames, focus changes).\n *\n * Subscriptions belong to the machine, not to the socket under it, so they\n * survive a reconnect. What does NOT survive is the guest's own capture\n * state: a guest agent that restarted has no screen, no frame encoder and\n * no video encoder, and a subscriber will simply stop receiving. Watch\n * `onConnectionChange` to notice.\n */\n subscribe(listener: (event: GuestEventMessage) => void): () => void\n /**\n * Watch the transport under this machine.\n *\n * Optional: a backend whose channel cannot drop has nothing to report. The\n * default backend reports every reconnect, because a reconnect that leaves\n * no trace turns a guest agent restarting every few minutes into a desk that\n * misbehaves for reasons nobody can find.\n */\n onConnectionChange?(listener: (change: DeskConnectionChange) => void): () => void\n /**\n * Open a TCP stream from the host to `port` on the guest.\n *\n * Optional: a backend whose guest has a routable address (Cloud Hypervisor's\n * TAP) leaves this out and the host dials the guest address directly. A\n * backend whose guest sits behind a userspace NAT (Virtualization.framework)\n * provides it, because there is no address on the Mac to dial. Rejects with\n * `DeskError` when the guest refuses or does not answer.\n */\n connect?(port: number): Promise<Duplex>\n /** Stop the VM. Idempotent; resolves once the VMM process has exited. */\n shutdown(): Promise<void>\n /**\n * Park the VM with its memory, so resuming continues the process tree.\n *\n * Optional: a backend with no snapshot facility simply does not offer it, and\n * the host falls back to `shutdown()`. Pauses the guest, writes a snapshot to\n * `snapshotDir`, then stops the VMM — and resolves only once all three have\n * happened, because a caller that believed a snapshot exists when it does not\n * would later refuse to boot the desk at all.\n *\n * The caller is responsible for quiescing the guest filesystem first. A paused\n * guest still holds dirty pages that exist only in the snapshot, so a disk\n * parked without a prior `sync` is consistent only *with that snapshot* —\n * which matters the moment the snapshot is discarded.\n */\n park?(snapshotDir: string): Promise<void>\n}\n\nexport interface DeskBootHooks {\n /**\n * Called on a restore, after the snapshot has been read back and before the\n * guest is allowed to run.\n *\n * This is the window in which the snapshot must be destroyed. A memory image\n * is valid only against the disk it was taken with, so once the guest resumes\n * and writes, any surviving snapshot describes a past that no longer matches —\n * and replaying it later would corrupt the desk. Restoring paused and\n * invalidating here is what makes that impossible rather than unlikely.\n *\n * Throwing aborts the restore: the VM is stopped and the caller is free to\n * cold boot, which is safe because a paused guest has written nothing.\n */\n onRestored?: () => Promise<void>\n}\n\nexport interface DeskBackend {\n /**\n * Which hypervisor this backend boots, and so which plan the host builds\n * for it. Absent means Cloud Hypervisor.\n */\n readonly hypervisor?: DeskHypervisor\n /**\n * The binary the backend launches — the VMM or the helper — for readiness\n * reporting. Optional; `verifyDeskHost` falls back to its own option.\n */\n readonly vmmPath?: string\n boot(plan: DeskLaunchPlan, hooks?: DeskBootHooks): Promise<DeskMachine>\n}\n\nexport type DeskProcessLauncher = (\n command: string,\n args: readonly string[],\n options: { identity?: DeskLauncherIdentity },\n) => ChildProcess\n\nexport interface CloudHypervisorBackendOptions {\n /** Spawns the VMM and image tooling; injectable for tests. */\n launcher?: DeskProcessLauncher\n /** Opens the host side of the vsock Unix socket; injectable for tests. */\n connect?: (socketPath: string) => Duplex\n platform?: NodeJS.Platform\n /**\n * Clock behind every connect and reconnect deadline. Injectable for the same\n * reason `DeskHostOptions.now` is: a test drives a window to its end by\n * moving the clock, rather than sleeping for it and hoping the machine it\n * runs on is fast enough.\n */\n now?: () => number\n idFactory?: () => string\n requestTimeoutMs?: number\n connectTimeoutMs?: number\n connectRetryDelayMs?: number\n /** Bound on a single handshake attempt; see attemptHandshake. */\n handshakeTimeoutMs?: number\n /**\n * How long a machine keeps trying to get its guest channel back after an\n * unexpected drop before declaring the desk lost. Bounded on purpose: the\n * VMM is still alive so a reconnect is cheap and usually fast, but retrying\n * forever against a guest that is never coming back is worse than failing.\n */\n reconnectWindowMs?: number\n /**\n * First delay between reconnect attempts. It doubles toward a ceiling, so\n * this is the fast first poll rather than the steady rate. Separate from\n * `connectRetryDelayMs`, which is sized for a guest that is still cold\n * booting; here the guest was up a moment ago.\n */\n reconnectRetryDelayMs?: number\n killGraceMs?: number\n guestAgentPort?: number\n}\n\nexport function createCloudHypervisorBackend(\n options: CloudHypervisorBackendOptions = {},\n): DeskBackend {\n const launcher = options.launcher ?? defaultLauncher\n const connect = options.connect ?? defaultConnect\n const platform = options.platform ?? process.platform\n const now = options.now ?? Date.now\n const idFactory = options.idFactory ?? randomUUID\n const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS\n const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS\n const connectRetryDelayMs = options.connectRetryDelayMs ?? DEFAULT_CONNECT_RETRY_DELAY_MS\n const handshakeTimeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS\n const reconnectWindowMs = options.reconnectWindowMs ?? DEFAULT_RECONNECT_WINDOW_MS\n const reconnectRetryDelayMs = options.reconnectRetryDelayMs ?? DEFAULT_RECONNECT_RETRY_DELAY_MS\n const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS\n const guestAgentPort = options.guestAgentPort ?? GUEST_AGENT_VSOCK_PORT\n\n async function boot(plan: DeskLaunchPlan, hooks?: DeskBootHooks): Promise<DeskMachine> {\n if (plan.hypervisor !== 'cloud-hypervisor') throw new DeskError('Cloud Hypervisor requires a cloud-hypervisor plan.')\n if (platform !== 'linux') {\n throw new DeskError(`The AppKit desk requires Linux; received ${platform}.`)\n }\n if (plan.overlay.create) {\n await runToCompletion(\n launcher(plan.overlay.create.command, plan.overlay.create.args, {\n identity: plan.launcherIdentity,\n }),\n `overlay creation for desk ${plan.deskId}`,\n )\n }\n\n const child = launcher(plan.vmm.command, plan.vmm.args, { identity: plan.launcherIdentity })\n const log = drainOutput(child)\n const exit = watchExit(child)\n\n // A restored guest comes back PAUSED (see the `resume=false` reasoning in\n // the plan). Three things have to happen in this order and nowhere else:\n // prove the restore finished, let the caller destroy the snapshot while the\n // guest still cannot write, then start it.\n if (plan.snapshot.restoring) {\n try {\n await waitForVmm(vmmApi, plan.api.socketPath, { now, exited: exit, timeoutMs: connectTimeoutMs })\n await hooks?.onRestored?.()\n await vmmApi(plan.api.socketPath, '/api/v1/vm.resume')\n } catch (error) {\n await terminate(child, exit, killGraceMs)\n const said = log.tail()\n const detail = said === '' ? '' : ` The VMM said: ${said}`\n throw new DeskError(\n `Desk ${plan.deskId} could not be restored from ${plan.snapshot.dir}: ${errorMessage(error)}${detail}`,\n )\n }\n }\n\n let stream: Duplex\n try {\n stream = await connectWithRetry({\n connect,\n now,\n socketPath: plan.vsock.socketPath,\n port: guestAgentPort,\n timeoutMs: connectTimeoutMs,\n retryDelayMs: connectRetryDelayMs,\n attemptTimeoutMs: handshakeTimeoutMs,\n exited: exit,\n })\n } catch (error) {\n await terminate(child, exit, killGraceMs)\n const said = log.tail()\n const detail = said === '' ? '' : ` The VMM said: ${said}`\n throw error instanceof DeskError\n ? new DeskError(`${error.message}${detail}`)\n : new DeskError(\n `Could not reach the guest agent for desk ${plan.deskId}: ${errorMessage(error)}${detail}`,\n )\n }\n return createGuestMachine({\n deskId: plan.deskId,\n exit,\n stream,\n // The same path the first connection took, deliberately: a reconnect\n // that skipped confirmGuest would adopt the very hollow handshake the\n // boot path learned not to trust.\n reconnect: (abandoned) =>\n connectWithRetry({\n connect,\n now,\n socketPath: plan.vsock.socketPath,\n port: guestAgentPort,\n timeoutMs: reconnectWindowMs,\n retryDelayMs: reconnectRetryDelayMs,\n maxRetryDelayMs: reconnectRetryDelayMs * RECONNECT_BACKOFF_CEILING,\n attemptTimeoutMs: handshakeTimeoutMs,\n exited: exit,\n exitedMessage: `the VMM for desk ${plan.deskId} exited.`,\n abandoned,\n }),\n reconnectWindowMs,\n now,\n idFactory,\n requestTimeoutMs,\n stop: () => terminate(child, exit, killGraceMs),\n park: async (snapshotDir) => {\n try {\n await vmmApi(plan.api.socketPath, '/api/v1/vm.pause')\n await vmmApi(plan.api.socketPath, '/api/v1/vm.snapshot', { destination_url: `file://${snapshotDir}` })\n } finally {\n await terminate(child, exit, killGraceMs)\n }\n },\n })\n }\n\n return { boot }\n}\n\n/**\n * Cloud Hypervisor's control plane: HTTP/1.1 over the `--api-socket` Unix\n * socket. The plan has always created that socket; until now nothing spoke to\n * it, because stopping a VM needed nothing more than killing the process.\n *\n * Pausing and snapshotting do need it, and `ch-remote` — the tool that usually\n * does this — is not in the runner image. Writing the two requests by hand is a\n * smaller change than adding a binary to the image, and keeps the whole\n * lifecycle inside one process that already owns the socket path.\n */\nexport type VmmApiCall = (\n socketPath: string,\n path: string,\n body?: Record<string, unknown>,\n method?: 'PUT' | 'GET',\n) => Promise<void>\n\nconst vmmApi: VmmApiCall = async (socketPath, path, body, method = 'PUT') => {\n const payload = body === undefined ? undefined : Buffer.from(JSON.stringify(body), 'utf8')\n await new Promise<void>((resolve, reject) => {\n const request = httpRequest(\n {\n socketPath,\n path,\n method,\n headers: {\n ...(payload ? { 'content-type': 'application/json', 'content-length': String(payload.length) } : {}),\n },\n },\n (response) => {\n // CH answers 204 with no body on success. Drain regardless: an unread\n // response keeps the socket open and the next call queues behind it.\n const chunks: Buffer[] = []\n response.on('data', (chunk: Buffer) => chunks.push(chunk))\n response.on('end', () => {\n const status = response.statusCode ?? 0\n if (status >= 200 && status < 300) {\n resolve()\n return\n }\n const said = Buffer.concat(chunks).toString('utf8').trim().slice(0, 400)\n reject(new DeskError(`${path} returned ${status}${said === '' ? '' : `: ${said}`}`))\n })\n },\n )\n request.on('error', (error) => reject(new DeskError(`${path} failed: ${errorMessage(error)}`)))\n if (payload) request.write(payload)\n request.end()\n })\n}\n\n/**\n * Wait until the VMM answers its control socket.\n *\n * On a restore this is also the only honest signal that the snapshot was read\n * back: Cloud Hypervisor creates the API socket early but does not serve\n * `vm.info` until it holds a VM, so a successful call means the restore\n * completed rather than merely that the process started. A VMM that exits\n * instead — a corrupt snapshot, a config the host can no longer satisfy — stops\n * the wait immediately rather than burning the whole timeout.\n */\nasync function waitForVmm(\n call: VmmApiCall,\n socketPath: string,\n options: { now: () => number; exited: ExitWatcher; timeoutMs: number; retryDelayMs?: number },\n): Promise<void> {\n const deadline = options.now() + options.timeoutMs\n const retryDelayMs = options.retryDelayMs ?? 50\n let lastFailure = 'it never answered'\n for (;;) {\n if (options.exited.done) throw new DeskError(`the VMM exited before it served its control socket`)\n try {\n await call(socketPath, '/api/v1/vm.info', undefined, 'GET')\n return\n } catch (error) {\n lastFailure = errorMessage(error)\n }\n if (options.now() >= deadline) {\n throw new DeskError(`the VMM did not answer ${socketPath} in ${options.timeoutMs}ms: ${lastFailure}`)\n }\n await new Promise((resolve) => setTimeout(resolve, retryDelayMs))\n }\n}\n\n/** The default backend: real Cloud Hypervisor over a real vsock socket. */\nexport const cloudHypervisorBackend: DeskBackend = {\n boot: (plan, hooks) => createCloudHypervisorBackend().boot(plan, hooks),\n}\n"]}
|
|
1
|
+
{"version":3,"file":"backend.js","sourceRoot":"","sources":["../src/backend.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomUUID } from 'node:crypto'\nimport { existsSync } from 'node:fs'\nimport { rm } from 'node:fs/promises'\nimport { request as httpRequest } from 'node:http'\nimport { GUEST_AGENT_VSOCK_PORT } from './protocol'\nimport {\n DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_CONNECT_RETRY_DELAY_MS,\n DEFAULT_HANDSHAKE_TIMEOUT_MS, DEFAULT_RECONNECT_WINDOW_MS, DEFAULT_RECONNECT_RETRY_DELAY_MS,\n DEFAULT_KILL_GRACE_MS, RECONNECT_BACKOFF_CEILING, connectWithRetry, createGuestMachine,\n defaultLauncher, defaultConnect, drainOutput, watchExit, terminate, runToCompletion, errorMessage,\n assertShareManifest, writeShareManifest, type ExitWatcher,\n} from './machine'\n/**\n * The backend port.\n *\n * `DeskBackend` is the seam that keeps the package testable without a\n * hypervisor: the host asks a backend to boot a `DeskLaunchPlan` and gets a\n * `DeskMachine` — a request/response channel to the in-guest agent plus an\n * event subscription. CI and non-KVM developer machines inject an in-memory\n * fake; production uses `cloudHypervisorBackend` on Linux or\n * `createVirtualizationBackend` on an Apple silicon Mac, whose process glue is\n * deliberately thin because everything that can be pure lives in the plan\n * builder, the protocol module, and the shared guest channel.\n */\nimport type { ChildProcess } from 'node:child_process'\nimport type { Duplex } from 'node:stream'\nimport {\n DeskError, type CloudHypervisorLaunchPlan, type DeskHypervisor, type DeskLaunchPlan, type DeskLauncherIdentity,\n} from './plan'\nimport type { GuestCommand, GuestEventMessage } from './protocol'\n\n/**\n * A request that was in flight when the guest connection dropped.\n *\n * Distinct from a plain `DeskError` because the caller's next move is\n * different: this request may ALREADY HAVE RUN. The frame reached the guest,\n * and whether the guest executed it before the channel went away is not\n * knowable from the host. An `exec` may have sent mail; a `click` may have\n * landed. So the transport refuses to guess — it names the ambiguity and lets\n * the caller decide whether the operation is safe to repeat. Anything that\n * rejects with a plain `DeskError` instead never reached the guest at all.\n */\nexport class DeskRequestFateUnknownError extends DeskError {\n override readonly name: string = 'DeskRequestFateUnknownError'\n}\n\n/**\n * What happened to the transport underneath a machine.\n *\n * `reconnected` means the host lost the guest channel mid-lease and got it\n * back; the desk is usable again. It is NOT a promise that the guest is\n * unchanged — see the note on `onConnectionChange`. `lost` means it will not\n * come back on this VM and the desk needs a fresh boot.\n */\nexport type DeskConnectionChange =\n | {\n state: 'reconnected'\n deskId: string\n /** How the previous connection went away. */\n reason: string\n /** Wall-clock time the desk spent with no channel to its guest. */\n downtimeMs: number\n /** Reconnects this machine has survived, including this one. */\n reconnects: number\n }\n | {\n state: 'lost'\n deskId: string\n /** Why it is gone for good: the VMM exited, or the window ran out. */\n reason: string\n }\n\n/** A booted microVM as the host sees it: one desk, one guest-agent channel. */\nexport interface DeskMachine {\n readonly deskId: string\n /**\n * Send one request to the in-guest agent and await its correlated result.\n *\n * Rejects with `DeskError` when the guest reports an error, the request\n * times out, or the connection is gone for good — in that last case the\n * request never left the host, so repeating it is safe.\n *\n * A connection lost mid-lease is re-established rather than fatal: a call\n * that arrives while that is happening WAITS for it instead of failing.\n * A call that was already in flight when the channel dropped rejects with\n * `DeskRequestFateUnknownError`, because the host cannot tell whether the\n * guest ran it.\n */\n request(command: GuestCommand, options?: { timeoutMs?: number }): Promise<unknown>\n /**\n * Subscribe to unsolicited guest events (job exits, frames, focus changes).\n *\n * Subscriptions belong to the machine, not to the socket under it, so they\n * survive a reconnect. What does NOT survive is the guest's own capture\n * state: a guest agent that restarted has no screen, no frame encoder and\n * no video encoder, and a subscriber will simply stop receiving. Watch\n * `onConnectionChange` to notice.\n */\n subscribe(listener: (event: GuestEventMessage) => void): () => void\n /**\n * Watch the transport under this machine.\n *\n * Optional: a backend whose channel cannot drop has nothing to report. The\n * default backend reports every reconnect, because a reconnect that leaves\n * no trace turns a guest agent restarting every few minutes into a desk that\n * misbehaves for reasons nobody can find.\n */\n onConnectionChange?(listener: (change: DeskConnectionChange) => void): () => void\n /**\n * Open a TCP stream from the host to `port` on the guest.\n *\n * Optional: a backend whose guest has a routable address (Cloud Hypervisor's\n * TAP) leaves this out and the host dials the guest address directly. A\n * backend whose guest sits behind a userspace NAT (Virtualization.framework)\n * provides it, because there is no address on the Mac to dial. Rejects with\n * `DeskError` when the guest refuses or does not answer.\n */\n connect?(port: number): Promise<Duplex>\n /** Stop the VM. Idempotent; resolves once the VMM process has exited. */\n shutdown(): Promise<void>\n /**\n * Park the VM with its memory, so resuming continues the process tree.\n *\n * Optional: a backend with no snapshot facility simply does not offer it, and\n * the host falls back to `shutdown()`. Pauses the guest, writes a snapshot to\n * `snapshotDir`, then stops the VMM — and resolves only once all three have\n * happened, because a caller that believed a snapshot exists when it does not\n * would later refuse to boot the desk at all.\n *\n * The caller is responsible for quiescing the guest filesystem first. A paused\n * guest still holds dirty pages that exist only in the snapshot, so a disk\n * parked without a prior `sync` is consistent only *with that snapshot* —\n * which matters the moment the snapshot is discarded.\n */\n park?(snapshotDir: string): Promise<void>\n}\n\nexport interface DeskBootHooks {\n /**\n * Called on a restore, after the snapshot has been read back and before the\n * guest is allowed to run.\n *\n * This is the window in which the snapshot must be destroyed. A memory image\n * is valid only against the disk it was taken with, so once the guest resumes\n * and writes, any surviving snapshot describes a past that no longer matches —\n * and replaying it later would corrupt the desk. Restoring paused and\n * invalidating here is what makes that impossible rather than unlikely.\n *\n * Throwing aborts the restore: the VM is stopped and the caller is free to\n * cold boot, which is safe because a paused guest has written nothing.\n */\n onRestored?: () => Promise<void>\n}\n\nexport interface DeskBackend {\n /**\n * Which hypervisor this backend boots, and so which plan the host builds\n * for it. Absent means Cloud Hypervisor.\n */\n readonly hypervisor?: DeskHypervisor\n /**\n * The binary the backend launches — the VMM or the helper — for readiness\n * reporting. Optional; `verifyDeskHost` falls back to its own option.\n */\n readonly vmmPath?: string\n boot(plan: DeskLaunchPlan, hooks?: DeskBootHooks): Promise<DeskMachine>\n}\n\nexport type DeskProcessLauncher = (\n command: string,\n args: readonly string[],\n options: { identity?: DeskLauncherIdentity },\n) => ChildProcess\n\nexport interface CloudHypervisorBackendOptions {\n /** Spawns the VMM and image tooling; injectable for tests. */\n launcher?: DeskProcessLauncher\n /** Opens the host side of the vsock Unix socket; injectable for tests. */\n connect?: (socketPath: string) => Duplex\n platform?: NodeJS.Platform\n /**\n * Clock behind every connect and reconnect deadline. Injectable for the same\n * reason `DeskHostOptions.now` is: a test drives a window to its end by\n * moving the clock, rather than sleeping for it and hoping the machine it\n * runs on is fast enough.\n */\n now?: () => number\n idFactory?: () => string\n requestTimeoutMs?: number\n connectTimeoutMs?: number\n connectRetryDelayMs?: number\n /** Bound on a single handshake attempt; see attemptHandshake. */\n handshakeTimeoutMs?: number\n /**\n * How long a machine keeps trying to get its guest channel back after an\n * unexpected drop before declaring the desk lost. Bounded on purpose: the\n * VMM is still alive so a reconnect is cheap and usually fast, but retrying\n * forever against a guest that is never coming back is worse than failing.\n */\n reconnectWindowMs?: number\n /**\n * First delay between reconnect attempts. It doubles toward a ceiling, so\n * this is the fast first poll rather than the steady rate. Separate from\n * `connectRetryDelayMs`, which is sized for a guest that is still cold\n * booting; here the guest was up a moment ago.\n */\n reconnectRetryDelayMs?: number\n killGraceMs?: number\n guestAgentPort?: number\n /** Whether a share daemon's socket exists yet; injectable for tests. */\n pathExists?: (path: string) => boolean\n /** Removes a stale share socket before its daemon starts; injectable for tests. */\n removeFile?: (path: string) => Promise<void>\n}\n\nexport function createCloudHypervisorBackend(\n options: CloudHypervisorBackendOptions = {},\n): DeskBackend {\n const launcher = options.launcher ?? defaultLauncher\n const connect = options.connect ?? defaultConnect\n const platform = options.platform ?? process.platform\n const now = options.now ?? Date.now\n const idFactory = options.idFactory ?? randomUUID\n const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS\n const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS\n const connectRetryDelayMs = options.connectRetryDelayMs ?? DEFAULT_CONNECT_RETRY_DELAY_MS\n const handshakeTimeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS\n const reconnectWindowMs = options.reconnectWindowMs ?? DEFAULT_RECONNECT_WINDOW_MS\n const reconnectRetryDelayMs = options.reconnectRetryDelayMs ?? DEFAULT_RECONNECT_RETRY_DELAY_MS\n const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS\n const guestAgentPort = options.guestAgentPort ?? GUEST_AGENT_VSOCK_PORT\n const pathExists = options.pathExists ?? existsSync\n const removeFile = options.removeFile ?? ((path: string) => rm(path, { force: true }))\n\n /**\n * Start one `virtiofsd` per share and wait until every socket exists.\n *\n * The VMM connects to these at boot and at restore alike, so they come up\n * first. A stale socket is removed beforehand, or its presence would read as\n * a daemon that is listening. Existence is the readiness signal rather than\n * a probe connection, because `virtiofsd` serves exactly one connection and\n * exits when it closes; the moment between bind and listen is covered by\n * Cloud Hypervisor retrying a refused vhost-user connect.\n */\n async function startShareDaemons(plan: CloudHypervisorLaunchPlan): Promise<ShareDaemons> {\n const daemons: { tag: string; child: ChildProcess; exit: ExitWatcher; log: { tail: () => string } }[] = []\n const stop = async () => {\n await Promise.all(daemons.map((daemon) => terminate(daemon.child, daemon.exit, killGraceMs)))\n }\n try {\n for (const share of plan.shares) {\n await removeFile(share.socketPath)\n const child = launcher(share.daemon.command, share.daemon.args, { identity: plan.launcherIdentity })\n daemons.push({ tag: share.tag, child, exit: watchExit(child), log: drainOutput(child) })\n }\n const deadline = now() + connectTimeoutMs\n for (;;) {\n const exited = daemons.find((daemon) => daemon.exit.done)\n if (exited) {\n const said = exited.log.tail()\n throw new DeskError(`virtiofsd for share ${exited.tag} exited before it listened${said === '' ? '.' : `: ${said}`}`)\n }\n const waiting = plan.shares.filter((share) => !pathExists(share.socketPath))\n if (waiting.length === 0) break\n if (now() >= deadline) {\n throw new DeskError(\n `virtiofsd did not create ${waiting.map((share) => share.socketPath).join(', ')} in ${connectTimeoutMs}ms.`,\n )\n }\n // A held timer: the wait is bounded by the deadline, and nothing else may be keeping\n // the process alive while a daemon starts.\n await new Promise<void>((resolvePromise) => setTimeout(resolvePromise, connectRetryDelayMs))\n }\n } catch (error) {\n await stop()\n throw error\n }\n return {\n stop,\n // The VMM cannot serve a share whose daemon is gone; neither can it be\n // handed a new one mid-run. So a daemon that dies stops the VMM, and the\n // desk is lost loudly instead of hanging every guest access to the share.\n supervise: (vmm, vmmExit) => {\n for (const daemon of daemons) {\n void daemon.exit.promise.then(() => terminate(vmm, vmmExit, killGraceMs))\n }\n },\n }\n }\n\n async function boot(plan: DeskLaunchPlan, hooks?: DeskBootHooks): Promise<DeskMachine> {\n if (plan.hypervisor !== 'cloud-hypervisor') throw new DeskError('Cloud Hypervisor requires a cloud-hypervisor plan.')\n if (platform !== 'linux') {\n throw new DeskError(`The AppKit desk requires Linux; received ${platform}.`)\n }\n if (plan.overlay.create) {\n await runToCompletion(\n launcher(plan.overlay.create.command, plan.overlay.create.args, {\n identity: plan.launcherIdentity,\n }),\n `overlay creation for desk ${plan.deskId}`,\n )\n }\n if (plan.snapshot.restoring) {\n try {\n await assertShareManifest(plan.snapshot.dir, plan.shares)\n } catch (error) {\n throw new DeskError(`Desk ${plan.deskId} could not be restored from ${plan.snapshot.dir}: ${errorMessage(error)}`)\n }\n }\n\n let shares: ShareDaemons\n try {\n shares = await startShareDaemons(plan)\n } catch (error) {\n throw new DeskError(`Desk ${plan.deskId} could not start its shares: ${errorMessage(error)}`)\n }\n const child = launcher(plan.vmm.command, plan.vmm.args, { identity: plan.launcherIdentity })\n const log = drainOutput(child)\n const exit = watchExit(child)\n shares.supervise(child, exit)\n // The VMM first: a daemon outliving it is harmless, while a daemon stopped\n // under a running VMM is a share failing in the guest.\n const stop = async () => {\n try {\n await terminate(child, exit, killGraceMs)\n } finally {\n await shares.stop()\n }\n }\n\n // A restored guest comes back PAUSED (see the `resume=false` reasoning in\n // the plan). Three things have to happen in this order and nowhere else:\n // prove the restore finished, let the caller destroy the snapshot while the\n // guest still cannot write, then start it.\n if (plan.snapshot.restoring) {\n try {\n await waitForVmm(vmmApi, plan.api.socketPath, { now, exited: exit, timeoutMs: connectTimeoutMs })\n await hooks?.onRestored?.()\n await vmmApi(plan.api.socketPath, '/api/v1/vm.resume')\n } catch (error) {\n await stop()\n const said = log.tail()\n const detail = said === '' ? '' : ` The VMM said: ${said}`\n throw new DeskError(\n `Desk ${plan.deskId} could not be restored from ${plan.snapshot.dir}: ${errorMessage(error)}${detail}`,\n )\n }\n }\n\n let stream: Duplex\n try {\n stream = await connectWithRetry({\n connect,\n now,\n socketPath: plan.vsock.socketPath,\n port: guestAgentPort,\n timeoutMs: connectTimeoutMs,\n retryDelayMs: connectRetryDelayMs,\n attemptTimeoutMs: handshakeTimeoutMs,\n exited: exit,\n })\n } catch (error) {\n await stop()\n const said = log.tail()\n const detail = said === '' ? '' : ` The VMM said: ${said}`\n throw error instanceof DeskError\n ? new DeskError(`${error.message}${detail}`)\n : new DeskError(\n `Could not reach the guest agent for desk ${plan.deskId}: ${errorMessage(error)}${detail}`,\n )\n }\n return createGuestMachine({\n deskId: plan.deskId,\n exit,\n stream,\n // The same path the first connection took, deliberately: a reconnect\n // that skipped confirmGuest would adopt the very hollow handshake the\n // boot path learned not to trust.\n reconnect: (abandoned) =>\n connectWithRetry({\n connect,\n now,\n socketPath: plan.vsock.socketPath,\n port: guestAgentPort,\n timeoutMs: reconnectWindowMs,\n retryDelayMs: reconnectRetryDelayMs,\n maxRetryDelayMs: reconnectRetryDelayMs * RECONNECT_BACKOFF_CEILING,\n attemptTimeoutMs: handshakeTimeoutMs,\n exited: exit,\n exitedMessage: `the VMM for desk ${plan.deskId} exited.`,\n abandoned,\n }),\n reconnectWindowMs,\n now,\n idFactory,\n requestTimeoutMs,\n stop,\n park: async (snapshotDir) => {\n try {\n await vmmApi(plan.api.socketPath, '/api/v1/vm.pause')\n await vmmApi(plan.api.socketPath, '/api/v1/vm.snapshot', { destination_url: `file://${snapshotDir}` })\n await writeShareManifest(snapshotDir, plan.shares)\n } finally {\n await stop()\n }\n },\n })\n }\n\n return { boot }\n}\n\ninterface ShareDaemons {\n /** Stop every daemon; resolves once all have exited. */\n stop: () => Promise<void>\n /** Stop the VMM if any daemon exits while it runs. */\n supervise: (vmm: ChildProcess, vmmExit: ExitWatcher) => void\n}\n\n/**\n * Cloud Hypervisor's control plane: HTTP/1.1 over the `--api-socket` Unix\n * socket. The plan has always created that socket; until now nothing spoke to\n * it, because stopping a VM needed nothing more than killing the process.\n *\n * Pausing and snapshotting do need it, and `ch-remote` — the tool that usually\n * does this — is not in the runner image. Writing the two requests by hand is a\n * smaller change than adding a binary to the image, and keeps the whole\n * lifecycle inside one process that already owns the socket path.\n */\nexport type VmmApiCall = (\n socketPath: string,\n path: string,\n body?: Record<string, unknown>,\n method?: 'PUT' | 'GET',\n) => Promise<void>\n\nconst vmmApi: VmmApiCall = async (socketPath, path, body, method = 'PUT') => {\n const payload = body === undefined ? undefined : Buffer.from(JSON.stringify(body), 'utf8')\n await new Promise<void>((resolve, reject) => {\n const request = httpRequest(\n {\n socketPath,\n path,\n method,\n headers: {\n ...(payload ? { 'content-type': 'application/json', 'content-length': String(payload.length) } : {}),\n },\n },\n (response) => {\n // CH answers 204 with no body on success. Drain regardless: an unread\n // response keeps the socket open and the next call queues behind it.\n const chunks: Buffer[] = []\n response.on('data', (chunk: Buffer) => chunks.push(chunk))\n response.on('end', () => {\n const status = response.statusCode ?? 0\n if (status >= 200 && status < 300) {\n resolve()\n return\n }\n const said = Buffer.concat(chunks).toString('utf8').trim().slice(0, 400)\n reject(new DeskError(`${path} returned ${status}${said === '' ? '' : `: ${said}`}`))\n })\n },\n )\n request.on('error', (error) => reject(new DeskError(`${path} failed: ${errorMessage(error)}`)))\n if (payload) request.write(payload)\n request.end()\n })\n}\n\n/**\n * Wait until the VMM answers its control socket.\n *\n * On a restore this is also the only honest signal that the snapshot was read\n * back: Cloud Hypervisor creates the API socket early but does not serve\n * `vm.info` until it holds a VM, so a successful call means the restore\n * completed rather than merely that the process started. A VMM that exits\n * instead — a corrupt snapshot, a config the host can no longer satisfy — stops\n * the wait immediately rather than burning the whole timeout.\n */\nasync function waitForVmm(\n call: VmmApiCall,\n socketPath: string,\n options: { now: () => number; exited: ExitWatcher; timeoutMs: number; retryDelayMs?: number },\n): Promise<void> {\n const deadline = options.now() + options.timeoutMs\n const retryDelayMs = options.retryDelayMs ?? 50\n let lastFailure = 'it never answered'\n for (;;) {\n if (options.exited.done) throw new DeskError(`the VMM exited before it served its control socket`)\n try {\n await call(socketPath, '/api/v1/vm.info', undefined, 'GET')\n return\n } catch (error) {\n lastFailure = errorMessage(error)\n }\n if (options.now() >= deadline) {\n throw new DeskError(`the VMM did not answer ${socketPath} in ${options.timeoutMs}ms: ${lastFailure}`)\n }\n await new Promise((resolve) => setTimeout(resolve, retryDelayMs))\n }\n}\n\n/** The default backend: real Cloud Hypervisor over a real vsock socket. */\nexport const cloudHypervisorBackend: DeskBackend = {\n boot: (plan, hooks) => createCloudHypervisorBackend().boot(plan, hooks),\n}\n"]}
|
package/host.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import type { Duplex } from 'node:stream';
|
|
3
|
-
import { type DeskLauncherIdentity, type DeskGuestNetworkOptions } from './plan.js';
|
|
3
|
+
import { type DeskLauncherIdentity, type DeskGuestNetworkOptions, type DeskShare, type DeskVirtiofsdSandbox } from './plan.js';
|
|
4
4
|
import { type DeskBackend } from './backend.js';
|
|
5
5
|
import type { A11yNode, DeskFrameFormat, DeskHandoverScope, DeskPoint, DeskPointerButton, DeskPorts, DeskVideoChunkKind, WindowInfo } from './events.js';
|
|
6
6
|
export declare const DEFAULT_CAPACITY = 4;
|
|
@@ -35,6 +35,13 @@ export interface DeskHostOptions {
|
|
|
35
35
|
launcherIdentity?: DeskLauncherIdentity;
|
|
36
36
|
backend?: DeskBackend;
|
|
37
37
|
guestNetwork?: DeskGuestNetworkOptions;
|
|
38
|
+
/**
|
|
39
|
+
* Cloud Hypervisor only: the `virtiofsd` (1.13 or later) behind each share.
|
|
40
|
+
* Defaults to the first on `PATH`, then `/usr/libexec/virtiofsd`.
|
|
41
|
+
*/
|
|
42
|
+
virtiofsdPath?: string;
|
|
43
|
+
/** Cloud Hypervisor only: how `virtiofsd` confines itself; defaults to `namespace`. */
|
|
44
|
+
virtiofsdSandbox?: DeskVirtiofsdSandbox;
|
|
38
45
|
ports: DeskPorts;
|
|
39
46
|
/** Override TCP dialing for tests or a host-specific transport. */
|
|
40
47
|
connectGuest?: (address: string, port: number) => Promise<Duplex>;
|
|
@@ -47,6 +54,8 @@ export interface DeskHostOptions {
|
|
|
47
54
|
now?: () => number;
|
|
48
55
|
pathExists?: (path: string) => boolean;
|
|
49
56
|
deviceExists?: (path: string) => boolean;
|
|
57
|
+
/** Whether a share's path is an existing directory; injectable for tests. */
|
|
58
|
+
isDirectory?: (path: string) => boolean;
|
|
50
59
|
/**
|
|
51
60
|
* Delete a destroyed desk's overlay. Injectable so the destroy path is
|
|
52
61
|
* testable without a filesystem; defaults to `rm(path, { force: true })`.
|
|
@@ -72,6 +81,12 @@ export interface DeskHostOptions {
|
|
|
72
81
|
cpuSignature?: () => Promise<string>;
|
|
73
82
|
readSnapshotSignature?: (directory: string) => Promise<string | null>;
|
|
74
83
|
writeSnapshotSignature?: (directory: string, signature: string) => Promise<void>;
|
|
84
|
+
/**
|
|
85
|
+
* The shares a parked desk was snapshotted with, which its restore uses in
|
|
86
|
+
* place of the start options' shares. Defaults to the record the backend
|
|
87
|
+
* writes beside the snapshot; a snapshot without one had no shares.
|
|
88
|
+
*/
|
|
89
|
+
readSnapshotShares?: (directory: string) => Promise<readonly DeskShare[]>;
|
|
75
90
|
/** Create a snapshot directory; defaults to `mkdir(path, { recursive: true })`. */
|
|
76
91
|
makeSnapshotDir?: (path: string) => Promise<void>;
|
|
77
92
|
/**
|
|
@@ -122,6 +137,14 @@ export interface DeskStartOptions {
|
|
|
122
137
|
macAddress?: string;
|
|
123
138
|
guestAddress?: string;
|
|
124
139
|
};
|
|
140
|
+
/**
|
|
141
|
+
* Host directories shared into the guest over virtio-fs. A desk parked with
|
|
142
|
+
* memory is restored with the shares it was parked with, because a snapshot
|
|
143
|
+
* holds its devices; these take effect on its next cold boot (see
|
|
144
|
+
* `shutdown`). A parked share whose directory is gone makes the restore fail,
|
|
145
|
+
* and the desk cold boots with these instead.
|
|
146
|
+
*/
|
|
147
|
+
shares?: readonly DeskShare[];
|
|
125
148
|
}
|
|
126
149
|
export interface DeskExecOptions {
|
|
127
150
|
command: string;
|
package/host.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../src/host.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAEpC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AACzC,OAAO,EAML,KAAK,oBAAoB,EACzB,KAAK,uBAAuB,
|
|
1
|
+
{"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../src/host.ts"],"names":[],"mappings":"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"}
|
package/host.js
CHANGED
|
@@ -18,6 +18,7 @@ import { Buffer } from 'node:buffer';
|
|
|
18
18
|
import { connect as tcpConnect, isIP } from 'node:net';
|
|
19
19
|
import { buildDeskLaunchPlan, cleanDeskId, DEFAULT_KVM_PATH, DEFAULT_VMM_PATH, DeskError, } from './plan.js';
|
|
20
20
|
import { cloudHypervisorBackend, } from './backend.js';
|
|
21
|
+
import { readShareManifest } from './machine.js';
|
|
21
22
|
import { parseCapabilities, parseClipboardText, parseExecResult, parseHandoverBegun, parseJobStarted, parseObservation, } from './protocol.js';
|
|
22
23
|
export const DEFAULT_CAPACITY = 4;
|
|
23
24
|
export const DEFAULT_IDLE_SUSPEND_MS = 5 * 60_000;
|
|
@@ -420,10 +421,12 @@ export function createDeskHost(options) {
|
|
|
420
421
|
// come up on a different tap than the one the egress rules were put on.
|
|
421
422
|
const cid = nextCid++;
|
|
422
423
|
const initramfsPath = resolveInitramfsPath();
|
|
423
|
-
const planFor = (restore) => buildDeskLaunchPlan({
|
|
424
|
+
const planFor = (restore, shares = startOptions.shares) => buildDeskLaunchPlan({
|
|
424
425
|
deskId,
|
|
425
426
|
hypervisor: backend.hypervisor ?? 'cloud-hypervisor',
|
|
426
|
-
...(backend.hypervisor === 'virtualization'
|
|
427
|
+
...(backend.hypervisor === 'virtualization'
|
|
428
|
+
? { guestNetwork: options.guestNetwork }
|
|
429
|
+
: { virtiofsdPath: options.virtiofsdPath, virtiofsdSandbox: options.virtiofsdSandbox }),
|
|
427
430
|
vmmPath: options.vmmPath,
|
|
428
431
|
kvmPath: options.kvmPath,
|
|
429
432
|
kernelPath,
|
|
@@ -436,13 +439,23 @@ export function createDeskHost(options) {
|
|
|
436
439
|
runtimeDir: options.runtimeDir,
|
|
437
440
|
tapDevice: startOptions.network?.tapDevice,
|
|
438
441
|
macAddress: startOptions.network?.macAddress,
|
|
442
|
+
shares,
|
|
439
443
|
// Forward the host's cmdline only when set; otherwise the plan falls
|
|
440
444
|
// back to DEFAULT_KERNEL_CMDLINE.
|
|
441
445
|
...(kernelCmdline === undefined ? {} : { kernelCmdline }),
|
|
442
446
|
launcherIdentity: options.launcherIdentity,
|
|
443
447
|
snapshotDir,
|
|
444
448
|
...(restore ? { restore: true } : {}),
|
|
445
|
-
}, { pathExists, deviceExists });
|
|
449
|
+
}, { pathExists, deviceExists, ...(options.isDirectory ? { isDirectory: options.isDirectory } : {}) });
|
|
450
|
+
const restorePlan = async () => {
|
|
451
|
+
const parked = await (options.readSnapshotShares ?? readShareManifest)(snapshotDir);
|
|
452
|
+
try {
|
|
453
|
+
return planFor(true, parked);
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
throw new DeskError(`its restore, with the shares it was parked with, could not be planned: ${errorMessage(error)}`);
|
|
457
|
+
}
|
|
458
|
+
};
|
|
446
459
|
let resuming = parkWithMemory && pathExists(snapshotDir);
|
|
447
460
|
if (resuming) {
|
|
448
461
|
const saved = await (options.readSnapshotSignature ?? readSnapshotSignature)(snapshotDir);
|
|
@@ -455,7 +468,7 @@ export function createDeskHost(options) {
|
|
|
455
468
|
let machine;
|
|
456
469
|
try {
|
|
457
470
|
machine = resuming
|
|
458
|
-
? await backend.boot(
|
|
471
|
+
? await backend.boot(await restorePlan(), {
|
|
459
472
|
// The guest is restored and PAUSED here. Destroying the snapshot in
|
|
460
473
|
// this window is what stops a memory image ever outliving the disk
|
|
461
474
|
// state it belongs to. If it fails the restore is abandoned rather
|