Chapter 19: Live reload: editing a script while it runs
This chapter:
- Watch a
.musfile's own modification time, polling rather than depending on an OS-specific filesystem-event API. - Recompile a changed script safely: a broken edit never crashes the host or discards the last-good version.
- Hot-swap the compiled bytecode a new VM instance will use, without disturbing whatever an already-running instance is doing, suspended mid-
waitor otherwise. - Grow
musc runits final flag,--watch, this book's most visible, most demoable feature before Embers exists to make it visually satisfying.
Where we left off: every script musc has run so far has been compiled once, at startup, and never touched again. A designer iterating on a wave pattern or a boss's attack timing (the exact audience Chapter 1 opened with) would need to stop the host, edit, and restart it for every single change. This chapter is what removes that stop.
19.1 Watching a file without an OS-specific API
Design decision: poll the file's mtime once per tick, rather than a native filesystem-event API (
inotifyon Linux,FSEventson macOS,ReadDirectoryChangesWon Windows).Alternatives considered: a real filesystem-event API delivers a notification the instant a file changes, no polling delay, no wasted work checking a file that hasn't changed.
Trade-off: every one of those APIs is platform-specific, and none of them exists as a bare
core:osprimitive Odin ships uniformly across targets. Wiring up three separate platform-specific bindings (or reaching for a wrapping library, ruled out by this book's "only what Odin ships" constraint, ยง0) is a real cost for a real-time-sensitive host loop that's already polling something every tick anyway (vm_resume, once per scripted entity).Choice:
os.modification_time_by_path, one cross-platformcore:oscall, checked once per tick against whatever mtime was last seen. The cost is a small, fixed amount of stat-call overhead per tick per watched file, invisible next to everything else a host frame already does, and the win is that this book's ownhotreload.odinneeds zero platform-specific code at all, exactly as portable as the rest ofmuspel/.
Watched_Script is the whole mechanism's own state: a path, the mtime last seen, and the currently-active compiled chunk.
// muspel/hotreload.odin
Watched_Script :: struct {
path: string,
mtime: time.Time,
chunk: ^Chunk,
}
chunk is deliberately a pointer, not a value, and that single choice is what makes section 17.3's hard problem easy: swapping it never mutates the Chunk an existing Frame might already be pointing at (vm_push_script stores a pointer, never a copy), so an already-running VM instance keeps executing the exact bytecode it started with even after chunk itself has been swapped out from under it.
19.2 Recompiling safely
watched_script_poll is the whole reload attempt: check the mtime, and only if it's actually changed, try recompiling.
// muspel/hotreload.odin
watched_script_poll :: proc(ws: ^Watched_Script, allocator := context.allocator) -> (reloaded: bool, ok: bool) {
mtime, stat_err := os.modification_time_by_path(ws.path)
if stat_err != nil do return false, false
if time.diff(ws.mtime, mtime) == 0 do return false, false
ws.mtime = mtime
src, read_err := os.read_entire_file_from_path(ws.path, allocator)
if read_err != nil do return true, false
new_chunk := new(Chunk, allocator)
if !compile_source(new_chunk, string(src), allocator) {
return true, false
}
ws.chunk = new_chunk
return true, true
}
ws.chunk is only ever reassigned on the very last line, after a fully successful lex/parse/resolve/compile. A syntax error or a resolution error partway through typing a change returns ok = false and leaves ws.chunk exactly as it was: a designer mid-edit, with an unbalanced brace or a half-typed identifier on disk, never sees their in-progress typo take an entity's script down. compile_source is the same lex/parse/resolve/compile pipeline every musc command has repeated by hand since Chapter 7, pulled into one shared proc specifically because this is the first place in the book that needs to try it and cleanly back out on failure rather than print an error and stop:
// muspel/hotreload.odin
@(private = "file")
compile_source :: proc(chunk: ^Chunk, src: string, allocator := context.allocator) -> bool {
stmts, errors := parse_with_stdlib(src, allocator)
if len(errors) > 0 do return false
r := resolver_init(allocator)
resolve_program(&r, stmts, allocator)
if len(r.errors) > 0 do return false
chunk^ = chunk_init(allocator)
compile_program(chunk, stmts, allocator)
return true
}
Gotcha: the source buffer can't be freed the way every earlier command freed it. Every string constant a compiled
Chunkholds, an identifier, a string literal, is a slice straight into whatever source buffer produced it, all the way back to the lexer's own tokens (lexer.odin'sToken.lexeme); nothing in this book's compiler ever clones a string literal to give it its own, independent lifetime. Every earliermusccommand got away with this for free:main's owndefer delete(src)frees the buffer only after the whole command has already finished running the script, so the buffer outlives every use without anyone having to think about it. A reload target doesn't have that luxury:ws.chunkneeds to stay valid for as long as any VM instance might still be running it, andhotreload.odinhas no way to know when that is. The first version of this file freedsrcright after compiling, the same shape every earlier command already used; the result was a genuine use-after-free, a compiled string constant reading back as garbage bytes the instant a latervm_resumecall tried to look one up, caught only by actually running the reload demo end to end rather than trusting that "compiles without error" meant "runs correctly." The fix is to never free it: leak the buffer, one per successful compile, for the life of the process. This is the identical trade-off the tree-walker's ownEnvironmentallocation already made back in Chapter 8 ("the garbage-free- but-leaking allocator keeps around for us," that chapter's own words for it), applied here for the same reason: nothing in this book's scope needs a script's memory back before the whole process exits, so nothing here spends any effort trying to reclaim it.
19.3 The hard part: swapping bytecode under a suspended instance
The genuinely hard question this chapter has to answer isn't "how do I recompile a file," it's "what happens to a VM instance that's currently suspended, mid-wait, inside the old version of a function that just got recompiled?" Its own Frame.chunk still points at the old ^Chunk; its own Frame.ip is an offset into that old chunk's own byte layout, which the new compile has no obligation to match at all (a single added or removed instruction anywhere earlier in the function shifts every offset after it).
Design decision: don't migrate a suspended instance's in-flight state across an incompatible bytecode change.
Alternatives considered: attempt to map the old
iponto the corresponding point in the new bytecode, and continue the suspended call there, as if the edit had always been in effect. Some production hot-reload systems attempt exactly this (Unity's own domain-reload machinery, named generally as the recognisable shape this alternative takes in a real, shipping tool).Trade-off: doing this correctly, in general, for arbitrary edits (a local variable removed, a loop restructured, a function's own arity changed) is a genuinely hard problem with no clean answer in the general case; real systems that attempt it accept real, occasionally surprising edge cases as the cost of trying. That's an honest trade a much larger, more mature engine can make; it's disproportionate machinery for what this book's own scope needs.
Choice: a currently-suspended instance simply keeps running on the exact
^Chunkit already has a pointer to, completely unaffected byws.chunkbeing reassigned underneath it, until it finishes (or is discarded, Chapter 15's own cancellation pattern). Only a new instance, created after the swap, picks up the new bytecode, the moment it callsvm_push_script(vm, ws.chunk)withws.chunk's now-current value. Less magical than true state migration, and the book says so plainly, in favour of a simple, honest rule that's also, not incidentally, already proven correct by section 19.2's own pointer-swap design: there's no migration code to get wrong because there's no migration at all.
examples/reload_demo.mus:
print("running version 1")
wait(0.3)
print("finished")
Started under musc run --watch, then edited (in a second terminal) to print "running version 2" instead, while the first run is still suspended inside its own wait(0.3):
./run.sh run --watch examples/reload_demo.mus
watching examples/reload_demo.mus (3 run(s), polling every 200ms)
--- run 1 ---
running version 1
[reload] examples/reload_demo.mus recompiled; this run keeps its own bytecode, the next run will use the new version
finished
--- run 2 ---
running version 2
finished
--- run 3 ---
running version 2
finished
Read this closely against section 19.3's own claim. Run 1 already printed "running version 1" before the edit landed; the edit is detected while run 1 is suspended inside wait; and run 1 still finishes by printing "finished", never "running version 2", because its own Frame never stopped pointing at the chunk it started with. Only run 2, a brand new VM instance pushed fresh against ws.chunk's now-updated value, ever sees the new source. Introducing a syntax error instead of a working edit (a missing closing parenthesis, say) produces the same shape with the other outcome:
--- run 1 ---
running version 1
[reload] examples/reload_demo.mus has a compile error; still serving the last-good version
finished
--- run 2 ---
running version 1
finished
ws.chunk was never reassigned; every later run keeps using the last version that actually compiled, exactly as section 19.2 promised.
19.4 musc run --watch
The whole host loop this chapter needed is short, because sections 17.1-17.3 already did the real work:
// tools/musc/watch_cmd.odin
cmd_run_watch :: proc(path: string) {
ws, ok := muspel.watched_script_load(path)
if !ok {
fmt.eprintfln("musc: %s failed to load or compile", path)
return
}
fmt.printfln("watching %s (%d run(s), polling every %v)", path, Watch_Run_Count, Watch_Poll_Interval)
for run_index in 1 ..= Watch_Run_Count {
fmt.printfln("--- run %d ---", run_index)
vm := muspel.vm_init()
muspel.vm_push_script(&vm, ws.chunk)
for {
result := muspel.vm_resume(&vm)
if result != .Suspended do break
time.sleep(Watch_Poll_Interval)
reloaded, compiled_ok := muspel.watched_script_poll(&ws)
if !reloaded do continue
if compiled_ok {
fmt.printfln("[reload] %s recompiled; this run keeps its own bytecode, the next run will use the new version", path)
} else {
fmt.printfln("[reload] %s has a compile error; still serving the last-good version", path)
}
}
}
}
Watch_Run_Count (3) bounds the demo to a fixed, reportable number of runs rather than looping forever, which is what makes this checkpoint reproducible in a book: no signal needs to be sent to stop it, and the exact transcripts in section 19.3 are real, complete command output, not an excerpt trimmed from an unbounded session. The poll only ever happens between vm_resume calls, while the current run is genuinely suspended: an instance that never yields runs straight through to completion without ever checking the file at all, which is correct, since there's no moment during a non-suspended run where swapping anything could be observed as anything other than "the next run used the new version," exactly what a suspended run already demonstrates.
This chapter is scoped honestly to a single file: watched_script_load/watched_script_poll watch exactly the one path they were given. A script that imports a shared module (Chapter 20) is a case this file-watcher doesn't yet handle: editing the imported file has no effect until the importing script itself changes. Chapter 20 says so plainly, rather than pretending otherwise, and leaves extending this chapter's own watcher across a whole import graph as a named, scoped-out piece of future work: the underlying rule (let a suspended instance finish, only new instances pick up a change) would carry over unchanged, but tracking a whole dependency graph's mtimes, rather than one path, is real, additional bookkeeping this book's own --watch doesn't build.
Checkpoint
musc run --watch examples/reload_demo.mus, edited in a second terminal while its first run is suspended, finishes that first run on the version it started with and picks up the edit on the very next run. Introducing a syntax error instead reports it cleanly and keeps serving the last-good version indefinitely, never crashing and never discarding working bytecode.
Exercises
Watch_Poll_Interval :: 200 * time.Millisecondis a guess, not a measured value, the same category of unexamined constant section 18.2's own Gotcha warned about. Lower it to20 * time.Millisecondand confirmexamples/reload_demo.mus's own three-run demo still behaves identically; then explain why this constant, unlike Chapter 18's instruction-budget hard cap, has no equivalent risk of a false positive against a legitimate workload, no matter how small it's set.watched_script_pollcallsos.modification_time_by_pathonce per suspension, not once per instruction. Sketch, in prose, what would go wrong (in either correctness or performance) if it were called once pervm_stepinstead, insidevm_call_run's own dispatch loop.- Section 19.3's Gotcha explains why
srcis deliberately leaked forever. Estimate, givenexamples/reload_demo.mus's own file size and a designer saving an edit roughly once every ten seconds during an hour-long tuning session, how much memory this leak actually costs over that session. Is this a real production concern for Embers (Chapter 21), or a cost this book's own scope can fairly treat as negligible? What would change your answer for a much larger.musfile? - Extend
watched_script_poll(or write a small wrapper around it) to watch a whole directory of.musfiles rather than one path, reusingWatched_Scriptper file. What's the smallest change tocmd_run_watch's own loop that would let it report which specific file changed, given multiple files might exist under watch at once?
Further reading
- Unity's domain-reload feature: a short, credited pointer to the general shape of "edit and continue" in a real, shipping engine, and the specific alternative section 19.3's own Design decision named and declined to build.
core:os'smodification_time_by_pathandFile_Info: https://pkg.odin-lang.org/core/os/- Crafting Interpreters has no direct equivalent to this chapter: clox compiles once and runs once, with no host loop and no notion of a script being edited while it's live, both being this book's own additions to the shape that book established.