Odin Speaks Odin ยท Chapter 19

Chapter 19: Live reload: editing a script while it runs

This chapter:

  • Watch a .mus file'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-wait or otherwise.
  • Grow musc run its 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 (inotify on Linux, FSEvents on macOS, ReadDirectoryChangesW on 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:os primitive 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-platform core:os call, 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 own hotreload.odin needs zero platform-specific code at all, exactly as portable as the rest of muspel/.

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 Chunk holds, 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's Token.lexeme); nothing in this book's compiler ever clones a string literal to give it its own, independent lifetime. Every earlier musc command got away with this for free: main's own defer 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.chunk needs to stay valid for as long as any VM instance might still be running it, and hotreload.odin has no way to know when that is. The first version of this file freed src right 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 later vm_resume call 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 own Environment allocation 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 ip onto 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 ^Chunk it already has a pointer to, completely unaffected by ws.chunk being 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 calls vm_push_script(vm, ws.chunk) with ws.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

  1. Watch_Poll_Interval :: 200 * time.Millisecond is a guess, not a measured value, the same category of unexamined constant section 18.2's own Gotcha warned about. Lower it to 20 * time.Millisecond and confirm examples/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.
  2. watched_script_poll calls os.modification_time_by_path once per suspension, not once per instruction. Sketch, in prose, what would go wrong (in either correctness or performance) if it were called once per vm_step instead, inside vm_call_run's own dispatch loop.
  3. Section 19.3's Gotcha explains why src is deliberately leaked forever. Estimate, given examples/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 .mus file?
  4. Extend watched_script_poll (or write a small wrapper around it) to watch a whole directory of .mus files rather than one path, reusing Watched_Script per file. What's the smallest change to cmd_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's modification_time_by_path and File_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.