fix: rabbit review 001 - persist Streamlit failures, guard Jupyter lifecycle

- st_demo: persist failure in call history, clear active_handle,
  and disable Run while call active to avoid overwriting handle
- JupyterApp.shutdown: gather all shutdowns with return_exceptions=True
  and re-raise as BaseExceptionGroup so one failure doesn't leak kernels
- messages: fallback known-but-malformed messages to UnknownJupyterMessage
  via ValidationError, make Unknown channel permissive (str) for nuance
- transport: record reader failures as terminal state (_reader_failure)
  and fail fast from execute/messages_for instead of hanging
- shell: don't reset execution_count to 0 when IPython returns None
- README: use ipython_shell imports

Skipped non-urgent/deferred items: line(), get_shell_2, test renames,
import laziness, call-ID bounding, settle window config (worth doing later).
This commit is contained in:
lda
2026-08-31 15:59:23 +07:00 Verified
parent 32d628677c
commit c2c41ea9ce
6 changed files with 53 additions and 9 deletions
+8 -3
View File
@@ -1,3 +1,4 @@
import asyncio
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from time import monotonic
@@ -97,6 +98,10 @@ class JupyterApp:
async def shutdown(self) -> None:
"""Stop all kernel processes owned by this app."""
awaitables = [shell.shutdown() for shell in self.shells.values()]
for awaitable in awaitables:
await awaitable
results = await asyncio.gather(
*(shell.shutdown() for shell in self.shells.values()),
return_exceptions=True,
)
errors = [result for result in results if isinstance(result, BaseException)]
if errors:
raise BaseExceptionGroup("Kernel shutdown failed", errors)
+12 -2
View File
@@ -1,6 +1,6 @@
from typing import Annotated, ClassVar, Final, Literal
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
type JupyterChannel = Literal["iopub", "shell", "stdin"]
@@ -176,6 +176,9 @@ class InputRequestMessage(_TypedMessage):
class UnknownJupyterMessage(JupyterMessage):
"""An extension or future message preserved without lossy parsing."""
# Allow any channel value so known-but-malformed fallbacks never fail
# on a strict Literal. The parser preserves the transport channel verbatim.
channel: str = "iopub" # type: ignore[assignment]
raw: dict[str, object] = Field(default_factory=dict)
@@ -252,7 +255,14 @@ def parse_jupyter_message(
if values["msg_type"] not in _KNOWN_MESSAGE_TYPES:
return UnknownJupyterMessage.model_validate({**values, "raw": raw})
return _KNOWN_MESSAGE_ADAPTER.validate_python(values)
try:
return _KNOWN_MESSAGE_ADAPTER.validate_python(values)
except ValidationError:
# Known msg_type but content or channel doesn't match the strict
# model (e.g., extra execution_state, wrong stdout/stderr name,
# or msg delivered on an unexpected channel). Degrade losslessly
# instead of aborting the whole call.
return UnknownJupyterMessage.model_validate({**values, "raw": raw})
# Existing callers use this name for the transport-level message. Keep it as
+6
View File
@@ -48,6 +48,7 @@ class JupyterTransport:
asyncio.Queue[tuple[JupyterChannel, dict[str, Any] | BaseException]] | None
) = None
self._reader_tasks: set[asyncio.Task[None]] = set()
self._reader_failure: BaseException | None = None
async def start(self) -> None:
"""Start the kernel and its persistent channel readers."""
@@ -100,6 +101,7 @@ class JupyterTransport:
raise
except BaseException as error:
# Surface reader failures instead of leaving messages_for() stuck.
self._reader_failure = error
await self._message_queue.put((channel, error))
async def _stop_readers(self) -> None:
@@ -113,6 +115,8 @@ class JupyterTransport:
async def execute(self, code: str) -> str:
"""Submit one cell and return its Jupyter message ID."""
if self._reader_failure is not None:
raise RuntimeError("JupyterTransport channel reader failed") from self._reader_failure
if self.client is None:
raise RuntimeError("JupyterTransport has not been started")
@@ -126,6 +130,8 @@ class JupyterTransport:
self, call_id: str
) -> AsyncIterator[ParsedJupyterMessage | InputRequest]:
"""Yield decoded output, input, and completion messages for one call."""
if self._reader_failure is not None:
raise RuntimeError("JupyterTransport channel reader failed") from self._reader_failure
if self.client is None:
raise RuntimeError("JupyterTransport has not been started")
if self._active_call != call_id: