fix: waits for startup by markers instead of timer
This commit is contained in:
@@ -313,6 +313,48 @@ class PtyClaudeProcess:
|
|||||||
return False
|
return False
|
||||||
await asyncio.sleep(poll)
|
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:
|
async def write(self, data: str | bytes, *, newline: bool = True) -> int:
|
||||||
r"""Write bytes to the child's stdin.
|
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_TURN_DURATION_TIMEOUT = 5.0
|
||||||
_DEFAULT_STARTUP_DELAY = 10.0
|
_DEFAULT_STARTUP_DELAY = 10.0
|
||||||
|
|
||||||
# DECSET 2004 — claude's TUI enables bracketed paste once it is ready to
|
# DECSET 2004 — claude's TUI enables bracketed paste mode early in init.
|
||||||
# accept pasted input. Writing the user prompt before this byte sequence
|
# This is necessary (without it our paste markers parse as raw ANSI and
|
||||||
# arrives causes the paste-mode markers to be parsed as raw escape codes
|
# the message is dropped) but NOT sufficient: the marker arrives ~2s on
|
||||||
# and the message is silently dropped. We poll for it in `start()` instead
|
# a slow Pi, but the input box / status bar continue rendering for
|
||||||
# of a fixed sleep so a slow box (e.g. a Raspberry Pi where claude's
|
# another few seconds. Writing during that window also loses the paste.
|
||||||
# initial render takes ~2-3s) doesn't lose the first turn.
|
# 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_READY_MARKER: bytes = b"\x1b[?2004h"
|
||||||
|
_TUI_QUIET_PERIOD: float = 1.5
|
||||||
|
|
||||||
|
|
||||||
ParseErrorCallback = Callable[[MessageParseError, JsonlRecord], None]
|
ParseErrorCallback = Callable[[MessageParseError, JsonlRecord], None]
|
||||||
@@ -135,20 +137,37 @@ class TurnManager:
|
|||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Spawn the PTY and let claude's TUI settle. Idempotent.
|
"""Spawn the PTY and let claude's TUI settle. Idempotent.
|
||||||
|
|
||||||
`startup_delay` is now a cap, not a fixed sleep: we wait for
|
`startup_delay` is the **total** cap on waiting for the TUI to be
|
||||||
claude's bracketed-paste-enable byte (DECSET 2004) to appear in
|
ready, in two phases bounded by it:
|
||||||
PTY output and return as soon as it does — typically faster than
|
|
||||||
`startup_delay`, but on a slow host where the marker takes longer
|
1. Wait for `_TUI_READY_MARKER` (DECSET 2004 / bracketed-paste-on)
|
||||||
we still bound the wait. Fake PTYs in tests may not expose
|
— confirms claude has set up its terminal modes.
|
||||||
`wait_for_output`; we fall back to a plain sleep then.
|
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:
|
if self._started:
|
||||||
return
|
return
|
||||||
await self._pty.start()
|
await self._pty.start()
|
||||||
if self._startup_delay > 0:
|
if self._startup_delay > 0:
|
||||||
wait = getattr(self._pty, "wait_for_output", None)
|
loop = asyncio.get_running_loop()
|
||||||
if wait is not None:
|
deadline = loop.time() + self._startup_delay
|
||||||
await wait(_TUI_READY_MARKER, timeout=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:
|
else:
|
||||||
await asyncio.sleep(self._startup_delay)
|
await asyncio.sleep(self._startup_delay)
|
||||||
self._started = True
|
self._started = True
|
||||||
|
|||||||
Reference in New Issue
Block a user