@tikoci/quickchr 0.5.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.
@@ -62,11 +62,11 @@ import {
62
62
  getDataDir,
63
63
  appendBootLog,
64
64
  } from "./state.ts";
65
- import { ensureCachedImage, copyImageToMachine, listCachedImages } from "./images.ts";
65
+ import { ensureCachedImage, listCachedImages } from "./images.ts";
66
66
  import { autoPruneIfOverCap } from "./cache.ts";
67
67
  import { resolveSetting } from "./settings.ts";
68
68
  import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, waitForApiReady, readinessProbeFor, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
69
- import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
69
+ import { bootDiskFormatOf, bootDiskPath, cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, writeBootDisk, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
70
70
  import { monitorCommand, serialStreams, qgaCommand, channelEndpoint, channelFileExists, channelPath } from "./channels.ts";
71
71
  import { captureBootFailure, newBootProbeStats, preserveOnFailure, type BootFailureReport, type BootProbeStats, type FailureTrigger } from "./diagnostics.ts";
72
72
  import { installPackages, installAllPackages, downloadAndListPackages, downloadPackages, findPackageFile, uploadPackages } from "./packages.ts";
@@ -103,8 +103,9 @@ import {
103
103
  import type { LicenseOptions } from "./types.ts";
104
104
  import type { GuestExec } from "./guest-snapshot.ts";
105
105
  import { toChrPorts } from "./network.ts";
106
- import { assertSufficientQuickchrStorage, formatQuickchrUsage, getQuickchrStorageReport } from "./storage.ts";
107
- import { existsSync, rmSync, copyFileSync } from "node:fs";
106
+ import { admitStorage, estimateMachineCreation, formatQuickchrUsage, getQuickchrStorageReport } from "./storage.ts";
107
+ import { pinCacheEntries } from "./storage-lease.ts";
108
+ import { existsSync, rmSync } from "node:fs";
108
109
  import { acquireLock, acquireLockNow, allocationLockPath, legacyMachineLockPath, listReservations, machineLockPath, type HeldLock, type LockOperation } from "./lock.ts";
109
110
  import { join } from "node:path";
110
111
 
@@ -373,19 +374,15 @@ async function resetGuestToFactory(state: MachineState): Promise<void> {
373
374
  if (!existsSync(imgPath)) {
374
375
  throw new QuickCHRError("MACHINE_NOT_FOUND", `Cached image not found: ${imgPath}`);
375
376
  }
376
- const destPath = join(state.machineDir, "disk.img");
377
- copyFileSync(imgPath, destPath);
378
-
379
- // Clean up disk files (boot.qcow2, extra disks)
377
+ // Drop boot.qcow2 and the extra disks, then rebuild the boot disk from the cache.
378
+ // A raw disk.img is replaced atomically. On a qcow2 machine, disk.img is the unused
379
+ // raw copy machines created before 0.5.1 kept: the new boot.qcow2 is converted from
380
+ // the cache, so the copy goes too (#199).
381
+ const format = bootDiskFormatOf(state);
380
382
  cleanDiskFiles(state.machineDir);
381
-
382
- // Re-prepare disks if the machine had disk customizations
383
- await ensureConfiguredDisks(
384
- state.machineDir,
385
- state.bootSize,
386
- state.extraDisks,
387
- state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
388
- );
383
+ await writeBootDisk(state.machineDir, imgPath, format, state.bootSize);
384
+ if (format === "qcow2") rmSync(join(state.machineDir, "disk.img"), { force: true });
385
+ await ensureConfiguredDisks(state.machineDir, state.bootSize, state.extraDisks, format, { booted: false });
389
386
 
390
387
  deleteInstanceCredentials(state.name);
391
388
  // The managed keypair authenticated to the account factory reset erased,
@@ -1001,21 +998,28 @@ function createInstance(state: MachineState): ChrInstance {
1001
998
  const names = typeof packages === "string" ? [packages] : packages;
1002
999
  if (names.length === 0) return [];
1003
1000
 
1004
- const extractDir = await downloadPackages(state.version, state.arch);
1001
+ // A booted machine no longer holds its package cache in use, so pin it until the
1002
+ // upload is done: a concurrent eviction must not remove it mid-read.
1003
+ const pin = pinCacheEntries(`install packages on ${state.name}`, [`packages-${state.arch}-${state.version}`]);
1005
1004
  const packagePaths: string[] = [];
1006
1005
  const installed: string[] = [];
1007
- for (const pkg of names) {
1008
- const pkgPath = findPackageFile(extractDir, pkg);
1009
- if (!pkgPath) {
1010
- console.warn(`Package "${pkg}" not found in all_packages for ${state.version} (${state.arch})`);
1011
- continue;
1006
+ try {
1007
+ const extractDir = await downloadPackages(state.version, state.arch);
1008
+ for (const pkg of names) {
1009
+ const pkgPath = findPackageFile(extractDir, pkg);
1010
+ if (!pkgPath) {
1011
+ console.warn(`Package "${pkg}" not found in all_packages for ${state.version} (${state.arch})`);
1012
+ continue;
1013
+ }
1014
+ packagePaths.push(pkgPath);
1015
+ installed.push(pkg);
1012
1016
  }
1013
- packagePaths.push(pkgPath);
1014
- installed.push(pkg);
1015
- }
1016
- if (packagePaths.length === 0) return [];
1017
+ if (packagePaths.length === 0) return [];
1017
1018
 
1018
- await uploadPackages(packagePaths, ports.ssh);
1019
+ await uploadPackages(packagePaths, ports.ssh);
1020
+ } finally {
1021
+ pin.release();
1022
+ }
1019
1023
 
1020
1024
  // Reboot to activate packages
1021
1025
  let rebootAuth: string;
@@ -1119,7 +1123,7 @@ function createInstance(state: MachineState): ChrInstance {
1119
1123
 
1120
1124
  snapshot: {
1121
1125
  async list(): Promise<SnapshotInfo[]> {
1122
- const format = state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw");
1126
+ const format = bootDiskFormatOf(state);
1123
1127
  if (format !== "qcow2") return [];
1124
1128
 
1125
1129
  // Try monitor first (works on running machines, gives live state)
@@ -1131,7 +1135,7 @@ function createInstance(state: MachineState): ChrInstance {
1131
1135
  }
1132
1136
 
1133
1137
  // Fall back to qemu-img info (works on stopped machines too)
1134
- const bootPath = join(state.machineDir, "boot.qcow2");
1138
+ const bootPath = bootDiskPath(state.machineDir, "qcow2");
1135
1139
  if (existsSync(bootPath)) {
1136
1140
  return listSnapshots(bootPath);
1137
1141
  }
@@ -1139,7 +1143,7 @@ function createInstance(state: MachineState): ChrInstance {
1139
1143
  },
1140
1144
 
1141
1145
  async save(name?: string): Promise<SnapshotInfo> {
1142
- const format = state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw");
1146
+ const format = bootDiskFormatOf(state);
1143
1147
  if (format !== "qcow2") {
1144
1148
  throw new QuickCHRError("STATE_ERROR", "Snapshots require a qcow2 boot disk. Recreate with bootDiskFormat: \"qcow2\".");
1145
1149
  }
@@ -1176,7 +1180,7 @@ function createInstance(state: MachineState): ChrInstance {
1176
1180
  },
1177
1181
 
1178
1182
  async load(name: string): Promise<void> {
1179
- const format = state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw");
1183
+ const format = bootDiskFormatOf(state);
1180
1184
  if (format !== "qcow2") {
1181
1185
  throw new QuickCHRError("STATE_ERROR", "Snapshots require a qcow2 boot disk.");
1182
1186
  }
@@ -1202,7 +1206,7 @@ function createInstance(state: MachineState): ChrInstance {
1202
1206
  },
1203
1207
 
1204
1208
  async delete(name: string): Promise<void> {
1205
- const format = state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw");
1209
+ const format = bootDiskFormatOf(state);
1206
1210
  if (format !== "qcow2") {
1207
1211
  throw new QuickCHRError("STATE_ERROR", "Snapshots require a qcow2 boot disk.");
1208
1212
  }
@@ -1584,7 +1588,8 @@ async function buildLaunchConfigFromState(state: MachineState): Promise<QemuLaun
1584
1588
  state.machineDir,
1585
1589
  state.bootSize,
1586
1590
  state.extraDisks,
1587
- state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
1591
+ bootDiskFormatOf(state),
1592
+ { booted: true },
1588
1593
  );
1589
1594
  const platform = await detectPlatform();
1590
1595
  const hostfwd = buildHostfwdString(state.ports);
@@ -2029,16 +2034,28 @@ export class QuickCHR {
2029
2034
  }
2030
2035
 
2031
2036
  const machineDir = getMachineDir(name);
2032
- assertSufficientQuickchrStorage(`prepare CHR ${name}`);
2033
- const { lock, portBase, ports, networks } = await claimNewMachine(name, "add", opts);
2037
+ // Held until machine.json is saved: it reserves the boot disk's bytes and pins the
2038
+ // image against a concurrent eviction (storage-lease.ts).
2039
+ const storageLease = admitStorage(
2040
+ `prepare CHR ${name}`,
2041
+ estimateMachineCreation({ version, arch, packages: !!(opts.installAllPackages || opts.packages?.length) }),
2042
+ );
2043
+ let claimed: Awaited<ReturnType<typeof claimNewMachine>>;
2044
+ try {
2045
+ claimed = await claimNewMachine(name, "add", opts);
2046
+ } catch (err) {
2047
+ storageLease.release();
2048
+ throw err;
2049
+ }
2050
+ const { lock, portBase, ports, networks } = claimed;
2034
2051
  try {
2035
2052
  const cachedImg = await ensureCachedImage(version, arch);
2036
- copyImageToMachine(cachedImg, machineDir);
2037
2053
  const diskArtifacts = await ensureConfiguredDisks(
2038
2054
  machineDir,
2039
2055
  diskOpts.bootSize,
2040
2056
  diskOpts.extraDisks,
2041
2057
  diskOpts.bootDiskFormat,
2058
+ { booted: false, factoryImage: async () => cachedImg },
2042
2059
  );
2043
2060
 
2044
2061
  const state: MachineState = {
@@ -2083,6 +2100,7 @@ export class QuickCHR {
2083
2100
  throw err;
2084
2101
  } finally {
2085
2102
  lock.release();
2103
+ storageLease.release();
2086
2104
  }
2087
2105
  }
2088
2106
 
@@ -2282,7 +2300,13 @@ export class QuickCHR {
2282
2300
  }
2283
2301
 
2284
2302
  const machineDir = getMachineDir(name);
2285
- assertSufficientQuickchrStorage(`start CHR ${name}`);
2303
+ // Held until machine.json is saved — after the spawn, or in the foreground once
2304
+ // QEMU exits — and released on failure: see add().
2305
+ const storageLease = admitStorage(
2306
+ `start CHR ${name}`,
2307
+ estimateMachineCreation({ version, arch, packages: !!(opts.installAllPackages || opts.packages?.length) }),
2308
+ { logger: logger ? (message: string) => logger.status(message) : undefined },
2309
+ );
2286
2310
  const dirCreatedHere = !existsSync(machineDir);
2287
2311
 
2288
2312
  const background = opts.background ?? true;
@@ -2301,11 +2325,14 @@ export class QuickCHR {
2301
2325
  opts.secureLogin === true
2302
2326
  );
2303
2327
 
2304
- const { lock, portBase, ports, networks: networkConfigs } = await claimNewMachine(
2305
- name,
2306
- !background && !hasProvisioning ? "foreground" : "start",
2307
- opts,
2308
- );
2328
+ let claimed: Awaited<ReturnType<typeof claimNewMachine>>;
2329
+ try {
2330
+ claimed = await claimNewMachine(name, !background && !hasProvisioning ? "foreground" : "start", opts);
2331
+ } catch (err) {
2332
+ storageLease.release();
2333
+ throw err;
2334
+ }
2335
+ const { lock, portBase, ports, networks: networkConfigs } = claimed;
2309
2336
  // Endpoints are claimed before QEMU is spawned, because resolution needs to know
2310
2337
  // which end this machine holds. A launch that fails before its state is persisted
2311
2338
  // has no instance lifecycle to release them later, so a failed start would leave a
@@ -2316,14 +2343,14 @@ export class QuickCHR {
2316
2343
 
2317
2344
  // Download and prepare image
2318
2345
  const cachedImg = await ensureCachedImage(version, arch, undefined, logger);
2319
- copyImageToMachine(cachedImg, machineDir);
2320
2346
 
2321
- // Prepare disks (boot resize + extra disks)
2347
+ // Build the boot disk from the cache (resized if asked) and any extra disks
2322
2348
  const diskArtifacts = await ensureConfiguredDisks(
2323
2349
  machineDir,
2324
2350
  diskOpts.bootSize,
2325
2351
  diskOpts.extraDisks,
2326
2352
  diskOpts.bootDiskFormat,
2353
+ { booted: false, factoryImage: async () => cachedImg },
2327
2354
  );
2328
2355
  const bootDisk = diskArtifacts.bootDisk;
2329
2356
  const extraDiskConfigs = diskArtifacts.extraDisks;
@@ -2414,6 +2441,7 @@ export class QuickCHR {
2414
2441
 
2415
2442
  saveMachine(state);
2416
2443
  unpersistedSocketClaim = undefined;
2444
+ storageLease.release();
2417
2445
 
2418
2446
  const instance = createInstance(state);
2419
2447
  if (opts.wait === false) return instance;
@@ -2539,6 +2567,7 @@ export class QuickCHR {
2539
2567
  throw err;
2540
2568
  } finally {
2541
2569
  lock.release();
2570
+ storageLease.release();
2542
2571
  }
2543
2572
  }
2544
2573
 
@@ -2743,16 +2772,17 @@ export class QuickCHR {
2743
2772
  hasProvisioning = false;
2744
2773
  }
2745
2774
 
2746
- const diskPath = join(state.machineDir, "disk.img");
2747
- if (!existsSync(diskPath)) {
2748
- throw new QuickCHRError("MACHINE_NOT_FOUND", `Disk image not found for "${state.name}"`);
2749
- }
2750
-
2775
+ // Checks the disk this machine boots from — a qcow2 machine needs only boot.qcow2
2776
+ // (#200) — and refuses, rather than rebuilds, a booted machine's missing disk.
2751
2777
  const diskArtifacts = await ensureConfiguredDisks(
2752
2778
  state.machineDir,
2753
2779
  state.bootSize,
2754
2780
  state.extraDisks,
2755
- state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
2781
+ bootDiskFormatOf(state),
2782
+ {
2783
+ booted: !isProvisioningWindowOpen(state),
2784
+ factoryImage: () => ensureCachedImage(state.version, state.arch, undefined, logger),
2785
+ },
2756
2786
  );
2757
2787
  const bootDisk = diskArtifacts.bootDisk;
2758
2788
  const extraDiskConfigs = diskArtifacts.extraDisks;
@@ -3067,14 +3097,14 @@ export class QuickCHR {
3067
3097
  detail: `${machines.length} instance${machines.length !== 1 ? "s" : ""} (${running} running, ${formatDiskSize(storage.machinesBytes)} on disk)`,
3068
3098
  });
3069
3099
 
3070
- let storageDetail = `${storage.path}: ${formatDiskSize(storage.freeBytes)} free; ${formatQuickchrUsage(storage)}`;
3100
+ let storageDetail = `${storage.statPath}: ${formatDiskSize(storage.freeBytes)} free; ${formatQuickchrUsage(storage)}`;
3071
3101
  if (storage.status === "error") {
3072
- storageDetail += ` — below required minimum ${formatDiskSize(storage.recommendedFreeBytes)}`;
3102
+ storageDetail += ` — below the ${formatDiskSize(storage.reserveBytes)} reserve: new machines and image downloads are refused (after evicting what the cache can spare)`;
3073
3103
  } else if (storage.status === "warn") {
3074
3104
  storageDetail += ` — low headroom (target ${formatDiskSize(storage.warningFreeBytes)} free)`;
3075
3105
  }
3076
3106
  checks.push({
3077
- label: storage.label === ".local" ? "Storage (.local)" : "Storage",
3107
+ label: "Storage",
3078
3108
  status: storage.status,
3079
3109
  detail: storageDetail,
3080
3110
  });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Publish-by-rename that survives Windows' transient sharing violations.
3
+ */
4
+
5
+ import { renameSync } from "node:fs";
6
+
7
+ /** Codes Windows returns while another process (Defender, the Search indexer) still
8
+ * holds a handle on a file it is scanning — typically one we just wrote. */
9
+ const TRANSIENT_WINDOWS_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
10
+ const RETRY_DELAYS_MS = [25, 50, 100, 200, 400, 800];
11
+
12
+ /**
13
+ * `renameSync`, retried on Windows while the failure looks like a sharing violation
14
+ * (#202). Every quickchr publish writes a temp file or directory and renames it into
15
+ * place, which is exactly when a scanner has the fresh file open. libuv does not retry
16
+ * this case. The budget is bounded (about 1.6 s); after it, the last error is thrown
17
+ * unchanged. Elsewhere this is a plain `renameSync`.
18
+ */
19
+ export function renameWithRetry(
20
+ from: string,
21
+ to: string,
22
+ opts: { platform?: NodeJS.Platform; rename?: (from: string, to: string) => void; sleep?: (ms: number) => void } = {},
23
+ ): void {
24
+ const rename = opts.rename ?? renameSync;
25
+ if ((opts.platform ?? process.platform) !== "win32") {
26
+ rename(from, to);
27
+ return;
28
+ }
29
+ const sleep = opts.sleep ?? Bun.sleepSync;
30
+ for (const delay of RETRY_DELAYS_MS) {
31
+ try {
32
+ rename(from, to);
33
+ return;
34
+ } catch (error) {
35
+ const code = (error as NodeJS.ErrnoException).code;
36
+ if (!code || !TRANSIENT_WINDOWS_CODES.has(code)) throw error;
37
+ sleep(delay);
38
+ }
39
+ }
40
+ rename(from, to);
41
+ }
@@ -17,7 +17,7 @@ import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, renameSync, u
17
17
  import { basename, dirname, join } from "node:path";
18
18
  import { ARCHES, CHANNELS, QuickCHRError, parseAccelMode } from "./types.ts";
19
19
  import type { AccelMode, Channel } from "./types.ts";
20
- import { parseSizeString, DEFAULT_CACHE_MAX_BYTES } from "./cache.ts";
20
+ import { parseSizeString, DEFAULT_CACHE_MAX_BYTES, DEFAULT_STORAGE_RESERVE_BYTES } from "./cache.ts";
21
21
  import { loadSettingsFileDefaults, readSettingsFileLines, settingsFilePath } from "./settings-file.ts";
22
22
 
23
23
  // The quickchr.env file tier lives in the leaf module settings-file.ts (see its
@@ -56,12 +56,14 @@ function parseArchSetting(raw: string): SettingsValue {
56
56
  return raw;
57
57
  }
58
58
 
59
- function parseCacheMaxSize(raw: string): SettingsValue {
60
- try {
61
- return parseSizeString(raw);
62
- } catch (e) {
63
- throw new QuickCHRError("INVALID_SETTING_VALUE", `Invalid value "${raw}" for cache-max-size — ${e instanceof Error ? e.message : String(e)}`);
64
- }
59
+ function sizeSetting(attr: string): (raw: string) => SettingsValue {
60
+ return (raw) => {
61
+ try {
62
+ return parseSizeString(raw);
63
+ } catch (e) {
64
+ throw new QuickCHRError("INVALID_SETTING_VALUE", `Invalid value "${raw}" for ${attr} — ${e instanceof Error ? e.message : String(e)}`);
65
+ }
66
+ };
65
67
  }
66
68
 
67
69
  /** Shared with the CLI's own `--timeout-extra`/`-T` flag validation (cmdStart in
@@ -112,7 +114,16 @@ export const SETTINGS_KEYS: readonly SettingsKeyDef[] = [
112
114
  attr: "cache-max-size",
113
115
  envKey: "QUICKCHR_CACHE_MAX_SIZE",
114
116
  builtinDefault: DEFAULT_CACHE_MAX_BYTES,
115
- parse: parseCacheMaxSize,
117
+ parse: sizeSetting("cache-max-size"),
118
+ serialize: (v) => String(v),
119
+ },
120
+ {
121
+ // Free space creating a machine or filling the cache must leave on the data
122
+ // dir's filesystem, on top of what the operation itself writes (#199).
123
+ attr: "storage-reserve",
124
+ envKey: "QUICKCHR_STORAGE_RESERVE",
125
+ builtinDefault: DEFAULT_STORAGE_RESERVE_BYTES,
126
+ parse: sizeSetting("storage-reserve"),
116
127
  serialize: (v) => String(v),
117
128
  },
118
129
  {
@@ -123,7 +134,7 @@ export const SETTINGS_KEYS: readonly SettingsKeyDef[] = [
123
134
  serialize: (v) => String(v),
124
135
  },
125
136
  {
126
- // No builtinDefault: unlike the other 5 keys, the wizard needs to distinguish
137
+ // No builtinDefault: unlike the other keys, the wizard needs to distinguish
127
138
  // "not configured" from "explicitly false" (src/cli/wizard.ts's userChoice
128
139
  // initialValue only pre-highlights "admin" when the setting is concretely
129
140
  // false) — a builtinDefault here would collapse that distinction and always
package/src/lib/state.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Machine state persistence — JSON files in ~/.local/share/quickchr/machines/.
3
3
  */
4
4
 
5
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync, statSync } from "node:fs";
5
+ import { appendFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync, statSync } from "node:fs";
6
6
  import { join, } from "node:path";
7
7
  import type { MachineState, } from "./types.ts";
8
8
  import { QuickCHRError } from "./types.ts";
@@ -415,3 +415,28 @@ export function pruneCache(maxAgeDays: number = 30): number {
415
415
 
416
416
  return removed;
417
417
  }
418
+
419
+ /** Logical size of a file, or of everything under a directory. */
420
+ export function directorySize(path: string): number {
421
+ if (!existsSync(path)) return 0;
422
+
423
+ const stat = lstatSync(path);
424
+ if (!stat.isDirectory()) {
425
+ return stat.size;
426
+ }
427
+
428
+ let total = 0;
429
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
430
+ const fullPath = join(path, entry.name);
431
+ if (entry.isDirectory()) {
432
+ total += directorySize(fullPath);
433
+ } else {
434
+ try {
435
+ total += lstatSync(fullPath).size;
436
+ } catch {
437
+ // Skip entries that can't be stat'd (e.g. Windows named pipes throw EACCES)
438
+ }
439
+ }
440
+ }
441
+ return total;
442
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Cross-process coordination between storage admission, cache readers and cache
3
+ * eviction (#199, Codex review of #203).
4
+ *
5
+ * One data-dir-wide lock, held only for the length of a check-and-record or an
6
+ * eviction pass: never across a download or a boot. Under it, an operation records a
7
+ * **lease** — a file naming the cache entries it is about to read (pins) and the bytes
8
+ * it is about to write (reservations, per filesystem). Eviction runs under the same
9
+ * lock and skips pinned entries; admission subtracts other live leases' reservations
10
+ * from measured free space. A pin is recorded before the reader checks the entry
11
+ * exists, so an eviction either sees the pin or has already finished — and then the
12
+ * reader finds the entry gone and fetches it again.
13
+ *
14
+ * A lease lives exactly as long as its operation: released when the operation's
15
+ * writes are done (or its machine.json, which protects the image from then on, is
16
+ * saved), and ignored and deleted once its pid is dead. While an operation is still
17
+ * writing, its reservation and the bytes already written are both counted — a
18
+ * deliberate over-estimate for the length of one create.
19
+ */
20
+
21
+ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
22
+ import { dirname, join } from "node:path";
23
+ import { acquireLockNow, type HeldLock } from "./lock.ts";
24
+ import { ensureDir, getDataDir } from "./state.ts";
25
+ import { renameWithRetry } from "./rename.ts";
26
+ import { QuickCHRError } from "./types.ts";
27
+
28
+ /** Bytes an in-flight operation will write to one filesystem, by `stat().dev`. */
29
+ export interface StorageReservation {
30
+ dev: number;
31
+ bytes: number;
32
+ }
33
+
34
+ export interface LeaseRecord {
35
+ pid: number;
36
+ at: string;
37
+ /** What the operation is, for diagnostics. */
38
+ action: string;
39
+ /** Cache entry names (as `cacheInventory` gives them) that must not be evicted. */
40
+ pins: string[];
41
+ reservations: StorageReservation[];
42
+ }
43
+
44
+ export interface StorageLease {
45
+ readonly record: LeaseRecord;
46
+ /** Idempotent. */
47
+ release(): void;
48
+ }
49
+
50
+ const LOCK_WAIT_MS = 30_000;
51
+ const LOCK_POLL_MS = 25;
52
+
53
+ export function storageLockPath(): string {
54
+ return join(getDataDir(), ".storage.lock");
55
+ }
56
+
57
+ function leaseDir(): string {
58
+ return join(getDataDir(), ".storage-leases");
59
+ }
60
+
61
+ export function isPidAlive(pid: number): boolean {
62
+ try {
63
+ process.kill(pid, 0);
64
+ return true;
65
+ } catch (error) {
66
+ return (error as NodeJS.ErrnoException).code === "EPERM";
67
+ }
68
+ }
69
+
70
+ /** Run `fn` holding the storage lock. Synchronous: holders keep it for milliseconds
71
+ * (a check, or one eviction pass), so a short spin is simpler than making every
72
+ * caller async. The lock is not reentrant: `fn` must not take it again. */
73
+ export function withStorageLock<T>(fn: () => T): T {
74
+ ensureDir(getDataDir());
75
+ const deadline = Date.now() + LOCK_WAIT_MS;
76
+ let lock: HeldLock | undefined;
77
+ for (;;) {
78
+ try {
79
+ lock = acquireLockNow(storageLockPath(), "storage", { subject: "The storage lock" });
80
+ break;
81
+ } catch (error) {
82
+ if (!(error instanceof QuickCHRError) || error.code !== "MACHINE_LOCKED" || Date.now() >= deadline) throw error;
83
+ Bun.sleepSync(LOCK_POLL_MS);
84
+ }
85
+ }
86
+ try {
87
+ return fn();
88
+ } finally {
89
+ lock.release();
90
+ }
91
+ }
92
+
93
+ /** Every live lease. Leases of dead processes are deleted. Call under the lock. */
94
+ export function liveLeases(): LeaseRecord[] {
95
+ let names: string[];
96
+ try {
97
+ names = readdirSync(leaseDir());
98
+ } catch {
99
+ return [];
100
+ }
101
+ const live: LeaseRecord[] = [];
102
+ for (const name of names) {
103
+ if (!name.endsWith(".json")) continue;
104
+ const path = join(leaseDir(), name);
105
+ try {
106
+ const record = JSON.parse(readFileSync(path, "utf8")) as LeaseRecord;
107
+ if (isPidAlive(record.pid)) {
108
+ live.push(record);
109
+ continue;
110
+ }
111
+ } catch { /* unreadable: a writer died mid-write — files are renamed into place */ }
112
+ rmSync(path, { force: true });
113
+ }
114
+ return live;
115
+ }
116
+
117
+ /** Pins of live leases, read without the lock (for `cache list`'s IN-USE column). */
118
+ export function pinnedEntries(): Set<string> {
119
+ const pins = new Set<string>();
120
+ try {
121
+ for (const name of readdirSync(leaseDir())) {
122
+ if (!name.endsWith(".json")) continue;
123
+ try {
124
+ const record = JSON.parse(readFileSync(join(leaseDir(), name), "utf8")) as LeaseRecord;
125
+ if (isPidAlive(record.pid)) for (const pin of record.pins) pins.add(pin);
126
+ } catch { /* being written or removed */ }
127
+ }
128
+ } catch { /* no leases */ }
129
+ return pins;
130
+ }
131
+
132
+ /** Record a lease. Call under the lock, after the checks it stands for. */
133
+ export function createLease(action: string, pins: string[], reservations: StorageReservation[]): StorageLease {
134
+ const record: LeaseRecord = { pid: process.pid, at: new Date().toISOString(), action, pins, reservations };
135
+ mkdirSync(leaseDir(), { recursive: true });
136
+ const path = join(leaseDir(), `${process.pid}-${crypto.randomUUID()}.json`);
137
+ writeFileSync(`${path}.tmp`, JSON.stringify(record));
138
+ renameWithRetry(`${path}.tmp`, path);
139
+ let released = false;
140
+ return {
141
+ record,
142
+ release() {
143
+ if (released) return;
144
+ released = true;
145
+ rmSync(path, { force: true });
146
+ },
147
+ };
148
+ }
149
+
150
+ /** Pin cache entries for the length of a read that is not otherwise protected (a
151
+ * running machine installing packages). Release when done. */
152
+ export function pinCacheEntries(action: string, pins: string[]): StorageLease {
153
+ return withStorageLock(() => createLease(action, pins, []));
154
+ }
155
+
156
+ /** The filesystem a path is (or will be) written on: `stat().dev` of its nearest
157
+ * existing ancestor, and that ancestor. */
158
+ export function filesystemOf(path: string): { dev: number; statPath: string } {
159
+ let current = path || ".";
160
+ for (;;) {
161
+ try {
162
+ return { dev: statSync(current).dev, statPath: current };
163
+ } catch {
164
+ const parent = dirname(current);
165
+ if (parent === current) return { dev: statSync(".").dev, statPath: "." };
166
+ current = parent;
167
+ }
168
+ }
169
+ }