@braedonsaunders/appkit-desk 0.6.0 → 0.8.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.
Files changed (53) hide show
  1. package/README.md +83 -1
  2. package/backend.d.ts +28 -7
  3. package/backend.d.ts.map +1 -1
  4. package/backend.js +18 -521
  5. package/backend.js.map +1 -1
  6. package/events.d.ts +3 -0
  7. package/events.d.ts.map +1 -1
  8. package/events.js.map +1 -1
  9. package/host.d.ts +32 -1
  10. package/host.d.ts.map +1 -1
  11. package/host.js +141 -23
  12. package/host.js.map +1 -1
  13. package/index.d.ts +3 -0
  14. package/index.d.ts.map +1 -1
  15. package/index.js +3 -0
  16. package/index.js.map +1 -1
  17. package/machine.d.ts +145 -0
  18. package/machine.d.ts.map +1 -0
  19. package/machine.js +518 -0
  20. package/machine.js.map +1 -0
  21. package/network.d.ts +37 -0
  22. package/network.d.ts.map +1 -0
  23. package/network.js +173 -0
  24. package/network.js.map +1 -0
  25. package/package.json +2 -2
  26. package/plan.d.ts +129 -31
  27. package/plan.d.ts.map +1 -1
  28. package/plan.js +165 -9
  29. package/plan.js.map +1 -1
  30. package/port-relay.d.ts +13 -0
  31. package/port-relay.d.ts.map +1 -0
  32. package/port-relay.js +56 -0
  33. package/port-relay.js.map +1 -0
  34. package/virtualization.d.ts +16 -0
  35. package/virtualization.d.ts.map +1 -0
  36. package/virtualization.js +148 -0
  37. package/virtualization.js.map +1 -0
  38. package/vz/Package.swift +23 -0
  39. package/vz/Sources/DeskNet/Addresses.swift +106 -0
  40. package/vz/Sources/DeskNet/DHCP.swift +111 -0
  41. package/vz/Sources/DeskNet/Fence.swift +57 -0
  42. package/vz/Sources/DeskNet/NetworkStack.swift +547 -0
  43. package/vz/Sources/DeskNet/Packets.swift +355 -0
  44. package/vz/Sources/DeskNet/Plumbing.swift +312 -0
  45. package/vz/Sources/DeskNet/Sockets.swift +221 -0
  46. package/vz/Sources/DeskNet/TCPFlow.swift +448 -0
  47. package/vz/Sources/appkit-desk-vz/Control.swift +152 -0
  48. package/vz/Sources/appkit-desk-vz/Helper.swift +505 -0
  49. package/vz/Sources/appkit-desk-vz/main.swift +38 -0
  50. package/vz/Tests/DeskNetTests/DeskNetTests.swift +426 -0
  51. package/vz/appkit-desk-vz.entitlements +8 -0
  52. package/vz/build.sh +23 -0
  53. package/vz/check/extract-boot.py +221 -0
package/host.js CHANGED
@@ -10,11 +10,12 @@
10
10
  */
11
11
  import { existsSync } from 'node:fs';
12
12
  import { execFile } from 'node:child_process';
13
- import { mkdir, rm, statfs, writeFile } from 'node:fs/promises';
14
- import { tmpdir } from 'node:os';
13
+ import { mkdir, readFile, rm, statfs, writeFile } from 'node:fs/promises';
14
+ import { arch, cpus, release, tmpdir } from 'node:os';
15
15
  import { dirname, join } from 'node:path';
16
- import { randomUUID } from 'node:crypto';
16
+ import { createHash, randomUUID } from 'node:crypto';
17
17
  import { Buffer } from 'node:buffer';
18
+ import { connect as tcpConnect, isIP } from 'node:net';
18
19
  import { buildDeskLaunchPlan, cleanDeskId, DEFAULT_KVM_PATH, DEFAULT_VMM_PATH, DeskError, } from './plan.js';
19
20
  import { cloudHypervisorBackend, } from './backend.js';
20
21
  import { parseCapabilities, parseClipboardText, parseExecResult, parseHandoverBegun, parseJobStarted, parseObservation, } from './protocol.js';
@@ -76,7 +77,7 @@ export function createDeskHost(options) {
76
77
  // cloud kernel boots without every caller having to name it; an explicit
77
78
  // option always wins.
78
79
  const defaultInitramfs = join(options.imageRoot, 'initrd');
79
- const initramfsPath = options.initramfsPath ?? (pathExists(defaultInitramfs) ? defaultInitramfs : undefined);
80
+ const resolveInitramfsPath = () => options.initramfsPath ?? (pathExists(defaultInitramfs) ? defaultInitramfs : undefined);
80
81
  const kernelCmdline = options.kernelCmdline;
81
82
  const records = new Map();
82
83
  /**
@@ -93,6 +94,8 @@ export function createDeskHost(options) {
93
94
  let lastStartedAt = null;
94
95
  let lastSuspendedAt = null;
95
96
  let lastError = null;
97
+ let snapshotCpuFallbacks = 0;
98
+ let lastSnapshotCpuFallback = null;
96
99
  let reconnects = 0;
97
100
  let lastReconnectAt = null;
98
101
  let lastReconnectDeskId = null;
@@ -276,13 +279,16 @@ export function createDeskHost(options) {
276
279
  function snapshotDirFor(overlayPath, deskId) {
277
280
  return join(dirname(overlayPath), `${deskId}.snapshot`);
278
281
  }
279
- async function teardown(record) {
282
+ async function teardown(record, keepMemory = true) {
280
283
  if (record.status !== 'resident')
281
284
  return false;
282
285
  await endHandover(record, 'revoked');
283
286
  for (const stop of [...record.streamStops])
284
287
  stop();
285
288
  record.streamStops.clear();
289
+ for (const stop of record.relayStops)
290
+ stop();
291
+ record.relayStops.clear();
286
292
  if (record.screen?.running) {
287
293
  record.screen.running = false;
288
294
  emit(record, { kind: 'screen_close' });
@@ -318,14 +324,18 @@ export function createDeskHost(options) {
318
324
  // the disk alone is exactly what is left whenever a snapshot is discarded,
319
325
  // which happens on every failed restore. Syncing first makes discarding a
320
326
  // snapshot always safe instead of usually safe.
321
- const parking = parkWithMemory && typeof machine.park === 'function';
322
- if (parking)
327
+ const parking = keepMemory && parkWithMemory && typeof machine.park === 'function';
328
+ // A shutdown that throws memory away leaves the disk as the only state, so
329
+ // it is flushed exactly as a park is.
330
+ if (parking || !keepMemory)
323
331
  await syncGuest(record, machine);
324
332
  try {
325
333
  if (parking) {
326
334
  const snapshotDir = snapshotDirFor(record.startOptions.overlayPath, record.deskId);
327
335
  await makeSnapshotDir(snapshotDir);
328
336
  await machine.park(snapshotDir);
337
+ const signature = await (options.cpuSignature ?? hostCpuSignature)();
338
+ await (options.writeSnapshotSignature ?? writeSnapshotSignature)(snapshotDir, signature);
329
339
  }
330
340
  else {
331
341
  await machine.shutdown();
@@ -409,8 +419,11 @@ export function createDeskHost(options) {
409
419
  // names its vsock and tap, and a cold-boot retry that renumbered them would
410
420
  // come up on a different tap than the one the egress rules were put on.
411
421
  const cid = nextCid++;
422
+ const initramfsPath = resolveInitramfsPath();
412
423
  const planFor = (restore) => buildDeskLaunchPlan({
413
424
  deskId,
425
+ hypervisor: backend.hypervisor ?? 'cloud-hypervisor',
426
+ ...(backend.hypervisor === 'virtualization' ? { guestNetwork: options.guestNetwork } : {}),
414
427
  vmmPath: options.vmmPath,
415
428
  kvmPath: options.kvmPath,
416
429
  kernelPath,
@@ -430,7 +443,15 @@ export function createDeskHost(options) {
430
443
  snapshotDir,
431
444
  ...(restore ? { restore: true } : {}),
432
445
  }, { pathExists, deviceExists });
433
- const resuming = parkWithMemory && pathExists(snapshotDir);
446
+ let resuming = parkWithMemory && pathExists(snapshotDir);
447
+ if (resuming) {
448
+ const saved = await (options.readSnapshotSignature ?? readSnapshotSignature)(snapshotDir);
449
+ if (saved !== null && saved !== await (options.cpuSignature ?? hostCpuSignature)()) {
450
+ await removeSnapshot(snapshotDir);
451
+ resuming = false;
452
+ recordCpuFallback(deskId, 'signature_mismatch');
453
+ }
454
+ }
434
455
  let machine;
435
456
  try {
436
457
  machine = resuming
@@ -450,8 +471,11 @@ export function createDeskHost(options) {
450
471
  // boot. Safe by construction — the guest never ran, so the disk is
451
472
  // exactly as the pre-park sync left it.
452
473
  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}`;
474
+ await removeSnapshot(snapshotDir);
475
+ if (isIncompatibleCpuSnapshotError(error))
476
+ recordCpuFallback(deskId, 'incompatible_cpuid');
477
+ else
478
+ lastError = `Desk ${deskId} could not resume from its snapshot and was cold booted: ${why}`;
455
479
  // Falls through to the shared record path below with a cold machine.
456
480
  machine = await backend.boot(planFor(false));
457
481
  }
@@ -479,6 +503,7 @@ export function createDeskHost(options) {
479
503
  handle: null,
480
504
  timer: null,
481
505
  streamStops: new Set(),
506
+ relayStops: new Set(),
482
507
  };
483
508
  records.set(deskId, record);
484
509
  }
@@ -490,6 +515,7 @@ export function createDeskHost(options) {
490
515
  record.jobs = new Map();
491
516
  record.screen = null;
492
517
  record.streamStops = new Set();
518
+ record.relayStops = new Set();
493
519
  }
494
520
  const current = record;
495
521
  const epoch = current.epoch;
@@ -735,6 +761,33 @@ export function createDeskHost(options) {
735
761
  touch(record);
736
762
  armTimer(record);
737
763
  },
764
+ async connect(port) {
765
+ requireMachine(record, epoch);
766
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
767
+ throw new DeskError('Guest TCP port must be between 1 and 65535.');
768
+ const machine = requireMachine(record, epoch);
769
+ const address = record.startOptions.network?.guestAddress;
770
+ if (!machine.connect && (!address || isIP(address) !== 4))
771
+ throw new DeskError('A guest IPv4 address is required to connect to a desk port.');
772
+ touch(record);
773
+ try {
774
+ const stream = machine.connect ? await machine.connect(port) : await (options.connectGuest ?? connectGuestTcp)(address, port);
775
+ try {
776
+ requireMachine(record, epoch);
777
+ }
778
+ catch (error) {
779
+ stream.destroy();
780
+ throw error;
781
+ }
782
+ const stop = () => stream.destroy();
783
+ record.relayStops.add(stop);
784
+ stream.once('close', () => record.relayStops.delete(stop));
785
+ return stream;
786
+ }
787
+ catch (error) {
788
+ throw new DeskError(`Cannot connect to guest ${address}:${port}: ${errorMessage(error)}`);
789
+ }
790
+ },
738
791
  exec: execImplementation,
739
792
  jobs() {
740
793
  return [...record.jobs.values()]
@@ -1121,6 +1174,19 @@ export function createDeskHost(options) {
1121
1174
  await teardown(record);
1122
1175
  });
1123
1176
  }
1177
+ async function shutdown(deskId) {
1178
+ const id = cleanDeskId(deskId);
1179
+ await serializeLifecycle(id, async () => {
1180
+ const record = records.get(id);
1181
+ if (!record)
1182
+ return;
1183
+ await teardown(record, false);
1184
+ // A desk parked earlier still has its memory image on disk; dropping it is
1185
+ // what makes the next boot cold. The overlay is untouched — only `destroy`
1186
+ // may remove it.
1187
+ await removeSnapshot(snapshotDirFor(record.startOptions.overlayPath, record.deskId));
1188
+ });
1189
+ }
1124
1190
  /**
1125
1191
  * Delete a desk's overlay. The ONLY place in this module that removes a
1126
1192
  * desk's disk, and it is reachable from `destroy` alone — see the contract
@@ -1191,6 +1257,11 @@ export function createDeskHost(options) {
1191
1257
  }
1192
1258
  return suspendedCount;
1193
1259
  }
1260
+ function recordCpuFallback(deskId, reason) {
1261
+ snapshotCpuFallbacks += 1;
1262
+ lastSnapshotCpuFallback = { deskId, reason, at: iso(now()) };
1263
+ ports.onEvent({ kind: 'snapshot_cpu_fallback', deskId, reason, at: iso(now()) });
1264
+ }
1194
1265
  function stats() {
1195
1266
  let suspended = 0;
1196
1267
  for (const record of records.values()) {
@@ -1208,12 +1279,15 @@ export function createDeskHost(options) {
1208
1279
  lastReconnectAt,
1209
1280
  lastReconnectDeskId,
1210
1281
  lastError,
1282
+ snapshotCpuFallbacks,
1283
+ lastSnapshotCpuFallback,
1211
1284
  };
1212
1285
  }
1213
- return { start, resume, suspend, destroy, stats, sweep };
1286
+ return { start, resume, suspend, shutdown, destroy, stats, sweep };
1214
1287
  }
1215
1288
  /**
1216
1289
  * Whether the overlay directory's filesystem can make reflink clones.
1290
+ * On macOS this probes APFS cloning with `cp -c`. On Linux:
1217
1291
  *
1218
1292
  * This exists because `cp --reflink=auto` — what `buildDeskLaunchPlan` uses,
1219
1293
  * deliberately, so a desk still boots on a filesystem without reflink — is
@@ -1246,7 +1320,7 @@ export async function verifyOverlayReflink(options) {
1246
1320
  + `support could not be determined: ${errorMessage(error)}`);
1247
1321
  }
1248
1322
  await copy(source, destination);
1249
- return { supported: true, overlayDirectory, detail: 'cp --reflink=always succeeded.' };
1323
+ return { supported: true, overlayDirectory, detail: process.platform === 'darwin' ? 'cp -c succeeded.' : 'cp --reflink=always succeeded.' };
1250
1324
  }
1251
1325
  catch (error) {
1252
1326
  if (error instanceof DeskError)
@@ -1265,16 +1339,18 @@ export async function verifyOverlayReflink(options) {
1265
1339
  export async function assertOverlayReflink(options) {
1266
1340
  const check = await verifyOverlayReflink(options);
1267
1341
  if (!check.supported) {
1268
- throw new DeskError(`The overlay directory ${check.overlayDirectory} is on a filesystem without reflink `
1269
- + 'support, so every desk would FULL-COPY the base image instead of sharing its '
1270
- + 'blocks — gigabytes and minutes per desk, and a disk sized for sharing fills after '
1271
- + `a handful of them. Put the overlay directory on XFS or Btrfs. Probe said: ${check.detail}`);
1342
+ throw new DeskError(process.platform === 'darwin'
1343
+ ? `The overlay directory ${check.overlayDirectory} does not support cloning. Put it on APFS. Probe said: ${check.detail}`
1344
+ : `The overlay directory ${check.overlayDirectory} is on a filesystem without reflink `
1345
+ + 'support, so every desk would FULL-COPY the base image instead of sharing its '
1346
+ + 'blocks — gigabytes and minutes per desk, and a disk sized for sharing fills after '
1347
+ + `a handful of them. Put the overlay directory on XFS or Btrfs. Probe said: ${check.detail}`);
1272
1348
  }
1273
1349
  return check;
1274
1350
  }
1275
1351
  async function reflinkAlways(source, destination) {
1276
1352
  await new Promise((resolvePromise, rejectPromise) => {
1277
- execFile('cp', ['--reflink=always', source, destination], (error, _stdout, stderr) => {
1353
+ execFile('cp', [process.platform === 'darwin' ? '-c' : '--reflink=always', source, destination], (error, _stdout, stderr) => {
1278
1354
  if (error) {
1279
1355
  rejectPromise(new Error(stderr.trim() || errorMessage(error)));
1280
1356
  return;
@@ -1297,13 +1373,14 @@ export async function verifyDeskHost(options) {
1297
1373
  const deviceExists = options.deviceExists ?? existsSync;
1298
1374
  const idFactory = options.idFactory ?? randomUUID;
1299
1375
  const backend = options.backend ?? cloudHypervisorBackend;
1300
- const vmmPath = options.vmmPath ?? DEFAULT_VMM_PATH;
1376
+ const isVirtualization = backend.hypervisor === 'virtualization';
1377
+ const vmmPath = options.vmmPath ?? backend.vmmPath ?? DEFAULT_VMM_PATH;
1301
1378
  const kvmPath = options.kvmPath ?? DEFAULT_KVM_PATH;
1302
- if (platform !== 'linux') {
1303
- throw new DeskError(`The AppKit desk requires Linux; received ${platform}.`);
1379
+ if (platform !== (isVirtualization ? 'darwin' : 'linux')) {
1380
+ throw new DeskError(isVirtualization ? `Virtualization requires macOS; received ${platform}.` : `The AppKit desk requires Linux; received ${platform}.`);
1304
1381
  }
1305
1382
  if (!pathExists(vmmPath)) {
1306
- throw new DeskError(`Cloud Hypervisor was not found at ${vmmPath}.`);
1383
+ throw new DeskError(isVirtualization ? `Virtualization helper was not found at ${vmmPath}.` : `Cloud Hypervisor was not found at ${vmmPath}.`);
1307
1384
  }
1308
1385
  if (!pathExists(options.kernelPath)) {
1309
1386
  throw new DeskError(`Guest kernel does not exist: ${options.kernelPath}`);
@@ -1311,7 +1388,7 @@ export async function verifyDeskHost(options) {
1311
1388
  if (!pathExists(options.baseImagePath)) {
1312
1389
  throw new DeskError(`Base image does not exist: ${options.baseImagePath}`);
1313
1390
  }
1314
- if (!deviceExists(kvmPath)) {
1391
+ if (!isVirtualization && !deviceExists(kvmPath)) {
1315
1392
  return { supported: false, vmmPath, kvm: false, vsock: false, virtioGpu: false };
1316
1393
  }
1317
1394
  const scratchDir = options.scratchDir ?? tmpdir();
@@ -1319,6 +1396,7 @@ export async function verifyDeskHost(options) {
1319
1396
  const overlayPath = join(scratchDir, `${deskId}.raw`);
1320
1397
  const plan = buildDeskLaunchPlan({
1321
1398
  deskId,
1399
+ hypervisor: backend.hypervisor ?? 'cloud-hypervisor',
1322
1400
  vmmPath,
1323
1401
  kvmPath,
1324
1402
  kernelPath: options.kernelPath,
@@ -1365,7 +1443,7 @@ export async function verifyDeskHost(options) {
1365
1443
  }
1366
1444
  await rm(overlayPath, { force: true });
1367
1445
  }
1368
- return { supported: vsock, vmmPath, kvm: true, vsock, virtioGpu };
1446
+ return { supported: vsock, vmmPath, kvm: !isVirtualization, vsock, virtioGpu };
1369
1447
  }
1370
1448
  function nonEmptyString(value, field) {
1371
1449
  if (typeof value !== 'string' || value.length === 0) {
@@ -1402,4 +1480,44 @@ async function defaultFreeBytes(path) {
1402
1480
  function errorMessage(error) {
1403
1481
  return error instanceof Error ? error.message : String(error);
1404
1482
  }
1483
+ async function connectGuestTcp(address, port) {
1484
+ return new Promise((resolve, reject) => {
1485
+ const socket = tcpConnect({ host: address, port });
1486
+ socket.setTimeout(10_000, () => socket.destroy(new Error('Guest TCP connection timed out.')));
1487
+ socket.once('error', reject);
1488
+ socket.once('connect', () => { socket.setTimeout(0); socket.removeListener('error', reject); resolve(socket); });
1489
+ });
1490
+ }
1491
+ /** Cloud Hypervisor's specific restore error; do not classify generic CPUID mentions. */
1492
+ export function isIncompatibleCpuSnapshotError(error) {
1493
+ return /Detected incompatible CPUID entry/.test(errorMessage(error));
1494
+ }
1495
+ /** Hash the host's CPU identity and advertised feature set, excluding per-core counters. */
1496
+ export async function hostCpuSignature() {
1497
+ if (process.platform === 'darwin') {
1498
+ const model = cpus()[0]?.model;
1499
+ if (!model)
1500
+ throw new DeskError('Cannot determine host CPU signature.');
1501
+ return createHash('sha256').update(`darwin:${arch()}:${release()}:${model}`).digest('hex');
1502
+ }
1503
+ const cpuinfo = await readFile('/proc/cpuinfo', 'utf8');
1504
+ const first = cpuinfo.split(/\n\s*\n/)[0] ?? '';
1505
+ const identity = first.split('\n').filter((line) => /^(vendor_id|cpu family|model\s|model name|stepping|flags|microcode)\s*:/.test(line)).join('\n');
1506
+ if (!identity)
1507
+ throw new DeskError('Cannot determine host CPU signature.');
1508
+ return createHash('sha256').update(identity).digest('hex');
1509
+ }
1510
+ async function readSnapshotSignature(directory) {
1511
+ try {
1512
+ return (await readFile(join(directory, 'appkit-cpu-signature'), 'utf8')).trim();
1513
+ }
1514
+ catch (error) {
1515
+ if (error.code === 'ENOENT')
1516
+ return null;
1517
+ throw error;
1518
+ }
1519
+ }
1520
+ async function writeSnapshotSignature(directory, signature) {
1521
+ await writeFile(join(directory, 'appkit-cpu-signature'), signature, { mode: 0o600 });
1522
+ }
1405
1523
  //# sourceMappingURL=host.js.map