DioxusLabs / DioxusLabs/dioxus
`dx serve` only replays hot-reload backlog to the first WS client when stdout is not a TTY
- Dominant language
- Rust
- Stars
- 39.1k
- Forks
- 1.9k
- Avg merge
- 4d 10h
- Merged PRs (30d)
- 4
Description
# `dx serve` only replays hot-reload backlog to the first WS client when stdout is not a TTY
## Environment
- `dx` CLI: 0.7.6 (installed via `cargo install dioxus-cli --version 0.7.6 --locked`)
- `dioxus` crate: 0.7
- Platform: macOS 14 / Darwin 24.6.0, x86_64 and aarch64 both reproduce
- Target: `--platform web` (WASM)
## Summary
When `dx serve` is run without a controlling TTY (stdout piped/redirected, as happens in any orchestrator or shell script that runs dx in the background), the hot-reload "backlog replay on new WS client" behaviour breaks after the first connection. Fresh browser refreshes see the initial on-disk build forever, even though dx is logging `Hotreloading: ...` and live-connected clients are still receiving patches correctly.
Wrapping the same command in a PTY (e.g. BSD `script -q /dev/null dx serve ...`) makes every fresh WS client receive the backlog as expected.
## Reproduction
Minimal Dioxus 0.7 web crate with any RSX.
### Broken case (no TTY)
```bash
dx serve --platform web --addr 127.0.0.1 --port 8787 --open false > /tmp/dx.log 2>&1 &
# (optional) --interactive true <-- does not help; dx checks is_terminal()
```
1. Wait for `Build completed successfully`.
2. Edit `src/main.rs` in-place (preserving inode) — change any RSX text.
3. Observe `dx` log: `Hotreloading: /src/main.rs` ✅.
4. Connect a fresh WS client to `ws://127.0.0.1:8787/_dioxus`. **First** connect receives 1 message (~7.5 KB) containing the templates with the edit. ✅
5. Edit `src/main.rs` again. Log: `Hotreloading: /src/main.rs` again.
6. Connect 5 more fresh WS clients, each with a 1.5 s receive window. **All 5 receive zero bytes.** ❌
7. Any further source edits reproduce step 6 — the backlog never serves to subsequent fresh clients for the lifetime of this `dx serve`.
### Working case (PTY)
```bash
script -q /dev/null dx serve --platform web --addr 127.0.0.1 --port 8787 --open false
```
Identical steps. All 5 sequential fresh WS clients receive the ~7.5 KB backlog with the latest edit. ✅
### Minimal WS test harness (Node, uses repo-local `ws@8`)
```js
const WebSocket = require('ws');
for (let i = 1; i <= 5; i++) {
await new Promise((resolve) => {
const ws = new WebSocket('ws://127.0.0.1:8787/_dioxus');
let bytes = 0;
ws.on('message', (d) => bytes += d.length);
setTimeout(() => { console.log(`client ${i}: ${bytes} bytes`); ws.close(); resolve(); }, 1500);
});
await new Promise(r => setTimeout(r, 300));
}
```
Non-TTY output:
```
client 1: 7574 bytes # only AFTER the very first edit, before any client has connected
client 2: 0 bytes
client 3: 0 bytes
client 4: 0 bytes
client 5: 0 bytes
```
PTY output (`script -q /dev/null ...`):
```
client 1: 7560 bytes
client 2: 37800 bytes # received live patch mid-window
client 3: 7560 bytes
client 4: 7560 bytes
client 5: 7560 bytes
```
## Expected behaviour
Per `packages/cli/src/serve/mod.rs:106-119`, `ServeUpdate::NewConnection` fires unconditionally for every new WS client and `devserver.send_hotreload(builder.applied_hot_reload_changes(BuildId::PRIMARY))` should replay the cumulative backlog. This is documented in-code at `packages/cli/src/build/builder.rs` around the `patches` field: *"the cumulative history of patches applied to this run of the app — used so that a fresh client connecting mid-session can be brought up to date"*.
Every fresh WS client should receive the merged templates / assets / last jump_table regardless of whether stdout is a TTY.
## What I investigated
Reading `packages/cli/src/serve/**` at v0.7.6:
- `applied_hot_reload_changes()` (`runner.rs:835-854`) `.clone()`s the aggregate — it does not drain.
- `add_hot_reload_message()` (`runner.rs:920-938`) merges into `applied_client_hot_reload_message` and is reached from the `Hotreloading` branch at `runner.rs:534-577`. The log line proves this path runs.
- `clear_hot_reload_changes()` is called only on full-rebuild / hotpatch paths (`runner.rs:521, 530, 743`). None are gated on `interactive` / TTY. We never pass `--hot-patch`.
- The only TTY-gated logic in the CLI is `is_interactive_tty()` at `cli/serve.rs:107-110`:
```rust
pub(crate) fn is_interactive_tty(&self) -> bool {
use std::io::IsTerminal;
std::io::stdout().is_terminal() && self.interactive.unwrap_or(true)
}
```
This value only reaches `open_browser` (`runner.rs:126`), TUI rendering (`output.rs`), and tracer redirection (`runner.rs:98`). It is not read anywhere in the backlog, replay, or send paths.
- `server.rs:275-278` short-circuits `send_hotreload` when `reload.is_empty()`, but the clone returned by `applied_hot_reload_changes()` should not be empty after a successful `add_hot_reload_message` call.
So from a static-analysis standpoint the backlog should be populated and replay should fire. The TTY→replay-breaks correlation suggests a runtime-level issue — maybe stdout write back-pressure stalling the async task that services `WebServer::send_hotreload`, maybe a `tracing` layer buffering differently when stdout is a pipe, or an interaction between `--interactive=false` implicit defaults and the async runtime. I couldn't pin the exact cause without instrumented builds.
## Workaround
Wrap dx in a PTY. `script -q /dev/null dx serve ...` (BSD `script`, macOS) or `script -q -c "dx serve ..." /dev/null` (util-linux). Fixes the replay for every reconnect and requires no source changes.
## Why this matters
Any orchestrator that runs `dx serve` under a pipe (a shell script launched from `beforeDevCommand` in Tauri, a CI smoke test, a `concurrently`-style multiplexer) silently loses hot-reload across refreshes. The app appears to "revert to the initial build" on every Cmd+R, which looks like a caching bug until you trace it through WS frames.
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.