Hot Reload
Hot reload is what redefinition means in JaiScript. Running a class Foo { ... } declaration while Foo exists replaces the class and migrates every live instance in place; running a function declaration rebinds the global. The host only re-runs the new source: execute, execute_file, or a script import whose file changed.
The contract.
- Check, then apply. A changed module is parsed and strictly checked, with the modules it imports and every unit already loaded, before any of it runs. A mistake rejects the reload with a catchable error naming file and line; nothing is applied, the program keeps the code it had, and the edit is retried when a file involved changes again.
- Applying replaces in place. Functions and classes swap, live instances migrate field by field (a migration never fails: a value with no conversion takes the new initializer), and the module's top-level statements run again.
- State survives by declaration. A variable declared with an initializer takes the new value; one declared without (
World world;,int kills;) keeps the value it holds. Put rules in modules that reload, and state in such variables or in a file nothing re-runs. - The re-run is ordinary code, with no rollback. A run-time error in the re-run's top-level statements, or a throw from a file's reload hook, surfaces to the code that reached the import, and the program stays as the re-run left it: declarations already replaced stay replaced. An error in one instance's class hook leaves that instance with the fields migration gave it, and the other instances still migrate.
- Reload is pull-based. A module re-runs when an
importof it is reached after its file changed. No watcher thread exists, so a re-run's side effects land where control flow puts them.
The standalone runner's jaiscript --watch file.jai re-runs a file on every save in a fresh engine, which suits a script that prints; state that survives an edit needs the host loop below.
Instance migration rules
One engine, two executes of Player:
eng->execute(R"( class Player { int hp = 100; float speed = 1.0; string name = "Unnamed"; }
var p = Player(); p.hp = 62; p.name = "Alice"; )");
eng->execute(R"( class Player {
string hp = "0"; // retyped
string name = "Unnamed"; // kept
int level = 1; // new
// speed removed
void hot_reload_migrate() { level = 5; }
} )");
eng->execute(R"( print("hp: ", type_of(p.hp), " ", p.hp);
print("name: ", p.name);
print("level: ", p.level);
try { print(p.speed); } catch (e) { print("speed: ", e); } )");hp: string 62
name: Alice
level: 5
speed: Player has no member 'speed'Every change, statics, constructors, base classes and C++ bases included: instance migration. The four the sample shows:
| change | what happens to live instances |
|---|---|
| field kept (same name and type) | value preserved, even if the initializer changed |
| field added | takes the new initializer |
| field removed | gone; access raises an error |
| field retyped | the value converts as an assignment would; a value with no conversion takes the new initializer |
The cost is linear in live instances and fields. To give live objects a changed default, write it in a hook or an update function.
The hot_reload_migrate hook
If the new class defines void hot_reload_migrate(), it runs once per live instance after field migration, on a reload that adds, removes or retypes a field. A reload that changes only method bodies or initializers migrates nothing and runs no hook. Inside the hook the removed fields are still readable:
class Config {
int version = 1;
int flags = 42;
}class Config {
int version = 2;
var settings = {}; // new field
// flags removed
void hot_reload_migrate() {
[[warn_mode]]
if (this.flags == 42) { // the OLD field, still readable here
this.settings = {"debug": true, "level": 5};
}
}
}include "config_v1.jai";
var c = Config();
include "config_v2.jai"; // the reload: c migrates in place
print(c.version); // kept its runtime value
print(c.settings["level"]); // the hook read the removed field1
5Each reload runs only the incoming class's hook, and an instance a hook constructs is already of the new class, so it is not migrated again.
Why the [[warn_mode]]. this.flags is not a member of the class as declared, and checking is strict. The checker lets a redefinition read fields of the shape the engine already holds, so the hook passes when the old class came from an earlier execute. Here both includes belong to one program, checked in full before its first statement runs, and a fresh engine holds no old shape either: without the attribute the load fails with no member 'flags' in class 'Config'. So put [[warn_mode]] on the statement that reads removed fields; it changes nothing at run time (Escape hatches).
A file's reload hook
A file that declares void hot_reload_migrate(map<string, var> previous) at its top level has it run once after each re-run of that file (an import reload, execute_file of the same path, the same string executed again, a top-level include run again by the same unit), after the re-run's top-level code and never on the first load. previous maps every program-scope variable the file declared to its value from before the re-run, including ones the new text removed, retyped or initializes again. The guide has a worked example.
- The hook belongs to the file's unit and is not a global: nothing can call it, each file keeps its own, and each run takes it from its own text.
- The checker types its signature (a wrong one is a load error spelling the required form) and reserves the name at a file's top level only.
- A throw from the hook is catchable by the code that reached the import, with no rollback.
- The old variables are snapshotted only when the new text declares a hook.
- A
jaibitesaves the hook with its program but runs none.
Code on the live call stack
Redefining a function or class while it is executing follows one rule: frames already on the stack finish with their old bodies, the global binding is replaced at once so recursion and later calls reach the new one, and the new definition persists. A function that swaps itself out:
function damage(int x) -> int {
print("v1 body: damage({})", x);
import "damage_v2.jai"; // program scope: swaps the global binding while this frame is live
if (x > 1) { print("recursive call, new body: {}", damage(1)); }
return x; // this frame finishes on the old body
}
print("outer call, old body: {}", damage(10));
print("later call, new body: {}", damage(10));with damage_v2.jai:
function damage(int x) -> int { return x * 3; }v1 body: damage(10)
recursive call, new body: 3
outer call, old body: 10
later call, new body: 30The same rule covers a class reloaded while one of its methods is on the stack and a reentrant execute(), which neither disturbs the suspended outer run nor leaks exceptions into it. A coroutine suspended at the reload keeps its old body until it is rebuilt.
The swap uses import because an import always acts at program scope. An include inside a function body is spliced into that body: functions it declares belong to the enclosing function and never replace a global (classes it declares still replace the global class). Reload from program scope, from the host, or through import.
The host loop
The host calls a script function every frame, and that function reaches its imports. The Quickstart's frame loop is the whole C++ side: tick in game.jai runs import("rules.jai"); before it uses the rules, and C++ calls tick through as_function().
Every failure comes back in that call's checked_result and the next frame runs: a run-time error, a rejected reload (the check listing, naming the file with its line and a caret), a module whose file went missing (Failed to open import file: <path>, raised once), a script throw nothing caught. A throwing call (execute, a convert_script_function wrapper) raises the same failures as exceptions (Error surfaces), and a script try around the import catches them.
An unchanged module costs a stamp check, and checks come in passes: at most once per import_recheck_interval (0.2 s by default) every import reached stats its module, the modules its last run imported and the files it included. A touched file is picked up within about the interval, and a frame sees all of a set of edits or none. import("a.jai", "b.jai") is one group decision that yields true when a module ran, and a trailing jai::force opens a pass at once (an F5 key). The guide's Reloading from a running program has these rules in full. The host's controls:
| call | effect |
|---|---|
import_recheck_interval(seconds) | The pass window. 0 checks on every reach, so a file a test writes is seen by the next import, even inside one execute(). Scripts can call it too. |
set_import_behavior(jai::engine::import_behavior::once) | file_timestamp (the default), once, or always, which treats every import as an include. |
reset_import(path), reset_imports() | Force a re-run under once. |
file_stamp_source(fn) | Replaces where stamps come from for execute_file, include, import and their caches: fn(path) returns a jai::engine::file_stamp{mtime, size}, or std::nullopt for a file it cannot stamp; an empty source selects the file system's. A host serving scripts from a pack supplies its own. |
A file is read under its stamp (modification time and size), and the stamp queried after the read must match the one taken before it, or a save landed during the read. Such a read never re-runs a module that ran before (the next pass reads it whole); a first load reads again at once. One load reads each file once: the text the check read is the text that runs.
Two shipped examples use the pattern: examples/crawler (key R, or save any script) re-runs every rule file and skips state.jai, which holds its globals, and examples/demoreel re-executes its scene files on r and never reloads main.jai.
Reload reports
A host (an editor panel, a log line) can ask what a reload did to live instances. Reporting is off by default; turned on, each automatic field operation is aggregated into rows until the host clears them. For the Player reload above, with outcome_name a switch over jai::field_migration_outcome:
eng->hot_reload_reporting(true);
eng->clear_hot_reload_reports();
eng->execute(new_player_source); // the reload
for (const jai::field_migration_report& row : eng->hot_reload_reports()) {
std::cout << row.class_name << "." << row.field_name << ": " << row.old_type << " -> " << row.new_type
<< " " << outcome_name(row.outcome) << " x" << row.count << "\n";
}Player.hp: int -> string converted x1
Player.name: string -> string preserved x1
Player.level: <absent> -> int added x1
Player.speed: float -> <absent> removed x1A row aggregates by class, field, stored type before and after, and outcome: preserved, converted, reset (a retyped value had no conversion and took the new initializer), added or removed. count is the number of instances touched. A reload that changes no fields adds no rows. Rows hold metadata only: no values, no object identities, no hook outcomes. The host reads them on the engine's thread after the reload; nothing calls back during migration.
Interaction with the rest of the engine
- Type proofs. A reload that retypes, drops, re-parents or hides a field re-checks every unit checked against the old shape at its next execute, and each proven lane keeps its runtime guard (Proofs). Nothing for the host to do.
- Callers in other files. A reloading module is checked together with every loaded unit that uses it: a function whose signature changed, or a global whose type did, while a caller no longer fits fails the reload with the caller's file and line, and the old code keeps running. Saved together, the module and its callers reload together.
- jaibite. Class redefinition works across repeated bite executions.
- Serialization.
hot_reload_migrateconverts live script objects; for saved data and C++ classes that change shape, see Versions and missing fields.