Keep full-logging alive in granian workers after post-fork dictConfig
Type of change
- Bug fix (non-breaking change which fixes an issue)
Changes To Core Features:
- Have you added an explanation of what your changes do and why you'd like us to include them?
- Have you written new tests for your core changes, as applicable?
- Have you successfully ran tests with your changes locally?
Defect
FINDING-005 from 0.9.9a1 pre-release testing (logging_cli cluster, verifier-confirmed): with REFLEX_ENABLE_FULL_LOGGING, granian's worker startup (granian/server/mp.py::WorkerProcess.wrap_target) runs granian.log.configure_logging -> logging.config.dictConfig before loading the app. Stdlib dictConfig unconditionally runs _clearExistingHandlers(), which closes every handler in the process โ including the fork-inherited reflex full-logging FileHandler. Since the handler was opened with mode="w", Python 3.10+ FileHandler.emit refuses to reopen it once closed (it would truncate), so every worker-side record was silently dropped from the log file โ precisely the records full logging exists to capture.
Worse, log_file_stream() then returned the closed handler's None stream (despite its comment claiming that could never happen), and the legacy console.print_to_log_file writer's rich.Console(file=None) falls back to stdout: every legacy console call in a worker leaked a duplicated [YYYY-mm-dd HH:MM:SS.ffffff] ... plain-text line, breaking the machine-readable --json output contract (11 non-JSON lines interleaved with JSON records in the repro). Regression introduced by #6863, which routed the legacy file console through the logging FileHandler's stream (0.9.8's file console opened its own append-mode file that dictConfig could not touch).
Fix
All in reflex_base/utils/log.py (+ one line in console.py), no post-fork hook needed โ the pipeline now self-heals lazily, which also protects against any application-side dictConfig:
_file_handler()truncates the log file explicitly at creation and opens the handler in append mode. The stdlib then reopens a closedmode="a"handler on the nextemit, so fork-inherited worker handlers resume writing instead of dropping records; appends are atomic (O_APPEND), letting the CLI parent and the reopened worker copies safely share one file. Per-run truncation semantics are unchanged.log_file_stream()reopens a closed stream under the handler lock instead of handing writers a dead one.- The legacy console file writer now holds a stable proxy file object (
log_file_proxy()) that resolves the live stream on every write โ it can neither fall back to stdout (the--jsonleak) nor crash withValueErroron a stale closed stream captured before the fork.
Test plan
- New unit tests in
tests/units/reflex_base/utils/test_log.py, written first and shown failing on unfixed main with the exact three failure modes:test_file_handler_survives_external_dictconfig(post-dictConfig record missing from the file),test_log_file_console_targets_file_after_external_close(timestamped line leaked to captured stdout),test_cached_log_file_console_survives_external_close(ValueError: I/O operation on closed file), plustest_file_handler_truncates_previous_runguarding the truncate-then-append semantics. All 52 tests in the module pass with the fix. - Real repro: scratch app with an
api_transformerroute emittingconsole.info/console.warn/reflex_base.*logger records,REFLEX_ENABLE_FULL_LOGGING=1 REFLEX_LOG_FILE=... reflex run --backend-onlyunder granian with the fork start method (the default on Python <= 3.13; forced viamultiprocessing.set_start_method("fork")on this 3.14 dev env, where granian otherwise coerces forkserver to spawn and the bug cannot trigger). Before: 0 worker records in the log file, 6 leaked timestamped stdout lines, and with--json11 non-JSON lines interleaved. After: all worker records (legacy shim + pipeline) land in the same file as the parent's, 0 leaked lines, and--jsonstdout is 14/14 valid JSON lines. uv run ruff check ./uv run ruff format .clean;uv run pyright reflex testsand pyright on the touched reflex-base files: 0 errors.
๐ค Generated with Claude Code
https://claude.ai/code/session_01EMjBXPozsNeQNSBZecNH8x
Generated by Claude Code