@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/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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
347
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
423
|
-
|
|
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)(
|
|
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(
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 === '
|
|
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 === '
|
|
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
|
-
|
|
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.
|