1212import argparse
1313import ast
1414import json
15+ import math
1516import os
1617import re
1718import signal
3132
3233DEFAULT_CONNECTION_FILE = Path .home () / ".cache" / "docx-editor" / "kernel.json"
3334
35+ # start_session's default budget, and the environment variable that overrides
36+ # it. A kernel start is a fresh interpreter importing ipykernel + IPython; on a
37+ # loaded machine (a 2-vCPU CI runner with parallel test workers, coverage
38+ # tracing on) that import alone can take longer than the unloaded 30 s default.
39+ DEFAULT_START_TIMEOUT = 30.0
40+ START_TIMEOUT_ENV = "DOCX_EDITOR_SESSION_START_TIMEOUT"
41+
42+ # How the kernel is launched. Tests substitute a stand-in kernel here to
43+ # exercise the start-failure paths deterministically; the "-f <file>" argument
44+ # is appended by start_session.
45+ _KERNEL_COMMAND = (sys .executable , "-m" , "ipykernel_launcher" )
46+
47+ # Bytes of kernel stderr kept in a start-failure diagnostic.
48+ _STDERR_TAIL_BYTES = 4096
49+
3450_EXTRA_HINT = "Session mode requires extra dependencies: pip install 'docx-editor[session]'"
3551
3652
@@ -131,29 +147,116 @@ def _read_pid(connection_file: Path) -> int | None:
131147 return None
132148
133149
134- def start_session (connection_file : Path = DEFAULT_CONNECTION_FILE , timeout : float = 30.0 ) -> int :
150+ def _start_timeout () -> float :
151+ """start_session's budget: DOCX_EDITOR_SESSION_START_TIMEOUT, else the 30 s default.
152+
153+ Raises:
154+ SessionError: If the variable is set to anything but a positive number.
155+ """
156+ raw = os .environ .get (START_TIMEOUT_ENV , "" ).strip ()
157+ if not raw :
158+ return DEFAULT_START_TIMEOUT
159+ try :
160+ value = float (raw )
161+ if not (math .isfinite (value ) and value > 0 ): # rejects nan and inf too
162+ raise ValueError
163+ except ValueError :
164+ raise SessionError (f"{ START_TIMEOUT_ENV } must be a positive number of seconds, got { raw !r} " ) from None
165+ return value
166+
167+
168+ def _drain_stderr (proc : subprocess .Popen [bytes ]) -> str :
169+ """Tail of what a (killed and reaped) kernel wrote to stderr, without blocking.
170+
171+ Invariant: nothing may read ``proc.stderr`` before this runs — the raw-fd
172+ read below bypasses the ``BufferedReader``, so any bytes already buffered
173+ there would be silently dropped. (Today the only other reader, the
174+ exited-during-startup path, always raises without reaching ``_abort``.)
175+
176+ The pipe is switched to non-blocking first: every writer is dead, so the
177+ read hits EOF at once — but if a grandchild ever inherited the write end,
178+ EAGAIN ends the loop instead of hanging a diagnostic that exists to explain
179+ a hang.
180+ """
181+ if proc .stderr is None :
182+ return ""
183+ fd = proc .stderr .fileno ()
184+ if os .name == "posix" :
185+ os .set_blocking (fd , False )
186+ chunks : list [bytes ] = []
187+ while True :
188+ try :
189+ chunk = os .read (fd , 65536 )
190+ except OSError : # EAGAIN: a writer still holds the pipe open
191+ break
192+ if not chunk :
193+ break
194+ chunks .append (chunk )
195+ return b"" .join (chunks )[- _STDERR_TAIL_BYTES :].decode (errors = "replace" ).strip ()
196+
197+
198+ def _describe_connection_file (connection_file : Path ) -> str :
199+ """One line on what the kernel managed to write before the budget ran out."""
200+ try :
201+ size = connection_file .stat ().st_size
202+ except OSError :
203+ return "Connection file: absent."
204+ try :
205+ info = json .loads (connection_file .read_text (encoding = "utf-8" ))
206+ return (
207+ f"Connection file: { size } bytes, { info ['transport' ]} { info ['ip' ]} , "
208+ f"shell={ info ['shell_port' ]} control={ info ['control_port' ]} ."
209+ )
210+ except (OSError , ValueError , KeyError , TypeError ) as e :
211+ return f"Connection file: { size } bytes, unreadable: { e } ."
212+
213+
214+ def _load_average () -> str :
215+ """Suffix "; load average N.NN" where the platform reports one, else ""."""
216+ if not hasattr (os , "getloadavg" ): # Windows
217+ return ""
218+ try :
219+ return f"; load average { os .getloadavg ()[0 ]:.2f} "
220+ except OSError :
221+ return ""
222+
223+
224+ def start_session (connection_file : Path = DEFAULT_CONNECTION_FILE , timeout : float | None = None ) -> int :
135225 """Start a detached IPython kernel and wait until it answers.
136226
137227 Args:
138228 connection_file: Where to write the kernel connection file
139- timeout: Seconds to wait for the kernel to become ready
229+ timeout: Seconds to wait for the kernel to become ready. ``None`` (the
230+ default) reads ``DOCX_EDITOR_SESSION_START_TIMEOUT`` and falls back to
231+ 30 s when it is unset or blank; an explicit argument always wins.
232+ The variable exists for loaded machines — CI runners, parallel
233+ test workers — where a fresh interpreter importing IPython takes
234+ far longer than it does interactively. ``docx-session start``
235+ has no flag for it and reaches the variable through this default.
140236
141237 Returns:
142238 PID of the kernel process.
143239
144240 Raises:
145241 SessionError: If a session is already running, the kernel fails to
146- start, or the connection file it wrote is unreadable.
242+ start, the connection file it wrote is unreadable, or
243+ ``DOCX_EDITOR_SESSION_START_TIMEOUT`` is not a positive number.
244+ A start that runs out of budget explains itself: which phase
245+ timed out (connection file vs. control-channel reply), whether
246+ the kernel was still alive, the load average, what it wrote to
247+ stderr, and the variable to raise.
147248 ImportError: If the [session] extra is not installed.
148249 """
250+ if timeout is None :
251+ timeout = _start_timeout ()
149252 if is_session_running (connection_file ):
150253 raise SessionError (f"Session already running (connection file: { connection_file } )" )
151254
152255 connection_file .parent .mkdir (parents = True , exist_ok = True )
153256 connection_file .unlink (missing_ok = True )
154257
155258 proc = subprocess .Popen (
156- [sys . executable , "-m" , "ipykernel_launcher" , "-f" , str (connection_file )],
259+ [* _KERNEL_COMMAND , "-f" , str (connection_file )],
157260 stdout = subprocess .DEVNULL ,
158261 # Keep stderr so a failed start (e.g. missing ipykernel) can explain itself.
159262 stderr = subprocess .PIPE ,
@@ -162,14 +265,32 @@ def start_session(connection_file: Path = DEFAULT_CONNECTION_FILE, timeout: floa
162265 )
163266 _pid_file (connection_file ).write_text (str (proc .pid ), encoding = "utf-8" )
164267
165- def _abort (message : str ) -> SessionError :
268+ def _abort (summary : str ) -> SessionError :
269+ # Sampled before the kill so the report says what the kernel was doing
270+ # at the timeout, not what the kill did to it.
271+ exit_code = proc .poll ()
166272 proc .kill ()
167273 proc .wait ()
274+ kernel = "was still alive at the timeout" if exit_code is None else f"had exited with code { exit_code } "
275+ stderr = _drain_stderr (proc )
276+ report = "\n " .join ([
277+ summary ,
278+ f"Kernel pid { proc .pid } { kernel } { _load_average ()} ." ,
279+ _describe_connection_file (connection_file ),
280+ "Kernel stderr:" ,
281+ "\n " .join (f" { line } " for line in stderr .splitlines ()) if stderr else " (empty)" ,
282+ f"Set { START_TIMEOUT_ENV } to raise the budget on a loaded machine." ,
283+ ])
168284 connection_file .unlink (missing_ok = True )
169285 _pid_file (connection_file ).unlink (missing_ok = True )
170- return SessionError (message )
171-
172- deadline = time .monotonic () + timeout
286+ return SessionError (report )
287+
288+ # Two phases share one budget. The connection file is written as soon as
289+ # the sockets are bound — *before* ipykernel imports IPython and wires the
290+ # control channel — so its appearance is an early signal, and the second
291+ # phase gets whatever is left (floored at 1 s).
292+ t0 = time .monotonic ()
293+ deadline = t0 + timeout
173294 while not (connection_file .exists () and connection_file .stat ().st_size > 0 ):
174295 if proc .poll () is not None :
175296 stderr = proc .stderr .read ().decode (errors = "replace" ).strip () if proc .stderr else ""
@@ -178,8 +299,11 @@ def _abort(message: str) -> SessionError:
178299 detail = f"\n { stderr } " if stderr else ""
179300 raise SessionError (f"Kernel process exited during startup (code { proc .returncode } ). { hint } { detail } " .strip ())
180301 if time .monotonic () > deadline :
181- raise _abort (f"Kernel did not start within { timeout } s" )
302+ raise _abort (
303+ f"Kernel did not start within { timeout } s: no connection file after { time .monotonic () - t0 :.1f} s."
304+ )
182305 time .sleep (0.1 )
306+ t_file = time .monotonic () - t0
183307
184308 kc = None
185309 try :
@@ -190,8 +314,12 @@ def _abort(message: str) -> SessionError:
190314 kc = _client (connection_file )
191315 except (ValueError , OSError ) as e :
192316 raise SessionError (f"Kernel wrote an unreadable connection file ({ connection_file } ): { e } " ) from e
193- if not _kernel_alive (kc , timeout = max (1.0 , deadline - time .monotonic ())):
194- raise SessionError (f"Kernel did not become ready within { timeout } s" )
317+ remaining = max (1.0 , deadline - time .monotonic ())
318+ if not _kernel_alive (kc , timeout = remaining ):
319+ raise _abort (
320+ f"Kernel did not become ready within { timeout } s: kernel.json appeared after { t_file :.1f} s, "
321+ f"then no kernel_info reply on the control channel in the remaining { remaining :.1f} s."
322+ )
195323 except BaseException :
196324 # Never leave a half-started kernel behind with no way to reach it.
197325 if proc .poll () is None :
0 commit comments