Skip to content

fix(engine): renders use the browser the CLI picked; Linux system Chrome is not beginframe - #5987

Merged
miguel-heygen merged 3 commits into
mainfrom
feat/browser-launcher
Oct 21, 2026
Merged

miguel-heygen merged 3 commits into
mainfrom
feat/browser-launcher

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

What

Two fixes to how renders pick their browser:

  1. One precedence for the browser env vars. With both HYPERFRAMES_BROWSER_PATH and PRODUCER_HEADLESS_SHELL_PATH set to different binaries, the CLI chose HYPERFRAMES_BROWSER_PATH and the engine launched PRODUCER_HEADLESS_SHELL_PATH. The preflight checked one browser while the render ran on the other. Both now resolve through one browserPathFromEnv (in chrome-host-ceiling, which both already import), with HYPERFRAMES_BROWSER_PATH first, as the CLI documents. The engine's resolveConfig no longer copies PRODUCER_HEADLESS_SHELL_PATH into chromePath. That copy was checked before the env, so a CLI render still launched the PRODUCER_ binary. An explicit chromePath still wins. One behaviour change: with a valid HYPERFRAMES_BROWSER_PATH set, a broken PRODUCER_HEADLESS_SHELL_PATH is no longer reported, because it is never used.
  2. System Chrome on Linux is no longer counted as headless-shell. The parallel capture router got headlessShell: resolveHeadlessShellPath(cfg) !== null. That function returns string | undefined, so the comparison was always true. A Linux render on system Chrome, which the engine runs in screenshot mode, was treated as beginframe, which turns the parallel-stream router on by default for the wrong capture mode. resolveParallelCaptureMode now takes the resolved path and checks for undefined itself.

Why

Both make what the CLI decides and what the engine does disagree. The second also sends one cohort of Linux renders down a router path that was only measured for beginframe capture.

Related work

None. A follow-up PR adds a generic seam for a host to supply its own Chromium launcher on top of the same resolvers.

How

  • chromeHostCeiling.ts gains browserPathFromEnv(env). The engine's resolveHeadlessShellPath and the CLI's findFromEnv both call it. Each keeps its existing reaction to a missing file: the engine throws, the CLI falls through.
  • config.ts drops its own env read for chromePath, so resolveHeadlessShellPath owns the env order for every caller.
  • resolveParallelCaptureMode({ headlessShellPath }) replaces the boolean, so the "was a shell resolved" check lives in the tested function, not at the call site.
  • The comment above findFromEnv now states the rule without its history.

Test plan

  • Unit tests added/updated:
    • browserManager.test.ts: with both env vars set to different existing files, resolveHeadlessShellPath({}) and resolveHeadlessShellPath(resolveConfig()) both return HYPERFRAMES_BROWSER_PATH. The block now clears both vars before each case, so a runner that exports one (the Windows job sets HYPERFRAMES_BROWSER_PATH) cannot change a case.
    • renderOrchestrator.test.ts: a Linux render with no resolved shell is screenshot.
    • fails without the fix:
      • with browserManager.ts from main, the precedence test fails (1 failed, 16 passed);
      • with the check written as !== null again, the screenshot case fails (1 failed, 2 passed);
      • with config.ts's env read put back, the resolveConfig() case fails (1 failed, 91 passed);
      • without the per-case env reset and with HYPERFRAMES_BROWSER_PATH set as on the Windows runner, the missing-binary case fails (1 failed, 91 passed), which is the Windows job's failure.
    • At c56e02e, browserManager.test.ts plus config.test.ts passed 3 runs in a row with HYPERFRAMES_BROWSER_PATH set (205 passed). These also pass: the CLI's render.test.ts (105 passed), the producer's renderOrchestrator.test.ts (280 passed) and the Lambda handler.test.ts (25 passed).
  • Manual testing performed
  • Documentation updated (if applicable)
  • Comments follow CONTRIBUTING.md "Comments"

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 153 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 85f45309. There are no other reviews yet.

What holds

  • The capture-mode fix is right. resolveHeadlessShellPath returns string | undefined, so !== null was always true. resolveParallelCaptureMode now uses the same truthy rule the engine's own launch uses (browserManager.ts:814-827), so the router and the launch agree.
  • browserPathFromEnv also skips an empty HYPERFRAMES_BROWSER_PATH="", which the old ?? in findFromEnv did not.

Blocker 1: a CLI render still launches PRODUCER_HEADLESS_SHELL_PATH when both are set
resolveConfig fills chromePath from PRODUCER_HEADLESS_SHELL_PATH (engine/src/config.ts:855). resolveHeadlessShellPath returns config.chromePath before it reaches browserPathFromEnv (browserManager.ts:252). The CLI render builds its config with producer.resolveConfig(…) (cli/src/commands/render.ts:1081), and a render with no producerConfig does the same (renderOrchestrator.ts:2872). So the render's resolveHeadlessShellPath(cfg) at :3452 and :4073 still gets the PRODUCER_ path.

Repro at this head, with both vars pointing at different existing files:

  • resolveHeadlessShellPath({}) returns HYPERFRAMES_BROWSER_PATH's file.
  • resolveHeadlessShellPath(resolveConfig()) returns PRODUCER_HEADLESS_SHELL_PATH's file, because cfg.chromePath is the PRODUCER_ path.

The new test passes {}, so it never sees the config layer. The preflight-vs-render split this PR targets is still there for CLI renders. Two ways to fix it: resolve chromePath in config.ts through browserPathFromEnv()?.path, or drop the env read there and let resolveHeadlessShellPath own it. Either way, a test through resolveConfig() would pin it.

Blocker 2: required Tests on windows-latest: engine-cli is red, caused by this change
browserManager.test.ts:828 ("throws a clear error when PRODUCER_HEADLESS_SHELL_PATH points at a missing binary") fails on Windows. That job exports HYPERFRAMES_BROWSER_PATH (windows-render.yml:509), and the test doesn't clear it. With the new order, the existing HYPERFRAMES_ file wins, so nothing throws (job). Locally, with HYPERFRAMES_BROWSER_PATH set to an existing file, it is 1 failed / 90 passed, and 91/91 pass without it. Adding delete process.env.HYPERFRAMES_BROWSER_PATH to that test fixes it.

That failure also shows a behaviour change the description doesn't mention: a valid HYPERFRAMES_BROWSER_PATH now silently hides a broken PRODUCER_HEADLESS_SHELL_PATH, which used to throw. That's arguably intended by "HYPERFRAMES first", but worth a line in the description.

Verdict: REQUEST CHANGES
Reasoning: The precedence fix doesn't reach a CLI render, because resolveConfig pins chromePath to PRODUCER_HEADLESS_SHELL_PATH first. A required Windows check also fails on a test the new order breaks. The capture-mode fix itself is correct.

— Via

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Both fixed at c56e02e:

  • Config layer (blocker 1): resolveConfig no longer copies PRODUCER_HEADLESS_SHELL_PATH into chromePath, so resolveHeadlessShellPath owns the env order for every caller and an explicit chromePath still wins. New case: resolveHeadlessShellPath(resolveConfig()) with both vars set returns HYPERFRAMES_BROWSER_PATH. It fails with the config read put back (1 failed, 91 passed).
  • Windows (blocker 2): the resolveHeadlessShellPath block now clears both vars before each case, so the runner's HYPERFRAMES_BROWSER_PATH can't reach any of them. I reproduced the Windows failure locally with that var set (1 failed, 91 passed); with the reset, 3 runs pass. That was the job's only failure: engine 1 failed / 104 passed, CLI 367 passed.
  • The behaviour change you named (a valid HYPERFRAMES_BROWSER_PATH hides a broken PRODUCER_ path, which is now never used) is in the description.

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at c56e02e9, covering the delta from 85f45309 (config.ts and browserManager.test.ts).

Both blockers are closed

  • The config layer no longer pins the browser. resolveConfig no longer reads PRODUCER_HEADLESS_SHELL_PATH into chromePath, so a CLI render's resolveHeadlessShellPath(cfg) reaches browserPathFromEnv and gets HYPERFRAMES_BROWSER_PATH first. Nothing else read cfg.chromePath for the env value. Its remaining readers are resolveHeadlessShellPath itself (browserManager.ts:252) and callers that pass an already-resolved path (browserManager.ts:632, frameCapture.ts:1293, renderOrchestrator.ts:3452). The new test goes through resolveConfig(). Putting the removed line back turns it red (1 failed / 91 passed).
  • The Windows env leak is fixed. The suite's beforeEach clears both vars. Locally, with both vars exported as the Windows job does, browserManager.test.ts and config.test.ts pass 205/205.

Nit: the resolveHeadlessShellPath docstring (browserManager.ts:246-247) still says "config.chromePath, then PRODUCER_HEADLESS_SHELL_PATH env var, then the CLI browser override". That order is now HYPERFRAMES_BROWSER_PATH first.

CI: every required check is green at c56e02e9, including Tests on windows-latest and its engine-cli job (CI run 39528131578, Windows run 39528131597). A second pull_request run at the same head skipped its jobs.

Verdict: APPROVE
Reasoning: The precedence now holds through the engine config a render actually uses, and the failing Windows test is isolated from the runner's env. The capture-mode fix was already correct.

— Via

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit 0260c40 Oct 21, 2026
120 checks passed
@miguel-heygen
miguel-heygen deleted the feat/browser-launcher branch October 21, 2026 02:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants