@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/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';
@@ -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,21 @@ 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.');
69
+ if (options.memoryRestoreMode && (!parkWithMemory || backend.hypervisor === 'virtualization')) {
70
+ throw new DeskError('Copy-on-write restore requires Cloud Hypervisor and parkWithMemory.');
71
+ }
72
+ const retireSnapshot = options.retireSnapshot ?? (async (from, to) => {
73
+ await rename(from, to);
74
+ const directory = await open(dirname(from), 'r');
75
+ try {
76
+ await directory.sync();
77
+ }
78
+ finally {
79
+ await directory.close();
80
+ }
81
+ });
65
82
  const makeSnapshotDir = options.makeSnapshotDir ?? ((path) => mkdir(path, { recursive: true }).then(() => undefined));
66
83
  const removeSnapshot = options.removeSnapshot ?? ((path) => rm(path, { recursive: true, force: true }));
67
84
  const freeBytes = options.freeBytes ?? defaultFreeBytes;
@@ -92,6 +109,7 @@ export function createDeskHost(options) {
92
109
  const queue = [];
93
110
  let occupied = 0;
94
111
  let nextCid = positiveInteger(options.cidBase ?? DEFAULT_CID_BASE, 'cidBase');
112
+ const cidOwners = new Map();
95
113
  let lastStartedAt = null;
96
114
  let lastSuspendedAt = null;
97
115
  let lastError = null;
@@ -125,7 +143,7 @@ export function createDeskHost(options) {
125
143
  record.lastActivityAt = now();
126
144
  }
127
145
  function requireMachine(record, epoch) {
128
- if (record.status !== 'resident' || record.epoch !== epoch || !record.machine) {
146
+ if (record.status !== 'resident' || record.paused || record.epoch !== epoch || !record.machine) {
129
147
  throw new DeskError(`Desk ${record.deskId} is not resident; resume it first.`);
130
148
  }
131
149
  return record.machine;
@@ -280,9 +298,46 @@ export function createDeskHost(options) {
280
298
  function snapshotDirFor(overlayPath, deskId) {
281
299
  return join(dirname(overlayPath), `${deskId}.snapshot`);
282
300
  }
283
- 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
+ }
321
+ if (record.status === 'suspending' && record.machine) {
322
+ // A previous stop failed. Keep the overlay fenced until a retry confirms
323
+ // the VMM has exited; never boot another VM or delete it underneath one.
324
+ await record.machine.shutdown();
325
+ await removeSnapshot(snapshotDirFor(record.startOptions.overlayPath, record.deskId));
326
+ record.machine = null;
327
+ record.status = 'suspended';
328
+ occupied -= 1;
329
+ lastSuspendedAt = iso(now());
330
+ drainQueue();
331
+ return true;
332
+ }
284
333
  if (record.status !== 'resident')
285
334
  return false;
335
+ // Resume before issuing trim, sync, or handover commands. On failure leave
336
+ // the resident record intact and fenced so the caller can retry.
337
+ if (record.paused) {
338
+ await record.machine.resume();
339
+ record.paused = false;
340
+ }
286
341
  await endHandover(record, 'revoked');
287
342
  for (const stop of [...record.streamStops])
288
343
  stop();
@@ -330,24 +385,108 @@ export function createDeskHost(options) {
330
385
  // it is flushed exactly as a park is.
331
386
  if (parking || !keepMemory)
332
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
+ }
333
395
  try {
334
396
  if (parking) {
335
397
  const snapshotDir = snapshotDirFor(record.startOptions.overlayPath, record.deskId);
336
398
  await makeSnapshotDir(snapshotDir);
337
- await machine.park(snapshotDir);
399
+ // Persist attachment identity before the VMM can produce a complete
400
+ // snapshot. A crash must never leave attached VM state with no record.
401
+ if (record.startOptions.readOnlyAttachments?.length) {
402
+ const manifest = join(snapshotDir, 'attachments.json');
403
+ const file = await open(manifest, 'wx', 0o600);
404
+ try {
405
+ await file.writeFile(JSON.stringify(record.startOptions.readOnlyAttachments));
406
+ await file.sync();
407
+ }
408
+ finally {
409
+ await file.close();
410
+ }
411
+ const directory = await open(snapshotDir, 'r');
412
+ try {
413
+ await directory.sync();
414
+ }
415
+ finally {
416
+ await directory.close();
417
+ }
418
+ }
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);
338
433
  const signature = await (options.cpuSignature ?? hostCpuSignature)();
339
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
+ }
340
473
  }
341
474
  else {
342
475
  await machine.shutdown();
343
476
  }
344
477
  }
345
478
  catch (error) {
346
- // `park` stops the VMM on its own way out, so the desk is down either
347
- // way; what is lost is the memory, not the desk. Leave nothing behind
348
- // that a later boot would try to restore from.
479
+ // Preparation can fail before park is called, so confirm the VMM is
480
+ // down even when park itself normally stops on failure.
349
481
  lastError = errorMessage(error);
350
- if (parking) {
482
+ try {
483
+ await machine.shutdown();
484
+ }
485
+ catch (stopError) {
486
+ record.machine = machine;
487
+ throw new DeskError(`Desk ${record.deskId} could not stop after teardown failed: ${errorMessage(stopError)}`);
488
+ }
489
+ if (parking && !strictMemory(record.startOptions)) {
351
490
  await removeSnapshot(snapshotDirFor(record.startOptions.overlayPath, record.deskId)).catch(() => undefined);
352
491
  }
353
492
  }
@@ -416,11 +555,35 @@ export function createDeskHost(options) {
416
555
  // as whatever the overlay copy fails with.
417
556
  await admitDisk(startOptions.overlayPath);
418
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
+ }
419
565
  // The SAME cid for both attempts below. A restored VM's snapshotted config
420
566
  // names its vsock and tap, and a cold-boot retry that renumbered them would
421
567
  // come up on a different tap than the one the egress rules were put on.
422
- const cid = nextCid++;
423
- const initramfsPath = resolveInitramfsPath();
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);
585
+ const initramfsPath = startOptions.initramfsPath ?? resolveInitramfsPath();
586
+ let restoreDir = snapshotDir;
424
587
  const planFor = (restore, shares = startOptions.shares) => buildDeskLaunchPlan({
425
588
  deskId,
426
589
  hypervisor: backend.hypervisor ?? 'cloud-hypervisor',
@@ -429,26 +592,28 @@ export function createDeskHost(options) {
429
592
  : { virtiofsdPath: options.virtiofsdPath, virtiofsdSandbox: options.virtiofsdSandbox }),
430
593
  vmmPath: options.vmmPath,
431
594
  kvmPath: options.kvmPath,
432
- kernelPath,
595
+ kernelPath: startOptions.kernelPath ?? kernelPath,
433
596
  ...(initramfsPath === undefined ? {} : { initramfsPath }),
434
597
  baseImagePath: startOptions.baseImage,
435
598
  overlayPath: startOptions.overlayPath,
436
599
  memoryMb: startOptions.memoryMb,
600
+ freePageReporting: startOptions.freePageReporting,
437
601
  vcpus: startOptions.vcpus,
438
602
  vsockCid: cid,
439
603
  runtimeDir: options.runtimeDir,
440
604
  tapDevice: startOptions.network?.tapDevice,
441
605
  macAddress: startOptions.network?.macAddress,
442
606
  shares,
607
+ readOnlyAttachments: startOptions.readOnlyAttachments,
443
608
  // Forward the host's cmdline only when set; otherwise the plan falls
444
609
  // back to DEFAULT_KERNEL_CMDLINE.
445
610
  ...(kernelCmdline === undefined ? {} : { kernelCmdline }),
446
611
  launcherIdentity: options.launcherIdentity,
447
- snapshotDir,
448
- ...(restore ? { restore: true } : {}),
449
- }, { pathExists, deviceExists, ...(options.isDirectory ? { isDirectory: options.isDirectory } : {}) });
612
+ snapshotDir: restore ? restoreDir : snapshotDir,
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 } : {}) });
450
615
  const restorePlan = async () => {
451
- const parked = await (options.readSnapshotShares ?? readShareManifest)(snapshotDir);
616
+ const parked = await (options.readSnapshotShares ?? readShareManifest)(restoreDir);
452
617
  try {
453
618
  return planFor(true, parked);
454
619
  }
@@ -456,34 +621,159 @@ export function createDeskHost(options) {
456
621
  throw new DeskError(`its restore, with the shares it was parked with, could not be planned: ${errorMessage(error)}`);
457
622
  }
458
623
  };
624
+ // Verify bytes, not just the caller's label, before the VMM can map them.
625
+ for (const image of startOptions.readOnlyAttachments ?? []) {
626
+ const hash = createHash('sha256');
627
+ for await (const chunk of createReadStream(image.path))
628
+ hash.update(chunk);
629
+ if (hash.digest('hex') !== image.sha256)
630
+ throw new DeskError(`Read-only attachment digest mismatch: ${image.path}`);
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
+ }
459
667
  let resuming = parkWithMemory && pathExists(snapshotDir);
668
+ if (initializeTemplate && !resuming)
669
+ throw new DeskError('A template fork requires its prepared snapshot.');
670
+ // Shared guest memory, required by virtio-fs, makes Cloud Hypervisor fall
671
+ // back to eager restore. Reject before retiring the snapshot or cold booting.
672
+ if (restoreMode) {
673
+ if (startOptions.shares?.length)
674
+ throw new DeskError('Copy-on-write or on-demand restore cannot be combined with virtio-fs shares.');
675
+ if (resuming && (await (options.readSnapshotShares ?? readShareManifest)(snapshotDir)).length) {
676
+ throw new DeskError('The parked snapshot has virtio-fs shares and cannot use copy-on-write or on-demand restore.');
677
+ }
678
+ }
460
679
  if (resuming) {
680
+ let savedAttachments;
681
+ try {
682
+ savedAttachments = JSON.parse(await readFile(join(snapshotDir, 'attachments.json'), 'utf8'));
683
+ }
684
+ catch (error) {
685
+ if (error.code !== 'ENOENT')
686
+ throw error;
687
+ savedAttachments = [];
688
+ }
689
+ if (JSON.stringify(savedAttachments) !== JSON.stringify(startOptions.readOnlyAttachments ?? [])) {
690
+ throw new DeskError('Snapshot read-only attachment identity does not match; restore requires the original images.');
691
+ }
461
692
  const saved = await (options.readSnapshotSignature ?? readSnapshotSignature)(snapshotDir);
693
+ if (strictRestore && saved === null)
694
+ throw new DeskError(`Desk ${deskId} snapshot has no CPU identity.`);
462
695
  if (saved !== null && saved !== await (options.cpuSignature ?? hostCpuSignature)()) {
696
+ if (strictRestore)
697
+ throw new DeskError(`Desk ${deskId} snapshot CPU identity does not match.`);
463
698
  await removeSnapshot(snapshotDir);
464
699
  resuming = false;
465
700
  recordCpuFallback(deskId, 'signature_mismatch');
466
701
  }
467
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
+ }
720
+ // Retire before starting the VMM: after a crash the old name can never be
721
+ // replayed against a disk the guest may have changed. The retired file is
722
+ // private backing memory, not a restorable snapshot.
723
+ if (resuming && restoreMode) {
724
+ restoreDir = `${snapshotDir}.source-${randomUUID()}`;
725
+ await retireSnapshot(snapshotDir, restoreDir);
726
+ }
468
727
  let machine;
469
728
  try {
729
+ const launchPlan = resuming ? await restorePlan() : planFor(false);
730
+ if (resuming && (startOptions.template || (startOptions.strictMemoryRestore && restoreMode === 'copyonwrite')))
731
+ launchPlan.snapshot.requirePrivateMemoryMapping = true;
470
732
  machine = resuming
471
- ? await backend.boot(await restorePlan(), {
472
- // The guest is restored and PAUSED here. Destroying the snapshot in
473
- // this window is what stops a memory image ever outliving the disk
474
- // state it belongs to. If it fails the restore is abandoned rather
475
- // than resumed, because resuming would leave both alive at once.
476
- onRestored: () => removeSnapshot(snapshotDir),
733
+ ? await backend.boot(launchPlan, {
734
+ // Eager restore removes the parked snapshot before resume. A CoW
735
+ // source was already retired durably before the VMM started.
736
+ onRestored: restoreMode ? async () => { } : () => removeSnapshot(snapshotDir),
477
737
  })
478
- : 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
+ }
479
764
  }
480
765
  catch (error) {
481
766
  if (resuming) {
767
+ if (strictRestore) {
768
+ lastError = `Desk ${deskId} memory restore failed: ${errorMessage(error)}`;
769
+ throw new DeskError(lastError);
770
+ }
482
771
  // A snapshot that will not come back is worse than no snapshot: it makes
483
772
  // the desk unbootable for as long as it sits there. Drop it and cold
484
773
  // boot. Safe by construction — the guest never ran, so the disk is
485
774
  // exactly as the pre-park sync left it.
486
775
  const why = errorMessage(error);
776
+ resuming = false;
487
777
  await removeSnapshot(snapshotDir);
488
778
  if (isIncompatibleCpuSnapshotError(error))
489
779
  recordCpuFallback(deskId, 'incompatible_cpuid');
@@ -499,12 +789,31 @@ export function createDeskHost(options) {
499
789
  : new DeskError(`Desk ${deskId} failed to boot: ${errorMessage(error)}`);
500
790
  }
501
791
  }
792
+ if (resuming && restoreMode) {
793
+ // The mapping remains file-backed until the VMM has exited. Never remove
794
+ // it after a failed stop; leaking it is safer than invalidating a live VM.
795
+ const restored = machine;
796
+ const stop = restored.shutdown.bind(restored);
797
+ const park = restored.park?.bind(restored);
798
+ const releaseSource = async () => {
799
+ try {
800
+ await removeSnapshot(restoreDir);
801
+ }
802
+ catch (error) {
803
+ lastError = `Snapshot source cleanup failed for ${deskId}: ${errorMessage(error)}`;
804
+ }
805
+ };
806
+ restored.shutdown = async () => { await stop(); await releaseSource(); };
807
+ if (park)
808
+ restored.park = async (dir, hooks) => { await park(dir, hooks); await releaseSource(); };
809
+ }
502
810
  let record = records.get(deskId);
503
811
  if (!record) {
504
812
  record = {
505
813
  deskId,
506
814
  startOptions,
507
815
  status: 'resident',
816
+ paused: false,
508
817
  machine,
509
818
  epoch: 1,
510
819
  leaseDeadline: 0,
@@ -523,6 +832,7 @@ export function createDeskHost(options) {
523
832
  else {
524
833
  record.startOptions = startOptions;
525
834
  record.status = 'resident';
835
+ record.paused = false;
526
836
  record.machine = machine;
527
837
  record.epoch += 1;
528
838
  record.jobs = new Map();
@@ -769,22 +1079,28 @@ export function createDeskHost(options) {
769
1079
  return {
770
1080
  deskId: record.deskId,
771
1081
  renewLease(ms) {
772
- requireMachine(record, epoch);
1082
+ // Renewing a lease is host-side and remains valid during resident pause.
1083
+ if (record.status !== 'resident' || record.epoch !== epoch || !record.machine) {
1084
+ throw new DeskError(`Desk ${record.deskId} is not resident; resume it first.`);
1085
+ }
773
1086
  record.leaseDeadline = now() + positiveInteger(ms, 'ms');
774
1087
  touch(record);
775
1088
  armTimer(record);
776
1089
  },
777
- async connect(port) {
1090
+ async connect(port, choice) {
778
1091
  requireMachine(record, epoch);
779
1092
  if (!Number.isInteger(port) || port < 1 || port > 65535)
780
1093
  throw new DeskError('Guest TCP port must be between 1 and 65535.');
781
1094
  const machine = requireMachine(record, epoch);
782
1095
  const address = record.startOptions.network?.guestAddress;
783
- if (!machine.connect && (!address || isIP(address) !== 4))
1096
+ const useVsock = choice?.transport === 'vsock' || (choice?.transport !== 'tcp' && !!machine.connect);
1097
+ if (useVsock && !machine.connect)
1098
+ throw new DeskError('This backend has no service vsock connector.');
1099
+ if (!useVsock && (!address || isIP(address) !== 4))
784
1100
  throw new DeskError('A guest IPv4 address is required to connect to a desk port.');
785
1101
  touch(record);
786
1102
  try {
787
- const stream = machine.connect ? await machine.connect(port) : await (options.connectGuest ?? connectGuestTcp)(address, port);
1103
+ const stream = useVsock ? await machine.connect(port) : await (options.connectGuest ?? connectGuestTcp)(address, port);
788
1104
  try {
789
1105
  requireMachine(record, epoch);
790
1106
  }
@@ -1162,29 +1478,61 @@ export function createDeskHost(options) {
1162
1478
  const deskId = cleanDeskId(startOptions.deskId);
1163
1479
  return serializeLifecycle(deskId, async () => {
1164
1480
  const existing = records.get(deskId);
1165
- if (existing?.status === 'resident' && existing.handle)
1481
+ if (existing?.status === 'suspending')
1482
+ throw new DeskError(`Desk ${deskId} has not stopped; retry suspend first.`);
1483
+ if (existing?.status === 'resident' && existing.handle) {
1484
+ if (existing.paused) {
1485
+ await existing.machine.resume();
1486
+ existing.paused = false;
1487
+ }
1166
1488
  return existing.handle;
1489
+ }
1167
1490
  return admit(() => boot(deskId, { ...startOptions, deskId }));
1168
1491
  });
1169
1492
  }
1493
+ function canPause(deskId) {
1494
+ const record = records.get(cleanDeskId(deskId));
1495
+ return record?.status === 'resident' && typeof record.machine?.pause === 'function' && typeof record.machine?.resume === 'function';
1496
+ }
1497
+ async function pause(deskId) {
1498
+ const id = cleanDeskId(deskId);
1499
+ await serializeLifecycle(id, async () => {
1500
+ const record = records.get(id);
1501
+ if (!record || record.status !== 'resident' || !canPause(id))
1502
+ throw new DeskError(`Desk ${id} cannot be paused.`);
1503
+ if (record.paused)
1504
+ return;
1505
+ if (record.screen?.handover)
1506
+ throw new DeskError(`Desk ${id} has an active handover.`);
1507
+ record.paused = true;
1508
+ await record.machine.pause();
1509
+ });
1510
+ }
1170
1511
  async function resume(deskId) {
1171
1512
  const id = cleanDeskId(deskId);
1172
1513
  return serializeLifecycle(id, async () => {
1173
1514
  const record = records.get(id);
1174
1515
  if (!record)
1175
1516
  throw new DeskError(`Unknown desk: ${deskId}`);
1176
- if (record.status === 'resident' && record.handle)
1517
+ if (record.status === 'suspending')
1518
+ throw new DeskError(`Desk ${id} has not stopped; retry suspend first.`);
1519
+ if (record.status === 'resident' && record.handle) {
1520
+ if (record.paused) {
1521
+ await record.machine.resume();
1522
+ record.paused = false;
1523
+ }
1177
1524
  return record.handle;
1525
+ }
1178
1526
  return admit(() => boot(record.deskId, record.startOptions));
1179
1527
  });
1180
1528
  }
1181
- async function suspend(deskId) {
1529
+ async function suspend(deskId, hooks) {
1182
1530
  const id = cleanDeskId(deskId);
1183
1531
  await serializeLifecycle(id, async () => {
1184
1532
  const record = records.get(id);
1185
1533
  if (!record)
1186
1534
  return;
1187
- await teardown(record);
1535
+ await teardown(record, true, hooks);
1188
1536
  });
1189
1537
  }
1190
1538
  async function shutdown(deskId) {
@@ -1197,7 +1545,10 @@ export function createDeskHost(options) {
1197
1545
  // A desk parked earlier still has its memory image on disk; dropping it is
1198
1546
  // what makes the next boot cold. The overlay is untouched — only `destroy`
1199
1547
  // may remove it.
1200
- 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);
1201
1552
  });
1202
1553
  }
1203
1554
  /**
@@ -1296,7 +1647,7 @@ export function createDeskHost(options) {
1296
1647
  lastSnapshotCpuFallback,
1297
1648
  };
1298
1649
  }
1299
- return { start, resume, suspend, shutdown, destroy, stats, sweep };
1650
+ return { start, canPause, pause, resume, suspend, shutdown, destroy, stats, sweep };
1300
1651
  }
1301
1652
  /**
1302
1653
  * Whether the overlay directory's filesystem can make reflink clones.