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
+9
View File
@@ -26,6 +26,11 @@ export interface Config {
exactProbeAfterMs?: number
/** Silence duration that yields `inferred_idle`. */
idleSilenceMs?: number
/**
* Extra wait beyond `idleSilenceMs`, once a prompt marker was seen, for the shell to
* regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`.
*/
handoffGraceMs?: number
/** Absolute send wait bound. */
timeoutMs?: number
/** Grace before teardown escalates to `SIGKILL`. */
@@ -48,6 +53,7 @@ export const Config: z<Config> = z.object({
pollIntervalMs: z.number().default(50),
exactProbeAfterMs: z.number().default(150),
idleSilenceMs: z.number().default(3_000),
handoffGraceMs: z.number().default(500),
timeoutMs: z.number().default(30_000),
disposeGraceMs: z.number().default(3_000),
})
@@ -69,4 +75,7 @@ export function validateConfig(config: Config): asserts config is ResolvedConfig
if (resolved.maxReadBytes > resolved.scrollbackMaxBytes) {
throw new Error('pty-local: maxReadBytes must not exceed scrollbackMaxBytes')
}
if (resolved.handoffGraceMs < resolved.pollIntervalMs) {
throw new Error('pty-local: handoffGraceMs must be at least pollIntervalMs so one readiness poll runs inside the grace window')
}
}