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
+48 -3
View File
@@ -325,24 +325,69 @@ async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Pr
}
}
/**
* How long {@link installFailLoud} waits for its `release` hook before exiting
* anyway. A wedged disposer must delay the fatal exit, never cancel it.
*/
export const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2_000
/**
* Install before boot to turn a late unhandled plugin-init rejection into one
* labelled stderr diagnostic and `exit(1)`. A rejection already included by
* {@link assertEntriesActivated} is ignored during its process checkpoint;
* every other rejection remains fatal. Stdout remains untouched for ACP; the
* returned function removes the handler.
*
* The Loader mounts entries concurrently, so a surface that owns the terminal
* can already hold it when a sibling entry rejects. Exiting straight from the
* handler would strand raw mode, bracketed paste, and the keyboard protocol on
* the user's shell, and leave an in-flight terminal query's reply to land as
* literal text at the next prompt. `release` is the terminal owner's chance to
* hand it back; it is awaited under {@link FAIL_LOUD_RELEASE_TIMEOUT_MS}. The
* diagnostic is written before the release so the reason survives a disposer
* that repaints or clears the screen, and the handler uninstalls itself before
* releasing so a rejection from teardown cannot re-enter it.
* @param binName - the diagnostic prefix on the fatal-failure line.
* @param proc - the process slice to register on; tests inject a fake.
* @param release - optional teardown awaited before exit, used by a
* terminal-owning surface to restore the terminal. Its own failure is
* swallowed because the pending fatal exit already owns the outcome.
* @returns the uninstaller that removes the rejection handler.
*/
export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
export function installFailLoud(
binName: string,
proc: FailLoudProcess = process,
release?: () => Promise<void> | void,
): () => void {
const handler = (err: unknown): void => {
if (assembledActivationRejections.has(err)) return
proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
proc.exit(1)
if (release === undefined) {
proc.exit(1)
return
}
// The release runs plugin disposers, which may themselves reject. Without
// this the handler would re-enter and report a teardown failure as a second
// fatal load failure, hiding the real one.
uninstall()
void (async () => {
try {
await Promise.race([
(async () => release())(),
new Promise<void>((resolve) => {
setTimeout(resolve, FAIL_LOUD_RELEASE_TIMEOUT_MS).unref()
}),
])
} catch {
// The terminal release failed; the fatal exit below is the outcome that
// matters, and no reporter runs after it.
}
proc.exit(1)
})()
}
const uninstall = (): void => void proc.off('unhandledRejection', handler)
proc.on('unhandledRejection', handler)
return () => void proc.off('unhandledRejection', handler)
return uninstall
}
/**