Merge pull request #1093 from deepseek-harness/fix/tui-terminal-restore-on-boot-failure

fix(app-boot): restore the terminal when boot fails
This commit is contained in:
Turtle
2026-08-03 14:48:20 +08:00
committed by GitHub
17 changed files with 454 additions and 23 deletions

View File

@@ -115,7 +115,6 @@ export async function runTui(
)
process.exit(1)
}
installFailLoud(NAME)
// The bin already loaded the invoking directory's .env, and that is the
// whole environment: $DSH_HOME/.env is credentials-local's writable store,
// and hoisting it would make every stored key read as a read-only ambient
@@ -142,6 +141,19 @@ export async function runTui(
const entry = process.argv[1]
const execve = process.execve?.bind(process)
const app: { current?: Context } = {}
// The Loader mounts entries concurrently, so `ui-tui` can already hold the
// terminal (raw mode, bracketed paste, keyboard protocol) when something
// else fails. A config-tree failure settles through `boot`, which disposes
// the tree itself; this release covers the rejections `boot` cannot see — a
// plugin's detached async work rejecting while mounting is still in flight
// or after the tree settled. Disposing the tree runs the TUI's own shutdown,
// which stops the terminal and hands the shell back; without it such a
// failure returns to a corrupted prompt. `app.current` is captured from
// boot's `prepare` hook, so it holds the root context for the whole mounting
// window rather than only after boot resolves.
installFailLoud(NAME, process, async () => {
await app.current?.fiber.dispose()
})
// Resume always enters the default surface because meta rejects
// parent options, including `--resume`. The resumed session already persists
// its cwd.
@@ -219,6 +231,10 @@ export async function runTui(
bootConfig,
patches,
(hostCtx) => {
// Runs after the Loader installs and before any config-tree entry mounts,
// so the fail-loud release hook can reach the tree for the whole window in
// which an entry may reject.
app.current = hostCtx
// The launcher owns session identity and the exit line: a config-mounted
// app bundle reads both from these slots, so no cordis.yml key can drop
// resume.

View File

@@ -0,0 +1,10 @@
# An overlay whose `llm-pi-ai` config fails schema validation: `providers` is a
# dict keyed by provider name, and a list is the shape users reach for. The
# entry rejects while `ui-tui` — mounted concurrently by the Loader — already
# holds the terminal, which is the boot failure the fail-loud release hook
# exists for.
- id: llm-pi-ai
config:
providers:
- provider: openai
apiKey: keyless-invalid-shape

View File

@@ -26,6 +26,9 @@ const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
// `--config` layers an overlay over the shared base, so the default surface
// needs no config argument at all; these are the overlays under test.
const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url))
// An overlay whose `llm-pi-ai` config fails validation, so an entry rejects
// while the TUI already holds the terminal.
const invalidProviderConfigPath = fileURLToPath(new URL('./fixtures/tui-invalid-provider.cordis.yml', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
const firstRunSnapshots = fileURLToPath(new URL('./tui-first-run-snapshots/', import.meta.url))
const synchronizedFrameEnd = '\x1b[?2026l'
@@ -414,6 +417,28 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
expect(output).toContain('\u001B[?2004l')
}, PTY_SMOKE_TEST_TIMEOUT_MS)
// The Loader mounts entries concurrently, so `ui-tui` can already own the
// terminal when a sibling entry rejects on its config. Exiting without the
// tree's own teardown left raw mode and bracketed paste set on the user's
// shell, and the pending Device Attributes reply landed there as literal
// text. The transactional mount must settle (an HMR initial-scan refresh
// once deadlocked its rollback into a silent exit 13) so `boot` disposes
// the tree — reaching the TUI's own shutdown — and rejects with the
// labelled diagnostic.
it('restores the terminal when a sibling entry fails to validate during boot', async () => {
const output = await smoke({
label: 'dsh invalid provider config',
tempDirPrefix: 'dsh-tui-invalid-config-',
configPath: invalidProviderConfigPath,
expectedExitCode: 1,
})
expect(output).toContain('dsh: plugin tree failed to load:')
expect(output).toContain('$.providers')
// Bracketed paste is disabled again, which only `ProcessTerminal.stop()`
// writes — proof the tree was disposed rather than exited out from under.
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => {
const output = await smoke({
label: 'dsh conversation',