castle-web-cli 0.4.80 → 0.4.81

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.
@@ -2,6 +2,7 @@ import type { Browser } from "playwright-core";
2
2
  import type { PlaytestInstallEvent } from "./playtest.js";
3
3
  export declare const INSTALL_START_LABEL = "Downloading playtest browser (one-time, ~250MB)\u2026";
4
4
  export declare const INSTALL_WAIT_LABEL = "Waiting for browser download (shared)\u2026";
5
+ export declare const DEPS_REPAIR_LABEL = "Installing missing browser system libraries (one-time)\u2026";
5
6
  export interface PlaywrightChromiumLike {
6
7
  executablePath(): string;
7
8
  launch(options?: {
@@ -14,6 +15,8 @@ export interface PlaywrightModuleLike {
14
15
  export interface PlaytestBrowserSeams {
15
16
  loadPlaywright?: () => Promise<PlaywrightModuleLike>;
16
17
  runInstall?: (onLine: (line: string) => void) => Promise<void>;
18
+ runInstallDeps?: () => Promise<void>;
19
+ platform?: NodeJS.Platform;
17
20
  }
18
21
  export interface BrowserInstallHooks {
19
22
  onProgress?: (label: string) => void;
@@ -44,18 +44,39 @@ const LAUNCH_ARGS = [
44
44
  ];
45
45
  const MISSING_PLAYWRIGHT_MESSAGE = "playwright-core is not installed in this CLI (run `npm install` from the repo root).";
46
46
  const MANUAL_INSTALL_HINT = "install manually with `npx playwright install chromium`";
47
+ const MANUAL_DEPS_HINT = "install manually with `npx playwright install-deps chromium` (needs root/apt)";
47
48
  export const INSTALL_START_LABEL = "Downloading playtest browser (one-time, ~250MB)\u2026";
48
49
  export const INSTALL_WAIT_LABEL = "Waiting for browser download (shared)\u2026";
50
+ export const DEPS_REPAIR_LABEL = "Installing missing browser system libraries (one-time)\u2026";
49
51
  // Progress labels are throttled to 20-point steps: every onProgress string
50
52
  // becomes a task-feed ROW (see runTaskAgentIn's onActivity wiring in
51
53
  // agent.ts), so per-percent updates would flood the feed with ~100 rows.
52
54
  const PROGRESS_STEP = 20;
55
+ // The Chromium binary and the OS shared libraries it dynamically links
56
+ // (libnspr4, libnss3, libatk-1.0, libgbm, ...) are two SEPARATE things: the
57
+ // on-demand download above only fetches the former. A box whose base image
58
+ // never baked in the latter (e.g. an E2B sandbox built from a pre-fix
59
+ // template -- see castle-cloud-cli/template.mjs) downloads Chromium fine but
60
+ // then fails to LAUNCH it with a linker error naming the missing .so. This
61
+ // regex is how launchBrowser tells "the binary can't find a system library"
62
+ // apart from any other launch failure (crash, bad args, OOM, ...) so it only
63
+ // attempts the apt-get repair below for the failure mode that repair can
64
+ // actually fix.
65
+ const MISSING_SHARED_LIBRARY_RE = /error while loading shared libraries|cannot open shared object file|\blib[\w.+-]+\.so\b/i;
66
+ function looksLikeMissingSharedLibrary(err) {
67
+ const message = err instanceof Error ? err.message : String(err);
68
+ return MISSING_SHARED_LIBRARY_RE.test(message);
69
+ }
53
70
  function launchFailureMessage(err) {
54
71
  const message = err instanceof Error ? err.message : String(err);
55
72
  const looksLikeMissingBrowser = /executable doesn't exist|download.*chromium/i.test(message);
56
- return looksLikeMissingBrowser
57
- ? `chromium is not installed for playwright -- ${MANUAL_INSTALL_HINT}. (${message})`
58
- : `could not launch chromium: ${message}`;
73
+ if (looksLikeMissingBrowser) {
74
+ return `chromium is not installed for playwright -- ${MANUAL_INSTALL_HINT}. (${message})`;
75
+ }
76
+ if (looksLikeMissingSharedLibrary(err)) {
77
+ return `chromium is missing OS-level system libraries -- ${MANUAL_DEPS_HINT}. (${message})`;
78
+ }
79
+ return `could not launch chromium: ${message}`;
59
80
  }
60
81
  async function loadPlaywrightReal() {
61
82
  return (await import("playwright-core"));
@@ -114,6 +135,36 @@ function runInstallReal(onLine) {
114
135
  });
115
136
  });
116
137
  }
138
+ // Same install CLI, `install-deps` instead of `install` -- apt-get's the OS
139
+ // shared libraries Chromium links (sudo internally if not already root; see
140
+ // the module header). No progress-line parsing: apt-get's output doesn't
141
+ // carry a percent, and this is a rare repair path, not the common-case
142
+ // download bar `runInstallReal` throttles updates for.
143
+ function runInstallDepsReal() {
144
+ return new Promise((resolve, reject) => {
145
+ const cliPath = resolveInstallCli();
146
+ if (!cliPath) {
147
+ reject(new Error(MISSING_PLAYWRIGHT_MESSAGE));
148
+ return;
149
+ }
150
+ const child = spawn(process.execPath, [cliPath, "install-deps", "chromium"], {
151
+ env: process.env,
152
+ stdio: ["ignore", "pipe", "pipe"],
153
+ });
154
+ let output = "";
155
+ child.stdout.on("data", (chunk) => (output += chunk.toString("utf8")));
156
+ child.stderr.on("data", (chunk) => (output += chunk.toString("utf8")));
157
+ child.on("error", (err) => reject(err));
158
+ child.on("exit", (code, signal) => {
159
+ if (code === 0)
160
+ resolve();
161
+ else {
162
+ const tail = output.trim().split("\n").slice(-5).join(" | ");
163
+ reject(new Error(`install-deps exited with ${signal ? `signal ${signal}` : `code ${code}`}${tail ? ` (${tail})` : ""}`));
164
+ }
165
+ });
166
+ });
167
+ }
117
168
  // MODULE-level (not per-manager): one Chromium download per process, no
118
169
  // matter how many managers/serves/prewarms race for it. Reset to null when
119
170
  // the flight settles -- on success the fs existence check gates any future
@@ -206,9 +257,67 @@ async function ensureInstalled(runInstall, hooks, getCachePath) {
206
257
  flight.progressListeners.delete(listener);
207
258
  }
208
259
  }
260
+ // -- deps repair (retrofits EXISTING sandboxes booted from a pre-fix image) ------
261
+ // Chromium DOWNLOAD (above) and Chromium's OS-level LAUNCH dependencies are
262
+ // fixed at two different times for two different populations: a template
263
+ // rebuild (castle-cloud-cli/template.mjs) bakes the libs in for sandboxes
264
+ // created AFTER the fix, but does nothing for already-running sandboxes that
265
+ // booted from the old image and may have already downloaded Chromium (so
266
+ // `missingExecutable` is false for them -- ensureInstalled's branch never
267
+ // runs again). This is the fallback for that population: attempted lazily,
268
+ // ONLY on an actual launch failure that looks like a missing shared library
269
+ // (see MISSING_SHARED_LIBRARY_RE), ONLY on linux (never on a local macOS
270
+ // dev machine), and at MOST ONCE per process -- a repair that fails (no
271
+ // sudo, no apt, offline) is not worth retrying on every subsequent playtest
272
+ // call. Module-level (not per-manager) for the same reason installFlight is:
273
+ // one apt-get for the whole process, no matter how many managers/callers hit
274
+ // a broken launch concurrently.
275
+ let depsRepairFlight = null;
276
+ let depsRepairSettled = null;
277
+ function logDepsRepairStart() {
278
+ console.error("[playtest] chromium launch failed on a missing OS library -- attempting one-time repair (apt-get via playwright install-deps)...");
279
+ }
280
+ function logDepsRepairResult(ok, elapsedMs, err) {
281
+ const elapsedS = (elapsedMs / 1000).toFixed(1);
282
+ if (ok) {
283
+ console.error(`[playtest] system library repair succeeded in ${elapsedS}s -- retrying browser launch`);
284
+ }
285
+ else {
286
+ const message = err instanceof Error ? err.message : String(err);
287
+ console.error(`[playtest] system library repair failed after ${elapsedS}s: ${message}`);
288
+ }
289
+ }
290
+ function attemptDepsRepair(runInstallDeps, onProgress) {
291
+ if (depsRepairSettled) {
292
+ // Already tried once this process: a success means the retry that
293
+ // follows should just work (no need to repeat), a failure means
294
+ // repeating would just fail again the same way (no sudo/apt/network) --
295
+ // either way, don't re-run apt-get on every subsequent broken launch.
296
+ return Promise.resolve(depsRepairSettled.ok ? { ok: true } : { ok: false, error: "system library repair already failed once this process" });
297
+ }
298
+ if (depsRepairFlight)
299
+ return depsRepairFlight;
300
+ const startedAt = Date.now();
301
+ logDepsRepairStart();
302
+ onProgress?.(DEPS_REPAIR_LABEL);
303
+ depsRepairFlight = runInstallDeps().then(() => {
304
+ depsRepairSettled = { ok: true };
305
+ logDepsRepairResult(true, Date.now() - startedAt);
306
+ return { ok: true };
307
+ }, (err) => {
308
+ depsRepairSettled = { ok: false };
309
+ logDepsRepairResult(false, Date.now() - startedAt, err);
310
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
311
+ }).finally(() => {
312
+ depsRepairFlight = null;
313
+ });
314
+ return depsRepairFlight;
315
+ }
209
316
  export function createPlaytestBrowserManager(seams) {
210
317
  const loadPlaywright = seams?.loadPlaywright ?? loadPlaywrightReal;
211
318
  const runInstall = seams?.runInstall ?? runInstallReal;
319
+ const runInstallDeps = seams?.runInstallDeps ?? runInstallDepsReal;
320
+ const platform = seams?.platform ?? process.platform;
212
321
  let browser = null;
213
322
  let idleTimer = null;
214
323
  let launching = null;
@@ -243,7 +352,7 @@ export function createPlaytestBrowserManager(seams) {
243
352
  return true;
244
353
  }
245
354
  }
246
- async function launchBrowser(playwright) {
355
+ async function tryLaunch(playwright) {
247
356
  try {
248
357
  const launched = await playwright.chromium.launch({ args: LAUNCH_ARGS });
249
358
  launched.on("disconnected", () => {
@@ -254,8 +363,28 @@ export function createPlaytestBrowserManager(seams) {
254
363
  return { ok: true, browser: launched };
255
364
  }
256
365
  catch (err) {
257
- return { ok: false, error: launchFailureMessage(err) };
366
+ return { ok: false, error: err };
367
+ }
368
+ }
369
+ async function launchBrowser(playwright, hooks) {
370
+ const first = await tryLaunch(playwright);
371
+ if (first.ok)
372
+ return first;
373
+ // Self-heal path for sandboxes that already have the Chromium BINARY
374
+ // (so ensureInstalled's download branch never re-runs) but are missing
375
+ // the OS libraries it links -- see the module header above
376
+ // attemptDepsRepair for why this is gated to linux + this specific
377
+ // failure shape + at most one attempt per process.
378
+ if (platform === "linux" && looksLikeMissingSharedLibrary(first.error)) {
379
+ const repaired = await attemptDepsRepair(runInstallDeps, hooks?.onProgress);
380
+ if (repaired.ok) {
381
+ const retry = await tryLaunch(playwright);
382
+ if (retry.ok)
383
+ return retry;
384
+ return { ok: false, error: launchFailureMessage(retry.error) };
385
+ }
258
386
  }
387
+ return { ok: false, error: launchFailureMessage(first.error) };
259
388
  }
260
389
  // Two DIFFERENT single-flights, deliberately layered: the install check
261
390
  // runs per caller (so every concurrent caller passes its OWN hooks through
@@ -301,7 +430,7 @@ export function createPlaytestBrowserManager(seams) {
301
430
  if (existing?.isConnected())
302
431
  return { ok: true, browser: existing, installedMs };
303
432
  if (!launching) {
304
- launching = launchBrowser(playwright).finally(() => {
433
+ launching = launchBrowser(playwright, hooks).finally(() => {
305
434
  launching = null;
306
435
  });
307
436
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.80",
3
+ "version": "0.4.81",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"