AxlRuntime — lifecycle services

AXL Runtime — lifecycle services for an AXL app

The pieces an AXL app interacts with around its own lifecycle and interruptibility. The CRT0 entry stub (src/crt0/axl-crt0-native.c, ~17 lines) bridges the UEFI entry point to int main(int argc, char **argv) by calling _axl_init before main and _axl_cleanup after. This module — the AXL runtime — implements those two bookends and everything they wire up: the default event loop, the cooperative yield, the interrupt handler registry, the tier-1 resource-leak sweep, and the LIFO atexit callback registry. CRT0 holds none of that state itself; it just calls in and back out.

The full lifecycle (init → main → cleanup → exit) and the runtime-vs-CRT0 split are documented in docs/AXL-Lifecycle.md. This README is the module-level reference for the runtime sources.

Four sub-modules, each a single concern:

  • axl-runtime.c_axl_init / _axl_cleanup, the singleton axl_loop_default(), and axl_yield.

  • axl-registry.c — tier-1 firmware-resource registry. Internal-only API (_axl_registry_*) called by the _new_impl / _free paths of AxlEvent, AxlLoop, AxlCancellable, and AxlArena. Sweeps leaked resources during _axl_cleanup.

  • axl-atexit.c — POSIX-flavored cleanup registry (axl_atexit / axl_atexit_remove). LIFO drain during _axl_cleanup, before the tier-1 sweep.

  • axl-signal.caxl_signal_install / axl_interrupted / axl_exit. Hooked into loop break detection; invokes the user handler once per Ctrl-C and sets the interrupted flag.

Headers:

  • <axl/axl-runtime.h> — default loop, yield, registry count

  • <axl/axl-signal.h> — interrupt API + blessed exit

  • <axl/axl-atexit.h> — LIFO cleanup callbacks

  • <axl/axl-loop.h> — includes axl_loop_iterate_until (nested-wait primitive), the loop-module partner for callers inside a callback that need to wait without freezing outer sources

When to Use What

I need to…

Use

Handle Ctrl-C with a custom callback

axl_signal_install(fn)

Ask “did Ctrl-C happen yet?” from a CPU loop

axl_interrupted()

Exit with cleanup guaranteed (vs raw gBS->Exit)

axl_exit(rc)

Keep a tight CPU loop responsive to Ctrl-C

axl_yield() every N iters

Free a long-lived resource on any exit path

axl_atexit(fn, data)

Share a loop across modules

axl_loop_default()

Wait inside a callback without starving the outer loop

axl_loop_iterate_until(loop, done, timeout_us)

Interrupt lifecycle

user presses Ctrl-C
   |
   v
shell signals its ExecutionBreak event
   |
   +--- loop observes via axl_backend_shell_break_flag /
   |    axl_backend_shell_break_event (in axl_loop_next_event)
   |
   +--- OR axl_yield observes via a non-blocking loop dispatch
   |    (or a direct poll when no default loop exists)
   |
   v
_axl_signal_on_break() fires once:
   - sets g_axl_interrupted = true
   - if axl_signal_install(fn) was called, invokes fn()
   - idempotent (subsequent observations no-op)
   |
   v
yield / loop_run / wait_* return with status indicating interrupt
   |
   +--- if user handler installed: caller unwinds main, CRT0
   |    runs _axl_cleanup on main's return
   |
   +--- if no handler: next axl_yield() auto-calls axl_exit(1),
        which takes the same _axl_cleanup path + gBS->Exit

The two exit paths (return from main, axl_exit(rc)) both converge on _axl_cleanup, so cleanup output is byte-identical between them. _axl_cleanup has a reentrancy guard: if axl_exit fires mid-main and the firmware somehow returns from gBS->Exit (paranoia), CRT0’s post-main cleanup is a no-op.

Cleanup order

_axl_cleanup runs these in order:

  1. atexit callbacks (LIFO) — _axl_atexit_run_all. User callbacks may free resources that would otherwise show up in the sweep.

  2. argv strings_axl_args_free in src/posix/axl-app.c.

  3. Default loop — explicit axl_loop_free(mDefaultLoop) so its registry entry comes off cleanly (otherwise sweep would flag it as a leak on every exit).

  4. Tier-1 registry sweep_axl_registry_sweep. LIFO walk of live entries, each logged with user file:line and closed via the appropriate _free.

  5. Heap leak report (AXL_MEM_DEBUG only) – axl_mem_dump_leaks. Release-mode auto-free of heap is deferred (see docs/AXL-Lifecycle.md §10.1).

Caller attribution

Every tier-1 resource allocation records the caller’s file/line at macro-expansion time:

AxlEvent *e = axl_event_new();   // expands to:
                                 //   axl_event_new_impl(__FILE__, __LINE__)

When the sweep fires on a leak, the warning names that file/line – the app developer’s call site, not the library’s internal wrapper. Library-internal callers (e.g., axl_cancellable_new internally calling axl_event_new) record the library’s own file/ line by design; library code that correctly frees never reaches the sweep, so the only way those appear is if the library itself leaks — in which case the library source is the correct attribution.

See also

API Reference

AxlSignal (interrupts + blessed exit)

Typedefs

typedef void (*AxlSignalHandler)(void)

axl-signal.h:

POSIX-flavored interrupt handler API. The axl_signal_* namespace was freed up pre-1.0 (see the axl_pubsub_* rename) specifically to host this surface.

At runtime:

  • CRT0 (via _axl_init) installs a notify path on the shell ExecutionBreak event. When the user presses Ctrl-C, the runtime sets a global “interrupted” flag and, if the app has installed a handler via axl_signal_install, invokes it.

  • The handler runs in a limited context (raised TPL). It is expected to set an app flag, log, and return — not to allocate, block, or call Boot Services that mutate state. Cleanup happens at the next yield point or inside the app’s own axl_atexit callback chain.

  • If no handler is installed, the default behavior is to terminate the app cleanly at the next axl_yield observation (runs _axl_cleanup, then gBS->Exit via axl_backend_boot_exit).

static volatile bool g_should_quit;

static void on_interrupt(void) {
    g_should_quit = true;
}

int main(int argc, char **argv) {
    axl_signal_install(on_interrupt);
    while (!g_should_quit && more_work()) {
        do_work();
        axl_yield();
    }
    return g_should_quit ? 1 : 0;
}

See docs/AXL-Lifecycle.md §2.2 and §4.4 for the design. AxlSignalHandler:

Interrupt-time callback. Runs at raised TPL when the shell ExecutionBreak event fires. Do not allocate, do not block, do not call Boot Services that mutate state. Set a flag, log, return.

Functions

void axl_signal_install(AxlSignalHandler on_interrupt)

Install a handler fired on Ctrl-C. Overrides the default exit-on-interrupt behavior.

Passing NULL is equivalent to axl_signal_default(): no user handler, the runtime will exit cleanly at the next yield point.

void axl_signal_default(void)

Restore the default handler (auto-exit on next yield).

Equivalent to axl_signal_install(NULL) — named for readability.

bool axl_interrupted(void)

Poll the interrupted flag.

True between the break event firing and _axl_cleanup clearing it (which only happens as part of axl_exit). App code reads this to decide whether to keep working or unwind.

void axl_exit(int rc)

Terminate the app with guaranteed cleanup. Does not return.

Runs atexit callbacks (LIFO), sweeps the tier-1 resource registry, then calls gBS->Exit via the backend. This is the ONE blessed exit path — returning from main takes the same route via CRT0. App code that calls gBS->Exit directly, or aborts through some other path, bypasses cleanup and leaks firmware resources; don’t.

Convention: rc == 0 -> EFI_SUCCESS, any other value -> EFI_ABORTED.

AxlAtexit (LIFO cleanup callbacks)

Typedefs

typedef void (*AxlAtexitFn)(void *data)

axl-atexit.h:

POSIX-flavored cleanup registry. Callbacks registered via axl_atexit run in LIFO order (last-registered-first-run) during _axl_cleanup, before the tier-1 resource-registry sweep. Matches C’s atexit(3) contract and gives library consumers a place to release long-lived resources (top-level loops, HTTP clients, caches) without hand-threading cleanup through main’s tail.

static void free_app_state(void *data) {
    app_state_free((AppState *)data);
}

int main(int argc, char **argv) {
    AppState *s = app_state_new();
    axl_atexit(free_app_state, s);
    return run(argc, argv, s);
}

See docs/AXL-Lifecycle.md §4.3 for the design rationale. AxlAtexitFn:

Cleanup callback. data is the opaque pointer supplied at registration time. Runs on the main thread during _axl_cleanup.

Functions

uint32_t axl_atexit(AxlAtexitFn fn, void *data)

Register a callback to run during _axl_cleanup.

Callbacks fire in LIFO order on every exit path (return from main, axl_exit, or Ctrl-C through the default signal handler).

Parameters:
  • fn – cleanup function

  • data – opaque user data passed to fn

Returns:

non-zero handle on success, 0 on failure (fn is NULL or registration-time allocation failed).

void axl_atexit_remove(uint32_t handle)

Remove a previously-registered atexit callback.

Safe to call with handle==0 or with an already-removed handle (no-op in both cases). Useful when a resource is freed explicitly before exit and the atexit entry would otherwise run with a dangling pointer.

Parameters:
  • handle – handle returned by axl_atexit

AxlRuntime (default loop, yield, registry inspection)

Typedefs

typedef struct AxlLoop AxlLoop

axl-runtime.h:

AXL runtime surface — the pieces an app interacts with around its own lifecycle and interruptibility. The runtime is the library code in src/runtime/ that powers the program lifecycle; the CRT0 entry stub (src/crt0/axl-crt0-native.c) invokes it at two boundaries:

  • _axl_init (before main): default loop singleton, shell-break notify, tier-1 resource registry, atexit registry.

  • _axl_cleanup (after main, or on axl_exit): drain atexit, sweep tier-1 leaks, free the default loop if created.

Full design and the runtime-vs-CRT0 split: docs/AXL-Lifecycle.md.

Related headers:

  • axl-signal.h Ctrl-C / interrupt handler API + axl_exit

  • axl-atexit.h POSIX-flavored cleanup callback registry

  • axl-loop.h Loop primitives, including axl_loop_iterate_until

Functions

AxlLoop *axl_loop_default(void)

Return the runtime’s default loop, lazy-creating on first call.

The default loop is owned by the runtime and freed during _axl_cleanup. Apps can run it directly (axl_loop_run(axl_loop_default())), add their own sources to it, or ignore it entirely and create private loops. axl_yield() dispatches immediately-ready work on this loop opportunistically.

Returns:

the default loop, or NULL if allocation failed.

void axl_yield(void)

Cooperative yield point.

Call inside CPU-bound loops to keep the app interruptible. Cost is ~nanoseconds when the default loop is idle. Safe from any context except a raised-TPL notify handler.

Behavior:

  1. If any immediately-ready work is pending on the default loop (timers, deferred callbacks), dispatch it — bounded to one iteration.

  2. If Ctrl-C was observed during that dispatch, sets the interrupted flag so axl_interrupted() returns true.

  3. Otherwise returns immediately.

void *axl_efi_find_config_table(const AxlGuid *guid)

Look up a UEFI Configuration Table entry by VendorGuid.

Walks the EFI System Table’s ConfigurationTable for an entry whose VendorGuid matches guid. Returns the corresponding VendorTable pointer (typed void * — caller casts to the spec-defined struct type for the GUID).

Common GUIDs and their published types:

  • EFI_ACPI_20_TABLE_GUID → RSDP

  • SMBIOS3_TABLE_GUID → SMBIOS3 entry-point struct

  • EFI_SYSTEM_RESOURCE_TABLE_GUID → ESRT

  • EFI_DEBUG_IMAGE_INFO_TABLE_GUID → debug image info

Modules with their own typed lookups (axl-acpi, axl-smbios) call this internally. New code that needs a one-shot lookup of an uncommon table (ESRT, MEMATTR, dmar, etc.) should use this directly instead of duplicating the configuration-table walk.

Parameters:
  • guid – guid to match (NULL → returns NULL)

Returns:

the matching VendorTable, or NULL if no match or guid is NULL.

size_t axl_registry_count(void)

Return the number of tier-1 resources currently registered.

Purely informational — mostly useful in tests to verify resource-balancing. Returns 0 if the registry has not been initialized yet.

AxlApp (program invocation path)

Functions

const char *axl_app_argv0(void)

Return the program’s invocation path (argv[0]) as captured by the runtime at startup.

axl-app.h:

Application-runtime accessors. Today: argv[0] capture for tools that need their own invocation path (sidecar discovery, “where am

I” diagnostics). The runtime owns the parsed argv array; this header exposes read-only views of it that don’t get clobbered when subcommand dispatchers shift argv inside the program.

Tools that use axl_subcommand_dispatch see argv[0] rewritten to the verb name inside each handler. To find the original program path (e.g. for loading a sidecar file from the binary’s directory), call axl_app_argv0 — it returns the value the runtime captured at startup, regardless of subsequent argv mutation.

The returned pointer is owned by the runtime — never freed by the caller, valid until the runtime’s cleanup phase (which runs after main returns). Stable across axl_subcommand_dispatch and similar argv-shifting helpers.

Don’t call this from atexit handlers registered before runtime cleanup runs — the pointer is invalidated when _axl_args_free fires during _axl_cleanup.

Returns:

invocation path, or NULL if the runtime received no arguments (extremely unusual — only happens on init OOM; the POSIX shim normally supplies at least argv[0] = “app” as a fallback).

const char *axl_app_image_path(void)

Return the canonical filesystem path of the running .efi image, decoded from EFI_LOADED_IMAGE_PROTOCOL.FilePath.

Orthogonal to axl_app_argv0. argv[0] reflects whatever the shell typed — often a basename when the user typed app.efi rather than fs0:\app.efi, sometimes a full path, sometimes (in the startup.nsh invocation case) just the name even when the script wrote the full path. The image path returned here is decoded from the device-path nodes UEFI used to actually find the binary, so it’s reliable regardless of how the shell was invoked.

The right anchor for sidecar discovery (pci-ids.json5, the classes[] section of pci-ids.json5, jedec.json5, etc.) — see axl_resolve_data_file, which prefers this over argv[0] when available.

Returns:

UTF-8 path string owned by the runtime, or NULL if the loaded-image protocol was unavailable or had no FILEPATH nodes (rare; only seen with synthetic load contexts that bypass the usual file-load path).

int axl_app_boot_path(const char *relative_path, char *out, size_t out_size)

Build a path on the boot volume the current image was loaded from.

Concatenates the volume prefix from axl_app_image_path() (e.g. "fs0:") with relative_path so the result is a fully-qualified path the rest of the AXL filesystem API understands. Convenience for tools that want to write logs / reports / sidecars next to their .efi without parsing the image path themselves.

Path-separator and \ are normalized — both "crash-report.txt" and "\\crash-report.txt" produce e.g. "fs0:\\crash-report.txt".

Parameters:
  • relative_path – path relative to boot-volume root (with or without leading ‘')

  • out – output buffer

  • out_size – capacity of out in bytes

Returns:

AXL_OK on success (out is NUL-terminated); AXL_ERR if the image source has no filesystem prefix (network boot, RAM disk with no source volume), the output buffer is too small, or out / relative_path is NULL.