Skip to content

Commit 8b9a5a5

Browse files
committed
Extend OutputSuppressionContext to restore stdout and stderr at both Python and OS levels
1 parent ace7758 commit 8b9a5a5

File tree

2 files changed

+187
-2
lines changed

2 files changed

+187
-2
lines changed

src/pynguin/testcase/execution.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -804,7 +804,14 @@ def clear_mutated_modules(self):
804804

805805

806806
class OutputSuppressionContext:
807-
"""A context manager that suppress stdout and stderr."""
807+
"""A context manager that suppresses stdout and stderr.
808+
809+
Operates at two levels:
810+
- Python level: redirects ``sys.stdout`` / ``sys.stderr`` to ``/dev/null``.
811+
- OS level: saves file descriptors 0/1/2 via ``os.dup`` so that if the SUT
812+
closes them (e.g. ``with open(1, 'w')`` where the int happens to be a
813+
stdio fd), they are restored on exit.
814+
"""
808815

809816
# Repeatedly opening/closing devnull caused problems.
810817
# This is closed when Pynguin terminates, since we don't need this output
@@ -815,17 +822,30 @@ def __init__(self) -> None:
815822
"""Create a new context manager that suppress stdout and stderr."""
816823
self._restored = False
817824
self._restored_lock = threading.Lock()
825+
self._saved_fds: dict[int, int] = {}
818826

819827
def restore(self) -> None:
820-
"""Restore stdout and stderr."""
828+
"""Restore stdout and stderr at both Python and OS level."""
821829
with self._restored_lock:
822830
if self._restored:
823831
return
824832
self._restored = True
833+
# Restore OS-level fds first so that sys.__stdout__ / sys.__stderr__
834+
# point to live fds again before we reassign the Python objects.
835+
for fd, saved_fd in self._saved_fds.items():
836+
with contextlib.suppress(OSError):
837+
os.dup2(saved_fd, fd)
838+
with contextlib.suppress(OSError):
839+
os.close(saved_fd)
840+
self._saved_fds.clear()
825841
sys.stdout = sys.__stdout__
826842
sys.stderr = sys.__stderr__
827843

828844
def __enter__(self) -> None:
845+
# Save OS-level fds before the SUT has a chance to close them.
846+
for fd in (0, 1, 2):
847+
with contextlib.suppress(OSError):
848+
self._saved_fds[fd] = os.dup(fd)
829849
sys.stdout = self._null_file
830850
sys.stderr = self._null_file
831851

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# This file is part of Pynguin.
2+
#
3+
# SPDX-FileCopyrightText: 2019–2026 Pynguin Contributors
4+
#
5+
# SPDX-License-Identifier: MIT
6+
#
7+
import contextlib
8+
import os
9+
import sys
10+
from unittest.mock import patch
11+
12+
import pytest
13+
14+
from pynguin.testcase.execution import OutputSuppressionContext
15+
16+
17+
@pytest.fixture
18+
def _protected_fds():
19+
"""Save and restore fds 0/1/2 around the test to prevent cross-test corruption.
20+
21+
Used by tests that deliberately close stdio fds inside a
22+
OutputSuppressionContext. If the test assertion fails *after* the context
23+
has already restored the fd, this fixture is a no-op safety net. If the
24+
context itself fails to restore a fd, this fixture ensures the rest of the
25+
suite still sees a healthy process.
26+
"""
27+
saved: dict[int, int] = {}
28+
for fd in (0, 1, 2):
29+
with contextlib.suppress(OSError):
30+
saved[fd] = os.dup(fd)
31+
yield
32+
for fd, saved_fd in saved.items():
33+
with contextlib.suppress(OSError):
34+
os.dup2(saved_fd, fd)
35+
with contextlib.suppress(OSError):
36+
os.close(saved_fd)
37+
38+
39+
def test_stdout_redirected_inside_context():
40+
with OutputSuppressionContext():
41+
assert sys.stdout is not sys.__stdout__
42+
43+
44+
def test_stderr_redirected_inside_context():
45+
with OutputSuppressionContext():
46+
assert sys.stderr is not sys.__stderr__
47+
48+
49+
def test_stdout_restored_after_context():
50+
with OutputSuppressionContext():
51+
pass
52+
assert sys.stdout is sys.__stdout__
53+
54+
55+
def test_stderr_restored_after_context():
56+
with OutputSuppressionContext():
57+
pass
58+
assert sys.stderr is sys.__stderr__
59+
60+
61+
@pytest.mark.usefixtures("_protected_fds")
62+
def test_fd1_restored_after_close_inside_context():
63+
"""Fd 1 (stdout) survives being closed by SUT code inside the context."""
64+
os.fstat(1) # pre-check: must be open
65+
66+
with OutputSuppressionContext():
67+
os.close(1)
68+
with pytest.raises(OSError, match="Bad file descriptor"):
69+
os.fstat(1) # confirm it really is closed
70+
71+
# OutputSuppressionContext.__exit__ must have restored fd 1
72+
os.fstat(1)
73+
74+
75+
@pytest.mark.usefixtures("_protected_fds")
76+
def test_fd2_restored_after_close_inside_context():
77+
"""Fd 2 (stderr) survives being closed by SUT code inside the context."""
78+
os.fstat(2)
79+
80+
with OutputSuppressionContext():
81+
os.close(2)
82+
with pytest.raises(OSError, match="Bad file descriptor"):
83+
os.fstat(2)
84+
85+
os.fstat(2)
86+
87+
88+
@pytest.mark.usefixtures("_protected_fds")
89+
def test_multiple_fds_restored_after_close():
90+
"""Both fd 1 and fd 2 are restored when closed simultaneously."""
91+
os.fstat(1)
92+
os.fstat(2)
93+
94+
with OutputSuppressionContext():
95+
os.close(1)
96+
os.close(2)
97+
98+
os.fstat(1)
99+
os.fstat(2)
100+
101+
102+
@pytest.mark.usefixtures("_protected_fds")
103+
def test_fd_replaced_inside_context_is_restored():
104+
"""If SUT replaces fd 1 via dup2, the original is restored on exit."""
105+
# Record what fd 1 currently points to
106+
orig_stat = os.fstat(1)
107+
108+
with OutputSuppressionContext():
109+
# Replace fd 1 with /dev/null — simulates a SUT that redirects stdout
110+
devnull_fd = os.open(os.devnull, os.O_WRONLY)
111+
os.dup2(devnull_fd, 1)
112+
os.close(devnull_fd)
113+
114+
# After exit, fd 1 should be back to the original
115+
assert os.fstat(1) == orig_stat
116+
117+
118+
def test_already_closed_fd_does_not_crash():
119+
"""__enter__ and __exit__ are safe when a fd is already closed.
120+
121+
We mock os.dup to simulate fd 0 being closed before the context is entered.
122+
The context must not raise on either __enter__ or __exit__.
123+
"""
124+
real_dup = os.dup
125+
126+
def dup_raising_on_0(fd):
127+
if fd == 0:
128+
raise OSError(9, "Bad file descriptor")
129+
return real_dup(fd)
130+
131+
with patch("os.dup", side_effect=dup_raising_on_0), OutputSuppressionContext():
132+
pass # must not raise
133+
134+
135+
@pytest.mark.usefixtures("_protected_fds")
136+
def test_explicit_restore_restores_fds():
137+
"""Calling restore() directly (timeout path) restores fds and Python objects."""
138+
ctx = OutputSuppressionContext()
139+
ctx.__enter__() # noqa: PLC2801
140+
141+
os.close(1) # simulate SUT closing stdout
142+
143+
ctx.restore()
144+
145+
# fd 1 and Python stdout must both be back
146+
os.fstat(1)
147+
assert sys.stdout is sys.__stdout__
148+
149+
150+
def test_restore_is_idempotent():
151+
"""Calling restore() multiple times never raises."""
152+
ctx = OutputSuppressionContext()
153+
ctx.__enter__() # noqa: PLC2801
154+
ctx.restore()
155+
ctx.restore() # second call: _restored flag is True, must be a no-op
156+
assert sys.stdout is sys.__stdout__
157+
158+
159+
def test_restore_then_exit_is_safe():
160+
"""restore() followed by __exit__ (the timeout cleanup sequence) is safe."""
161+
ctx = OutputSuppressionContext()
162+
ctx.__enter__() # noqa: PLC2801
163+
ctx.restore()
164+
ctx.__exit__(None, None, None)
165+
assert sys.stdout is sys.__stdout__

0 commit comments

Comments
 (0)