Hot reload
A reload swaps the code of a running object. The object keeps its field values.
What a reload does
The host compiles the new script as a new generation. The commit runs at the frame boundary. If the compile or the reload fails, the old generation keeps running.
| Change | Result |
|---|---|
| Method body | LIVE |
Add a method, a virtual or an override |
LIVE. An old object can call a new method at once. |
| Change a method signature | LIVE. Native delegates bound to the old signature detach with a warning. |
| Add, remove or reorder a field | LIVE |
| Change a field initializer | LIVE. Existing objects keep their values. |
| Change a constructor | LIVE. Existing objects do not run it again. |
Rename a field with [alias] |
LIVE. The value moves. |
Rename a field without [alias] |
LIVE. The value is lost and the runtime warns. |
| Change a field type | LIVE. A convert_ hook converts the value. Else the default value and a warning. |
| Change a script struct, enum or module variable | LIVE |
| Change the native base class or a native interface base | Full reload |
| Change the script parent class | Full reload |
Change the shell layout, for example the first use of this as a handle |
Full reload |
| Remove a function so that a class has fewer slots | Full reload |
| Change a native header, a native class layout or a native virtual signature | Full reload. The host triggers it. |
A .cpp script (see C++ scripts) follows the same table. It has no properties, no script virtual methods and no script-class handles. A .cpp reload is slower, because hotc does one more libclang parse. A normal build of the file has no state migration and no slots.
Tasks
A task of an async method keeps its generation alive. After a reload, a suspended task of the old generation ends with TaskReloadCancelled at its next resume. The old code does not run. A retired generation unloads only after the host destroys the task. Loader::reset fails while a task lives, so destroy all tasks first. Call hot::scheduler().cancel_all() to destroy the spawned tasks before reset. See Async and await.
Full reload
A few changes cannot keep live objects. Loader::request_reload then returns the status FullReloadRequired, with a reason and the class names. The live generation stays. The host restarts all script state and keeps its own state.
A native base, a script parent or a shell layout change changes the C++ layout of the shell. A live object cannot move to a new layout. A removed function cannot free its slot, because the slot table only grows. These changes stay full reloads.
The runtime does not see a change in a native header. When a native header changes, the host starts a full reload itself. LoadResult::dependencies lists the headers and the imported scripts that the build read: the host watches these files.
The host sequence:
- Save the host state, for example the position and the name of each actor.
- Drop all script objects, handles, closures and thunk pointers.
- Call
loader.reset(error). - Compile the script with
backend->load(source, 1, hot::LoadMode::Fresh). - Call
loader.load(std::move(*fresh.module)). The generation is 1 again. - Restore the host state with the new scripts.
hot::ReloadResult r = loader.request_reload(backend->load(source, next_generation++));
if (r.status == hot::ReloadStatus::FullReloadRequired) {
std::printf("[host] full reload: %s\n", r.error.c_str());
std::string saved = world.Save(); // 1
world.Clear(); // 2
std::string error;
if (!loader.reset(error)) {
std::printf("[host] full reload failed: %s\n", error.c_str());
world.Restore(saved, make_actor); // the old scripts go on
return;
}
hot::LoadResult fresh =
backend->load(source, 1, hot::LoadMode::Fresh); // 4
if (fresh && loader.load(std::move(*fresh.module))) {
next_generation = 2;
world.Restore(saved, make_actor); // 6
}
}
The sample hot-reload shows request_reload, commit_pending and frame_boundary in a small program.
The host must drop all objects first. reset fails when one script object remains. The error names the classes, for example 3 live objects remain: Driver x1, G x1, S x1. It also fails when a reload is pending or a closure pins a generation. A failed reset changes nothing. A module variable can hold a closure, for example in a std.function. The closure pins its generation, and reset then fails with generation N is pinned (a closure or a task is alive). Before reset, the host calls a script function that clears such a variable (see tests/scripts/modclosure.hot). loader.load fails with BadModule when the loader still has a generation: call reset first. Build the Fresh module before world.Clear() when you can: a build error then leaves the actors intact (the test host tests/engine/Harness.cpp does this). After a reset, every pointer from find(), module_state() and thunk() is invalid.
Renamed fields
[alias("old")] gives the old name. The reload moves the value to the new field.
using "Actor.h";
class Hero : Actor {
[alias("hp")] float health = 100.0;
}
Changed types
convert_<field> gets the old value and returns the new value. The function has no this. It cannot read other fields.
using "Actor.h";
class Hero : Actor {
float level = 1.0; // was: int level
float convert_level(int old) {
return old * 10.0;
}
}
onReload
void onReload(OldState old) runs once for each live object, after the migration of all objects. old.has("hp") tests the old field name. old.get<float>("hp") reads the old value. It throws when the field is absent or has another type.
A throw from onReload is logged. The object keeps the defaults for the affected fields, and the reload completes. A reload has no rollback after the commit. To undo a change, revert the source. The next reload migrates back with the same rules.
using "Actor.h";
class Hero : Actor {
[alias("hp")] float health = 100.0;
float old_hp = 0.0;
void onReload(OldState old) {
if (old.has("hp")) old_hp = old.get<float>("hp");
if (health > 100.0) health = 100.0;
}
}
Module variables
A module variable can have a convert_<name> hook at file level. It is the only free function that a script can have.
using "Actor.h";
float level = 1.0; // was: int level
float convert_level(int old) {
return old * 1.5;
}
class Probe : Actor {
int seen = 0;
}