Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion src/main/runtime/harness-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()}`
Expand Down
74 changes: 74 additions & 0 deletions test/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
buildHarnessArguments,
buildHarnessSpawnOptions,
buildNodeArguments,
extractFailureCause,
formatExitCode
} from '../src/main/runtime/harness-runtime'
import { canGrantWindowPermission, isTrustedAppUrl } from '../src/main/security-policy'
Expand Down Expand Up @@ -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)
Expand Down
Loading