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:
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,11 +331,12 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
// A prompt candidate can race bash's foreground handoff, but an interactive
|
||||
// child also inherits PROMPT_COMMAND. Silence therefore remains the bound
|
||||
// on waiting for shell ownership instead of letting a child marker suppress
|
||||
// readiness until the absolute timeout. One final poll lets a foreground
|
||||
// handoff coincident with that boundary win before the fallback settles.
|
||||
// readiness until the absolute timeout. When a prompt marker was seen, the
|
||||
// configured grace holds the fallback past the silence bound so polls in
|
||||
// that window can observe the foreground handoff and settle as stdin_read.
|
||||
const idleFor = Date.now() - this.lastOutputAt
|
||||
const handoffGrace = this.promptSeen ? this.config.pollIntervalMs : 0
|
||||
if (startupHasOutput && idleFor >= this.config.idleSilenceMs && idleFor - this.config.idleSilenceMs >= handoffGrace) {
|
||||
const handoffGrace = this.promptSeen ? this.config.handoffGraceMs : 0
|
||||
if (startupHasOutput && idleFor >= this.config.idleSilenceMs + handoffGrace) {
|
||||
this.settleActive('inferred_idle')
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user