Skip to content

Commit 52d3c19

Browse files
committed
fix(desktop): harden smoke result persistence
1 parent 3fdb5fd commit 52d3c19

4 files changed

Lines changed: 124 additions & 21 deletions

File tree

.agents/session-log.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,4 +385,5 @@
385385
- 2026-04-08: Updated `.agents/workflow.md` so chat ownership is now a constant repo rule. The workflow file now defines stable Engine versus Desktop/UI ownership, cross-boundary stop rules, branch and dirty-tree preflight checks, and a requirement to record the validation actually run.
386386
- 2026-04-08: Started issue #214 renderer tab binding registry hardening. This slice is limited to `desktop/renderer/app.js` plus `.agents` continuity, and replaces the long `bindTabContentEvents` conditional chain with a local handler registry without changing renderer behavior.
387387
- 2026-04-08: Completed issue #214 renderer tab binding registry hardening. `bindTabContentEvents` now dispatches through a local `TAB_CONTENT_EVENT_BINDERS` registry in `desktop/renderer/app.js`, and `npm run smoke` still passes via local runs fallback.
388+
- 2026-04-10: Started issue #346 to harden `desktop-smoke` result persistence after CI repeatedly failed with raw `ENOENT` on missing `result.json` in a planning-only PR. The slice is limited to `desktop/main.js`, `desktop/scripts/smoke.js`, and `.agents` continuity so smoke emits structured failures instead of crashing when Electron exits too early.
388389
- 2026-04-10: Added the desktop layout regression remediation block after reviewing the post-merge desktop state in real screenshots. Opened issues #342, #343, and #344 to target empty-pane collapse, stronger active-surface focus and context containment, and better runs-family density plus right-rail space budgeting without reopening core or `research_ui` scope.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Issue #346 — Desktop smoke result persistence in CI
2+
3+
## Goal
4+
Prevent `desktop-smoke` from failing with a raw missing-file error before the smoke harness can read or emit a structured result.
5+
6+
---
7+
8+
## Why this matters
9+
Planning-only Desktop/UI PRs are being blocked by a smoke harness failure that occurs before `result.json` exists. The harness needs a reliable failure path even when Electron exits before the normal smoke callback completes.
10+
11+
---
12+
13+
## Scope
14+
15+
### In scope
16+
- smoke result persistence
17+
- early-exit and renderer-failure handling for smoke runs
18+
- minimal harness hardening in `desktop/scripts/smoke.js`
19+
20+
### Out of scope
21+
- renderer UI changes
22+
- `research_ui` changes
23+
- core, broker, CLI, or CI workflow edits
24+
25+
---
26+
27+
## Relevant files
28+
29+
- `desktop/main.js`
30+
- `desktop/scripts/smoke.js`
31+
32+
---
33+
34+
## Expected deliverable
35+
36+
A smoke harness that always produces a structured result or a clear structured failure message instead of crashing with `ENOENT` on missing `result.json`.
37+
38+
---
39+
40+
## Done when
41+
42+
- CI no longer fails with raw `ENOENT` for missing smoke output
43+
- smoke runs persist a result even when Electron exits too early
44+
- local smoke validation still passes for normal fallback and real-path cases

desktop/main.js

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ const ELECTRON_STATE_ROOT = path.join(DESKTOP_OUTPUTS_ROOT, "electron");
2828
const IS_SMOKE_RUN = process.env.QUANTLAB_DESKTOP_SMOKE === "1";
2929
const SMOKE_OUTPUT_PATH = process.env.QUANTLAB_DESKTOP_SMOKE_OUTPUT || "";
3030
const SKIP_RESEARCH_UI_BOOT = process.env.QUANTLAB_DESKTOP_DISABLE_SERVER_BOOT === "1";
31+
let smokeResultPersisted = false;
32+
let smokeDidFinishLoadSeen = false;
3133

3234
app.setPath("userData", path.join(ELECTRON_STATE_ROOT, "user-data"));
3335
app.setPath("cache", path.join(ELECTRON_STATE_ROOT, "cache"));
@@ -830,16 +832,43 @@ function createMainWindow() {
830832
});
831833
}
832834

833-
async function runDesktopSmoke() {
834-
const result = {
835+
function defaultSmokeResult(overrides = {}) {
836+
return {
835837
bridgeReady: false,
836838
shellReady: false,
837839
serverReady: false,
838840
apiReady: false,
839841
localRunsReady: false,
840842
serverUrl: "",
841843
error: "",
844+
...overrides,
842845
};
846+
}
847+
848+
async function persistSmokeResult(resultOverrides = {}) {
849+
if (!SMOKE_OUTPUT_PATH || smokeResultPersisted) return;
850+
const result = defaultSmokeResult(resultOverrides);
851+
await fsp.mkdir(path.dirname(SMOKE_OUTPUT_PATH), { recursive: true });
852+
await fsp.writeFile(SMOKE_OUTPUT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
853+
smokeResultPersisted = true;
854+
}
855+
856+
async function failSmokeAndQuit(errorMessage, overrides = {}) {
857+
try {
858+
await persistSmokeResult({
859+
error: errorMessage,
860+
...overrides,
861+
});
862+
} catch (_error) {
863+
// Best effort persistence; the outer smoke harness also handles missing files.
864+
} finally {
865+
process.exitCode = 1;
866+
app.quit();
867+
}
868+
}
869+
870+
async function runDesktopSmoke() {
871+
const result = defaultSmokeResult();
843872
try {
844873
result.bridgeReady = await mainWindow.webContents.executeJavaScript(
845874
"Boolean(window.quantlabDesktop && typeof window.quantlabDesktop.getWorkspaceState === 'function')",
@@ -871,10 +900,7 @@ async function runDesktopSmoke() {
871900
result.error = error.message;
872901
}
873902

874-
if (SMOKE_OUTPUT_PATH) {
875-
await fsp.mkdir(path.dirname(SMOKE_OUTPUT_PATH), { recursive: true });
876-
await fsp.writeFile(SMOKE_OUTPUT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
877-
}
903+
await persistSmokeResult(result);
878904

879905
if (!result.bridgeReady || !result.shellReady) {
880906
process.exitCode = 1;
@@ -993,19 +1019,16 @@ if (singleInstanceLock) {
9931019
createMainWindow();
9941020
if (!SKIP_RESEARCH_UI_BOOT) startResearchUiServer();
9951021
if (IS_SMOKE_RUN) {
1022+
mainWindow.webContents.once("did-fail-load", (_event, errorCode, errorDescription) => {
1023+
failSmokeAndQuit(`Desktop smoke load failed: ${errorDescription || `code ${errorCode}`}`);
1024+
});
1025+
mainWindow.webContents.once("render-process-gone", (_event, details) => {
1026+
failSmokeAndQuit(`Desktop smoke renderer exited before completion: ${details?.reason || "unknown"}`);
1027+
});
9961028
mainWindow.webContents.once("did-finish-load", () => {
1029+
smokeDidFinishLoadSeen = true;
9971030
runDesktopSmoke().catch((error) => {
998-
if (SMOKE_OUTPUT_PATH) {
999-
fsp.mkdir(path.dirname(SMOKE_OUTPUT_PATH), { recursive: true })
1000-
.then(() => fsp.writeFile(SMOKE_OUTPUT_PATH, `${JSON.stringify({ bridgeReady: false, shellReady: false, serverReady: false, apiReady: false, localRunsReady: false, error: error.message }, null, 2)}\n`, "utf8"))
1001-
.finally(() => {
1002-
process.exitCode = 1;
1003-
app.quit();
1004-
});
1005-
} else {
1006-
process.exitCode = 1;
1007-
app.quit();
1008-
}
1031+
failSmokeAndQuit(error.message);
10091032
});
10101033
});
10111034
}
@@ -1019,6 +1042,14 @@ if (singleInstanceLock) {
10191042
}
10201043

10211044
app.on("window-all-closed", () => {
1045+
if (IS_SMOKE_RUN && !smokeResultPersisted) {
1046+
failSmokeAndQuit(
1047+
smokeDidFinishLoadSeen
1048+
? "Desktop smoke window closed before the result was persisted."
1049+
: "Desktop smoke window closed before the renderer finished loading.",
1050+
);
1051+
return;
1052+
}
10221053
if (process.platform !== "darwin") {
10231054
app.quit();
10241055
}
@@ -1027,3 +1058,14 @@ app.on("window-all-closed", () => {
10271058
app.on("before-quit", () => {
10281059
stopResearchUiServer();
10291060
});
1061+
1062+
process.on("uncaughtException", (error) => {
1063+
if (!IS_SMOKE_RUN) throw error;
1064+
failSmokeAndQuit(`Desktop smoke uncaught exception: ${error?.message || String(error)}`);
1065+
});
1066+
1067+
process.on("unhandledRejection", (reason) => {
1068+
if (!IS_SMOKE_RUN) throw reason;
1069+
const message = reason instanceof Error ? reason.message : String(reason);
1070+
failSmokeAndQuit(`Desktop smoke unhandled rejection: ${message}`);
1071+
});

desktop/scripts/smoke.js

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ async function main() {
77
const desktopRoot = path.resolve(__dirname, "..");
88
const projectRoot = path.resolve(desktopRoot, "..");
99
const electronBinary = require("electron");
10+
const electronArgs = process.platform === "linux" ? ["--no-sandbox", "."] : ["."];
1011
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "quantlab-desktop-smoke-"));
1112
const outputPath = path.join(tempRoot, "result.json");
1213
const desktopOutputsRoot = path.join(tempRoot, "outputs");
@@ -75,7 +76,7 @@ async function main() {
7576
}
7677

7778
try {
78-
const child = spawn(electronBinary, ["."], {
79+
const child = spawn(electronBinary, electronArgs, {
7980
cwd: desktopRoot,
8081
windowsHide: true,
8182
stdio: ["ignore", "pipe", "pipe"],
@@ -105,9 +106,24 @@ async function main() {
105106
child.on("exit", (code) => resolve(code ?? 1));
106107
});
107108
clearTimeout(timeout);
108-
109-
const raw = await fs.readFile(outputPath, "utf8");
110-
const result = JSON.parse(raw);
109+
let result = null;
110+
for (let attempt = 0; attempt < 10; attempt += 1) {
111+
try {
112+
const raw = await fs.readFile(outputPath, "utf8");
113+
result = JSON.parse(raw);
114+
break;
115+
} catch (error) {
116+
if (error?.code !== "ENOENT") throw error;
117+
await new Promise((resolve) => setTimeout(resolve, 100));
118+
}
119+
}
120+
if (!result) {
121+
throw new Error(
122+
`Desktop smoke did not persist result.json before Electron exited (code ${exitCode}).`
123+
+ `${stdout.trim() ? ` stdout: ${stdout.trim()}` : ""}`
124+
+ `${stderr.trim() ? ` stderr: ${stderr.trim()}` : ""}`,
125+
);
126+
}
111127

112128
if (exitCode !== 0 || !result.bridgeReady || !result.shellReady) {
113129
if (stdout.trim()) console.error(stdout.trim());

0 commit comments

Comments
 (0)