Skip to content

Commit a06ff7e

Browse files
committed
feat(watch): filter Bun native-runtime noise from watcher stderr
The Bun-built tailwindcss standalone binary occasionally leaks unhandled native-module crash traces (EIO on shutdown, ERR_DLOPEN_FAILED on the rare DLOPEN race). Capture stderr per watcher and run a daemon thread that drops the upstream noise while forwarding Tailwind's own diagnostics verbatim. Cleanup output stays clean even when users kill -TERM the runserver.
1 parent 3322b3d commit a06ff7e

3 files changed

Lines changed: 126 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
- **SIGTERM-graceful shutdown for both watch managers**: `ProcessManager` and `MultiWatchProcessManager` now install a SIGTERM handler only when running on the main thread, so `kill -TERM` cleans up child watchers in `tailwind watch --noreload` and `tailwind runserver` without re-introducing the autoreloader crash. SIGINT keeps using Python's default handler (which raises `KeyboardInterrupt`).
88
- **Sidestepped a Bun DLOPEN race in multi-entry watch**: `MultiWatchProcessManager` now staggers successive watcher subprocess spawns by 300 ms. The Bun-built tailwindcss standalone binary extracts its embedded `@parcel/watcher` native module to `/$bunfs/` on first use; two parallel processes raced on that path and the loser crashed with `ERR_DLOPEN_FAILED`. The 300 ms gap is below noticeable in interactive use.
99

10+
### 🛠️ Developer Experience
11+
- **Filtered Bun native-runtime noise from watcher stderr**: `MultiWatchProcessManager` captures each `tailwindcss` subprocess' stderr and drops upstream Bun stack traces (`EIO: i/o error` on shutdown, `ERR_DLOPEN_FAILED` on the rare DLOPEN race) while forwarding Tailwind's own diagnostics verbatim. Cleanup output stays clean even when the user `kill -TERM`s the runserver.
12+
1013
### 🔧 Technical Improvements
1114
- **Hardened GitHub Actions workflows**: pinned all actions to commit SHAs, scoped top-level permissions, added concurrency groups, moved `github.ref_name` / `github.repository` out of shell interpolation into `env:` vars, and added a [zizmor](https://docs.zizmor.sh/) audit job to keep workflow security regressions out of CI.
1215

src/django_tailwind_cli/management/commands/tailwind.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
import importlib.util
44
import functools
55
import os
6+
import re
67
import signal
78
import subprocess
89
import sys
910
import threading
1011
import time
1112
from pathlib import Path
1213
from types import FrameType
13-
from typing import Any
14+
from typing import IO, Any
1415
from collections.abc import Callable
1516

1617
from django_tailwind_cli.utils import http
@@ -51,6 +52,44 @@
5152
# without being noticeable in interactive use.
5253
_WATCH_SPAWN_STAGGER_S = 0.3
5354

55+
# Bun-built tailwindcss occasionally leaks unhandled native-module errors
56+
# (DLOPEN race on startup, EIO on shutdown when its watch FD is closed under
57+
# it). The traces are upstream noise — neither actionable nor caused by
58+
# user code. We drop matching lines from forwarded stderr while keeping
59+
# Tailwind's own diagnostics intact.
60+
_BUN_NOISE = re.compile(
61+
r"""
62+
^EIO:\ i/o\ error | # EIO header
63+
^Bun\ v\d | # crash footer
64+
^error:\ dlopen\( | # DLOPEN header
65+
^\d+\ ?\| | # numbered source-context line
66+
^\s+(?:fd|syscall|errno|code): | # error-detail field
67+
^\s+at\ <anonymous>\ \(/\$bunfs/ | # bunfs stack frame
68+
^\s+\^\s*$ | # caret pointer
69+
^\s+code:\s*"(?:EIO|ERR_DLOPEN_FAILED)" # error-code value
70+
""",
71+
re.VERBOSE,
72+
)
73+
74+
75+
def _is_bun_noise(line: str) -> bool:
76+
"""Return True if the line looks like a Bun native-runtime crash trace."""
77+
return bool(_BUN_NOISE.match(line))
78+
79+
80+
def _drain_filtered_stderr(stream: IO[str], is_shutting_down: Callable[[], bool]) -> None:
81+
"""Forward subprocess stderr to the parent's stderr, dropping Bun noise.
82+
83+
Runs in a daemon thread until the subprocess closes the pipe. Drops every
84+
line once `is_shutting_down()` returns True — post-shutdown stderr is not
85+
actionable and just churns output during cleanup.
86+
"""
87+
for line in stream:
88+
if is_shutting_down() or _is_bun_noise(line):
89+
continue
90+
sys.stderr.write(line)
91+
sys.stderr.flush()
92+
5493

5594
# DECORATORS AND COMMON SETUP ---------------------------------------------------------------------
5695

@@ -1166,15 +1205,25 @@ def start_watch_processes(self, config: Config, *, verbose: bool = False) -> Non
11661205
typer.secho(f"🚀 Starting watch for '{entry.name}'...", fg=typer.colors.CYAN)
11671206
typer.secho(f" • Command: {' '.join(watch_cmd)}", fg=typer.colors.BLUE)
11681207

1169-
# Inherit stdout/stderr so output flows to the terminal and we
1170-
# avoid a pipe-fill deadlock — the OS pipe buffer would otherwise
1171-
# block the watcher after ~64 KB of rebuild status lines.
1208+
# Inherit stdout (high-volume rebuild progress) to avoid a pipe-fill
1209+
# deadlock — the OS pipe buffer would otherwise block the watcher
1210+
# after ~64 KB. stderr is captured so we can filter Bun's native-
1211+
# runtime noise (DLOPEN race, EIO on shutdown) before it hits the
1212+
# terminal; volume there is low enough that the drain thread keeps
1213+
# up easily.
11721214
process = subprocess.Popen(
11731215
watch_cmd,
11741216
cwd=settings.BASE_DIR,
11751217
text=True,
1218+
stderr=subprocess.PIPE,
11761219
)
11771220
self.processes.append(process)
1221+
if process.stderr is not None:
1222+
threading.Thread(
1223+
target=_drain_filtered_stderr,
1224+
args=(process.stderr, lambda: self.shutdown_requested),
1225+
daemon=True,
1226+
).start()
11781227
typer.secho(f"Watching '{entry.name}': {entry.src_css}", fg=typer.colors.GREEN)
11791228

11801229
self._monitor_processes()

tests/test_integration.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66
# pyright: reportPrivateUsage=false
77

8+
import io
89
import os
910
import platform
1011
import signal
@@ -29,6 +30,8 @@
2930
_WATCH_SPAWN_STAGGER_S,
3031
MultiWatchProcessManager,
3132
ProcessManager,
33+
_drain_filtered_stderr,
34+
_is_bun_noise,
3235
_run_watch_loop,
3336
)
3437

@@ -343,6 +346,7 @@ def mock_download_func(
343346
mock_process = Mock()
344347
mock_process.poll.return_value = 0 # Process already exited
345348
mock_process.wait.return_value = 0
349+
mock_process.stderr = None # skip the stderr drain thread
346350
mock_popen.return_value = mock_process
347351

348352
call_command("tailwind", "watch")
@@ -405,6 +409,7 @@ def mock_download_func(
405409
mock_process = Mock()
406410
mock_process.poll.return_value = 0 # already exited → monitor loop returns immediately
407411
mock_process.wait.return_value = 0
412+
mock_process.stderr = None # skip the stderr drain thread
408413
mock_popen.return_value = mock_process
409414

410415
errors: list[BaseException] = []
@@ -460,6 +465,7 @@ def mock_download_func(
460465
mock_process = Mock()
461466
mock_process.poll.return_value = 0 # exits immediately so monitor loop returns
462467
mock_process.wait.return_value = 0
468+
mock_process.stderr = None # skip the stderr drain thread
463469
mock_popen = mocker.patch("subprocess.Popen", return_value=mock_process)
464470

465471
# Patch only the time reference inside tailwind.py to avoid disturbing
@@ -587,6 +593,70 @@ def test_signal_handler_is_idempotent(self, manager_cls: type, capsys: CaptureFi
587593
assert manager.shutdown_requested is True
588594

589595

596+
class TestBunStderrFilter:
597+
"""Pattern-match and drain logic for filtering Bun native-runtime crash traces."""
598+
599+
@pytest.mark.parametrize(
600+
"line",
601+
[
602+
"EIO: i/o error, read\n",
603+
" fd: 7,\n",
604+
' syscall: "read",\n',
605+
" errno: -5,\n",
606+
' code: "EIO"\n',
607+
"Bun v1.2.8 (macOS arm64)\n",
608+
"error: dlopen(/$bunfs/root/watcher-tzt47keb.node, 0x0001): tried...\n",
609+
" at <anonymous> (/$bunfs/root/tailwindcss-macos-arm64:22717:29)\n",
610+
' code: "ERR_DLOPEN_FAILED"\n',
611+
"22716 | module.exports = __require(...);\n",
612+
" ^\n",
613+
],
614+
)
615+
def test_is_bun_noise_drops_known_patterns(self, line: str):
616+
assert _is_bun_noise(line) is True
617+
618+
@pytest.mark.parametrize(
619+
"line",
620+
[
621+
"≈ tailwindcss v4.1.3\n",
622+
"Done in 32ms\n",
623+
'[ERROR] Could not resolve "tailwindcss"\n',
624+
"Watching 'admin': /path/to/admin.css\n",
625+
"warn - Cannot find module './foo'\n",
626+
"\n", # blank line — drained but not filtered
627+
],
628+
)
629+
def test_is_bun_noise_keeps_legitimate_lines(self, line: str):
630+
assert _is_bun_noise(line) is False
631+
632+
def test_drain_filtered_stderr_drops_bun_noise(self, capsys: CaptureFixture[str]):
633+
stream = io.StringIO(
634+
"Done in 32ms\n"
635+
"EIO: i/o error, read\n"
636+
" fd: 7,\n"
637+
"Bun v1.2.8 (macOS arm64)\n"
638+
'[ERROR] Could not resolve "tailwindcss"\n'
639+
)
640+
641+
_drain_filtered_stderr(stream, lambda: False)
642+
643+
err = capsys.readouterr().err
644+
assert "Done in 32ms" in err
645+
assert "[ERROR] Could not resolve" in err
646+
assert "EIO" not in err
647+
assert "Bun v" not in err
648+
assert "fd: 7" not in err
649+
650+
def test_drain_filtered_stderr_drops_everything_after_shutdown(self, capsys: CaptureFixture[str]):
651+
"""Post-shutdown stderr is just churn from terminating subprocesses; drop it."""
652+
stream = io.StringIO("Done in 32ms\n[ERROR] something legit\n")
653+
654+
_drain_filtered_stderr(stream, lambda: True)
655+
656+
err = capsys.readouterr().err
657+
assert err == ""
658+
659+
590660
class TestCLIDownloadIntegration:
591661
"""Test CLI download and setup workflows."""
592662

0 commit comments

Comments
 (0)