From bd1fdc19441ad3d2d7460c7cdf3ae3af40836392 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Thu, 1 Oct 2026 13:46:43 -0700 Subject: [PATCH 1/2] test(python): synchronize interactive exec TTY readiness Wait for the complete readiness marker before streaming stdin so PTY echo cannot split the separately written TTY flags. Preserve pipe stream separation and verify consumed stdin and both output sentinels in TTY mode. Fixes #4075 Signed-off-by: Matthew Grossman --- e2e/python/test_sandbox_api.py | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/e2e/python/test_sandbox_api.py b/e2e/python/test_sandbox_api.py index 5885f376b2..1e7cf0484e 100644 --- a/e2e/python/test_sandbox_api.py +++ b/e2e/python/test_sandbox_api.py @@ -136,6 +136,7 @@ def test_sandbox_interactive_exec_honors_tty( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, ) -> None: + ready_marker = b"tty-ready\n" stdin_sentinel = b"streamed-stdin-sentinel" stdout_sentinel = b"stdout-sentinel" stderr_sentinel = b"stderr-sentinel" @@ -150,7 +151,7 @@ def exec_interactive(sandbox_name: str, *, tty: bool) -> tuple[bytes, bytes]: "-c", "[ -t 0 ] && printf T || printf N; " "[ -t 1 ] && printf T || printf N; " - "[ -t 2 ] && printf T || printf N; printf '\\n'; " + "[ -t 2 ] && printf T || printf N; printf '\\ntty-ready\\n'; " "IFS= read -r stdin_value; " "printf 'stdin:%s\\n' \"$stdin_value\"; " "printf 'stdout-sentinel\\n'; " @@ -161,35 +162,45 @@ def exec_interactive(sandbox_name: str, *, tty: bool) -> tuple[bytes, bytes]: ) ) + ready = threading.Event() done = threading.Event() def requests(): yield request + # PTY input echo can split the separate TTY flag writes. Wait for + # the complete marker, including its newline, before sending input. + if not ready.wait(timeout=20) or done.is_set(): + return yield openshell_pb2.ExecSandboxInput(stdin=stdin_sentinel + b"\n") done.wait(timeout=30) - stdout: list[bytes] = [] - stderr: list[bytes] = [] + stdout = bytearray() + stderr = bytearray() exit_code: int | None = None try: events = sandbox_client._stub.ExecSandboxInteractive(requests(), timeout=30) for event in events: payload = event.WhichOneof("payload") if payload == "stdout": - stdout.append(bytes(event.stdout.data)) + stdout.extend(event.stdout.data) + if ready_marker in stdout.replace(b"\r\n", b"\n"): + ready.set() elif payload == "stderr": - stderr.append(bytes(event.stderr.data)) + stderr.extend(event.stderr.data) elif payload == "exit": exit_code = int(event.exit.exit_code) finally: done.set() + # Unblock a request iterator waiting for readiness on early exit + # or RPC failure without sending input after the call has ended. + ready.set() assert exit_code == 0 - return b"".join(stdout), b"".join(stderr) + return bytes(stdout), bytes(stderr) with sandbox(delete_on_exit=True) as sb: stdout, stderr = exec_interactive(sb.sandbox.name, tty=False) - assert b"NNN" in stdout + assert b"NNN" in stdout.splitlines() assert b"stdin:" + stdin_sentinel in stdout assert stdout_sentinel in stdout assert stdout_sentinel not in stderr @@ -197,7 +208,11 @@ def requests(): assert stderr_sentinel not in stdout stdout, stderr = exec_interactive(sb.sandbox.name, tty=True) - assert b"TTT" in stdout + stderr + terminal_output = stdout + stderr + assert b"TTT" in terminal_output.splitlines() + assert b"stdin:" + stdin_sentinel in terminal_output + assert stdout_sentinel in terminal_output + assert stderr_sentinel in terminal_output def test_interactive_exec_drains_output_after_request_eof( From 825a021f2578b637d072cc7b966ee56ca28311e1 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Thu, 1 Oct 2026 14:27:47 -0700 Subject: [PATCH 2/2] test(python): reuse interactive exec readiness marker Signed-off-by: Matthew Grossman --- e2e/python/test_sandbox_api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/python/test_sandbox_api.py b/e2e/python/test_sandbox_api.py index 1e7cf0484e..5544981e3f 100644 --- a/e2e/python/test_sandbox_api.py +++ b/e2e/python/test_sandbox_api.py @@ -151,7 +151,8 @@ def exec_interactive(sandbox_name: str, *, tty: bool) -> tuple[bytes, bytes]: "-c", "[ -t 0 ] && printf T || printf N; " "[ -t 1 ] && printf T || printf N; " - "[ -t 2 ] && printf T || printf N; printf '\\ntty-ready\\n'; " + "[ -t 2 ] && printf T || printf N; " + f"printf '\\n%s' '{ready_marker.decode()}'; " "IFS= read -r stdin_value; " "printf 'stdin:%s\\n' \"$stdin_value\"; " "printf 'stdout-sentinel\\n'; "