@oh-my-pi/pi-utils 18.0.9 → 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 +20 -0
- package/dist/types/async.d.ts +2 -0
- package/dist/types/color.d.ts +34 -0
- package/dist/types/postmortem.d.ts +29 -5
- package/dist/types/runtime-install.d.ts +8 -1
- package/dist/types/sqlite.d.ts +3 -0
- package/package.json +2 -2
- package/src/async.ts +20 -6
- package/src/color.ts +173 -0
- package/src/postmortem.ts +151 -50
- package/src/runtime-install.ts +77 -26
- package/src/sqlite.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
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
|
+
|
|
11
|
+
## [18.0.10] - 2026-08-28
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Added `postmortem.drainStdout` to flush buffered standard output before process exit or exec-replacement.
|
|
16
|
+
- Added an `exitOnly` option to `postmortem.register` for resources that should remain available during keep-alive cleanup and be released only on actual process exit.
|
|
17
|
+
- Added `hexToOklch` and `oklchToHex` color conversion utilities with sRGB gamut mapping that reduces chroma when necessary.
|
|
18
|
+
- Added `checkpointWal` to checkpoint committed SQLite WAL frames without blocking concurrent readers.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Fixed repeatable `postmortem` cleanup behavior so persistent resources and callbacks registered during cleanup remain active until the eventual process exit.
|
|
23
|
+
- Fixed asynchronous `postmortem` cleanup so callbacks registered during a cleanup pass are awaited before cleanup completes, including during signal-driven exits.
|
|
24
|
+
|
|
5
25
|
## [18.0.9] - 2026-08-28
|
|
6
26
|
|
|
7
27
|
### Fixed
|
package/dist/types/async.d.ts
CHANGED
|
@@ -17,4 +17,6 @@ export declare class AsyncDrain<T> {
|
|
|
17
17
|
constructor(delayMs?: number);
|
|
18
18
|
/** Queue `value`; `hnd` receives the whole batch when the window closes. */
|
|
19
19
|
push(value: T, hnd: (values: T[]) => Promise<void> | void): Promise<void>;
|
|
20
|
+
/** Runs the pending batch handler immediately and returns its completion promise. */
|
|
21
|
+
flush(): Promise<void>;
|
|
20
22
|
}
|
package/dist/types/color.d.ts
CHANGED
|
@@ -82,6 +82,40 @@ export declare function adjustHsv(hex: string, adj: HSVAdjustment): string;
|
|
|
82
82
|
* Convert HSL (h: 0-360, s: 0-1, l: 0-1) to a CSS hex string.
|
|
83
83
|
*/
|
|
84
84
|
export declare function hslToHex(h: number, s: number, l: number): string;
|
|
85
|
+
export interface OKLCH {
|
|
86
|
+
/** Perceptual lightness (0-1) */
|
|
87
|
+
l: number;
|
|
88
|
+
/** Chroma (0 = gray; sRGB peaks around 0.37) */
|
|
89
|
+
c: number;
|
|
90
|
+
/** Hue in degrees (0-360) */
|
|
91
|
+
h: number;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Convert a hex color to OKLCH (perceptual lightness/chroma/hue).
|
|
95
|
+
*
|
|
96
|
+
* Unlike HSL, equal `l`/`c` values look equally bright and colorful across
|
|
97
|
+
* hues, so carrying them between colors preserves the palette's "weight".
|
|
98
|
+
*/
|
|
99
|
+
export declare function hexToOklch(hex: string): OKLCH;
|
|
100
|
+
/**
|
|
101
|
+
* The sRGB gamut cusp for an OKLCH hue: the lightness/chroma point where the
|
|
102
|
+
* hue reaches its maximum chroma inside sRGB.
|
|
103
|
+
*
|
|
104
|
+
* The cusp lightness varies wildly per hue (yellow ≈ 0.97, blue ≈ 0.45), so
|
|
105
|
+
* transferring absolute OKLCH lightness/chroma between hues distorts
|
|
106
|
+
* vividness; normalize against the cusp instead. See `getSessionAccentHex`.
|
|
107
|
+
*/
|
|
108
|
+
export declare function oklchCusp(h: number): {
|
|
109
|
+
l: number;
|
|
110
|
+
c: number;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Convert OKLCH to a CSS hex string, gamut-mapping by chroma reduction.
|
|
114
|
+
*
|
|
115
|
+
* Out-of-gamut inputs keep their lightness and hue while chroma is bisected
|
|
116
|
+
* down until the color fits sRGB, matching CSS Color 4's recommended intent.
|
|
117
|
+
*/
|
|
118
|
+
export declare function oklchToHex(oklch: OKLCH): string;
|
|
85
119
|
/**
|
|
86
120
|
* Perceptual luma (gamma-encoded BT.709 weights over raw sRGB), normalized to 0..1.
|
|
87
121
|
*
|
|
@@ -123,15 +123,33 @@ export declare function interceptUnhandledRejections(interceptor: (reason: unkno
|
|
|
123
123
|
* through an uncaught exception or unhandled rejection.
|
|
124
124
|
*/
|
|
125
125
|
export declare function registerFatalRecoveryHint(provider: FatalRecoveryHintProvider): () => void;
|
|
126
|
+
/** Controls when a registered cleanup callback participates in cleanup passes. */
|
|
127
|
+
export interface CleanupRegistrationOptions {
|
|
128
|
+
/**
|
|
129
|
+
* Run only on a real exit, never during a manual keep-alive cleanup.
|
|
130
|
+
* The registration remains armed when a keep-alive pass skips it.
|
|
131
|
+
*/
|
|
132
|
+
exitOnly?: boolean;
|
|
133
|
+
}
|
|
126
134
|
/**
|
|
127
|
-
*
|
|
135
|
+
* Registers a cleanup callback for shutdown, signals, fatal errors, and
|
|
136
|
+
* repeatable manual cleanup passes.
|
|
137
|
+
*
|
|
138
|
+
* Registrations persist across keep-alive {@link cleanup} passes and run at
|
|
139
|
+
* most once per pass. Set `exitOnly` for resources the continuing process still
|
|
140
|
+
* holds (open databases, cached handles): keep-alive passes skip the callback
|
|
141
|
+
* without consuming its registration, while the eventual real exit runs it.
|
|
142
|
+
*
|
|
143
|
+
* A callback registered during a running keep-alive pass joins future passes;
|
|
144
|
+
* normal callbacks also run immediately for the current pass. Registrations
|
|
145
|
+
* made during a real exit run immediately.
|
|
128
146
|
*
|
|
129
|
-
* Returns a
|
|
130
|
-
* If register is called after cleanup already began, invokes callback on a microtask.
|
|
147
|
+
* Returns a function that permanently cancels the registration.
|
|
131
148
|
*/
|
|
132
|
-
export declare function register(id: string, callback: (reason: Reason) => void | Promise<void
|
|
149
|
+
export declare function register(id: string, callback: (reason: Reason) => void | Promise<void>, options?: CleanupRegistrationOptions): () => void;
|
|
133
150
|
/**
|
|
134
|
-
* Runs all cleanup callbacks without exiting
|
|
151
|
+
* Runs all cleanup callbacks without exiting, then re-arms the system so
|
|
152
|
+
* resources opened afterwards are still cleaned at the eventual real exit.
|
|
135
153
|
* Use this in workers or when you need to clean up but continue execution.
|
|
136
154
|
*/
|
|
137
155
|
export declare function cleanup(): Promise<void>;
|
|
@@ -140,6 +158,12 @@ export interface QuitOptions {
|
|
|
140
158
|
/** Wait for buffered stdout before exiting; disable after the terminal has disconnected. */
|
|
141
159
|
drainStdout?: boolean;
|
|
142
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Waits (bounded) for buffered stdout to reach the terminal. Used before
|
|
163
|
+
* process exit and before an exec-replace, where unflushed output would be
|
|
164
|
+
* lost with the process image.
|
|
165
|
+
*/
|
|
166
|
+
export declare function drainStdout(): Promise<void>;
|
|
143
167
|
/**
|
|
144
168
|
* Runs all cleanup callbacks and exits through the current `process.exit`.
|
|
145
169
|
*
|
|
@@ -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
|
|
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/dist/types/sqlite.d.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* one implementation here prevents the classifiers from drifting between the
|
|
8
8
|
* credential store and the model cache.
|
|
9
9
|
*/
|
|
10
|
+
import type { Database } from "bun:sqlite";
|
|
11
|
+
/** Checkpoints committed WAL frames without waiting for concurrent readers. */
|
|
12
|
+
export declare function checkpointWal(db: Database): void;
|
|
10
13
|
/**
|
|
11
14
|
* SQLite's busy result-code family — base `SQLITE_BUSY` plus the extended
|
|
12
15
|
* variants `SQLITE_BUSY_RECOVERY` (concurrent WAL recovery), `SQLITE_BUSY_SNAPSHOT`,
|
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.
|
|
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.
|
|
34
|
+
"@oh-my-pi/pi-natives": "18.0.11"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/bun": "^1.3.14"
|
package/src/async.ts
CHANGED
|
@@ -59,6 +59,7 @@ export function withTimeout<T>(promise: Promise<T>, ms: number, message: string,
|
|
|
59
59
|
export class AsyncDrain<T> {
|
|
60
60
|
#queue?: T[];
|
|
61
61
|
#promise = Promise.resolve();
|
|
62
|
+
#flush?: () => void;
|
|
62
63
|
|
|
63
64
|
constructor(readonly delayMs: number = 0) {}
|
|
64
65
|
|
|
@@ -66,21 +67,28 @@ export class AsyncDrain<T> {
|
|
|
66
67
|
push(value: T, hnd: (values: T[]) => Promise<void> | void): Promise<void> {
|
|
67
68
|
let queue = this.#queue;
|
|
68
69
|
if (!queue) {
|
|
69
|
-
|
|
70
|
+
const batch: T[] = [];
|
|
71
|
+
this.#queue = batch;
|
|
72
|
+
queue = batch;
|
|
70
73
|
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
71
74
|
const exec = (): void => {
|
|
75
|
+
if (this.#queue !== batch) return;
|
|
76
|
+
this.#queue = undefined;
|
|
77
|
+
this.#flush = undefined;
|
|
72
78
|
try {
|
|
73
|
-
|
|
74
|
-
this.#queue = undefined;
|
|
75
|
-
}
|
|
76
|
-
resolve(hnd(queue!));
|
|
79
|
+
resolve(hnd(batch));
|
|
77
80
|
} catch (error) {
|
|
78
81
|
reject(error);
|
|
79
82
|
}
|
|
80
83
|
};
|
|
81
84
|
if (this.delayMs > 0) {
|
|
82
|
-
setTimeout(exec, this.delayMs);
|
|
85
|
+
const timer = setTimeout(exec, this.delayMs);
|
|
86
|
+
this.#flush = () => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
exec();
|
|
89
|
+
};
|
|
83
90
|
} else {
|
|
91
|
+
this.#flush = exec;
|
|
84
92
|
queueMicrotask(exec);
|
|
85
93
|
}
|
|
86
94
|
this.#promise = promise;
|
|
@@ -88,4 +96,10 @@ export class AsyncDrain<T> {
|
|
|
88
96
|
queue.push(value);
|
|
89
97
|
return this.#promise;
|
|
90
98
|
}
|
|
99
|
+
|
|
100
|
+
/** Runs the pending batch handler immediately and returns its completion promise. */
|
|
101
|
+
flush(): Promise<void> {
|
|
102
|
+
this.#flush?.();
|
|
103
|
+
return this.#promise;
|
|
104
|
+
}
|
|
91
105
|
}
|
package/src/color.ts
CHANGED
|
@@ -272,6 +272,179 @@ function linearizeChannel(channel: number): number {
|
|
|
272
272
|
const c = channel / 255;
|
|
273
273
|
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
274
274
|
}
|
|
275
|
+
/** Gamma-encode a linear 0..1 channel back to 0..255 sRGB. */
|
|
276
|
+
function delinearizeChannel(linear: number): number {
|
|
277
|
+
const c = linear <= 0.0031308 ? linear * 12.92 : 1.055 * linear ** (1 / 2.4) - 0.055;
|
|
278
|
+
return c * 255;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface OKLCH {
|
|
282
|
+
/** Perceptual lightness (0-1) */
|
|
283
|
+
l: number;
|
|
284
|
+
/** Chroma (0 = gray; sRGB peaks around 0.37) */
|
|
285
|
+
c: number;
|
|
286
|
+
/** Hue in degrees (0-360) */
|
|
287
|
+
h: number;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Convert linear sRGB (0..1 channels) to OKLab (Björn Ottosson's reference matrices). */
|
|
291
|
+
function linearRgbToOklab(r: number, g: number, b: number): { L: number; a: number; b: number } {
|
|
292
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
293
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
294
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
295
|
+
return {
|
|
296
|
+
L: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
297
|
+
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
298
|
+
b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Convert OKLab back to linear sRGB; channels may fall outside 0..1 when out of gamut. */
|
|
303
|
+
function oklabToLinearRgb(L: number, a: number, b: number): { r: number; g: number; b: number } {
|
|
304
|
+
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
305
|
+
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
306
|
+
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
307
|
+
return {
|
|
308
|
+
r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
309
|
+
g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
310
|
+
b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Convert a hex color to OKLCH (perceptual lightness/chroma/hue).
|
|
316
|
+
*
|
|
317
|
+
* Unlike HSL, equal `l`/`c` values look equally bright and colorful across
|
|
318
|
+
* hues, so carrying them between colors preserves the palette's "weight".
|
|
319
|
+
*/
|
|
320
|
+
export function hexToOklch(hex: string): OKLCH {
|
|
321
|
+
const rgb = hexToRgb(hex);
|
|
322
|
+
const lab = linearRgbToOklab(linearizeChannel(rgb.r), linearizeChannel(rgb.g), linearizeChannel(rgb.b));
|
|
323
|
+
const c = Math.hypot(lab.a, lab.b);
|
|
324
|
+
let h = (Math.atan2(lab.b, lab.a) * 180) / Math.PI;
|
|
325
|
+
if (h < 0) h += 360;
|
|
326
|
+
return { l: lab.L, c, h };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Max OKLab saturation (C/L) that stays inside sRGB for the hue direction
|
|
331
|
+
* `(a, b)`, via Björn Ottosson's polynomial fit plus one Halley refinement.
|
|
332
|
+
*/
|
|
333
|
+
function computeMaxSaturation(a: number, b: number): number {
|
|
334
|
+
// Select the channel that clips first for this hue direction.
|
|
335
|
+
let k0: number, k1: number, k2: number, k3: number, k4: number;
|
|
336
|
+
let wl: number, wm: number, ws: number;
|
|
337
|
+
if (-1.88170328 * a - 0.80936493 * b > 1) {
|
|
338
|
+
// red channel
|
|
339
|
+
k0 = 1.19086277;
|
|
340
|
+
k1 = 1.76576728;
|
|
341
|
+
k2 = 0.59662641;
|
|
342
|
+
k3 = 0.75515197;
|
|
343
|
+
k4 = 0.56771245;
|
|
344
|
+
wl = 4.0767416621;
|
|
345
|
+
wm = -3.3077115913;
|
|
346
|
+
ws = 0.2309699292;
|
|
347
|
+
} else if (1.81444104 * a - 1.19445276 * b > 1) {
|
|
348
|
+
// green channel
|
|
349
|
+
k0 = 0.73956515;
|
|
350
|
+
k1 = -0.45954404;
|
|
351
|
+
k2 = 0.08285427;
|
|
352
|
+
k3 = 0.1254107;
|
|
353
|
+
k4 = 0.14503204;
|
|
354
|
+
wl = -1.2684380046;
|
|
355
|
+
wm = 2.6097574011;
|
|
356
|
+
ws = -0.3413193965;
|
|
357
|
+
} else {
|
|
358
|
+
// blue channel
|
|
359
|
+
k0 = 1.35733652;
|
|
360
|
+
k1 = -0.00915799;
|
|
361
|
+
k2 = -1.1513021;
|
|
362
|
+
k3 = -0.50559606;
|
|
363
|
+
k4 = 0.00692167;
|
|
364
|
+
wl = -0.0041960863;
|
|
365
|
+
wm = -0.7034186147;
|
|
366
|
+
ws = 1.707614701;
|
|
367
|
+
}
|
|
368
|
+
const S = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b;
|
|
369
|
+
|
|
370
|
+
// One Halley step against f = (channel at S) - 0: polishes the fit to
|
|
371
|
+
// well below hex quantization error.
|
|
372
|
+
const kl = 0.3963377774 * a + 0.2158037573 * b;
|
|
373
|
+
const km = -0.1055613458 * a - 0.0638541728 * b;
|
|
374
|
+
const ks = -0.0894841775 * a - 1.291485548 * b;
|
|
375
|
+
const l_ = 1 + S * kl;
|
|
376
|
+
const m_ = 1 + S * km;
|
|
377
|
+
const s_ = 1 + S * ks;
|
|
378
|
+
const f = wl * l_ ** 3 + wm * m_ ** 3 + ws * s_ ** 3;
|
|
379
|
+
const f1 = wl * 3 * kl * l_ * l_ + wm * 3 * km * m_ * m_ + ws * 3 * ks * s_ * s_;
|
|
380
|
+
const f2 = wl * 6 * kl * kl * l_ + wm * 6 * km * km * m_ + ws * 6 * ks * ks * s_;
|
|
381
|
+
return S - (f * f1) / (f1 * f1 - 0.5 * f * f2);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The sRGB gamut cusp for an OKLCH hue: the lightness/chroma point where the
|
|
386
|
+
* hue reaches its maximum chroma inside sRGB.
|
|
387
|
+
*
|
|
388
|
+
* The cusp lightness varies wildly per hue (yellow ≈ 0.97, blue ≈ 0.45), so
|
|
389
|
+
* transferring absolute OKLCH lightness/chroma between hues distorts
|
|
390
|
+
* vividness; normalize against the cusp instead. See `getSessionAccentHex`.
|
|
391
|
+
*/
|
|
392
|
+
export function oklchCusp(h: number): { l: number; c: number } {
|
|
393
|
+
const hRad = (h * Math.PI) / 180;
|
|
394
|
+
const a = Math.cos(hRad);
|
|
395
|
+
const b = Math.sin(hRad);
|
|
396
|
+
const sCusp = computeMaxSaturation(a, b);
|
|
397
|
+
const rgb = oklabToLinearRgb(1, sCusp * a, sCusp * b);
|
|
398
|
+
const lCusp = Math.cbrt(1 / Math.max(rgb.r, rgb.g, rgb.b));
|
|
399
|
+
return { l: lCusp, c: lCusp * sCusp };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Slack allowed on linear channels before a color counts as out of sRGB gamut. */
|
|
403
|
+
const GAMUT_EPSILON = 1e-4;
|
|
404
|
+
|
|
405
|
+
/** True when all linear channels sit inside sRGB (within {@link GAMUT_EPSILON}). */
|
|
406
|
+
function inSrgbGamut(rgb: { r: number; g: number; b: number }): boolean {
|
|
407
|
+
return (
|
|
408
|
+
rgb.r >= -GAMUT_EPSILON &&
|
|
409
|
+
rgb.r <= 1 + GAMUT_EPSILON &&
|
|
410
|
+
rgb.g >= -GAMUT_EPSILON &&
|
|
411
|
+
rgb.g <= 1 + GAMUT_EPSILON &&
|
|
412
|
+
rgb.b >= -GAMUT_EPSILON &&
|
|
413
|
+
rgb.b <= 1 + GAMUT_EPSILON
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Convert OKLCH to a CSS hex string, gamut-mapping by chroma reduction.
|
|
419
|
+
*
|
|
420
|
+
* Out-of-gamut inputs keep their lightness and hue while chroma is bisected
|
|
421
|
+
* down until the color fits sRGB, matching CSS Color 4's recommended intent.
|
|
422
|
+
*/
|
|
423
|
+
export function oklchToHex(oklch: OKLCH): string {
|
|
424
|
+
const l = Math.max(0, Math.min(1, oklch.l));
|
|
425
|
+
const hRad = (oklch.h * Math.PI) / 180;
|
|
426
|
+
const cos = Math.cos(hRad);
|
|
427
|
+
const sin = Math.sin(hRad);
|
|
428
|
+
const at = (c: number) => oklabToLinearRgb(l, c * cos, c * sin);
|
|
429
|
+
|
|
430
|
+
let rgb = at(oklch.c);
|
|
431
|
+
if (!inSrgbGamut(rgb)) {
|
|
432
|
+
// `lo` always fits (chroma 0 is the gray axis), `hi` never does.
|
|
433
|
+
let lo = 0;
|
|
434
|
+
let hi = oklch.c;
|
|
435
|
+
for (let i = 0; i < 20; i++) {
|
|
436
|
+
const mid = (lo + hi) / 2;
|
|
437
|
+
if (inSrgbGamut(at(mid))) lo = mid;
|
|
438
|
+
else hi = mid;
|
|
439
|
+
}
|
|
440
|
+
rgb = at(lo);
|
|
441
|
+
}
|
|
442
|
+
return rgbToHex({
|
|
443
|
+
r: Math.max(0, Math.min(255, delinearizeChannel(rgb.r))),
|
|
444
|
+
g: Math.max(0, Math.min(255, delinearizeChannel(rgb.g))),
|
|
445
|
+
b: Math.max(0, Math.min(255, delinearizeChannel(rgb.b))),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
275
448
|
|
|
276
449
|
/**
|
|
277
450
|
* Perceptual luma (gamma-encoded BT.709 weights over raw sRGB), normalized to 0..1.
|
package/src/postmortem.ts
CHANGED
|
@@ -24,10 +24,25 @@ export enum Reason {
|
|
|
24
24
|
MANUAL = "manual", // Manual cleanup (not triggered by process)
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
interface CleanupRegistration {
|
|
28
|
+
id: string;
|
|
29
|
+
callback: (reason: Reason) => Promise<void> | void;
|
|
30
|
+
exitOnly: boolean;
|
|
31
|
+
cancelled: boolean;
|
|
32
|
+
lastPass: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Active cleanup callbacks in registration order. Registrations survive
|
|
36
|
+
// keep-alive passes; `lastPass` enforces at-most-once invocation per pass.
|
|
37
|
+
const callbackList: CleanupRegistration[] = [];
|
|
38
|
+
// Tracks cleanup run state (to prevent recursion/reentry issues).
|
|
30
39
|
let cleanupStage: "idle" | "running" | "complete" = "idle";
|
|
40
|
+
let cleanupPass = 0;
|
|
41
|
+
let activeCleanupReason: Reason | undefined;
|
|
42
|
+
let activeCleanupKeepAlive = false;
|
|
43
|
+
// Promises of callbacks invoked late (registered while a pass runs), joined by
|
|
44
|
+
// the active pass before it settles so `cleanup()`/signal exits await them.
|
|
45
|
+
let activeLatePromises: Promise<void>[] | undefined;
|
|
31
46
|
const CLEANUP_DEADLINE_MS = 10_000;
|
|
32
47
|
/**
|
|
33
48
|
* Symbol stamped by the extension-load guard onto the throwing replacement it
|
|
@@ -78,13 +93,32 @@ export interface FatalRecoveryHint {
|
|
|
78
93
|
type FatalRecoveryHintProvider = () => FatalRecoveryHint | undefined;
|
|
79
94
|
const fatalRecoveryHintProviders = new Set<FatalRecoveryHintProvider>();
|
|
80
95
|
|
|
96
|
+
function invokeCleanup(
|
|
97
|
+
registration: CleanupRegistration,
|
|
98
|
+
reason: Reason,
|
|
99
|
+
keepAlive: boolean,
|
|
100
|
+
pass: number,
|
|
101
|
+
): Promise<void> | void {
|
|
102
|
+
if (registration.cancelled || registration.lastPass === pass) return;
|
|
103
|
+
if (registration.exitOnly && keepAlive) return;
|
|
104
|
+
registration.lastPass = pass;
|
|
105
|
+
return registration.callback(reason);
|
|
106
|
+
}
|
|
107
|
+
|
|
81
108
|
/**
|
|
82
109
|
* Internal: runs all registered cleanup callbacks for the given reason.
|
|
83
|
-
* Ensures each
|
|
110
|
+
* Ensures each registration is invoked at most once per pass, handles errors,
|
|
111
|
+
* and prevents reentrancy.
|
|
112
|
+
*
|
|
113
|
+
* `keepAlive` marks a manual cleanup that keeps the process running (see
|
|
114
|
+
* {@link cleanup}). Such a pass returns the stage to `idle`; registrations stay
|
|
115
|
+
* active for later resources and the eventual real exit. Exit-only callbacks
|
|
116
|
+
* skip keep-alive passes without consuming their registration. An exit-driven
|
|
117
|
+
* pass instead settles to `complete` and stays there.
|
|
84
118
|
*
|
|
85
119
|
* Returns a Promise that settles after all cleanups complete or error out.
|
|
86
120
|
*/
|
|
87
|
-
function runCleanup(reason: Reason): Promise<void> {
|
|
121
|
+
function runCleanup(reason: Reason, keepAlive = false): Promise<void> {
|
|
88
122
|
switch (cleanupStage) {
|
|
89
123
|
case "idle":
|
|
90
124
|
cleanupStage = "running";
|
|
@@ -95,30 +129,52 @@ function runCleanup(reason: Reason): Promise<void> {
|
|
|
95
129
|
return Promise.resolve();
|
|
96
130
|
}
|
|
97
131
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
132
|
+
const pass = ++cleanupPass;
|
|
133
|
+
activeCleanupReason = reason;
|
|
134
|
+
activeCleanupKeepAlive = keepAlive;
|
|
135
|
+
const late: Promise<void>[] = [];
|
|
136
|
+
activeLatePromises = late;
|
|
137
|
+
const settle = (): void => {
|
|
138
|
+
if (activeLatePromises === late) activeLatePromises = undefined;
|
|
139
|
+
if (cleanupPass !== pass) return;
|
|
140
|
+
cleanupStage = keepAlive ? "idle" : "complete";
|
|
141
|
+
if (keepAlive) {
|
|
142
|
+
activeCleanupReason = undefined;
|
|
143
|
+
activeCleanupKeepAlive = false;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Snapshot the pass. Registrations added while a keep-alive cleanup runs are
|
|
148
|
+
// invoked by register() when appropriate and remain active for later passes.
|
|
149
|
+
const promises = callbackList.toReversed().map(registration => {
|
|
150
|
+
return Promise.try(() => invokeCleanup(registration, reason, keepAlive, pass));
|
|
102
151
|
});
|
|
103
152
|
|
|
104
|
-
const cleanupSettled = Promise.allSettled(promises).then(results => {
|
|
153
|
+
const cleanupSettled = Promise.allSettled(promises).then(async results => {
|
|
105
154
|
for (const result of results) {
|
|
106
155
|
if (result.status === "rejected") {
|
|
107
156
|
const err = result.reason instanceof Error ? result.reason : new Error(String(result.reason));
|
|
108
157
|
logger.error("Cleanup callback failed", { err, stack: err.stack });
|
|
109
158
|
}
|
|
110
159
|
}
|
|
111
|
-
|
|
160
|
+
// Join callbacks registered while this pass ran (already error-caught);
|
|
161
|
+
// each batch may register more. The deadline race still bounds the pass.
|
|
162
|
+
while (late.length > 0) await Promise.allSettled(late.splice(0));
|
|
163
|
+
settle();
|
|
112
164
|
});
|
|
113
165
|
const deadline = Promise.withResolvers<void>();
|
|
114
166
|
const deadlineTimer = setTimeout(() => {
|
|
115
167
|
logger.error("Cleanup deadline exceeded; proceeding with exit", { reason });
|
|
116
|
-
|
|
168
|
+
settle();
|
|
117
169
|
deadline.resolve();
|
|
118
170
|
}, CLEANUP_DEADLINE_MS);
|
|
119
|
-
|
|
171
|
+
const passPromise = Promise.race([cleanupSettled, deadline.promise]).finally(() => {
|
|
120
172
|
clearTimeout(deadlineTimer);
|
|
173
|
+
// A re-armed pass must drop only its own settled promise; an older
|
|
174
|
+
// deadline-limited pass may finish after a newer one has already started.
|
|
175
|
+
if (keepAlive && cleanupPass === pass && cleanupPromise === passPromise) cleanupPromise = undefined;
|
|
121
176
|
});
|
|
177
|
+
cleanupPromise = passPromise;
|
|
122
178
|
return cleanupPromise;
|
|
123
179
|
}
|
|
124
180
|
|
|
@@ -464,57 +520,92 @@ if (isMainThread) {
|
|
|
464
520
|
});
|
|
465
521
|
}
|
|
466
522
|
|
|
523
|
+
/** Controls when a registered cleanup callback participates in cleanup passes. */
|
|
524
|
+
export interface CleanupRegistrationOptions {
|
|
525
|
+
/**
|
|
526
|
+
* Run only on a real exit, never during a manual keep-alive cleanup.
|
|
527
|
+
* The registration remains armed when a keep-alive pass skips it.
|
|
528
|
+
*/
|
|
529
|
+
exitOnly?: boolean;
|
|
530
|
+
}
|
|
531
|
+
|
|
467
532
|
/**
|
|
468
|
-
*
|
|
533
|
+
* Registers a cleanup callback for shutdown, signals, fatal errors, and
|
|
534
|
+
* repeatable manual cleanup passes.
|
|
535
|
+
*
|
|
536
|
+
* Registrations persist across keep-alive {@link cleanup} passes and run at
|
|
537
|
+
* most once per pass. Set `exitOnly` for resources the continuing process still
|
|
538
|
+
* holds (open databases, cached handles): keep-alive passes skip the callback
|
|
539
|
+
* without consuming its registration, while the eventual real exit runs it.
|
|
540
|
+
*
|
|
541
|
+
* A callback registered during a running keep-alive pass joins future passes;
|
|
542
|
+
* normal callbacks also run immediately for the current pass. Registrations
|
|
543
|
+
* made during a real exit run immediately.
|
|
469
544
|
*
|
|
470
|
-
* Returns a
|
|
471
|
-
* If register is called after cleanup already began, invokes callback on a microtask.
|
|
545
|
+
* Returns a function that permanently cancels the registration.
|
|
472
546
|
*/
|
|
473
|
-
export function register(
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
547
|
+
export function register(
|
|
548
|
+
id: string,
|
|
549
|
+
callback: (reason: Reason) => void | Promise<void>,
|
|
550
|
+
options: CleanupRegistrationOptions = {},
|
|
551
|
+
): () => void {
|
|
552
|
+
const registration: CleanupRegistration = {
|
|
553
|
+
id,
|
|
554
|
+
callback,
|
|
555
|
+
exitOnly: options.exitOnly ?? false,
|
|
556
|
+
cancelled: false,
|
|
557
|
+
lastPass: 0,
|
|
558
|
+
};
|
|
559
|
+
const cancel = (): void => {
|
|
560
|
+
registration.cancelled = true;
|
|
561
|
+
const index = callbackList.indexOf(registration);
|
|
562
|
+
if (index >= 0) callbackList.splice(index, 1);
|
|
563
|
+
};
|
|
564
|
+
const invokeLate = (reason: Reason, keepAlive: boolean): void => {
|
|
478
565
|
try {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const
|
|
566
|
+
const pending = invokeCleanup(registration, reason, keepAlive, cleanupPass);
|
|
567
|
+
if (!pending) return;
|
|
568
|
+
const tracked = pending.catch(error => {
|
|
569
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
570
|
+
logger.error("Cleanup callback failed", { err, id, stack: err.stack });
|
|
571
|
+
});
|
|
572
|
+
// Join the active pass so cleanup()/signal exits await it; after a
|
|
573
|
+
// completed exit pass there is nothing left to join.
|
|
574
|
+
activeLatePromises?.push(tracked);
|
|
575
|
+
} catch (error) {
|
|
576
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
482
577
|
logger.error("Cleanup callback failed", { err, id, stack: err.stack });
|
|
483
578
|
}
|
|
484
579
|
};
|
|
485
580
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
}
|
|
491
|
-
done = true;
|
|
492
|
-
};
|
|
581
|
+
if (cleanupStage === "idle") {
|
|
582
|
+
callbackList.push(registration);
|
|
583
|
+
return cancel;
|
|
584
|
+
}
|
|
493
585
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
//
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
const err = e instanceof Error ? e : new Error(String(e));
|
|
502
|
-
logger.error("Cleanup callback failed", { err, id, stack: err.stack });
|
|
503
|
-
}
|
|
504
|
-
return () => {};
|
|
586
|
+
const reason = activeCleanupReason ?? Reason.MANUAL;
|
|
587
|
+
if (cleanupStage === "running" && activeCleanupKeepAlive) {
|
|
588
|
+
// The current pass already snapshotted its callbacks. Keep the new owner
|
|
589
|
+
// registered for future passes; normal callbacks also join this pass now.
|
|
590
|
+
callbackList.push(registration);
|
|
591
|
+
if (!registration.exitOnly) invokeLate(reason, true);
|
|
592
|
+
return cancel;
|
|
505
593
|
}
|
|
506
594
|
|
|
507
|
-
//
|
|
508
|
-
|
|
595
|
+
// A real exit is running or complete. There is no later pass to arm for, so
|
|
596
|
+
// invoke every late registration now, including exit-only callbacks.
|
|
597
|
+
logger.debug("Cleanup already started; running late callback once", { id });
|
|
598
|
+
invokeLate(reason, false);
|
|
509
599
|
return cancel;
|
|
510
600
|
}
|
|
511
601
|
|
|
512
602
|
/**
|
|
513
|
-
* Runs all cleanup callbacks without exiting
|
|
603
|
+
* Runs all cleanup callbacks without exiting, then re-arms the system so
|
|
604
|
+
* resources opened afterwards are still cleaned at the eventual real exit.
|
|
514
605
|
* Use this in workers or when you need to clean up but continue execution.
|
|
515
606
|
*/
|
|
516
607
|
export function cleanup(): Promise<void> {
|
|
517
|
-
return runCleanup(Reason.MANUAL);
|
|
608
|
+
return runCleanup(Reason.MANUAL, true);
|
|
518
609
|
}
|
|
519
610
|
|
|
520
611
|
/** Controls how manual process shutdown handles terminal output. */
|
|
@@ -523,6 +614,18 @@ export interface QuitOptions {
|
|
|
523
614
|
drainStdout?: boolean;
|
|
524
615
|
}
|
|
525
616
|
|
|
617
|
+
/**
|
|
618
|
+
* Waits (bounded) for buffered stdout to reach the terminal. Used before
|
|
619
|
+
* process exit and before an exec-replace, where unflushed output would be
|
|
620
|
+
* lost with the process image.
|
|
621
|
+
*/
|
|
622
|
+
export async function drainStdout(): Promise<void> {
|
|
623
|
+
if (process.stdout.writableLength === 0) return;
|
|
624
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
625
|
+
process.stdout.once("drain", resolve);
|
|
626
|
+
await Promise.race([promise, Bun.sleep(5000)]);
|
|
627
|
+
}
|
|
628
|
+
|
|
526
629
|
async function runQuit(code: number, exitMode: "guarded" | "native", options: QuitOptions = {}): Promise<void> {
|
|
527
630
|
await runCleanup(Reason.MANUAL);
|
|
528
631
|
|
|
@@ -530,10 +633,8 @@ async function runQuit(code: number, exitMode: "guarded" | "native", options: Qu
|
|
|
530
633
|
return; // Workers: cleanup done, let worker exit naturally
|
|
531
634
|
}
|
|
532
635
|
|
|
533
|
-
if (options.drainStdout !== false
|
|
534
|
-
|
|
535
|
-
process.stdout.once("drain", resolve);
|
|
536
|
-
await Promise.race([promise, Bun.sleep(5000)]);
|
|
636
|
+
if (options.drainStdout !== false) {
|
|
637
|
+
await drainStdout();
|
|
537
638
|
}
|
|
538
639
|
|
|
539
640
|
switch (exitMode) {
|
package/src/runtime-install.ts
CHANGED
|
@@ -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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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.
|
|
316
|
-
|
|
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 (!
|
|
321
|
-
|
|
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
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
}
|
package/src/sqlite.ts
CHANGED
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
* one implementation here prevents the classifiers from drifting between the
|
|
8
8
|
* credential store and the model cache.
|
|
9
9
|
*/
|
|
10
|
+
import type { Database } from "bun:sqlite";
|
|
11
|
+
|
|
12
|
+
/** Checkpoints committed WAL frames without waiting for concurrent readers. */
|
|
13
|
+
export function checkpointWal(db: Database): void {
|
|
14
|
+
db.run("PRAGMA wal_checkpoint(PASSIVE)");
|
|
15
|
+
}
|
|
10
16
|
|
|
11
17
|
/**
|
|
12
18
|
* SQLite's busy result-code family — base `SQLITE_BUSY` plus the extended
|