fix: Make launch-browser independent of Uvicorn log level#2317
Conversation
Refactor server startup event handling to use callback-based approach instead of parsing uvicorn log messages. This change introduces a new ShinyServer class that extends uvicorn.Server with an on_started callback, and a _run_uvicorn function to manage uvicorn configuration and lifecycle. The callback-based approach is more reliable and decouples application startup logic from uvicorn's logging implementation.
Use functools.partial for on_started so it is picklable in reload/multiprocess modes (fixes --launch-browser when Uvicorn INFO logs are disabled). Replace cast-based attribute access with getattr checks for config/server attributes (reload, workers, uds, started), remove SHINY_BROWSER_HOST/PORT env var side-effects, and update CHANGELOG.md.
Replace getattr usage with typed attribute access using typing.cast. Adds cast import and introduces config_attrs = cast(Any, config) for repeated access, uses cast(Any, server).started for server checks, and casts uds to str | None before removal. This refactor improves static typing (pyright/mypy) and removes dynamic getattr calls without changing runtime behavior.
schloerke
left a comment
There was a problem hiding this comment.
This is a clear improvement over the old log-string sniffing, and I verified the _run_uvicorn() mirror against uvicorn 0.50.2's run() — the reload/workers/uds/startup-failure logic matches. Pickling in reload mode is safe (reload_end is module-level; the partial(launch_browser, ...) path only runs in non-reload), the reload branch keeps autoreload websocket notifications working even without --launch-browser, and the port-conflict fallback (reload = False) correctly falls through to the browser-launch assignment.
A few comments inline — the dev_mode test leak is the one I'd like fixed before merge; the rest are take-it-or-leave-it.
One more thing to be aware of (not a defect): this PR stops setting SHINY_BROWSER_HOST/SHINY_BROWSER_PORT entirely. They're internal-only per a grep of the codebase, but they were visible to running user apps, so any out-of-tree code reading them will break silently.
…adiness # Conflicts: # CHANGELOG.md
…rkarounds Subclassing Config as ShinyConfig with declared reload/workers/uds attributes (and declaring started on ShinyServer) satisfies pyright's strict mode against the generated uvicorn stubs, which omit instance attributes. Also use a named argument for webbrowser.open(new=1).
ShinyConfig, ShinyServer, _run_uvicorn, maybe_setup_rsw_proxying, _set_workbench_kwargs, and ReloadArgs now live in shiny/_uvicorn.py.
The reload and launch-browser branches can never both assign on_started; explain why and assert the invariant.
The bare `import uvicorn` lost its last use when uvicorn.run() was replaced by _run_uvicorn(); the remaining uvicorn.config.* references are covered by `import uvicorn.config`. setup_hot_reload() had become a pure pass-through to _autoreload.start_server() with a single call site, so call it directly.
Bisected uvicorn 0.16.0..0.51.0 (uv run --with uvicorn==X per version): - uvicorn.config.STARTUP_FAILURE is only importable since 0.50.0, so the local _UVICORN_STARTUP_FAILURE copy is required at the >=0.16 floor - Config.load_app() was added in 0.47.0, so the hasattr guard is required - uvicorn._types.ASGIApplication was added in 0.23.0, so the TYPE_CHECKING guard is required - uvicorn.supervisors ChangeReload/Multiprocess exist at 0.16.0 (unguarded import is safe)
The 0.16.0 floor was fictional. On Posit Workbench, run_app() injects ws_per_message_deflate into uvicorn's Config, a keyword that only exists in uvicorn >= 0.17.0 -- so 'shiny run' on Workbench raised TypeError with the floor version installed. No CI job installed the floor, so the pin rotted unnoticed since 2021. Why 0.23.0 (July 2023): old enough to be universally available, new enough to cover the Workbench requirement with margin, and the first release to ship uvicorn._types.ASGIApplication, which shiny/_uvicorn.py references in type hints. Deliberately NOT 0.47.0/0.50.0, which would allow deleting the Config.load_app() hasattr guard and the local _UVICORN_STARTUP_FAILURE constant: those released 2026-05-14 and 2026-07-04 -- too recent to require. Boundaries were established by bisecting uvicorn 0.16.0..0.51.0 with isolated 'uv run --with uvicorn==X' probes. The new check-uvicorn-floor CI job installs uvicorn==0.23.0 on top of the normal dev setup and runs the unit tests, so the floor cannot silently rot again. Verified locally: full unit suite (864 tests) passes against uvicorn 0.23.0.
uvicorn 0.51.0 removed Multiprocess's target parameter (workers build their own Server from config), which broke pyright in CI against freshly generated stubs. Dispatch on the runtime signature and suppress the stub mismatch on both arms, since only one can match at a time.
Summary
Fixes #569 by replacing the
--launch-browserreadiness signal from Uvicorn log interception with a startup callback.This makes browser launch work when users run with
--log-level=warningor disable INFO logging.Changes
ShinyServer, a smalluvicorn.Serverwrapper that runs anon_startedcallback after startup.uvicorn.run()locally so Shiny can pass that callback through reload and non-reload modes.LaunchBrowserHandlerwith direct browser opening after startup._autoreload.reload_end()from startup instead.Manually tested the following cases
shiny run app.py --launch-browser --log-level=warning→ browser opensshiny run app.py --reload --launch-browser→ browser opens once