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)