fix: waits for startup by markers instead of timer
This commit is contained in:
@@ -313,6 +313,48 @@ class PtyClaudeProcess:
|
||||
return False
|
||||
await asyncio.sleep(poll)
|
||||
|
||||
async def wait_for_quiet(
|
||||
self,
|
||||
*,
|
||||
idle: float,
|
||||
timeout: float,
|
||||
poll: float = 0.05,
|
||||
) -> bool:
|
||||
"""Wait until PTY output stalls for `idle` consecutive seconds.
|
||||
|
||||
Polls ``len(captured_output())`` and resets a stopwatch each time
|
||||
the buffer grows; returns True once the stopwatch reaches `idle`,
|
||||
False on `timeout`. Intended as a "TUI fully rendered" signal:
|
||||
the bracketed-paste-enable byte (`\\x1b[?2004h`) fires when the
|
||||
terminal mode is set up, but claude's TUI keeps rendering status
|
||||
bar / input box for another few seconds afterwards. Waiting for
|
||||
a quiet period after the marker is a cheap, version-agnostic way
|
||||
to know the UI has actually settled.
|
||||
"""
|
||||
if idle < 0:
|
||||
msg = f"idle must be non-negative, got {idle!r}"
|
||||
raise ValueError(msg)
|
||||
if timeout < 0:
|
||||
msg = f"timeout must be non-negative, got {timeout!r}"
|
||||
raise ValueError(msg)
|
||||
if poll <= 0:
|
||||
msg = f"poll must be positive, got {poll!r}"
|
||||
raise ValueError(msg)
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
last_len = len(self.captured_output())
|
||||
last_change = loop.time()
|
||||
while True:
|
||||
if loop.time() - last_change >= idle:
|
||||
return True
|
||||
if loop.time() >= deadline:
|
||||
return False
|
||||
await asyncio.sleep(poll)
|
||||
cur_len = len(self.captured_output())
|
||||
if cur_len != last_len:
|
||||
last_len = cur_len
|
||||
last_change = loop.time()
|
||||
|
||||
async def write(self, data: str | bytes, *, newline: bool = True) -> int:
|
||||
r"""Write bytes to the child's stdin.
|
||||
|
||||
|
||||
+34
-15
@@ -51,13 +51,15 @@ _DEFAULT_FILE_WAIT_TIMEOUT = 30.0
|
||||
_DEFAULT_TURN_DURATION_TIMEOUT = 5.0
|
||||
_DEFAULT_STARTUP_DELAY = 10.0
|
||||
|
||||
# DECSET 2004 — claude's TUI enables bracketed paste once it is ready to
|
||||
# accept pasted input. Writing the user prompt before this byte sequence
|
||||
# arrives causes the paste-mode markers to be parsed as raw escape codes
|
||||
# and the message is silently dropped. We poll for it in `start()` instead
|
||||
# of a fixed sleep so a slow box (e.g. a Raspberry Pi where claude's
|
||||
# initial render takes ~2-3s) doesn't lose the first turn.
|
||||
# DECSET 2004 — claude's TUI enables bracketed paste mode early in init.
|
||||
# This is necessary (without it our paste markers parse as raw ANSI and
|
||||
# the message is dropped) but NOT sufficient: the marker arrives ~2s on
|
||||
# a slow Pi, but the input box / status bar continue rendering for
|
||||
# another few seconds. Writing during that window also loses the paste.
|
||||
# So we wait for the marker AND then for a quiet period — `_TUI_QUIET_PERIOD`
|
||||
# seconds with no new PTY bytes — to know the TUI has truly settled.
|
||||
_TUI_READY_MARKER: bytes = b"\x1b[?2004h"
|
||||
_TUI_QUIET_PERIOD: float = 1.5
|
||||
|
||||
|
||||
ParseErrorCallback = Callable[[MessageParseError, JsonlRecord], None]
|
||||
@@ -135,20 +137,37 @@ class TurnManager:
|
||||
async def start(self) -> None:
|
||||
"""Spawn the PTY and let claude's TUI settle. Idempotent.
|
||||
|
||||
`startup_delay` is now a cap, not a fixed sleep: we wait for
|
||||
claude's bracketed-paste-enable byte (DECSET 2004) to appear in
|
||||
PTY output and return as soon as it does — typically faster than
|
||||
`startup_delay`, but on a slow host where the marker takes longer
|
||||
we still bound the wait. Fake PTYs in tests may not expose
|
||||
`wait_for_output`; we fall back to a plain sleep then.
|
||||
`startup_delay` is the **total** cap on waiting for the TUI to be
|
||||
ready, in two phases bounded by it:
|
||||
|
||||
1. Wait for `_TUI_READY_MARKER` (DECSET 2004 / bracketed-paste-on)
|
||||
— confirms claude has set up its terminal modes.
|
||||
2. Wait for `_TUI_QUIET_PERIOD` consecutive seconds of no new PTY
|
||||
bytes — confirms the initial render burst (status bar / input
|
||||
box) has finished. Writing earlier loses the paste even though
|
||||
bracketed-paste mode is on.
|
||||
|
||||
Fake PTYs in tests may not expose these helpers; we fall back to
|
||||
a plain sleep then.
|
||||
"""
|
||||
if self._started:
|
||||
return
|
||||
await self._pty.start()
|
||||
if self._startup_delay > 0:
|
||||
wait = getattr(self._pty, "wait_for_output", None)
|
||||
if wait is not None:
|
||||
await wait(_TUI_READY_MARKER, timeout=self._startup_delay)
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + self._startup_delay
|
||||
wait_marker = getattr(self._pty, "wait_for_output", None)
|
||||
wait_quiet = getattr(self._pty, "wait_for_quiet", None)
|
||||
if wait_marker is not None:
|
||||
# Phase 1: bracketed-paste-enable byte tells us terminal
|
||||
# mode is set up. Necessary but not sufficient.
|
||||
await wait_marker(_TUI_READY_MARKER, timeout=self._startup_delay)
|
||||
# Phase 2: wait for the TUI render burst to settle —
|
||||
# consecutive seconds of no new PTY bytes. Bounded by what's
|
||||
# left of `startup_delay`.
|
||||
remaining = max(0.0, deadline - loop.time())
|
||||
if wait_quiet is not None and remaining > 0:
|
||||
await wait_quiet(idle=_TUI_QUIET_PERIOD, timeout=remaining)
|
||||
else:
|
||||
await asyncio.sleep(self._startup_delay)
|
||||
self._started = True
|
||||
|
||||
Reference in New Issue
Block a user