@@ -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");
3449const hadDocusaurusCache = fs . existsSync ( docusaurusCache ) ;
3550
3651let server ;
52+ let serverClosed ;
3753let browser ;
3854
3955function 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+
4370async 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-
119131function 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+
149200async 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
640691async 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
655712main ( )
0 commit comments