From 6cee2f20214a38d8de7b4e9e186f8c936df879e1 Mon Sep 17 00:00:00 2001 From: yaojin Date: Tue, 18 Aug 2026 19:35:30 +0800 Subject: [PATCH] fix: extract and show root cause in harness startup failure dialog When the Harness process exits unexpectedly during startup, users only saw 'exit code 1' with no indication of what actually went wrong. This change extracts the most relevant error from the captured stderr and includes it directly in the failure message, so the dialog shows the actual cause (e.g. plugin load failures, missing modules, config errors) without requiring users to dig into the log file. Closes #77 --- src/main/runtime/harness-runtime.ts | 54 ++++++++++++++++++++- test/runtime.test.ts | 74 +++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/harness-runtime.ts b/src/main/runtime/harness-runtime.ts index 7555bdbb..47067dca 100644 --- a/src/main/runtime/harness-runtime.ts +++ b/src/main/runtime/harness-runtime.ts @@ -160,7 +160,14 @@ export class HarnessRuntime { this.writeLog(`[node] Harness process exited (${detail})`) if (this.child !== child) return this.child = undefined - this.setState('failed', `Harness stopped unexpectedly (${detail}).`) + const cause = extractFailureCause(this.logLines) + this.setState( + 'failed', + cause + ? `Harness stopped unexpectedly (${detail}). +${cause}` + : `Harness stopped unexpectedly (${detail}).` + ) }) const startedAt = Date.now() @@ -241,6 +248,51 @@ export class HarnessRuntime { } } +export function extractFailureCause(logLines: readonly string[]): string | undefined { + const stderrLines: string[] = [] + let dshEntryError: string | undefined + let uncaughtError: string | undefined + + for (const line of logLines) { + if (!line.startsWith('[stderr] ')) continue + const text = line.slice(8) + stderrLines.push(text) + + if (dshEntryError === undefined) { + const m = text.match(/DSH entry failed:\s*(.+)/) + if (m && m[1]) dshEntryError = m[1].trim() + } + + if (uncaughtError === undefined) { + const m1 = text.match(/uncaught exception:\s*(.+)/) + if (m1 && m1[1]) { + uncaughtError = m1[1].trim() + } else { + const m2 = text.match(/unhandled rejection:\s*(.+)/) + if (m2 && m2[1]) uncaughtError = m2[1].trim() + } + } + } + + if (dshEntryError) return dshEntryError + if (uncaughtError) return uncaughtError + + for (let i = stderrLines.length - 1; i >= 0; i--) { + const line = stderrLines[i]?.trim() + if (!line) continue + if (line.length < 200 && /\b(error|Error|ERROR|failed|Failed|FAILED)\b/.test(line)) { + return line + } + } + + if (stderrLines.length > 0) { + const last = stderrLines[stderrLines.length - 1]?.trim() + if (last && last.length < 200) return last + } + + return undefined +} + export function formatExitCode(code: number): string { const unsigned = code >>> 0 const hexadecimal = `0x${unsigned.toString(16).padStart(8, '0').toUpperCase()}` diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 816548ab..563dcab0 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -3,6 +3,7 @@ import { buildHarnessArguments, buildHarnessSpawnOptions, buildNodeArguments, + extractFailureCause, formatExitCode } from '../src/main/runtime/harness-runtime' import { canGrantWindowPermission, isTrustedAppUrl } from '../src/main/security-policy' @@ -88,6 +89,79 @@ describe('Harness launch contract', () => { }) }) + + +describe('harness failure cause extraction', () => { + it('extracts the DSH entry failure message from stderr', () => { + const logs = [ + '[stderr] [harness-node] DSH entry failed: Error: dsh: plugin tree failed to load', + '[stderr] AggregateError: loader entries failed to apply', + ] + expect(extractFailureCause(logs)).toBe('Error: dsh: plugin tree failed to load') + }) + + it('extracts uncaught exception messages from stderr', () => { + const logs = [ + '[stderr] [harness-node] uncaught exception: ReferenceError: foo is not defined', + ] + expect(extractFailureCause(logs)).toBe('ReferenceError: foo is not defined') + }) + + it('extracts unhandled rejection messages from stderr', () => { + const logs = [ + '[stderr] [harness-node] unhandled rejection: TypeError: cannot read property x of null', + ] + expect(extractFailureCause(logs)).toBe('TypeError: cannot read property x of null') + }) + + it('prefers DSH entry failure over uncaught error', () => { + const logs = [ + '[stderr] [harness-node] uncaught exception: some error', + '[stderr] [harness-node] DSH entry failed: Error: plugin failed to load', + ] + expect(extractFailureCause(logs)).toBe('Error: plugin failed to load') + }) + + it('falls back to the last error-like stderr line', () => { + const logs = [ + '[stderr] some random output', + '[stderr] another line', + '[stderr] FATAL: configuration error in settings.yaml', + ] + expect(extractFailureCause(logs)).toBe('FATAL: configuration error in settings.yaml') + }) + + it('falls back to the last stderr line when nothing matches', () => { + const logs = [ + '[stderr] starting up', + '[stderr] something happened', + '[stderr] process exiting now', + ] + expect(extractFailureCause(logs)).toBe('process exiting now') + }) + + it('returns undefined when there are no stderr lines', () => { + const logs = [ + '[stdout] normal output', + '[desktop] starting harness', + ] + expect(extractFailureCause(logs)).toBeUndefined() + }) + + it('returns undefined for empty log array', () => { + expect(extractFailureCause([])).toBeUndefined() + }) + + it('ignores long error lines (>200 chars) when falling back', () => { + const longLine = 'x'.repeat(250) + const logs = [ + `[stderr] ${longLine}`, + '[stderr] short error message', + ] + expect(extractFailureCause(logs)).toBe('short error message') + }) +}) + describe('navigation trust boundary', () => { it('only trusts the launcher and loopback HTTP pages', () => { expect(isTrustedAppUrl('file:///app/index.html')).toBe(true)