@braedonsaunders/appkit-desk 0.9.0 → 0.11.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 +166 -1
- package/backend.d.ts +22 -3
- package/backend.d.ts.map +1 -1
- package/backend.js +48 -11
- package/backend.js.map +1 -1
- package/host.d.ts +52 -6
- package/host.d.ts.map +1 -1
- package/host.js +384 -33
- package/host.js.map +1 -1
- package/index.d.ts +3 -0
- package/index.d.ts.map +1 -1
- package/index.js +3 -0
- package/index.js.map +1 -1
- package/machine.d.ts +6 -2
- package/machine.d.ts.map +1 -1
- package/machine.js +48 -5
- package/machine.js.map +1 -1
- package/package.json +1 -1
- package/plan.d.ts +20 -0
- package/plan.d.ts.map +1 -1
- package/plan.js +34 -4
- package/plan.js.map +1 -1
- package/template-mapping.d.ts +5 -0
- package/template-mapping.d.ts.map +1 -0
- package/template-mapping.js +48 -0
- package/template-mapping.js.map +1 -0
- package/template-plan.d.ts +18 -0
- package/template-plan.d.ts.map +1 -0
- package/template-plan.js +52 -0
- package/template-plan.js.map +1 -0
- package/template-registry.d.ts +37 -0
- package/template-registry.d.ts.map +1 -0
- package/template-registry.js +190 -0
- package/template-registry.js.map +1 -0
- package/template-source.d.ts +15 -0
- package/template-source.d.ts.map +1 -0
- package/template-source.js +95 -0
- package/template-source.js.map +1 -0
package/README.md
CHANGED
|
@@ -27,6 +27,18 @@ tiers already cover.
|
|
|
27
27
|
|
|
28
28
|
Size the concurrency cap against screen-open desks, not headless ones.
|
|
29
29
|
|
|
30
|
+
Explicit `suspend` accepts optional sidecar hooks: `beforePark` runs after guest
|
|
31
|
+
trim and sync, and `onPaused` runs after the Cloud Hypervisor VM pauses and
|
|
32
|
+
before its memory snapshot. The caller must undo partial preparation on failure,
|
|
33
|
+
bound hook duration, and provide durable recovery across crashes. A hook failure
|
|
34
|
+
is reported to `onPausedError` and ordinary memory parking continues. Backends
|
|
35
|
+
without the paused hook report it unavailable and park normally.
|
|
36
|
+
`onParked(snapshotDir)` runs only after a successful memory snapshot, CPU identity
|
|
37
|
+
write, and file/directory sync, and only if preparation and paused hooks reported
|
|
38
|
+
no failure. Use that positive completion signal to publish a sidecar; `suspend`
|
|
39
|
+
returning or a snapshot directory existing alone does not prove a successful park.
|
|
40
|
+
A publication callback failure is reported without discarding the memory snapshot.
|
|
41
|
+
|
|
30
42
|
## Booting a desk
|
|
31
43
|
|
|
32
44
|
```ts
|
|
@@ -112,6 +124,19 @@ stops, the disk persists, applications cold-start on `resume`. Keep-alive jobs
|
|
|
112
124
|
die with the lease, and their termination is recorded as `job_exit` so no
|
|
113
125
|
process ever runs where an operator cannot see it.
|
|
114
126
|
|
|
127
|
+
`host.canPause(deskId)` reports whether a resident desk supports in-memory
|
|
128
|
+
pause. Cloud Hypervisor supports `await host.pause(deskId)` and
|
|
129
|
+
`await host.resume(deskId)`; other backends can decline it. A paused desk keeps
|
|
130
|
+
its handle, capacity slot, running jobs and original lease and idle deadlines.
|
|
131
|
+
Guest requests are rejected while paused; requests already in flight fail with
|
|
132
|
+
unknown fate. A guest connection lost during the pause reconnects on resume.
|
|
133
|
+
An active handover prevents pause. Suspend and shutdown resume the desk before
|
|
134
|
+
trimming and syncing. Lifecycle operations, including lease sweeps, serialize
|
|
135
|
+
per desk. If a control operation fails and the VM state cannot be confirmed,
|
|
136
|
+
the desk remains fenced; retry `resume` after the control socket is available.
|
|
137
|
+
Existing guest service streams remain open, but cannot make progress while the
|
|
138
|
+
VM is paused.
|
|
139
|
+
|
|
115
140
|
`host.shutdown(deskId)` is a restart, not a destroy: a resident desk is synced
|
|
116
141
|
and shut down instead of parked, a parked desk's memory snapshot is discarded,
|
|
117
142
|
and the overlay stays, so the next `start` or `resume` cold boots from disk.
|
|
@@ -247,6 +272,26 @@ The default iptables chain prefix is `desk`. Existing deployments using the `bh`
|
|
|
247
272
|
|
|
248
273
|
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
274
|
|
|
275
|
+
`freePageReporting: true` on `host.start` configures a zero-size
|
|
276
|
+
virtio-balloon with free-page reporting on a Cloud Hypervisor cold boot. It
|
|
277
|
+
requires guest driver support; the snapshot retains its original device
|
|
278
|
+
configuration. Reporting frees host memory when the guest reports unused pages;
|
|
279
|
+
it does not promise a particular snapshot size. Virtualization.framework does
|
|
280
|
+
not support this option.
|
|
281
|
+
|
|
282
|
+
With Cloud Hypervisor v54 or later, `memoryRestoreMode: 'copyonwrite'` opts in
|
|
283
|
+
to file-backed restore alongside `parkWithMemory: true`. The parked snapshot is
|
|
284
|
+
atomically renamed to a unique source and its parent directory synced before
|
|
285
|
+
the VMM starts, so it cannot be replayed after the disk changes. The source is
|
|
286
|
+
kept unchanged until the VMM exits, then removed. A host crash or failed stop can
|
|
287
|
+
leave a `*.snapshot.source-*` directory; only remove it after verifying that no
|
|
288
|
+
VMM maps it. Restore failures can leave one too. The source contains guest RAM
|
|
289
|
+
and needs the same protection as a parked snapshot. Copy-on-write restore refuses virtio-fs shares, including shares recorded in
|
|
290
|
+
a parked snapshot. Cloud Hypervisor can fall back to eager copying for other
|
|
291
|
+
incompatible memory or devices. Separate reflinked
|
|
292
|
+
files do not share page-cache identity, and this option does not create cloned
|
|
293
|
+
template guests or promise incremental snapshots.
|
|
294
|
+
|
|
250
295
|
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
296
|
|
|
252
297
|
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 +302,31 @@ DESK_CHECK_IMAGE_ROOT=/data/desk-image DESK_CHECK_BASE_IMAGE=/data/desk-image/ba
|
|
|
257
302
|
DESK_CHECK_TMPDIR=/data/tmp pnpm --filter @braedonsaunders/appkit-desk exec tsx scripts/check-kvm.mts
|
|
258
303
|
```
|
|
259
304
|
|
|
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.
|
|
305
|
+
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.
|
|
306
|
+
|
|
307
|
+
The check uses a unique desk ID and private runtime directory. Set `DESK_CHECK_CID`
|
|
308
|
+
to a free guest CID when testing alongside other hosts. Put `DESK_CHECK_TMPDIR` on
|
|
309
|
+
the same reflink-capable filesystem as the base image to avoid a full disk copy.
|
|
310
|
+
Run inside a disposable network namespace when testing on a machine with an active
|
|
311
|
+
host. `DESK_CHECK_VMM` selects a test binary without changing the system VMM.
|
|
312
|
+
|
|
313
|
+
With the [pinned incremental build](vmm/README.md), set `DESK_CHECK_INCREMENTAL=1`
|
|
314
|
+
and `DESK_CHECK_COW=1` on XFS or Btrfs. This retains a hard link to the original
|
|
315
|
+
memory file, verifies its digest after another park, checks shared filesystem
|
|
316
|
+
extents, and confirms the guest HTTP process survives a second restore. It uses
|
|
317
|
+
`filefrag` from e2fsprogs. A failed VMM stop leaves the test files in place for
|
|
318
|
+
recovery instead of deleting backing files from a live process. The check does
|
|
319
|
+
not measure latency or memory savings.
|
|
320
|
+
|
|
321
|
+
`memoryRestoreMode: 'ondemand'` selects Cloud Hypervisor's userfaultfd page-on-access
|
|
322
|
+
restore instead of its default eager copy. It requires Linux 6.6 or newer and
|
|
323
|
+
permission to open `/dev/userfaultfd` or use the corresponding syscall. It rejects
|
|
324
|
+
virtio-fs shares and does not silently cold boot when restore is unavailable. The
|
|
325
|
+
source snapshot is durably retired before boot and retained until the VMM stops;
|
|
326
|
+
an interrupted restore leaves a durable fence requiring operator recovery. The
|
|
327
|
+
mode can be selected per host or per desk and is separate from copy-on-write
|
|
328
|
+
restore: it does not share CoW pages or enable incremental snapshot writes.
|
|
329
|
+
Run the disposable check with `DESK_CHECK_RESTORE_MODE=ondemand` to exercise it.
|
|
261
330
|
|
|
262
331
|
## Host directory shares
|
|
263
332
|
|
|
@@ -383,3 +452,99 @@ For clients that require a local TCP URL, `await listenDeskPort(desk, port)`
|
|
|
383
452
|
returns a loopback-only `{ host, port, close }` listener. Each incoming
|
|
384
453
|
connection opens a guest stream through `desk.connect`; call `close()` when the
|
|
385
454
|
lease ends to close the listener and its connections.
|
|
455
|
+
|
|
456
|
+
Cloud Hypervisor backends can opt into `serviceVsock: true` when their guest image provides
|
|
457
|
+
vsock service listeners. `DeskHandle.connect(port)` then opens that vsock port using the
|
|
458
|
+
Cloud Hypervisor socket handshake. The default remains TCP for existing guest images.
|
|
459
|
+
|
|
460
|
+
## Immutable image attachments
|
|
461
|
+
|
|
462
|
+
On Cloud Hypervisor, `DeskStartOptions.readOnlyAttachments` accepts an ordered list of
|
|
463
|
+
`{ path, sha256, kind }`, where `kind` is `block` or `pmem` and `sha256` is the
|
|
464
|
+
lowercase SHA-256 of the image. Block devices are attached read-only; pmem uses
|
|
465
|
+
`discard_writes=on`, so guest writes do not persist. The caller must keep each file
|
|
466
|
+
and its path immutable and available for as long as a desk or its snapshot uses it.
|
|
467
|
+
AppKit hashes each image on start and records the attachment list with the snapshot;
|
|
468
|
+
restore refuses a changed list before resuming. Virtualization.framework rejects
|
|
469
|
+
attachments. Device support and filesystem mount options depend on the guest kernel.
|
|
470
|
+
|
|
471
|
+
Cloud Hypervisor backends accept `inheritedFds`, a list of open descriptors to
|
|
472
|
+
retain in the VMM and each share daemon, mapped to consecutive child descriptors
|
|
473
|
+
starting at 3. Keep each descriptor open until the backend has stopped its
|
|
474
|
+
children. For example, a host can retain an advisory lock in those processes
|
|
475
|
+
so that an orphaned VMM continues to fence offline disk operations. The caller
|
|
476
|
+
is responsible for acquiring and validating the lock and for ensuring the
|
|
477
|
+
executables do not close the inherited descriptors. Run
|
|
478
|
+
`python3 packages/desk/scripts/check-inherited-lock.py` from the repository to
|
|
479
|
+
check descriptor inheritance and release with disposable processes.
|
|
480
|
+
### Snapshot memory pins
|
|
481
|
+
|
|
482
|
+
`pinTemplateMemory(source, destination, config)` creates an exclusive child
|
|
483
|
+
snapshot directory with a hard link to the source `memory-ranges` inode and
|
|
484
|
+
private `state.json` and `config.json` files. `assertPinnedTemplateMemory`
|
|
485
|
+
checks the publication marker, source inode, and read-only mode before use.
|
|
486
|
+
Source and destination must be on the same filesystem. The caller must keep
|
|
487
|
+
the memory bytes unchanged for the lifetime of every child and prevent all
|
|
488
|
+
writers, including processes with permission to change the file mode. Removing
|
|
489
|
+
the source pathname does not invalidate an existing hard link.
|
|
490
|
+
|
|
491
|
+
These filesystem operations alone do not establish that a snapshot is safe to
|
|
492
|
+
clone. A caller must independently validate its contents and compatibility,
|
|
493
|
+
prepare independent disk and device identities, fence the guest until it has
|
|
494
|
+
been reinitialized, and retain each memory pin while its VM can use it. A
|
|
495
|
+
partially published destination is left in place after an interruption and
|
|
496
|
+
must not be restored. Remove it only after verifying no VM is using it.
|
|
497
|
+
|
|
498
|
+
`planTemplateConfig` checks a snapshot config for a restricted Cloud Hypervisor
|
|
499
|
+
copy-on-write configuration and produces a child-specific copy with distinct
|
|
500
|
+
disk, TAP, MAC and vsock endpoints. It does not create a disk or establish
|
|
501
|
+
that any of those identities are globally unique. Cloud Hypervisor restores
|
|
502
|
+
virtio-net configuration from saved device state, so the guest must also change
|
|
503
|
+
its live MAC under an external network fence before traffic is allowed.
|
|
504
|
+
Read-only attachments retain their paths; the caller must verify their contents.
|
|
505
|
+
|
|
506
|
+
`strictMemoryRestore` refuses a missing or mismatched saved CPU identity and
|
|
507
|
+
refuses a cold-boot fallback when a restore fails. Before restore it writes a
|
|
508
|
+
durable `.restore-pending` marker beside the snapshot. The marker stays until a
|
|
509
|
+
later memory park has completed and its snapshot files are synced. If the host
|
|
510
|
+
restarts with the marker present, wake fails and requires operator recovery;
|
|
511
|
+
the disk and retired source are not automatically deleted. This is a lifecycle
|
|
512
|
+
fence, not a template registration or guest identity reset.
|
|
513
|
+
|
|
514
|
+
`registerDeskTemplate` records the hashes of a stopped, read-only template disk,
|
|
515
|
+
snapshot files, kernel, initramfs and read-only attachments together with its VMM
|
|
516
|
+
and CPU identities. The caller must supply actual VMM and CPU identities and
|
|
517
|
+
prepare the source in isolation without user data or credentials. Hashing cannot
|
|
518
|
+
establish that provenance. The source and its memory inode must be protected
|
|
519
|
+
against all writers for the lifetime of its descendants.
|
|
520
|
+
|
|
521
|
+
`forkDeskTemplate` checks the registration and actual file bytes, reserves a new
|
|
522
|
+
private directory, copies the template disk into an independent writable file,
|
|
523
|
+
and pins the same snapshot memory inode in the child directory for its lifetime,
|
|
524
|
+
including while parked. Its returned disk and snapshot are
|
|
525
|
+
prepared artifacts, not a running VM. A caller must verify `.prepared`, establish
|
|
526
|
+
an external network and workload fence before restore, reinitialize the guest's
|
|
527
|
+
identity and entropy, and only then allow traffic or guest workloads. An
|
|
528
|
+
interrupted preparation leaves a directory without `.prepared`; it must not be
|
|
529
|
+
restored. Retain and inspect it before cleanup.
|
|
530
|
+
|
|
531
|
+
A caller can pass `template` in `DeskStartOptions` on a Cloud Hypervisor host
|
|
532
|
+
with memory parking enabled. This opts that desk into strict copy-on-write restore
|
|
533
|
+
without changing the restore mode of other desks. Its `holdFence` callback establishes the external network fence before
|
|
534
|
+
preparation or restore, including when preparation was interrupted. Its `prepare`
|
|
535
|
+
callback receives the assigned launch plan and creates the disk and snapshot. AppKit verifies their device and resource identities against that
|
|
536
|
+
plan. After restore, `initialize` runs before the desk handle is exposed;
|
|
537
|
+
only after it succeeds and the initialization marker is durable does
|
|
538
|
+
`releaseFence` run. If initialization fails, AppKit stops the guest, preserves
|
|
539
|
+
the interruption marker and does not release the fence. The caller is
|
|
540
|
+
responsible for ensuring the source has no running user workloads at capture
|
|
541
|
+
and for resetting guest entropy and identity in `initialize`.
|
|
542
|
+
|
|
543
|
+
A caller may supply `vsockCid` on a start to use a stable guest CID. AppKit
|
|
544
|
+
rejects collisions among CIDs allocated by the same host instance; the caller
|
|
545
|
+
must also keep that allocation unique across host instances and restarts.
|
|
546
|
+
|
|
547
|
+
A descendant may select strict memory handling and copy-on-write restore per
|
|
548
|
+
start without requesting template initialization again. Explicit `shutdown`
|
|
549
|
+
stops that known VM, discards its memory and clears its interruption marker;
|
|
550
|
+
this permits an intentional cold restart of its existing disk.
|
package/backend.d.ts
CHANGED
|
@@ -53,6 +53,7 @@ export type DeskConnectionChange = {
|
|
|
53
53
|
};
|
|
54
54
|
/** A booted microVM as the host sees it: one desk, one guest-agent channel. */
|
|
55
55
|
export interface DeskMachine {
|
|
56
|
+
supportsPausedParkHook?: true;
|
|
56
57
|
readonly deskId: string;
|
|
57
58
|
/**
|
|
58
59
|
* Send one request to the in-guest agent and await its correlated result.
|
|
@@ -99,6 +100,9 @@ export interface DeskMachine {
|
|
|
99
100
|
* `DeskError` when the guest refuses or does not answer.
|
|
100
101
|
*/
|
|
101
102
|
connect?(port: number): Promise<Duplex>;
|
|
103
|
+
/** Optional resident pause and resume. Callers must serialize these with lifecycle operations. */
|
|
104
|
+
pause?(): Promise<void>;
|
|
105
|
+
resume?(): Promise<void>;
|
|
102
106
|
/** Stop the VM. Idempotent; resolves once the VMM process has exited. */
|
|
103
107
|
shutdown(): Promise<void>;
|
|
104
108
|
/**
|
|
@@ -115,14 +119,20 @@ export interface DeskMachine {
|
|
|
115
119
|
* parked without a prior `sync` is consistent only *with that snapshot* —
|
|
116
120
|
* which matters the moment the snapshot is discarded.
|
|
117
121
|
*/
|
|
118
|
-
park?(snapshotDir: string): Promise<void>;
|
|
122
|
+
park?(snapshotDir: string, hooks?: DeskParkHooks): Promise<void>;
|
|
123
|
+
}
|
|
124
|
+
export interface DeskParkHooks {
|
|
125
|
+
/** Called while the VM is paused, before its memory snapshot is written. */
|
|
126
|
+
onPaused?: () => Promise<void>;
|
|
127
|
+
/** Reports a sidecar failure; ordinary memory parking continues. */
|
|
128
|
+
onPausedError?: (error: unknown) => void;
|
|
119
129
|
}
|
|
120
130
|
export interface DeskBootHooks {
|
|
121
131
|
/**
|
|
122
132
|
* Called on a restore, after the snapshot has been read back and before the
|
|
123
133
|
* guest is allowed to run.
|
|
124
134
|
*
|
|
125
|
-
*
|
|
135
|
+
* The snapshot must no longer be available for replay by this point. A memory image
|
|
126
136
|
* is valid only against the disk it was taken with, so once the guest resumes
|
|
127
137
|
* and writes, any surviving snapshot describes a past that no longer matches —
|
|
128
138
|
* and replaying it later would corrupt the desk. Restoring paused and
|
|
@@ -148,8 +158,13 @@ export interface DeskBackend {
|
|
|
148
158
|
}
|
|
149
159
|
export type DeskProcessLauncher = (command: string, args: readonly string[], options: {
|
|
150
160
|
identity?: DeskLauncherIdentity;
|
|
161
|
+
inheritedFds?: readonly number[];
|
|
151
162
|
}) => ChildProcess;
|
|
152
163
|
export interface CloudHypervisorBackendOptions {
|
|
164
|
+
/** VMM API transport, injectable for tests. */
|
|
165
|
+
vmmApi?: VmmApiCall;
|
|
166
|
+
/** Descriptors retained by the VMM and share daemons, mapped consecutively from fd 3. */
|
|
167
|
+
inheritedFds?: readonly number[];
|
|
153
168
|
/** Spawns the VMM and image tooling; injectable for tests. */
|
|
154
169
|
launcher?: DeskProcessLauncher;
|
|
155
170
|
/** Opens the host side of the vsock Unix socket; injectable for tests. */
|
|
@@ -184,6 +199,8 @@ export interface CloudHypervisorBackendOptions {
|
|
|
184
199
|
reconnectRetryDelayMs?: number;
|
|
185
200
|
killGraceMs?: number;
|
|
186
201
|
guestAgentPort?: number;
|
|
202
|
+
/** Use guest-provided vsock service bridges for DeskHandle.connect. Opt in only when the image provides them. */
|
|
203
|
+
serviceVsock?: boolean;
|
|
187
204
|
/** Whether a share daemon's socket exists yet; injectable for tests. */
|
|
188
205
|
pathExists?: (path: string) => boolean;
|
|
189
206
|
/** Removes a stale share socket before its daemon starts; injectable for tests. */
|
|
@@ -200,7 +217,9 @@ export declare function createCloudHypervisorBackend(options?: CloudHypervisorBa
|
|
|
200
217
|
* smaller change than adding a binary to the image, and keeps the whole
|
|
201
218
|
* lifecycle inside one process that already owns the socket path.
|
|
202
219
|
*/
|
|
203
|
-
export type VmmApiCall = (socketPath: string, path: string, body?: Record<string, unknown>, method?: 'PUT' | 'GET') => Promise<void>;
|
|
220
|
+
export type VmmApiCall = (socketPath: string, path: string, body?: Record<string, unknown>, method?: 'PUT' | 'GET') => Promise<void | string>;
|
|
221
|
+
/** Reconcile an ambiguous command failure against the VMM before releasing the fence. */
|
|
222
|
+
export declare function setVmmState(call: VmmApiCall, socketPath: string, desired: 'Paused' | 'Running'): Promise<void>;
|
|
204
223
|
/** The default backend: real Cloud Hypervisor over a real vsock socket. */
|
|
205
224
|
export declare const cloudHypervisorBackend: DeskBackend;
|
|
206
225
|
//# 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,9 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { assertPrivateMemoryMapping } from './template-mapping.js';
|
|
2
3
|
import { existsSync } from 'node:fs';
|
|
3
4
|
import { rm } from 'node:fs/promises';
|
|
4
5
|
import { request as httpRequest } from 'node:http';
|
|
5
6
|
import { GUEST_AGENT_VSOCK_PORT } from './protocol.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 { 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
8
|
import { DeskError, } from './plan.js';
|
|
8
9
|
/**
|
|
9
10
|
* A request that was in flight when the guest connection dropped.
|
|
@@ -20,6 +21,7 @@ export class DeskRequestFateUnknownError extends DeskError {
|
|
|
20
21
|
name = 'DeskRequestFateUnknownError';
|
|
21
22
|
}
|
|
22
23
|
export function createCloudHypervisorBackend(options = {}) {
|
|
24
|
+
const api = options.vmmApi ?? vmmApi;
|
|
23
25
|
const launcher = options.launcher ?? defaultLauncher;
|
|
24
26
|
const connect = options.connect ?? defaultConnect;
|
|
25
27
|
const platform = options.platform ?? process.platform;
|
|
@@ -53,7 +55,7 @@ export function createCloudHypervisorBackend(options = {}) {
|
|
|
53
55
|
try {
|
|
54
56
|
for (const share of plan.shares) {
|
|
55
57
|
await removeFile(share.socketPath);
|
|
56
|
-
const child = launcher(share.daemon.command, share.daemon.args, { identity: plan.launcherIdentity });
|
|
58
|
+
const child = launcher(share.daemon.command, share.daemon.args, { identity: plan.launcherIdentity, inheritedFds: options.inheritedFds });
|
|
57
59
|
daemons.push({ tag: share.tag, child, exit: watchExit(child), log: drainOutput(child) });
|
|
58
60
|
}
|
|
59
61
|
const deadline = now() + connectTimeoutMs;
|
|
@@ -116,7 +118,7 @@ export function createCloudHypervisorBackend(options = {}) {
|
|
|
116
118
|
catch (error) {
|
|
117
119
|
throw new DeskError(`Desk ${plan.deskId} could not start its shares: ${errorMessage(error)}`);
|
|
118
120
|
}
|
|
119
|
-
const child = launcher(plan.vmm.command, plan.vmm.args, { identity: plan.launcherIdentity });
|
|
121
|
+
const child = launcher(plan.vmm.command, plan.vmm.args, { identity: plan.launcherIdentity, inheritedFds: options.inheritedFds });
|
|
120
122
|
const log = drainOutput(child);
|
|
121
123
|
const exit = watchExit(child);
|
|
122
124
|
shares.supervise(child, exit);
|
|
@@ -132,13 +134,15 @@ export function createCloudHypervisorBackend(options = {}) {
|
|
|
132
134
|
};
|
|
133
135
|
// A restored guest comes back PAUSED (see the `resume=false` reasoning in
|
|
134
136
|
// the plan). Three things have to happen in this order and nowhere else:
|
|
135
|
-
// prove the restore finished, let the caller
|
|
137
|
+
// prove the restore finished, let the caller invalidate replay while the
|
|
136
138
|
// guest still cannot write, then start it.
|
|
137
139
|
if (plan.snapshot.restoring) {
|
|
138
140
|
try {
|
|
139
|
-
await waitForVmm(
|
|
141
|
+
await waitForVmm(api, plan.api.socketPath, { now, exited: exit, timeoutMs: connectTimeoutMs });
|
|
142
|
+
if (plan.snapshot.requirePrivateMemoryMapping)
|
|
143
|
+
await assertPrivateMemoryMapping(child.pid, plan.snapshot.dir);
|
|
140
144
|
await hooks?.onRestored?.();
|
|
141
|
-
await
|
|
145
|
+
await api(plan.api.socketPath, '/api/v1/vm.resume');
|
|
142
146
|
}
|
|
143
147
|
catch (error) {
|
|
144
148
|
await stop();
|
|
@@ -192,11 +196,28 @@ export function createCloudHypervisorBackend(options = {}) {
|
|
|
192
196
|
now,
|
|
193
197
|
idFactory,
|
|
194
198
|
requestTimeoutMs,
|
|
199
|
+
...(options.serviceVsock ? { connect: (port) => {
|
|
200
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
201
|
+
throw new DeskError('Guest port must be between 1 and 65535.');
|
|
202
|
+
return attemptHandshake(connect, plan.vsock.socketPath, port, handshakeTimeoutMs);
|
|
203
|
+
} } : {}),
|
|
195
204
|
stop,
|
|
196
|
-
|
|
205
|
+
pause: () => setVmmState(api, plan.api.socketPath, 'Paused'),
|
|
206
|
+
resume: () => setVmmState(api, plan.api.socketPath, 'Running'),
|
|
207
|
+
supportsPausedParkHook: true,
|
|
208
|
+
park: async (snapshotDir, hooks) => {
|
|
197
209
|
try {
|
|
198
|
-
await
|
|
199
|
-
|
|
210
|
+
await api(plan.api.socketPath, '/api/v1/vm.pause');
|
|
211
|
+
try {
|
|
212
|
+
await hooks?.onPaused?.();
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
try {
|
|
216
|
+
hooks?.onPausedError?.(error);
|
|
217
|
+
}
|
|
218
|
+
catch { /* preserve memory */ }
|
|
219
|
+
}
|
|
220
|
+
await api(plan.api.socketPath, '/api/v1/vm.snapshot', { destination_url: `file://${snapshotDir}` });
|
|
200
221
|
await writeShareManifest(snapshotDir, plan.shares);
|
|
201
222
|
}
|
|
202
223
|
finally {
|
|
@@ -207,9 +228,25 @@ export function createCloudHypervisorBackend(options = {}) {
|
|
|
207
228
|
}
|
|
208
229
|
return { boot };
|
|
209
230
|
}
|
|
231
|
+
/** Reconcile an ambiguous command failure against the VMM before releasing the fence. */
|
|
232
|
+
export async function setVmmState(call, socketPath, desired) {
|
|
233
|
+
try {
|
|
234
|
+
await call(socketPath, desired === 'Paused' ? '/api/v1/vm.pause' : '/api/v1/vm.resume');
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
try {
|
|
238
|
+
const response = await call(socketPath, '/api/v1/vm.info', undefined, 'GET');
|
|
239
|
+
const info = typeof response === 'string' ? JSON.parse(response) : null;
|
|
240
|
+
if (info && typeof info === 'object' && 'state' in info && info.state === desired)
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
catch { /* State is unknown: preserve the original failure and the request fence. */ }
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
210
247
|
const vmmApi = async (socketPath, path, body, method = 'PUT') => {
|
|
211
248
|
const payload = body === undefined ? undefined : Buffer.from(JSON.stringify(body), 'utf8');
|
|
212
|
-
await new Promise((resolve, reject) => {
|
|
249
|
+
return await new Promise((resolve, reject) => {
|
|
213
250
|
const request = httpRequest({
|
|
214
251
|
socketPath,
|
|
215
252
|
path,
|
|
@@ -225,7 +262,7 @@ const vmmApi = async (socketPath, path, body, method = 'PUT') => {
|
|
|
225
262
|
response.on('end', () => {
|
|
226
263
|
const status = response.statusCode ?? 0;
|
|
227
264
|
if (status >= 200 && status < 300) {
|
|
228
|
-
resolve();
|
|
265
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
229
266
|
return;
|
|
230
267
|
}
|
|
231
268
|
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 { 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"]}
|
|
1
|
+
{"version":3,"file":"backend.js","sourceRoot":"","sources":["../src/backend.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomUUID } from 'node:crypto'\nimport { assertPrivateMemoryMapping } from './template-mapping'\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 supportsPausedParkHook?: true\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, hooks?: DeskParkHooks): Promise<void>\n}\n\nexport interface DeskParkHooks {\n /** Called while the VM is paused, before its memory snapshot is written. */\n onPaused?: () => Promise<void>\n /** Reports a sidecar failure; ordinary memory parking continues. */\n onPausedError?: (error: unknown) => 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; inheritedFds?: readonly number[] },\n) => ChildProcess\n\nexport interface CloudHypervisorBackendOptions {\n /** VMM API transport, injectable for tests. */\n vmmApi?: VmmApiCall\n /** Descriptors retained by the VMM and share daemons, mapped consecutively from fd 3. */\n inheritedFds?: readonly number[]\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 api = options.vmmApi ?? vmmApi\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, inheritedFds: options.inheritedFds })\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, inheritedFds: options.inheritedFds })\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(api, plan.api.socketPath, { now, exited: exit, timeoutMs: connectTimeoutMs })\n if (plan.snapshot.requirePrivateMemoryMapping) await assertPrivateMemoryMapping(child.pid, plan.snapshot.dir)\n await hooks?.onRestored?.()\n await api(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(api, plan.api.socketPath, 'Paused'),\n resume: () => setVmmState(api, plan.api.socketPath, 'Running'),\n supportsPausedParkHook: true,\n park: async (snapshotDir, hooks) => {\n try {\n await api(plan.api.socketPath, '/api/v1/vm.pause')\n try { await hooks?.onPaused?.() }\n catch (error) { try { hooks?.onPausedError?.(error) } catch { /* preserve memory */ } }\n await api(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"]}
|