# Conclusion: tai_test.py's Transport, Cleanup, and Framework Design ## Metadata | Field | Value | |---|---| | Institute | Molodetz Institute — Research & Retrieval Division | | Subject | async-pty-process-testing | | Document type | Conclusion | | Tier | N/A | | Date decided | 2026-10-09 | | Decided by | retoor, advised by tai (agent, Claude Sonnet 5) | | Status | Active | | Confidence | High | ## Question / problem statement How should `tai_test.py` — a reusable integration-test framework for driving `tai.py` both as a one-shot subprocess and as a persistent interactive REPL (including scripting input and detecting turn boundaries) — be built so that it's safe (no leaked zombie processes on timeout, crash, or cancellation) and extensible (a clean base class future tests can subclass), using only the Python standard library, consistent with `tai.py`'s own zero-dependency rule? ## Conclusion The framework uses two unified transports behind one `asyncio.StreamReader`-based interface: plain `asyncio.create_subprocess_exec` with `PIPE` streams for one-shot calls, and a real pty (stdlib `pty.openpty()`, the slave fd given to the child as stdin/stdout/stderr, the master fd wrapped into a `StreamReader` via `loop.connect_read_pipe`) for anything that needs the child to behave as if a human were typing — because `tai.py`'s own code branches on `sys.stdin.isatty()`. Optional live human stdin passthrough is bridged via `loop.add_reader` + raw `os.read` on the fd (never buffered `sys.stdin`, which blocks), with no background thread, mirroring how the child's own output is already consumed. Every process is started with `start_new_session=True` and torn down through an escalating SIGTERM-then-SIGKILL `os.killpg` call that always finishes with `proc.wait()` to reap it, so a timeout or crash never leaves an orphan. The `TestCase` base class (`setup`/`run`/`verify`/`teardown` hooks, typed exceptions carrying the full transcript) follows the declarative-base-class-plus-override-hooks pattern used by real prior art (CERN's Gaudi, gem5, OpenStack's oslotest) rather than being invented from scratch. ## Supporting sources - `../ptyprocess-docs.md`, `../pypi-shellous.md`, `../github-agents-issue1-controlling-terminal.md` — established why a real pty (not a pipe) was needed, and the documented, accepted limitation that `tai_test.py`'s pty mode does not call `ioctl(TIOCSCTTY)` and so doesn't guarantee job-control signal semantics. - `../github-gist-nonblocking-stdin-os-read.md`, `../python-asyncio-issue499-add-reader-eof.md`, `../glama-mcp-remote-py-stdio-jsonl.md` — the exact techniques `StdinBridge` (raw `os.read` via `add_reader`) and `ManagedProcess`'s pty-mode `StreamReader` wrapping (`loop.connect_read_pipe`) implement. - `../pypi-pexpect.md`, `../pyte-terminal-emulator.md`, `../pypi-stitch-pty.md` — the alternatives surveyed and not adopted. - `../iannuccisolutions-timeout-process-group.md`, `../python-docs-asyncio-subprocess.md`, `../alexandra-zaharia-kill-subprocess-children-timeout.md`, `../execa-termination-docs.md` — the direct justification for `start_new_session` + `killpg` + always-reap. - `../discourse-merge-two-streams.md` — the reason a generic multi-stream-merge utility was deliberately **not** built. - `../gaudi-subprocess-base-test.md`, `../gem5-test-infrastructure-commit.md`, `../oslotest-timeout-fixture.md` — the test-framework architecture `TestCase` is modeled on. - `../discourse-subprocess-process-gc.md`, `../slingacademy-stop-kill-child-process.md`, `../bpo-wait-for-timeout-leaves-process-alive.md` — confirmation that relying on timeout or garbage collection alone to clean up a process is documented, known-unreliable, which is why `execute()`'s `finally` block always calls `terminate()` explicitly regardless of how the test ended. ## Alternatives considered and rejected - **pexpect** — rejected because it's a blocking/synchronous library and `tai_test.py` is asyncio-native throughout; mixing a blocking library in would have meant either wrapping every call in a thread executor or accepting blocking the event loop, neither of which fit. - **stitch-pty** — rejected as too young/unverified to depend on for core framework plumbing, and as a compiled Rust-backed dependency it would also have violated the project's stdlib-only constraint. - **A generic tagged-queue multi-stream-merge utility** — rejected because the Discourse thread's own participants described the resulting async-generator plumbing as hard to maintain, and `tai_test.py`'s actual need (merging stdout+stderr in pipe mode) is fully satisfied by the simpler `stderr=STDOUT` subprocess argument, making the general utility unnecessary complexity. - **A background thread for bridging live stdin** — not rejected as wrong, but passed over in favor of `loop.add_reader` + `os.read` specifically so stdin-bridging follows the same no-thread, fd-based pattern as the child's own output handling, rather than mixing two different concurrency models in one framework. ## Risks / open questions The pty mode does not explicitly call `ioctl(TIOCSCTTY)`, so there is no guarantee the child's session becomes the pty's controlling terminal in the full job-control sense. `isatty()`-based behavior (all that `tai.py`'s own code checks) is unaffected, since that's a property of the fd itself, not of session leadership — but SIGWINCH-based resize forwarding and tty-line-discipline-generated Ctrl-C-as-SIGINT are not guaranteed to work. This is accepted because no test built so far needs either capability; every test built on `tai_test.py` so far only scripts line-based input/output. ## Action taken Implemented the same day as this research: `tai_test.py` (`ManagedProcess`, `StdinBridge`, `TeeLogger`, `TestCase`, `TestRunner`, `main_cli`) at the repository root, and `tests/rag.py` built on it — both already in use and passing against the real `tai.py` binary. ## Changelog | Date | Change | |---|---| | 2026-10-09 | Initial conclusion recorded. |