fix(pty): make the foreground-handoff grace a config field

The local PTY readiness poll held its inferred_idle fallback for exactly
one pollIntervalMs after a prompt marker, so a bash foreground handoff
that lands on the silence boundary only wins the exact stdin_read
attribution when the kernel publishes it inside that single poll. On a
slow or loaded host it does not, and the attribution flips.

handoffGraceMs replaces the hardcoded one-poll window as a validated,
deployment-owned config field defaulting to 500ms, rejected at load when
it cannot contain one readiness poll. Real-shell tests that interrupt a
send now assert the session is usable again rather than which readiness
tier observed the handoff, because no fixed grace removes the race.
This commit is contained in:
Chinesezjc
2026-07-27 14:14:59 +08:00
parent 3586422b69
commit f35c18f7e6
15 changed files with 81 additions and 22 deletions
+22 -1
View File
@@ -88,7 +88,7 @@ function config(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
return {
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, timeoutMs: 100,
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100,
disposeGraceMs: 20,
...overrides,
}
@@ -307,6 +307,27 @@ describe('LocalPtySession readiness and output', () => {
expect((await operation.done).waitReason).toBe('stdin_read')
})
it('holds the idle fallback for the configured handoff grace, not one poll', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = new LocalPtySession(terminal.asPty(), inspector, config({ handoffGraceMs: 40 }))
await initialize(session, terminal)
const operation = session.startSend({ text: 'run', submit: true })
let settled = false
void operation.done.then(() => { settled = true })
inspector.pgid = 789
terminal.emitData('\x1b]133;D;0\x07dsh> ')
// One poll past the silence bound would already have settled inferred_idle.
await vi.advanceTimersByTimeAsync(70)
expect(settled).toBe(false)
inspector.pgid = 456
await vi.advanceTimersByTimeAsync(10)
expect((await operation.done).waitReason).toBe('stdin_read')
})
it('falls back to inferred idle when a foreground child emits an inherited prompt marker', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()