Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions openhack/shells.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ def spawn(self, command: str, cwd: Optional[str] = None,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
errors="replace", # non-UTF-8 output must not crash the reader and deadlock the pipe
bufsize=1,
cwd=cwd or os.getcwd(),
env=env,
Expand Down
18 changes: 18 additions & 0 deletions tests/test_shells.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,21 @@ def test_ids_are_sequential_and_listed():
ids = [mgr.spawn("true") for _ in range(3)]
assert ids == ["sh1", "sh2", "sh3"]
assert {s.id for s in mgr.list()} == {"sh1", "sh2", "sh3"}


def test_non_utf8_output_does_not_deadlock():
# Regression for #13: a command that emits invalid UTF-8 bytes must not
# crash the reader thread and deadlock on a full stdout pipe (the child
# blocks in write() while the reader stops draining, and wait() hangs).
mgr = ShellManager()
sid = mgr.spawn(
"python3 -c 'import sys; sys.stdout.buffer.write(b\"\\xff\" * 200000)'"
)
sh = mgr.get(sid)
assert _wait(lambda: sh.status == "exited", timeout=5.0), (
"shell deadlocked on non-UTF-8 output"
)
assert sh.returncode == 0
# the invalid bytes are decoded as replacement characters, not dropped
assert any("\ufffd" in line for line in sh.tail(20))