@rynx-ai/daemon 0.1.11-beta.31 → 0.1.11-beta.32

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/plugin-channel-lark",
3
- "version": "0.1.11-beta.31",
3
+ "version": "0.1.11-beta.32",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/rynx-ai/rynx.git",
@@ -0,0 +1,9 @@
1
+ export interface ChromeForTestingFetchInit {
2
+ headers?: HeadersInit;
3
+ redirect?: RequestRedirect;
4
+ signal?: AbortSignal | null;
5
+ }
6
+ /** HTTP behavior shared by Chrome for Testing metadata and artifact downloads. */
7
+ export declare function fetchChromeForTesting(url: string, init?: ChromeForTestingFetchInit): Promise<Response>;
8
+ /** Preserve nested network diagnostics instead of reporting only `fetch failed`. */
9
+ export declare function formatChromeForTestingNetworkError(error: unknown): string;
@@ -0,0 +1,58 @@
1
+ import { EnvHttpProxyAgent, fetch as undiciFetch } from "undici";
2
+ import { installedDaemonVersion } from "./entry-path.js";
3
+ const CONNECT_TIMEOUT_MS = 30_000;
4
+ const REQUEST_TIMEOUT_MS = 120_000;
5
+ let dispatcher;
6
+ /** HTTP behavior shared by Chrome for Testing metadata and artifact downloads. */
7
+ export async function fetchChromeForTesting(url, init = {}) {
8
+ const headers = new Headers(init.headers);
9
+ if (!headers.has("user-agent")) {
10
+ headers.set("user-agent", `rynx/${installedDaemonVersion()}`);
11
+ }
12
+ const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
13
+ const signal = init.signal
14
+ ? AbortSignal.any([init.signal, timeoutSignal])
15
+ : timeoutSignal;
16
+ const response = await undiciFetch(url, {
17
+ dispatcher: dispatcher ??= createDispatcher(),
18
+ headers: Object.fromEntries(headers.entries()),
19
+ redirect: init.redirect,
20
+ signal,
21
+ });
22
+ return response;
23
+ }
24
+ /** Preserve nested network diagnostics instead of reporting only `fetch failed`. */
25
+ export function formatChromeForTestingNetworkError(error) {
26
+ const messages = [];
27
+ const seen = new Set();
28
+ let current = error;
29
+ while (current instanceof Error && !seen.has(current) && messages.length < 8) {
30
+ seen.add(current);
31
+ if (current.message && messages.at(-1) !== current.message)
32
+ messages.push(current.message);
33
+ current = current.cause;
34
+ }
35
+ return messages.join(": ") || String(error);
36
+ }
37
+ function createDispatcher() {
38
+ const allProxy = firstEnvironmentValue("all_proxy", "ALL_PROXY");
39
+ const httpProxy = firstEnvironmentValue("http_proxy", "HTTP_PROXY") ?? allProxy;
40
+ const httpsProxy = firstEnvironmentValue("https_proxy", "HTTPS_PROXY") ?? httpProxy;
41
+ const noProxy = firstEnvironmentValue("no_proxy", "NO_PROXY");
42
+ return new EnvHttpProxyAgent({
43
+ connectTimeout: CONNECT_TIMEOUT_MS,
44
+ headersTimeout: REQUEST_TIMEOUT_MS,
45
+ bodyTimeout: REQUEST_TIMEOUT_MS,
46
+ ...(httpProxy ? { httpProxy } : {}),
47
+ ...(httpsProxy ? { httpsProxy } : {}),
48
+ ...(noProxy ? { noProxy } : {}),
49
+ });
50
+ }
51
+ function firstEnvironmentValue(...names) {
52
+ for (const name of names) {
53
+ const value = process.env[name];
54
+ if (value)
55
+ return value;
56
+ }
57
+ return undefined;
58
+ }
@@ -7,6 +7,7 @@
7
7
  * store computes and records the archive digest when it installs these bytes.
8
8
  */
9
9
  import { resolveChromeForTestingPlatform, } from "./chrome-for-testing-store.js";
10
+ import { fetchChromeForTesting, formatChromeForTestingNetworkError, } from "./chrome-for-testing-http.js";
10
11
  const MANIFEST_BASE_URL = "https://googlechromelabs.github.io/chrome-for-testing/";
11
12
  const CHANNEL_MANIFEST_URL = `${MANIFEST_BASE_URL}last-known-good-versions-with-downloads.json`;
12
13
  const ARTIFACT_BASE_URL = "https://storage.googleapis.com/chrome-for-testing-public/";
@@ -53,11 +54,17 @@ function validateSelector(selector) {
53
54
  }
54
55
  async function fetchManifest(manifestUrl, fetchMetadata, signal) {
55
56
  assertManifestUrl(manifestUrl);
56
- const response = await fetchMetadata(manifestUrl, {
57
- signal,
58
- redirect: "error",
59
- headers: { accept: "application/json" },
60
- });
57
+ let response;
58
+ try {
59
+ response = await fetchMetadata(manifestUrl, {
60
+ signal,
61
+ redirect: "error",
62
+ headers: { accept: "application/json" },
63
+ });
64
+ }
65
+ catch (error) {
66
+ throw new Error(`Chrome for Testing metadata request failed: ${formatChromeForTestingNetworkError(error)}`, { cause: error });
67
+ }
61
68
  if (!response.ok) {
62
69
  throw new Error(`Chrome for Testing metadata request failed: HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`);
63
70
  }
@@ -210,5 +217,5 @@ function isRecord(value) {
210
217
  return typeof value === "object" && value !== null && !Array.isArray(value);
211
218
  }
212
219
  async function defaultFetchMetadata(url, init) {
213
- return fetch(url, init);
220
+ return fetchChromeForTesting(url, init);
214
221
  }
@@ -48,6 +48,8 @@ export interface ChromeForTestingArtifactStoreOptions {
48
48
  arch?: string;
49
49
  fetchArtifact?: ChromeForTestingArtifactFetcher;
50
50
  extractZip?: ChromeForTestingZipExtractor;
51
+ /** Injectable only so download retries can be tested without real delays. */
52
+ retryDelay?: (attempt: number, signal?: AbortSignal) => Promise<void>;
51
53
  maxArchiveBytes?: number;
52
54
  now?: () => Date;
53
55
  /** Defaults to ~/.rynx/browser/profiles/headless-chromium. */
@@ -6,12 +6,14 @@
6
6
  * explicitly pinned build or an already-resolved official release.
7
7
  */
8
8
  import { createHash } from "node:crypto";
9
- import { accessSync, chmodSync, closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, writeSync, } from "node:fs";
9
+ import { accessSync, chmodSync, closeSync, existsSync, ftruncateSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, writeSync, } from "node:fs";
10
10
  import { constants as fsConstants } from "node:fs";
11
11
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
12
12
  import { DatabaseSync } from "node:sqlite";
13
+ import { setTimeout as delay } from "node:timers/promises";
13
14
  import extractZipArchive from "@electron-internal/extract-zip";
14
15
  import { rynxHome } from "@rynx-ai/core";
16
+ import { fetchChromeForTesting, formatChromeForTestingNetworkError, } from "./chrome-for-testing-http.js";
15
17
  import { hasSqlitePrimaryCode, SQLITE_BUSY, SQLITE_LOCKED } from "./sqlite.js";
16
18
  const BUILD_OWNER_FILE = ".rynx-cft-owner.json";
17
19
  const STAGING_OWNER_FILE = ".rynx-cft-staging-owner.json";
@@ -20,8 +22,9 @@ const STORE_LOCK_FILE = ".rynx-cft-store.sqlite";
20
22
  const MAX_METADATA_BYTES = 8 * 1024;
21
23
  const DEFAULT_MAX_ARCHIVE_BYTES = 1_024 * 1_024 * 1_024;
22
24
  const DEFAULT_STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
23
- const DOWNLOAD_PROGRESS_PERCENT_STEP = 10;
25
+ const DOWNLOAD_PROGRESS_PERCENT_STEP = 5;
24
26
  const DOWNLOAD_PROGRESS_BYTES_STEP = 16 * 1_024 * 1_024;
27
+ const DOWNLOAD_MAX_ATTEMPTS = 3;
25
28
  const STAGING_NAME_PATTERN = /^install-([A-Za-z0-9]{6})$/;
26
29
  const HEADLESS_PROFILE_NAME_PATTERN = /^session-([a-f0-9]{64})$/;
27
30
  const VERSION_PATTERN = /^\d{1,6}\.\d{1,6}\.\d{1,6}\.\d{1,6}$/;
@@ -68,6 +71,7 @@ export function createChromeForTestingArtifactStore(options = {}) {
68
71
  const rootDir = resolve(options.rootDir ?? join(rynxHome(), "cache", "browser", "chrome-for-testing"));
69
72
  const fetchArtifact = options.fetchArtifact ?? defaultFetchArtifact;
70
73
  const extractZip = options.extractZip ?? defaultExtractZip;
74
+ const retryDelay = options.retryDelay ?? defaultDownloadRetryDelay;
71
75
  const maxArchiveBytes = options.maxArchiveBytes ?? DEFAULT_MAX_ARCHIVE_BYTES;
72
76
  const now = options.now ?? (() => new Date());
73
77
  const headlessProfileRoot = resolve(options.headlessProfileRoot ?? join(rynxHome(), "browser", "profiles", "headless-chromium"));
@@ -139,6 +143,7 @@ export function createChromeForTestingArtifactStore(options = {}) {
139
143
  maxArchiveBytes,
140
144
  signal: installOptions.signal,
141
145
  onProgress: installOptions.onProgress,
146
+ retryDelay,
142
147
  });
143
148
  throwIfAborted(installOptions.signal);
144
149
  const build = { ...release, sha256 };
@@ -450,9 +455,54 @@ function validateDownloadMetadata(input, platform) {
450
455
  return { version: input.version, platform: input.platform, url: url.href };
451
456
  }
452
457
  async function downloadArchive(options) {
453
- const response = await options.fetchArtifact(options.url, { signal: options.signal });
458
+ const descriptor = openSync(options.destination, "wx", 0o600);
459
+ try {
460
+ for (let attempt = 1; attempt <= DOWNLOAD_MAX_ATTEMPTS; attempt += 1) {
461
+ try {
462
+ if (attempt > 1)
463
+ ftruncateSync(descriptor, 0);
464
+ return await downloadArchiveAttempt(options, descriptor);
465
+ }
466
+ catch (error) {
467
+ throwIfAborted(options.signal);
468
+ if (!(error instanceof RetryableDownloadError) || attempt === DOWNLOAD_MAX_ATTEMPTS) {
469
+ throw error;
470
+ }
471
+ options.onProgress?.(`Chrome for Testing 下载失败,正在重试 (${attempt + 1}/${DOWNLOAD_MAX_ATTEMPTS})`);
472
+ try {
473
+ await options.retryDelay(attempt, options.signal);
474
+ }
475
+ catch (delayError) {
476
+ throwIfAborted(options.signal);
477
+ throw delayError;
478
+ }
479
+ }
480
+ }
481
+ }
482
+ finally {
483
+ closeSync(descriptor);
484
+ }
485
+ throw new Error("Chrome for Testing download failed");
486
+ }
487
+ async function downloadArchiveAttempt(options, descriptor) {
488
+ let response;
489
+ try {
490
+ response = await options.fetchArtifact(options.url, { signal: options.signal });
491
+ }
492
+ catch (error) {
493
+ throw new RetryableDownloadError(`Chrome for Testing download failed: ${formatChromeForTestingNetworkError(error)}`, { cause: error });
494
+ }
454
495
  if (!response.ok) {
455
- throw new Error(`Chrome for Testing download failed: HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`);
496
+ try {
497
+ await response.body?.cancel();
498
+ }
499
+ catch {
500
+ // The status code remains the useful failure when response cleanup fails.
501
+ }
502
+ const error = new Error(`Chrome for Testing download failed: HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`);
503
+ if (response.status >= 500)
504
+ throw new RetryableDownloadError(error.message, { cause: error });
505
+ throw error;
456
506
  }
457
507
  if (!response.body)
458
508
  throw new Error("Chrome for Testing download returned no response body");
@@ -461,15 +511,20 @@ async function downloadArchive(options) {
461
511
  throw new Error(`Chrome for Testing archive exceeds ${options.maxArchiveBytes} bytes`);
462
512
  }
463
513
  const hash = createHash("sha256");
464
- const descriptor = openSync(options.destination, "wx", 0o600);
465
514
  let bytes = 0;
466
- let nextPercent = DOWNLOAD_PROGRESS_PERCENT_STEP;
515
+ let lastReportedPercent = 0;
467
516
  let nextBytes = DOWNLOAD_PROGRESS_BYTES_STEP;
468
517
  const reader = response.body.getReader();
469
518
  try {
470
519
  while (true) {
471
520
  throwIfAborted(options.signal);
472
- const next = await reader.read();
521
+ let next;
522
+ try {
523
+ next = await reader.read();
524
+ }
525
+ catch (error) {
526
+ throw new RetryableDownloadError(`Chrome for Testing download failed: ${formatChromeForTestingNetworkError(error)}`, { cause: error });
527
+ }
473
528
  if (next.done)
474
529
  break;
475
530
  const chunk = Buffer.from(next.value);
@@ -478,24 +533,24 @@ async function downloadArchive(options) {
478
533
  throw new Error(`Chrome for Testing archive exceeds ${options.maxArchiveBytes} bytes`);
479
534
  }
480
535
  hash.update(chunk);
481
- writeAll(descriptor, chunk);
536
+ writeAll(descriptor, chunk, bytes - chunk.byteLength);
482
537
  if (length !== undefined && length > 0) {
483
538
  const completed = Math.min(100, Math.floor(bytes * 100 / length));
484
- while (nextPercent <= completed) {
485
- options.onProgress?.(`Chrome for Testing 下载进度:${nextPercent}%`);
486
- nextPercent += DOWNLOAD_PROGRESS_PERCENT_STEP;
539
+ if (completed >= lastReportedPercent + DOWNLOAD_PROGRESS_PERCENT_STEP) {
540
+ lastReportedPercent = completed;
541
+ options.onProgress?.(`Chrome for Testing 下载进度:${formatDownloadMegabytes(bytes)} / ` +
542
+ `${formatDownloadMegabytes(length)} (${completed}%)`);
487
543
  }
488
544
  }
489
545
  else {
490
546
  while (nextBytes <= bytes) {
491
- options.onProgress?.(`Chrome for Testing 已下载:${formatDownloadBytes(nextBytes)}`);
547
+ options.onProgress?.(`Chrome for Testing 已下载:${formatDownloadMegabytes(nextBytes)}`);
492
548
  nextBytes += DOWNLOAD_PROGRESS_BYTES_STEP;
493
549
  }
494
550
  }
495
551
  }
496
552
  }
497
553
  finally {
498
- closeSync(descriptor);
499
554
  reader.releaseLock();
500
555
  }
501
556
  const actual = hash.digest("hex");
@@ -504,13 +559,18 @@ async function downloadArchive(options) {
504
559
  }
505
560
  return actual;
506
561
  }
507
- function formatDownloadBytes(bytes) {
508
- return `${Math.floor(bytes / (1_024 * 1_024))} MiB`;
562
+ class RetryableDownloadError extends Error {
563
+ }
564
+ async function defaultDownloadRetryDelay(attempt, signal) {
565
+ await delay(2 ** attempt * 1_000, undefined, { signal });
566
+ }
567
+ function formatDownloadMegabytes(bytes) {
568
+ return `${(bytes / (1_024 * 1_024)).toFixed(1)} MB`;
509
569
  }
510
- function writeAll(descriptor, value) {
570
+ function writeAll(descriptor, value, position) {
511
571
  let offset = 0;
512
572
  while (offset < value.byteLength) {
513
- const written = writeSync(descriptor, value, offset, value.byteLength - offset);
573
+ const written = writeSync(descriptor, value, offset, value.byteLength - offset, position + offset);
514
574
  if (written <= 0)
515
575
  throw new Error("Chrome for Testing archive write made no progress");
516
576
  offset += written;
@@ -841,7 +901,7 @@ function throwIfAborted(signal) {
841
901
  throw new Error("Chrome for Testing installation aborted");
842
902
  }
843
903
  async function defaultFetchArtifact(url, init) {
844
- return fetch(url, { signal: init.signal, redirect: "follow" });
904
+ return fetchChromeForTesting(url, { signal: init.signal, redirect: "follow" });
845
905
  }
846
906
  async function defaultExtractZip(archivePath, destination) {
847
907
  await extractZipArchive(archivePath, { dir: destination });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/daemon",
3
- "version": "0.1.11-beta.31",
3
+ "version": "0.1.11-beta.32",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/rynx-ai/rynx.git",
@@ -49,18 +49,19 @@
49
49
  "@electron-internal/extract-zip": "1.0.5",
50
50
  "pm2": "^6.0.0",
51
51
  "tar": "^7.5.19",
52
+ "undici": "^7.28.0",
52
53
  "ws": "^8.21.0",
53
- "@rynx-ai/plugin-runner": "0.1.11-beta.31",
54
- "@rynx-ai/emulator": "0.1.11-beta.31",
55
- "@rynx-ai/plugin-sdk": "0.1.11-beta.31",
56
- "@rynx-ai/protocol": "0.1.11-beta.31",
57
- "@rynx-ai/remote-runtime-client": "0.1.11-beta.31",
58
- "@rynx-ai/server": "0.1.11-beta.31",
59
- "@rynx-ai/core": "0.1.11-beta.31"
54
+ "@rynx-ai/core": "0.1.11-beta.32",
55
+ "@rynx-ai/emulator": "0.1.11-beta.32",
56
+ "@rynx-ai/plugin-runner": "0.1.11-beta.32",
57
+ "@rynx-ai/plugin-sdk": "0.1.11-beta.32",
58
+ "@rynx-ai/protocol": "0.1.11-beta.32",
59
+ "@rynx-ai/remote-runtime-client": "0.1.11-beta.32",
60
+ "@rynx-ai/server": "0.1.11-beta.32"
60
61
  },
61
62
  "devDependencies": {
62
63
  "@types/ws": "^8.18.1",
63
- "@rynx-ai/plugin-channel-lark": "0.1.11-beta.31"
64
+ "@rynx-ai/plugin-channel-lark": "0.1.11-beta.32"
64
65
  },
65
66
  "scripts": {
66
67
  "build": "rm -rf dist bundled-plugins && tsc -p tsconfig.json && chmod +x dist/index-daemon.js && node ../../scripts/stage-bundled-plugins.mjs",