fix: stale TRANSITIVE_EXTERNALS + dashboard dist/client clean + cross-worker build lock (round 12) (#2065)

## Summary

Fixes shard 3 failures from runs 29258546612 + 29259574946 (FN-7936
drift).

## Fixes

### `package-config.test.ts` — stale TRANSITIVE_EXTERNALS entry
FN-7936 aliased `@fusion/core` to a runtime shim in bundled plugin
outputs; it's no longer a tsup external. Removed the stale allowlist
entry.

### `bundle-output.test.ts` — stale dashboard client hash ENOENT
**Root cause:** Two test files (`bundle-output.test.ts` +
`extension-integration.test.ts`) call
`buildCliWithRealDashboardAssets()` which triggers concurrent vite/tsup
builds. Vitest runs them in parallel (`pool: "forks"`, `fileParallelism:
true`). Without coordination, two builds clean and write `dist/client`
simultaneously, causing `ENOENT` on content-hashed chunk files.

**Fix (3 parts):**
1. **`workspace-tools.ts buildDashboardClient`** — `rm dist/client`
before vite build. Prevents stale content-hash references from previous
builds.
2. **`bundle-output-helpers.ts`** — atomic `mkdirSync` file lock around
`buildCliWithRealDashboardAssets()`. Winner builds; losers poll with
`Atomics.wait`, then re-check `hasBuiltDashboardAssets()`. On timeout,
**throws** (never builds without owning the lock).
3. Lock uses `Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0,
0, 500)` for sync sleep — no child process spawning.

## Verification
- Gate: exit 0 ✅

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved dashboard asset builds by removing stale files before
rebuilding.
* Prevented concurrent builds from producing incomplete or corrupted
dashboard assets.
* Added safeguards to detect stalled asset builds and fail with clearer
errors.

* **Tests**
* Updated package validation checks to reflect current runtime bundling
behavior.
  * Improved reliability of CLI build-related test execution.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-07-13 08:14:20 -07:00
committed by GitHub
parent d4001ab0ee
commit c3c3861efa
3 changed files with 72 additions and 12 deletions

View File

@@ -1,6 +1,7 @@
import { execFileSync, execSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
export const cliRoot = join(__dirname, "..", "..");
export const workspaceRoot = join(cliRoot, "..", "..");
@@ -61,24 +62,74 @@ export function hasBuiltDashboardAssets(): boolean {
/**
* This suite verifies real copied dashboard client assets in CLI dist output.
* It must build those assets explicitly instead of skip-gating on ambient dist/.
*
* FNXC:TestInfrastructure 2026-07-13-12:20:
* bundle-output.test.ts and extension-integration.test.ts both call this helper,
* and Vitest runs test files in parallel (pool: "forks", fileParallelism: true).
* Without a cross-worker lock, two workers can simultaneously trigger vite/tsup
* builds that clean and write dist/client concurrently, causing ENOENT on
* content-hashed chunk files. The lock uses atomic mkdirSync — the winner builds,
* losers poll until the lock disappears then re-check hasBuiltDashboardAssets().
*/
const buildLockDir = join(tmpdir(), "fusion-cli-build-assets.lock");
const BUILD_LOCK_TIMEOUT_MS = 300_000;
export function buildCliWithRealDashboardAssets() {
if (hasBuiltDashboardAssets()) {
return;
}
runBuildCommand(`node ${join(workspaceRoot, "scripts", "ensure-test-artifacts.mjs")}`, workspaceRoot);
runBuildCommand("pnpm --filter @fusion/dashboard build:client", workspaceRoot);
runBuildCommand("pnpm build", cliRoot);
if (hasBuiltDashboardAssets()) {
return;
// Try to acquire the lock atomically. mkdirSync throws EEXIST if the dir exists.
let acquiredLock = false;
try {
mkdirSync(buildLockDir);
acquiredLock = true;
} catch {
// Another worker holds the lock — wait for it.
}
// Fallback for environments where build:client alone does not refresh the
// dashboard dist/client bundle consumed by the CLI copy step.
runBuildCommand("pnpm --filter @fusion/dashboard build", workspaceRoot);
runBuildCommand("pnpm build", cliRoot);
if (!acquiredLock) {
const deadline = Date.now() + BUILD_LOCK_TIMEOUT_MS;
while (existsSync(buildLockDir) && Date.now() < deadline) {
// Synchronous sleep without spawning a child process.
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
}
// The other worker should have finished building. Re-check assets.
if (hasBuiltDashboardAssets()) {
return;
}
// Lock is still held after timeout — do NOT build without owning the lock
// (that reintroduces the concurrent-build race). Throw so the test fails loudly.
if (existsSync(buildLockDir)) {
throw new Error(
`buildCliWithRealDashboardAssets: timed out after ${BUILD_LOCK_TIMEOUT_MS}ms waiting for another worker's build lock at ${buildLockDir}. ` +
`If the other worker crashed, remove the lock dir manually and rerun.`,
);
}
// Lock disappeared but assets weren't built — try to acquire for our own build.
try { mkdirSync(buildLockDir); acquiredLock = true; } catch {
throw new Error(`buildCliWithRealDashboardAssets: could not acquire build lock at ${buildLockDir} after previous holder exited.`);
}
}
try {
runBuildCommand(`node ${join(workspaceRoot, "scripts", "ensure-test-artifacts.mjs")}`, workspaceRoot);
runBuildCommand("pnpm --filter @fusion/dashboard build:client", workspaceRoot);
runBuildCommand("pnpm build", cliRoot);
if (hasBuiltDashboardAssets()) {
return;
}
// Fallback for environments where build:client alone does not refresh the
// dashboard dist/client bundle consumed by the CLI copy step.
runBuildCommand("pnpm --filter @fusion/dashboard build", workspaceRoot);
runBuildCommand("pnpm build", cliRoot);
} finally {
if (acquiredLock) {
rmSync(buildLockDir, { recursive: true, force: true });
}
}
}
export function readClientIndexHtml() {

View File

@@ -220,7 +220,8 @@ describe("CLI package.json publishing config", () => {
"cpu-features": "transitive dep of dockerode (via ssh2)",
"@homebridge/node-pty-prebuilt-multiarch":
"aliased as node-pty in dependencies; the alias entry satisfies the import",
"@fusion/core": "plugin-entry bundling external only; not a runtime dep of the CLI bin",
// FNXC:BuildConfig 2026-07-13-12:00: FN-7936 aliased @fusion/core to a runtime shim in bundled plugin outputs; it's no longer a tsup external, so this allowlist entry is stale.
// "@fusion/core": REMOVED — was "plugin-entry bundling external only; not a runtime dep of the CLI bin",
"@fusion/engine": "plugin-entry bundling external only; not a runtime dep of the CLI bin",
};

View File

@@ -168,6 +168,14 @@ export async function stageDesktopDeploy(): Promise<void> {
}
export async function buildDashboardClient(): Promise<void> {
// FNXC:DesktopBuild 2026-07-13-12:15:
// Clean the dashboard client output directory before building. Vite generates
// content-hashed chunk filenames; if a previous build left stale assets, the
// rollup write queue can hit ENOENT on stale-hashed paths. A clean outDir
// ensures the new build owns all filenames deterministically.
const clientDir = resolve(workspaceRoot, "packages", "dashboard", "dist", "client");
await rm(clientDir, { recursive: true, force: true });
// Desktop loads index.html via file:// from inside the asar, so absolute
// asset paths (/assets/...) resolve to the filesystem root and fail. Build
// with a relative base so the bundled HTML references ./assets/... instead.