Skip to content

Commit de4e29a

Browse files
fix(ci): bound Chromium provisioning so a stalled apt mirror cannot cancel Build & E2E (#5308)
Fixes #5304
1 parent e7c3b3e commit de4e29a

4 files changed

Lines changed: 335 additions & 9 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -585,13 +585,46 @@ jobs:
585585
path: ~/.cache/ms-playwright
586586
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
587587

588+
# ── No `apt-get` on this path any more (objectui#5304) ────────────────
589+
# `playwright install --with-deps` / `install-deps` shell out to
590+
# `apt-get update` as root. On 2026-08-19 that hung this job three times,
591+
# each for the full 30-minute ceiling, with a byte-identical last line —
592+
# `Get:5 https://archive.ubuntu.com/ubuntu noble-security InRelease
593+
# [126 kB]` — after the runner's Azure mirror answered `Ign:` for all four
594+
# noble indexes and apt fell back to archive.ubuntu.com:
595+
# #5290 job 95986117935 07:05:48 -> cancelled 07:34:39 (30m14s)
596+
# #5290 job 95993643751 07:38:31 -> cancelled 08:07:32 (30m18s, re-run)
597+
# #5294 job 95988703670 07:17:27 -> cancelled 07:46:21 (30m17s)
598+
# apt has no timeout covering a mirror that connects and then trickles, so
599+
# the only backstop was `timeout-minutes: 30` — which converts a transient
600+
# network fault into a CANCELLED check: a gate that reports nothing at all.
601+
#
602+
# A transient, not a property of the workflow: on #5290 the
603+
# `Live E2E (informational)` job (95986118258) ran the same `install-deps`
604+
# on the same commit in the same minute and went green in 3m26s. Which is
605+
# why the call has to be bounded rather than trusted.
606+
#
607+
# Browser binaries come from the cache step above, never from apt
608+
# (measured on the run that then hung: `Cache hit for:
609+
# playwright-Linux-1.62.1`, 269 MB restored). `install` without
610+
# `--with-deps` fetches them from Playwright's CDN and touches no mirror,
611+
# so the cache-miss path is safe as spelled.
588612
- name: Install Playwright browsers
589613
if: steps.relevant.outputs.should_run == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
590-
run: pnpm exec playwright install --with-deps chromium
591-
592-
- name: Install Playwright system dependencies
593-
if: steps.relevant.outputs.should_run == 'true' && steps.playwright-cache.outputs.cache-hit == 'true'
594-
run: pnpm exec playwright install-deps chromium
614+
run: pnpm exec playwright install chromium
615+
616+
# The system libraries are on the `ubuntu-latest` image already — it ships
617+
# Chrome, Edge and Firefox, whose shared-library closure is the one
618+
# `install-deps chromium` asks for. Rather than assume that, this step
619+
# PROVES it on every run by launching the same bundled Chromium the suite
620+
# launches, and reaches for apt only if the launch fails — bounded, so a
621+
# stalled mirror costs seconds instead of the job. It is not a softener:
622+
# if no browser can be made to launch the script exits non-zero and this
623+
# job goes red. Rationale and the incident log: the script's header;
624+
# behaviour pinned by `scripts/__tests__/ensure-chromium-ready.test.ts`.
625+
- name: Ensure Chromium can launch
626+
if: steps.relevant.outputs.should_run == 'true'
627+
run: bash scripts/ensure-chromium-ready.sh
595628

596629
- name: Run E2E tests
597630
if: steps.relevant.outputs.should_run == 'true'

‎.github/workflows/live-e2e.yml‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,13 +146,20 @@ jobs:
146146
path: ~/.cache/ms-playwright
147147
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
148148

149+
# Same provisioning shape as `ci.yml`'s `Build & E2E`, and it carries the
150+
# same exposure: `--with-deps` / `install-deps` run `apt-get update` with
151+
# nothing bounding it. That is what hung `Build & E2E` three times on
152+
# 2026-08-19 for the full job ceiling (objectui#5304); this job's ceiling
153+
# is 40 minutes, so the same mirror stall would cost even more here. The
154+
# browsers come from the cache step above, and the launch probe in the
155+
# script is what proves a browser exists. See the script header for the
156+
# measured incident log.
149157
- name: Install Playwright browsers
150158
if: steps.playwright-cache.outputs.cache-hit != 'true'
151-
run: pnpm exec playwright install --with-deps chromium
159+
run: pnpm exec playwright install chromium
152160

153-
- name: Install Playwright system dependencies
154-
if: steps.playwright-cache.outputs.cache-hit == 'true'
155-
run: pnpm exec playwright install-deps chromium
161+
- name: Ensure Chromium can launch
162+
run: bash scripts/ensure-chromium-ready.sh
156163

157164
- name: Run live E2E allowlist
158165
run: pnpm test:e2e:live:ci
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { spawnSync } from 'node:child_process';
3+
import fs from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import { fileURLToPath } from 'node:url';
7+
8+
/**
9+
* objectui#5304 — `Build & E2E` hung inside `apt-get update` and was cancelled
10+
* at the 30-minute job ceiling, three times, with a byte-identical last line:
11+
*
12+
* Get:5 https://archive.ubuntu.com/ubuntu noble-security InRelease [126 kB]
13+
*
14+
* PR #5290 job 95986117935 07:05:48 -> cancelled 07:34:39 (30m14s)
15+
* PR #5290 job 95993643751 07:38:31 -> cancelled 08:07:32 (30m18s) (re-run)
16+
* PR #5294 job 95988703670 07:17:27 -> cancelled 07:46:21 (30m17s)
17+
*
18+
* The apt call was not in the workflow — it is what `playwright install-deps`
19+
* shells out to. Nothing bounded it, so a mirror that accepts the connection
20+
* and then never finishes the transfer consumed the entire job and left the
21+
* check `cancelled`: a gate that reports NOTHING, which is the worst of the
22+
* three possible outcomes.
23+
*
24+
* `scripts/ensure-chromium-ready.sh` replaces the unconditional apt call with
25+
* "prove the browser launches; reach for apt only if it does not, and bound it
26+
* when you do". The four cases below are the whole contract, and the third is
27+
* the direct regression pin: with apt stubbed to hang forever, the script must
28+
* still terminate.
29+
*
30+
* These run without a browser and without a network — both the launch probe and
31+
* the dependency install are substituted through the documented env seams. That
32+
* is deliberate: GitHub Actions cannot be run locally, so the logic that has to
33+
* survive a stalled mirror is pinned here, where it CAN be measured, rather
34+
* than only in a workflow nobody can execute outside CI.
35+
*/
36+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
37+
const script = path.join(repoRoot, 'scripts/ensure-chromium-ready.sh');
38+
39+
interface RunResult {
40+
status: number | null;
41+
stdout: string;
42+
stderr: string;
43+
elapsedMs: number;
44+
}
45+
46+
function run(env: Record<string, string>): RunResult {
47+
const startedAt = Date.now();
48+
const result = spawnSync('bash', [script], {
49+
cwd: repoRoot,
50+
encoding: 'utf8',
51+
env: { ...process.env, ...env },
52+
});
53+
return {
54+
status: result.status,
55+
stdout: result.stdout ?? '',
56+
stderr: result.stderr ?? '',
57+
elapsedMs: Date.now() - startedAt,
58+
};
59+
}
60+
61+
/** A fresh marker path that no test shares with another. */
62+
function marker(name: string): string {
63+
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), `os-5304-${name}-`)), 'deps-ran');
64+
}
65+
66+
describe('ensure-chromium-ready.sh — apt is off the happy path', () => {
67+
it('never invokes the dependency install when Chromium already launches', () => {
68+
const ran = marker('happy');
69+
70+
const result = run({
71+
OS_CHROMIUM_PROBE_CMD: 'true',
72+
OS_CHROMIUM_DEPS_CMD: `touch ${ran}`,
73+
});
74+
75+
expect(result.status, result.stderr).toBe(0);
76+
expect(
77+
fs.existsSync(ran),
78+
'The dependency install ran even though Chromium launched. That is the objectui#5304 ' +
79+
'exposure back again: an unconditional apt-get on the critical path of the gate.',
80+
).toBe(false);
81+
expect(result.stdout).toMatch(/No apt-get on this path/);
82+
});
83+
});
84+
85+
describe('ensure-chromium-ready.sh — recovery when a library really is missing', () => {
86+
it('installs the system libraries and succeeds once Chromium launches', () => {
87+
const ran = marker('recover');
88+
89+
// The probe fails until the install has run, then succeeds — the shape of a
90+
// runner image that genuinely lost a shared library.
91+
const result = run({
92+
OS_CHROMIUM_PROBE_CMD: `test -f ${ran}`,
93+
OS_CHROMIUM_DEPS_CMD: `touch ${ran}`,
94+
});
95+
96+
expect(result.status, result.stderr).toBe(0);
97+
expect(fs.existsSync(ran)).toBe(true);
98+
expect(result.stdout).toMatch(/Chromium launches after installing its system libraries/);
99+
});
100+
});
101+
102+
describe('ensure-chromium-ready.sh — a hanging apt cannot hang the job (the #5304 pin)', () => {
103+
it('terminates well inside the job ceiling when the dependency install never returns', () => {
104+
const result = run({
105+
OS_CHROMIUM_PROBE_CMD: 'false',
106+
// Stands in for `apt-get update` against the stalled mirror: it accepts
107+
// the call and never comes back.
108+
OS_CHROMIUM_DEPS_CMD: 'sleep 600',
109+
OS_CHROMIUM_DEPS_TIMEOUT: '2',
110+
});
111+
112+
// The defect was unbounded waiting, so the assertion is on the clock. 600s
113+
// is what the stub would take unbounded; the real incident took 1_800s.
114+
expect(
115+
result.elapsedMs,
116+
`The script ran for ${result.elapsedMs}ms against a dependency install that never ` +
117+
'returns. Unbounded again — this is exactly what cancelled three jobs in objectui#5304.',
118+
).toBeLessThan(60_000);
119+
expect(result.status, 'a browser that never became usable must still fail the job').not.toBe(0);
120+
expect(result.stderr).toMatch(/objectui#5304 signature/);
121+
}, 120_000);
122+
});
123+
124+
describe('ensure-chromium-ready.sh — the gate still reports', () => {
125+
it('fails the job when Chromium cannot launch even after the install', () => {
126+
const result = run({
127+
OS_CHROMIUM_PROBE_CMD: 'false',
128+
OS_CHROMIUM_DEPS_CMD: 'true',
129+
});
130+
131+
expect(
132+
result.status,
133+
'Exiting 0 with no usable browser would let the E2E suite report on a browser it never ' +
134+
'had. The point of #5304 is to RESTORE the signal, so this path must be red.',
135+
).toBe(1);
136+
expect(result.stderr).toMatch(/must not report green/);
137+
});
138+
});
139+
140+
describe('the workflows actually use it', () => {
141+
const workflows = ['.github/workflows/ci.yml', '.github/workflows/live-e2e.yml'];
142+
143+
/**
144+
* Whole-line comments removed. Required, not cosmetic: both workflows now
145+
* explain the incident in prose and name the very spellings asserted against
146+
* below, so a scan that counted the prose would report an unbounded apt call
147+
* that no step makes.
148+
*/
149+
const withoutComments = (yaml: string): string =>
150+
yaml
151+
.split('\n')
152+
.filter((line) => !/^\s*#/.test(line))
153+
.join('\n');
154+
155+
it.each(workflows)('%s calls the script instead of shelling straight to apt', (file) => {
156+
const yaml = withoutComments(fs.readFileSync(path.join(repoRoot, file), 'utf8'));
157+
158+
expect(
159+
yaml,
160+
`${file} must drive Chromium provisioning through scripts/ensure-chromium-ready.sh — ` +
161+
'that is the only place the apt call is bounded.',
162+
).toMatch(/ensure-chromium-ready\.sh/);
163+
164+
// The two spellings that reintroduce the unbounded `apt-get update`.
165+
// `playwright install chromium` (no `--with-deps`) is deliberately still
166+
// allowed and is what the cache-miss step runs: it downloads browser
167+
// binaries from Playwright's CDN and touches no apt mirror.
168+
expect(
169+
yaml,
170+
`${file} reintroduces an unbounded apt-get: 'playwright install --with-deps' and ` +
171+
"'playwright install-deps' both run 'apt-get update' with no timeout, which is the " +
172+
'objectui#5304 defect. Let scripts/ensure-chromium-ready.sh decide, and bound, that call.',
173+
).not.toMatch(/playwright\s+install(-deps|\s+--with-deps)/);
174+
});
175+
});

‎scripts/ensure-chromium-ready.sh‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Make the Playwright jobs independent of Ubuntu's apt mirrors (objectui#5304).
4+
#
5+
# ── What went wrong ───────────────────────────────────────────────────────────
6+
# `playwright install --with-deps` / `playwright install-deps` shell out to
7+
# `apt-get update` as root. On 2026-08-19 that hung `Build & E2E` three times,
8+
# each for the full 30-minute job ceiling, with a byte-identical last line:
9+
#
10+
# Get:5 https://archive.ubuntu.com/ubuntu noble-security InRelease [126 kB]
11+
#
12+
# PR #5290 job 95986117935 07:05:48 -> cancelled 07:34:39 (30m14s)
13+
# PR #5290 job 95993643751 07:38:31 -> cancelled 08:07:32 (30m18s) (re-run)
14+
# PR #5294 job 95988703670 07:17:27 -> cancelled 07:46:21 (30m17s)
15+
#
16+
# In each run the runner's `azure.archive.ubuntu.com` mirror answered `Ign:` for
17+
# all four noble indexes, apt fell back to `archive.ubuntu.com`, started three
18+
# InRelease fetches and never returned. apt applies no timeout that covers a
19+
# mirror which accepts the connection and then trickles, so the only backstop
20+
# was the job's `timeout-minutes` — which turns a transient mirror fault into a
21+
# cancelled gate that reports nothing.
22+
#
23+
# It is a transient rather than a property of the workflow: on #5290 the
24+
# `Live E2E (informational)` job (95986118258) ran the SAME `install-deps` step
25+
# on the SAME commit in the SAME minute and finished green in 3m26s. That is
26+
# precisely why the call has to be bounded rather than trusted.
27+
#
28+
# ── Why the happy path needs no apt at all ────────────────────────────────────
29+
# The browser binaries come from the `actions/cache` step, not from apt
30+
# (measured on the very run that then hung: `Cache hit for:
31+
# playwright-Linux-1.62.1`, 269 MB restored). The system libraries come from the
32+
# runner image, which ships Google Chrome, Microsoft Edge and Firefox — their
33+
# shared-library closure is the one `install-deps chromium` asks for.
34+
#
35+
# So: probe first, and only reach for apt if the probe says a library really is
36+
# missing. The probe is the honest test — it launches the same bundled Chromium
37+
# the suite launches, in the same environment, on every run. Nothing here is
38+
# assumed about the image; if the assumption above ever stops holding, the
39+
# recovery path below runs and says so in the log.
40+
#
41+
# ── Why this does not make the gate quieter ───────────────────────────────────
42+
# Every exit is a verdict. If Chromium cannot launch and apt cannot fix it, this
43+
# script exits non-zero and the job goes RED — it is never skipped, never
44+
# `continue-on-error`, and the E2E suite still has to pass afterwards. What
45+
# changes is only that a stalled mirror costs seconds instead of consuming the
46+
# whole job ceiling and cancelling the check.
47+
set -uo pipefail
48+
49+
# Bounds the apt recovery path. Deliberately far below any job `timeout-minutes`
50+
# so this script can never be the thing that consumes the ceiling.
51+
DEPS_TIMEOUT=${OS_CHROMIUM_DEPS_TIMEOUT:-240}
52+
53+
# Test seams. `scripts/__tests__/ensure-chromium-ready.test.ts` substitutes both
54+
# so the four branches below can be exercised without a browser or a network —
55+
# in particular the one that matters, "a hanging apt cannot hang the job".
56+
DEPS_CMD=${OS_CHROMIUM_DEPS_CMD:-"pnpm exec playwright install-deps chromium"}
57+
PROBE_CMD=${OS_CHROMIUM_PROBE_CMD:-}
58+
59+
# Launches the bundled Chromium exactly as `playwright.config.ts` does — its
60+
# `chromium` project is `devices['Desktop Chrome']` with no `channel`, so the
61+
# default launch is the representative one.
62+
probe() {
63+
if [ -n "$PROBE_CMD" ]; then
64+
bash -c "$PROBE_CMD"
65+
return $?
66+
fi
67+
node --input-type=module -e '
68+
import { chromium } from "@playwright/test";
69+
const browser = await chromium.launch();
70+
await browser.close();
71+
'
72+
}
73+
74+
if probe; then
75+
echo "Chromium launches with the libraries already present on this image."
76+
echo "No apt-get on this path (objectui#5304)."
77+
exit 0
78+
fi
79+
80+
echo "---"
81+
echo "Chromium did not launch, so a system library is genuinely missing."
82+
echo "Falling back to 'playwright install-deps', bounded at ${DEPS_TIMEOUT}s so a"
83+
echo "stalled Ubuntu mirror cannot hang this job the way it did in objectui#5304."
84+
85+
# `-k` escalates to KILL if apt ignores the TERM: a process blocked in a socket
86+
# read is exactly the shape that does. 124 is timeout's own "deadline expired".
87+
timeout -k 15s "${DEPS_TIMEOUT}s" bash -c "$DEPS_CMD"
88+
deps_status=$?
89+
90+
if [ "$deps_status" -eq 124 ] || [ "$deps_status" -eq 137 ]; then
91+
echo "install-deps exceeded ${DEPS_TIMEOUT}s and was killed (exit ${deps_status})." >&2
92+
echo "This is the objectui#5304 signature: an apt mirror that accepts the" >&2
93+
echo "connection and then never completes the transfer." >&2
94+
elif [ "$deps_status" -ne 0 ]; then
95+
echo "install-deps failed with exit ${deps_status}." >&2
96+
else
97+
echo "install-deps completed."
98+
fi
99+
100+
# Whatever apt did or did not manage, the question is unchanged and is settled
101+
# the same way: can Chromium launch? Only the probe may answer it.
102+
if probe; then
103+
echo "Chromium launches after installing its system libraries."
104+
exit 0
105+
fi
106+
107+
echo "---" >&2
108+
echo "Chromium still cannot launch after the recovery attempt above." >&2
109+
echo "Failing the job: the E2E suite cannot produce a verdict without a browser," >&2
110+
echo "and a gate that cannot produce a verdict must not report green." >&2
111+
exit 1

0 commit comments

Comments
 (0)