99
1010import errno
1111import gc
12+ import io
1213import logging
1314import math
1415import os
@@ -139,12 +140,19 @@ def __init__(
139140 on_stdout_receive : Callable [[], None ] | None = None ,
140141 ) -> None :
141142 self ._stdout_send , stdout_receive = anyio .create_memory_object_stream [bytes ](math .inf )
143+ self ._stderr_send , stderr_receive = anyio .create_memory_object_stream [bytes ](math .inf )
142144 self .stdout = _FakeStdout (
143145 stdout_receive ,
144146 eof_error = stdout_eof_error ,
145147 aclose_error = stdout_aclose_error ,
146148 on_receive = self ._dispatch_stdout_receive ,
147149 )
150+ self .stderr = _FakeStdout (
151+ stderr_receive ,
152+ eof_error = None ,
153+ aclose_error = None ,
154+ on_receive = lambda : None ,
155+ )
148156 self .pid = 424242
149157 self .written : list [bytes ] = []
150158 self .stdin_closed = anyio .Event ()
@@ -170,10 +178,19 @@ def close_stdout(self) -> None:
170178 """End the fake process's stdout, as the kernel does when it dies."""
171179 self ._stdout_send .close ()
172180
181+ async def feed_stderr (self , data : bytes ) -> None :
182+ """Make `data` readable on the fake process's stderr."""
183+ await self ._stderr_send .send (data )
184+
185+ def close_stderr (self ) -> None :
186+ """End the fake process's stderr, as the kernel does when it dies."""
187+ self ._stderr_send .close ()
188+
173189 def exit (self , code : int = 0 ) -> None :
174190 """Die: set the exit code and EOF stdout, as the kernel does."""
175191 self .returncode = code
176192 self .close_stdout ()
193+ self .close_stderr ()
177194
178195 def pending_stdout_chunks (self ) -> int :
179196 """How many fed chunks the client has not yet pulled off the fake stdout."""
@@ -225,6 +242,32 @@ async def _next_message(read_stream: ReadStream[SessionMessage | Exception]) ->
225242 return received .message
226243
227244
245+ @pytest .mark .anyio
246+ async def test_server_stderr_is_forwarded_to_errlog (monkeypatch : pytest .MonkeyPatch ) -> None :
247+ """Server stderr is piped and forwarded, so notebook-like errlogs still see it."""
248+ process = FakeProcess ()
249+ install_fake_process (monkeypatch , process )
250+ errlog = io .StringIO ()
251+
252+ async with stdio_client (FAKE_PARAMS , errlog = errlog ):
253+ await process .feed_stderr (b"starting server\n " )
254+ with anyio .fail_after (1 ):
255+ while errlog .getvalue () != "starting server\n " :
256+ await anyio .sleep (0 )
257+ process .exit ()
258+
259+
260+ @pytest .mark .anyio
261+ async def test_missing_server_stderr_pipe_is_ignored (monkeypatch : pytest .MonkeyPatch ) -> None :
262+ """Windows-compatible process objects may not expose a stderr pipe."""
263+ process = FakeProcess ()
264+ process .stderr = None
265+ install_fake_process (monkeypatch , process )
266+
267+ async with stdio_client (FAKE_PARAMS ):
268+ process .exit ()
269+
270+
228271@pytest .mark .anyio
229272async def test_messages_split_and_packed_across_chunks_are_reframed (monkeypatch : pytest .MonkeyPatch ) -> None :
230273 """Framing survives arbitrary chunk boundaries.
0 commit comments