From f705ed2e93d4ad3a696d7b9bc61f3eef3ab6d12a Mon Sep 17 00:00:00 2001 From: Leticia Date: Tue, 31 Mar 2026 18:55:14 +0200 Subject: [PATCH] hardening(desktop): close remaining electron p1 gaps --- desktop/main.js | 144 ++++++++++++++++++++---------- desktop/renderer/app.js | 62 +++++++++++-- desktop/renderer/modules/utils.js | 35 ++++++++ 3 files changed, 186 insertions(+), 55 deletions(-) diff --git a/desktop/main.js b/desktop/main.js index e032f39..391b793 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -618,57 +618,39 @@ async function monitorResearchUiStartup() { } } -function resolvePythonCommand() { +function resolvePythonCandidates() { const isWindows = process.platform === "win32"; const localVenv = path.join(PROJECT_ROOT, ".venv", isWindows ? "Scripts" : "bin", isWindows ? "python.exe" : "python"); - if (localVenv && require("fs").existsSync(localVenv)) { - return localVenv; - } - return process.env.PYTHON || (isWindows ? "python" : "python3"); -} - -function extractServerUrl(line) { - const match = String(line).match(/URL:\s*(http:\/\/[^\s]+)/i); - return match ? match[1] : null; -} - -async function startResearchUiServer({ forceRestart = false } = {}) { - if (forceRestart) { - stopResearchUiServer({ force: true }); - } - - if (researchServerProcess) return; - - const existingUrl = await detectResearchUiServerUrl(); - if (existingUrl) { - researchServerOwned = false; - markResearchUiReady(existingUrl, "external"); - appendLog(`[startup] reusing existing research_ui server at ${existingUrl}`); - return; - } - - updateWorkspaceState({ - status: "starting", - serverUrl: null, - error: null, - source: "managed", + const candidates = [ + localVenv, + process.env.PYTHON || "", + isWindows ? "python" : "python3", + ] + .map((value) => String(value || "").trim()) + .filter(Boolean); + + return [...new Set(candidates)].filter((candidate) => { + if (!path.isAbsolute(candidate)) return true; + try { + fs.accessSync(candidate, fs.constants.R_OK); + return true; + } catch (_error) { + return false; + } }); - scheduleResearchStartupTimeout(); +} - researchServerProcess = spawn(resolvePythonCommand(), [SERVER_SCRIPT], { - cwd: PROJECT_ROOT, - windowsHide: true, - stdio: ["ignore", "pipe", "pipe"], - }); +function bindResearchUiProcess(processHandle, pythonCommand, candidates, candidateIndex) { + researchServerProcess = processHandle; researchServerOwned = true; - researchServerProcess.stdout.setEncoding("utf8"); - researchServerProcess.stderr.setEncoding("utf8"); + processHandle.stdout.setEncoding("utf8"); + processHandle.stderr.setEncoding("utf8"); monitorResearchUiStartup().catch((error) => { appendLog(`[startup-monitor-error] ${error.message}`); }); - researchServerProcess.stdout.on("data", (chunk) => { + processHandle.stdout.on("data", (chunk) => { String(chunk || "") .split(/\r?\n/) .forEach((line) => { @@ -678,7 +660,7 @@ async function startResearchUiServer({ forceRestart = false } = {}) { if (discoveredUrl) { isResearchUiReachable(discoveredUrl) .then((reachable) => { - if (!reachable || !researchServerProcess) return; + if (!reachable || !researchServerProcess || researchServerProcess !== processHandle) return; markResearchUiReady(discoveredUrl, "managed"); }) .catch(() => {}); @@ -686,7 +668,7 @@ async function startResearchUiServer({ forceRestart = false } = {}) { }); }); - researchServerProcess.stderr.on("data", (chunk) => { + processHandle.stderr.on("data", (chunk) => { String(chunk || "") .split(/\r?\n/) .forEach((line) => { @@ -695,7 +677,8 @@ async function startResearchUiServer({ forceRestart = false } = {}) { }); }); - researchServerProcess.on("exit", (code, signal) => { + processHandle.on("exit", (code, signal) => { + if (researchServerProcess !== processHandle) return; researchServerProcess = null; researchServerOwned = false; clearResearchStartupTimer(); @@ -707,9 +690,20 @@ async function startResearchUiServer({ forceRestart = false } = {}) { }); }); - researchServerProcess.on("error", (error) => { - researchServerProcess = null; - researchServerOwned = false; + processHandle.on("error", (error) => { + if (researchServerProcess === processHandle) { + researchServerProcess = null; + researchServerOwned = false; + } + const shouldRetry = + ["EACCES", "EPERM", "ENOENT"].includes(error?.code || "") + && candidateIndex < candidates.length - 1; + if (shouldRetry) { + const nextCommand = candidates[candidateIndex + 1]; + appendLog(`[spawn-error] ${pythonCommand} failed (${error.code}). Retrying with ${nextCommand}.`); + launchResearchUiProcess(candidates, candidateIndex + 1); + return; + } clearResearchStartupTimer(); updateWorkspaceState({ status: "error", @@ -721,6 +715,60 @@ async function startResearchUiServer({ forceRestart = false } = {}) { }); } +function launchResearchUiProcess(candidates, candidateIndex = 0) { + const pythonCommand = candidates[candidateIndex]; + appendLog(`[startup] launching research_ui with ${pythonCommand}`); + const child = spawn(pythonCommand, [SERVER_SCRIPT], { + cwd: PROJECT_ROOT, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + bindResearchUiProcess(child, pythonCommand, candidates, candidateIndex); +} + +function extractServerUrl(line) { + const match = String(line).match(/URL:\s*(http:\/\/[^\s]+)/i); + return match ? match[1] : null; +} + +async function startResearchUiServer({ forceRestart = false } = {}) { + if (forceRestart) { + stopResearchUiServer({ force: true }); + } + + if (researchServerProcess) return; + + const existingUrl = await detectResearchUiServerUrl(); + if (existingUrl) { + researchServerOwned = false; + markResearchUiReady(existingUrl, "external"); + appendLog(`[startup] reusing existing research_ui server at ${existingUrl}`); + return; + } + + updateWorkspaceState({ + status: "starting", + serverUrl: null, + error: null, + source: "managed", + }); + scheduleResearchStartupTimeout(); + + const pythonCandidates = resolvePythonCandidates(); + if (!pythonCandidates.length) { + clearResearchStartupTimer(); + updateWorkspaceState({ + status: "error", + serverUrl: null, + error: "No usable Python interpreter was found for research_ui startup.", + source: "managed", + }); + return; + } + + launchResearchUiProcess(pythonCandidates, 0); +} + function stopResearchUiServer({ force = false } = {}) { clearResearchStartupTimer(); if (!researchServerProcess || !researchServerOwned) return; @@ -754,7 +802,7 @@ function createMainWindow() { preload: path.join(DESKTOP_ROOT, "preload.js"), contextIsolation: true, nodeIntegration: false, - sandbox: false, + sandbox: true, }, }); diff --git a/desktop/renderer/app.js b/desktop/renderer/app.js index 709b83f..71ba61c 100644 --- a/desktop/renderer/app.js +++ b/desktop/renderer/app.js @@ -17,6 +17,7 @@ import { stripWrappingQuotes as stripQuotes, titleCase as titleCaseValue, toneClass as resolveToneClass, + LruCache, uniqueRunIds as dedupeRunIds, } from "./modules/utils.js"; import { @@ -51,8 +52,12 @@ const CONFIG = { maxSweepRows: 5, maxSweepDecisionCompare: 4, persistDebounceMs: 400, + maxDetailCacheEntries: 50, + maxConsecutiveRefreshErrors: 3, }; +let unsubscribeWorkspaceState = null; + const state = { workspace: { status: "starting", serverUrl: null, logs: [], error: null }, snapshot: null, @@ -61,14 +66,20 @@ const state = { sweepDecisionStore: defaultSweepDecisionStore(), sweepDecisionLoaded: false, selectedRunIds: [], - detailCache: new Map(), + detailCache: new LruCache(CONFIG.maxDetailCacheEntries), experimentsWorkspace: { status: "idle", configs: [], sweeps: [], error: null, updatedAt: null }, experimentConfigPreviewCache: new Map(), isSubmittingLaunch: false, launchFeedback: "Use deterministic inputs or ask from chat.", refreshTimer: null, isStepbitSubmitting: false, - snapshotStatus: { status: "idle", error: null, lastSuccessAt: null }, + snapshotStatus: { + status: "idle", + error: null, + lastSuccessAt: null, + consecutiveErrors: 0, + refreshPaused: false, + }, isRetryingWorkspace: false, chatMessages: [ { @@ -177,7 +188,7 @@ document.addEventListener("DOMContentLoaded", async () => { state.workspace = initialState; renderWorkspaceState(); renderWorkflow(); - window.quantlabDesktop.onWorkspaceState((payload) => { + unsubscribeWorkspaceState = window.quantlabDesktop.onWorkspaceState((payload) => { state.workspace = payload; renderWorkspaceState(); if (payload.serverUrl) ensureRefreshLoop(); @@ -186,8 +197,12 @@ document.addEventListener("DOMContentLoaded", async () => { }); window.addEventListener("beforeunload", () => { - if (state.refreshTimer) window.clearInterval(state.refreshTimer); + stopRefreshLoop(); if (state.workspacePersistTimer) window.clearTimeout(state.workspacePersistTimer); + if (unsubscribeWorkspaceState) { + unsubscribeWorkspaceState(); + unsubscribeWorkspaceState = null; + } if (state.workspaceStoreLoaded) { window.quantlabDesktop.saveShellWorkspaceStore(serializeShellWorkspaceStore()).catch(() => {}); } @@ -296,12 +311,23 @@ function bindEvents() { } function ensureRefreshLoop() { + if (!state.workspace.serverUrl) return; refreshSnapshot(); if (!state.refreshTimer) state.refreshTimer = window.setInterval(refreshSnapshot, CONFIG.refreshIntervalMs); } +function stopRefreshLoop() { + if (state.refreshTimer) { + window.clearInterval(state.refreshTimer); + state.refreshTimer = null; + } +} + async function refreshSnapshot() { - if (!state.workspace.serverUrl) return; + if (!state.workspace.serverUrl) { + stopRefreshLoop(); + return; + } try { const runsRegistry = await window.quantlabDesktop.requestJson(CONFIG.runsIndexPath); state.detailCache.clear(); @@ -318,7 +344,13 @@ async function refreshSnapshot() { brokerHealth: extra[2].status === "fulfilled" ? extra[2].value : state.snapshot?.brokerHealth || null, stepbitWorkspace: extra[3].status === "fulfilled" ? extra[3].value : state.snapshot?.stepbitWorkspace || null, }; - state.snapshotStatus = { status: "ok", error: null, lastSuccessAt: new Date().toISOString() }; + state.snapshotStatus = { + status: "ok", + error: null, + lastSuccessAt: new Date().toISOString(), + consecutiveErrors: 0, + refreshPaused: false, + }; const validIds = new Set(getRuns().map((run) => run.run_id)); const filteredSelection = state.selectedRunIds.filter((runId) => validIds.has(runId)); if (filteredSelection.length !== state.selectedRunIds.length) { @@ -334,10 +366,15 @@ async function refreshSnapshot() { refreshExperimentsWorkspace({ focusTab: false, silent: true }); } } catch (error) { + const consecutiveErrors = (state.snapshotStatus.consecutiveErrors || 0) + 1; + const refreshPaused = consecutiveErrors >= CONFIG.maxConsecutiveRefreshErrors; + if (refreshPaused) stopRefreshLoop(); state.snapshotStatus = { status: "error", error: error?.message || "The local API is unavailable.", lastSuccessAt: state.snapshotStatus.lastSuccessAt, + consecutiveErrors, + refreshPaused, }; renderWorkspaceState(); // Keep the shell usable even if optional surfaces are down. @@ -689,10 +726,13 @@ function buildRuntimeAlert() { }; } if (state.snapshotStatus.status === "error") { + const pausedSuffix = state.snapshotStatus.refreshPaused + ? " Automatic refresh paused after repeated failures." + : ""; return { tone: "warn", actionLabel: "Retry API", - message: `API unavailable: ${state.snapshotStatus.error || "local request failed."}`, + message: `API unavailable: ${state.snapshotStatus.error || "local request failed."}${pausedSuffix}`, }; } if (state.workspace.status === "starting") { @@ -714,7 +754,15 @@ async function retryWorkspaceRuntime() { state.workspace = await window.quantlabDesktop.restartWorkspaceServer(); renderWorkspaceState(); } + state.snapshotStatus = { + ...state.snapshotStatus, + consecutiveErrors: 0, + refreshPaused: false, + }; await refreshSnapshot(); + if (state.workspace.serverUrl && !state.refreshTimer && state.snapshotStatus.status !== "error") { + ensureRefreshLoop(); + } } finally { state.isRetryingWorkspace = false; renderWorkspaceState(); diff --git a/desktop/renderer/modules/utils.js b/desktop/renderer/modules/utils.js index 69cf5c6..2f4259b 100644 --- a/desktop/renderer/modules/utils.js +++ b/desktop/renderer/modules/utils.js @@ -196,6 +196,41 @@ export function parseCsvRows(text, limit = Infinity) { return rows; } +export class LruCache { + constructor(maxSize = 50) { + this.maxSize = Math.max(1, Number(maxSize) || 50); + this.cache = new Map(); + } + + clear() { + this.cache.clear(); + } + + has(key) { + return this.cache.has(key); + } + + get(key) { + if (!this.cache.has(key)) return undefined; + const value = this.cache.get(key); + this.cache.delete(key); + this.cache.set(key, value); + return value; + } + + set(key, value) { + if (this.cache.has(key)) { + this.cache.delete(key); + } + this.cache.set(key, value); + while (this.cache.size > this.maxSize) { + const oldestKey = this.cache.keys().next().value; + this.cache.delete(oldestKey); + } + return value; + } +} + function splitCsvLine(line) { const values = []; let current = "";