@braedonsaunders/appkit-desk 0.10.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/host.js CHANGED
@@ -20,6 +20,8 @@ import { buildDeskLaunchPlan, cleanDeskId, DEFAULT_KVM_PATH, DEFAULT_VMM_PATH, D
20
20
  import { cloudHypervisorBackend, } from './backend.js';
21
21
  import { readShareManifest } from './machine.js';
22
22
  import { parseCapabilities, parseClipboardText, parseExecResult, parseHandoverBegun, parseJobStarted, parseObservation, } from './protocol.js';
23
+ /** Capability marker for the fenced template lifecycle and mapping proof. */
24
+ export const DESK_TEMPLATE_VERSION = 3;
23
25
  export const DEFAULT_CAPACITY = 4;
24
26
  export const DEFAULT_IDLE_SUSPEND_MS = 5 * 60_000;
25
27
  export const DEFAULT_LEASE_MS = 15 * 60_000;
@@ -62,6 +64,8 @@ export function createDeskHost(options) {
62
64
  const deviceExists = options.deviceExists ?? existsSync;
63
65
  const removeOverlay = options.removeOverlay ?? ((path) => rm(path, { force: true }));
64
66
  const parkWithMemory = options.parkWithMemory ?? false;
67
+ if (options.strictMemoryRestore && !parkWithMemory)
68
+ throw new DeskError('Strict memory restore requires parkWithMemory.');
65
69
  if (options.memoryRestoreMode && (!parkWithMemory || backend.hypervisor === 'virtualization')) {
66
70
  throw new DeskError('Copy-on-write restore requires Cloud Hypervisor and parkWithMemory.');
67
71
  }
@@ -105,6 +109,7 @@ export function createDeskHost(options) {
105
109
  const queue = [];
106
110
  let occupied = 0;
107
111
  let nextCid = positiveInteger(options.cidBase ?? DEFAULT_CID_BASE, 'cidBase');
112
+ const cidOwners = new Map();
108
113
  let lastStartedAt = null;
109
114
  let lastSuspendedAt = null;
110
115
  let lastError = null;
@@ -293,7 +298,26 @@ export function createDeskHost(options) {
293
298
  function snapshotDirFor(overlayPath, deskId) {
294
299
  return join(dirname(overlayPath), `${deskId}.snapshot`);
295
300
  }
296
- async function teardown(record, keepMemory = true) {
301
+ function strictMemory(start) {
302
+ return Boolean(options.strictMemoryRestore || start.strictMemoryRestore || start.template
303
+ || (start.memoryRestoreMode ?? options.memoryRestoreMode) === 'ondemand');
304
+ }
305
+ async function clearRestoreMarker(snapshotDir) {
306
+ await rm(`${snapshotDir}.restore-pending`, { force: true });
307
+ const parent = await open(dirname(snapshotDir), 'r');
308
+ try {
309
+ await parent.sync();
310
+ }
311
+ finally {
312
+ await parent.close();
313
+ }
314
+ }
315
+ async function teardown(record, keepMemory = true, hooks) {
316
+ let sidecarFailed = false;
317
+ if (hooks) {
318
+ const supplied = hooks;
319
+ hooks = { ...supplied, onPausedError: error => { sidecarFailed = true; supplied.onPausedError?.(error); } };
320
+ }
297
321
  if (record.status === 'suspending' && record.machine) {
298
322
  // A previous stop failed. Keep the overlay fenced until a retry confirms
299
323
  // the VMM has exited; never boot another VM or delete it underneath one.
@@ -361,6 +385,13 @@ export function createDeskHost(options) {
361
385
  // it is flushed exactly as a park is.
362
386
  if (parking || !keepMemory)
363
387
  await syncGuest(record, machine);
388
+ if (hooks && (!parking || !machine.supportsPausedParkHook)) {
389
+ try {
390
+ hooks.onPausedError?.(new DeskError('Paused park hooks are unavailable'));
391
+ }
392
+ catch { /* preserve memory */ }
393
+ hooks = undefined;
394
+ }
364
395
  try {
365
396
  if (parking) {
366
397
  const snapshotDir = snapshotDirFor(record.startOptions.overlayPath, record.deskId);
@@ -385,9 +416,60 @@ export function createDeskHost(options) {
385
416
  await directory.close();
386
417
  }
387
418
  }
388
- await machine.park(snapshotDir);
419
+ if (hooks?.beforePark) {
420
+ try {
421
+ await hooks.beforePark(machine);
422
+ }
423
+ catch (error) {
424
+ try {
425
+ hooks.onPausedError?.(error);
426
+ }
427
+ catch { /* preserve memory */ }
428
+ ;
429
+ hooks = undefined;
430
+ }
431
+ }
432
+ await machine.park(snapshotDir, hooks);
389
433
  const signature = await (options.cpuSignature ?? hostCpuSignature)();
390
434
  await (options.writeSnapshotSignature ?? writeSnapshotSignature)(snapshotDir, signature);
435
+ if (strictMemory(record.startOptions) || (hooks?.onParked && !sidecarFailed)) {
436
+ for (const name of ['config.json', 'state.json', 'memory-ranges', 'appkit-cpu-signature']) {
437
+ const file = await open(join(snapshotDir, name), 'r');
438
+ try {
439
+ await file.sync();
440
+ }
441
+ finally {
442
+ await file.close();
443
+ }
444
+ }
445
+ const directory = await open(snapshotDir, 'r');
446
+ try {
447
+ await directory.sync();
448
+ }
449
+ finally {
450
+ await directory.close();
451
+ }
452
+ const parent = await open(dirname(snapshotDir), 'r');
453
+ try {
454
+ await parent.sync();
455
+ }
456
+ finally {
457
+ await parent.close();
458
+ }
459
+ if (strictMemory(record.startOptions))
460
+ await clearRestoreMarker(snapshotDir);
461
+ }
462
+ if (hooks?.onParked && !sidecarFailed) {
463
+ try {
464
+ await hooks.onParked(snapshotDir);
465
+ }
466
+ catch (error) {
467
+ try {
468
+ hooks.onPausedError?.(error);
469
+ }
470
+ catch { /* preserve memory */ }
471
+ }
472
+ }
391
473
  }
392
474
  else {
393
475
  await machine.shutdown();
@@ -404,7 +486,7 @@ export function createDeskHost(options) {
404
486
  record.machine = machine;
405
487
  throw new DeskError(`Desk ${record.deskId} could not stop after teardown failed: ${errorMessage(stopError)}`);
406
488
  }
407
- if (parking) {
489
+ if (parking && !strictMemory(record.startOptions)) {
408
490
  await removeSnapshot(snapshotDirFor(record.startOptions.overlayPath, record.deskId)).catch(() => undefined);
409
491
  }
410
492
  }
@@ -473,10 +555,33 @@ export function createDeskHost(options) {
473
555
  // as whatever the overlay copy fails with.
474
556
  await admitDisk(startOptions.overlayPath);
475
557
  const snapshotDir = snapshotDirFor(startOptions.overlayPath, deskId);
558
+ const strictRestore = strictMemory(startOptions);
559
+ const restoreMode = startOptions.template ? 'copyonwrite' : startOptions.memoryRestoreMode ?? options.memoryRestoreMode;
560
+ if ((strictRestore || restoreMode) && (!parkWithMemory || backend.hypervisor === 'virtualization'))
561
+ throw new DeskError('Strict or copy-on-write restore requires Cloud Hypervisor and parkWithMemory.');
562
+ if (strictRestore && existsSync(`${snapshotDir}.restore-pending`)) {
563
+ throw new DeskError(`Desk ${deskId} has an interrupted memory restore; operator recovery is required.`);
564
+ }
476
565
  // The SAME cid for both attempts below. A restored VM's snapshotted config
477
566
  // names its vsock and tap, and a cold-boot retry that renumbered them would
478
567
  // come up on a different tap than the one the egress rules were put on.
479
- const cid = nextCid++;
568
+ let cid;
569
+ if (startOptions.vsockCid !== undefined) {
570
+ cid = startOptions.vsockCid;
571
+ if (!Number.isSafeInteger(cid) || cid < 3 || cid > 0xfffffffe)
572
+ throw new DeskError('Invalid guest vsock CID.');
573
+ const owner = cidOwners.get(cid);
574
+ if (owner !== undefined && owner !== deskId)
575
+ throw new DeskError('Guest vsock CID is already assigned.');
576
+ }
577
+ else {
578
+ while (cidOwners.has(nextCid))
579
+ nextCid++;
580
+ cid = nextCid++;
581
+ if (cid > 0xfffffffe)
582
+ throw new DeskError('Guest vsock CID space exhausted.');
583
+ }
584
+ cidOwners.set(cid, deskId);
480
585
  const initramfsPath = startOptions.initramfsPath ?? resolveInitramfsPath();
481
586
  let restoreDir = snapshotDir;
482
587
  const planFor = (restore, shares = startOptions.shares) => buildDeskLaunchPlan({
@@ -505,8 +610,8 @@ export function createDeskHost(options) {
505
610
  ...(kernelCmdline === undefined ? {} : { kernelCmdline }),
506
611
  launcherIdentity: options.launcherIdentity,
507
612
  snapshotDir: restore ? restoreDir : snapshotDir,
508
- ...(restore ? { restore: true, ...(options.memoryRestoreMode ? { memoryRestoreMode: options.memoryRestoreMode } : {}) } : {}),
509
- }, { pathExists, deviceExists, ...(options.isDirectory ? { isDirectory: options.isDirectory } : {}) });
613
+ ...(restore ? { restore: true, ...(restoreMode ? { memoryRestoreMode: restoreMode } : {}) } : {}),
614
+ }, { pathExists: (path) => Boolean(startOptions.template && path === dirname(startOptions.overlayPath)) || pathExists(path), deviceExists, ...(options.isDirectory ? { isDirectory: options.isDirectory } : {}) });
510
615
  const restorePlan = async () => {
511
616
  const parked = await (options.readSnapshotShares ?? readShareManifest)(restoreDir);
512
617
  try {
@@ -524,14 +629,51 @@ export function createDeskHost(options) {
524
629
  if (hash.digest('hex') !== image.sha256)
525
630
  throw new DeskError(`Read-only attachment digest mismatch: ${image.path}`);
526
631
  }
632
+ let initializeTemplate = false;
633
+ if (startOptions.template) {
634
+ if (!strictRestore || restoreMode !== 'copyonwrite' || backend.hypervisor === 'virtualization') {
635
+ throw new DeskError('Template forks require strict Cloud Hypervisor copy-on-write restore.');
636
+ }
637
+ const childDir = dirname(startOptions.overlayPath);
638
+ const prepared = join(childDir, '.prepared');
639
+ const initialized = join(childDir, '.initialized');
640
+ if (existsSync(initialized) && !existsSync(snapshotDir))
641
+ throw new DeskError('Template descendant has no saved memory; recovery is required.');
642
+ if (existsSync(initialized))
643
+ await startOptions.template.verifyResume(planFor(true));
644
+ if (!existsSync(initialized)) {
645
+ await startOptions.template.holdFence();
646
+ if (!existsSync(prepared)) {
647
+ if (existsSync(startOptions.overlayPath) || existsSync(snapshotDir))
648
+ throw new DeskError('Incomplete template fork needs recovery.');
649
+ await startOptions.template.prepare(planFor(false));
650
+ }
651
+ if (await readFile(prepared, 'utf8') !== '1\n' || !existsSync(startOptions.overlayPath) || !existsSync(snapshotDir))
652
+ throw new DeskError('Template fork was not prepared completely.');
653
+ const planned = planFor(true);
654
+ if (planned.hypervisor !== 'cloud-hypervisor')
655
+ throw new DeskError('Template fork requires Cloud Hypervisor.');
656
+ const savedConfig = JSON.parse(await readFile(join(snapshotDir, 'config.json'), 'utf8'));
657
+ if (savedConfig.disks?.[0]?.path !== startOptions.overlayPath || savedConfig.net?.[0]?.tap !== planned.tap.device
658
+ || savedConfig.net?.[0]?.mac?.toLowerCase() !== planned.tap.mac.toLowerCase()
659
+ || savedConfig.vsock?.cid !== planned.vsock.cid || savedConfig.vsock.socket !== planned.vsock.socketPath
660
+ || savedConfig.memory?.size !== planned.memoryMb * 1024 * 1024 || savedConfig.cpus?.boot_vcpus !== planned.vcpus) {
661
+ throw new DeskError('Prepared template device or resource identity does not match its launch plan.');
662
+ }
663
+ await startOptions.template.verifyPrepared(planned);
664
+ initializeTemplate = true;
665
+ }
666
+ }
527
667
  let resuming = parkWithMemory && pathExists(snapshotDir);
668
+ if (initializeTemplate && !resuming)
669
+ throw new DeskError('A template fork requires its prepared snapshot.');
528
670
  // Shared guest memory, required by virtio-fs, makes Cloud Hypervisor fall
529
671
  // back to eager restore. Reject before retiring the snapshot or cold booting.
530
- if (options.memoryRestoreMode === 'copyonwrite') {
672
+ if (restoreMode) {
531
673
  if (startOptions.shares?.length)
532
- throw new DeskError('Copy-on-write restore cannot be combined with virtio-fs shares.');
674
+ throw new DeskError('Copy-on-write or on-demand restore cannot be combined with virtio-fs shares.');
533
675
  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.');
676
+ throw new DeskError('The parked snapshot has virtio-fs shares and cannot use copy-on-write or on-demand restore.');
535
677
  }
536
678
  }
537
679
  if (resuming) {
@@ -548,31 +690,84 @@ export function createDeskHost(options) {
548
690
  throw new DeskError('Snapshot read-only attachment identity does not match; restore requires the original images.');
549
691
  }
550
692
  const saved = await (options.readSnapshotSignature ?? readSnapshotSignature)(snapshotDir);
693
+ if (strictRestore && saved === null)
694
+ throw new DeskError(`Desk ${deskId} snapshot has no CPU identity.`);
551
695
  if (saved !== null && saved !== await (options.cpuSignature ?? hostCpuSignature)()) {
696
+ if (strictRestore)
697
+ throw new DeskError(`Desk ${deskId} snapshot CPU identity does not match.`);
552
698
  await removeSnapshot(snapshotDir);
553
699
  resuming = false;
554
700
  recordCpuFallback(deskId, 'signature_mismatch');
555
701
  }
556
702
  }
703
+ if (strictRestore) {
704
+ const marker = await open(`${snapshotDir}.restore-pending`, 'wx', 0o600);
705
+ try {
706
+ await marker.writeFile('restore in progress\n');
707
+ await marker.sync();
708
+ }
709
+ finally {
710
+ await marker.close();
711
+ }
712
+ const parent = await open(dirname(snapshotDir), 'r');
713
+ try {
714
+ await parent.sync();
715
+ }
716
+ finally {
717
+ await parent.close();
718
+ }
719
+ }
557
720
  // Retire before starting the VMM: after a crash the old name can never be
558
721
  // replayed against a disk the guest may have changed. The retired file is
559
722
  // private backing memory, not a restorable snapshot.
560
- if (resuming && options.memoryRestoreMode === 'copyonwrite') {
723
+ if (resuming && restoreMode) {
561
724
  restoreDir = `${snapshotDir}.source-${randomUUID()}`;
562
725
  await retireSnapshot(snapshotDir, restoreDir);
563
726
  }
564
727
  let machine;
565
728
  try {
729
+ const launchPlan = resuming ? await restorePlan() : planFor(false);
730
+ if (resuming && (startOptions.template || (startOptions.strictMemoryRestore && restoreMode === 'copyonwrite')))
731
+ launchPlan.snapshot.requirePrivateMemoryMapping = true;
566
732
  machine = resuming
567
- ? await backend.boot(await restorePlan(), {
733
+ ? await backend.boot(launchPlan, {
568
734
  // Eager restore removes the parked snapshot before resume. A CoW
569
735
  // source was already retired durably before the VMM started.
570
- onRestored: options.memoryRestoreMode === 'copyonwrite' ? async () => { } : () => removeSnapshot(snapshotDir),
736
+ onRestored: restoreMode ? async () => { } : () => removeSnapshot(snapshotDir),
571
737
  })
572
- : await backend.boot(planFor(false));
738
+ : await backend.boot(launchPlan);
739
+ if (initializeTemplate && startOptions.template) {
740
+ try {
741
+ await startOptions.template.initialize(machine, launchPlan);
742
+ const initialized = await open(join(dirname(startOptions.overlayPath), '.initialized'), 'wx', 0o600);
743
+ try {
744
+ await initialized.writeFile('1\n');
745
+ await initialized.sync();
746
+ }
747
+ finally {
748
+ await initialized.close();
749
+ }
750
+ const directory = await open(dirname(startOptions.overlayPath), 'r');
751
+ try {
752
+ await directory.sync();
753
+ }
754
+ finally {
755
+ await directory.close();
756
+ }
757
+ await startOptions.template.releaseFence();
758
+ }
759
+ catch (error) {
760
+ await machine.shutdown();
761
+ throw error;
762
+ }
763
+ }
573
764
  }
574
765
  catch (error) {
575
766
  if (resuming) {
767
+ if (strictRestore) {
768
+ lastError = `Desk ${deskId} memory restore failed: ${errorMessage(error)}`;
769
+ throw new DeskError(lastError);
770
+ }
576
771
  // A snapshot that will not come back is worse than no snapshot: it makes
577
772
  // the desk unbootable for as long as it sits there. Drop it and cold
578
773
  // boot. Safe by construction — the guest never ran, so the disk is
@@ -594,7 +789,7 @@ export function createDeskHost(options) {
594
789
  : new DeskError(`Desk ${deskId} failed to boot: ${errorMessage(error)}`);
595
790
  }
596
791
  }
597
- if (resuming && options.memoryRestoreMode === 'copyonwrite') {
792
+ if (resuming && restoreMode) {
598
793
  // The mapping remains file-backed until the VMM has exited. Never remove
599
794
  // it after a failed stop; leaking it is safer than invalidating a live VM.
600
795
  const restored = machine;
@@ -610,7 +805,7 @@ export function createDeskHost(options) {
610
805
  };
611
806
  restored.shutdown = async () => { await stop(); await releaseSource(); };
612
807
  if (park)
613
- restored.park = async (dir) => { await park(dir); await releaseSource(); };
808
+ restored.park = async (dir, hooks) => { await park(dir, hooks); await releaseSource(); };
614
809
  }
615
810
  let record = records.get(deskId);
616
811
  if (!record) {
@@ -1331,13 +1526,13 @@ export function createDeskHost(options) {
1331
1526
  return admit(() => boot(record.deskId, record.startOptions));
1332
1527
  });
1333
1528
  }
1334
- async function suspend(deskId) {
1529
+ async function suspend(deskId, hooks) {
1335
1530
  const id = cleanDeskId(deskId);
1336
1531
  await serializeLifecycle(id, async () => {
1337
1532
  const record = records.get(id);
1338
1533
  if (!record)
1339
1534
  return;
1340
- await teardown(record);
1535
+ await teardown(record, true, hooks);
1341
1536
  });
1342
1537
  }
1343
1538
  async function shutdown(deskId) {
@@ -1350,7 +1545,10 @@ export function createDeskHost(options) {
1350
1545
  // A desk parked earlier still has its memory image on disk; dropping it is
1351
1546
  // what makes the next boot cold. The overlay is untouched — only `destroy`
1352
1547
  // may remove it.
1353
- await removeSnapshot(snapshotDirFor(record.startOptions.overlayPath, record.deskId));
1548
+ const snapshot = snapshotDirFor(record.startOptions.overlayPath, record.deskId);
1549
+ await removeSnapshot(snapshot);
1550
+ if (strictMemory(record.startOptions))
1551
+ await clearRestoreMarker(snapshot);
1354
1552
  });
1355
1553
  }
1356
1554
  /**