@braedonsaunders/appkit-desk 0.5.1 → 0.6.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/backend.d.ts +43 -1
- package/backend.d.ts.map +1 -1
- package/backend.js +110 -1
- package/backend.js.map +1 -1
- package/host.d.ts +25 -0
- package/host.d.ts.map +1 -1
- package/host.js +99 -9
- package/host.js.map +1 -1
- package/package.json +1 -1
- package/plan.d.ts +21 -0
- package/plan.d.ts.map +1 -1
- package/plan.js +58 -17
- package/plan.js.map +1 -1
package/host.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { execFile } from 'node:child_process';
|
|
13
|
-
import { rm, statfs, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { mkdir, rm, statfs, writeFile } from 'node:fs/promises';
|
|
14
14
|
import { tmpdir } from 'node:os';
|
|
15
15
|
import { dirname, join } from 'node:path';
|
|
16
16
|
import { randomUUID } from 'node:crypto';
|
|
@@ -59,6 +59,9 @@ export function createDeskHost(options) {
|
|
|
59
59
|
const pathExists = options.pathExists ?? existsSync;
|
|
60
60
|
const deviceExists = options.deviceExists ?? existsSync;
|
|
61
61
|
const removeOverlay = options.removeOverlay ?? ((path) => rm(path, { force: true }));
|
|
62
|
+
const parkWithMemory = options.parkWithMemory ?? false;
|
|
63
|
+
const makeSnapshotDir = options.makeSnapshotDir ?? ((path) => mkdir(path, { recursive: true }).then(() => undefined));
|
|
64
|
+
const removeSnapshot = options.removeSnapshot ?? ((path) => rm(path, { recursive: true, force: true }));
|
|
62
65
|
const freeBytes = options.freeBytes ?? defaultFreeBytes;
|
|
63
66
|
const minFreeBytes = nonNegativeInteger(options.minFreeBytes ?? 0, 'minFreeBytes');
|
|
64
67
|
const trimOnSuspend = options.trimOnSuspend ?? true;
|
|
@@ -251,6 +254,28 @@ export function createDeskHost(options) {
|
|
|
251
254
|
lastError = `Trim before suspend failed for ${record.deskId}: ${errorMessage(error)}`;
|
|
252
255
|
}
|
|
253
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Flush the guest's filesystem, immediately before it stops.
|
|
259
|
+
*
|
|
260
|
+
* Only needed when parking keeps memory. A snapshot always matches the disk it
|
|
261
|
+
* was taken with, so the pair is consistent either way — but the disk ON ITS
|
|
262
|
+
* OWN is not, and that is what remains every time a snapshot has to be
|
|
263
|
+
* discarded. Without this, discarding one leaves a filesystem mid-transaction
|
|
264
|
+
* and the next cold boot recovers a journal and loses the last writes. Best
|
|
265
|
+
* effort and bounded: a guest too wedged to sync must still be parked.
|
|
266
|
+
*/
|
|
267
|
+
async function syncGuest(record, machine) {
|
|
268
|
+
try {
|
|
269
|
+
await machine.request({ op: 'exec', command: 'sync', args: [], timeoutMs: trimTimeoutMs });
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
lastError = `Sync before park failed for ${record.deskId}: ${errorMessage(error)}`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** Where this desk's memory snapshot lives; must agree with the launch plan. */
|
|
276
|
+
function snapshotDirFor(overlayPath, deskId) {
|
|
277
|
+
return join(dirname(overlayPath), `${deskId}.snapshot`);
|
|
278
|
+
}
|
|
254
279
|
async function teardown(record) {
|
|
255
280
|
if (record.status !== 'resident')
|
|
256
281
|
return false;
|
|
@@ -286,11 +311,34 @@ export function createDeskHost(options) {
|
|
|
286
311
|
record.handle = null;
|
|
287
312
|
if (machine) {
|
|
288
313
|
await trimGuest(record, machine);
|
|
314
|
+
// Flush the guest's filesystem before anything stops.
|
|
315
|
+
//
|
|
316
|
+
// A paused guest still holds dirty pages that exist only in its memory, so
|
|
317
|
+
// a snapshot is consistent with the disk but the DISK ALONE is not — and
|
|
318
|
+
// the disk alone is exactly what is left whenever a snapshot is discarded,
|
|
319
|
+
// which happens on every failed restore. Syncing first makes discarding a
|
|
320
|
+
// snapshot always safe instead of usually safe.
|
|
321
|
+
const parking = parkWithMemory && typeof machine.park === 'function';
|
|
322
|
+
if (parking)
|
|
323
|
+
await syncGuest(record, machine);
|
|
289
324
|
try {
|
|
290
|
-
|
|
325
|
+
if (parking) {
|
|
326
|
+
const snapshotDir = snapshotDirFor(record.startOptions.overlayPath, record.deskId);
|
|
327
|
+
await makeSnapshotDir(snapshotDir);
|
|
328
|
+
await machine.park(snapshotDir);
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
await machine.shutdown();
|
|
332
|
+
}
|
|
291
333
|
}
|
|
292
334
|
catch (error) {
|
|
335
|
+
// `park` stops the VMM on its own way out, so the desk is down either
|
|
336
|
+
// way; what is lost is the memory, not the desk. Leave nothing behind
|
|
337
|
+
// that a later boot would try to restore from.
|
|
293
338
|
lastError = errorMessage(error);
|
|
339
|
+
if (parking) {
|
|
340
|
+
await removeSnapshot(snapshotDirFor(record.startOptions.overlayPath, record.deskId)).catch(() => undefined);
|
|
341
|
+
}
|
|
294
342
|
}
|
|
295
343
|
}
|
|
296
344
|
lastSuspendedAt = iso(now());
|
|
@@ -356,7 +404,12 @@ export function createDeskHost(options) {
|
|
|
356
404
|
// Before the plan, so a full disk is reported as a full disk rather than
|
|
357
405
|
// as whatever the overlay copy fails with.
|
|
358
406
|
await admitDisk(startOptions.overlayPath);
|
|
359
|
-
const
|
|
407
|
+
const snapshotDir = snapshotDirFor(startOptions.overlayPath, deskId);
|
|
408
|
+
// The SAME cid for both attempts below. A restored VM's snapshotted config
|
|
409
|
+
// names its vsock and tap, and a cold-boot retry that renumbered them would
|
|
410
|
+
// come up on a different tap than the one the egress rules were put on.
|
|
411
|
+
const cid = nextCid++;
|
|
412
|
+
const planFor = (restore) => buildDeskLaunchPlan({
|
|
360
413
|
deskId,
|
|
361
414
|
vmmPath: options.vmmPath,
|
|
362
415
|
kvmPath: options.kvmPath,
|
|
@@ -366,7 +419,7 @@ export function createDeskHost(options) {
|
|
|
366
419
|
overlayPath: startOptions.overlayPath,
|
|
367
420
|
memoryMb: startOptions.memoryMb,
|
|
368
421
|
vcpus: startOptions.vcpus,
|
|
369
|
-
vsockCid:
|
|
422
|
+
vsockCid: cid,
|
|
370
423
|
runtimeDir: options.runtimeDir,
|
|
371
424
|
tapDevice: startOptions.network?.tapDevice,
|
|
372
425
|
macAddress: startOptions.network?.macAddress,
|
|
@@ -374,16 +427,40 @@ export function createDeskHost(options) {
|
|
|
374
427
|
// back to DEFAULT_KERNEL_CMDLINE.
|
|
375
428
|
...(kernelCmdline === undefined ? {} : { kernelCmdline }),
|
|
376
429
|
launcherIdentity: options.launcherIdentity,
|
|
430
|
+
snapshotDir,
|
|
431
|
+
...(restore ? { restore: true } : {}),
|
|
377
432
|
}, { pathExists, deviceExists });
|
|
433
|
+
const resuming = parkWithMemory && pathExists(snapshotDir);
|
|
378
434
|
let machine;
|
|
379
435
|
try {
|
|
380
|
-
machine =
|
|
436
|
+
machine = resuming
|
|
437
|
+
? await backend.boot(planFor(true), {
|
|
438
|
+
// The guest is restored and PAUSED here. Destroying the snapshot in
|
|
439
|
+
// this window is what stops a memory image ever outliving the disk
|
|
440
|
+
// state it belongs to. If it fails the restore is abandoned rather
|
|
441
|
+
// than resumed, because resuming would leave both alive at once.
|
|
442
|
+
onRestored: () => removeSnapshot(snapshotDir),
|
|
443
|
+
})
|
|
444
|
+
: await backend.boot(planFor(false));
|
|
381
445
|
}
|
|
382
446
|
catch (error) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
447
|
+
if (resuming) {
|
|
448
|
+
// A snapshot that will not come back is worse than no snapshot: it makes
|
|
449
|
+
// the desk unbootable for as long as it sits there. Drop it and cold
|
|
450
|
+
// boot. Safe by construction — the guest never ran, so the disk is
|
|
451
|
+
// exactly as the pre-park sync left it.
|
|
452
|
+
const why = errorMessage(error);
|
|
453
|
+
await removeSnapshot(snapshotDir).catch(() => undefined);
|
|
454
|
+
lastError = `Desk ${deskId} could not resume from its snapshot and was cold booted: ${why}`;
|
|
455
|
+
// Falls through to the shared record path below with a cold machine.
|
|
456
|
+
machine = await backend.boot(planFor(false));
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
lastError = errorMessage(error);
|
|
460
|
+
throw error instanceof DeskError
|
|
461
|
+
? error
|
|
462
|
+
: new DeskError(`Desk ${deskId} failed to boot: ${errorMessage(error)}`);
|
|
463
|
+
}
|
|
387
464
|
}
|
|
388
465
|
let record = records.get(deskId);
|
|
389
466
|
if (!record) {
|
|
@@ -1061,6 +1138,19 @@ export function createDeskHost(options) {
|
|
|
1061
1138
|
catch (error) {
|
|
1062
1139
|
lastError = `Overlay was not reclaimed for ${record.deskId}: ${errorMessage(error)}`;
|
|
1063
1140
|
}
|
|
1141
|
+
// The snapshot goes with the disk, always — not only when parking is on.
|
|
1142
|
+
//
|
|
1143
|
+
// A snapshot outliving its overlay is the worst state this feature can
|
|
1144
|
+
// produce: the next desk created under the same id gets a FRESH overlay and
|
|
1145
|
+
// would restore a previous desk's memory on top of it. That is not stale
|
|
1146
|
+
// state, it is one machine's RAM over another machine's disk. Teardown just
|
|
1147
|
+
// ran, so nothing holds it open.
|
|
1148
|
+
try {
|
|
1149
|
+
await removeSnapshot(snapshotDirFor(record.startOptions.overlayPath, record.deskId));
|
|
1150
|
+
}
|
|
1151
|
+
catch (error) {
|
|
1152
|
+
lastError = `Snapshot was not reclaimed for ${record.deskId}: ${errorMessage(error)}`;
|
|
1153
|
+
}
|
|
1064
1154
|
}
|
|
1065
1155
|
async function destroy(deskId) {
|
|
1066
1156
|
const id = cleanDeskId(deskId);
|