fix(app-boot): release the terminal before a fatal load exit

A dsh launch whose config failed validation returned the user to a broken
shell: typing was invisible and the next command was mangled by a stray
Device Attributes reply (1;2;4cecho ...).

The Loader mounts entries concurrently, so ui-tui can already hold the
terminal (raw mode, bracketed paste, keyboard protocol, plus an in-flight
DA query) when a sibling entry rejects on its own config. installFailLoud
wrote its diagnostic and exited immediately, so nothing disposed the tree
and ProcessTerminal.stop() never ran.

Give installFailLoud an optional release teardown, awaited between the
diagnostic and the exit and bounded by FAIL_LOUD_RELEASE_TIMEOUT_MS. The
TUI launcher passes one that disposes the root context, reaching the same
shutdown() the /exit path already uses (drainInput() + ui.stop()). The
context is captured in boot()'s prepare hook because the rejection arrives
while boot() is still in flight.

Bins that pass no release keep the previous behavior exactly.
This commit is contained in:
Turtle
2026-07-31 19:57:45 +08:00
parent 5c4b701afe
commit 70f37206d2
9 changed files with 245 additions and 9 deletions

View File

@@ -5,7 +5,8 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot,
FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
@@ -162,6 +163,56 @@ describe('installFailLoud', () => {
proc.handlers[0]!(error)
expect(proc.exits).toEqual([1])
})
// The Loader mounts entries concurrently, so a terminal-owning surface can
// already hold raw mode when a sibling entry rejects. Exiting without running
// its teardown strands the terminal on the user's shell.
it('awaits the release hook before exiting so the terminal owner can restore it', async () => {
const proc = fakeProc()
const order: string[] = []
installFailLoud(NAME, proc, async () => {
await Promise.resolve()
order.push('released')
})
proc.handlers[0]!(new Error('sibling entry rejected'))
expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
// The release is in flight, so the exit has not committed yet.
expect(proc.exits).toEqual([])
await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
expect(order).toEqual(['released'])
})
it('still exits when the release hook rejects', async () => {
const proc = fakeProc()
installFailLoud(NAME, proc, () => Promise.reject(new Error('terminal stop failed')))
proc.handlers[0]!(new Error('boom'))
await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
})
it('exits without waiting when a release hook never settles', async () => {
vi.useFakeTimers()
try {
const proc = fakeProc()
installFailLoud(NAME, proc, () => new Promise<void>(() => {}))
proc.handlers[0]!(new Error('boom'))
expect(proc.exits).toEqual([])
await vi.advanceTimersByTimeAsync(FAIL_LOUD_RELEASE_TIMEOUT_MS)
expect(proc.exits).toEqual([1])
} finally {
vi.useRealTimers()
}
})
// Teardown runs plugin disposers, whose own rejection must not be reported as
// a second fatal load failure over the real one.
it('uninstalls the handler before releasing, so teardown cannot re-enter it', async () => {
const proc = fakeProc()
installFailLoud(NAME, proc, () => {})
proc.handlers[0]!(new Error('boom'))
expect(proc.handlers).toHaveLength(0)
await vi.waitFor(() => { expect(proc.exits).toEqual([1]) })
expect(proc.written).toHaveLength(1)
})
})
describe('assertEntriesLoaded', () => {