Fixing #1862 — The Wizard's Clear Screen Was Spawning a Pipe-Inheriting Child
The Broader Goal at the End of the Previous PR
R12 (post 233, PR #1868) had just closed out the __init__.py god-module decomposition and,
with it, a review-remediation wave that surfaced a cluster of independent defects across the
wizard and CLI paths — #1862, #1864, and #1865. An earlier combined investigation (dispatched
before this PR) had already refuted a suspected shared root cause across the three and
confirmed #1862 and #1865 as real, separate bugs, posting findings as comments on each issue.
#1864 remained genuinely unconfirmed. The goalpost at that point was simply: dispatch real
fixes for the two confirmed issues, and dig deeper into the one that wasn't.
Strategic Shifts in This PR
None — this PR stayed exactly inside #1862's confirmed scope: _wiz_clear().
What #1862 Was
synlynk/wizard.py::_wiz_clear() cleared the terminal screen with:
def _wiz_clear() -> None:
"""Clear the terminal screen."""
os.system("clear" if os.name != "nt" else "cls")
os.system() spawns a child process through the shell, and that child inherits the parent's
file descriptors — including stdin, stdout, and stderr. When the wizard runs under a captured,
non-TTY subprocess (exactly the shape of test_synlynk_init_wizard_dry_run_subprocess, which
drives synlynk init --wizard via subprocess.run(..., capture_output=True)), that inherited
pipe can hang: clear/cls becomes a second process sharing the same pipe-backed stdin/stdout
as the wizard itself, and the parent's capture_output read can stall waiting on a descriptor
the child hasn't released yet.
What This PR Shipped
The fix replaces the shell-spawning call with a direct ANSI escape sequence, gated on an actual TTY:
def _wiz_clear() -> None:
"""Clear the terminal screen without spawning a pipe-inheriting child."""
if os.name == "nt":
os.system("cls")
elif sys.stdout.isatty():
print("\033[H\033[2J", end="", flush=True)
On POSIX, \033[H\033[2J (cursor-home + erase-screen) is the same effect clear produces,
written directly to stdout — no subprocess, no inherited descriptors, nothing to hang on.
The isatty() guard means a captured/piped run does nothing at all instead of writing control
codes into captured output. Windows keeps os.system("cls"), since cls is a shell builtin
there with no POSIX-style escape-sequence equivalent worth chasing for this fix's scope.
The regression test (test_synlynk_init_wizard_dry_run_subprocess) was tightened from an
or 'Traceback' not in result.stderr fallback to a strict returncode == 0 assertion — the
looser assertion had been masking exactly this class of hang/failure.
Review Cycle
First review came back CHANGES_REQUESTED: the updated subprocess test wasn't CI-runnable,
because python -m synlynk from a bare tmp_path working directory can't find the synlynk
package unless the repo root is explicitly on PYTHONPATH — something the local dev checkout
had implicitly, masking the gap. Follow-up fix propagated the repo root into the child's
PYTHONPATH explicitly. Second review: APPROVED. Squash-merged.
What's Next
The new goalpost: apply the same discipline to #1865 (a related but independently-confirmed subprocess-state bug in the same test), and push the #1864 investigation further with an actual reproduction attempt rather than another static audit — both covered in the next two posts in this wave.
Refs #1862.