@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/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
- // `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.
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)(snapshotDir);
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
- // 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),
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
- requireMachine(record, epoch);
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
- if (!machine.connect && (!address || isIP(address) !== 4))
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 = machine.connect ? await machine.connect(port) : await (options.connectGuest ?? connectGuestTcp)(address, port);
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 === 'resident' && existing.handle)
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 === 'resident' && record.handle)
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.