oct
Loading...
Searching...
No Matches
stdout_diversion.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/mcp/stdout_diversion.py
4
5"""
6Purpose
7-------
8Keep the stdio transport's protocol channel clean while the ``oct-mcp``
9server starts. Under the ``stdio`` transport, standard output (file
10descriptor 1) carries JSON-RPC messages, and the MCP specification says
11a stdio server must not write anything else there. While
12:func:`mcp.server.stdio.stdio_server` serves, the MCP Python SDK already
13points fd 1 at standard error and writes the protocol through a private
14duplicate. Before that, from process start until
15:meth:`mcp.server.MCPServer.run` enters the transport, nothing protected
16fd 1, so a ``_dbg`` record from startup (a lifecycle line with the MCP
17domain at level 2 or higher, any L1 startup error, the production-mode
18downgrade warning, or the FS-25 fallback warning) was written onto the
19wire ahead of the ``initialize`` response. This module covers that
20startup window with the same mechanism the SDK uses while serving.
21
22Responsibilities
23----------------
24- :func:`divert_stdout`: keep a private duplicate of fd 1 (the wire),
25 always above the standard descriptors, and point fd 1 at standard
26 error, or at the null device when standard error is not open. Every
27 write to fd 1 then misses the wire, whoever makes it: ``_dbg`` in any
28 output mode, ``print()``, a third-party library or a child process
29 that inherits standard output.
30- :func:`restore_stdout`: flush Python's ``sys.stdout`` while fd 1 is
31 still diverted, discarding into the null device whatever standard
32 error rejects, then point fd 1 back at the wire and close the private
33 duplicate. :func:`oct.mcp.server.start_server` calls it immediately
34 before ``MCPServer.run(transport="stdio")``, whose ``stdio_server()``
35 claims fd 1 again at once.
36- :func:`stdout_is_diverted`: report whether a diversion is still
37 recorded, so :func:`oct.mcp.server.start_server` can refuse to serve
38 when the hand-back failed.
39
40Diagnostics
41-----------
42Domain: MCP
43Levels:
44 L1 - errors: fd 1 could not be pointed back at the wire
45 L2 - lifecycle: diversion started, fd 1 handed back to the transport;
46 EXTERNAL_ERROR when standard error rejected buffered output
47 L3 - (none): the lifecycle records are the whole trace
48 L4 - (none): no deep trace is emitted
49
50Every record is written while fd 1 is diverted, so none of them can
51reach the wire: the hand-back record is emitted before fd 1 moves.
52
53Contracts
54---------
55- None of the three public functions raises. A failed diversion leaves
56 fd 1 carrying the wire. A failed hand-back leaves the diversion
57 recorded, with fd 1 still off the wire (on standard error or the null
58 device, or closed when a failing Windows ``dup2`` closed it first);
59 :func:`stdout_is_diverted` then returns ``True`` and
60 :func:`oct.mcp.server.start_server` exits with code 1 instead of
61 letting the SDK serve the protocol on standard error.
62- :func:`divert_stdout` returns ``True`` only when it started a
63 diversion. It returns ``False``, with fd 1 carrying the wire, when a
64 diversion is already active, when fd 1 is not an open descriptor, or
65 when fd 1 can be pointed neither at standard error nor at the null
66 device.
67- The private duplicate never lands on descriptor 0 or 2. When one of
68 them is closed and the duplicate lands there, that duplicate is held
69 while another is taken and then closed again, so the standard
70 descriptor stays closed. The SDK reaches the same result with
71 ``F_DUPFD_CLOEXEC`` on POSIX; this form works on every platform.
72- :func:`restore_stdout` returns ``True`` only when fd 1 carries the
73 wire again after a diversion. Without an active diversion it does
74 nothing and returns ``False``, so callers such as an in-process test
75 of ``start_server`` can call it unconditionally.
76- Bytes written into ``sys.stdout`` during the diversion are flushed
77 before fd 1 moves back. When standard error rejects that flush (a
78 broken or closed pipe), fd 1 is pointed at the null device and the
79 flush repeated, so the rejected bytes are discarded instead of
80 reaching the wire when the interpreter flushes at exit. Only when the
81 null device cannot be opened either can such bytes stay buffered.
82- ``sys.stdout`` itself is never replaced or wrapped: only the
83 descriptor moves, so ``sys.stdout.buffer.fileno()`` stays 1 and the
84 SDK's own claim of fd 1 still applies once the server runs.
85- Bytes buffered in ``sys.stdout`` before the diversion starts are not
86 flushed first, so they are diverted too; nothing legitimate is
87 written to standard output before the transport starts.
88- On Windows a child process inherits the Win32 standard-output handle,
89 not fd 1. The C runtime of a console interpreter (``python.exe``)
90 updates that handle whenever fd 1 changes, so a child started during
91 the diversion writes to standard error too. The GUI-subsystem
92 ``pythonw.exe`` does not; no child process is started during startup,
93 and the SDK rebinds the handle itself while serving (through pywin32,
94 when it is installed).
95- Module state is not locked: the functions are called from the main
96 thread during startup, before any tool call can run.
97
98Dependencies
99------------
100- oct.core.diagnostics (_dbg structured logger)
101"""
102
103from __future__ import annotations
104
105import os
106import sys
107from contextlib import suppress
108
109from oct.core.diagnostics import _dbg
110
111#: The standard output descriptor, which carries the protocol under stdio.
112_STDOUT_FD: int = 1
113
114#: The standard error descriptor, where diverted output goes.
115_STDERR_FD: int = 2
116
117#: Private duplicate of the original fd 1 while a diversion is active;
118#: ``None`` otherwise.
119_wire_fd: int | None = None
120
121
122def _dup_above_std(fd: int) -> int:
123 """Duplicate *fd* onto a descriptor above the standard range.
124
125 A duplicate that lands on 0 or 2 (a closed standard descriptor) is
126 held while the next one is taken and closed afterwards, so the wire
127 never sits behind a standard descriptor and a closed one stays
128 closed. With *fd* (fd 1) itself open, at most descriptors 0 and 2
129 can be free below 3, so the third duplicate is always above the
130 range.
131
132 Raises
133 ------
134 OSError
135 When *fd* cannot be duplicated, or when three duplicates in a
136 row land in the standard range.
137 """
138 held: list[int] = []
139 try:
140 for _attempt in range(_STDERR_FD + 1):
141 duplicate = os.dup(fd)
142 if duplicate > _STDERR_FD:
143 return duplicate
144 held.append(duplicate)
145 raise OSError(f"no duplicate of fd {fd} above the standard range")
146 finally:
147 for low in held:
148 with suppress(OSError):
149 os.close(low)
150
151
152def _point_fd1_at(target_fd: int) -> bool:
153 """Make fd 1 a duplicate of *target_fd*; return whether that worked."""
154 try:
155 os.dup2(target_fd, _STDOUT_FD)
156 except OSError:
157 return False
158 return True
159
160
162 """Make fd 1 a duplicate of the null device; return whether that worked."""
163 try:
164 null_fd = os.open(os.devnull, os.O_WRONLY)
165 except OSError:
166 return False
167 try:
168 return _point_fd1_at(null_fd)
169 finally:
170 with suppress(OSError):
171 os.close(null_fd)
172
173
174def _divert_fd1() -> str | None:
175 """Point fd 1 at standard error, or at the null device as a fallback.
176
177 Returns the name of the target, or ``None`` when neither worked.
178 """
179 if _point_fd1_at(_STDERR_FD):
180 return "standard error"
182 return "the null device"
183 return None
184
185
187 """Flush ``sys.stdout`` and ``sys.__stdout__``.
188
189 Returns ``False`` when a flush was rejected with :class:`OSError`,
190 which leaves the bytes buffered. A closed stream holds no bytes, so
191 its :class:`ValueError` counts as flushed.
192 """
193 flushed = True
194 for stream in (sys.stdout, sys.__stdout__):
195 if stream is None:
196 continue
197 try:
198 stream.flush()
199 except ValueError:
200 continue
201 except OSError:
202 flushed = False
203 return flushed
204
205
206def divert_stdout() -> bool:
207 """Point fd 1 at standard error and keep the wire in a private descriptor.
208
209 Called by :func:`oct.mcp.__main__.main` under the ``stdio`` transport,
210 right after argument parsing, so ``--help`` still prints to standard
211 output but nothing written during startup reaches the protocol
212 channel. :func:`restore_stdout` ends the diversion.
213
214 Returns
215 -------
216 ``True`` when this call started a diversion. ``False`` when a
217 diversion was already active or fd 1 could not be diverted; fd 1
218 then still carries the wire.
219 """
220 global _wire_fd
221 func = "divert_stdout"
222 if _wire_fd is not None:
223 return False
224 try:
225 wire = _dup_above_std(_STDOUT_FD)
226 except OSError:
227 # No open fd 1 (pythonw.exe without handles, a closed stdout): there
228 # is no wire to protect.
229 return False
230 target = _divert_fd1()
231 if target is None:
232 # A failed Windows dup2 can close its target first, so make sure fd 1
233 # carries the wire again before giving up.
234 _point_fd1_at(wire)
235 with suppress(OSError):
236 os.close(wire)
237 return False
238 _wire_fd = wire
239 _dbg(
240 "MCP", func,
241 f"standard output diverted to {target} until the stdio transport starts",
242 2,
243 )
244 return True
245
246
247def restore_stdout() -> bool:
248 """End a diversion started by :func:`divert_stdout`.
249
250 Emits its lifecycle record and flushes ``sys.stdout`` while fd 1 is
251 still diverted, then points fd 1 back at the wire and closes the
252 private duplicate. Called by :func:`oct.mcp.server.start_server`
253 immediately before ``MCPServer.run(transport="stdio")``, and again
254 by :func:`oct.mcp.__main__.main` on the way out, where it is a
255 no-op unless startup failed.
256
257 Returns
258 -------
259 ``True`` when fd 1 carries the wire again. ``False`` when no
260 diversion was active, or when fd 1 could not be pointed back; in the
261 second case an L1 record says so and the diversion stays recorded,
262 which :func:`stdout_is_diverted` reports.
263 """
264 global _wire_fd
265 func = "restore_stdout"
266 wire = _wire_fd
267 if wire is None:
268 return False
269 _dbg("MCP", func, "handing standard output back to the stdio transport", 2)
270 if not _flush_python_stdout():
271 # Standard error rejected the flush (a broken or closed pipe), so the
272 # bytes are still buffered and the interpreter would flush them onto
273 # the wire later. Discard them into the null device while fd 1 is
274 # still off the wire.
275 _dbg(
276 "MCP", func,
277 "EXTERNAL_ERROR: standard error rejected buffered output; "
278 "discarding it into the null device",
279 2,
280 )
283 if not _point_fd1_at(wire):
284 _dbg(
285 "MCP", func,
286 "SYSTEM_ERROR: could not point standard output back at the stdio wire",
287 1,
288 )
289 return False
290 with suppress(OSError):
291 os.close(wire)
292 _wire_fd = None
293 return True
294
295
296def stdout_is_diverted() -> bool:
297 """Return whether a diversion started by :func:`divert_stdout` is still recorded.
298
299 ``True`` after a failed hand-back: fd 1 does not carry the wire, so
300 the stdio transport must not start.
301 """
302 return _wire_fd is not None
bool _point_fd1_at(int target_fd)