Dev Mode: Hot Reload Without the Heart Attack
Part of: Dev Docs
morph dev is the command you will run a thousand times, so it had better feel like magic and behave like engineering. It does both: the same in-process pipeline from The Compiler Pipeline runs on every save, but instead of producing a standalone binary, it hot-swaps fresh logic into a living window over a loopback TCP connection. The window, the GL context, and the layout tree stay alive. Only your mistakes get replaced.
The five moving parts
| Part | Lives in | Job |
|---|---|---|
| The watcher | crates/morph-build/src/dev.rs, driven by crates/morphc/src/commands/dev.rs |
Notices you saved, debounces, kicks the pipeline |
| The dev renderer | morph_devrt, built from runtime/cpp/dev/ via CMake in crates/morph-build/src/devrt.rs |
A prebuilt native window that knows how to receive new brains |
| The logic library | crates/morph-build/src/logic.rs |
Your app logic compiled per-change to a shared object (g++ -shared) |
| The pipe | crates/morph-build/src/ipc.rs + runtime/cpp/dev/dev_socket.h |
Loopback TCP (127.0.0.1:39573, ephemeral fallback on collision), buffered protocol |
| The new brain format | crates/morph-ir/src/serializer.rs + runtime/cpp/dev/ir_deserializer.h + json_parser.h |
Serialized IR document the dev runtime parses and installs |
The sequence, slowly
1. Boot. morph dev ensures the runtime is installed, then builds morph_devrt via CMake — but only if its source hash changed, so the second launch is fast. The dev renderer launches and announces its IPC address. This is the last restart you will see for a while.
2. Watch. Source directories are watched with notify, debounced at 100ms. The debounce exists because editors love saving files three times in a row and version control loves touching everything at once. One calm rebuild beats three panicked ones.
3. Recompile. On change: parse → CSS → IR → emit, exactly like a build. Then the logic is compiled to a shared library (g++ -shared) instead of linked into a standalone binary. Compiling only the logic is the whole trick — it is small, so it is fast.
4. Push. The serialized IR document travels over loopback TCP to morph_devrt. Loopback means it never leaves your machine; the ephemeral-port fallback means two morph dev sessions on one box do not fight over 39573.
5. Swap. The running window installs the new IR document and rewires logic without restarting. State signals are preserved through the swap via the signal store (runtime/cpp/dev/signal_store.h), nodes are matched through the node registry (node_registry.h), and morph_logic_rewire re-registers every subscription. Because re-registration runs on every reload, it calls clear_channels() first (see The Reactivity Engine) — otherwise one emit would fire N handlers after N reloads, and your counter would develop a caffeine problem.
The dev-runtime supporting cast
The runtime/cpp/dev/ directory is the part of the runtime that production binaries never see:
| Header | Role |
|---|---|
dev_socket.h |
The buffered socket protocol — framing, partial reads, the unglamorous plumbing that keeps the pipe from starving |
json_parser.h |
Parses the incoming IR document |
ir_deserializer.h |
Turns the parsed document into live nodes |
node_registry.h |
Matches new nodes to existing ones so state and focus survive the swap |
signal_store.h |
Preserves signal values across rewires |
logic_prelude.h |
What the hot-loaded logic expects to find on arrival |
inspector.h, dev_log.h, dev_net.h |
DevTools support: element inspection, log ring buffers, network request logging |
morph_api.h is the single public entry header the logic plugins build against — the front door with the welcome mat.
What survives a reload, and what doesn't
- Survives: the window, the GL context, the layout tree, signal values (via the signal store), and your dignity.
- Rebuilt: the logic shared object, the IR document, every event subscription (cleared, then re-registered).
- Reset: anything you only set up in startup-only code paths. If a value mysteriously returns to its initial state on every save, it lives somewhere the rewire path does not replay — that is your bug, and now you know where to look.
Failure modes, translated
| Symptom | Likely cause | Where to look |
|---|---|---|
| Change never appears | Watcher missed it, or the debounce ate a multi-save burst | dev.rs watch roots and debounce; check the CLI log for a rebuild line |
| Window restarts instead of swapping | Dev renderer rebuilt (source hash changed) or IPC connect failed | devrt.rs hash check; ipc.rs address announcement |
| Counter jumps by N after N saves | Subscriptions stacking — the clear_channels() guard regressed |
Rewire path; see the FAQ in State & Event Internals |
| Crash right after swap, referencing focus/capture | Use-after-free of s_focusedNode / s_mouseCapture across the swap |
core/node.h statics; the dtor clearing logic |
| Styles update but logic doesn't (or vice versa) | IR pushed but logic .so failed to compile, or the reverse |
logic.rs compile output — read the actual g++ error, it is usually honest |
Verify by
# From any fixture project: delete the stale binary first so fingerprinting
# doesn't skip the rebuild, then build without UPX for speed.
rm -f .morph/output/<name>*
<repo>/target/debug/morph build --no-upx
<binary> --morph-self-test # must report 0 failures
./tests/runtime/run-selftests.sh # the full fixture sweep, from the repo rootDev-mode changes deserve the same sweep: edit, save, watch the window update, then run the self-tests. Hot reload that only works when nobody checks is not hot reload — it is a demo.