@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.
package/src/lib/images.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Image download, ZIP extraction, and cache management.
3
3
  */
4
4
 
5
- import { existsSync, copyFileSync, mkdtempSync, readdirSync, renameSync, rmSync, unlinkSync } from "node:fs";
5
+ import { existsSync, mkdtempSync, readdirSync, rmSync, unlinkSync, utimesSync } from "node:fs";
6
6
  import { join, basename } from "node:path";
7
7
  import type { Arch } from "./types.ts";
8
8
  import { QuickCHRError } from "./types.ts";
@@ -11,7 +11,18 @@ import { downloadToFile } from "./download.ts";
11
11
  import { getCacheDir, ensureDir } from "./state.ts";
12
12
  import { createLogger, type ProgressLogger } from "./log.ts";
13
13
  import { extractZip } from "./zip.ts";
14
- import { assertSufficientQuickchrStorage } from "./storage.ts";
14
+ import { admitStorage, STORAGE_ESTIMATES } from "./storage.ts";
15
+ import { renameWithRetry } from "./rename.ts";
16
+
17
+ /** A cache hit is a use: bump the mtime so automatic eviction counts the entry as
18
+ * recently used while this operation reads it (#199). Best-effort: a read-only cache
19
+ * still works. */
20
+ export function touchCacheEntry(path: string): void {
21
+ try {
22
+ const now = new Date();
23
+ utimesSync(path, now, now);
24
+ } catch { /* read-only cache */ }
25
+ }
15
26
 
16
27
  function finalizeExtractedImage(zipPath: string, imgPath: string): string {
17
28
  if (existsSync(imgPath) && existsSync(zipPath)) {
@@ -118,7 +129,7 @@ export async function extractImage(
118
129
  if (!(await isUsableCachedImage(extractedPath))) {
119
130
  throw new QuickCHRError("PROCESS_FAILED", `Extracted CHR image is incomplete or invalid: ${expected}`);
120
131
  }
121
- renameSync(extractedPath, imgPath);
132
+ renameWithRetry(extractedPath, imgPath);
122
133
  } finally {
123
134
  rmSync(tempDir, { recursive: true, force: true });
124
135
  }
@@ -138,21 +149,29 @@ export async function ensureCachedImage(
138
149
  if (await isUsableCachedImage(imgPath)) {
139
150
  const log = logger ?? createLogger();
140
151
  log.status(` Using cached image: ${chrImageBasename(version, arch)}`);
152
+ touchCacheEntry(imgPath);
141
153
  return imgPath;
142
154
  }
143
155
  if (existsSync(imgPath)) unlinkSync(imgPath);
144
- assertSufficientQuickchrStorage(`cache CHR ${version} (${arch})`);
145
- const zipPath = await downloadImage(version, arch, cacheDir, logger);
146
- const extractedImgPath = await extractImage(zipPath, cacheDir, logger);
147
- return finalizeExtractedImage(zipPath, extractedImgPath);
148
- }
149
-
150
- /** Copy a cached image to a machine's working directory as disk.img. */
151
- export function copyImageToMachine(cachedImgPath: string, machineDir: string): string {
152
- ensureDir(machineDir);
153
- const dest = join(machineDir, "disk.img");
154
- copyFileSync(cachedImgPath, dest);
155
- return dest;
156
+ const log = logger ?? createLogger();
157
+ const base = chrImageBasename(version, arch);
158
+ const lease = admitStorage(
159
+ `cache CHR ${version} (${arch})`,
160
+ {
161
+ parts: [{ label: "image download", bytes: STORAGE_ESTIMATES.imageDownload, path: cache }],
162
+ protectVersions: [version],
163
+ pins: [`${base}.img`, `${base}.img.zip`],
164
+ cacheDir: cache,
165
+ },
166
+ { logger: (message: string) => log.status(message) },
167
+ );
168
+ try {
169
+ const zipPath = await downloadImage(version, arch, cacheDir, logger);
170
+ const extractedImgPath = await extractImage(zipPath, cacheDir, logger);
171
+ return finalizeExtractedImage(zipPath, extractedImgPath);
172
+ } finally {
173
+ lease.release();
174
+ }
156
175
  }
157
176
 
158
177
  /** List cached images. */
package/src/lib/lock.ts CHANGED
@@ -65,7 +65,8 @@ export type LockOperation =
65
65
  | "remove"
66
66
  | "device-mode"
67
67
  | "remove-orphan"
68
- | "allocate";
68
+ | "allocate"
69
+ | "storage";
69
70
 
70
71
  /** Resources a machine creation has claimed but not yet persisted in `machine.json`.
71
72
  * Carried in the creator's machine lock, so it lives exactly as long as the create
@@ -2,7 +2,7 @@
2
2
  * Extra package download and installation for CHR instances.
3
3
  */
4
4
 
5
- import { existsSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
5
+ import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
6
6
  import { join, basename } from "node:path";
7
7
  import { QuickCHRError, type Arch } from "./types.ts";
8
8
  import { packagesDownloadUrl } from "./versions.ts";
@@ -12,6 +12,10 @@ import { createLogger, type ProgressLogger } from "./log.ts";
12
12
  import { restPost } from "./rest.ts";
13
13
  import { scpPush } from "./scp.ts";
14
14
  import { extractZip } from "./zip.ts";
15
+ import { renameWithRetry } from "./rename.ts";
16
+ import { admitStorage, STORAGE_ESTIMATES } from "./storage.ts";
17
+ import { touchCacheEntry } from "./images.ts";
18
+ import { pinCacheEntries } from "./storage-lease.ts";
15
19
 
16
20
  const PACKAGE_CACHE_MANIFEST = ".quickchr-complete.json";
17
21
 
@@ -76,50 +80,65 @@ export async function downloadPackages(
76
80
  if (await isCompletePackageCache(version, arch, cache)) {
77
81
  const log = logger ?? createLogger();
78
82
  log.status(` Using cached packages: ${version} (${arch})`);
83
+ touchCacheEntry(zipPath);
79
84
  return extractDir;
80
85
  }
81
86
 
82
- // Download if needed. Same bounded path as the image download (#116) — this
83
- // call site previously had neither a deadline nor retries, so its only bound
84
- // was whatever the calling test happened to impose.
85
- if (!existsSync(zipPath)) {
86
- const log = logger ?? createLogger();
87
- log.status(`Downloading packages for ${version} (${arch})...`);
88
- log.status(` ${url}`);
89
- await downloadToFile(url, zipPath, { logger: log });
90
- }
91
-
92
- // Extract into a sibling directory, record the exact complete set, then
93
- // rename it into place. A killed extraction can never look like a cache hit.
94
- const tempDir = mkdtempSync(join(cache, ".quickchr-packages-"));
87
+ const lease = admitStorage(
88
+ `cache packages ${version} (${arch})`,
89
+ {
90
+ parts: [{ label: "packages download", bytes: STORAGE_ESTIMATES.packagesDownload, path: cache }],
91
+ protectVersions: [version],
92
+ pins: [`packages-${arch}-${version}`],
93
+ cacheDir: cache,
94
+ },
95
+ { logger: (message: string) => (logger ?? createLogger()).status(message) },
96
+ );
95
97
  try {
96
- extractZip(zipPath, tempDir);
97
- const packages = packageFiles(tempDir);
98
- if (packages.length === 0) {
99
- throw new Error(`No non-empty .npk files found in ${zipName}`);
98
+ // Download if needed. Same bounded path as the image download (#116) — this
99
+ // call site previously had neither a deadline nor retries, so its only bound
100
+ // was whatever the calling test happened to impose.
101
+ if (!existsSync(zipPath)) {
102
+ const log = logger ?? createLogger();
103
+ log.status(`Downloading packages for ${version} (${arch})...`);
104
+ log.status(` ${url}`);
105
+ await downloadToFile(url, zipPath, { logger: log });
100
106
  }
101
- const manifest: PackageCacheManifest = {
102
- version,
103
- arch,
104
- archiveSize: statSync(zipPath).size,
105
- packages,
106
- };
107
- await Bun.write(join(tempDir, PACKAGE_CACHE_MANIFEST), `${JSON.stringify(manifest, null, "\t")}\n`);
108
- if (existsSync(extractDir)) rmSync(extractDir, { recursive: true, force: true });
109
- renameSync(tempDir, extractDir);
110
- } catch (error) {
111
- rmSync(tempDir, { recursive: true, force: true });
112
- if (error instanceof Error && error.message.startsWith("No non-empty")) {
113
- throw new QuickCHRError("PROCESS_FAILED", error.message);
107
+
108
+ // Extract into a sibling directory, record the exact complete set, then
109
+ // rename it into place. A killed extraction can never look like a cache hit.
110
+ const tempDir = mkdtempSync(join(cache, ".quickchr-packages-"));
111
+ try {
112
+ extractZip(zipPath, tempDir);
113
+ const packages = packageFiles(tempDir);
114
+ if (packages.length === 0) {
115
+ throw new Error(`No non-empty .npk files found in ${zipName}`);
116
+ }
117
+ const manifest: PackageCacheManifest = {
118
+ version,
119
+ arch,
120
+ archiveSize: statSync(zipPath).size,
121
+ packages,
122
+ };
123
+ await Bun.write(join(tempDir, PACKAGE_CACHE_MANIFEST), `${JSON.stringify(manifest, null, "\t")}\n`);
124
+ if (existsSync(extractDir)) rmSync(extractDir, { recursive: true, force: true });
125
+ renameWithRetry(tempDir, extractDir);
126
+ } catch (error) {
127
+ rmSync(tempDir, { recursive: true, force: true });
128
+ if (error instanceof Error && error.message.startsWith("No non-empty")) {
129
+ throw new QuickCHRError("PROCESS_FAILED", error.message);
130
+ }
131
+ throw error;
114
132
  }
115
- throw error;
116
- }
117
133
 
118
- if (!(await isCompletePackageCache(version, arch, cache))) {
119
- throw new QuickCHRError(
120
- "PROCESS_FAILED",
121
- `Package cache verification failed for ${version} (${arch})`,
122
- );
134
+ if (!(await isCompletePackageCache(version, arch, cache))) {
135
+ throw new QuickCHRError(
136
+ "PROCESS_FAILED",
137
+ `Package cache verification failed for ${version} (${arch})`,
138
+ );
139
+ }
140
+ } finally {
141
+ lease.release();
123
142
  }
124
143
 
125
144
  return extractDir;
@@ -225,8 +244,15 @@ export async function downloadAndListPackages(
225
244
  version: string,
226
245
  arch: Arch,
227
246
  ): Promise<string[]> {
228
- const extractDir = await downloadPackages(version, arch);
229
- return listAvailablePackages(extractDir);
247
+ // Pinned through the listing: once downloadPackages() returns, nothing else stops a
248
+ // concurrent eviction from removing the extraction mid-read.
249
+ const pin = pinCacheEntries(`list packages ${version} (${arch})`, [`packages-${arch}-${version}`]);
250
+ try {
251
+ const extractDir = await downloadPackages(version, arch);
252
+ return listAvailablePackages(extractDir);
253
+ } finally {
254
+ pin.release();
255
+ }
230
256
  }
231
257
 
232
258
  /** Download all packages and install every one of them. Useful for API schema generation.
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { existsSync, readdirSync, statSync } from "node:fs";
6
- import type { AccelMode, EfiFirmwarePaths, HostInterface, PackageManager, PlatformInfo, SocketVmnetInfo } from "./types.ts";
6
+ import type { AccelMode, EfiFirmwarePaths, HostInterface, PackageManager, PlatformInfo, SocketVmnetInfo, DoctorCheck } from "./types.ts";
7
7
  import { QuickCHRError as QError, parseAccelMode } from "./types.ts";
8
8
  // settings-file.ts, not settings.ts: the latter's graph (cache → state → network
9
9
  // → platform) is a cycle that would load eagerly on every CLI start.
@@ -590,3 +590,15 @@ export function resolveInterfaceAlias(
590
590
  `No interface found for alias '${alias}' — run 'quickchr doctor' to see available interfaces`,
591
591
  );
592
592
  }
593
+
594
+ /** Compare the running Bun against the same pin CI installs. */
595
+ export async function bunRuntimeCheck(version = Bun.version): Promise<DoctorCheck> {
596
+ const pin = (await Bun.file(new URL("../../.bun-version", import.meta.url)).text()).trim();
597
+ // Runtimes predating the Semver API are also older than the tested pin.
598
+ const older = typeof Bun.semver?.order !== "function" || Bun.semver.order(version, pin) < 0;
599
+ return {
600
+ label: "Bun runtime",
601
+ status: older ? "warn" : "ok",
602
+ detail: `bun ${version}; tested with Bun ${pin}${older ? ` — upgrade to ${pin} or newer` : ""}`,
603
+ };
604
+ }
@@ -30,7 +30,7 @@ import type {
30
30
  import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION, HOST_GATEWAY_IP, PORTS_PER_BLOCK } from "./types.ts";
31
31
  import { assertValidResourceName, assertPathSafeName, shellQuote } from "./names.ts";
32
32
  import packageJson from "../../package.json";
33
- import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, accelNote, resolveAccelOverrideWithSource, accelSourceLabel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
33
+ import { bunRuntimeCheck, detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, accelNote, resolveAccelOverrideWithSource, accelSourceLabel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
34
34
  import {
35
35
  resolveVersion,
36
36
  isValidVersion,
@@ -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;
@@ -2967,12 +2997,7 @@ export class QuickCHR {
2967
2997
  static async doctor(): Promise<DoctorResult> {
2968
2998
  const checks: DoctorResult["checks"] = [];
2969
2999
 
2970
- // Bun version
2971
- checks.push({
2972
- label: "Bun runtime",
2973
- status: "ok",
2974
- detail: `bun ${Bun.version}`,
2975
- });
3000
+ checks.push(await bunRuntimeCheck());
2976
3001
 
2977
3002
  // QEMU for each arch
2978
3003
  for (const arch of ARCHES) {
@@ -3067,14 +3092,14 @@ export class QuickCHR {
3067
3092
  detail: `${machines.length} instance${machines.length !== 1 ? "s" : ""} (${running} running, ${formatDiskSize(storage.machinesBytes)} on disk)`,
3068
3093
  });
3069
3094
 
3070
- let storageDetail = `${storage.path}: ${formatDiskSize(storage.freeBytes)} free; ${formatQuickchrUsage(storage)}`;
3095
+ let storageDetail = `${storage.statPath}: ${formatDiskSize(storage.freeBytes)} free; ${formatQuickchrUsage(storage)}`;
3071
3096
  if (storage.status === "error") {
3072
- storageDetail += ` — below required minimum ${formatDiskSize(storage.recommendedFreeBytes)}`;
3097
+ storageDetail += ` — below the ${formatDiskSize(storage.reserveBytes)} reserve: new machines and image downloads are refused (after evicting what the cache can spare)`;
3073
3098
  } else if (storage.status === "warn") {
3074
3099
  storageDetail += ` — low headroom (target ${formatDiskSize(storage.warningFreeBytes)} free)`;
3075
3100
  }
3076
3101
  checks.push({
3077
- label: storage.label === ".local" ? "Storage (.local)" : "Storage",
3102
+ label: "Storage",
3078
3103
  status: storage.status,
3079
3104
  detail: storageDetail,
3080
3105
  });
@@ -3095,7 +3120,7 @@ export class QuickCHR {
3095
3120
  });
3096
3121
  }
3097
3122
 
3098
- // qemu-img (optional, for disk resize and extra disks)
3123
+ // qemu-img (default qcow2 creation and disk features)
3099
3124
  const qemuImgPath = findQemuImg();
3100
3125
  if (qemuImgPath) {
3101
3126
  checks.push({
@@ -3107,7 +3132,7 @@ export class QuickCHR {
3107
3132
  checks.push({
3108
3133
  label: "qemu-img",
3109
3134
  status: "warn",
3110
- detail: `not found — required for --boot-size and --add-disk (${getQemuInstallHint()})`,
3135
+ detail: `not found — required for default qcow2 creation, setup, --boot-size and --add-disk (${getQemuInstallHint()})`,
3111
3136
  });
3112
3137
  }
3113
3138
 
@@ -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
+ }