fix(FN-765): harden dashboard startup probe and document Bun standalone limitation

- Harden dashboard startup probe to handle exit race condition in build-exe tests
- Add STANDALONE.md documenting Bun's lack of node:sqlite support in standalone builds
- Refactor build-exe tests with improved wait/retry logic for process lifecycle
- Reduce flakiness in standalone executable integration tests
This commit is contained in:
gsxdsm
2026-04-02 22:41:42 -07:00
parent ca7c2ca0ec
commit 63d5cdbce4
2 changed files with 104 additions and 56 deletions

View File

@@ -160,3 +160,19 @@ When the dashboard starts from a Bun-compiled binary, it attempts to set up nati
If all resolution methods fail, terminal creation gracefully returns `null`, which the HTTP layer converts to a 503 Service Unavailable response.
**Cross-compilation:** Native assets are staged per-platform during build. When cross-compiling, only the target platform's assets are included. PTY functionality requires running on a platform with matching native assets.
### Known Bun `node:sqlite` Limitation
Bun-compiled standalone binaries may encounter a `No such built-in module: node:sqlite` error at startup. This happens because Bun's compiler does not include the full `node:sqlite` built-in module in all compilation targets.
**Impact:** When this error occurs, the binary exits immediately. This affects any command that initializes the SQLite-backed task store, including `dashboard`, `task list`, and `task create`. Commands that don't need the store (like `--help`) continue to work.
**Detection:** The startup validation test suite treats this specific error as an expected limitation — it is not misinterpreted as a generic dashboard startup failure. The test probe distinguishes between:
| Outcome | Behavior |
|---------|----------|
| Startup banner detected | Full test proceeds (PTY endpoint verification) |
| `node:sqlite` error in output | Test skips cleanly (known Bun limitation) |
| Other early exit | Test fails with diagnostic output |
Only the exact `node:sqlite` built-in module error is handled specially. Any other exit or crash during startup is treated as a real regression and fails the test with full process output for debugging.

View File

@@ -110,69 +110,101 @@ describe("build-exe", () => {
cwd: dir,
stdio: ["ignore", "pipe", "pipe"],
});
// Wait for server to be ready
if (!child.stdout || !child.stderr) {
throw new Error("Dashboard process stdio was not piped");
}
// ── Deterministic startup probe ────────────────────────────────
//
// Three possible outcomes:
// "ready" — startup banner detected, proceed to PTY test
// "sqlite-unsupported" — known Bun node:sqlite limitation, skip test
// (reject) — unexpected early exit, fail with diagnostics
//
// Uses 'close' (not 'exit') to guarantee all stdio data has been
// consumed before evaluating the outcome. This prevents the race
// where exit fires before stderr delivers the sqlite error message.
//
let startupOutput = "";
let sqliteUnsupported = false;
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
child!.kill("SIGTERM");
reject(new Error(`Server startup timeout\n${startupOutput}`));
}, 10_000);
let settled = false;
const onStdout = (d: Buffer) => {
startupOutput += d.toString();
if (startupOutput.includes("kb board") && startupOutput.includes(`→ http://localhost:${port}`)) {
clearTimeout(timeout);
resolve();
}
};
const outcome = await new Promise<"ready" | "sqlite-unsupported">(
(resolve, reject) => {
const SQLITE_ERROR = "No such built-in module: node:sqlite";
const onStderr = (d: Buffer) => {
startupOutput += d.toString();
if (startupOutput.includes("No such built-in module: node:sqlite")) {
sqliteUnsupported = true;
clearTimeout(timeout);
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
child!.kill("SIGTERM");
resolve();
}
};
reject(
new Error(`Server startup timeout\nOutput:\n${startupOutput}`),
);
}, 10_000);
if (!child) {
clearTimeout(timeout);
reject(new Error("Dashboard process failed to start"));
return;
}
if (!child.stdout || !child.stderr) {
clearTimeout(timeout);
reject(new Error("Dashboard process stdio was not piped"));
return;
}
child.stdout.on("data", onStdout);
child.stderr.on("data", onStderr);
child.on("error", reject);
child.on("exit", () => {
if (startupOutput.includes("No such built-in module: node:sqlite")) {
sqliteUnsupported = true;
const settle = (
result: "ready" | "sqlite-unsupported" | Error,
) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve();
return;
}
if (!startupOutput.includes("kb board")) {
clearTimeout(timeout);
reject(new Error(`Dashboard exited before becoming ready\n${startupOutput}`));
}
});
});
if (result instanceof Error) {
reject(result);
} else if (result === "sqlite-unsupported") {
child!.kill("SIGTERM");
resolve(result);
} else {
resolve(result);
}
};
if (sqliteUnsupported || child.exitCode !== null) {
child!.stdout!.on("data", (d: Buffer) => {
startupOutput += d.toString();
if (
startupOutput.includes("kb board") &&
startupOutput.includes(`→ http://localhost:${port}`)
) {
settle("ready");
}
});
child!.stderr!.on("data", (d: Buffer) => {
startupOutput += d.toString();
if (startupOutput.includes(SQLITE_ERROR)) {
settle("sqlite-unsupported");
}
});
// 'close' fires after all stdio streams are drained, so
// startupOutput is guaranteed to be complete here.
child!.on("close", (code) => {
if (startupOutput.includes(SQLITE_ERROR)) {
settle("sqlite-unsupported");
return;
}
settle(
new Error(
`Dashboard exited unexpectedly (code=${code})\nOutput:\n${startupOutput}`,
),
);
});
child!.on("error", (err) => {
settle(
new Error(
`Dashboard process error: ${err.message}\nOutput:\n${startupOutput}`,
),
);
});
},
);
// Known Bun limitation: skip the rest of the test
if (outcome === "sqlite-unsupported") {
return;
}
// Test PTY session creation endpoint
// outcome === "ready" — verify PTY session creation endpoint
let response: Response | undefined;
let lastError: unknown;
for (let attempt = 0; attempt < 20; attempt++) {
@@ -196,10 +228,10 @@ describe("build-exe", () => {
return;
}
if (!codes.includes("ECONNREFUSED")) {
throw new Error(`PTY endpoint request failed after startup\n${startupOutput}\n${String(error)}`);
throw new Error(`PTY endpoint request failed after startup\n${String(error)}`);
}
if (child?.exitCode !== null) {
throw new Error(`Dashboard exited before PTY endpoint became available\n${startupOutput}`);
throw new Error(`Dashboard exited before PTY endpoint became available`);
}
await new Promise((r) => setTimeout(r, 100));
}
@@ -209,7 +241,7 @@ describe("build-exe", () => {
throw lastError;
}
if (!response) {
throw new Error(`PTY endpoint did not respond after retries\n${startupOutput}`);
throw new Error(`PTY endpoint did not respond after retries`);
}
// Accept either success (201) or service unavailable (503 when PTY not available)