@oh-my-pi/pi-utils 18.1.20 → 18.1.22

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,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.1.22] - 2026-09-14
6
+
7
+ ### Fixed
8
+
9
+ - Fixed `extractRetryHint` sleeping hours past the provider's stated wait when a timezone-naive `reset at` timestamp overshoots the relative retry hint: the skewed stamp is now ignored instead of winning longest-wins ([#12070](https://github.com/can1357/oh-my-pi/pull/12070) by [@H4vC](https://github.com/H4vC)).
10
+
11
+ ## [18.1.21] - 2026-09-14
12
+
13
+ ### Added
14
+
15
+ - Added `getBrowserProfilesDir()` (`~/.omp/browser-profiles`; XDG: `$XDG_STATE_HOME/omp/browser-profiles`) for profiles of Chromium browsers spawned by the browser tool.
16
+
17
+ ### Fixed
18
+
19
+ - Timed out stalled Chrome-for-Testing metadata requests after 30 seconds when looking up download metadata
20
+ - Concurrent browser installations share one download without replacing a running browser, and stalled downloads time out with partial files cleaned up for retry.
21
+
5
22
  ## [18.1.19] - 2026-09-12
6
23
 
7
24
  ### Added
@@ -178,6 +178,8 @@ export declare function getPythonGatewayDir(): string;
178
178
  export declare function getPuppeteerDir(): string;
179
179
  /** Get the browser relay extension install directory (~/.omp/browser-relay). */
180
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;
181
183
  /** Get DOCS_RS cache directory () */
182
184
  export declare function getDocsRsCacheDir(): string;
183
185
  /** Get the auto-QA grievances SQLite database path (~/.omp/autoqa.db; XDG: $XDG_DATA_HOME/omp/autoqa.db). */
@@ -1,6 +1,5 @@
1
1
  /**
2
2
  * Server-suggested retry delay extraction. Merges the patterns historically used
3
- * by the OpenAI Codex and Google Gemini retry helpers.
4
3
  *
5
4
  * Header sources (checked in order):
6
5
  * - `retry-after-ms` (milliseconds)
@@ -16,6 +15,8 @@
16
15
  * - `try again in 250ms` / `try again in 12s` / `try again in 5 min` / `try again in ~158 min`
17
16
  * - `retry-after-ms=98497000` / `retry-after-ms: 7200000` / `retry-after-ms = 7200000`
18
17
  * - `Your limit will reset at 2026-09-01 09:44:51` / `将在 2026-09-01 09:44:51 重置`
18
+ * (offset-bearing only; a timezone-naive stamp is provider wall clock in
19
+ * an unknown zone and only resolves when no relative signal is present)
19
20
  *
20
21
  * Returns `undefined` if no signal is found, or `0` when the provider
21
22
  * explicitly asks for an immediate retry (`retry-after…=0`, or an absolute
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.1.20",
3
+ "version": "18.1.22",
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.20"
57
+ "@oh-my-pi/pi-natives": "18.1.22"
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
@@ -708,6 +708,11 @@ export function getBrowserRelayDir(): string {
708
708
  return dirs.rootSubdir("browser-relay", "data");
709
709
  }
710
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
+
711
716
  /** Get DOCS_RS cache directory () */
712
717
  export function getDocsRsCacheDir(): string {
713
718
  return dirs.rootSubdir("webcache", "cache");
@@ -19,9 +19,18 @@ const CN_RESET_AT_PATTERN = /将在\s*([0-9]{4}-[0-9]{2}-[0-9]{2}\s+[0-9]{2}:[0-
19
19
  // "retry-after-ms=98497000" / "retry-after-ms: 7200000" / "retry-after-ms = 7200000"
20
20
  const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
21
21
 
22
+ // A timezone-naive `reset at` stamp (no `Z`/offset) is the provider's
23
+ // wall clock in an unknown zone: it cannot be converted to a delay without
24
+ // guessing the zone, so it resolves only as a fallback when the body
25
+ // carries no unambiguous relative signal.
26
+
27
+ // A conflict probe needs no new signal: when a naive `reset at` wall stamp
28
+ // disagrees with the merged relative wait, the merged wait (which already
29
+ // ignores the naive stamp) sleeps first, and the retry after it is the
30
+ // probe — success proves skew, a fresh 429 re-anchors with live timing.
31
+
22
32
  /**
23
33
  * Server-suggested retry delay extraction. Merges the patterns historically used
24
- * by the OpenAI Codex and Google Gemini retry helpers.
25
34
  *
26
35
  * Header sources (checked in order):
27
36
  * - `retry-after-ms` (milliseconds)
@@ -37,6 +46,8 @@ const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
37
46
  * - `try again in 250ms` / `try again in 12s` / `try again in 5 min` / `try again in ~158 min`
38
47
  * - `retry-after-ms=98497000` / `retry-after-ms: 7200000` / `retry-after-ms = 7200000`
39
48
  * - `Your limit will reset at 2026-09-01 09:44:51` / `将在 2026-09-01 09:44:51 重置`
49
+ * (offset-bearing only; a timezone-naive stamp is provider wall clock in
50
+ * an unknown zone and only resolves when no relative signal is present)
40
51
  *
41
52
  * Returns `undefined` if no signal is found, or `0` when the provider
42
53
  * explicitly asks for an immediate retry (`retry-after…=0`, or an absolute
@@ -98,6 +109,15 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
98
109
  // when the parse returns undefined, which would sleep a session the
99
110
  // provider told to retry immediately.
100
111
  let retryNow = false;
112
+
113
+ // Timezone-naive `reset at` stamps (no `Z`/offset) are the provider's
114
+ // wall clock in an unknown zone — converting them to a delay requires
115
+ // guessing the zone. They resolve AFTER every unambiguous signal below,
116
+ // and only as a fallback when none was found.
117
+ let longestNaiveMs: number | undefined;
118
+ const considerNaive = (ms: number | undefined): void => {
119
+ if (ms !== undefined && ms > 0 && (longestNaiveMs === undefined || ms > longestNaiveMs)) longestNaiveMs = ms;
120
+ };
101
121
  const consider = (ms: number | undefined): void => {
102
122
  if (ms !== undefined && ms > 0 && (longestMs === undefined || ms > longestMs)) longestMs = ms;
103
123
  };
@@ -119,14 +139,23 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
119
139
  }
120
140
  for (const pattern of [WILL_RESET_AT_PATTERN, CN_RESET_AT_PATTERN]) {
121
141
  const match = pattern.exec(body);
122
- if (match?.[1]) {
123
- // Provider timestamps without an explicit offset are interpreted as UTC.
124
- const normalized = match[1].replace(" ", "T");
125
- const hasOffset = /(?:Z|[+-][0-9]{2}:?[0-9]{2})$/i.test(normalized);
126
- const parsed = Date.parse(hasOffset ? normalized : `${normalized}Z`);
142
+ if (!match?.[1]) continue;
143
+ // Offset-bearing stamps are unambiguous and compete by longest-wins.
144
+ // Naive stamps (provider wall clock, unknown zone) resolve after the
145
+ // relative signals below, and only when nothing unambiguous was
146
+ // found — never by guessing the zone against a conflicting signal.
147
+ const normalized = match[1].replace(" ", "T");
148
+ const hasOffset = /(?:Z|[+-][0-9]{2}:?[0-9]{2})$/i.test(normalized);
149
+ if (hasOffset) {
150
+ const parsed = Date.parse(normalized);
127
151
  if (!Number.isNaN(parsed) && parsed > Date.now()) {
128
152
  consider(parsed - Date.now());
129
153
  }
154
+ } else {
155
+ const parsed = Date.parse(`${normalized}Z`);
156
+ if (!Number.isNaN(parsed) && parsed > Date.now()) {
157
+ considerNaive(parsed - Date.now());
158
+ }
130
159
  }
131
160
  }
132
161
  const accountResetMatch = WILL_RESET_IN_PATTERN.exec(body);
@@ -188,7 +217,9 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
188
217
  considerClamped(resetSeconds > 1_000_000_000 ? resetSeconds * 1000 - Date.now() : resetSeconds * 1000);
189
218
  }
190
219
  }
191
- return longestMs ?? (retryNow ? 0 : undefined);
220
+ // Elapsed naive stamps were ignored before this change and stay ignored:
221
+ // only an explicit zero/expired relative signal is authoritative retry-now.
222
+ return longestMs ?? longestNaiveMs ?? (retryNow ? 0 : undefined);
192
223
  }
193
224
 
194
225
  function unitToMs(unit: string): number | undefined {