From 75368ddcdbbc847e4c3fe8000802f81fda9ad3eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Mon, 17 Aug 2026 15:35:39 +0200 Subject: [PATCH 1/3] fix(docs-tests): always rebuild dist in producer mode to walk current source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RouteCheckTests fixture previously honored a pre-existing docs/.vitepress/dist/index.html as evidence that dist was current. That was safe under the sharded CI matrix (ROUTE_SHARD_TOTAL > 1), where the docs-prepare job is the docfx-owning producer and each shard downloads its artefact — but not in producer mode. A warm local checkout keeps a stale dist across sessions, and the fixture would silently walk it: the landing-page test asserted against the old rendered HTML (missing the https:// og:image URL that landed in a later config.ts revision), and the producer-mode mtime invariant fired because no rebuild took place. Split the two modes explicitly: consumer shards keep the artefact contract (skip rebuild when index.html is present); producer mode always rebuilds via npm run build. This walks the current source on every run, satisfies the mtime invariant unconditionally in producer mode, and preserves the sharded artefact flow. Claude-Session: https://claude.ai/code/session_0162RfaA55VT8NX6QfU7RUVo --- .../RouteCheckTests.cs | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs index daad0c643..524747e26 100644 --- a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs +++ b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs @@ -125,20 +125,39 @@ public async Task OneTimeSetUp() RunNpm("ci", _docsRoot); } - // Rebuild dist only when it is missing so the test asserts against - // a tree generated from the current source. CI's sharded - // route-check jobs download the dist/ tree from the `docs-prepare` - // workflow artefact and do NOT install docfx, so re-running - // `npm run build` here would invoke the `prebuild` hook - // (`scripts/generate-api-ref.sh` → `docfx metadata`) and fail - // with "docfx not found on PATH". Honouring the pre-existing - // dist/index.html sentinel matches the workflow's documented - // contract: docs-prepare is the single docfx-owning producer - // and each shard consumes its artefact. Locally, deleting - // docs/.vitepress/dist/ (or running on a clean clone) still - // triggers a full build. + // Producer vs. consumer mode. + // + // Producer mode (non-shard local + non-shard CI leg): the + // fixture is the sole authority on dist/, so it always + // rebuilds — a warm-cache local run must still walk a tree + // generated from the CURRENT source markdown, current + // config.ts, current sidebar, and so on. Honouring a + // pre-existing dist/index.html sentinel was the earlier + // policy and it silently walked a stale tree whenever a + // developer re-ran `dotnet test` after editing source: the + // walked routes, meta tags, and rendered HTML lagged the + // source by an arbitrary distance (a stale dist from Jun 2 + // failed the landing-page og:image assertion on Aug 17 for + // exactly this reason, because the config-side fix that + // added the https:// og:image URL had landed since the last + // build). The rebuild cost is bounded by npm's incremental + // Vite bundling — a warm-cache no-source-change rebuild is + // seconds, not minutes — and this fixture is already gated + // by [Category("E2E")] so it never blocks the fast tier. + // + // Consumer mode (sharded CI matrix, ROUTE_SHARD_TOTAL > 1): + // CI's sharded route-check jobs download the dist/ tree + // from the `docs-prepare` workflow artefact and do NOT + // install docfx, so re-running `npm run build` would invoke + // the `prebuild` hook (`scripts/generate-api-ref.sh` → + // `docfx metadata`) and fail with "docfx not found on + // PATH". Under shard mode the fixture consumes whatever + // dist/ the artefact download produced; the docs-prepare + // job is the single docfx-owning producer. var distIndex = Path.Combine(distDir, "index.html"); - if (!File.Exists(distIndex)) + var (_, shardTotal) = RouteCheckHelpers.ReadShardEnv(); + var isConsumerShard = shardTotal > 1; + if (!isConsumerShard || !File.Exists(distIndex)) { stage = "npm run build"; RunNpm("run build", _docsRoot); From 8488f15e4ed91561f4ebc68a2c7a9a1bd32f16b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Mon, 17 Aug 2026 18:48:00 +0200 Subject: [PATCH 2/3] refactor(docs-tests): call vitepress build directly to keep obj/ untouched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amends the earlier producer-mode-always-rebuilds fix so the fixture no longer routes through npm run build. The `prebuild` hook wired into docs/package.json runs docs/scripts/generate-api-ref.sh, which `dotnet build -c Debug --no-incremental`s every library, agent, adapter and module project. That sweep clobbers each project's obj/project.assets.json down to a Debug-only net8.0 view and races any in-flight multi-TFM Release build under `dotnet test MTConnect.NET.sln -c Release`, tripping NETSDK1005 on every non-net8.0 target MSBuild has not yet linked (net47, net461, net472, net9.0, net10.0, …). Invoke node node_modules/vitepress/bin/vitepress.js build directly under docs/ instead. Walks the same source markdown, produces the same dist/, keeps the producer-mode rebuild guarantee, and leaves the obj/ tree untouched. The docs/api/ sub-tree stays as whatever the last regen produced — the fixture never owned that regen (the docs-prepare workflow and generate-api-ref.sh do). Claude-Session: https://claude.ai/code/session_0162RfaA55VT8NX6QfU7RUVo --- .../RouteCheckTests.cs | 109 +++++++++++++++--- 1 file changed, 96 insertions(+), 13 deletions(-) diff --git a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs index 524747e26..b5bc927d3 100644 --- a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs +++ b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs @@ -140,27 +140,40 @@ public async Task OneTimeSetUp() // failed the landing-page og:image assertion on Aug 17 for // exactly this reason, because the config-side fix that // added the https:// og:image URL had landed since the last - // build). The rebuild cost is bounded by npm's incremental - // Vite bundling — a warm-cache no-source-change rebuild is - // seconds, not minutes — and this fixture is already gated - // by [Category("E2E")] so it never blocks the fast tier. + // build). // // Consumer mode (sharded CI matrix, ROUTE_SHARD_TOTAL > 1): // CI's sharded route-check jobs download the dist/ tree - // from the `docs-prepare` workflow artefact and do NOT - // install docfx, so re-running `npm run build` would invoke - // the `prebuild` hook (`scripts/generate-api-ref.sh` → - // `docfx metadata`) and fail with "docfx not found on - // PATH". Under shard mode the fixture consumes whatever - // dist/ the artefact download produced; the docs-prepare - // job is the single docfx-owning producer. + // from the `docs-prepare` workflow artefact and skip the + // rebuild. The docs-prepare job is the single docfx-owning + // producer. + // + // Why call vitepress directly instead of `npm run build`: + // the `prebuild` hook wired into `package.json` runs + // `docs/scripts/generate-api-ref.sh`, which does a + // `dotnet build -c Debug --no-incremental` sweep of every + // library, agent, adapter and module project. Under a full + // `dotnet test MTConnect.NET.sln -c Release` invocation the + // solution build is still in flight (multi-TFM Release + // outputs for net47, net461, net472, net9.0, net10.0, … + // build in parallel with the net8.0 test hosts), so + // clobbering each project's `obj/project.assets.json` back + // to a Debug-only net8.0 view races the Release build and + // trips NETSDK1005 on every non-net8.0 target that MSBuild + // has not yet linked. Invoking vitepress directly walks the + // same source markdown, produces the same dist/, keeps the + // producer-mode rebuild guarantee, and leaves the obj/ + // tree untouched. The api reference sub-tree under + // docs/api/ stays as whatever the last regen produced — + // this fixture does not own that regen (the docs-prepare + // workflow and `docs/scripts/generate-api-ref.sh` do). var distIndex = Path.Combine(distDir, "index.html"); var (_, shardTotal) = RouteCheckHelpers.ReadShardEnv(); var isConsumerShard = shardTotal > 1; if (!isConsumerShard || !File.Exists(distIndex)) { - stage = "npm run build"; - RunNpm("run build", _docsRoot); + stage = "vitepress build"; + RunVitepressBuild(_docsRoot); } // Install the chromium binary the Playwright .NET binding drives. @@ -809,6 +822,76 @@ private static void StopPreviewServer(Process? proc) // ─── npm bootstrap ─────────────────────────────────────────────────────── + /// + /// Invoke the local vitepress binary directly against the docs root, + /// bypassing the package.json prebuild hook that + /// npm run build would trigger. The prebuild step runs + /// docs/scripts/generate-api-ref.sh, which does a + /// dotnet build -c Debug --no-incremental sweep across the + /// entire library, agent, adapter and module surface; that sweep + /// rewrites every touched project's obj/project.assets.json + /// to a Debug-only net8.0 view and races any in-flight + /// multi-TFM Release build (NETSDK1005 on net47, + /// net9.0, net10.0, …). This helper resolves + /// node_modules/vitepress/bin/vitepress.js relative to the + /// docs root, drains stdout+stderr concurrently to avoid the + /// classic pipe-deadlock pattern, and rethrows with the captured + /// output when the child exits non-zero. + /// + /// + /// Absolute path to the docs site (docs/ under the repo + /// root); becomes the child process's working directory and the + /// anchor for the node_modules lookup. + /// + /// + /// Thrown when the vitepress binary cannot be located, + /// returns + /// , or the child process exits with a + /// non-zero code (the captured stdout and stderr are appended to + /// the exception message for diagnosis). + /// + private static void RunVitepressBuild(string docsRoot) + { + var vitepressEntry = Path.Combine(docsRoot, "node_modules", "vitepress", "bin", "vitepress.js"); + if (!File.Exists(vitepressEntry)) + { + throw new InvalidOperationException( + $"Cannot invoke vitepress build directly — expected entry point at '{vitepressEntry}' does not exist. Run `npm ci` under {docsRoot} first (the OneTimeSetUp does this when node_modules is missing)."); + } + + var psi = new ProcessStartInfo + { + FileName = "node", + WorkingDirectory = docsRoot, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + // Mirror package.json's `build` script memory budget — vitepress + // build's Vue SSR pass can exceed V8's default 2 GB old-space + // when the source tree is thousands of pages. + psi.ArgumentList.Add("--max-old-space-size=8192"); + psi.ArgumentList.Add(vitepressEntry); + psi.ArgumentList.Add("build"); + + var proc = Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start `node … vitepress build` process"); + + var stdoutTask = proc.StandardOutput.ReadToEndAsync(); + var stderrTask = proc.StandardError.ReadToEndAsync(); + Task.WaitAll(stdoutTask, stderrTask); + var stdout = stdoutTask.Result; + var stderr = stderrTask.Result; + proc.WaitForExit(); + + if (proc.ExitCode != 0) + { + throw new InvalidOperationException( + $"`node … vitepress.js build` exited {proc.ExitCode}{Environment.NewLine}stdout:{Environment.NewLine}{stdout}{Environment.NewLine}stderr:{Environment.NewLine}{stderr}"); + } + } + private static void RunNpm(string arguments, string workingDirectory) { var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); From f123d1be5c25504ec57c07d42cfe49a4e18cc5f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 19:45:13 +0200 Subject: [PATCH 3/3] fix(docs-tests): bound vitepress build + dispose Process + sync docs Convergent MEDIUM findings from the PR #227 6-agent Ultrareview (F-SEC-001, F-SEC-002, F-IMP-001, F-CR-001) around the new RunVitepressBuild helper and MEDIUM/HIGH stale-doc-reference findings (F-DOC-001..006) around the producer/consumer mode shift. Deferred MEDIUM items (node preflight, dist-lock, bounded stdout, RunProcess helper, predicate/failure-path coverage) tracked in #238 with concrete sketches so each lands as its own scoped PR. RunVitepressBuild fixture-side: * VitepressBuildTimeoutMs = 20 min bounds the child; on expiry the process tree is killed, drained partials captured, and an InvalidOperationException surfaces the diagnostic rather than a wall-clock CI timeout that discards it. * "using" on the Process handle guarantees OS handle + pipe release on every path (previously leaked on drain/wait/exit-code throws; warm CI runners accumulated handles across reruns). * XML doc block updated to enumerate the timeout arm alongside the pre-existing missing-entry and non-zero-exit arms. Stale doc sync (atomic bug-class fix): * tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs class summary, ServerReadyTimeoutMs summary, and OneTimeSetUp_Rebuilds_Dist remarks: replace "npm ci + npm run build" and the sentinel-only invariant with the new producer-always-rebuild + consumer-shard contract and the direct-vitepress-invocation rationale. * docs/development/docs-site.md: rewrite the end-to-end route-check section (lines 64, 72) to explain producer vs. consumer mode and why the fixture bypasses npm run build. * .github/workflows/dotnet.yml: expand the docs-prepare header comment to document the fixture's two-mode contract so the next maintainer editing the workflow does not re-encode the old sentinel-only invariant. Verification (bluefin, verify/pr227 worktree): * dotnet build MTConnect.NET.sln -p:IntegrationCoverage=true -> 0 warnings, 0 errors, 5s. * dotnet test tests/MTConnect.NET-Docs-Tests --no-build -> 72/72 passed, 57s. * dotnet test MTConnect.NET.sln --no-build -> 5,033/5,033 passed across every test project. * node --max-old-space-size=8192 node_modules/vitepress/bin/vitepress.js build -> build complete in 20.41s. Refs: TrakHound/MTConnect.NET#238 --- .github/workflows/dotnet.yml | 15 ++- docs/development/docs-site.md | 4 +- .../RouteCheckTests.cs | 92 +++++++++++++++---- 3 files changed, 85 insertions(+), 26 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 8793ee637..ebbf9b3f0 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -328,10 +328,17 @@ jobs: # ref.sh` → `docfx metadata`). The built dist tree is uploaded as a # workflow artifact so the sharded route-check job below can skip # the npm + docfx + build wall-clock (~5 min) entirely. The test - # fixture's [OneTimeSetUp] checks for docs/.vitepress/dist/index.html - # and skips the `npm ci && npm run build` bootstrap when the file is - # present, so a shard that downloads the artifact into the right - # path bypasses the bootstrap altogether. + # fixture's [OneTimeSetUp] contract has two modes: + # - Consumer mode (ROUTE_SHARD_TOTAL > 1, i.e. the sharded matrix + # leg below): checks for docs/.vitepress/dist/index.html and + # skips the vitepress build bootstrap when the sentinel is + # present. A shard that downloads this job's artifact into the + # right path bypasses the bootstrap altogether. + # - Producer mode (ROUTE_SHARD_TOTAL <= 1, i.e. local + unsharded + # CI): always rebuilds by invoking `vitepress build` directly + # (bypassing the `prebuild` docfx hook so the shard runners' + # missing docfx is a non-issue) so a warm-cache developer run + # still walks a dist tree generated from the CURRENT source. # ------------------------------------------------------------------ docs-prepare: name: docs-prepare diff --git a/docs/development/docs-site.md b/docs/development/docs-site.md index bc88dc565..61801b7af 100644 --- a/docs/development/docs-site.md +++ b/docs/development/docs-site.md @@ -61,7 +61,7 @@ The classic symptom of a base mismatch is a deployed page that renders as raw HT ## End-to-end route check -`tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs` is a Playwright e2e fixture that builds the docs site, spawns `vitepress preview` against the built `dist/` tree, walks every route the markdown source tree implies in a headless Chromium browser, and asserts no client-side 404s. CI runs it on the `ubuntu-latest` matrix leg of `.github/workflows/dotnet.yml` (the `windows-latest` leg filters `Category=E2E` out — hosted Windows runners do not carry Linux-image Docker, and the test fixture's `npm ci && npm run build` bootstrap is the easier target to keep Linux-only). +`tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs` is a Playwright e2e fixture that builds the docs site, spawns `vitepress preview` against the built `dist/` tree, walks every route the markdown source tree implies in a headless Chromium browser, and asserts no client-side 404s. CI runs it on the `ubuntu-latest` matrix leg of `.github/workflows/dotnet.yml` (the `windows-latest` leg filters `Category=E2E` out — hosted Windows runners do not carry Linux-image Docker, and the test fixture's `npm ci` + direct `vitepress build` bootstrap is the easier target to keep Linux-only). Run locally from the repo root: @@ -69,7 +69,7 @@ Run locally from the repo root: dotnet test tests/MTConnect.NET-Docs-Tests --filter Category=E2E ``` -On the first run the fixture installs the chromium binary the Playwright .NET binding drives (~150 MB; cached on subsequent runs) and — if `docs/.vitepress/dist/` is missing — invokes `npm ci && npm run build` from `docs/` to produce a preview-able site. Subsequent runs reuse both, so a warm working tree completes in a couple of minutes; a cold checkout takes longer because the build artefact is rebuilt from scratch. +On the first run the fixture installs the chromium binary the Playwright .NET binding drives (~150 MB; cached on subsequent runs) and, in producer mode (local + unsharded CI, i.e. `ROUTE_SHARD_TOTAL <= 1`), invokes `npm ci` when `docs/node_modules/` is missing and then always invokes `vitepress build` directly from `docs/` — bypassing the `package.json` `prebuild` hook (`docs/scripts/generate-api-ref.sh` → `docfx metadata`) that would otherwise clobber every touched project's `obj/project.assets.json` back to a Debug-only `net8.0` view and race any in-flight multi-TFM Release build. In consumer mode (sharded CI matrix with `ROUTE_SHARD_TOTAL > 1`), the shard downloads a `dist/` tree from the `docs-prepare` workflow artifact and honours the `docs/.vitepress/dist/index.html` sentinel, skipping the rebuild. Subsequent local runs reuse the cached `node_modules/` and Playwright chromium, so a warm producer-mode run completes in a couple of minutes; a cold checkout takes longer because the build artefact is rebuilt from scratch. Failure output names every route that surfaced as a 404 along with which of the two signals fired—the `.NotFound` element rendered by the VitePress default theme's NotFound component, or `document.title` starting with `404` (the static `404.html` emits `404 | MTConnect.NET`, so a prefix match catches it regardless of the trailing site-title suffix). Typical fixes: diff --git a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs index b5bc927d3..3f95ad36e 100644 --- a/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs +++ b/tests/MTConnect.NET-Docs-Tests/RouteCheckTests.cs @@ -31,8 +31,13 @@ namespace MTConnect.NET_Docs_Tests; /// dotnet test tests/MTConnect.NET-Docs-Tests --filter Category=E2E /// /// Prerequisites: -/// - Node.js (the setup invokes `npm ci` + `npm run build` if the -/// docs/.vitepress/dist/ artifact is missing). +/// - Node.js. In producer mode (local + unsharded CI) the setup +/// runs `npm ci` when `node_modules/` is absent and then always +/// invokes `vitepress build` directly (bypassing the docfx +/// `prebuild` hook that would clobber `obj/project.assets.json`). +/// In consumer mode (`ROUTE_SHARD_TOTAL > 1`) the shard consumes +/// a `dist/` produced by the `docs-prepare` CI job and skips the +/// rebuild when `docs/.vitepress/dist/index.html` is present. /// - The Microsoft.Playwright package's chromium browser binary /// (installed automatically by the fixture's one-time setup). /// @@ -54,13 +59,23 @@ public class RouteCheckTests private const int ServerReadyPollMs = 200; /// Hard deadline for the preview-server bind. 60 s - /// accommodates a cold CI runner where `npm ci` + `npm run build` + /// accommodates a cold CI runner where `npm ci` + `vitepress build` /// + vitepress startup land before the first port probe — anything /// past that is a real failure (dist/ missing, port collision, /// vitepress CLI usage error) worth surfacing as a TimeoutException /// with the drained startup log. private const int ServerReadyTimeoutMs = 60_000; + /// Hard deadline for the vitepress build spawned by + /// . 20 minutes bounds the worst + /// documented cold path (cold node_modules cache + full SSR pass + /// on a slow runner completes in ~5 min); anything past that + /// implies a hang (deadlocked worker, HMR loop, wedged fetch) + /// worth surfacing as an InvalidOperationException with the + /// drained output rather than a wall-clock CI timeout that + /// discards the diagnostic. + private const int VitepressBuildTimeoutMs = 20 * 60 * 1000; + /// Per-page navigation timeout. 30 s covers a slow runner /// with a cold network cache; anything past that is a real failure /// (vitepress hang, JS exception that prevents Load) worth failing @@ -144,7 +159,7 @@ public async Task OneTimeSetUp() // // Consumer mode (sharded CI matrix, ROUTE_SHARD_TOTAL > 1): // CI's sharded route-check jobs download the dist/ tree - // from the `docs-prepare` workflow artefact and skip the + // from the `docs-prepare` workflow artifact and skip the // rebuild. The docs-prepare job is the single docfx-owning // producer. // @@ -500,15 +515,15 @@ public async Task Landing_Hero_Image_Asset_Resolves() /// /// /// Sharded CI runs (matrix env var ROUTE_SHARD_TOTAL > 1) - /// download the dist artefact from the upstream docs-prepare - /// job and intentionally bypass the in-fixture build — the shard - /// runners do not install docfx, so re-running npm run build - /// would fail on the prebuild hook - /// (scripts/generate-api-ref.shdocfx metadata). In - /// that mode the upstream job is the producer and this fixture is a - /// pure consumer, so the mtime invariant does not apply and the test - /// is inconclusive. Local invocations and the unsharded leg still - /// enforce it. + /// download the dist artifact from the upstream docs-prepare + /// job and intentionally bypass the in-fixture build — the + /// docs-prepare job is the single docfx-owning producer + /// and each shard is a pure consumer, so the mtime invariant does + /// not apply and the test is inconclusive. Local invocations and + /// the unsharded CI leg still enforce it (the producer path + /// invokes vitepress build directly, bypassing the + /// package.json prebuild hook so the shard runners' + /// missing docfx binary is a non-issue for the fixture itself). /// [Test] [Category("E2E")] @@ -835,8 +850,13 @@ private static void StopPreviewServer(Process? proc) /// net9.0, net10.0, …). This helper resolves /// node_modules/vitepress/bin/vitepress.js relative to the /// docs root, drains stdout+stderr concurrently to avoid the - /// classic pipe-deadlock pattern, and rethrows with the captured - /// output when the child exits non-zero. + /// classic pipe-deadlock pattern, bounds the child by + /// so a wedged worker + /// surfaces as an actionable exception rather than a job-level + /// timeout that discards the diagnostic, and rethrows with the + /// captured output when the child exits non-zero. The + /// handle is disposed on every path so a + /// warm test-runner does not leak file descriptors across reruns. /// /// /// Absolute path to the docs site (docs/ under the repo @@ -846,9 +866,12 @@ private static void StopPreviewServer(Process? proc) /// /// Thrown when the vitepress binary cannot be located, /// returns - /// , or the child process exits with a - /// non-zero code (the captured stdout and stderr are appended to - /// the exception message for diagnosis). + /// , the child fails to exit within + /// milliseconds (the child + /// tree is killed before the exception is thrown), or the child + /// process exits with a non-zero code. The captured stdout and + /// stderr are appended to the exception message in every failure + /// mode for diagnosis. /// private static void RunVitepressBuild(string docsRoot) { @@ -875,15 +898,44 @@ private static void RunVitepressBuild(string docsRoot) psi.ArgumentList.Add(vitepressEntry); psi.ArgumentList.Add("build"); - var proc = Process.Start(psi) + // `using` on Process guarantees the OS handle + redirected + // pipes are released even when the drain/wait/exit-code path + // throws — a warm test-runner otherwise accumulates handles + // and can starve pipes across reruns. + using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start `node … vitepress build` process"); var stdoutTask = proc.StandardOutput.ReadToEndAsync(); var stderrTask = proc.StandardError.ReadToEndAsync(); + + // Bound the child wait so a wedged vitepress (deadlocked + // Vue-SSR worker, hung fetch, infinite HMR loop) surfaces as + // an actionable exception with the drained partial output + // rather than a wall-clock CI timeout that discards it. + if (!proc.WaitForExit(VitepressBuildTimeoutMs)) + { + try + { + proc.Kill(entireProcessTree: true); + } + catch + { + // Best-effort — the child may already be exiting; a + // failure to signal is not itself the diagnostic. + } + // Give the drains one last chance to complete after the + // kill; ignore any fault so the timeout message is what + // the caller sees. + try { Task.WaitAll(new[] { stdoutTask, stderrTask }, millisecondsTimeout: 2_000); } catch { } + var partialStdout = stdoutTask.IsCompletedSuccessfully ? stdoutTask.Result : ""; + var partialStderr = stderrTask.IsCompletedSuccessfully ? stderrTask.Result : ""; + throw new InvalidOperationException( + $"`node … vitepress.js build` did not exit within {VitepressBuildTimeoutMs} ms — killed the child tree and captured what stdout/stderr had been drained.{Environment.NewLine}stdout:{Environment.NewLine}{partialStdout}{Environment.NewLine}stderr:{Environment.NewLine}{partialStderr}"); + } + Task.WaitAll(stdoutTask, stderrTask); var stdout = stdoutTask.Result; var stderr = stderrTask.Result; - proc.WaitForExit(); if (proc.ExitCode != 0) {