fix(studio/colab): fix OutStream startup crash and tidy the notebook cards (#7404)

* fix(studio/colab): survive ipykernel OutStream close() during startup

Unsloth Studio crashed at server startup on Colab with:

  Unsloth Studio failed to start: 'OutStream' object has no attribute
  'watch_fd_thread'

Root cause:
- Colab's ipykernel OutStream is created with watchfd=False, so it never
  gains a watch_fd_thread. The OutStream.close() in the affected ipykernel
  versions joins that thread unconditionally and raises AttributeError
  (ipython/ipykernel#867).
- _setup_server_disk_logging() replaces sys.stdout/sys.stderr with a tee.
  That changes the console object identity, so Colab's absl logging handler
  (which captured the original OutStream and whose close() deliberately skips
  sys.stdout/sys.stderr) no longer treats it as the live console.
- run_server builds uvicorn.Config(...), whose configure_logging runs
  logging.config.dictConfig -> logging.shutdown, closing every existing
  handler. The absl handler then calls close() on the orphaned OutStream and
  the AttributeError propagates out of uvicorn.Config and aborts startup.

Fix:
- Before installing the tee, harden the displaced console streams' close() so
  only the ipykernel#867 AttributeError is swallowed; a healthy close() runs
  unchanged and any other error still propagates. The buggy close() raises
  before it nulls pub_thread, so the stream stays fully usable.
- Give _TeeStream its own close() that flushes the log copy and forwards
  close() to the wrapped console stream best-effort, so a handler that
  captured the tee cannot crash startup either.

Add regression tests reproducing the exact path (an absl-style handler closing
a watchfd=False OutStream stand-in during logging.shutdown) and asserting the
tee/console path survives and keeps logging.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Show the Colab login password in the shareable link card

* Tighten Colab card comments for PR #7404

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Make the Colab tunnel URL clickable and emphasise the password

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Narrow the console close() hardening to the watch_fd_thread AttributeError

* Put the Colab password on its own line so selection excludes the label

* Keep the Colab password as plain selectable text

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Daniel Han 2026-07-25 04:11:03 -07:00 committed by GitHub
commit c4b777263d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 512 additions and 22 deletions

View file

@ -218,11 +218,10 @@ def _colab_login_html(username: str, password: str) -> str:
Unsloth Studio Login (Colab)
</h2>
<p style="color: #333333; margin: 0 0 12px 0; font-size: 14px; font-weight: bold;">
Log in to Studio with the Cloudflare link above using these credentials. This cell
is visible only in your notebook session.
Log in as <code>{username}</code> with this password. This cell is visible only in
your notebook session.
</p>
<p style="color: #333333; margin: 0; font-size: 14px; font-family: monospace; font-weight: bold;">
Username: <code>{username}</code><br>
Password: <code>{password}</code>
</p>
</div>
@ -441,8 +440,29 @@ def _is_studio_healthy(port: int, timeout: float = 2.0) -> bool:
return False
def _shareable_link_html(cloudflare_url: str) -> str:
"""Branded card for the shareable Cloudflare link, styled like the show_link banner."""
def _shareable_link_html(
cloudflare_url: str,
password: "str | None" = None,
username: "str | None" = None,
) -> str:
"""Branded card for the shareable Cloudflare link, styled like the show_link banner.
*password* renders under the link so the credential sits in the card with the button
it unlocks. The username is always the default admin, so it reads inline.
"""
login_block = ""
if password:
login_block = f"""
<p style="color: #000000; margin: 16px 0 0 0; font-size: 20px; font-weight: 800;">
Password
</p>
<p style="margin: 6px 0 0 0;"><code style="display: inline-block; font-size: 24px;
font-weight: 800; text-decoration: underline; background: #f3f3f3;
padding: 4px 10px; border-radius: 6px;">{password}</code></p>
<p style="color: #666666; margin: 6px 0 0 0; font-size: 12px;">
Log in as <code>{username}</code> with this password. Shown only in your
notebook session, and never included in the shared link.
</p>"""
return f"""
<div style="display: inline-block; padding: 20px; background: #ffffff; border: 2px solid #000000;
border-radius: 12px; margin: 10px 0; font-family: system-ui, -apple-system, sans-serif;">
@ -460,11 +480,12 @@ def _shareable_link_html(cloudflare_url: str) -> str:
Open Unsloth Studio
</a>
<p style="color: #333333; margin: 12px 0 0 0; font-size: 14px; font-weight: bold;">
This Cloudflare HTTPS link works from any device share it with anyone. The Colab view below only works in this tab.
This Cloudflare HTTPS link works from any device, so you can share it with anyone.
</p>
<p style="color: #333333; margin: 16px 0 0 0; font-size: 13px; font-family: monospace; font-weight: bold;">
🔗 {cloudflare_url}
</p>
🔗 <a href="{cloudflare_url}" onclick="var w=window.open(this.href,'_blank');if(!w){{return true;}}return false;"
style="color: #000000; text-decoration: underline; cursor: pointer;">{cloudflare_url}</a>
</p>{login_block}
</div>
"""
@ -555,28 +576,37 @@ def _show_and_embed(
cloudflare_url = cloudflare_url,
)
# Fold the credentials into the link card rather than a second card below it.
credentials_shown = False
if cloudflare_url:
try:
from IPython.display import HTML, display
display(HTML(_shareable_link_html(cloudflare_url)))
username, password = colab_login if colab_login else (None, None)
display(HTML(_shareable_link_html(cloudflare_url, password, username)))
credentials_shown = bool(colab_login)
except Exception as e:
logger.info(f"Could not render Cloudflare link card ({e}).")
if colab_login:
if colab_login and not credentials_shown:
try:
_show_colab_login_credentials(*colab_login)
except Exception as e:
logger.info(f"Could not render Colab login card ({e}).")
try:
show_link(
port,
_url = url,
has_cloudflare_link = bool(cloudflare_url),
cloudflare_requested = cloudflare_requested,
)
except Exception as e:
logger.info(f"Could not render Unsloth link card ({e}).")
# With a tunnel up the embed below is skipped, so the ready card would only restate
# the link card and print a proxy URL that 404s outside this tab.
skip_ready_card = _is_colab_runtime() and bool(cloudflare_url)
if not skip_ready_card:
try:
show_link(
port,
_url = url,
has_cloudflare_link = bool(cloudflare_url),
cloudflare_requested = cloudflare_requested,
)
except Exception as e:
logger.info(f"Could not render Unsloth link card ({e}).")
# On Colab with a working tunnel, skip the in-cell proxy embed (often blank).
if _is_colab_runtime() and cloudflare_url:

View file

@ -991,10 +991,88 @@ class _TeeStream:
except Exception:
pass
def close(self):
# We do NOT own the console stream (it is the terminal / Jupyter kernel
# stream we wrapped), so closing the tee must never take the server down.
# Flush the log copy, then forward close() to the wrapped stream
# best-effort: on Colab that stream is an ipykernel OutStream whose
# close() can raise (see _harden_console_close / ipython/ipykernel#867).
try:
self._log_fh.flush()
except Exception:
pass
try:
self._stream.close()
except Exception:
pass
def __getattr__(self, name):
return getattr(self._stream, name)
_WATCH_FD_THREAD_ATTR = "watch_fd_thread"
def _is_missing_watch_fd_thread(exc):
"""True only for ipython/ipykernel#867's missing-``watch_fd_thread`` error.
``AttributeError.name`` exists from Python 3.10; the message carries the
attribute name on every version (possibly with a "Did you mean" tail), so
check both and let every other AttributeError through.
"""
if getattr(exc, "name", None) == _WATCH_FD_THREAD_ATTR:
return True
return _WATCH_FD_THREAD_ATTR in str(exc)
def _harden_console_close(stream):
"""Stop a displaced console stream's close() from aborting Studio startup.
``_setup_server_disk_logging`` replaces ``sys.stdout``/``sys.stderr`` with a
tee. That changes the object identity of the console stream, so a third-party
logging handler that captured the ORIGINAL stream (notably Colab's ``absl``
logging handler, whose ``close()`` skips ``sys.stdout``/``sys.stderr`` but not
a stream that is no longer either) treats it as an ordinary stream and calls
``close()`` on it during logging teardown -- ``uvicorn.Config()`` ->
``logging.config.dictConfig()`` -> ``logging.shutdown()``.
A Jupyter/Colab ``ipykernel`` ``OutStream`` created with ``watchfd=False``
(the Colab default, and every in-process kernel) never gains a
``watch_fd_thread``, yet the ``OutStream.close()`` shipped in the affected
ipykernel versions joins that thread unconditionally and raises
``AttributeError: 'OutStream' object has no attribute 'watch_fd_thread'``
(ipython/ipykernel#867). That AttributeError propagates out of
``uvicorn.Config(...)`` and aborts startup ("Unsloth Studio failed to start").
Wrap the stream's ``close()`` in a transparent pass-through that swallows
ONLY that specific teardown AttributeError. A healthy close() (a real console
stream, or an OutStream with fd-watching on) runs to completion exactly as
before and any other error still propagates, so nothing changes off Colab. A
stream whose ``close`` cannot be reassigned keeps its original close().
"""
try:
_orig_close = stream.close
except Exception:
return
def _safe_close(*args, **kwargs):
try:
return _orig_close(*args, **kwargs)
except AttributeError as exc:
if not _is_missing_watch_fd_thread(exc):
# A real teardown failure; never hide it.
raise
# ipython/ipykernel#867: watchfd=False OutStream.close() joins a
# thread that was never created. Nothing to clean up; keep going.
return None
try:
stream.close = _safe_close
except (AttributeError, TypeError):
# A stream that forbids setting instance attributes; leave it as-is.
pass
def _setup_server_disk_logging():
"""Tee stdout/stderr to ~/.unsloth/studio/logs/server/ and aim
faulthandler at the same file so hard crashes (access violations /
@ -1037,6 +1115,11 @@ def _setup_server_disk_logging():
# the stderr the server already captures.
os.environ.setdefault("PYTHONFAULTHANDLER", "1")
# Replacing the console streams orphans them from third-party "is this the
# live console?" checks, so guard their close() first (ipython/ipykernel#867).
_harden_console_close(sys.stdout)
_harden_console_close(sys.stderr)
sys.stdout = _TeeStream(sys.stdout, log_fh)
sys.stderr = _TeeStream(sys.stderr, log_fh)

View file

@ -336,17 +336,71 @@ def test_colab_login_html_includes_credentials():
html = colab._colab_login_html("unsloth", "alpha-beta-gamma-delta")
assert "unsloth" in html
assert "alpha-beta-gamma-delta" in html
# The username is fixed, so it reads inline rather than as its own field.
assert "Username:" not in html
def test_show_and_embed_renders_cloudflare_before_colab_login(monkeypatch):
def test_shareable_link_html_embeds_password_under_the_link():
"""The credential belongs in the same card as the button it unlocks."""
html = colab._shareable_link_html("https://share.trycloudflare.com", "secret-pass", "unsloth")
assert "share.trycloudflare.com" in html
assert "secret-pass" in html
# Username is stated inline, not as its own labelled field.
assert "Username:" not in html
assert "unsloth" in html
# The password must sit after the link, not above it.
assert html.index("share.trycloudflare.com") < html.index("secret-pass")
def test_shareable_link_html_renders_the_url_as_a_link():
"""The printed URL is an anchor, using the popup-safe open the button uses."""
html = colab._shareable_link_html("https://share.trycloudflare.com")
assert '<a href="https://share.trycloudflare.com"' in html
assert ">https://share.trycloudflare.com</a>" in html
assert html.count("window.open(this.href,'_blank')") == 2
def test_shareable_link_html_emphasises_the_password():
"""The password is the one thing to copy, so it is enlarged and underlined."""
html = colab._shareable_link_html("https://share.trycloudflare.com", "secret-pass", "unsloth")
pw_tag = html[html.index("Password") : html.index("secret-pass")]
assert "font-size: 24px" in pw_tag
assert "text-decoration: underline" in pw_tag
def test_shareable_link_html_password_has_no_adjacent_whitespace():
"""Whitespace beside the password is selected with it on a double click."""
html = colab._shareable_link_html("https://share.trycloudflare.com", "secret-pass", "unsloth")
before, after = html.split("secret-pass", 1)
assert before.endswith(">")
assert after.startswith("<")
# Label on its own line, so nothing shares the password's text node.
assert "Password:" not in html
# Plain selectable text: user-select overrides break double click to select.
assert "user-select" not in html
def test_shareable_link_html_omits_login_block_without_password():
html = colab._shareable_link_html("https://share.trycloudflare.com")
assert "Password" not in html
def test_show_and_embed_folds_login_into_the_cloudflare_card(monkeypatch):
"""One card, not two: the tunnel card carries the password itself."""
displayed: list[str] = []
ipython_display = SimpleNamespace(
HTML = lambda html: SimpleNamespace(html = html),
display = lambda html: displayed.append(html.html),
)
login_cards: list[tuple] = []
monkeypatch.setattr(colab, "get_colab_url", lambda port: "https://8888-test.prod.colab.dev/")
monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True)
monkeypatch.setattr(
colab,
"_show_colab_login_credentials",
lambda *args: login_cards.append(args),
)
monkeypatch.setattr(
colab,
"show_link",
@ -360,9 +414,74 @@ def test_show_and_embed_renders_cloudflare_before_colab_login(monkeypatch):
colab_login = ("unsloth", "secret-pass"),
)
assert len(displayed) == 2
assert len(displayed) == 1
assert "share.trycloudflare.com" in displayed[0]
assert "secret-pass" in displayed[1]
assert "secret-pass" in displayed[0]
assert login_cards == []
def test_show_and_embed_keeps_separate_login_card_without_tunnel(monkeypatch):
"""No tunnel card to fold into, so the standalone login card still renders."""
login_cards: list[tuple] = []
monkeypatch.setattr(colab, "get_colab_url", lambda port: "https://8888-test.prod.colab.dev/")
monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True)
monkeypatch.setattr(
colab,
"_show_colab_login_credentials",
lambda *args: login_cards.append(args),
)
monkeypatch.setattr(
colab,
"show_link",
lambda port, *, _url = None, has_cloudflare_link = False, cloudflare_requested = False: None,
)
monkeypatch.setattr(colab, "_embed_kernel_port_iframe", lambda port: True)
colab._show_and_embed(8888, colab_login = ("unsloth", "secret-pass"))
assert login_cards == [("unsloth", "secret-pass")]
def test_show_and_embed_skips_ready_card_when_tunnel_is_up(monkeypatch):
"""The ready card only restates the tunnel card and prints a proxy URL that 404s."""
calls: list[str] = []
monkeypatch.setattr(colab, "get_colab_url", lambda port: "https://8888-test.prod.colab.dev/")
monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True)
monkeypatch.setattr(
colab,
"show_link",
lambda port,
*,
_url = None,
has_cloudflare_link = False,
cloudflare_requested = False: calls.append("show_link"),
)
monkeypatch.setattr(colab, "_embed_kernel_port_iframe", lambda port: True)
colab._show_and_embed(8888, cloudflare_url = "https://share.trycloudflare.com")
assert calls == []
def test_show_and_embed_keeps_ready_card_without_tunnel(monkeypatch):
"""Without a tunnel the ready card is the only guidance, so it must stay."""
calls: list[str] = []
monkeypatch.setattr(colab, "get_colab_url", lambda port: "https://8888-test.prod.colab.dev/")
monkeypatch.setattr(colab, "_is_colab_runtime", lambda: True)
monkeypatch.setattr(
colab,
"show_link",
lambda port,
*,
_url = None,
has_cloudflare_link = False,
cloudflare_requested = False: calls.append("show_link"),
)
monkeypatch.setattr(colab, "_embed_kernel_port_iframe", lambda port: True)
colab._show_and_embed(8888)
assert calls == ["show_link"]
def test_show_and_embed_skips_iframe_on_colab_when_cloudflare_ready(monkeypatch):

View file

@ -0,0 +1,258 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Regression tests for the Colab "OutStream has no attribute 'watch_fd_thread'"
startup crash.
Field report (Colab): Unsloth Studio dies at server startup with
`` Unsloth Studio failed to start: 'OutStream' object has no attribute
'watch_fd_thread'``.
Root cause chain:
* Colab's ipykernel ``OutStream`` is created with ``watchfd=False``, so it
never gains a ``watch_fd_thread``; the ``OutStream.close()`` shipped in the
affected ipykernel versions joins that thread unconditionally and raises
``AttributeError`` (ipython/ipykernel#867).
* ``run._setup_server_disk_logging()`` replaces ``sys.stdout``/``sys.stderr``
with a ``_TeeStream``. That changes the console object identity, so Colab's
``absl`` logging handler -- which captured the ORIGINAL OutStream and whose
``close()`` deliberately skips ``sys.stdout``/``sys.stderr`` -- no longer
recognizes it as the live console.
* ``run_server`` builds ``uvicorn.Config(...)``, whose ``configure_logging`` ->
``logging.config.dictConfig`` -> ``logging.shutdown`` closes every existing
handler. The absl handler then calls ``OutStream.close()`` on the orphaned
stream, and the AttributeError aborts startup.
These tests reproduce the mechanism with a stand-in OutStream (Colab-identical
constructs are not importable off Colab) and assert the tee/console path used at
startup survives it.
"""
from __future__ import annotations
import io
import logging
import sys
import weakref
from pathlib import Path
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
import run as run_mod # noqa: E402
class _ColabOutStream(io.TextIOBase):
"""Stand-in for Colab's ipykernel OutStream built with ``watchfd=False``:
no ``watch_fd_thread`` and an unguarded ``close()`` that joins it
(ipython/ipykernel#867)."""
def __init__(self, name: str, sink: io.StringIO):
self.name = name
self._sink = sink
def write(self, s):
return self._sink.write(s)
def flush(self):
pass
def writable(self):
return True
def isatty(self):
return False
def close(self):
# Never set because watchfd=False -> AttributeError, exactly as Colab.
self.watch_fd_thread.join()
def __del__(self):
# io.TextIOBase.__del__ would call our buggy close() at GC (the harmless
# "Exception ignored" tail seen in Colab); silence it so the test is clean.
pass
class _WatchingOutStream(_ColabOutStream):
"""OutStream with fd-watching ON: ``watch_fd_thread`` exists, close() is
well behaved and must keep working unchanged."""
def __init__(self, name: str, sink: io.StringIO):
super().__init__(name, sink)
self.close_ran = False
self.watch_fd_thread = type("_T", (), {"join": lambda self: None})()
def close(self):
self.watch_fd_thread.join()
self.close_ran = True
class _AbslLikeHandler(logging.StreamHandler):
"""Mirror of ``absl.logging.PythonHandler.close()``: close the captured
stream unless it is (still) one of the user-managed console streams."""
def close(self):
try:
user_managed = (sys.stderr, sys.stdout, sys.__stderr__, sys.__stdout__)
if self.stream not in user_managed and (
not hasattr(self.stream, "isatty") or not self.stream.isatty()
):
self.stream.close()
except ValueError:
pass
super().close()
class TestHardenConsoleClose:
def test_neutralizes_watchfd_false_close(self):
stream = _ColabOutStream("stdout", io.StringIO())
with pytest.raises(AttributeError):
stream.close() # baseline: the ipykernel #867 bug is real
stream = _ColabOutStream("stdout", io.StringIO())
run_mod._harden_console_close(stream)
assert stream.close() is None # swallowed, no crash
def test_healthy_close_still_runs_fully(self):
stream = _WatchingOutStream("stdout", io.StringIO())
run_mod._harden_console_close(stream)
stream.close()
assert stream.close_ran is True
def test_only_attributeerror_is_swallowed(self):
class _Boom:
def close(self):
raise ValueError("real teardown failure")
stream = _Boom()
run_mod._harden_console_close(stream)
with pytest.raises(ValueError):
stream.close()
def test_unrelated_attributeerror_still_propagates(self):
# Only #867 is neutralized; a genuine missing attribute during teardown
# must still surface instead of looking like a clean close.
class _Console:
def close(self):
return self.not_a_real_attribute
stream = _Console()
run_mod._harden_console_close(stream)
with pytest.raises(AttributeError, match = "not_a_real_attribute"):
stream.close()
def test_swallowed_across_attributeerror_message_shapes(self):
# Python 3.12 appends a "Did you mean" tail; the match must survive it,
# and pre-3.10 AttributeErrors carry no ``name``, only the message.
class _Suggesting:
def close(self):
raise AttributeError(
"'OutStream' object has no attribute 'watch_fd_thread'. "
"Did you mean: '_watch_pipe_fd'?"
)
stream = _Suggesting()
run_mod._harden_console_close(stream)
assert stream.close() is None
def test_unsettable_close_is_left_alone(self):
# A stream whose close cannot be reassigned must not raise from hardening.
class _Frozen:
__slots__ = ()
def close(self):
return "ok"
stream = _Frozen()
run_mod._harden_console_close(stream) # must not raise
assert stream.close() == "ok"
class TestTeeStreamClose:
def test_tee_close_over_buggy_stream_never_raises(self):
console = _ColabOutStream("stdout", io.StringIO())
log = io.StringIO()
tee = run_mod._TeeStream(console, log)
tee.write("before-close")
tee.close() # must not raise despite the wrapped stream's broken close
assert log.getvalue() == "before-close"
def test_tee_close_flushes_log(self):
class _FlushCounting(io.StringIO):
def __init__(self):
super().__init__()
self.flushes = 0
def flush(self):
self.flushes += 1
super().flush()
console, log = io.StringIO(), _FlushCounting()
tee = run_mod._TeeStream(console, log)
tee.write("x")
tee.close()
assert log.flushes >= 1
class TestColabStartupRegression:
"""End-to-end: the exact trigger -- an absl-style handler closing the
orphaned OutStream during the ``logging.shutdown`` that uvicorn's
``uvicorn.Config`` -> ``dictConfig`` runs -- must not crash Studio, and the
tee must keep logging afterwards.
``logging.shutdown`` is driven over a LOCAL weakref list (identical code path
to ``logging.config._clearExistingHandlers``) so the global logging state and
pytest's own capture are untouched.
"""
def _make_console_and_handlers(self, monkeypatch):
out_sink, err_sink = io.StringIO(), io.StringIO()
out_stream = _ColabOutStream("stdout", out_sink)
err_stream = _ColabOutStream("stderr", err_sink)
monkeypatch.setattr(sys, "stdout", out_stream)
monkeypatch.setattr(sys, "stderr", err_stream)
# absl-like handlers capture the ORIGINAL OutStreams (as in Colab).
handlers = [_AbslLikeHandler(sys.stdout), _AbslLikeHandler(sys.stderr)]
return out_sink, err_sink, out_stream, err_stream, handlers
def test_baseline_reproduces_crash_without_fix(self, monkeypatch):
# Prove the test exercises the real path: swapping the console identity
# (what the tee does) makes the absl-like close hit #867.
_, _, out_stream, err_stream, handlers = self._make_console_and_handlers(monkeypatch)
try:
monkeypatch.setattr(sys, "stdout", io.StringIO())
monkeypatch.setattr(sys, "stderr", io.StringIO())
with pytest.raises(AttributeError, match = "watch_fd_thread"):
logging.shutdown([weakref.ref(h) for h in handlers])
finally:
# Neutralize so a lingering handler can't crash global teardown.
run_mod._harden_console_close(out_stream)
run_mod._harden_console_close(err_stream)
for h in handlers:
try:
h.close()
except Exception:
pass
def test_startup_survives_with_harden_and_tee(self, monkeypatch):
out_sink, _, out_stream, err_stream, handlers = self._make_console_and_handlers(monkeypatch)
# Exactly what _setup_server_disk_logging does before serving:
run_mod._harden_console_close(sys.stdout)
run_mod._harden_console_close(sys.stderr)
log_fh = io.StringIO()
monkeypatch.setattr(sys, "stdout", run_mod._TeeStream(sys.stdout, log_fh))
monkeypatch.setattr(sys, "stderr", run_mod._TeeStream(sys.stderr, log_fh))
# The close-storm uvicorn triggers via dictConfig -> logging.shutdown,
# closing the absl-like handlers over the (now orphaned) OutStreams.
logging.shutdown([weakref.ref(h) for h in handlers]) # must NOT raise
# The tee still tees to both console and disk afterwards.
print("post-startup-line")
sys.stdout.flush()
assert "post-startup-line" in out_sink.getvalue()
assert "post-startup-line" in log_fh.getvalue()