Embedding Nupp#

libnupp lets a C application own the process and event loop while checked Nupp modules run in an embedded LuaJIT state. The host loads a prebuilt component, calls named exports, and keeps collector-managed values alive with opaque handles.

Lifecycle
nupp_config config;
nupp_runtime *runtime = NULL;
nupp_component *component = NULL;
nupp_error *error = NULL;

nupp_config_init(&config);
nupp_runtime_new(&config, &runtime, &error);
nupp_component_load(runtime, bytes, length, "game.nuppc", &component, &error);
nupp_component_start(runtime, component, 0, NULL, &error);
nupp_component_release(runtime, component, &error);
nupp_runtime_shutdown(runtime, &error);
nupp_runtime_free(runtime);

This fragment shows the ownership order. A complete host also checks every returned status and releases its error values; see Errors for that boundary.

Embedding and checked C interop point in opposite directions. Embedding starts with a C application and brings Nupp into it. Checked C interop starts with Nupp source and gives that source typed access to a C API. An embedded application can use both boundaries.

Complete example#

The repository carries a C application example in host/examples/embed.c and its component project in host/examples/component. The example calls the public C ABI implemented by the Rust-owned SDK; it is not the removed legacy C host implementation. Build the component from its project directory:

cd host/examples/component
nupp build

The project manifest names one application entry and one callable export:

host/examples/component/nupp.lua
return {
    include = {"src"},
    build = {
        kind = "component",
        description = "Build the embeddable example component",
        entries = {"app.main"},
        exports = {"game.answer"},
    },
}

Each export is a dotted member of a declared module:

host/examples/component/src/game.nupp
module game

export function answer(value: integer): integer
    return value + 1
end

nupp build writes build/component.nuppc. A component records its format, compiler host ABI, entry module, public exports, layout target, required host features, modules, and resources. It is application data, not a shared library and not a stable C ABI for Nupp records or closures.

The example entry also uses nupp.log and nupp.codec.json. Compiler-provided modules reached by a component are compiled into the component with their runtime dependency closure, including catalog-backed service providers; the embedding host does not need a Nupp module tree on its Lua search path.

Build the Rust-owned libnupp SDK from the repository root:

SDK=$(./scripts/toolchain host-library \
  lpeg,native-files,native-net,native-process,native-tls,workers)
cc -std=c11 -I"$SDK" host/examples/embed.c \
  -L"$SDK" -lnupp -Wl,-rpath,"$SDK" -o build/embed-nupp

The same SDK directory contains libnupp.dylib on macOS, libnupp.so on Linux, or nupp.dll plus libnupp.dll.a on Windows. link.json names the header, static library, dynamic library, exact features, and Windows import library when present. Put the directory on the platform's dynamic-library search path, then run the host:

DYLD_LIBRARY_PATH="$SDK" \
  build/embed-nupp host/examples/component/build/component.nuppc
game.answer(41) = 42

Use LD_LIBRARY_PATH instead of DYLD_LIBRARY_PATH on Linux. Static linking uses libnupp.a, which contains the pinned LuaJIT VM, the Rust host, and the exact-feature Rust native provider. Pass the staticLinkFlags array from link.json to the C linker after the archive; it includes the system libraries and frameworks selected by the SDK features and the flag that makes the host executable export the provider's symbols (-rdynamic on Linux, -Wl,--export-all-symbols on Windows), without which LuaJIT cannot find them. The SDK does not require separate LuaJIT, LPeg, or provider archives.

The dynamic library exports what host/include/nupp.exports lists and nothing else: the nupp_* functions in the header, the native provider the runtime reaches through LuaJIT's FFI, and LuaJIT's own C API from lua.h, lauxlib.h, lualib.h and luajit.h. Every platform's linker is given that one list, so it is also the file to read for what the library's ABI is. Exporting the Lua C API pins the LuaJIT floor stated under Attached LuaJIT states.

A host may therefore call the Lua C API against either library: a preload opener, an AOT builder registrar, any use of the state nupp_runtime_lua_state returns, or a state the host created and attaches. A shared AOT module finds the API in the process the same way. Compile against the LuaJIT headers of that floor, and do not link a second LuaJIT beside libnupp: it would be a second VM, and nupp_runtime_attach cannot use a state that VM made.

Runtime ownership#

A runtime either owns a new state or attaches to one supplied by the host. The choice fixes who closes the state.

Form Creation Standard libraries lua_close
Owned nupp_runtime_new Opened by default Called by Nupp after shutdown
Attached nupp_runtime_attach Already open, or requested in the config Never called by Nupp

Initialize nupp_config before changing any field. Its size and abi_version let the library reject a caller built for an incompatible embedding ABI.

nupp_config config;
nupp_config_init(&config);

nupp_runtime *runtime = NULL;
nupp_error *error = NULL;
nupp_status status = nupp_runtime_new(&config, &runtime, &error);

NUPP_CONFIG_OPEN_LIBRARIES is the only current flag and is enabled by nupp_config_init. Pass a null config to nupp_runtime_new for the same default.

Attached LuaJIT states#

Attachment keeps the host's allocator, heap, globals, module tables, and GC. It verifies that the state is LuaJIT 2.1.1784535649 or newer before publishing Nupp's host record. The check reads the state's own jit library before Nupp opens any libraries, so asking Nupp to open them cannot hide an older LuaJIT. A bare state with no jit library is accepted only when NUPP_CONFIG_OPEN_LIBRARIES asks Nupp to open the libraries.

nupp_config config;
nupp_config_init(&config);
config.flags = 0;

nupp_runtime *runtime = NULL;
nupp_status status = nupp_runtime_attach(existing_state, &config, &runtime, &error);

A state that already carries a Nupp host record, because a stamped binary or another runtime set it up, keeps that record: an attached runtime shares its features and resources rather than replacing them, and attaching is refused when a __nuppHost global exists that is not one. The registration freeze described under Host configuration belongs to the state as well, so once any runtime has loaded a component into it, every runtime attached to it is refused further registrations.

Use flags = 0 only when the host already opened the libraries the component needs. Leave NUPP_CONFIG_OPEN_LIBRARIES enabled when Nupp must call luaL_openlibs during attachment. The host must keep the state alive until after nupp_runtime_free, and it must not call lua_close while Nupp owns registry roots in the state.

nupp_runtime_lua_state returns the application state for a host which also uses the Lua C API. Calls made directly through that API use the host's stack discipline and protection rules; the managed Nupp calls described below do not manage a stack frame the host creates itself.

Host configuration#

Modules, features, and resources are registered before the first successful component load. The set freezes at that point so generated code cannot observe providers appearing while it runs.

nupp_runtime_preload(runtime, "engine.clock", luaopen_engine_clock, &error);
nupp_runtime_add_feature(runtime, "engine.clock", &error);
nupp_runtime_add_resource(runtime, "engine/defaults.json", data, length, &error);

An AOT archive that contains a Lua builder has one more registration before the component is loaded. Link the archive into the executable, then pass its registrar and the key generated in the component binding to the runtime:

extern int ks_register_example(lua_State *state);

nupp_runtime_register_aot_builders(
    runtime, "ks_register_example", ks_register_example, &error
);

The registrar runs with the embedded runtime's Lua state and returns the table of builder functions for that archive. Registration is rejected after the first component load, just like every other provider registration. Static AOT code without a Lua builder needs no registration: its generated binding resolves C symbols from the process image.

nupp_runtime_preload accepts an ordinary lua_CFunction and places it in package.preload. Requiring engine.clock from Lua or Nupp invokes the opener once through normal Lua module semantics. The opener is compiled against LuaJIT's C headers and returns values on the Lua stack in the usual way.

A feature is a deployment capability name. Component loading rejects a component whose compiler-recorded requirements are absent. A resource is copied during registration, so the caller may release its input buffer after the call returns. Host resources are for runtime and provider glue; component resources declared in nupp.lua remain available through the ordinary nupp.embedded module.

workers is the one name nupp_runtime_add_feature refuses. A component's generated gate reads the feature table alone, so declaring it would admit a component this ABI cannot run: starting native workers also installs the adapter modules and the worker host behind them, and nothing here asks for that. The refusal arrives at the declaration rather than later, as a component failing to find nupp.workers.native.

A registration attempted after component loading returns NUPP_STATUS_RUNTIME. Configure every provider before loading any component, including a library component whose entry will never start.

Static AOT components#

A component target selects static AOT with aotLinkage = "static" and aot = "require". Its build produces the ordinary component plus an AOT archive under outDir/lib. The archive is input to the embedding application's link, not a file the component will discover at runtime.

The host contract is four items:

  1. Link and retain the archive.
  2. Expose its AOT and probe symbols to the VM's default C namespace.
  3. Call each builder registrar with the owned lua_State *.
  4. Call nupp_runtime_register_aot_builders before nupp_component_load for each builder registrar.

The build writes outDir/aot/link.json saying what those are for one archive: its path, the probe symbol and the value it must return, every exported kernel and registrar symbol, the builder keys the host owes, and the retain and export flags the target's linker takes. See Static AOT components for its shape. The host also constructs the component with the same target ABI. Numeric and span kernels then resolve through the process image; substituting ffi.load would reintroduce the dynamic-loader dependency this mode avoids.

Retention is a physical link requirement, not something a generated wrapper can recover from: an FFI lookup creates no undefined C reference, so a linker reading the archive normally extracts nothing from it. The archive therefore exports a probe every rewritten module checks first. An archive that was never linked, or one stripped by the linker, reports that it is not in the default C namespace; an archive from a different build of the component reports that instead. An unregistered builder reports itself the same way. All three are host deployment errors and all three say so at load.

Use static AOT when the application owns the final link or cannot rely on a dynamic loader. Use the default shared AOT linkage when components must travel and update independently of the host. Static archives share one C namespace, so the compiler qualifies their generated AOT and registrar symbols.

Component lifecycle#

Loading, starting, and releasing are separate operations.

  1. nupp_component_load validates the format and host ABI, checks required features and name collisions, and installs module loaders.
  2. nupp_export_find may obtain a library export before the application entry starts.
  3. nupp_component_start runs the entry exactly once and installs its arg table from the supplied arguments.
  4. nupp_component_release releases the C name, not the modules installed in the runtime. Like the other releases it takes the runtime and returns a status, refusing a component already released or loaded by another runtime.
nupp_component_load(runtime, bytes, length, "game.nuppc", &component, &error);
nupp_export_find(runtime, component, "game.answer", &answer, &error);
nupp_component_start(runtime, component, argc, argv, &error);

The compiler-owned descriptor runs during loading, but application module top levels do not. An exported module loads lazily on its first export call. The entry module loads only when the host starts the component, unless another module required it first.

Multiple components can share one runtime. A component carries the compiler-provided runtime modules reached by its generated code, including standard-library modules. Byte-identical copies are shared by components in the same runtime; loading refuses a different copy under the same name. Project and dependency modules still refuse any name already present in package.loaded or package.preload, and a public export name claimed by an earlier component is also refused. These checks run before the new module loaders are installed.

Component unloading is unsupported. Tables, closures, cdata, native state, or opaque handles may already have escaped, so releasing nupp_component cannot make its Lua modules disappear safely. Closing an owned state reclaims all of it. An attached state keeps installed module entries after Nupp shuts down; the host reclaims them when it closes that state.

Calling an export#

nupp_export_find roots a named callable in the runtime registry and returns a nupp_handle. The handle remains valid across Lua collections until the host releases it or shuts down the runtime.

nupp_handle *answer = NULL;
nupp_status status = nupp_export_find(
    runtime, component, "game.answer", &answer, &error
);

The managed call boundary has a deliberately small value vocabulary:

Kind C fields Crossing behavior
NUPP_VALUE_NIL none Lua nil
NUPP_VALUE_BOOLEAN boolean Copied
NUPP_VALUE_NUMBER number Copied binary64
NUPP_VALUE_STRING data, length Copied bytes intended as UTF-8 text
NUPP_VALUE_BYTES data, length Copied arbitrary bytes
NUPP_VALUE_HANDLE handle Existing runtime registry root

Input string and byte pointers need to remain live only for the duration of nupp_call. A number is binary64; the generic boundary does not preserve an integer wider than binary64 can represent exactly. Use the Lua C API or an explicit native interface when an exact wider integer is part of the contract.

nupp_value argument = {0};
nupp_value result = {0};
size_t result_count = 0;

argument.kind = NUPP_VALUE_NUMBER;
argument.number = 41.0;
nupp_status status = nupp_call(
    runtime, answer, &argument, 1, &result, 1, &result_count, &error
);

Every non-scalar Lua result, including a table, function, thread, userdata, or cdata value, returns as NUPP_VALUE_HANDLE. Passing that value into a later call pushes the rooted Lua value. A handle belongs to one runtime; calls reject a handle from another runtime or one already released, including a copy of a result value whose other copy was released.

Returned strings and bytes own their buffers. Release every written result with nupp_value_release; that function also releases a result whose kind is NUPP_VALUE_HANDLE. Release the standalone callable returned by nupp_export_find with nupp_handle_release.

nupp_value_release(runtime, &result, &error);
nupp_handle_release(runtime, answer, &error);

nupp_call writes the number of returned values to result_count. If the callable returns more values than result_capacity, the call has already run, its results are discarded, and the status is NUPP_STATUS_BUFFER_TOO_SMALL. Supply a buffer sized for the export's contract; do not retry a side-effecting export only to enlarge the buffer.

Garbage collection#

Embedded Nupp does not add another application collector. Ordinary Lua, generated Nupp modules, and the host's Lua objects occupy the same state and run under the same LuaJIT GC.

Copied scalars and byte sequences have no GC relationship after a public call returns. Opaque handles are Lua registry roots, not object addresses, so a full collection cannot invalidate them. nupp_handle_release removes exactly that root and makes the handle unusable.

Nupp's ownership contracts govern native pointers retained by FFI calls. Pinning a Lua-managed owner and rooting a value solve different lifetime problems: the pin protects a native relationship described to the checker, while the registry handle keeps a Lua value reachable for the calling application.

Dive deeper

Nupp runs in the host's own state, on the host's own heap. A second embedded VM would mean two collectors, two object models, and a marshaling layer between code that is the same language. Raw pointers into collector-managed values are never exposed as a public object ABI, which is the line an embedding API is most tempted to cross and the one that would constrain the runtime permanently: a published address is a promise about object layout that every later change to the runtime has to keep.

Errors#

Every fallible public function returns nupp_status and accepts an optional nupp_error **. On failure, the error owns its message until nupp_error_free. Component evaluation, entry calls, and export calls are protected and return Lua failures through this boundary.

static int report(nupp_status status, nupp_error *error) {
    if (status == NUPP_STATUS_OK) return 0;
    fprintf(stderr, "nupp: %.*s\n",
        (int)nupp_error_message_length(error),
        nupp_error_message(error));
    nupp_error_free(error);
    return 1;
}

The status describes how the operation ended; the category describes the source of the owned error.

Status Meaning
NUPP_STATUS_INVALID_ARGUMENT A required pointer, UTF-8 name, value kind, or vector shape is invalid
NUPP_STATUS_INCOMPATIBLE The public ABI or attached LuaJIT is incompatible
NUPP_STATUS_RUNTIME Component or application work failed
NUPP_STATUS_BUFFER_TOO_SMALL A completed call returned more values than fit

The current categories are configuration, compatibility, component, and runtime. nupp_error_status repeats the returned status when an error object must cross another API boundary. Passing a null nupp_error ** discards the owned detail but does not change the returned status.

The message is the whole text of the failure however long it is, a Lua error's included. A NUL byte that text carried reads as ?, so the message is a single C string of nupp_error_message_length bytes.

Initialize or clear the caller's error pointer before reusing it. Each call sets the output to null before doing work; it does not free an older error the caller failed to release.

Threads and polling#

Status-returning runtime calls are affine to the OS thread which created or attached the runtime. A call from another thread returns a runtime error. nupp_runtime_lua_state returns null there because it has no error output. Cross-thread work uses a queue whose consumer enters Nupp on the runtime thread, or independent LuaJIT states with no shared handles.

nupp_runtime_poll is the pump for a host that owns the event loop. Each call makes one non-blocking pass over the readiness sources the state's modules have registered with nupp.suspension -- the network, HTTP, process, file-transfer, GPU and timer sources -- and returns. It never waits, so call it from the loop, once per frame or whenever the host's own readiness fires. A failure a source raises returns NUPP_STATUS_RUNTIME. Polling drives readiness; it does not install a scheduler or resume a coroutine nobody is waiting on, and a host-provided suspension handler still decides what runs while a call is parked.

while (running) {
    host_process_events();
    error = NULL;
    if (report(nupp_runtime_poll(runtime, &error), error)) break;
}

Answering host requests#

A component asks its application for things through nupp.host: it names a kind, such as game.image.decode, and waits for the answer. The application registers a handler per kind and answers each request with nupp_host_answer or nupp_host_fail, inside the handler or later from its own loop:

static void decode(nupp_runtime *runtime, uint64_t request, const char *kind,
    const nupp_value *arguments, size_t count, void *userdata) {
    struct game *game = userdata;
    /* The arguments are borrowed for this call: copy what a later answer needs. */
    queue_decode(game, request, (const char *)arguments[0].data, arguments[0].length);
}

static void abandoned(nupp_runtime *runtime, uint64_t request, void *userdata) {
    cancel_decode(userdata, request);
}

nupp_host_register(runtime, "game.image.decode", decode, abandoned, game, &error);

/* Later, in the loop, once the decode is done: */
nupp_value results[3] = {
    {.kind = NUPP_VALUE_NUMBER, .number = texture},
    {.kind = NUPP_VALUE_NUMBER, .number = width},
    {.kind = NUPP_VALUE_NUMBER, .number = height},
};
nupp_host_answer(runtime, request, results, 3, &error);

An answer, given in the handler or later, waits in the runtime until the next nupp_runtime_poll hands it to the task waiting for it. The values follow the channel's model: nil, booleans, finite numbers, UTF-8 strings of at most 64 KiB, and bytes, at most 8 MiB in an answer. Handles are refused, as are non-finite numbers and malformed text; the application names its own resources with numbers it assigns.

Arguments Answers and pushes
Strings and bytes Borrowed until the handler returns Copied before the call returns
Ownership of what a result names The application's The program's, from the answer on

A request whose caller stops waiting calls the kind's cancel callback, which is advisory: the application may still answer, and the answer goes to the caller's late cleanup. A handler may call back into the runtime. A post reaches its handler like a call, and its answer is discarded. A send on an outbound stream reaches its handler at once with request 0, and is never answered.

nupp_host_push queues an inbound message on a kind the program has routed to an event; the next poll delivers it. A message carries scalars and strings, and past 4096 waiting messages the oldest is dropped.

Without a suspension handler, a park blocks inside the nupp_call that made it, so the application could never answer a request it did not answer at once; such a request is refused rather than left to hang. Run the operation that makes it under nupp.host.pump, which parks instead, and poll the pump from the loop:

local pump = nupp.host.pump

local session = pump.newPump()

export function frame(dt: number): pump.State
    if session:parked() then
        return session:poll()
    end
    return session:start(function(): boolean
        return update(dt)
    end)
end

Only the first runtime on a Lua state answers its host requests; registering on a runtime attached to a state another runtime already answers for returns NUPP_STATUS_INCOMPATIBLE.

Shutdown#

Release public values before shutting down so their C wrappers and registry roots can both be retired cleanly:

nupp_value_release(runtime, &result, &error);
nupp_handle_release(runtime, answer, &error);
nupp_component_release(runtime, component, &error);
nupp_runtime_shutdown(runtime, &error);
nupp_runtime_free(runtime);

Call shutdown on the runtime thread. It releases component and handle roots, then closes an owned state. Attached shutdown leaves the host's state open. nupp_runtime_free performs best-effort teardown but returns no status, so an application which needs cleanup failures calls nupp_runtime_shutdown explicitly first.

A host callback reached from inside a call may call back into the runtime, but it cannot pull the state out from under that call. nupp_runtime_shutdown returns NUPP_STATUS_RUNTIME while any call is running in the runtime's Lua state, including a call made through another runtime attached to the same state. nupp_runtime_free there is deferred: it takes effect when the last call running in that state returns, so the call that was running finishes normally.

After shutdown, ordinary runtime operations fail. Calling shutdown again is allowed and succeeds. Components, handles and reload sessions are opaque names rather than addresses, and a name is never issued twice, so a call given one that was already released is refused instead of reaching whatever took its place. Freeing a runtime twice remains invalid C.

Hot reload#

A development host can keep its program's function bodies current without restarting the process. There are two ways in, and both end at the same session:

  • nupp_reload_attach adopts the components already loaded here that were built with reload = true. The program is the component the host would ship, loaded the way it always is.
  • nupp_reload_open builds a .nupp entry in watch mode and runs it, for a host with no component to load.

Reload compiles while the program runs, so the compiler is part of the process rather than of the artifact: compiler_path names a directory holding the compiler's own Lua modules, which is what nupp build --target bootstrapCompiler writes.

nupp_reload_config reloading;
nupp_reload *reload = NULL;

nupp_component_load(runtime, bytes, length, "game-dev.nuppc", &component, &error);
nupp_export_find(runtime, component, "game.update", &update, &error);

nupp_reload_config_init(&reloading);
reloading.compiler_path = "/path/to/nupp/build";
reloading.root = "/path/to/project";
if (nupp_reload_attach(runtime, &reloading, &reload, &error) != NUPP_STATUS_OK) {
    fprintf(stderr, "nupp: %s\n", nupp_error_message(error));
    return 1;
}

Load the component before attaching. A component installs the runtime modules it carries and refuses a module the state already has, and attaching is what brings the compiler in. Attaching recompiles every module the component is running and compares each against the digest the component recorded, so a session either proves it is patching the code that is actually running or refuses to open; a tree that has moved on since the component was built is a rebuild, not a patch.

A handle taken before a commit keeps working after it. A watch build dispatches every named function through a slot, so nupp_export_find, or nupp_reload_find for a member of an entry opened from source, returns a value that stays valid for the life of the session, and so does every other value the program has already handed out.

The commit boundary#

Preparing and applying are separate calls, because only one of them changes the running program:

uint32_t verdict = 0;
uint64_t generation = 0;

/* Anywhere: compiles, checks and stages. Nothing that is running changes. */
nupp_reload_prepare(runtime, reload, &verdict, &generation, &error);
if (verdict == NUPP_RELOAD_REJECTED) {
    fprintf(stderr, "nupp: %s\n", nupp_reload_message(reload));
}

/* Between frames, between requests, outside a transaction: publishes it. */
if (verdict == NUPP_RELOAD_PREPARED) {
    nupp_reload_apply(runtime, reload, &verdict, &generation, &error);
}
Verdict Meaning
NUPP_RELOAD_NO_CHANGE Nothing a running implementation depends on changed
NUPP_RELOAD_PREPARED A complete patch is staged, waiting for nupp_reload_apply
NUPP_RELOAD_COMMITTED A generation was published; generation names it
NUPP_RELOAD_REJECTED The candidate did not check, and the last good generation keeps running
NUPP_RELOAD_RESTART_REQUIRED The change is outside what a live process can take

A step that fails outright returns a status other than NUPP_STATUS_OK, and like every other call it clears its outputs before doing any work: the verdict reads NUPP_RELOAD_NO_CHANGE, the generation reads 0, and nupp_reload_message is null, so nothing from an earlier step is left for the host to act on. The error carries the reason.

nupp_reload_poll is both at one point, for a host with nothing to gain by separating them. A second prepare replaces what the first staged, since the newer edit is the one the program is about to be asked for, and an apply with nothing staged returns NUPP_RELOAD_NO_CHANGE rather than committing something twice.

Nothing changes in the running process anywhere but inside nupp_reload_apply, and what it publishes is complete: staging proves the whole patch compatible before a commit assigns any slot, so no call can see half of one. A call already running finishes on the implementation it entered. nupp_reload_message carries the diagnostics behind a refusal, formatted as one line each; the session owns those bytes and the next step replaces them.

What survives a commit and what reports NUPP_RELOAD_RESTART_REQUIRED is watch mode's own account, in Hot reload: bodies of named functions are replaceable, and a changed declaration, layout, capture set or native artifact is not. Nupp does not replace native machine code in a live process, so a changed C library is a restart however the host asks.

For the same reason a reload component cannot be built under an @aot policy: that policy rewrites a body into a call to compiled machine code, and a patch replacing the wrapper would report a committed generation the kernel behind it knows nothing about. The build refuses the two together, so the shipping target keeps its kernels and the reload target is the same program in Lua. An @aot annotation with no policy asking for it is inert, and the function it marks patches like any other.

A commit reaches the Lua state the session is attached to and no other: it replaces functions in that state's slot arrays, and a worker task runs its own state built from the stamped payload it was spawned with. A patch therefore never reaches a worker, whether the worker was already running or starts afterwards.

Today the two do not meet, and that is enforced rather than merely unreachable. Opening a session in a state that carries the native worker adapter is refused, and so is installing the adapter while a session is open. The exclusion holds whichever order a host tries, and whether the adapter was installed by this runtime or by the stamped binary whose state it attached to.

If the two are ever deliberately allowed together, the policy is already settled by the same reasoning. A commit stays confined to the session's state rather than being refused or queued while tasks are outstanding, since refusing would buy nothing a worker could observe. A worker keeps running the artifact it was spawned from and reports the generation stamped into that artifact, not the one the session last published. Outstanding worker tasks are not a reason a safe point is unsafe, so nupp_reload_apply goes on leaving the safe point entirely to the host.

Closing#

nupp_reload_close retires the session and nupp_reload_free releases the handle to it. Its ok argument says whether the program came to a good end: for a session nupp_reload_open started, zero records a failed run and discards the entry's recorded load, as a failed watch run does, and a session attached to components ignores it. A session still open when its runtime shuts down is closed then, as a failed one, so a runtime attached to the same state later can open its own. The program's own values survive both, and every function it handed out keeps answering; what stops is reloading them. A process holds one session at a time: opening a second before the first closes is refused.

Watch builds are development builds: always -O0, and carrying slot dispatch the ordinary build has no trace of. Measure and ship the ordinary build.

ABI evolution#

The header and the library carry NUPP_EMBED_ABI_VERSION, and the library refuses a nupp_config whose abi_version is not its own with NUPP_STATUS_INCOMPATIBLE. Within one version the ABI only grows, so a host built against it keeps working against a later library of the same version:

  • Functions, status codes, error categories and value kinds may be added. A host treats a status, category or kind it does not recognize as a failure it cannot act on, and reads the message for the rest.
  • A config struct grows only at its tail. Its size says how much of it the host allocated, and a size smaller than the struct's first version is refused.
  • nupp_value is frozen, because hosts allocate arrays of it.
  • Anything else, such as a changed signature, moves the version.

host/include/nupp.exports lists everything the dynamic library exports, so a change to that file is a change to the ABI. The native provider the library carries follows its own rules, in Provider ABI evolution.

Authority and limits#

Embedding is not a hostile-code sandbox. Standard libraries, LuaJIT FFI, debug, native modules, and host callbacks can grant process authority. Treat component bytes with the same trust as other executable application code.

The current embedding release has these deliberate limits:

  • components remain installed until the runtime is destroyed;
  • the managed number kind is binary64 rather than an exact integer family;
  • nupp_runtime_poll drives readiness sources and does not provide a scheduler; nupp.host.pump is one a component runs its exports under;
  • the in-process compiler is reachable only through a reload session, and not as a general compile-this-source API;
  • a reload session is development-only, and needs the project's source tree and a compiler beside the running process;
  • a reload session and native workers are mutually exclusive in one Lua state.

The current C header remains the authority for the implemented ABI.