@braedonsaunders/appkit-desk 0.8.0 → 0.10.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 +119 -1
- package/backend.d.ts +13 -2
- package/backend.d.ts.map +1 -1
- package/backend.js +118 -9
- package/backend.js.map +1 -1
- package/host.d.ts +45 -4
- package/host.d.ts.map +1 -1
- package/host.js +192 -26
- package/host.js.map +1 -1
- package/machine.d.ts +12 -1
- package/machine.d.ts.map +1 -1
- package/machine.js +98 -2
- package/machine.js.map +1 -1
- package/package.json +1 -1
- package/plan.d.ts +78 -0
- package/plan.d.ts.map +1 -1
- package/plan.js +163 -7
- 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
|
@@ -112,6 +112,19 @@ stops, the disk persists, applications cold-start on `resume`. Keep-alive jobs
|
|
|
112
112
|
die with the lease, and their termination is recorded as `job_exit` so no
|
|
113
113
|
process ever runs where an operator cannot see it.
|
|
114
114
|
|
|
115
|
+
`host.canPause(deskId)` reports whether a resident desk supports in-memory
|
|
116
|
+
pause. Cloud Hypervisor supports `await host.pause(deskId)` and
|
|
117
|
+
`await host.resume(deskId)`; other backends can decline it. A paused desk keeps
|
|
118
|
+
its handle, capacity slot, running jobs and original lease and idle deadlines.
|
|
119
|
+
Guest requests are rejected while paused; requests already in flight fail with
|
|
120
|
+
unknown fate. A guest connection lost during the pause reconnects on resume.
|
|
121
|
+
An active handover prevents pause. Suspend and shutdown resume the desk before
|
|
122
|
+
trimming and syncing. Lifecycle operations, including lease sweeps, serialize
|
|
123
|
+
per desk. If a control operation fails and the VM state cannot be confirmed,
|
|
124
|
+
the desk remains fenced; retry `resume` after the control socket is available.
|
|
125
|
+
Existing guest service streams remain open, but cannot make progress while the
|
|
126
|
+
VM is paused.
|
|
127
|
+
|
|
115
128
|
`host.shutdown(deskId)` is a restart, not a destroy: a resident desk is synced
|
|
116
129
|
and shut down instead of parked, a parked desk's memory snapshot is discarded,
|
|
117
130
|
and the overlay stays, so the next `start` or `resume` cold boots from disk.
|
|
@@ -247,6 +260,26 @@ The default iptables chain prefix is `desk`. Existing deployments using the `bh`
|
|
|
247
260
|
|
|
248
261
|
Supply `network: { tapDevice, macAddress, guestAddress }` to `host.start`, then `await desk.connect(port)` returns a Node `Duplex` TCP stream to the guest. The caller owns the stream; suspension closes it. A service must listen on the guest interface, and its protocol and authentication remain the application's responsibility. `connectGuest` on host options injects a dialer for testing.
|
|
249
262
|
|
|
263
|
+
`freePageReporting: true` on `host.start` configures a zero-size
|
|
264
|
+
virtio-balloon with free-page reporting on a Cloud Hypervisor cold boot. It
|
|
265
|
+
requires guest driver support; the snapshot retains its original device
|
|
266
|
+
configuration. Reporting frees host memory when the guest reports unused pages;
|
|
267
|
+
it does not promise a particular snapshot size. Virtualization.framework does
|
|
268
|
+
not support this option.
|
|
269
|
+
|
|
270
|
+
With Cloud Hypervisor v54 or later, `memoryRestoreMode: 'copyonwrite'` opts in
|
|
271
|
+
to file-backed restore alongside `parkWithMemory: true`. The parked snapshot is
|
|
272
|
+
atomically renamed to a unique source and its parent directory synced before
|
|
273
|
+
the VMM starts, so it cannot be replayed after the disk changes. The source is
|
|
274
|
+
kept unchanged until the VMM exits, then removed. A host crash or failed stop can
|
|
275
|
+
leave a `*.snapshot.source-*` directory; only remove it after verifying that no
|
|
276
|
+
VMM maps it. Restore failures can leave one too. The source contains guest RAM
|
|
277
|
+
and needs the same protection as a parked snapshot. Copy-on-write restore refuses virtio-fs shares, including shares recorded in
|
|
278
|
+
a parked snapshot. Cloud Hypervisor can fall back to eager copying for other
|
|
279
|
+
incompatible memory or devices. Separate reflinked
|
|
280
|
+
files do not share page-cache identity, and this option does not create cloned
|
|
281
|
+
template guests or promise incremental snapshots.
|
|
282
|
+
|
|
250
283
|
With `parkWithMemory`, new snapshots carry a host CPU signature. A changed signature skips restore and cold boots; Cloud Hypervisor's specific incompatible-CPUID restore error also triggers a cold boot. These outcomes increment `stats().snapshotCpuFallbacks`, set `lastSnapshotCpuFallback`, and emit `snapshot_cpu_fallback`, rather than setting `lastError`. Existing snapshots without a signature are attempted normally. Signatures are conservative CPU identity and feature fingerprints, not a guarantee of CPUID compatibility; a cold boot loses the parked process state. Keep snapshots and their overlays together.
|
|
251
284
|
|
|
252
285
|
For a disposable real-host check, provide a guest image with the desk agent and Python 3, Cloud Hypervisor, KVM, and a free TAP name and IP pair. Run from the repository root in the target network namespace with privileges to manage TAP and iptables:
|
|
@@ -257,7 +290,77 @@ DESK_CHECK_IMAGE_ROOT=/data/desk-image DESK_CHECK_BASE_IMAGE=/data/desk-image/ba
|
|
|
257
290
|
DESK_CHECK_TMPDIR=/data/tmp pnpm --filter @braedonsaunders/appkit-desk exec tsx scripts/check-kvm.mts
|
|
258
291
|
```
|
|
259
292
|
|
|
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.
|
|
293
|
+
Set `DESK_CHECK_COW=1` to exercise copy-on-write snapshot restore and `DESK_CHECK_BALLOON=1` to check the guest virtio-balloon driver. 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 resident pause and resume, and 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.
|
|
294
|
+
|
|
295
|
+
## Host directory shares
|
|
296
|
+
|
|
297
|
+
A desk can mount host directories over virtio-fs. Pass `shares` to
|
|
298
|
+
`host.start` (or `buildDeskLaunchPlan`):
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
const desk = await host.start({
|
|
302
|
+
deskId: 'agent-7',
|
|
303
|
+
baseImage: '/data/agent-disks/base.img',
|
|
304
|
+
overlayPath: '/data/agent-disks/overlays/agent-7.img',
|
|
305
|
+
shares: [
|
|
306
|
+
{ tag: 'tools', path: '/srv/desk-tools', readOnly: true },
|
|
307
|
+
{ tag: 'build', path: '/srv/builds/agent-7', readOnly: false },
|
|
308
|
+
],
|
|
309
|
+
})
|
|
310
|
+
await desk.exec({ command: '/bin/sh', args: ['-c', 'mkdir -p /opt/tools && mount -t virtiofs tools /opt/tools'] })
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
A `tag` is 1–36 lowercase letters, digits or hyphens, starting with a letter or
|
|
314
|
+
digit, and unique within the desk; it is the name the guest mounts. `path` is an
|
|
315
|
+
absolute path to an existing host directory. `readOnly` is required. A read-only
|
|
316
|
+
share is enforced by the host's file server, so remounting it read-write in the
|
|
317
|
+
guest does not make it writable. The guest kernel needs virtio-fs
|
|
318
|
+
(`CONFIG_VIRTIO_FS`; Debian's cloud kernels ship it as the `virtiofs` module).
|
|
319
|
+
|
|
320
|
+
On Cloud Hypervisor, each share is served by its own `virtiofsd` (the Rust
|
|
321
|
+
implementation, 1.13 or later). `virtiofsdPath` on the host options names the
|
|
322
|
+
binary; without it the first `virtiofsd` on `PATH` is used, then
|
|
323
|
+
`/usr/libexec/virtiofsd`. A desk without shares does not need `virtiofsd`. Each
|
|
324
|
+
daemon runs as the launcher identity with:
|
|
325
|
+
|
|
326
|
+
```sh
|
|
327
|
+
virtiofsd --socket-path=<runtimeDir>/desk-<deskId>.<tag>.fs.sock --shared-dir=<path> \
|
|
328
|
+
--sandbox=<namespace|chroot> --cache=auto \
|
|
329
|
+
--migration-mode=find-paths --migration-on-error=guest-error [--readonly]
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
and the VMM gets `--fs tag=<tag>,socket=<socket>` with its memory as
|
|
333
|
+
`size=<n>M,shared=on`, which vhost-user devices require; a desk without shares
|
|
334
|
+
keeps private memory. `--cache=auto` lets the guest cache for about a second,
|
|
335
|
+
so files the host changes under a share appear in running guests.
|
|
336
|
+
`virtiofsdSandbox` picks how the daemon confines itself to the directory:
|
|
337
|
+
`namespace` (the default) needs CAP_SYS_ADMIN when the launcher is root and
|
|
338
|
+
unprivileged user namespaces otherwise; `chroot` needs root and suits a
|
|
339
|
+
container that cannot create namespaces. Both prevent symlink escapes. The
|
|
340
|
+
backend starts every daemon and waits for its socket before starting the VMM,
|
|
341
|
+
on a boot and on a restore, and stops the daemons after the VMM. A daemon that
|
|
342
|
+
exits while its VMM runs stops the VMM, so the desk is lost rather than left
|
|
343
|
+
with a share that no longer answers. `virtiofsd` also exits when its VMM
|
|
344
|
+
disconnects. The share socket path must fit in 107 bytes.
|
|
345
|
+
|
|
346
|
+
On Virtualization.framework, each share is a
|
|
347
|
+
`VZVirtioFileSystemDeviceConfiguration` with a `VZSingleDirectoryShare`, and the
|
|
348
|
+
framework enforces `readOnly`. `virtiofsdPath` and `virtiofsdSandbox` are
|
|
349
|
+
refused there. When Virtualization.framework reports that a configuration
|
|
350
|
+
cannot be saved, the desk parks by shutting down, as for any other device.
|
|
351
|
+
|
|
352
|
+
Parking keeps a desk's shares with its memory. The backend records them in
|
|
353
|
+
`shares.json` beside the snapshot, and a restore uses that record instead of the
|
|
354
|
+
start options: a snapshot holds its devices, so a share added since joins the
|
|
355
|
+
desk at its next cold boot (`host.shutdown` forces one), and a backend refuses a
|
|
356
|
+
restore plan whose shares differ from the record. A recorded share whose
|
|
357
|
+
directory no longer exists fails the restore; the host discards the snapshot,
|
|
358
|
+
cold boots with the current shares, and reports it in `lastError`. A snapshot
|
|
359
|
+
holds no share contents. On Cloud Hypervisor it carries each daemon's state,
|
|
360
|
+
which the new daemon loads on restore by reopening every file and directory the
|
|
361
|
+
guest held by its path, so open files survive. Paths are the link: a file the
|
|
362
|
+
host unlinks or moves while the desk is parked returns errors to the guest for
|
|
363
|
+
that file only, and a file the host puts at its path is the one the guest gets.
|
|
261
364
|
|
|
262
365
|
## macOS Virtualization.framework backend
|
|
263
366
|
|
|
@@ -313,3 +416,18 @@ For clients that require a local TCP URL, `await listenDeskPort(desk, port)`
|
|
|
313
416
|
returns a loopback-only `{ host, port, close }` listener. Each incoming
|
|
314
417
|
connection opens a guest stream through `desk.connect`; call `close()` when the
|
|
315
418
|
lease ends to close the listener and its connections.
|
|
419
|
+
|
|
420
|
+
Cloud Hypervisor backends can opt into `serviceVsock: true` when their guest image provides
|
|
421
|
+
vsock service listeners. `DeskHandle.connect(port)` then opens that vsock port using the
|
|
422
|
+
Cloud Hypervisor socket handshake. The default remains TCP for existing guest images.
|
|
423
|
+
|
|
424
|
+
## Immutable image attachments
|
|
425
|
+
|
|
426
|
+
On Cloud Hypervisor, `DeskStartOptions.readOnlyAttachments` accepts an ordered list of
|
|
427
|
+
`{ path, sha256, kind }`, where `kind` is `block` or `pmem` and `sha256` is the
|
|
428
|
+
lowercase SHA-256 of the image. Block devices are attached read-only; pmem uses
|
|
429
|
+
`discard_writes=on`, so guest writes do not persist. The caller must keep each file
|
|
430
|
+
and its path immutable and available for as long as a desk or its snapshot uses it.
|
|
431
|
+
AppKit hashes each image on start and records the attachment list with the snapshot;
|
|
432
|
+
restore refuses a changed list before resuming. Virtualization.framework rejects
|
|
433
|
+
attachments. Device support and filesystem mount options depend on the guest kernel.
|
package/backend.d.ts
CHANGED
|
@@ -99,6 +99,9 @@ export interface DeskMachine {
|
|
|
99
99
|
* `DeskError` when the guest refuses or does not answer.
|
|
100
100
|
*/
|
|
101
101
|
connect?(port: number): Promise<Duplex>;
|
|
102
|
+
/** Optional resident pause and resume. Callers must serialize these with lifecycle operations. */
|
|
103
|
+
pause?(): Promise<void>;
|
|
104
|
+
resume?(): Promise<void>;
|
|
102
105
|
/** Stop the VM. Idempotent; resolves once the VMM process has exited. */
|
|
103
106
|
shutdown(): Promise<void>;
|
|
104
107
|
/**
|
|
@@ -122,7 +125,7 @@ export interface DeskBootHooks {
|
|
|
122
125
|
* Called on a restore, after the snapshot has been read back and before the
|
|
123
126
|
* guest is allowed to run.
|
|
124
127
|
*
|
|
125
|
-
*
|
|
128
|
+
* The snapshot must no longer be available for replay by this point. A memory image
|
|
126
129
|
* is valid only against the disk it was taken with, so once the guest resumes
|
|
127
130
|
* and writes, any surviving snapshot describes a past that no longer matches —
|
|
128
131
|
* and replaying it later would corrupt the desk. Restoring paused and
|
|
@@ -184,6 +187,12 @@ export interface CloudHypervisorBackendOptions {
|
|
|
184
187
|
reconnectRetryDelayMs?: number;
|
|
185
188
|
killGraceMs?: number;
|
|
186
189
|
guestAgentPort?: number;
|
|
190
|
+
/** Use guest-provided vsock service bridges for DeskHandle.connect. Opt in only when the image provides them. */
|
|
191
|
+
serviceVsock?: boolean;
|
|
192
|
+
/** Whether a share daemon's socket exists yet; injectable for tests. */
|
|
193
|
+
pathExists?: (path: string) => boolean;
|
|
194
|
+
/** Removes a stale share socket before its daemon starts; injectable for tests. */
|
|
195
|
+
removeFile?: (path: string) => Promise<void>;
|
|
187
196
|
}
|
|
188
197
|
export declare function createCloudHypervisorBackend(options?: CloudHypervisorBackendOptions): DeskBackend;
|
|
189
198
|
/**
|
|
@@ -196,7 +205,9 @@ export declare function createCloudHypervisorBackend(options?: CloudHypervisorBa
|
|
|
196
205
|
* smaller change than adding a binary to the image, and keeps the whole
|
|
197
206
|
* lifecycle inside one process that already owns the socket path.
|
|
198
207
|
*/
|
|
199
|
-
export type VmmApiCall = (socketPath: string, path: string, body?: Record<string, unknown>, method?: 'PUT' | 'GET') => Promise<void>;
|
|
208
|
+
export type VmmApiCall = (socketPath: string, path: string, body?: Record<string, unknown>, method?: 'PUT' | 'GET') => Promise<void | string>;
|
|
209
|
+
/** Reconcile an ambiguous command failure against the VMM before releasing the fence. */
|
|
210
|
+
export declare function setVmmState(call: VmmApiCall, socketPath: string, desired: 'Paused' | 'Running'): Promise<void>;
|
|
200
211
|
/** The default backend: real Cloud Hypervisor over a real vsock socket. */
|
|
201
212
|
export declare const cloudHypervisorBackend: DeskBackend;
|
|
202
213
|
//# sourceMappingURL=backend.d.ts.map
|
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, attemptHandshake, 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,12 +101,38 @@ 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
|
-
// prove the restore finished, let the caller
|
|
135
|
+
// prove the restore finished, let the caller invalidate replay while the
|
|
51
136
|
// guest still cannot write, then start it.
|
|
52
137
|
if (plan.snapshot.restoring) {
|
|
53
138
|
try {
|
|
@@ -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,23 +192,47 @@ export function createCloudHypervisorBackend(options = {}) {
|
|
|
107
192
|
now,
|
|
108
193
|
idFactory,
|
|
109
194
|
requestTimeoutMs,
|
|
110
|
-
|
|
195
|
+
...(options.serviceVsock ? { connect: (port) => {
|
|
196
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
197
|
+
throw new DeskError('Guest port must be between 1 and 65535.');
|
|
198
|
+
return attemptHandshake(connect, plan.vsock.socketPath, port, handshakeTimeoutMs);
|
|
199
|
+
} } : {}),
|
|
200
|
+
stop,
|
|
201
|
+
pause: () => setVmmState(vmmApi, plan.api.socketPath, 'Paused'),
|
|
202
|
+
resume: () => setVmmState(vmmApi, plan.api.socketPath, 'Running'),
|
|
111
203
|
park: async (snapshotDir) => {
|
|
112
204
|
try {
|
|
113
205
|
await vmmApi(plan.api.socketPath, '/api/v1/vm.pause');
|
|
114
206
|
await vmmApi(plan.api.socketPath, '/api/v1/vm.snapshot', { destination_url: `file://${snapshotDir}` });
|
|
207
|
+
await writeShareManifest(snapshotDir, plan.shares);
|
|
115
208
|
}
|
|
116
209
|
finally {
|
|
117
|
-
await
|
|
210
|
+
await stop();
|
|
118
211
|
}
|
|
119
212
|
},
|
|
120
213
|
});
|
|
121
214
|
}
|
|
122
215
|
return { boot };
|
|
123
216
|
}
|
|
217
|
+
/** Reconcile an ambiguous command failure against the VMM before releasing the fence. */
|
|
218
|
+
export async function setVmmState(call, socketPath, desired) {
|
|
219
|
+
try {
|
|
220
|
+
await call(socketPath, desired === 'Paused' ? '/api/v1/vm.pause' : '/api/v1/vm.resume');
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
try {
|
|
224
|
+
const response = await call(socketPath, '/api/v1/vm.info', undefined, 'GET');
|
|
225
|
+
const info = typeof response === 'string' ? JSON.parse(response) : null;
|
|
226
|
+
if (info && typeof info === 'object' && 'state' in info && info.state === desired)
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
catch { /* State is unknown: preserve the original failure and the request fence. */ }
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
124
233
|
const vmmApi = async (socketPath, path, body, method = 'PUT') => {
|
|
125
234
|
const payload = body === undefined ? undefined : Buffer.from(JSON.stringify(body), 'utf8');
|
|
126
|
-
await new Promise((resolve, reject) => {
|
|
235
|
+
return await new Promise((resolve, reject) => {
|
|
127
236
|
const request = httpRequest({
|
|
128
237
|
socketPath,
|
|
129
238
|
path,
|
|
@@ -139,7 +248,7 @@ const vmmApi = async (socketPath, path, body, method = 'PUT') => {
|
|
|
139
248
|
response.on('end', () => {
|
|
140
249
|
const status = response.statusCode ?? 0;
|
|
141
250
|
if (status >= 200 && status < 300) {
|
|
142
|
-
resolve();
|
|
251
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
143
252
|
return;
|
|
144
253
|
}
|
|
145
254
|
const said = Buffer.concat(chunks).toString('utf8').trim().slice(0, 400);
|
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, attemptHandshake,\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 /** Optional resident pause and resume. Callers must serialize these with lifecycle operations. */\n pause?(): Promise<void>\n resume?(): Promise<void>\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 * The snapshot must no longer be available for replay by this point. 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 /** Use guest-provided vsock service bridges for DeskHandle.connect. Opt in only when the image provides them. */\n serviceVsock?: boolean\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 invalidate replay 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 ...(options.serviceVsock ? { connect: (port: number) => {\n if (!Number.isInteger(port) || port < 1 || port > 65535) throw new DeskError('Guest port must be between 1 and 65535.')\n return attemptHandshake(connect, plan.vsock.socketPath, port, handshakeTimeoutMs)\n } } : {}),\n stop,\n pause: () => setVmmState(vmmApi, plan.api.socketPath, 'Paused'),\n resume: () => setVmmState(vmmApi, plan.api.socketPath, 'Running'),\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 | string>\n\n/** Reconcile an ambiguous command failure against the VMM before releasing the fence. */\nexport async function setVmmState(call: VmmApiCall, socketPath: string, desired: 'Paused' | 'Running'): Promise<void> {\n try {\n await call(socketPath, desired === 'Paused' ? '/api/v1/vm.pause' : '/api/v1/vm.resume')\n } catch (error) {\n try {\n const response = await call(socketPath, '/api/v1/vm.info', undefined, 'GET')\n const info: unknown = typeof response === 'string' ? JSON.parse(response) : null\n if (info && typeof info === 'object' && 'state' in info && info.state === desired) return\n } catch { /* State is unknown: preserve the original failure and the request fence. */ }\n throw error\n }\n}\n\nconst vmmApi: VmmApiCall = async (socketPath, path, body, method = 'PUT') => {\n const payload = body === undefined ? undefined : Buffer.from(JSON.stringify(body), 'utf8')\n return await new Promise<string>((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(Buffer.concat(chunks).toString('utf8'))\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"]}
|