@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/events.d.ts +1 -0
- package/events.d.ts.map +1 -1
- package/events.js.map +1 -1
- package/host.d.ts +72 -1
- package/host.d.ts.map +1 -1
- package/host.js +314 -37
- package/host.js.map +1 -1
- package/package.json +1 -1
- package/protocol.d.ts +1 -0
- package/protocol.d.ts.map +1 -1
- package/protocol.js +8 -0
- package/protocol.js.map +1 -1
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
}
|