7f61ae589f
Programs run via terminal(background=true, pty=true) can block forever when they probe their terminal — device-status (ESC[5n), window-size (ESC[18t), cursor-position (ESC[6n), or DEC private-mode (ESC[?N$p) queries — because nothing on the PTY master side answers, and the raw query bytes leak into captured output. - tools/pty_query_responder.py: incremental byte scanner that strips the handled queries from PTY output (chunk splits included) and produces bounded replies; everything else passes through untouched. - tools/process_registry.py: wire the responder into _pty_reader_loop (POSIX only — ConPTY answers its own queries); flush partial escape tails at end-of-stream. - tests mirror the codex fixtures plus a live-PTY E2E where a subprocess blocks on ESC[6n until answered.
174 lines
5.1 KiB
Python
174 lines
5.1 KiB
Python
"""Tests for tools.pty_query_responder (port of openai/codex#41436).
|
|
|
|
Covers the byte-level scanner (exact queries, chunk splits, DEC private-mode
|
|
queries, passthrough of unhandled sequences) and a live PTY E2E where a
|
|
subprocess blocks on a cursor-position report until answered.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from tools.pty_query_responder import PtyQueryResponder
|
|
|
|
|
|
def test_plain_output_passes_through():
|
|
r = PtyQueryResponder()
|
|
out, resp = r.process(b"hello world\n")
|
|
assert out == b"hello world\n"
|
|
assert resp == b""
|
|
|
|
|
|
def test_device_status_report_answered_and_stripped():
|
|
r = PtyQueryResponder()
|
|
out, resp = r.process(b"before\x1b[5nafter")
|
|
assert out == b"beforeafter"
|
|
assert resp == b"\x1b[0n"
|
|
|
|
|
|
def test_window_size_query_reports_spawn_dimensions():
|
|
r = PtyQueryResponder(rows=30, cols=120)
|
|
out, resp = r.process(b"\x1b[18t")
|
|
assert out == b""
|
|
assert resp == b"\x1b[8;30;120t"
|
|
|
|
|
|
def test_cursor_position_report():
|
|
r = PtyQueryResponder()
|
|
out, resp = r.process(b"\x1b[6n")
|
|
assert out == b""
|
|
assert resp == b"\x1b[1;1R"
|
|
|
|
|
|
def test_dec_private_mode_query_reported_unrecognized():
|
|
r = PtyQueryResponder()
|
|
out, resp = r.process(b"\x1b[?1049$p")
|
|
assert out == b""
|
|
assert resp == b"\x1b[?1049;0$y"
|
|
|
|
|
|
def test_combined_stream_matches_codex_fixture():
|
|
# Mirrors the driver-backed test in openai/codex#41436: queries split
|
|
# across chunks, mixed with a color escape and plain text.
|
|
r = PtyQueryResponder()
|
|
out1, resp1 = r.process(b"before\x1b[")
|
|
out2, resp2 = r.process(b"5n\x1b[18t\x1b[6n\x1b[?1049$p\x1b[31mafter")
|
|
assert resp1 + resp2 == b"\x1b[0n\x1b[8;24;80t\x1b[1;1R\x1b[?1049;0$y"
|
|
assert out1 + out2 + r.flush() == b"before\x1b[31mafter"
|
|
|
|
|
|
def test_query_split_across_many_chunks():
|
|
r = PtyQueryResponder()
|
|
total_out = b""
|
|
total_resp = b""
|
|
for b in (b"\x1b", b"[", b"6", b"n"):
|
|
out, resp = r.process(b)
|
|
total_out += out
|
|
total_resp += resp
|
|
assert total_out == b""
|
|
assert total_resp == b"\x1b[1;1R"
|
|
|
|
|
|
def test_unhandled_csi_sequence_passes_through():
|
|
r = PtyQueryResponder()
|
|
out, resp = r.process(b"\x1b[31mred\x1b[0m")
|
|
assert out == b"\x1b[31mred\x1b[0m"
|
|
assert resp == b""
|
|
|
|
|
|
def test_non_csi_escape_passes_through():
|
|
r = PtyQueryResponder()
|
|
out, resp = r.process(b"\x1bMreverse")
|
|
assert out == b"\x1bMreverse"
|
|
assert resp == b""
|
|
|
|
|
|
def test_fresh_esc_aborts_partial_sequence():
|
|
r = PtyQueryResponder()
|
|
out, resp = r.process(b"\x1b[6\x1b[5n")
|
|
# The aborted partial "\x1b[6" is flushed through; the complete
|
|
# device-status query is answered and stripped.
|
|
assert out == b"\x1b[6"
|
|
assert resp == b"\x1b[0n"
|
|
|
|
|
|
def test_oversized_mode_query_passes_through():
|
|
r = PtyQueryResponder()
|
|
seq = b"\x1b[?12345678901$p" # 11 digits > MAX_MODE_DIGITS
|
|
out, resp = r.process(seq)
|
|
assert resp == b""
|
|
assert out + r.flush() == seq
|
|
|
|
|
|
def test_flush_returns_incomplete_tail():
|
|
r = PtyQueryResponder()
|
|
out, resp = r.process(b"text\x1b[1")
|
|
assert out == b"text"
|
|
assert resp == b""
|
|
assert r.flush() == b"\x1b[1"
|
|
# flush is destructive
|
|
assert r.flush() == b""
|
|
|
|
|
|
def test_dec_mode_non_digit_passes_through():
|
|
r = PtyQueryResponder()
|
|
seq = b"\x1b[?10a9$p"
|
|
out, resp = r.process(seq)
|
|
assert resp == b""
|
|
assert out + r.flush() == seq
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX ptyprocess only")
|
|
def test_live_pty_subprocess_unblocked_by_cursor_report(tmp_path):
|
|
"""E2E: a PTY subprocess blocking on ESC[6n exits once answered.
|
|
|
|
Mirrors direct_terminal_queries_are_answered from openai/codex#41436.
|
|
"""
|
|
ptyprocess = pytest.importorskip("ptyprocess")
|
|
from tools.pty_query_responder import PtyQueryResponder
|
|
|
|
script = (
|
|
"stty -echo -icanon; printf 'alpha\\033[6n'; "
|
|
"dd bs=1 count=6 2>/dev/null; printf '\\nok'"
|
|
)
|
|
proc = ptyprocess.PtyProcess.spawn(
|
|
["/bin/sh", "-c", script], dimensions=(24, 80)
|
|
)
|
|
responder = PtyQueryResponder()
|
|
output = b""
|
|
deadline = time.time() + 10
|
|
try:
|
|
while proc.isalive() and time.time() < deadline:
|
|
try:
|
|
chunk = proc.read(4096)
|
|
except EOFError:
|
|
break
|
|
if not chunk:
|
|
continue
|
|
out, replies = responder.process(chunk)
|
|
output += out
|
|
if replies:
|
|
proc.write(replies)
|
|
# Drain anything left after exit.
|
|
while True:
|
|
try:
|
|
chunk = proc.read(4096)
|
|
except EOFError:
|
|
break
|
|
if not chunk:
|
|
break
|
|
out, replies = responder.process(chunk)
|
|
output += out
|
|
finally:
|
|
if proc.isalive():
|
|
proc.terminate(force=True)
|
|
pytest.fail(f"subprocess still blocked; output={output!r}")
|
|
output += responder.flush()
|
|
assert b"alpha" in output
|
|
assert b"ok" in output
|
|
# The query itself must have been stripped from the captured output,
|
|
# and the echoed reply is what dd consumed (not visible w/ -echo).
|
|
assert b"\x1b[6n" not in output
|