Skip to content

Commit 5cb918a

Browse files
committed
fix(runtime): route late writes around closed capture buffers
Signed-off-by: Paul Furgale <pfurgale@nvidia.com>
1 parent 8237a88 commit 5cb918a

2 files changed

Lines changed: 59 additions & 8 deletions

File tree

src/nooa/runtime/stream_wrappers.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,25 +44,42 @@ def __init__(
4444
self.mode = getattr(original, "mode", "w")
4545

4646
def write(self, data: str) -> int:
47-
"""Write to contextvar buffer if set, otherwise to original stream."""
47+
"""Write to the task buffer, falling back if a stale buffer was closed."""
4848
buffer = self._buffer_var.get()
4949
if buffer is not None:
50-
return buffer.write(data)
50+
try:
51+
return buffer.write(data)
52+
except ValueError:
53+
# Background tasks inherit contextvars. They can outlive the
54+
# execution capture that installed this buffer, leaving a
55+
# closed StringIO in their copied context. Preserve logging
56+
# and exception reporting by routing those late writes to the
57+
# process stream instead.
58+
if not getattr(buffer, "closed", False):
59+
raise
5160
return self._original.write(data)
5261

5362
def writelines(self, lines: list[str]) -> None:
54-
"""Write multiple lines."""
63+
"""Write multiple lines, falling back if a stale buffer was closed."""
5564
buffer = self._buffer_var.get()
5665
if buffer is not None:
57-
buffer.writelines(lines)
58-
else:
59-
self._original.writelines(lines)
66+
try:
67+
buffer.writelines(lines)
68+
return
69+
except ValueError:
70+
if not getattr(buffer, "closed", False):
71+
raise
72+
self._original.writelines(lines)
6073

6174
def flush(self) -> None:
62-
"""Flush both buffer and original stream."""
75+
"""Flush the active buffer when usable, then the original stream."""
6376
buffer = self._buffer_var.get()
6477
if buffer is not None:
65-
buffer.flush()
78+
try:
79+
buffer.flush()
80+
except ValueError:
81+
if not getattr(buffer, "closed", False):
82+
raise
6683
self._original.flush()
6784

6885
def fileno(self) -> int:

tests/runtime/test_stream_wrappers.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ def test_write_returns_length_from_original(self):
6868
n = stream.write("xyz")
6969
assert n == 3
7070

71+
def test_write_falls_back_to_original_when_inherited_buffer_is_closed(self):
72+
stream, buf_var, original = make_stream()
73+
buf = io.StringIO()
74+
token = buf_var.set(buf)
75+
buf.close()
76+
try:
77+
assert stream.write("late log") == len("late log")
78+
assert original.getvalue() == "late log"
79+
finally:
80+
buf_var.reset(token)
81+
7182

7283
class TestContextVarStreamWritelines:
7384
"""Tests for ContextVarStream.writelines() routing."""
@@ -88,6 +99,17 @@ def test_writelines_to_original_when_no_buffer(self):
8899
stream.writelines(["x", "y", "z"])
89100
assert original.getvalue() == "xyz"
90101

102+
def test_writelines_falls_back_to_original_when_buffer_is_closed(self):
103+
stream, buf_var, original = make_stream()
104+
buf = io.StringIO()
105+
token = buf_var.set(buf)
106+
buf.close()
107+
try:
108+
stream.writelines(["late", " log"])
109+
assert original.getvalue() == "late log"
110+
finally:
111+
buf_var.reset(token)
112+
91113

92114
class TestContextVarStreamFlush:
93115
"""Tests for ContextVarStream.flush() behavior with and without a buffer."""
@@ -118,6 +140,18 @@ def test_flush_without_buffer_flushes_original(self):
118140
stream.flush()
119141
mock_original.flush.assert_called_once()
120142

143+
def test_flush_ignores_closed_inherited_buffer(self):
144+
stream, buf_var, original = make_stream()
145+
buf = io.StringIO()
146+
token = buf_var.set(buf)
147+
buf.close()
148+
try:
149+
stream.flush() # must not mask late exception reporting
150+
original.write("still usable")
151+
assert original.getvalue() == "still usable"
152+
finally:
153+
buf_var.reset(token)
154+
121155

122156
class TestContextVarStreamFileno:
123157
"""Tests for ContextVarStream.fileno() delegation to the original stream."""

0 commit comments

Comments
 (0)