@oh-my-pi/pi-utils 18.1.19 → 18.1.21

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,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.1.21] - 2026-09-14
6
+
7
+ ### Added
8
+
9
+ - Added `getBrowserProfilesDir()` (`~/.omp/browser-profiles`; XDG: `$XDG_STATE_HOME/omp/browser-profiles`) for profiles of Chromium browsers spawned by the browser tool.
10
+
11
+ ### Fixed
12
+
13
+ - Timed out stalled Chrome-for-Testing metadata requests after 30 seconds when looking up download metadata
14
+ - Concurrent browser installations share one download without replacing a running browser, and stalled downloads time out with partial files cleaned up for retry.
15
+
5
16
  ## [18.1.19] - 2026-09-12
6
17
 
7
18
  ### Added
@@ -40,6 +40,8 @@ export declare function normalizeProfileName(profile: string | undefined): strin
40
40
  * {@link normalizeProfileName} (which throws on a syntactically invalid value).
41
41
  */
42
42
  export declare function resolveProfileEnv(omp: string | undefined, pi: string | undefined): string | undefined;
43
+ /** Profile-independent config root (~/.omp), shared by every omp profile. */
44
+ export declare function getBaseConfigRoot(): string;
43
45
  export declare function resolveEquivalentPath(inputPath: string): string;
44
46
  export declare function normalizePathForComparison(inputPath: string): string;
45
47
  export declare function pathIsWithin(root: string, candidate: string): boolean;
@@ -176,6 +178,8 @@ export declare function getPythonGatewayDir(): string;
176
178
  export declare function getPuppeteerDir(): string;
177
179
  /** Get the browser relay extension install directory (~/.omp/browser-relay). */
178
180
  export declare function getBrowserRelayDir(): string;
181
+ /** Get the profile root for Chromium browsers the browser tool spawns via `app.path` (~/.omp/browser-profiles). */
182
+ export declare function getBrowserProfilesDir(): string;
179
183
  /** Get DOCS_RS cache directory () */
180
184
  export declare function getDocsRsCacheDir(): string;
181
185
  /** Get the auto-QA grievances SQLite database path (~/.omp/autoqa.db; XDG: $XDG_DATA_HOME/omp/autoqa.db). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.1.19",
3
+ "version": "18.1.21",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "cli",
@@ -54,7 +54,7 @@
54
54
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
55
55
  },
56
56
  "dependencies": {
57
- "@oh-my-pi/pi-natives": "18.1.19"
57
+ "@oh-my-pi/pi-natives": "18.1.21"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/bun": "^1.3.14"
package/src/browsers.ts CHANGED
@@ -5,9 +5,13 @@ import * as fsp from "node:fs/promises";
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { type ArchiveLimits, extractArchive } from "./ar";
8
+ import { withFileLock } from "./file-lock";
8
9
 
9
10
  const CHROME_FOR_TESTING_BASE_URL = "https://storage.googleapis.com/chrome-for-testing-public";
10
11
  const CHROME_METADATA_BASE_URL = "https://googlechromelabs.github.io/chrome-for-testing";
12
+ // Installation is outside browser/Eval operation deadlines, but a stalled CDN
13
+ // must still release the cached install promise and permit a later attempt.
14
+ const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
11
15
 
12
16
  /**
13
17
  * Archive ceilings for the managed browser download. The default archive
@@ -251,29 +255,39 @@ export async function install(options: InstallOptions): Promise<InstalledBrowser
251
255
  return { browser: options.browser, buildId: options.buildId, platform, path: installPath, executablePath };
252
256
  }
253
257
 
254
- await fsp.mkdir(options.cacheDir, { recursive: true });
255
- const nonce = `${process.pid}-${crypto.randomUUID()}`;
256
- const archivePath = path.join(options.cacheDir, `.browser-${nonce}.zip`);
257
- const stagingPath = path.join(options.cacheDir, `.browser-${nonce}`);
258
- try {
259
- await downloadArchive(
260
- getDownloadUrl(options.browser, platform, options.buildId, options.baseUrl),
261
- archivePath,
262
- options.downloadProgressCallback,
263
- );
264
- await extractArchive(archivePath, stagingPath, { limits: BROWSER_ARCHIVE_LIMITS });
265
- await fsp.mkdir(path.dirname(installPath), { recursive: true });
266
- await fsp.rm(installPath, { recursive: true, force: true });
267
- await fsp.rename(stagingPath, installPath);
268
- } finally {
269
- await Promise.all([
270
- fsp.rm(archivePath, { force: true }).catch(() => {}),
271
- fsp.rm(stagingPath, { recursive: true, force: true }).catch(() => {}),
272
- ]);
273
- }
274
- if (!(await pathExists(executablePath)))
275
- throw new Error(`Browser archive did not contain its expected executable: ${executablePath}`);
276
- return { browser: options.browser, buildId: options.buildId, platform, path: installPath, executablePath };
258
+ await fsp.mkdir(path.dirname(installPath), { recursive: true });
259
+ return withFileLock(
260
+ `${installPath}.install`,
261
+ async () => {
262
+ // The in-process launch promise cannot serialize separate OMP sessions.
263
+ // Recheck under the OS lock: never replace a winner's running Chrome.
264
+ if (!(await pathExists(executablePath))) {
265
+ const nonce = `${process.pid}-${crypto.randomUUID()}`;
266
+ const archivePath = path.join(options.cacheDir, `.browser-${nonce}.zip`);
267
+ const stagingPath = path.join(options.cacheDir, `.browser-${nonce}`);
268
+ try {
269
+ await downloadArchive(
270
+ getDownloadUrl(options.browser, platform, options.buildId, options.baseUrl),
271
+ archivePath,
272
+ options.downloadProgressCallback,
273
+ );
274
+ await extractArchive(archivePath, stagingPath, { limits: BROWSER_ARCHIVE_LIMITS });
275
+ if (!(await pathExists(path.join(stagingPath, path.relative(installPath, executablePath))))) {
276
+ throw new Error(`Browser archive did not contain its expected executable: ${executablePath}`);
277
+ }
278
+ await fsp.rm(installPath, { recursive: true, force: true });
279
+ await fsp.rename(stagingPath, installPath);
280
+ } finally {
281
+ await Promise.all([
282
+ fsp.rm(archivePath, { force: true }).catch(() => {}),
283
+ fsp.rm(stagingPath, { recursive: true, force: true }).catch(() => {}),
284
+ ]);
285
+ }
286
+ }
287
+ return { browser: options.browser, buildId: options.buildId, platform, path: installPath, executablePath };
288
+ },
289
+ { retries: Math.ceil(DOWNLOAD_TIMEOUT_MS / 100) + 1, retryDelayMs: 100 },
290
+ );
277
291
  }
278
292
 
279
293
  function chromeChannelName(tag: string): string | undefined {
@@ -292,7 +306,7 @@ function chromeChannelName(tag: string): string | undefined {
292
306
  }
293
307
 
294
308
  async function fetchMetadata<T>(filename: string): Promise<T> {
295
- const response = await fetch(`${CHROME_METADATA_BASE_URL}/${filename}`);
309
+ const response = await fetch(`${CHROME_METADATA_BASE_URL}/${filename}`, { signal: AbortSignal.timeout(30_000) });
296
310
  if (!response.ok)
297
311
  throw new Error(`Failed to fetch Chrome-for-Testing metadata (${response.status} ${response.statusText})`);
298
312
  return (await response.json()) as T;
@@ -345,7 +359,7 @@ async function downloadArchive(
345
359
  destination: string,
346
360
  onProgress: ((progress: BrowserDownloadProgress) => void) | undefined,
347
361
  ): Promise<void> {
348
- const response = await fetch(url);
362
+ const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
349
363
  if (!response.ok || !response.body) {
350
364
  throw new Error(`Browser download failed (${response.status} ${response.statusText}) from ${url}`);
351
365
  }
package/src/dirs.ts CHANGED
@@ -107,7 +107,8 @@ function readProfileFromEnvSafe(): string | undefined {
107
107
  }
108
108
  }
109
109
 
110
- function getBaseConfigRoot(): string {
110
+ /** Profile-independent config root (~/.omp), shared by every omp profile. */
111
+ export function getBaseConfigRoot(): string {
111
112
  return path.join(os.homedir(), getConfigDirName());
112
113
  }
113
114
 
@@ -707,6 +708,11 @@ export function getBrowserRelayDir(): string {
707
708
  return dirs.rootSubdir("browser-relay", "data");
708
709
  }
709
710
 
711
+ /** Get the profile root for Chromium browsers the browser tool spawns via `app.path` (~/.omp/browser-profiles). */
712
+ export function getBrowserProfilesDir(): string {
713
+ return dirs.rootSubdir("browser-profiles", "state");
714
+ }
715
+
710
716
  /** Get DOCS_RS cache directory () */
711
717
  export function getDocsRsCacheDir(): string {
712
718
  return dirs.rootSubdir("webcache", "cache");