Skip to content

Commit 32d507c

Browse files
committed
ci: kill browser-test server reliably and bound CI teardown
The browser contract test step hung forever after passing: cleanup sent SIGTERM to the 'npm run serve' wrapper, but on ubuntu dash the signal never reached the docusaurus server, which survived as an orphan holding the stdio pipes — so the child 'close' event (exit + stdio EOF) never fired. Spawn the docusaurus binary directly (detached, process-group kill, SIGTERM→SIGKILL escalation), bound every teardown await with a timeout, and add explicit job/step timeout-minutes so any future hang fails fast instead of sitting out the 360min default.
1 parent 1786ca9 commit 32d507c

2 files changed

Lines changed: 86 additions & 23 deletions

File tree

‎.github/workflows/deploy.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ concurrency:
1818
jobs:
1919
build:
2020
runs-on: ubuntu-latest
21+
# Healthy runtime ~9min; bounded so a hang fails fast instead of sitting
22+
# out the 360min platform default.
23+
timeout-minutes: 25
2124
defaults:
2225
run:
2326
working-directory: ./website
@@ -62,6 +65,9 @@ jobs:
6265

6366
- name: Run browser contract tests against the built site
6467
run: npm run test:browser-contracts
68+
# Healthy runtime ~2-4min; the suite hangs if teardown fails, so this
69+
# step must have its own bound tighter than the job's.
70+
timeout-minutes: 15
6571
env:
6672
BROWSER_TEST_BUILD_DIR: ./build
6773

‎scripts/test-responsive-diagrams.cjs‎

Lines changed: 80 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,21 @@ const puppeteer = require(
99
path.resolve(__dirname, "..", "website", "node_modules", "puppeteer"),
1010
);
1111

12+
// Spawned directly via node instead of `npm run serve`: the npm/sh wrapper
13+
// layers swallow SIGTERM on Linux CI (ubuntu dash), orphaning the server while
14+
// it keeps our stdio pipes open — Node's child `close` event (exit + stdio EOF)
15+
// never fires and teardown hangs forever. See npm/rfcs#829.
16+
const docusaurusBin = path.resolve(
17+
__dirname,
18+
"..",
19+
"website",
20+
"node_modules",
21+
"@docusaurus",
22+
"core",
23+
"bin",
24+
"docusaurus.mjs",
25+
);
26+
1227
// Labels below must stay in sync with website/chapters.ts (chapterGroups[].label
1328
// and standaloneChapters). Centralized here so renames cause a single-point
1429
// update rather than scattered string literals.
@@ -34,12 +49,24 @@ const docusaurusCache = path.join(websiteDir, ".docusaurus");
3449
const hadDocusaurusCache = fs.existsSync(docusaurusCache);
3550

3651
let server;
52+
let serverClosed;
3753
let browser;
3854

3955
function fail(message) {
4056
throw new Error(message);
4157
}
4258

59+
// Bounded wait: teardown must never hang the CI step on a process that won't die.
60+
function withTimeout(promise, ms, label) {
61+
let timer;
62+
return Promise.race([
63+
promise,
64+
new Promise((_, reject) => {
65+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
66+
}),
67+
]).finally(() => clearTimeout(timer));
68+
}
69+
4370
async function captureFailure(page, name) {
4471
try {
4572
if (page && typeof page.isClosed === "function" && !page.isClosed()) {
@@ -101,27 +128,27 @@ function reservePort() {
101128
});
102129
}
103130

104-
function serveArgs(port) {
105-
return [
106-
"run",
107-
"serve",
108-
"--",
109-
"--dir",
110-
buildDir,
111-
"--port",
112-
String(port),
113-
"--host",
114-
"127.0.0.1",
115-
"--no-open",
116-
];
117-
}
118-
119131
function startServer(port) {
120132
const output = [];
121-
server = spawn("npm", serveArgs(port), {
122-
cwd: websiteDir,
123-
stdio: ["ignore", "pipe", "pipe"],
124-
});
133+
// detached: true gives the server its own process group (PGID == PID) so
134+
// stopServer can signal the whole tree; killing a lone PID is what lets
135+
// grandchildren survive as orphans holding our stdio pipes.
136+
server = spawn(
137+
process.execPath,
138+
[
139+
docusaurusBin,
140+
"serve",
141+
"--dir",
142+
buildDir,
143+
"--port",
144+
String(port),
145+
"--host",
146+
"127.0.0.1",
147+
"--no-open",
148+
],
149+
{ cwd: websiteDir, stdio: ["ignore", "pipe", "pipe"], detached: true },
150+
);
151+
serverClosed = new Promise((resolve) => server.once("close", resolve));
125152
for (const stream of [server.stdout, server.stderr]) {
126153
stream.on("data", (chunk) => {
127154
output.push(String(chunk));
@@ -146,6 +173,30 @@ function checkServer(port) {
146173
});
147174
}
148175

176+
// Signals the server's whole process group; safe to call when already gone.
177+
function signalServerGroup(signal) {
178+
if (!server || server.pid === undefined) return;
179+
try {
180+
process.kill(-server.pid, signal);
181+
} catch {} // process group already gone
182+
}
183+
184+
async function stopServer() {
185+
if (!server) return;
186+
signalServerGroup("SIGTERM");
187+
try {
188+
await withTimeout(serverClosed, 5000, "static server shutdown");
189+
} catch {
190+
console.warn("[cleanup] static server ignored SIGTERM; sending SIGKILL");
191+
signalServerGroup("SIGKILL");
192+
try {
193+
await withTimeout(serverClosed, 5000, "static server SIGKILL");
194+
} catch {
195+
console.warn("[cleanup] static server survived SIGKILL; exiting anyway");
196+
}
197+
}
198+
}
199+
149200
async function waitForServer(port) {
150201
for (let attempt = 0; attempt < 120; attempt += 1) {
151202
if (server.exitCode !== null) {
@@ -638,18 +689,24 @@ async function main() {
638689
}
639690

640691
async function cleanup() {
641-
if (browser) await browser.close().catch(() => {});
642-
if (server && server.exitCode === null) {
643-
server.kill("SIGTERM");
644-
await new Promise((resolve) => server.once("close", resolve));
692+
if (browser) {
693+
try {
694+
await withTimeout(browser.close(), 5000, "browser.close()");
695+
} catch (error) {
696+
console.warn(`[cleanup] ${error.message}`);
697+
}
645698
}
699+
await stopServer();
646700
// Only remove what this script created: tempRoot when self-built, and the
647701
// .docusaurus cache when the self-build was the first thing to create it.
648702
// TRACE_DIR is intentionally preserved for CI artifact upload on failure.
649703
if (tempRoot) fs.rmSync(tempRoot, { recursive: true, force: true });
650704
if (!hadDocusaurusCache && !providedBuildDir) {
651705
fs.rmSync(docusaurusCache, { recursive: true, force: true });
652706
}
707+
// Belt-and-braces: teardown must never hang the CI step. If any handle is
708+
// still keeping the event loop alive, leave explicitly with the test result.
709+
process.exit(process.exitCode ?? 0);
653710
}
654711

655712
main()

0 commit comments

Comments
 (0)