@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.
- package/DESIGN.md +57 -7
- package/MANUAL.md +124 -26
- package/README.md +20 -2
- package/package.json +2 -2
- package/src/cli/flags.ts +7 -2
- package/src/cli/index.ts +59 -31
- package/src/lib/cache.ts +220 -38
- package/src/lib/disk.ts +90 -33
- package/src/lib/download.ts +3 -2
- package/src/lib/images.ts +34 -15
- package/src/lib/lock.ts +2 -1
- package/src/lib/packages.ts +66 -40
- package/src/lib/quickchr.ts +83 -53
- package/src/lib/rename.ts +41 -0
- package/src/lib/settings.ts +20 -9
- package/src/lib/state.ts +26 -1
- package/src/lib/storage-lease.ts +169 -0
- package/src/lib/storage.ts +223 -66
- package/src/lib/types.ts +13 -3
package/src/lib/quickchr.ts
CHANGED
|
@@ -62,11 +62,11 @@ import {
|
|
|
62
62
|
getDataDir,
|
|
63
63
|
appendBootLog,
|
|
64
64
|
} from "./state.ts";
|
|
65
|
-
import { ensureCachedImage,
|
|
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 {
|
|
107
|
-
import {
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1008
|
-
const
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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.
|
|
1014
|
-
installed.push(pkg);
|
|
1015
|
-
}
|
|
1016
|
-
if (packagePaths.length === 0) return [];
|
|
1017
|
+
if (packagePaths.length === 0) return [];
|
|
1017
1018
|
|
|
1018
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
2033
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2305
|
-
|
|
2306
|
-
!background && !hasProvisioning ? "foreground" : "start",
|
|
2307
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
2747
|
-
|
|
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
|
-
|
|
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.
|
|
3100
|
+
let storageDetail = `${storage.statPath}: ${formatDiskSize(storage.freeBytes)} free; ${formatQuickchrUsage(storage)}`;
|
|
3071
3101
|
if (storage.status === "error") {
|
|
3072
|
-
storageDetail += ` — below
|
|
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:
|
|
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
|
+
}
|
package/src/lib/settings.ts
CHANGED
|
@@ -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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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:
|
|
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
|
|
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
|
+
}
|