@oh-my-pi/pi-utils 18.0.10 → 18.0.11

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.0.11] - 2026-08-29
6
+
7
+ ### Fixed
8
+
9
+ - Fixed runtime installation getting stuck for up to 60 seconds after an installer crash or forced termination, allowing subsequent installation attempts to proceed normally.
10
+
5
11
  ## [18.0.10] - 2026-08-28
6
12
 
7
13
  ### Added
@@ -73,6 +73,13 @@ export interface EnsureRuntimeInstalledOptions {
73
73
  export declare function writeRuntimeManifest(runtimeDir: string, install: RuntimeInstallSpec): Promise<void>;
74
74
  /**
75
75
  * Materialize a pinned dependency set into `runtimeDir` (idempotent,
76
- * cross-process safe via a lock directory). Returns `runtimeDir`.
76
+ * cross-process safe). Returns `runtimeDir`.
77
+ *
78
+ * Serialization uses the OS-backed {@link withFileLock} at
79
+ * `${runtimeDir}.install.lock`, which the kernel releases on process death, so
80
+ * a crashed installer cannot wedge later attempts (issue #10120). The path is
81
+ * deliberately distinct from the legacy `${runtimeDir}.lock` mkdir directory;
82
+ * {@link withLegacyInstallLock} atomically reserves that namespace during the
83
+ * new install so older processes cannot cross the migration boundary.
77
84
  */
78
85
  export declare function ensureRuntimeInstalled(options: EnsureRuntimeInstalledOptions): Promise<string>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "18.0.10",
4
+ "version": "18.0.11",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "18.0.10"
34
+ "@oh-my-pi/pi-natives": "18.0.11"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/bun": "^1.3.14"
@@ -2,6 +2,8 @@ import * as fs from "node:fs";
2
2
  import * as fsp from "node:fs/promises";
3
3
  import * as Module from "node:module";
4
4
  import * as path from "node:path";
5
+ import { withFileLock } from "./file-lock";
6
+ import { isEexist, isEnoent } from "./fs-error";
5
7
 
6
8
  /**
7
9
  * On-demand runtime dependency support for native-heavy optional packages
@@ -303,25 +305,62 @@ export interface EnsureRuntimeInstalledOptions {
303
305
  lockSleepMs?: number;
304
306
  }
305
307
 
306
- function isErrnoCode(error: unknown, code: string): boolean {
307
- return typeof error === "object" && error !== null && "code" in error && error.code === code;
308
- }
308
+ /** No runtime install plausibly runs this long, so older legacy lock directories are crash orphans. */
309
+ const STALE_LEGACY_LOCK_MS = 10 * 60_000;
309
310
 
310
- async function acquireInstallLock(runtimeDir: string, attempts: number, sleepMs: number): Promise<() => Promise<void>> {
311
- const lockDir = `${runtimeDir}.lock`;
312
- await fsp.mkdir(path.dirname(lockDir), { recursive: true });
313
- for (let attempt = 0; attempt < attempts; attempt++) {
311
+ /**
312
+ * Run `fn` while reserving the pre-crash-safe `${runtimeDir}.lock` namespace.
313
+ *
314
+ * Versions through 18.0.10 serialized installs with a bare lock *directory*
315
+ * that only its creator removed; an installer killed outside that window
316
+ * (SIGKILL/OOM/Ctrl-C) left it unreleasable, wedging every later install for
317
+ * the full wait envelope (issue #10120). During an in-flight upgrade a legacy
318
+ * process may still legitimately own this directory, so poll until it is
319
+ * released and only force-reclaim once the directory is older than any
320
+ * plausible install ({@link STALE_LEGACY_LOCK_MS}) — never merely because a
321
+ * retry budget elapsed, which would delete a still-active legacy lock and let
322
+ * two installers race the same tree. Once the namespace is free, atomically
323
+ * create and retain a regular file through `fn`: an older process cannot
324
+ * acquire it between the handoff check and the new install. A file left by a
325
+ * crashed new installer can be reused immediately because the outer OS lock
326
+ * proves its owner is gone, unlike a legacy directory whose owner is unknown.
327
+ */
328
+ async function withLegacyInstallLock<T>(runtimeDir: string, sleepMs: number, fn: () => Promise<T>): Promise<T> {
329
+ const legacy = `${runtimeDir}.lock`;
330
+ for (;;) {
314
331
  try {
315
- await fsp.mkdir(lockDir);
316
- return async () => {
317
- await fsp.rm(lockDir, { recursive: true, force: true });
318
- };
332
+ const reservation = await fsp.open(legacy, "wx");
333
+ await reservation.close();
319
334
  } catch (error) {
320
- if (!isErrnoCode(error, "EEXIST")) throw error;
321
- await Bun.sleep(sleepMs);
335
+ if (!isEexist(error)) throw error;
336
+ let stat: fs.Stats;
337
+ try {
338
+ stat = await fsp.stat(legacy);
339
+ } catch (statError) {
340
+ if (isEnoent(statError)) continue; // released between open and stat; retry
341
+ throw statError;
342
+ }
343
+ // A non-directory is a reservation left by a newer installer. The
344
+ // outer OS lock proves that installer is gone, so reuse it at once.
345
+ if (!stat.isDirectory()) break;
346
+ // A fresh directory may still belong to a live pre-18.x installer, so
347
+ // wait for it to finish; only a crash orphan (older than any plausible
348
+ // install) is force-reclaimed.
349
+ if (Date.now() - stat.mtimeMs > STALE_LEGACY_LOCK_MS) {
350
+ await fsp.rm(legacy, { recursive: true, force: true });
351
+ } else {
352
+ await Bun.sleep(sleepMs);
353
+ }
354
+ continue;
322
355
  }
356
+ break;
357
+ }
358
+ // Retain the regular-file reservation across the install.
359
+ try {
360
+ return await fn();
361
+ } finally {
362
+ await fsp.rm(legacy, { force: true });
323
363
  }
324
- throw new Error(`Timed out waiting for runtime install lock: ${lockDir}`);
325
364
  }
326
365
 
327
366
  export async function writeRuntimeManifest(runtimeDir: string, install: RuntimeInstallSpec): Promise<void> {
@@ -363,7 +402,14 @@ async function runRuntimeInstall(runtimeDir: string): Promise<void> {
363
402
 
364
403
  /**
365
404
  * Materialize a pinned dependency set into `runtimeDir` (idempotent,
366
- * cross-process safe via a lock directory). Returns `runtimeDir`.
405
+ * cross-process safe). Returns `runtimeDir`.
406
+ *
407
+ * Serialization uses the OS-backed {@link withFileLock} at
408
+ * `${runtimeDir}.install.lock`, which the kernel releases on process death, so
409
+ * a crashed installer cannot wedge later attempts (issue #10120). The path is
410
+ * deliberately distinct from the legacy `${runtimeDir}.lock` mkdir directory;
411
+ * {@link withLegacyInstallLock} atomically reserves that namespace during the
412
+ * new install so older processes cannot cross the migration boundary.
367
413
  */
368
414
  export async function ensureRuntimeInstalled(options: EnsureRuntimeInstalledOptions): Promise<string> {
369
415
  const { runtimeDir, install, onPhase, lockAttempts = 240, lockSleepMs = 250 } = options;
@@ -379,15 +425,20 @@ export async function ensureRuntimeInstalled(options: EnsureRuntimeInstalledOpti
379
425
  if (await probeManifest.exists()) return runtimeDir;
380
426
 
381
427
  onPhase?.("initiate");
382
- const releaseLock = await acquireInstallLock(runtimeDir, lockAttempts, lockSleepMs);
383
- try {
384
- if (await probeManifest.exists()) return runtimeDir;
385
- await writeRuntimeManifest(runtimeDir, install);
386
- onPhase?.("download");
387
- await runRuntimeInstall(runtimeDir);
388
- onPhase?.("done");
389
- return runtimeDir;
390
- } finally {
391
- await releaseLock();
392
- }
428
+ // withFileLock does not create parent directories; the runtime cache dir may
429
+ // not exist yet on the very first install.
430
+ await fsp.mkdir(path.dirname(runtimeDir), { recursive: true });
431
+ return withFileLock(
432
+ `${runtimeDir}.install`,
433
+ () =>
434
+ withLegacyInstallLock(runtimeDir, lockSleepMs, async () => {
435
+ if (await probeManifest.exists()) return runtimeDir;
436
+ await writeRuntimeManifest(runtimeDir, install);
437
+ onPhase?.("download");
438
+ await runRuntimeInstall(runtimeDir);
439
+ onPhase?.("done");
440
+ return runtimeDir;
441
+ }),
442
+ { retries: lockAttempts, retryDelayMs: lockSleepMs },
443
+ );
393
444
  }