@braedonsaunders/appkit-desk 0.4.0 → 0.5.1

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
@@ -9,9 +9,10 @@
9
9
  * never touches a database and knows nothing about tenants or approvals.
10
10
  */
11
11
  import { existsSync } from 'node:fs';
12
- import { rm } from 'node:fs/promises';
12
+ import { execFile } from 'node:child_process';
13
+ import { rm, statfs, writeFile } from 'node:fs/promises';
13
14
  import { tmpdir } from 'node:os';
14
- import { join } from 'node:path';
15
+ import { dirname, join } from 'node:path';
15
16
  import { randomUUID } from 'node:crypto';
16
17
  import { Buffer } from 'node:buffer';
17
18
  import { buildDeskLaunchPlan, cleanDeskId, DEFAULT_KVM_PATH, DEFAULT_VMM_PATH, DeskError, } from './plan.js';
@@ -20,6 +21,10 @@ import { parseCapabilities, parseClipboardText, parseExecResult, parseHandoverBe
20
21
  export const DEFAULT_CAPACITY = 4;
21
22
  export const DEFAULT_IDLE_SUSPEND_MS = 5 * 60_000;
22
23
  export const DEFAULT_LEASE_MS = 15 * 60_000;
24
+ /** The in-guest trim run before a desk suspends — see `trimGuest`. */
25
+ export const DEFAULT_TRIM_COMMAND = '/usr/sbin/fstrim';
26
+ export const DEFAULT_TRIM_ARGS = ['/'];
27
+ export const DEFAULT_TRIM_TIMEOUT_MS = 60_000;
23
28
  export const DEFAULT_CID_BASE = 100;
24
29
  const MAX_BUFFERED_FRAMES = 60;
25
30
  /**
@@ -53,6 +58,13 @@ export function createDeskHost(options) {
53
58
  const now = options.now ?? Date.now;
54
59
  const pathExists = options.pathExists ?? existsSync;
55
60
  const deviceExists = options.deviceExists ?? existsSync;
61
+ const removeOverlay = options.removeOverlay ?? ((path) => rm(path, { force: true }));
62
+ const freeBytes = options.freeBytes ?? defaultFreeBytes;
63
+ const minFreeBytes = nonNegativeInteger(options.minFreeBytes ?? 0, 'minFreeBytes');
64
+ const trimOnSuspend = options.trimOnSuspend ?? true;
65
+ const trimCommand = options.trimCommand ?? DEFAULT_TRIM_COMMAND;
66
+ const trimArgs = options.trimArgs ?? DEFAULT_TRIM_ARGS;
67
+ const trimTimeoutMs = positiveInteger(options.trimTimeoutMs ?? DEFAULT_TRIM_TIMEOUT_MS, 'trimTimeoutMs');
56
68
  const capacity = positiveInteger(options.capacity ?? DEFAULT_CAPACITY, 'capacity');
57
69
  const idleSuspendMs = positiveInteger(options.idleSuspendMs ?? DEFAULT_IDLE_SUSPEND_MS, 'idleSuspendMs');
58
70
  const defaultLeaseMs = positiveInteger(options.defaultLeaseMs ?? DEFAULT_LEASE_MS, 'defaultLeaseMs');
@@ -64,6 +76,14 @@ export function createDeskHost(options) {
64
76
  const initramfsPath = options.initramfsPath ?? (pathExists(defaultInitramfs) ? defaultInitramfs : undefined);
65
77
  const kernelCmdline = options.kernelCmdline;
66
78
  const records = new Map();
79
+ /**
80
+ * The last lifecycle transition scheduled for each desk. A desk may only
81
+ * have one VMM child touching its overlay, so boot and teardown are ordered
82
+ * on the desk identity rather than allowed to race through independent API
83
+ * calls. The stored tail always resolves; callers still receive their own
84
+ * operation's result or error.
85
+ */
86
+ const lifecycleTransitions = new Map();
67
87
  const queue = [];
68
88
  let occupied = 0;
69
89
  let nextCid = positiveInteger(options.cidBase ?? DEFAULT_CID_BASE, 'cidBase');
@@ -76,6 +96,19 @@ export function createDeskHost(options) {
76
96
  function iso(timestamp) {
77
97
  return new Date(timestamp).toISOString();
78
98
  }
99
+ function serializeLifecycle(deskId, transition) {
100
+ const previous = lifecycleTransitions.get(deskId);
101
+ // Preserve the host's existing synchronous admission semantics when this
102
+ // desk has no transition in flight. Only conflicts need the promise tail;
103
+ // unrelated starts must still appear in queue stats before `start` returns.
104
+ const current = previous ? previous.then(transition, transition) : transition();
105
+ const tail = current.then(() => undefined, () => undefined);
106
+ lifecycleTransitions.set(deskId, tail);
107
+ return current.finally(() => {
108
+ if (lifecycleTransitions.get(deskId) === tail)
109
+ lifecycleTransitions.delete(deskId);
110
+ });
111
+ }
79
112
  function emit(record, detail) {
80
113
  if (record.screen?.handover && HANDOVER_MASKED_KINDS.has(detail.kind))
81
114
  return;
@@ -165,9 +198,62 @@ export function createDeskHost(options) {
165
198
  at: iso(now()),
166
199
  });
167
200
  }
201
+ /**
202
+ * Stop the machine and keep the disk. This is the SUSPEND half of the
203
+ * lifecycle and it is shared by three callers — `suspend`, `sweep`, and
204
+ * `destroy` — so it must never delete, truncate, or rewrite the overlay.
205
+ *
206
+ * That is not a stylistic preference. For a suspended desk the overlay IS
207
+ * the desk: its home directory, its installed state, its half-written work.
208
+ * A desk that comes back from suspend with a fresh disk has silently lost
209
+ * everything, and the loss surfaces long after the code that caused it.
210
+ * Reclaiming the disk is the exclusive business of `reclaimOverlay`, which
211
+ * is called from exactly one place — `destroy` — where the caller has said
212
+ * the desk should cease to exist.
213
+ */
214
+ /**
215
+ * Hand the guest's freed blocks back to the host, as the last thing the
216
+ * guest does before it stops.
217
+ *
218
+ * An overlay only ever GROWS otherwise. A block the guest writes is
219
+ * allocated on the host filesystem forever; deleting the file inside the
220
+ * guest frees it in the guest's own filesystem and tells the host nothing,
221
+ * so a long-lived desk ratchets upward until the volume is full even though
222
+ * the guest reports itself half empty. `fstrim` is what closes that loop:
223
+ * it walks the guest filesystem's free space and issues discards, which
224
+ * Cloud Hypervisor's virtio-blk turns into hole-punches on the raw overlay.
225
+ *
226
+ * Suspend is the right moment for it. The image's own `fstrim.timer` fires
227
+ * WEEKLY, and a desk that idle-suspends after minutes is almost never up
228
+ * when that lands — so on a leased-and-parked desk the scheduled trim
229
+ * effectively never runs. Suspend, by contrast, is exactly when the guest
230
+ * has stopped needing its scratch and the host wants the space back.
231
+ *
232
+ * This does not violate the disk contract on `teardown`. `fstrim` discards
233
+ * only blocks the guest's filesystem already considers free; it cannot
234
+ * remove a suspended desk's state, and a desk resumed afterwards sees an
235
+ * identical filesystem. It is also strictly best-effort and bounded: a
236
+ * guest that is wedged, disconnected, or lacks the binary must still
237
+ * suspend promptly, so every failure here is swallowed into `lastError`.
238
+ */
239
+ async function trimGuest(record, machine) {
240
+ if (!trimOnSuspend)
241
+ return;
242
+ try {
243
+ await machine.request({
244
+ op: 'exec',
245
+ command: trimCommand,
246
+ args: trimArgs,
247
+ timeoutMs: trimTimeoutMs,
248
+ });
249
+ }
250
+ catch (error) {
251
+ lastError = `Trim before suspend failed for ${record.deskId}: ${errorMessage(error)}`;
252
+ }
253
+ }
168
254
  async function teardown(record) {
169
255
  if (record.status !== 'resident')
170
- return;
256
+ return false;
171
257
  await endHandover(record, 'revoked');
172
258
  for (const stop of [...record.streamStops])
173
259
  stop();
@@ -193,9 +279,13 @@ export function createDeskHost(options) {
193
279
  record.timer = null;
194
280
  const machine = record.machine;
195
281
  record.machine = null;
196
- record.status = 'suspended';
282
+ // Do not advertise this desk as suspended until its VMM child is actually
283
+ // down. `serializeLifecycle` prevents a new boot from entering while this
284
+ // transitional state still owns the overlay.
285
+ record.status = 'suspending';
197
286
  record.handle = null;
198
287
  if (machine) {
288
+ await trimGuest(record, machine);
199
289
  try {
200
290
  await machine.shutdown();
201
291
  }
@@ -205,7 +295,9 @@ export function createDeskHost(options) {
205
295
  }
206
296
  lastSuspendedAt = iso(now());
207
297
  occupied -= 1;
298
+ record.status = 'suspended';
208
299
  drainQueue();
300
+ return true;
209
301
  }
210
302
  function drainQueue() {
211
303
  while (occupied < capacity && queue.length > 0) {
@@ -233,7 +325,37 @@ export function createDeskHost(options) {
233
325
  queue.push({ run, resolve: resolvePromise, reject: rejectPromise });
234
326
  });
235
327
  }
328
+ /**
329
+ * Refuse a desk the overlay filesystem cannot afford. A reflink clone costs
330
+ * almost nothing at creation and then grows as the guest writes, so the
331
+ * moment that matters is not the `cp` — it is every write after it. Checking
332
+ * headroom here converts a mid-boot ENOSPC, which the guest reports as a
333
+ * corrupt disk or a hang, into one refusal naming the actual shortage.
334
+ */
335
+ async function admitDisk(overlayPath) {
336
+ if (minFreeBytes <= 0)
337
+ return;
338
+ let free;
339
+ try {
340
+ free = await freeBytes(overlayPath);
341
+ }
342
+ catch (error) {
343
+ // Fail OPEN on an unreadable filesystem: a desk refused because statfs
344
+ // hiccuped is a worse outcome than one admitted onto a disk that turns
345
+ // out to be full, which at least fails with a real disk error.
346
+ lastError = `Free-space check failed for ${overlayPath}: ${errorMessage(error)}`;
347
+ return;
348
+ }
349
+ if (free < minFreeBytes) {
350
+ throw new DeskError(`Refusing to start a desk: the overlay filesystem holding ${overlayPath} has `
351
+ + `${free} bytes free, below the ${minFreeBytes}-byte floor. Reclaim destroyed `
352
+ + 'desks’ overlays or grow the volume before leasing another desk.');
353
+ }
354
+ }
236
355
  async function boot(deskId, startOptions) {
356
+ // Before the plan, so a full disk is reported as a full disk rather than
357
+ // as whatever the overlay copy fails with.
358
+ await admitDisk(startOptions.overlayPath);
237
359
  const plan = buildDeskLaunchPlan({
238
360
  deskId,
239
361
  vmmPath: options.vmmPath,
@@ -367,8 +489,15 @@ export function createDeskHost(options) {
367
489
  // space of a screen that no longer exists lands somewhere nobody chose.
368
490
  // Input then refuses until the caller observes again — the existing
369
491
  // contract, not a new one.
370
- if (record.screen)
492
+ if (record.screen) {
371
493
  record.screen.lastObservation = null;
494
+ // The reconnected channel proves the machine is alive, not that the
495
+ // guest compositor survived. Treat the screen as needing an explicit
496
+ // restart so screen.start() reaches the guest instead of returning the
497
+ // stale handle forever. Keep ScreenState itself so an active handover
498
+ // remains masked in the safe direction until its explicit/TTL end.
499
+ record.screen.running = false;
500
+ }
372
501
  // A handover is deliberately NOT ended here. If a human is still on that
373
502
  // screen, dropping the mask would start recording them; letting the TTL
374
503
  // expire it fails in the safe direction.
@@ -380,7 +509,14 @@ export function createDeskHost(options) {
380
509
  // is about. Suspending it frees the capacity slot and makes the next
381
510
  // `resume()` boot a healthy desk.
382
511
  lastError = change.reason;
383
- void teardown(record).catch((error) => {
512
+ void serializeLifecycle(record.deskId, async () => {
513
+ // The loss belongs to the epoch that registered this watcher. If an
514
+ // earlier queued transition already replaced that machine, this stale
515
+ // signal must not retire the fresh resident.
516
+ if (record.status !== 'resident' || record.epoch !== epoch)
517
+ return;
518
+ await teardown(record);
519
+ }).catch((error) => {
384
520
  lastError = errorMessage(error);
385
521
  });
386
522
  }
@@ -467,6 +603,21 @@ export function createDeskHost(options) {
467
603
  }
468
604
  touch(record);
469
605
  await machine.request({ op: 'screen-start', width: cleanWidth, height: cleanHeight });
606
+ const existing = record.screen;
607
+ if (existing) {
608
+ // A guest-agent reconnect invalidates what the host knew about the
609
+ // compositor without necessarily ending an active handover. Reuse
610
+ // the same handle so existing consumers can recover, and retain the
611
+ // handover mask until its explicit/TTL end rather than briefly
612
+ // exposing a human's private input while the screen restarts.
613
+ existing.running = true;
614
+ existing.width = cleanWidth;
615
+ existing.height = cleanHeight;
616
+ existing.lastObservation = null;
617
+ existing.lastFocusedId = null;
618
+ emit(record, { kind: 'screen_open', width: cleanWidth, height: cleanHeight });
619
+ return existing.handle;
620
+ }
470
621
  const handle = makeScreenHandle(record, epoch);
471
622
  record.screen = {
472
623
  running: true,
@@ -550,10 +701,12 @@ export function createDeskHost(options) {
550
701
  assertCoordinates(record, [{ x, y }]);
551
702
  await sendInput(null, { type: 'move', x, y });
552
703
  },
553
- async click(x, y, button = 'left') {
704
+ async click(x, y, button = 'left', clicks = 1) {
554
705
  requireScreen(record, epoch);
555
706
  assertCoordinates(record, [{ x, y }]);
556
- await sendInput({ kind: 'click', x, y, button }, { type: 'click', x, y, button });
707
+ if (clicks !== 1 && clicks !== 2)
708
+ throw new DeskError('clicks must be 1 or 2.');
709
+ await sendInput({ kind: 'click', x, y, button, clicks }, { type: 'click', x, y, button, clicks });
557
710
  },
558
711
  async type(text) {
559
712
  requireScreen(record, epoch);
@@ -864,48 +1017,87 @@ export function createDeskHost(options) {
864
1017
  }
865
1018
  async function start(startOptions) {
866
1019
  const deskId = cleanDeskId(startOptions.deskId);
867
- const existing = records.get(deskId);
868
- if (existing?.status === 'resident' && existing.handle)
869
- return existing.handle;
870
- return admit(() => boot(deskId, { ...startOptions, deskId }));
1020
+ return serializeLifecycle(deskId, async () => {
1021
+ const existing = records.get(deskId);
1022
+ if (existing?.status === 'resident' && existing.handle)
1023
+ return existing.handle;
1024
+ return admit(() => boot(deskId, { ...startOptions, deskId }));
1025
+ });
871
1026
  }
872
1027
  async function resume(deskId) {
873
- const record = records.get(cleanDeskId(deskId));
874
- if (!record)
875
- throw new DeskError(`Unknown desk: ${deskId}`);
876
- if (record.status === 'resident' && record.handle)
877
- return record.handle;
878
- return admit(() => boot(record.deskId, record.startOptions));
1028
+ const id = cleanDeskId(deskId);
1029
+ return serializeLifecycle(id, async () => {
1030
+ const record = records.get(id);
1031
+ if (!record)
1032
+ throw new DeskError(`Unknown desk: ${deskId}`);
1033
+ if (record.status === 'resident' && record.handle)
1034
+ return record.handle;
1035
+ return admit(() => boot(record.deskId, record.startOptions));
1036
+ });
879
1037
  }
880
1038
  async function suspend(deskId) {
881
- const record = records.get(cleanDeskId(deskId));
882
- if (!record)
883
- return;
884
- await teardown(record);
1039
+ const id = cleanDeskId(deskId);
1040
+ await serializeLifecycle(id, async () => {
1041
+ const record = records.get(id);
1042
+ if (!record)
1043
+ return;
1044
+ await teardown(record);
1045
+ });
1046
+ }
1047
+ /**
1048
+ * Delete a desk's overlay. The ONLY place in this module that removes a
1049
+ * desk's disk, and it is reachable from `destroy` alone — see the contract
1050
+ * on `teardown` for why suspend must never come near it.
1051
+ *
1052
+ * The record is dropped whether or not the unlink succeeds: a desk the
1053
+ * caller has destroyed must not stay leaseable because its file was busy.
1054
+ * A failed unlink is surfaced through `lastError` and leaves the file for
1055
+ * the reaper rather than being retried here.
1056
+ */
1057
+ async function reclaimOverlay(record) {
1058
+ try {
1059
+ await removeOverlay(record.startOptions.overlayPath);
1060
+ }
1061
+ catch (error) {
1062
+ lastError = `Overlay was not reclaimed for ${record.deskId}: ${errorMessage(error)}`;
1063
+ }
885
1064
  }
886
1065
  async function destroy(deskId) {
887
1066
  const id = cleanDeskId(deskId);
888
- const record = records.get(id);
889
- if (!record)
890
- return;
891
- await teardown(record);
892
- records.delete(id);
1067
+ await serializeLifecycle(id, async () => {
1068
+ const record = records.get(id);
1069
+ if (!record)
1070
+ return;
1071
+ await teardown(record);
1072
+ records.delete(id);
1073
+ // Strictly after teardown: the VMM still has the file open until its child
1074
+ // has exited, and unlinking underneath a live guest would leave the desk
1075
+ // writing into an orphaned inode instead of failing loudly.
1076
+ await reclaimOverlay(record);
1077
+ });
893
1078
  }
894
1079
  async function sweep() {
895
1080
  const timestamp = now();
896
1081
  let suspendedCount = 0;
897
1082
  for (const record of [...records.values()]) {
898
- if (record.status !== 'resident')
899
- continue;
900
- const handover = record.screen?.handover;
901
- if (handover && handover.deadline <= timestamp) {
902
- await endHandover(record, 'expired');
903
- }
904
- if (record.leaseDeadline <= timestamp
905
- || timestamp - record.lastActivityAt >= idleSuspendMs) {
906
- await teardown(record);
1083
+ const suspended = await serializeLifecycle(record.deskId, async () => {
1084
+ // A destroy and a later start may have replaced a captured record
1085
+ // before its turn in the sweep. Never apply the old deadline to the
1086
+ // new desk that happens to share its id.
1087
+ if (records.get(record.deskId) !== record || record.status !== 'resident')
1088
+ return false;
1089
+ const handover = record.screen?.handover;
1090
+ if (handover && handover.deadline <= timestamp) {
1091
+ await endHandover(record, 'expired');
1092
+ }
1093
+ if (record.leaseDeadline <= timestamp
1094
+ || timestamp - record.lastActivityAt >= idleSuspendMs) {
1095
+ return teardown(record);
1096
+ }
1097
+ return false;
1098
+ });
1099
+ if (suspended)
907
1100
  suspendedCount += 1;
908
- }
909
1101
  }
910
1102
  return suspendedCount;
911
1103
  }
@@ -930,6 +1122,77 @@ export function createDeskHost(options) {
930
1122
  }
931
1123
  return { start, resume, suspend, destroy, stats, sweep };
932
1124
  }
1125
+ /**
1126
+ * Whether the overlay directory's filesystem can make reflink clones.
1127
+ *
1128
+ * This exists because `cp --reflink=auto` — what `buildDeskLaunchPlan` uses,
1129
+ * deliberately, so a desk still boots on a filesystem without reflink — is
1130
+ * SILENT when it falls back. On XFS or Btrfs a 20GB base clones instantly and
1131
+ * costs kilobytes; on ext4 the identical command copies all 20GB, slowly, per
1132
+ * desk. The failure is invisible until a disk sized for reflink sharing fills
1133
+ * after two desks, which looks like a capacity bug rather than the filesystem
1134
+ * choice it actually is.
1135
+ *
1136
+ * So the probe uses `--reflink=always`, which REFUSES to fall back, on a tiny
1137
+ * throwaway file. Call it at startup and refuse to serve rather than discover
1138
+ * this in production — see `assertOverlayReflink`.
1139
+ */
1140
+ export async function verifyOverlayReflink(options) {
1141
+ const overlayDirectory = options.overlayDirectory;
1142
+ const idFactory = options.idFactory ?? randomUUID;
1143
+ const copy = options.copy ?? reflinkAlways;
1144
+ const token = idFactory();
1145
+ const source = join(overlayDirectory, `.reflink-probe-${token}`);
1146
+ const destination = join(overlayDirectory, `.reflink-probe-${token}.clone`);
1147
+ try {
1148
+ try {
1149
+ await writeFile(source, 'reflink probe');
1150
+ }
1151
+ catch (error) {
1152
+ // A directory that cannot be written to is not a filesystem without
1153
+ // reflink — it is a broken deployment, and reporting it as the former
1154
+ // would send an operator to reformat a disk over a permissions bug.
1155
+ throw new DeskError(`The overlay directory ${overlayDirectory} could not be written to, so reflink `
1156
+ + `support could not be determined: ${errorMessage(error)}`);
1157
+ }
1158
+ await copy(source, destination);
1159
+ return { supported: true, overlayDirectory, detail: 'cp --reflink=always succeeded.' };
1160
+ }
1161
+ catch (error) {
1162
+ if (error instanceof DeskError)
1163
+ throw error;
1164
+ return { supported: false, overlayDirectory, detail: errorMessage(error) };
1165
+ }
1166
+ finally {
1167
+ await rm(source, { force: true }).catch(() => undefined);
1168
+ await rm(destination, { force: true }).catch(() => undefined);
1169
+ }
1170
+ }
1171
+ /**
1172
+ * Fail loudly at startup on a filesystem without reflink support, naming the
1173
+ * cost so the operator can act on it.
1174
+ */
1175
+ export async function assertOverlayReflink(options) {
1176
+ const check = await verifyOverlayReflink(options);
1177
+ if (!check.supported) {
1178
+ throw new DeskError(`The overlay directory ${check.overlayDirectory} is on a filesystem without reflink `
1179
+ + 'support, so every desk would FULL-COPY the base image instead of sharing its '
1180
+ + 'blocks — gigabytes and minutes per desk, and a disk sized for sharing fills after '
1181
+ + `a handful of them. Put the overlay directory on XFS or Btrfs. Probe said: ${check.detail}`);
1182
+ }
1183
+ return check;
1184
+ }
1185
+ async function reflinkAlways(source, destination) {
1186
+ await new Promise((resolvePromise, rejectPromise) => {
1187
+ execFile('cp', ['--reflink=always', source, destination], (error, _stdout, stderr) => {
1188
+ if (error) {
1189
+ rejectPromise(new Error(stderr.trim() || errorMessage(error)));
1190
+ return;
1191
+ }
1192
+ resolvePromise();
1193
+ });
1194
+ });
1195
+ }
933
1196
  /**
934
1197
  * Boot-time probe. Distinguishes a host that is unusable — wrong platform,
935
1198
  * missing VMM, missing images — which throws, from capabilities that are
@@ -1032,6 +1295,20 @@ function positiveInteger(value, field) {
1032
1295
  }
1033
1296
  return value;
1034
1297
  }
1298
+ function nonNegativeInteger(value, field) {
1299
+ if (!Number.isInteger(value) || value < 0) {
1300
+ throw new DeskError(`${field} must be an integer of zero or more.`);
1301
+ }
1302
+ return value;
1303
+ }
1304
+ /**
1305
+ * Free bytes on the filesystem holding `path`, measured against the directory
1306
+ * so the answer is the same before and after the overlay itself exists.
1307
+ */
1308
+ async function defaultFreeBytes(path) {
1309
+ const stats = await statfs(dirname(path));
1310
+ return Number(stats.bsize) * Number(stats.bavail);
1311
+ }
1035
1312
  function errorMessage(error) {
1036
1313
  return error instanceof Error ? error.message : String(error);
1037
1314
  }