Rules, errors and traceback
Rules for all situations
Ownership and lifetime
- The
Loaderowns everyModule. A module owns its code memory (keep_alive): for a.sothe last copy callsdlclose. - Declare the
Loaderbefore the script objects. The loader is then destroyed after them. The objects must die before the loader unloads the code. All samples do this. - Declare the backend before the loader. The samples do this too.
Loader::find(name)returns aconst ClassEntry*ornullptr.entry->create()makes an object and returns anInstance.Instance::nativeis a pointer to the native base class (for exampleCreature). The caller owns the object. Delete it through the native pointer (the native class needs a virtual destructor). The samples usestd::unique_ptr<Creature>.- With AOT,
AotRegistry::create(name)returns theInstanceandcreate_as<Native>(name)returns the native pointer. The caller owns the object, or gives it to the engine (for exampleWorld::Add). - With optimized AOT code (
MODE AOT OPTIMIZED), aLoaderthat took the table withload_aotruns the classes, but it refusesrequest_reload,revert_unitandload_unitwithBadModule. The message isthe loader holds optimized AOT code (MODE AOT OPTIMIZED): it cannot override or reload a script. Build the scripts without OPTIMIZED. See Optimized AOT. - The pointers from
find(),module_state()andthunk()and every function pointer, closure and handle that came from a script module become invalid afterLoader::reset. - A retired generation unloads in
frame_boundary()when its pin count is zero, at least one frame boundary passed, and no code of the module is in use. A live state field that can hold code (kMayHoldCode, for example astd::shared_ptr) blocks every unload. A native struct with a function pointer field also setskMayHoldCode. The translator finds the struct by its class or alias name, not a template instance such asCb<int>. - A JIT
Modulekeeps the JIT alive, so its code stays valid. ForClangFilesBackendthe module keeps the loaded library alive.
Threads
These rules come from the comments in include/hot/runtime/loader.h.
load,frame_boundary,stats,findandthunkrun on the main thread only. Worker threads do not call them.request_reloadandreload_pendingcan run on any thread, also while workers run script code.Backend::loadcan run on a worker thread, also while other threads run the old code. Then the main thread commits the result at the frame boundary, so the compile does not stop the frame. Run oneloadat a time for each backend. Do not runLoader::loadorcommit_pendingduring aload: the commit writes the slot state, andloadreads it. The script library opens on the thread of theload, so its static initializers run on that thread. The file system of the backend must be thread safe (seehot/vfs.h).- In the default mode (
StopTheWorld),commit_pendingruns on the main thread at the frame boundary, when no worker is in script code. - In
EntryCountersmode (Loader::set_sync_mode),commit_pendingcan run on one thread while other threads call script code. It blocks new script entries and waits for the running ones. It fails when the calling thread is in script code. The mode is global for the process. Change it only when no thread is in script code. - In
EntryCountersmode, native code that reads the fields of a script object holds aScriptEntry. A script frame that waits for a commit, or for a thread at the gate, deadlocks. Loader::resetruns on the main thread when no worker runs script code. Never call it from script code.- Any thread can create and destroy script objects, when the build has
HOT_THREAD_SAFE=ON(the default). This includesnewin script code,ClassEntry::createand the release of the last handle. An object can be destroyed on another thread than the thread that made it. WithHOT_THREAD_SAFE=OFFonly one thread at a time creates or destroys script objects, and a host that does this on more than one thread uses its own lock. See Build options and Internals. Loader::stats,frame_boundaryandresetcan run while other threads create and destroy script objects. The count of objects is a snapshot. Callframe_boundaryat the safe point as before.- Do not create or destroy script objects on any thread while
commit_pendingruns. The commit migrates the objects and runs script code, so it cannot lock the list. In the default mode (StopTheWorld) the host must make sure of this. InEntryCountersmode the commit blocks the creation and the destruction. - The host and the runtime must use the same
HOT_THREAD_SAFEvalue. If not, the constructor ofLoaderthrowsstd::runtime_error. The backends pass the value of the runtime to the compile of the scripts. If you compile a script yourself, pass the same-DHOT_THREAD_SAFE=value. AotRegistryis made and destroyed on the main thread.ClangFilesBackendbuilds one script at a time (it has a mutex for the shared slot file).
Symbol export
A host that loads a module must export its symbols. The modules resolve the host functions and the runtime functions from the executable.
- Set
ENABLE_EXPORTSon the executable (-rdynamicon Linux). Every sample exceptaotdoes this. - Link a static library of your own as a whole archive when scripts call functions that the host code does not use.
samples/imgui/CMakeLists.txtuses$<LINK_LIBRARY:WHOLE_ARCHIVE,imgui_sdl3>for this. - The executable must link the runtime objects that the script code uses (see the JIT section of
README.md). The samples link the Hot++ libraries without a whole-archive link. - An AOT program does not load a module, so it does not export. A program with run-time overrides loads modules, so it exports (
MODE AOT_RELOADsets this). -
The runtime keeps the call depth of the script calls in a static TLS variable (
hot::detail::g_call_depth). The script code reads it with one instruction. A host that is one executable, or that links the runtime library at the program start, needs nothing more. A host that loads the runtime library later withdlopencan fail withcannot allocate memory in static TLS block. Then give glibc more static TLS space with an environment variable. This needs glibc 2.32 or later. Set the variable before the process starts: a change at run time has no effect. Use 4096 bytes or more:GLIBC_TUNABLES=glibc.rtld.optional_static_tls=4096 ./my_host
C++17 baseline
The generated code, include/hot and the runtime library need C++17 only. A C++17 host works. The standard of the scripts is the standard of the host (17, 20 or 23). A standard below 20 selects fmt for ${}. See CMake for the table.
Core copies
module_from_library makes a second, independent instance of the library for the loader (the “core”). It copies the file to the temp folder as hot-core-<pid>-<n>-<name> and removes the copy after dlclose. If the temp folder is not available, it uses the folder of the library. If the copy fails (cannot copy <path> for the core module: ...), the generation stays loaded and the loader prints [script] WARNING: no core module for ... to stderr.
Error reports
A script can throw NullReference, IndexOutOfRange, ArithmeticError or ScriptError (see Statements). All of them derive from hot::ScriptError, which derives from std::runtime_error. Include hot/runtime.h.
When the host calls a script function and the script does not catch a ScriptError, the runtime reports it. Then the call returns a default value (0, false, or nothing for void). The object stays alive. The next call works. A field keeps the values that the script wrote before the throw.
Set a handler to receive the report:
hot::set_script_error_handler([](const hot::ScriptErrorReport& r) {
log("%s in %s: %s", r.type, r.class_name, r.message);
});
hot::set_script_error_handler({}) restores the default. hot::script_error_handler() gives the current handler. The handler runs on the thread of the call. These fields are valid only during the call of the handler:
| Field | Meaning |
|---|---|
type |
hot::NullReference, hot::IndexOutOfRange, hot::ArithmeticError or hot::ScriptError. |
message |
The text of what(). |
class_name |
The script class of the object, or ?. |
self |
The shell of the object (const hot::ShellCore*), or null. The state member needs an object that is not optimized (hot::ShellBase). |
traceback |
The script call stack of the throw point, or an empty text when the traceback is off. See “Traceback”. |
Without a handler, the runtime prints one line to stderr: [script] ERROR: unhandled hot::IndexOutOfRange in Player: <message>. When the traceback is on, the lines of the traceback follow it.
An error reports only when it leaves the outermost script call. A script that calls native code, which calls a script override, can catch the error in the calling script.
Only a ScriptError stops at the boundary. Any other C++ exception that a script throws reaches the host. The host must catch it. If the return type has no default value (for example a reference), the runtime reports the error and throws it again.
A noexcept native virtual function cannot throw, so the runtime reports the error and must return a value. If the return type has no default value, the host gives that value with a specialization of hot::noexcept_result<R>. Without it, the exception leaves the noexcept function and ends the program.
#include "hot/runtime.h"
#include <string>
namespace hot
{
template <> struct noexcept_result<const std::string&>
{
static const std::string& get()
{
static const std::string empty;
return empty;
}
};
} // namespace hot
The checks are a build-time choice. The CMake option HOT_RUNTIME_CHECKS (default ON) sets the macro HOT_RUNTIME_CHECKS. With OFF the macro is 0: the generated code has no checks, and an installed host sees the same setting. Each check costs one compare for a pointer access, a handle access or an index of a checked container (std::array, std::vector, a stub with checked_index). See CMake.
Traceback
The traceback is off by default. To turn it on, configure Hot++ with -DHOT_TRACEBACK=ON. The option sets the macro HOT_TRACEBACK to 1 for the runtime library and for the compile of the scripts. It works for .hot scripts only.
With the traceback on, each script member function records a frame on a thread-local stack. Each statement stores its script line in the frame. A hot::ScriptError copies the stack when the script throws it, because the unwind removes the frames. hot::ScriptError::traceback() gives the text. hot::ScriptErrorReport::traceback has the same text. Each frame is one line, and the innermost frame is first:
[script] ERROR: unhandled hot::NullReference in Player: null handle dereference
at Player.Inner (player.hot:12)
at Player.Outer (player.hot:17)
at Player.Tick (player.hot:21)
The stack keeps the 64 innermost frames. A last line ` … N more` shows how many outer frames it dropped. A lambda records no frame and no line.
The cost is one store for each statement and one push and pop for each call. The generated code is the same when the option is OFF: the macros HOT_TRACE_FRAME and HOT_TRACE_LINE are empty. Build the host and the scripts with the same setting. A script without the macros gives an empty traceback.