@tikoci/quickchr 0.4.8 → 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 +219 -14
- package/MANUAL.md +396 -71
- package/README.md +33 -8
- package/package.json +2 -2
- package/src/cli/flags.ts +22 -3
- package/src/cli/index.ts +461 -104
- package/src/cli/wizard.ts +10 -2
- package/src/index.ts +2 -0
- package/src/lib/api-login.ts +138 -0
- package/src/lib/cache.ts +220 -38
- package/src/lib/completions.ts +20 -11
- package/src/lib/device-mode.ts +149 -10
- package/src/lib/diagnostics.ts +13 -6
- 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 +498 -0
- package/src/lib/names.ts +24 -0
- package/src/lib/network.ts +16 -16
- package/src/lib/packages.ts +66 -40
- package/src/lib/provision.ts +3 -2
- package/src/lib/provisioning-window.ts +356 -0
- package/src/lib/qemu.ts +54 -2
- package/src/lib/quickchr.ts +950 -359
- package/src/lib/rename.ts +41 -0
- package/src/lib/settings.ts +20 -9
- package/src/lib/shutdown.ts +49 -0
- package/src/lib/socket-registry.ts +2 -2
- package/src/lib/state.ts +89 -2
- package/src/lib/storage-lease.ts +169 -0
- package/src/lib/storage.ts +223 -66
- package/src/lib/types.ts +109 -23
- package/src/lib/versions.ts +54 -0
|
@@ -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
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Bounded guest shutdown, independently observing process exit (#143). */
|
|
2
|
+
import { QuickCHRError } from "./types.ts";
|
|
3
|
+
import type { ShutdownResult } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_SHUTDOWN_TIMEOUT_MS = 10_000;
|
|
6
|
+
|
|
7
|
+
export function validateShutdownTimeout(timeoutMs: number): number {
|
|
8
|
+
// node:http timers cannot represent a larger delay; overflow becomes a 1 ms timer.
|
|
9
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
|
|
10
|
+
throw new QuickCHRError("INVALID_ARGUMENT", "shutdown timeoutMs must be a positive integer no greater than 2147483647 milliseconds");
|
|
11
|
+
}
|
|
12
|
+
return timeoutMs;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The process observer is separate from the request: RouterOS can return HTTP 400
|
|
16
|
+
* `interrupted` while successfully halting. Request must enforce its timeout (exec does). */
|
|
17
|
+
export async function shutdownGuest(
|
|
18
|
+
timeoutMs: number,
|
|
19
|
+
request: (timeoutMs: number) => Promise<unknown>,
|
|
20
|
+
isRunning: () => boolean,
|
|
21
|
+
forceStop: () => Promise<unknown>,
|
|
22
|
+
): Promise<ShutdownResult> {
|
|
23
|
+
const deadline = performance.now() + timeoutMs;
|
|
24
|
+
let requestError: string | undefined;
|
|
25
|
+
const pending = Promise.resolve().then(() => request(Math.max(1, Math.ceil(deadline - performance.now())))).catch((error: unknown) => {
|
|
26
|
+
requestError = error instanceof Error ? error.message : String(error);
|
|
27
|
+
});
|
|
28
|
+
while (isRunning() && performance.now() < deadline) {
|
|
29
|
+
await Bun.sleep(Math.min(100, Math.max(1, deadline - performance.now())));
|
|
30
|
+
}
|
|
31
|
+
const guestExited = !isRunning();
|
|
32
|
+
// exec's HTTP timer settles this within the same grace budget, including when
|
|
33
|
+
// QEMU exits while its HTTP response is still pending. Never replay the request.
|
|
34
|
+
await pending;
|
|
35
|
+
if (guestExited || !isRunning()) {
|
|
36
|
+
return { outcome: "guest-exited", ...(requestError === undefined ? {} : { requestError }) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await forceStop();
|
|
41
|
+
} catch {
|
|
42
|
+
if (isRunning()) throw new QuickCHRError("STATE_ERROR", "shutdown could not terminate QEMU; machine remains running");
|
|
43
|
+
}
|
|
44
|
+
// stopQemu sends SIGKILL after its SIGTERM wait but does not await that exit.
|
|
45
|
+
const killDeadline = performance.now() + 1000;
|
|
46
|
+
while (isRunning() && performance.now() < killDeadline) await Bun.sleep(50);
|
|
47
|
+
if (isRunning()) throw new QuickCHRError("STATE_ERROR", "shutdown could not confirm QEMU exit; machine remains running");
|
|
48
|
+
return { outcome: "forced", ...(requestError === undefined ? {} : { requestError }) };
|
|
49
|
+
}
|
|
@@ -142,8 +142,8 @@ const LOCK_TIMEOUT_MS = 5_000;
|
|
|
142
142
|
* A two-process repro lost an endpoint on 12 of 12 rounds.
|
|
143
143
|
*
|
|
144
144
|
* `quickchr start a & quickchr start b &` is the shape that hits this, and it is
|
|
145
|
-
* exactly how the field report drove its multi-CHR lab. The per-machine
|
|
146
|
-
*
|
|
145
|
+
* exactly how the field report drove its multi-CHR lab. The per-machine lock
|
|
146
|
+
* cannot help: the two contenders are different machines. */
|
|
147
147
|
const _heldLocks = new Set<string>();
|
|
148
148
|
|
|
149
149
|
/** How long a lock file may be unreadable before it counts as abandoned.
|
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, 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";
|
|
@@ -285,9 +285,71 @@ export function removeMachine(name: string): void {
|
|
|
285
285
|
if (!existsSync(dir)) {
|
|
286
286
|
throw new QuickCHRError("MACHINE_NOT_FOUND", `Machine "${name}" not found`);
|
|
287
287
|
}
|
|
288
|
-
|
|
288
|
+
// Out of `machines/` in one step, then deleted. A recursive delete in place removes
|
|
289
|
+
// entries one by one, so there is a window where the machine lock (`.lock/`, inside
|
|
290
|
+
// the directory) is gone while the directory is not, and a waiter or a same-named
|
|
291
|
+
// create could take a fresh lock in a directory that is being deleted. After the
|
|
292
|
+
// rename the name is simply free.
|
|
293
|
+
//
|
|
294
|
+
// There is no in-place fallback, because it would reopen exactly that window. A
|
|
295
|
+
// rename can fail briefly while a scanner or indexer holds a handle (Windows refuses
|
|
296
|
+
// to rename a directory with one open), so it is retried. If it still fails, the
|
|
297
|
+
// directory is left alone and the failure is reported.
|
|
298
|
+
const trashDir = join(getDataDir(), ".removing");
|
|
299
|
+
const trash = join(trashDir, `${name}.${process.pid}.${crypto.randomUUID()}`);
|
|
300
|
+
ensureDir(trashDir);
|
|
301
|
+
sweepAbandonedRemovals(trashDir);
|
|
302
|
+
for (let attempt = 1; ; attempt++) {
|
|
303
|
+
try {
|
|
304
|
+
renameSync(dir, trash);
|
|
305
|
+
break;
|
|
306
|
+
} catch (e) {
|
|
307
|
+
if (attempt >= REMOVE_RENAME_ATTEMPTS) {
|
|
308
|
+
throw new QuickCHRError(
|
|
309
|
+
"STATE_ERROR",
|
|
310
|
+
`Could not remove machine "${name}": moving ${dir} aside failed (${(e as Error).message}). ` +
|
|
311
|
+
`The directory was left in place — close anything using it and run 'quickchr remove ${name}' again.`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
Bun.sleepSync(REMOVE_RENAME_RETRY_MS);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// The name is already free, so a failed delete does not fail the removal. The
|
|
318
|
+
// files stay under `.removing/` and the next removal sweeps them.
|
|
319
|
+
try {
|
|
320
|
+
rmSync(trash, { recursive: true, force: true });
|
|
321
|
+
} catch (e) {
|
|
322
|
+
console.warn(`Warning: machine "${name}" is removed, but deleting its files at ${trash} failed (${(e as Error).message}). The next 'quickchr remove' retries it, or delete that directory by hand.`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Delete what earlier removals moved aside and did not finish deleting: a delete that
|
|
327
|
+
* failed, or a process that stopped after its rename. Each entry is
|
|
328
|
+
* `<name>.<pid>.<uuid>`. One whose pid is alive in another process is still being
|
|
329
|
+
* deleted, so it is left alone. Best effort: a failure here never fails a removal. */
|
|
330
|
+
function sweepAbandonedRemovals(trashDir: string): void {
|
|
331
|
+
let entries: string[];
|
|
332
|
+
try { entries = readdirSync(trashDir); } catch { return; }
|
|
333
|
+
for (const entry of entries) {
|
|
334
|
+
const pid = Number(entry.split(".").at(-2));
|
|
335
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
336
|
+
if (pid !== process.pid) {
|
|
337
|
+
try {
|
|
338
|
+
process.kill(pid, 0);
|
|
339
|
+
continue; // still being deleted by that process
|
|
340
|
+
} catch (e) {
|
|
341
|
+
if ((e as NodeJS.ErrnoException).code === "EPERM") continue;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
try { rmSync(join(trashDir, entry), { recursive: true, force: true }); } catch { /* next time */ }
|
|
345
|
+
}
|
|
289
346
|
}
|
|
290
347
|
|
|
348
|
+
/** How long `removeMachine()` keeps trying to move a directory aside: about 2 s in
|
|
349
|
+
* all, enough for a scanner to let go of a handle. */
|
|
350
|
+
const REMOVE_RENAME_ATTEMPTS = 10;
|
|
351
|
+
const REMOVE_RENAME_RETRY_MS = 200;
|
|
352
|
+
|
|
291
353
|
/** Update the status of a machine. */
|
|
292
354
|
export function updateMachineStatus(
|
|
293
355
|
name: string,
|
|
@@ -353,3 +415,28 @@ export function pruneCache(maxAgeDays: number = 30): number {
|
|
|
353
415
|
|
|
354
416
|
return removed;
|
|
355
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
|
+
}
|