@tikoci/quickchr 0.5.0 → 0.5.2

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.
@@ -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
+ }
@@ -1,41 +1,81 @@
1
1
  /**
2
- * Storage health and usage helpers for quickchr-managed files.
2
+ * Storage admission and usage helpers for quickchr-managed files.
3
+ *
4
+ * An operation that writes is admitted when each filesystem it writes to has room for
5
+ * what the operation itself will write there (its estimated peak) plus the configured
6
+ * host reserve. When one does not, eligible cache entries are evicted first and free
7
+ * space is measured again; only then is the operation refused (#199).
3
8
  */
4
9
 
5
- import { existsSync, lstatSync, readdirSync, statfsSync } from "node:fs";
10
+ import { existsSync, statfsSync } from "node:fs";
6
11
  import { dirname, join } from "node:path";
12
+ import { type CacheEntry, countEntries, describePrune, type PruneResult, pruneCacheUnderLock, RECENT_USE_MS } from "./cache.ts";
13
+ import { createLease, filesystemOf, liveLeases, type StorageLease, withStorageLock } from "./storage-lease.ts";
7
14
  import { formatDiskSize } from "./disk.ts";
8
- import { getCacheDir, getDataDir, getMachinesDir } from "./state.ts";
9
- import { QuickCHRError } from "./types.ts";
15
+ import { resolveSetting } from "./settings.ts";
16
+ import { directorySize, getCacheDir, getDataDir, getMachinesDir } from "./state.ts";
17
+ import { chrImageBasename } from "./versions.ts";
18
+ import { type Arch, QuickCHRError } from "./types.ts";
10
19
 
11
- export const QUICKCHR_MIN_FREE_BYTES = 10 * 1024 ** 3;
12
- export const QUICKCHR_WARN_FREE_BYTES = 20 * 1024 ** 3;
20
+ const MiB = 1024 ** 2;
21
+
22
+ /**
23
+ * Peak new bytes per stage, rounded up from one host's measurements (#199, macOS x86,
24
+ * CHR 7.20–7.26): a 128 MiB extracted image from a ~45 MiB ZIP, a qcow2 boot disk of
25
+ * ~50 MiB after first boot (never more than the raw image it is converted from), and
26
+ * 29–52 MiB package archives that extract to about their own size. Estimates, not
27
+ * bounds: they are what admission charges, and the reserve absorbs the error.
28
+ */
29
+ export const STORAGE_ESTIMATES = {
30
+ /** Image ZIP plus its extraction, which coexist until the ZIP is removed. */
31
+ imageDownload: 256 * MiB,
32
+ /** A raw copy or a qcow2 conversion of the image, plus first-boot writes. */
33
+ bootDisk: 192 * MiB,
34
+ /** The all-packages archive plus its extraction; both are kept. */
35
+ packagesDownload: 192 * MiB,
36
+ } as const;
37
+
38
+ /** doctor warns while free space is under the reserve plus this much. */
39
+ const WARN_MARGIN_BYTES = 2 * 1024 ** 3;
13
40
 
14
41
  export interface QuickchrStorageReport {
15
- label: ".local" | "data dir";
42
+ /** The data dir: where the cache and machines are written. */
16
43
  path: string;
44
+ /** The nearest existing directory to `path`, whose filesystem is measured. */
17
45
  statPath: string;
18
46
  freeBytes: number;
19
47
  quickchrBytes: number;
20
48
  cacheBytes: number;
21
49
  machinesBytes: number;
22
50
  otherBytes: number;
23
- recommendedFreeBytes: number;
51
+ reserveBytes: number;
24
52
  warningFreeBytes: number;
25
53
  status: "ok" | "warn" | "error";
26
54
  }
27
55
 
28
- function resolveStorageRoot(): { label: QuickchrStorageReport["label"]; path: string } {
29
- if (process.env.QUICKCHR_DATA_DIR || process.platform === "win32") {
30
- return { label: "data dir", path: getDataDir() };
31
- }
32
-
33
- const home = process.env.HOME || process.env.USERPROFILE || "";
34
- if (!home) {
35
- return { label: "data dir", path: getDataDir() };
36
- }
56
+ /** One stage of an operation's writes. */
57
+ export interface StoragePart {
58
+ /** For the refusal message, e.g. "boot disk". */
59
+ label: string;
60
+ /** Estimated peak new bytes. */
61
+ bytes: number;
62
+ /** Where they are written: the part is checked on that path's filesystem. */
63
+ path: string;
64
+ /** Reserve the bytes in the operation's lease (the default). False for a stage that
65
+ * admits and reserves for itself when it runs, such as a download a create will
66
+ * trigger: checked now, so the create refuses early, but not held twice. */
67
+ hold?: boolean;
68
+ }
37
69
 
38
- return { label: ".local", path: join(home, ".local") };
70
+ /** What an operation is about to write and read, for admission. */
71
+ export interface StorageNeed {
72
+ parts: StoragePart[];
73
+ /** Versions the operation reads from the cache: reclamation never evicts them. */
74
+ protectVersions?: string[];
75
+ /** Cache entries the operation reads; pinned in its lease. */
76
+ pins?: string[];
77
+ /** The cache reclamation evicts from. Default: the data dir's cache. */
78
+ cacheDir?: string;
39
79
  }
40
80
 
41
81
  function nearestExistingPath(path: string): string {
@@ -48,30 +88,6 @@ function nearestExistingPath(path: string): string {
48
88
  return current;
49
89
  }
50
90
 
51
- function directorySize(path: string): number {
52
- if (!existsSync(path)) return 0;
53
-
54
- const stat = lstatSync(path);
55
- if (!stat.isDirectory()) {
56
- return stat.size;
57
- }
58
-
59
- let total = 0;
60
- for (const entry of readdirSync(path, { withFileTypes: true })) {
61
- const fullPath = join(path, entry.name);
62
- if (entry.isDirectory()) {
63
- total += directorySize(fullPath);
64
- } else {
65
- try {
66
- total += lstatSync(fullPath).size;
67
- } catch {
68
- // Skip entries that can't be stat'd (e.g. Windows named pipes throw EACCES)
69
- }
70
- }
71
- }
72
- return total;
73
- }
74
-
75
91
  function freeBytesForPath(path: string): number {
76
92
  if (process.platform !== "win32") {
77
93
  const result = Bun.spawnSync(["df", "-kP", path], {
@@ -95,15 +111,23 @@ function freeBytesForPath(path: string): number {
95
111
  return fsStats.bavail * (fsStats.bsize || 4096);
96
112
  }
97
113
 
114
+ /** The free-space measurement and filesystem identity, as an object so tests can
115
+ * replace them (a unit test cannot mount a second filesystem). */
116
+ export const storageProbe = { freeBytes: freeBytesForPath, filesystem: filesystemOf };
117
+
118
+ /** The configured host reserve: the `storage-reserve` setting (default 2 GiB). */
119
+ export function resolveStorageReserve(): number {
120
+ return resolveSetting("storage-reserve").value as number;
121
+ }
122
+
98
123
  export function getQuickchrStorageReport(
99
- recommendedFreeBytes: number = QUICKCHR_MIN_FREE_BYTES,
100
- warningFreeBytes: number = QUICKCHR_WARN_FREE_BYTES,
124
+ reserveBytes: number = resolveStorageReserve(),
125
+ warningFreeBytes: number = reserveBytes + WARN_MARGIN_BYTES,
101
126
  ): QuickchrStorageReport {
102
- const storageRoot = resolveStorageRoot();
103
- const statPath = nearestExistingPath(storageRoot.path);
104
- const freeBytes = freeBytesForPath(statPath);
105
-
106
127
  const dataDir = getDataDir();
128
+ const statPath = nearestExistingPath(dataDir);
129
+ const freeBytes = storageProbe.freeBytes(statPath);
130
+
107
131
  const cacheBytes = directorySize(getCacheDir());
108
132
  const machinesBytes = directorySize(getMachinesDir());
109
133
  const dataDirBytes = directorySize(dataDir);
@@ -111,22 +135,21 @@ export function getQuickchrStorageReport(
111
135
  const otherBytes = Math.max(0, dataDirBytes - cacheBytes - machinesBytes);
112
136
 
113
137
  let status: QuickchrStorageReport["status"] = "ok";
114
- if (freeBytes < recommendedFreeBytes) {
138
+ if (freeBytes < reserveBytes) {
115
139
  status = "error";
116
140
  } else if (freeBytes < warningFreeBytes) {
117
141
  status = "warn";
118
142
  }
119
143
 
120
144
  return {
121
- label: storageRoot.label,
122
- path: storageRoot.path,
145
+ path: dataDir,
123
146
  statPath,
124
147
  freeBytes,
125
148
  quickchrBytes,
126
149
  cacheBytes,
127
150
  machinesBytes,
128
151
  otherBytes,
129
- recommendedFreeBytes,
152
+ reserveBytes,
130
153
  warningFreeBytes,
131
154
  status,
132
155
  };
@@ -143,21 +166,155 @@ export function formatQuickchrUsage(report: QuickchrStorageReport): string {
143
166
  return `quickchr uses ${formatDiskSize(report.quickchrBytes)} (${parts.join(", ")})`;
144
167
  }
145
168
 
146
- export function assertSufficientQuickchrStorage(
147
- action: string,
148
- minimumFreeBytes: number = QUICKCHR_MIN_FREE_BYTES,
149
- ): QuickchrStorageReport {
150
- const report = getQuickchrStorageReport(
151
- minimumFreeBytes,
152
- Math.max(QUICKCHR_WARN_FREE_BYTES, minimumFreeBytes),
153
- );
169
+ /** What creating a machine will write: its boot disk, plus the image and package
170
+ * downloads it still needs. Extra disks are sparse qcow2 and start near zero.
171
+ * Only the boot disk is held in the create's lease: each download admits and
172
+ * reserves for itself when it runs, so holding it here would count it twice. */
173
+ export function estimateMachineCreation(opts: { version: string; arch: Arch; packages: boolean }): StorageNeed {
174
+ const cache = getCacheDir();
175
+ const image = chrImageBasename(opts.version, opts.arch);
176
+ const parts: StoragePart[] = [{ label: "boot disk", bytes: STORAGE_ESTIMATES.bootDisk, path: getMachinesDir() }];
177
+ if (!existsSync(join(cache, `${image}.img`))) {
178
+ parts.push({ label: "image download", bytes: STORAGE_ESTIMATES.imageDownload, path: cache, hold: false });
179
+ }
180
+ if (opts.packages && !existsSync(join(cache, `packages-${opts.arch}-${opts.version}`))) {
181
+ parts.push({ label: "packages download", bytes: STORAGE_ESTIMATES.packagesDownload, path: cache, hold: false });
182
+ }
183
+ return { parts, protectVersions: [opts.version], pins: [`${image}.img`, `${image}.img.zip`] };
184
+ }
185
+
186
+ interface FilesystemGroup {
187
+ dev: number;
188
+ statPath: string;
189
+ parts: StoragePart[];
190
+ bytes: number;
191
+ }
154
192
 
155
- if (report.freeBytes < minimumFreeBytes) {
156
- throw new QuickCHRError(
157
- "INSUFFICIENT_DISK_SPACE",
158
- `Not enough free space to ${action} in ${report.path}: ${formatDiskSize(report.freeBytes)} available, need at least ${formatDiskSize(minimumFreeBytes)}. ${formatQuickchrUsage(report)}.`,
159
- );
193
+ function groupByFilesystem(parts: StoragePart[]): FilesystemGroup[] {
194
+ const groups = new Map<number, FilesystemGroup>();
195
+ for (const part of parts.length > 0 ? parts : [{ label: "", bytes: 0, path: getDataDir() }]) {
196
+ const { dev, statPath } = storageProbe.filesystem(part.path);
197
+ const group = groups.get(dev) ?? { dev, statPath, parts: [], bytes: 0 };
198
+ if (part.label) group.parts.push(part);
199
+ group.bytes += part.bytes;
200
+ groups.set(dev, group);
160
201
  }
202
+ return [...groups.values()];
203
+ }
204
+
205
+ /**
206
+ * Admit an operation, or throw `INSUFFICIENT_DISK_SPACE`. Returns a lease the caller
207
+ * releases once its writes are done (see storage-lease.ts).
208
+ *
209
+ * Each filesystem the operation writes to is checked on its own: free space, less
210
+ * what other in-flight operations have reserved there, must cover that filesystem's
211
+ * parts plus the reserve. When it does not, and the cache being reclaimed lives on that
212
+ * filesystem, eligible entries are evicted (oldest version first; never in-use, pinned,
213
+ * protected or recently used ones) and free space is measured again — repeatedly,
214
+ * because logical sizes are no proof of room (sparse files, APFS clones), until the
215
+ * measurement admits or nothing more can be evicted. When everything eligible could
216
+ * not cover the shortage, nothing is evicted.
217
+ *
218
+ * The check and the lease are made under the storage lock, so two creators cannot
219
+ * spend the same free bytes, and an eviction cannot remove what this operation pins.
220
+ */
221
+ export function admitStorage(
222
+ action: string,
223
+ need: StorageNeed = { parts: [] },
224
+ opts: { reclaim?: boolean; logger?: (message: string) => void } = {},
225
+ ): StorageLease {
226
+ return withStorageLock(() => {
227
+ const reserveBytes = resolveStorageReserve();
228
+ const leases = liveLeases();
229
+ const pins = new Set([...leases.flatMap((lease) => lease.pins), ...(need.pins ?? [])]);
230
+ const cacheDir = need.cacheDir ?? getCacheDir();
231
+ const cacheDev = storageProbe.filesystem(cacheDir).dev;
161
232
 
162
- return report;
233
+ for (const group of groupByFilesystem(need.parts)) {
234
+ const reservedByOthers = leases
235
+ .flatMap((lease) => lease.reservations)
236
+ .filter((r) => r.dev === group.dev)
237
+ .reduce((sum, r) => sum + r.bytes, 0);
238
+ const measure = () => storageProbe.freeBytes(group.statPath) - reservedByOthers;
239
+ const required = group.bytes + reserveBytes;
240
+ let free = measure();
241
+ if (free >= required) continue;
242
+
243
+ const reclaim = opts.reclaim !== false && cacheDev === group.dev;
244
+ const evicted: CacheEntry[] = [];
245
+ const failed: PruneResult["failed"] = [];
246
+ let kept: PruneResult | undefined;
247
+ let wouldNotCover = false;
248
+ if (reclaim) {
249
+ const policy = { cacheDir, protectVersions: need.protectVersions, skipRecentMs: RECENT_USE_MS };
250
+ // Evicting cannot close a gap larger than everything eligible: refuse with the
251
+ // cache intact rather than empty it for nothing.
252
+ kept = pruneCacheUnderLock({ ...policy, reclaimBytes: required - free, dryRun: true }, pins);
253
+ wouldNotCover = kept.freedBytes < required - free;
254
+ while (!wouldNotCover && free < required) {
255
+ const pass = pruneCacheUnderLock({
256
+ ...policy,
257
+ reclaimBytes: required - free,
258
+ skipEntries: failed.map((f) => f.entry.basename),
259
+ }, pins);
260
+ for (const line of describePrune(pass)) opts.logger?.(line);
261
+ evicted.push(...pass.evicted);
262
+ failed.push(...pass.failed);
263
+ kept = pass;
264
+ free = measure();
265
+ if (pass.evicted.length === 0) break;
266
+ }
267
+ if (free >= required) continue;
268
+ }
269
+
270
+ throw insufficientSpace(action, group, { free, required, reserveBytes, reservedByOthers, reclaimed: kept && { evicted, failed, kept, wouldNotCover } });
271
+ }
272
+
273
+ const reservations = groupByFilesystem(need.parts.filter((part) => part.hold !== false))
274
+ .filter((group) => group.bytes > 0)
275
+ .map((group) => ({ dev: group.dev, bytes: group.bytes }));
276
+ if (reservations.length === 0 && (need.pins ?? []).length === 0) return { record: { pid: process.pid, at: "", action, pins: [], reservations: [] }, release() {} };
277
+ return createLease(action, need.pins ?? [], reservations);
278
+ });
279
+ }
280
+
281
+ function insufficientSpace(
282
+ action: string,
283
+ group: FilesystemGroup,
284
+ facts: {
285
+ free: number;
286
+ required: number;
287
+ reserveBytes: number;
288
+ reservedByOthers: number;
289
+ reclaimed?: { evicted: CacheEntry[]; failed: PruneResult["failed"]; kept: PruneResult; wouldNotCover: boolean };
290
+ },
291
+ ): QuickCHRError {
292
+ const detail = group.parts.map((part) => `${part.label} ${formatDiskSize(part.bytes)}`).join(" + ");
293
+ const needs = detail ? `${detail} + ${formatDiskSize(facts.reserveBytes)} reserve` : `${formatDiskSize(facts.reserveBytes)} reserve`;
294
+ const available = facts.reservedByOthers > 0
295
+ ? `${formatDiskSize(facts.free)} available after ${formatDiskSize(facts.reservedByOthers)} held by other operations in progress`
296
+ : `${formatDiskSize(facts.free)} available`;
297
+ const sentences = [
298
+ `Not enough free space to ${action} in ${group.statPath}: ${available}, need ${formatDiskSize(facts.required)} (${needs}), ${formatDiskSize(facts.required - facts.free)} short.`,
299
+ ];
300
+ const r = facts.reclaimed;
301
+ if (r) {
302
+ const freed = r.evicted.reduce((sum, e) => sum + e.sizeBytes, 0);
303
+ let line = r.wouldNotCover
304
+ ? r.kept.evicted.length > 0
305
+ ? `The cache could spare only ${formatDiskSize(r.kept.freedBytes)} (${countEntries(r.kept.evicted.length)}), not enough, so nothing was evicted.`
306
+ : "Nothing in the cache could be evicted."
307
+ : `Evicted ${countEntries(r.evicted.length)} from the cache (${formatDiskSize(freed)}) first.`;
308
+ if (r.failed.length > 0) line += ` ${r.failed.length} delete(s) failed.`;
309
+ const keptBytes = r.kept.kept
310
+ .filter((e) => r.kept.reasons[e.basename] !== "kept")
311
+ .reduce((sum, e) => sum + e.sizeBytes, 0);
312
+ if (keptBytes > 0) line += ` ${formatDiskSize(keptBytes)} is kept: in use by a machine or an operation in progress, needed by this operation, or used in the last ${RECENT_USE_MS / 60_000} minutes.`;
313
+ sentences.push(line);
314
+ }
315
+ return new QuickCHRError(
316
+ "INSUFFICIENT_DISK_SPACE",
317
+ sentences.join(" "),
318
+ "Free space on that filesystem, remove machines you no longer need, or lower the reserve with 'quickchr settings set storage-reserve <size>' (QUICKCHR_STORAGE_RESERVE).",
319
+ );
163
320
  }
package/src/lib/types.ts CHANGED
@@ -432,7 +432,8 @@ export interface StartOptions {
432
432
  * networks: ["user", { type: "socket-connect", port: hostTcpServerPort }]
433
433
  */
434
434
  networks?: NetworkSpecifier[];
435
- /** Install OS-level dependencies (QEMU, firmware) automatically if missing. */
435
+ /** @deprecated Ignored — nothing ever read it, so it never installed anything (#193).
436
+ * `quickchr doctor` lists missing host dependencies. To be removed in a later minor. */
436
437
  installDeps?: boolean;
437
438
  /** Print QEMU command and config without actually starting the VM. */
438
439
  dryRun?: boolean;
@@ -740,7 +741,8 @@ export interface ChrInstance {
740
741
  * **Soft errors.** Some RouterOS commands return HTTP 200 with an error
741
742
  * string in `output` (e.g. `/dude/agent/add` may resolve OK with
742
743
  * `"doAdd Agent not implemented"`). `exec()` does not parse output for these
743
- * patterns — inspect `result.output` if you need to assert success.
744
+ * patterns — inspect `result.output` if you need to assert success. For a
745
+ * pass/fail result, use `centrs execute --quickchr <name> --yes <command>`.
744
746
  */
745
747
  exec(command: string, opts?: ExecOptions): Promise<ExecResult>;
746
748
  /** Apply or renew a CHR trial license.
@@ -949,7 +951,15 @@ export interface ExecOptions {
949
951
  timeout?: number;
950
952
  }
951
953
 
952
- /** Result of a ChrInstance.exec() call. */
954
+ /** Result of a ChrInstance.exec() call.
955
+ *
956
+ * A resolved `exec()` means the command was **delivered**, not that RouterOS
957
+ * accepted it. `/rest/execute` answers HTTP 200 with the rejection text (e.g.
958
+ * `failure: invalid user name`) in the same envelope as a success, so there is no
959
+ * failure field here, and quickchr does not pattern-match RouterOS error text
960
+ * (#142). For a pass/fail result, run the command through
961
+ * `centrs execute --quickchr <name> --yes <command>`, which validates it first and
962
+ * exits non-zero on a rejection (`--yes` confirms a write command outside a TTY). */
953
963
  export interface ExecResult {
954
964
  /** Command output as a string (or parsed JSON when output="json"). */
955
965
  output: string;