@braedonsaunders/appkit-desk 0.9.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 +49 -1
- package/backend.d.ts +9 -2
- package/backend.d.ts.map +1 -1
- package/backend.js +27 -4
- package/backend.js.map +1 -1
- package/host.d.ts +22 -4
- package/host.d.ts.map +1 -1
- package/host.js +176 -23
- package/host.js.map +1 -1
- package/machine.d.ts +2 -0
- package/machine.d.ts.map +1 -1
- package/machine.js +43 -2
- package/machine.js.map +1 -1
- package/package.json +1 -1
- package/plan.d.ts +15 -0
- package/plan.d.ts.map +1 -1
- package/plan.js +30 -4
- package/plan.js.map +1 -1
package/host.js
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* emission to the consumer's ports, and the handover masking contract. It
|
|
9
9
|
* never touches a database and knows nothing about tenants or approvals.
|
|
10
10
|
*/
|
|
11
|
-
import { existsSync } from 'node:fs';
|
|
11
|
+
import { createReadStream, existsSync } from 'node:fs';
|
|
12
12
|
import { execFile } from 'node:child_process';
|
|
13
|
-
import { mkdir, readFile, rm, statfs, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { mkdir, open, readFile, rename, rm, statfs, writeFile } from 'node:fs/promises';
|
|
14
14
|
import { arch, cpus, release, tmpdir } from 'node:os';
|
|
15
15
|
import { dirname, join } from 'node:path';
|
|
16
16
|
import { createHash, randomUUID } from 'node:crypto';
|
|
@@ -62,6 +62,19 @@ export function createDeskHost(options) {
|
|
|
62
62
|
const deviceExists = options.deviceExists ?? existsSync;
|
|
63
63
|
const removeOverlay = options.removeOverlay ?? ((path) => rm(path, { force: true }));
|
|
64
64
|
const parkWithMemory = options.parkWithMemory ?? false;
|
|
65
|
+
if (options.memoryRestoreMode && (!parkWithMemory || backend.hypervisor === 'virtualization')) {
|
|
66
|
+
throw new DeskError('Copy-on-write restore requires Cloud Hypervisor and parkWithMemory.');
|
|
67
|
+
}
|
|
68
|
+
const retireSnapshot = options.retireSnapshot ?? (async (from, to) => {
|
|
69
|
+
await rename(from, to);
|
|
70
|
+
const directory = await open(dirname(from), 'r');
|
|
71
|
+
try {
|
|
72
|
+
await directory.sync();
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
await directory.close();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
65
78
|
const makeSnapshotDir = options.makeSnapshotDir ?? ((path) => mkdir(path, { recursive: true }).then(() => undefined));
|
|
66
79
|
const removeSnapshot = options.removeSnapshot ?? ((path) => rm(path, { recursive: true, force: true }));
|
|
67
80
|
const freeBytes = options.freeBytes ?? defaultFreeBytes;
|
|
@@ -125,7 +138,7 @@ export function createDeskHost(options) {
|
|
|
125
138
|
record.lastActivityAt = now();
|
|
126
139
|
}
|
|
127
140
|
function requireMachine(record, epoch) {
|
|
128
|
-
if (record.status !== 'resident' || record.epoch !== epoch || !record.machine) {
|
|
141
|
+
if (record.status !== 'resident' || record.paused || record.epoch !== epoch || !record.machine) {
|
|
129
142
|
throw new DeskError(`Desk ${record.deskId} is not resident; resume it first.`);
|
|
130
143
|
}
|
|
131
144
|
return record.machine;
|
|
@@ -281,8 +294,26 @@ export function createDeskHost(options) {
|
|
|
281
294
|
return join(dirname(overlayPath), `${deskId}.snapshot`);
|
|
282
295
|
}
|
|
283
296
|
async function teardown(record, keepMemory = true) {
|
|
297
|
+
if (record.status === 'suspending' && record.machine) {
|
|
298
|
+
// A previous stop failed. Keep the overlay fenced until a retry confirms
|
|
299
|
+
// the VMM has exited; never boot another VM or delete it underneath one.
|
|
300
|
+
await record.machine.shutdown();
|
|
301
|
+
await removeSnapshot(snapshotDirFor(record.startOptions.overlayPath, record.deskId));
|
|
302
|
+
record.machine = null;
|
|
303
|
+
record.status = 'suspended';
|
|
304
|
+
occupied -= 1;
|
|
305
|
+
lastSuspendedAt = iso(now());
|
|
306
|
+
drainQueue();
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
284
309
|
if (record.status !== 'resident')
|
|
285
310
|
return false;
|
|
311
|
+
// Resume before issuing trim, sync, or handover commands. On failure leave
|
|
312
|
+
// the resident record intact and fenced so the caller can retry.
|
|
313
|
+
if (record.paused) {
|
|
314
|
+
await record.machine.resume();
|
|
315
|
+
record.paused = false;
|
|
316
|
+
}
|
|
286
317
|
await endHandover(record, 'revoked');
|
|
287
318
|
for (const stop of [...record.streamStops])
|
|
288
319
|
stop();
|
|
@@ -334,6 +365,26 @@ export function createDeskHost(options) {
|
|
|
334
365
|
if (parking) {
|
|
335
366
|
const snapshotDir = snapshotDirFor(record.startOptions.overlayPath, record.deskId);
|
|
336
367
|
await makeSnapshotDir(snapshotDir);
|
|
368
|
+
// Persist attachment identity before the VMM can produce a complete
|
|
369
|
+
// snapshot. A crash must never leave attached VM state with no record.
|
|
370
|
+
if (record.startOptions.readOnlyAttachments?.length) {
|
|
371
|
+
const manifest = join(snapshotDir, 'attachments.json');
|
|
372
|
+
const file = await open(manifest, 'wx', 0o600);
|
|
373
|
+
try {
|
|
374
|
+
await file.writeFile(JSON.stringify(record.startOptions.readOnlyAttachments));
|
|
375
|
+
await file.sync();
|
|
376
|
+
}
|
|
377
|
+
finally {
|
|
378
|
+
await file.close();
|
|
379
|
+
}
|
|
380
|
+
const directory = await open(snapshotDir, 'r');
|
|
381
|
+
try {
|
|
382
|
+
await directory.sync();
|
|
383
|
+
}
|
|
384
|
+
finally {
|
|
385
|
+
await directory.close();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
337
388
|
await machine.park(snapshotDir);
|
|
338
389
|
const signature = await (options.cpuSignature ?? hostCpuSignature)();
|
|
339
390
|
await (options.writeSnapshotSignature ?? writeSnapshotSignature)(snapshotDir, signature);
|
|
@@ -343,10 +394,16 @@ export function createDeskHost(options) {
|
|
|
343
394
|
}
|
|
344
395
|
}
|
|
345
396
|
catch (error) {
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
// that a later boot would try to restore from.
|
|
397
|
+
// Preparation can fail before park is called, so confirm the VMM is
|
|
398
|
+
// down even when park itself normally stops on failure.
|
|
349
399
|
lastError = errorMessage(error);
|
|
400
|
+
try {
|
|
401
|
+
await machine.shutdown();
|
|
402
|
+
}
|
|
403
|
+
catch (stopError) {
|
|
404
|
+
record.machine = machine;
|
|
405
|
+
throw new DeskError(`Desk ${record.deskId} could not stop after teardown failed: ${errorMessage(stopError)}`);
|
|
406
|
+
}
|
|
350
407
|
if (parking) {
|
|
351
408
|
await removeSnapshot(snapshotDirFor(record.startOptions.overlayPath, record.deskId)).catch(() => undefined);
|
|
352
409
|
}
|
|
@@ -420,7 +477,8 @@ export function createDeskHost(options) {
|
|
|
420
477
|
// names its vsock and tap, and a cold-boot retry that renumbered them would
|
|
421
478
|
// come up on a different tap than the one the egress rules were put on.
|
|
422
479
|
const cid = nextCid++;
|
|
423
|
-
const initramfsPath = resolveInitramfsPath();
|
|
480
|
+
const initramfsPath = startOptions.initramfsPath ?? resolveInitramfsPath();
|
|
481
|
+
let restoreDir = snapshotDir;
|
|
424
482
|
const planFor = (restore, shares = startOptions.shares) => buildDeskLaunchPlan({
|
|
425
483
|
deskId,
|
|
426
484
|
hypervisor: backend.hypervisor ?? 'cloud-hypervisor',
|
|
@@ -429,26 +487,28 @@ export function createDeskHost(options) {
|
|
|
429
487
|
: { virtiofsdPath: options.virtiofsdPath, virtiofsdSandbox: options.virtiofsdSandbox }),
|
|
430
488
|
vmmPath: options.vmmPath,
|
|
431
489
|
kvmPath: options.kvmPath,
|
|
432
|
-
kernelPath,
|
|
490
|
+
kernelPath: startOptions.kernelPath ?? kernelPath,
|
|
433
491
|
...(initramfsPath === undefined ? {} : { initramfsPath }),
|
|
434
492
|
baseImagePath: startOptions.baseImage,
|
|
435
493
|
overlayPath: startOptions.overlayPath,
|
|
436
494
|
memoryMb: startOptions.memoryMb,
|
|
495
|
+
freePageReporting: startOptions.freePageReporting,
|
|
437
496
|
vcpus: startOptions.vcpus,
|
|
438
497
|
vsockCid: cid,
|
|
439
498
|
runtimeDir: options.runtimeDir,
|
|
440
499
|
tapDevice: startOptions.network?.tapDevice,
|
|
441
500
|
macAddress: startOptions.network?.macAddress,
|
|
442
501
|
shares,
|
|
502
|
+
readOnlyAttachments: startOptions.readOnlyAttachments,
|
|
443
503
|
// Forward the host's cmdline only when set; otherwise the plan falls
|
|
444
504
|
// back to DEFAULT_KERNEL_CMDLINE.
|
|
445
505
|
...(kernelCmdline === undefined ? {} : { kernelCmdline }),
|
|
446
506
|
launcherIdentity: options.launcherIdentity,
|
|
447
|
-
snapshotDir,
|
|
448
|
-
...(restore ? { restore: true } : {}),
|
|
507
|
+
snapshotDir: restore ? restoreDir : snapshotDir,
|
|
508
|
+
...(restore ? { restore: true, ...(options.memoryRestoreMode ? { memoryRestoreMode: options.memoryRestoreMode } : {}) } : {}),
|
|
449
509
|
}, { pathExists, deviceExists, ...(options.isDirectory ? { isDirectory: options.isDirectory } : {}) });
|
|
450
510
|
const restorePlan = async () => {
|
|
451
|
-
const parked = await (options.readSnapshotShares ?? readShareManifest)(
|
|
511
|
+
const parked = await (options.readSnapshotShares ?? readShareManifest)(restoreDir);
|
|
452
512
|
try {
|
|
453
513
|
return planFor(true, parked);
|
|
454
514
|
}
|
|
@@ -456,8 +516,37 @@ export function createDeskHost(options) {
|
|
|
456
516
|
throw new DeskError(`its restore, with the shares it was parked with, could not be planned: ${errorMessage(error)}`);
|
|
457
517
|
}
|
|
458
518
|
};
|
|
519
|
+
// Verify bytes, not just the caller's label, before the VMM can map them.
|
|
520
|
+
for (const image of startOptions.readOnlyAttachments ?? []) {
|
|
521
|
+
const hash = createHash('sha256');
|
|
522
|
+
for await (const chunk of createReadStream(image.path))
|
|
523
|
+
hash.update(chunk);
|
|
524
|
+
if (hash.digest('hex') !== image.sha256)
|
|
525
|
+
throw new DeskError(`Read-only attachment digest mismatch: ${image.path}`);
|
|
526
|
+
}
|
|
459
527
|
let resuming = parkWithMemory && pathExists(snapshotDir);
|
|
528
|
+
// Shared guest memory, required by virtio-fs, makes Cloud Hypervisor fall
|
|
529
|
+
// back to eager restore. Reject before retiring the snapshot or cold booting.
|
|
530
|
+
if (options.memoryRestoreMode === 'copyonwrite') {
|
|
531
|
+
if (startOptions.shares?.length)
|
|
532
|
+
throw new DeskError('Copy-on-write restore cannot be combined with virtio-fs shares.');
|
|
533
|
+
if (resuming && (await (options.readSnapshotShares ?? readShareManifest)(snapshotDir)).length) {
|
|
534
|
+
throw new DeskError('The parked snapshot has virtio-fs shares and cannot use copy-on-write restore.');
|
|
535
|
+
}
|
|
536
|
+
}
|
|
460
537
|
if (resuming) {
|
|
538
|
+
let savedAttachments;
|
|
539
|
+
try {
|
|
540
|
+
savedAttachments = JSON.parse(await readFile(join(snapshotDir, 'attachments.json'), 'utf8'));
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
if (error.code !== 'ENOENT')
|
|
544
|
+
throw error;
|
|
545
|
+
savedAttachments = [];
|
|
546
|
+
}
|
|
547
|
+
if (JSON.stringify(savedAttachments) !== JSON.stringify(startOptions.readOnlyAttachments ?? [])) {
|
|
548
|
+
throw new DeskError('Snapshot read-only attachment identity does not match; restore requires the original images.');
|
|
549
|
+
}
|
|
461
550
|
const saved = await (options.readSnapshotSignature ?? readSnapshotSignature)(snapshotDir);
|
|
462
551
|
if (saved !== null && saved !== await (options.cpuSignature ?? hostCpuSignature)()) {
|
|
463
552
|
await removeSnapshot(snapshotDir);
|
|
@@ -465,15 +554,20 @@ export function createDeskHost(options) {
|
|
|
465
554
|
recordCpuFallback(deskId, 'signature_mismatch');
|
|
466
555
|
}
|
|
467
556
|
}
|
|
557
|
+
// Retire before starting the VMM: after a crash the old name can never be
|
|
558
|
+
// replayed against a disk the guest may have changed. The retired file is
|
|
559
|
+
// private backing memory, not a restorable snapshot.
|
|
560
|
+
if (resuming && options.memoryRestoreMode === 'copyonwrite') {
|
|
561
|
+
restoreDir = `${snapshotDir}.source-${randomUUID()}`;
|
|
562
|
+
await retireSnapshot(snapshotDir, restoreDir);
|
|
563
|
+
}
|
|
468
564
|
let machine;
|
|
469
565
|
try {
|
|
470
566
|
machine = resuming
|
|
471
567
|
? await backend.boot(await restorePlan(), {
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
// than resumed, because resuming would leave both alive at once.
|
|
476
|
-
onRestored: () => removeSnapshot(snapshotDir),
|
|
568
|
+
// Eager restore removes the parked snapshot before resume. A CoW
|
|
569
|
+
// source was already retired durably before the VMM started.
|
|
570
|
+
onRestored: options.memoryRestoreMode === 'copyonwrite' ? async () => { } : () => removeSnapshot(snapshotDir),
|
|
477
571
|
})
|
|
478
572
|
: await backend.boot(planFor(false));
|
|
479
573
|
}
|
|
@@ -484,6 +578,7 @@ export function createDeskHost(options) {
|
|
|
484
578
|
// boot. Safe by construction — the guest never ran, so the disk is
|
|
485
579
|
// exactly as the pre-park sync left it.
|
|
486
580
|
const why = errorMessage(error);
|
|
581
|
+
resuming = false;
|
|
487
582
|
await removeSnapshot(snapshotDir);
|
|
488
583
|
if (isIncompatibleCpuSnapshotError(error))
|
|
489
584
|
recordCpuFallback(deskId, 'incompatible_cpuid');
|
|
@@ -499,12 +594,31 @@ export function createDeskHost(options) {
|
|
|
499
594
|
: new DeskError(`Desk ${deskId} failed to boot: ${errorMessage(error)}`);
|
|
500
595
|
}
|
|
501
596
|
}
|
|
597
|
+
if (resuming && options.memoryRestoreMode === 'copyonwrite') {
|
|
598
|
+
// The mapping remains file-backed until the VMM has exited. Never remove
|
|
599
|
+
// it after a failed stop; leaking it is safer than invalidating a live VM.
|
|
600
|
+
const restored = machine;
|
|
601
|
+
const stop = restored.shutdown.bind(restored);
|
|
602
|
+
const park = restored.park?.bind(restored);
|
|
603
|
+
const releaseSource = async () => {
|
|
604
|
+
try {
|
|
605
|
+
await removeSnapshot(restoreDir);
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
lastError = `Snapshot source cleanup failed for ${deskId}: ${errorMessage(error)}`;
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
restored.shutdown = async () => { await stop(); await releaseSource(); };
|
|
612
|
+
if (park)
|
|
613
|
+
restored.park = async (dir) => { await park(dir); await releaseSource(); };
|
|
614
|
+
}
|
|
502
615
|
let record = records.get(deskId);
|
|
503
616
|
if (!record) {
|
|
504
617
|
record = {
|
|
505
618
|
deskId,
|
|
506
619
|
startOptions,
|
|
507
620
|
status: 'resident',
|
|
621
|
+
paused: false,
|
|
508
622
|
machine,
|
|
509
623
|
epoch: 1,
|
|
510
624
|
leaseDeadline: 0,
|
|
@@ -523,6 +637,7 @@ export function createDeskHost(options) {
|
|
|
523
637
|
else {
|
|
524
638
|
record.startOptions = startOptions;
|
|
525
639
|
record.status = 'resident';
|
|
640
|
+
record.paused = false;
|
|
526
641
|
record.machine = machine;
|
|
527
642
|
record.epoch += 1;
|
|
528
643
|
record.jobs = new Map();
|
|
@@ -769,22 +884,28 @@ export function createDeskHost(options) {
|
|
|
769
884
|
return {
|
|
770
885
|
deskId: record.deskId,
|
|
771
886
|
renewLease(ms) {
|
|
772
|
-
|
|
887
|
+
// Renewing a lease is host-side and remains valid during resident pause.
|
|
888
|
+
if (record.status !== 'resident' || record.epoch !== epoch || !record.machine) {
|
|
889
|
+
throw new DeskError(`Desk ${record.deskId} is not resident; resume it first.`);
|
|
890
|
+
}
|
|
773
891
|
record.leaseDeadline = now() + positiveInteger(ms, 'ms');
|
|
774
892
|
touch(record);
|
|
775
893
|
armTimer(record);
|
|
776
894
|
},
|
|
777
|
-
async connect(port) {
|
|
895
|
+
async connect(port, choice) {
|
|
778
896
|
requireMachine(record, epoch);
|
|
779
897
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
780
898
|
throw new DeskError('Guest TCP port must be between 1 and 65535.');
|
|
781
899
|
const machine = requireMachine(record, epoch);
|
|
782
900
|
const address = record.startOptions.network?.guestAddress;
|
|
783
|
-
|
|
901
|
+
const useVsock = choice?.transport === 'vsock' || (choice?.transport !== 'tcp' && !!machine.connect);
|
|
902
|
+
if (useVsock && !machine.connect)
|
|
903
|
+
throw new DeskError('This backend has no service vsock connector.');
|
|
904
|
+
if (!useVsock && (!address || isIP(address) !== 4))
|
|
784
905
|
throw new DeskError('A guest IPv4 address is required to connect to a desk port.');
|
|
785
906
|
touch(record);
|
|
786
907
|
try {
|
|
787
|
-
const stream =
|
|
908
|
+
const stream = useVsock ? await machine.connect(port) : await (options.connectGuest ?? connectGuestTcp)(address, port);
|
|
788
909
|
try {
|
|
789
910
|
requireMachine(record, epoch);
|
|
790
911
|
}
|
|
@@ -1162,19 +1283,51 @@ export function createDeskHost(options) {
|
|
|
1162
1283
|
const deskId = cleanDeskId(startOptions.deskId);
|
|
1163
1284
|
return serializeLifecycle(deskId, async () => {
|
|
1164
1285
|
const existing = records.get(deskId);
|
|
1165
|
-
if (existing?.status === '
|
|
1286
|
+
if (existing?.status === 'suspending')
|
|
1287
|
+
throw new DeskError(`Desk ${deskId} has not stopped; retry suspend first.`);
|
|
1288
|
+
if (existing?.status === 'resident' && existing.handle) {
|
|
1289
|
+
if (existing.paused) {
|
|
1290
|
+
await existing.machine.resume();
|
|
1291
|
+
existing.paused = false;
|
|
1292
|
+
}
|
|
1166
1293
|
return existing.handle;
|
|
1294
|
+
}
|
|
1167
1295
|
return admit(() => boot(deskId, { ...startOptions, deskId }));
|
|
1168
1296
|
});
|
|
1169
1297
|
}
|
|
1298
|
+
function canPause(deskId) {
|
|
1299
|
+
const record = records.get(cleanDeskId(deskId));
|
|
1300
|
+
return record?.status === 'resident' && typeof record.machine?.pause === 'function' && typeof record.machine?.resume === 'function';
|
|
1301
|
+
}
|
|
1302
|
+
async function pause(deskId) {
|
|
1303
|
+
const id = cleanDeskId(deskId);
|
|
1304
|
+
await serializeLifecycle(id, async () => {
|
|
1305
|
+
const record = records.get(id);
|
|
1306
|
+
if (!record || record.status !== 'resident' || !canPause(id))
|
|
1307
|
+
throw new DeskError(`Desk ${id} cannot be paused.`);
|
|
1308
|
+
if (record.paused)
|
|
1309
|
+
return;
|
|
1310
|
+
if (record.screen?.handover)
|
|
1311
|
+
throw new DeskError(`Desk ${id} has an active handover.`);
|
|
1312
|
+
record.paused = true;
|
|
1313
|
+
await record.machine.pause();
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1170
1316
|
async function resume(deskId) {
|
|
1171
1317
|
const id = cleanDeskId(deskId);
|
|
1172
1318
|
return serializeLifecycle(id, async () => {
|
|
1173
1319
|
const record = records.get(id);
|
|
1174
1320
|
if (!record)
|
|
1175
1321
|
throw new DeskError(`Unknown desk: ${deskId}`);
|
|
1176
|
-
if (record.status === '
|
|
1322
|
+
if (record.status === 'suspending')
|
|
1323
|
+
throw new DeskError(`Desk ${id} has not stopped; retry suspend first.`);
|
|
1324
|
+
if (record.status === 'resident' && record.handle) {
|
|
1325
|
+
if (record.paused) {
|
|
1326
|
+
await record.machine.resume();
|
|
1327
|
+
record.paused = false;
|
|
1328
|
+
}
|
|
1177
1329
|
return record.handle;
|
|
1330
|
+
}
|
|
1178
1331
|
return admit(() => boot(record.deskId, record.startOptions));
|
|
1179
1332
|
});
|
|
1180
1333
|
}
|
|
@@ -1296,7 +1449,7 @@ export function createDeskHost(options) {
|
|
|
1296
1449
|
lastSnapshotCpuFallback,
|
|
1297
1450
|
};
|
|
1298
1451
|
}
|
|
1299
|
-
return { start, resume, suspend, shutdown, destroy, stats, sweep };
|
|
1452
|
+
return { start, canPause, pause, resume, suspend, shutdown, destroy, stats, sweep };
|
|
1300
1453
|
}
|
|
1301
1454
|
/**
|
|
1302
1455
|
* Whether the overlay directory's filesystem can make reflink clones.
|