Runtime Model (Proposal)

AXL Runtime

Status: implemented in Phase A7 (April 2026 – commits 3789aea4368256 on main). This document now describes the runtime as it is, not as proposed. A few items from the original design (release-mode heap sweep, opt-in watchdog) remain deferred and are called out in §10.

This doc describes the higher-level runtime model: CRT0 owning a default event loop, Linux-style signal handling for Ctrl-C, axl_yield() as a first-class cooperative escape hatch, and a coherent story for resource cleanup when main returns or exits early. It also calls out the hard limits we can’t paper over (UEFI has no preemption).

Related reading:

  • AXL-Concurrency.md — the four-axis primitive taxonomy (dispatch / coordination / notification / offload). This doc proposes the runtime under those primitives.

  • AXL-Design.md — overall library architecture.

  • AXL-SDK-Design.md — CRT0 / axl-cc / entry point flow.


1. Motivation

Today, Ctrl-C handling is scattered and cooperative-in-a-bad-way:

  • The event loop observes the shell break event and returns -1.

  • axl_wait_* / axl_event_wait_* map that to AXL_CANCELLED.

  • Every caller has to notice the magic return code and unwind.

  • Apps that don’t use these primitives (pure CPU loops, or naive reads from a file) aren’t interruptible at all.

  • There is no centralized “on Ctrl-C, clean up and exit” path; each app reinvents it.

Linux developers reaching for AXL will expect:

  1. Ctrl-C ends the program by default.

  2. A signal-install API for apps that want custom cleanup.

  3. Long-running operations feel responsive to interruption.

  4. Resources get freed when the program exits.

We can’t give them POSIX signals — UEFI BSP has no preemption, a tight CPU loop is inherently uninterruptible. But we can give them a cooperative runtime that feels Linux-shaped for any app that uses AXL APIs, which is effectively all of them (consumers link against libaxl.a for almost everything — printf, malloc, file I/O, networking, all yield through AXL).

The key insight: we control every AXL API. If every slow API checks a flag, the app gets Linux-like responsiveness without needing preemption.

2. Runtime model

2.1 Who owns what

_AxlEntry (CRT0)
  ├─ _axl_init()
  │    ├─ initialize memory, console, backend
  │    ├─ install shell-break notify → sets g_axl_interrupted
  │    ├─ install UEFI watchdog (60s livelock guard) [optional, opt-in]
  │    ├─ create axl_loop_default() — library-wide default loop
  │    └─ initialize atexit registry
  ├─ _axl_get_args() → argc/argv
  ├─ main(argc, argv)                       ← app runs here
  └─ _axl_cleanup()
       ├─ run atexit callbacks in reverse order
       ├─ axl_loop_free(default_loop)
       ├─ close break notify
       ├─ cancel watchdog
       └─ memory leak report (AXL_MEM_DEBUG)

CRT0 owns: the default loop, the break notify, the atexit registry, the watchdog timer. These exist from _axl_init through _axl_cleanup — for the app’s entire lifetime.

The app owns: anything it allocates. It can register axl_atexit handlers to free them automatically.

2.2 Signal subsystem

Shell break handling moves out of axl_loop_run and the wait helpers. Instead:

  • CRT0 registers a notify callback on the shell break event during _axl_init.

  • The notify sets g_axl_interrupted = true and invokes any user handler registered via axl_signal_install.

  • Default policy (no handler installed): interrupted flag is set, next yield point observes it and initiates clean exit (_axl_cleanup + gBS->Exit).

Public API:

/* Signal handler runs in a limited context — set flags, log,
 * return. Do not allocate, do not call Boot Services that mutate
 * state. Any cleanup should happen at the next yield point or in
 * an axl_atexit handler. */
typedef void (*AxlSignalHandler)(void);

void axl_signal_install(AxlSignalHandler on_interrupt);
void axl_signal_default(void);               /* restore auto-exit */
bool axl_interrupted(void);                  /* poll the flag */

Rationale: matches Linux signal(SIGINT, handler) shape while acknowledging the UEFI constraint that handlers run at raised TPL and can’t do much. In practice, a handler typically sets a per-app “please unwind” flag and returns; the main thread’s next yield exits through the normal path.

Naming note. The axl_signal_* prefix was previously occupied by a GObject-style pub/sub bus. Pre-1.0, that bus is renamed to axl_pubsub_* in axl-pubsub.h (~90 identifiers across 14 files; mechanical rename) specifically to free up the axl_signal_* namespace for this POSIX-flavored interrupt API — the meaning users’ muscle memory reaches for first. “Break” remains in the internal plumbing (backend helpers axl_backend_shell_break_event / axl_backend_shell_break_flag, UEFI’s own “ExecutionBreak”) because that’s the mechanism-level name of the firmware event. “Signal” is what the API surface offers the app author. See §9 and Appendix.

2.3 The default loop

CRT0 creates axl_loop_default() during init. It exists for the app’s lifetime but is not automatically running — an app that needs an event loop calls axl_loop_run(axl_loop_default()), or ignores it entirely.

Why have it at all? Three use cases:

  1. Apps that need a loop but don’t want to manage it — much like asyncio.get_event_loop().

  2. Library-internal background work — e.g., the atexit registry, periodic watchdog pets if we add one.

  3. Anchor for yield-point dispatchingaxl_yield() can opportunistically dispatch pending sources on the default loop, letting timers/defers advance during CPU work.

Apps can still create their own loops (and frequently will, for scope/resource-management reasons). Nested loops are a real concern — see §3.

3. axl_yield(): cooperative escape hatch

The new public API:

/**
 * @brief Cooperative yield point.
 *
 * Call inside tight loops to make them interruptible. Three
 * things can happen, in order:
 *
 * 1. If axl_interrupted() is true, the default signal handler
 *    runs (or the installed one), and on completion the program
 *    unwinds via _axl_cleanup + exit. An installed handler that
 *    returns normally lets axl_yield return — then the caller
 *    sees axl_interrupted() == true and can react.
 *
 * 2. If the default loop has pending events (timers, defers,
 *    signals), they dispatch — bounded by a single iteration.
 *    This keeps scheduled work alive during app CPU loops.
 *
 * 3. Otherwise, returns immediately (one flag read).
 *
 * Cost: ~nanoseconds when idle. Safe from any context except raised-
 * TPL notify handlers. Inline-friendly (single volatile-bool read).
 */
void axl_yield(void);

3.1 Where AXL APIs inject yields automatically

Every AXL public API that can take noticeable time should call axl_yield(). The guideline:

If the function can execute for longer than a few microseconds under reasonable inputs, and it doesn’t already use axl_loop_* internally, instrument it with axl_yield().

Initial targets:

Area

Functions

Pattern

File I/O

axl_fread, axl_fwrite, axl_file_read_all, axl_file_write_all, directory iteration

yield per N bytes or per directory entry

Network blocking

Already use waits — cancel path already exists; only need to funnel through the new flag

HTTP upload/download

axl_http_get, large-body POST, body-streamed responses

yield per chunk

Data operations

axl_array_sort (if ever added), hash-table growth, digest update on large buffers

yield per N elements/bytes

Format / printf

Very long format strings, stream writes to slow backends

yield after N chars

Task pool polling

Already loop-driven; fine

Shell / SMBIOS / SMBUS / IPMI

Inherently slow hardware ops

yield on retry boundaries

Not worth instrumenting:

  • O(1) or short-O(log n) operations (hash-table insert, list push, str-copy-small) — overhead would dwarf the work.

  • Pure arithmetic helpers.

  • Anything under a few µs typical.

3.2 App code using axl_yield

int main(int argc, char **argv) {
    /* CPU-heavy scan with no AXL calls in the hot loop */
    for (size_t i = 0; i < huge; i++) {
        crunch(&state, i);
        if ((i & 0xFFF) == 0) axl_yield();   /* every 4k iterations */
    }
    return 0;
}

Callers choose their own cadence. AXL never demands a minimum — it’s the same contract Rust’s .await and Node’s microtask queue expose: “the runtime can act at your yield points, and only there.”

4. Resource cleanup when main returns

4.1 UEFI vs POSIX exit semantics

This is where UEFI diverges sharply from Linux. On Linux, when main returns or the process calls exit(), the kernel reclaims the entire address space — heap, file descriptors, signal registrations, everything. Sloppy programs don’t crash the OS; they just waste memory until exit.

UEFI has no process model. There is no per-application address space. There is no teardown. When an AXL app returns, control flows back to the Shell (or BDS), which has no knowledge of what the app allocated. Specifically:

Resource

On Linux exit()

On UEFI app return

Heap (axl_malloc)

kernel reclaims

leaks until reboot — each allocation is a separate gBS->AllocatePool call from a firmware-global pool

EFI_EVENT / AxlEvent

closed

crash hazard — firmware keeps the event registered; if a later SignalEvent calls a notify function whose code pages were unloaded with your image, system crashes post-exit

Installed protocols

N/A

crash hazard — firmware holds the vtable forever

File handles

closed

filesystem driver keeps state pinned

Loaded child images

N/A

stay in memory

UEFI variables, network handles, registered callbacks

N/A

all leak

The firmware-facing resources (events, protocols, registered callbacks) are the dangerous class. A crash two minutes after the app exits — triggered by a timer firing into unloaded code — is one of the harder UEFI bugs to diagnose.

Today _axl_cleanup (src/posix/axl-app.c:92) only:

  1. Frees the argv/argc it allocated in _axl_init.

  2. Under AXL_MEM_DEBUG, calls axl_mem_dump_leaks() — a diagnostic report, not cleanup. It names what leaked; it doesn’t free anything.

Phase A7 fixes this by making the library responsible for firmware-facing resources it handed to the user, and for running a guaranteed cleanup path on every exit type.

4.2 The internal resource registry

Design principle: every library function that creates a firmware-facing resource registers it. On exit, a sweep closes whatever’s left. This is not garbage collection or refcounting — it’s a safety net for sloppy app code.

Two-tier policy

Tier 1 — firmware-facing or container-owned (always tracked, always swept).

Creator

What enters the registry

Removed by

axl_event_new

one event (crash hazard if leaked)

axl_event_free (removes before teardown) or _axl_cleanup sweep

axl_cancellable_new

the wrapped event

axl_cancellable_free or sweep

axl_loop_new

the loop + each internal event it creates

axl_loop_free or sweep

axl_arena_new

the arena (covers all sub-allocations inside it — see below)

axl_arena_free or sweep

(future) axl_file_open, axl_http_client_new, axl_tcp_*

respective handle

respective _free or sweep

On sweep, each remaining entry’s type determines its teardown call. Sweep order is LIFO (reverse registration order), matching atexit semantics and letting containers (loops) tear down before their contents (events they registered as sources).

Tier 2 — heap (axl_malloc et al.).

axl_malloc already tracks every allocation under AXL_MEM_DEBUG via a doubly-linked list (see src/mem/axl-mem.c:100). Extend the cleanup path:

  • Under AXL_MEM_DEBUG: keep current behavior — report on cleanup, don’t free. Dev sees bugs and fixes them.

  • In release builds: walk the same list, axl_free each entry. Heap returns to the firmware pool cleanly.

Rationale: heap leaks waste memory but don’t crash firmware. Auto-freeing in debug would hide bugs; auto-freeing in release is the production safety net. Tier 1 is different — leaks there can crash the system, so safety wins in every mode.

Arena sub-allocations (axl_arena_alloc) do not produce individual tracker entries — they’re pure bump-pointer offsets into the arena’s backing buffer, not separate heap blocks. The arena itself is what gets tracked (tier-1 registry above), and freeing it reclaims every sub-allocation it handed out at once. Callers who lean on AxlArena for scoped lifetimes get implicit coverage: thousands of sub-allocations, one registry entry, one sweep call clears them all.

4.2.1 Caller attribution for sweep warnings

Sweep warnings are most useful when they name user-code file:line, not the library wrapper. Today, axl_calloc inside axl_arena_new records src/mem/axl-arena.c as the alloc site — technically accurate, practically useless for debugging.

Same trick the allocator already uses: the public APIs become macros that capture __FILE__ / __LINE__ at the user call site, forward to an _impl function that accepts them:

/* include/axl/axl-arena.h */
#define axl_arena_new(cap)  axl_arena_new_impl((cap), __FILE__, __LINE__)
AxlArena *axl_arena_new_impl(size_t capacity, const char *file, int line);

Extend to axl_event_new, axl_loop_new, axl_cancellable_new, and the future file/http wrappers. Sweep output goes from:

[WARN] runtime: 1 MB heap leaked  (src/mem/axl-arena.c:48)

to:

[WARN] runtime: auto-closing 1 leaked AxlArena  (main.c:17, 1 MB)

Much more actionable.

Registry structure (sketch)
/* src/runtime/axl-registry.c (new under Phase A7) */

typedef enum {
    AXL_RES_EVENT,
    AXL_RES_LOOP,
    AXL_RES_FILE,
    /* grows as more library wrappers are added */
} AxlResourceKind;

/* Called by library wrappers in their new/free functions */
uint32_t _axl_registry_add(AxlResourceKind kind, void *resource,
                           const char *file, int line);
void     _axl_registry_remove(uint32_t handle);

/* Called from _axl_cleanup after user atexit handlers have run */
void     _axl_registry_sweep(void);

Each tier-1 wrapper changes from:

AxlEvent *axl_event_new(void) {
    /* ...existing init... */
    return e;
}

to:

AxlEvent *axl_event_new(void) {
    /* ...existing init... */
    e->_registry_handle = _axl_registry_add(AXL_RES_EVENT, e,
                                            __FILE__, __LINE__);
    return e;
}

void axl_event_free(AxlEvent *e) {
    if (e == NULL || e->magic != AXL_EVENT_MAGIC) return;
    _axl_registry_remove(e->_registry_handle);
    /* ...existing teardown... */
}
Sweep logging

When the sweep finds anything, loudly log it — the user’s code should be fixed, not silently rescued:

[WARN] runtime: auto-closing 3 leaked AxlEvent instances
   event@0x7FE12340  allocated at src/myapp.c:42 by axl_event_new
   event@0x7FE12380  allocated at src/myapp.c:58 by axl_loop_new
   event@0x7FE12400  allocated at src/myapp.c:91 by axl_tcp_accept_async
[WARN] runtime: 1024 bytes of heap auto-freed on exit (set
       AXL_MEM_DEBUG to get per-allocation detail)

Same pattern axl_mem_dump_leaks uses; just extend to tier-1 resources.

Double-close safety

The sweep walks resources that slipped past explicit _free calls. Magic-number guards on AxlEvent and AxlCancellable catch any ordering bug (loop frees before its child events are swept, etc.) by no-oping on dead magic with a logged warning.

4.3 axl_atexit — POSIX-flavored cleanup registry

/**
 * @brief Register a callback to run during _axl_cleanup.
 *
 * Callbacks fire in LIFO order (last-registered-first-run), which
 * matches C's atexit() and matches stack-unwinding intuition for
 * "tear down the newest thing first." Each callback receives the
 * user data pointer supplied at registration.
 *
 * Use cases: free top-level resources (loops, caches, HTTP
 * clients, open files) that would leak if not explicitly released.
 *
 * Limits: registry is fixed-size (default 16 entries). Returns
 * a handle so handlers can be removed early via axl_atexit_remove.
 */
typedef void (*AxlAtexitFn)(void *data);

uint32_t axl_atexit(AxlAtexitFn fn, void *data);
void     axl_atexit_remove(uint32_t handle);

4.4 axl_exit(rc) — the guaranteed-cleanup exit path

Today, app code that calls gBS->Exit directly (or aborts through some other path) bypasses _axl_cleanup entirely — argv isn’t freed, leak report doesn’t fire, and once the registry lands, events won’t be swept either. This is a landmine.

Phase A7 introduces:

/**
 * @brief Terminate the application with cleanup guaranteed.
 *
 * Runs atexit callbacks (LIFO), sweeps the resource registry,
 * runs heap cleanup per build mode (debug: report; release: free),
 * then calls gBS->Exit(image, status, 0, NULL). Does not return.
 *
 * This is the ONLY blessed exit path. Apps that return from main
 * take the same path via the AXL_APP entry wrapper. Apps that
 * call gBS->Exit directly bypass cleanup -- don't.
 */
AXL_NORETURN void axl_exit(int rc);

All the exit flows funnel through it:

Entry

Path

main returns

AXL_APP wrapper → _axl_cleanupgBS->Exit

App calls axl_exit(rc)

_axl_cleanupgBS->Exit

App calls exit(rc) (POSIX compat)

thin wrapper to axl_exit

Default break handler fires

_axl_cleanupgBS->Exit(..., EFI_ABORTED, ...)

Installed break handler returns

flag set → next yield / wait returns AXL_CANCELLED → caller unwinds → main returns → wrapper path

_axl_cleanup itself becomes:

void _axl_cleanup(void) {
    _axl_atexit_run_all();      /* user callbacks, LIFO */
    _axl_registry_sweep();       /* tier-1 firmware resources */
#ifdef AXL_MEM_DEBUG
    axl_mem_dump_leaks();        /* diagnose */
#else
    axl_mem_sweep_free_all();    /* safety net (new) */
#endif
    /* argv, io streams, break-notify teardown, watchdog cancel */
    ...
}

4.5 What fires when

Normal exit path (main returns):

  1. Entry wrapper captures rc from main.

  2. Calls axl_exit(rc) (or inlines the body).

  3. axl_exit runs _axl_cleanup, calls gBS->Exit.

Explicit exit (axl_exit(rc) or exit(rc)):

  1. Same as above from step 2. Unwinding stack above the call does not happen — AXL_AUTOPTR in outer scopes does not run. Apps that need scope cleanup must register via axl_atexit.

Break-driven exit, default handler (no axl_signal_install):

  1. Break notify fires at raised TPL → sets g_axl_interrupted, calls registered default handler.

  2. Default handler returns; next yield/wait observes the flag.

  3. Yield path calls axl_exit(EFI_ABORTED).

Break-driven exit, user handler installed:

  1. Break notify fires → sets flag → calls user handler.

  2. User handler does limited work (set local flag, log) and returns.

  3. Next yield or wait returns AXL_CANCELLED to the caller.

  4. Caller unwinds normally through AXL_AUTOPTR etc.

  5. main returns; entry wrapper path runs.

The user-installed handler is never expected to do cleanup itself. It can’t reliably — it runs at raised TPL with limited services available. Cleanup happens on the normal unwind path, same as any other exit.

4.6 What AXL_AUTOPTR handles already

Scope-bound resources (declared with AXL_AUTOPTR(AxlEvent) etc.) automatically free on scope exit — including when a wait returns AXL_CANCELLED and the caller unwinds back through the scope. No atexit entry needed for those.

axl_atexit is specifically for long-lived resources that outlive function scope and would leak at process exit.

5. Nested loops

“What happens when a user embeds an Axl main loop within our CRT0 created loop?”

Scenarios and their semantics:

5.1 App doesn’t use the default loop at all

int main(int argc, char **argv) {
    AxlLoop *loop = axl_loop_new();
    /* ... register sources ... */
    axl_loop_run(loop);
    axl_loop_free(loop);
    return 0;
}

Semantics: fine. Default loop exists idle in CRT0 but nothing drives it. Break is still detected (via notify callback, not via loop dispatch). App’s loop is the active one; it picks up the break flag via its own sources (the break-event poll continues to register there too, under the hood).

5.2 App uses default loop directly

int main(int argc, char **argv) {
    AxlLoop *loop = axl_loop_default();
    axl_loop_add_timer(loop, 1000, on_tick, NULL);
    axl_loop_run(loop);
    /* no axl_loop_free — CRT0 owns this one */
    return 0;
}

Semantics: fine. One loop, no nesting. Default loop is torn down in _axl_cleanup by CRT0.

5.3 App creates its own loop alongside the default

int main(int argc, char **argv) {
    /* default loop exists, idle */
    AxlLoop *my_loop = axl_loop_new();
    axl_loop_add_timer(my_loop, 1000, on_tick, NULL);
    axl_loop_run(my_loop);  /* drives my_loop, not the default */
    axl_loop_free(my_loop);
    return 0;
}

Semantics: the two loops are independent. The running one dispatches its sources; the default sits idle. Sources registered with the default loop (e.g., if CRT0 has a watchdog timer there) do not fire while my_loop is running. This is OK because CRT0 shouldn’t rely on the default loop being driven — break is notify-based, not loop-based.

5.4 True nested loops (inner loop runs while outer is running)

This happens inside the library today: axl_wait_* and axl_event_wait_* spin up a throwaway loop to wait, while the caller’s outer loop is blocked in a callback.

outer axl_loop_run
  └─ source fires → cb is running
       └─ cb calls axl_wait_for_flag(...)
            └─ creates throwaway inner loop, runs it
                 └─ inner dispatches inner sources until flag is true
            └─ inner freed, axl_wait_for_flag returns
       └─ cb returns
  └─ outer resumes dispatch

Semantics: fine. Throwaway loops are a known pattern. Inner loop has its own sources (event, timeout, cancel event). Outer loop’s sources are not dispatched during the inner run — that’s the nesting cost, accepted.

5.5 Rule

The default loop is never used as a wait-helper throwaway. Wait/event-wait always create their own ephemeral loops. This prevents source leaks between unrelated waits, and keeps the default loop’s invariants (for CRT0’s own use) intact.

5.6 Nested-wait primitive: axl_loop_iterate_until

The throwaway-loop pattern in §5.4 has a real cost: while the inner loop is running, the outer loop’s sources are frozen. Confirmed by the Phase A7 prototype (scenario 5, April 2026): a timeout source added to the outer loop inside a callback cannot fire until the callback returns, because the outer loop’s WaitForEvent is paused.

For callers that want the opposite behavior — drive the current loop until a condition fires, without quitting it — the library exposes an iteration primitive:

/** Iterate `loop` until `done` is signalled, `pred` returns true,
 *  `timeout_us` elapses, or Ctrl-C. Does NOT set the loop's quit
 *  flag — the caller's outer run continues after this returns.
 *
 *  @return 0 on `done` / `pred`, -1 on timeout, AXL_CANCELLED on
 *          Ctrl-C. */
typedef bool (*AxlIteratePred)(void *data);

int axl_loop_iterate_until(
    AxlLoop          *loop,
    AxlEventHandle    done,           /* or NULL */
    AxlIteratePred    pred,           /* or NULL */
    void             *pred_data,
    uint64_t          timeout_us);

Usage split:

  • Library-internal waits that don’t know the caller’s loop → keep using ephemeral loops (safe default, no coupling).

  • Waits inside a callback of a known loop → call axl_loop_iterate_until(loop, done, ..., timeout). Outer sources continue to fire. This is the primitive users would otherwise reach for loop-inheritance to get.

Loop inheritance (e.g., axl_loop_set_parent) is explicitly deferred. Inheritance solves the same symptom but introduces ambiguity (which loop owns a source, double-dispatch, lifetime coupling) and conflicts with the “ephemeral loop for unknown callers” default. The iterate-until primitive gives the same ergonomics opt-in at the call site where the caller already knows which loop they’re inside. Revisit inheritance only if a specific use case demands it.

6. Public API surface

/* axl-signal.h -- interrupt handler + blessed exit path */
typedef void (*AxlSignalHandler)(void);

void              axl_signal_install(AxlSignalHandler on_interrupt);
void              axl_signal_default(void);
bool              axl_interrupted(void);
AXL_NORETURN void axl_exit(int rc);

/* axl-runtime.h -- default loop + yield + registry inspection */
AxlLoop *axl_loop_default(void);
void     axl_yield(void);
size_t   axl_registry_count(void);

/* axl-atexit.h -- LIFO cleanup registry */
typedef void (*AxlAtexitFn)(void *data);

uint32_t axl_atexit(AxlAtexitFn fn, void *data);
void     axl_atexit_remove(uint32_t handle);

/* axl-loop.h -- nested-wait primitive, see §5.6 */
int axl_loop_iterate_until(
    AxlLoop  *loop,
    AxlEvent *done,        /* NULL = no done event */
    uint64_t  timeout_us); /* 0 = wait forever */

Source layout:

src/runtime/
  axl-runtime.c    _axl_init / _axl_cleanup; axl_loop_default; axl_yield
  axl-registry.c   tier-1 resource registry (internal)
  axl-atexit.c     LIFO callback registry
  axl-signal.c     signal install / interrupted / axl_exit

axl_loop_iterate_until lives in src/loop/axl-loop.c alongside axl_loop_run.

Pre-landing rename (merged as PR #1). The existing axl-signal.h pub/sub bus was renamed to axl_pubsub_* / axl-pubsub.h specifically to free the axl_signal_* namespace for this interrupt API. The new axl-signal.h houses AxlSignalHandler / axl_signal_install / axl_signal_default / axl_interrupted / axl_exit. Identifier map for historical reference:

Pre-landing rename (separate PR). The existing axl-signal.h pub/sub bus is renamed to axl_pubsub_* / axl-pubsub.h before any axl_signal_* interrupt-API symbol ships. The new axl-signal.h replaces the old file and houses AxlSignalHandler / axl_signal_install / axl_signal_default. Blast radius: ~90 identifiers across 14 files; mostly mechanical. Identifier map:

Old

New

AxlSignalCallback

AxlPubsubCallback

axl_signal_new(loop, name)

axl_pubsub_register(loop, name)

axl_signal_reset

axl_pubsub_reset

axl_signal_connect

axl_pubsub_subscribe

axl_signal_disconnect

axl_pubsub_unsubscribe

axl_signal_emit

axl_pubsub_publish

AXL_SIGNAL_H

AXL_PUBSUB_H

7. What we are not doing

  • setjmp/longjmp from the break notify. Classic footgun; skips all destructors and leaks resources; corrupts invariants.

  • UEFI watchdog as a signal mechanism. Watchdog is reset-only; can’t be repurposed. Optional use as a library-livelock guard only.

  • NMIs, hardware interrupts, or firmware-specific preemption hooks. Platform-dependent, unreliable, out of AXL’s scope.

  • Any claim that CPU-bound app code that ignores AXL is interruptible. It isn’t, and that’s honest. Document loudly.

8. Landed as Phase A7

The runtime prototype in sdk/examples/runtime-demo.c validated the API shape end-to-end; the real module then landed as a seven- commit series on main (April 2026):

Commit

Scope

3789aea

axl_loop_iterate_until promoted from prototype

0990ae2

src/runtime/ skeleton + default loop + axl_yield

a09fe38

Tier-1 registry + caller-attribution macros

8a0f275

axl_atexit LIFO cleanup registry

dc37daa

axl_signal_install + axl_exit + axl_backend_boot_exit

64bad90

AxlTestRuntime unit test binary

4368256

runtime-demo migrated off the mini-runtime to real APIs

The eight runtime-demo scenarios now drive the real runtime:

#

Subcommand

Validates

1

signal

axl_signal_install; user handler fires on Ctrl-C

2

atexit

LIFO drain during _axl_cleanup

3

yield

axl_yield + axl_interrupted; ≤100 ms break response

4

default-loop

Singleton loop teardown

5

nested-loop

Ephemeral-loop contract (outer freezes during wait)

5b

iterate-until

axl_loop_iterate_until (outer sources keep firing)

6

leak-event

Registry sweep catches leaks with user file:line

7

axl-exit-vs-return

Identical cleanup on both exit paths

Regression state at the end of the series: 1332/1332 unit tests on X64 and AARCH64, CPU-idle ratio 0.39 (threshold 0.60).

9. Design decisions locked in

Captured here so they don’t re-surface as questions during implementation:

  • Registry is always on. No AXL_NO_RUNTIME_REGISTRY escape hatch. Drivers and runtime images rarely create resources through the axl_event_* public API — they work directly with backend or EDK2 primitives — so the registry cost falls on the app-level consumers who benefit from it.

  • Heap sweep is mode-dependent: debug reports, release frees. Debug must not auto-free or developers never see their bugs.

  • Sweep order is LIFO registration order. Matches atexit and lets containers tear down before their contents.

  • axl_exit is the only blessed exit path. Bypassing it (raw gBS->Exit, explicit PE return) is documented as unsafe and skips all cleanup.

  • User break handlers don’t do cleanup. They run at raised TPL where cleanup isn’t safe; cleanup runs on the unwind.

  • Interrupt API uses axl_signal_* (the POSIX-flavored name users’ muscle memory reaches for first). The axl_signal_* namespace is freed by renaming the existing pub/sub bus — see next bullet.

  • Pub/sub bus renamed to axl_pubsub_*. Happens as a separate pre-landing PR specifically to free up axl_signal_* for the interrupt API. Pre-1.0, ~90 identifiers across 14 files; see §6 for the identifier map.

  • Loop inheritance / axl_loop_set_parent deferred. The nested-wait use case is covered by axl_loop_iterate_until (§5.6), which is opt-in at the call site. Revisit only if a concrete use case demands inheritance semantics.

  • Registry storage: dynamic (AxlArray-backed), not fixed-size. The prototype used fixed-16 and never hit the cap, but apps with hundreds of live resources (HTTP clients, cached connections) could; the cost is one arena-backed AxlArray that rarely grows past initial capacity.

  • axl_yield dispatches the default loop only when pending work is immediately ready (non-blocking poll). No wait, no iteration count.

  • axl_interrupted() reports Ctrl-C only, not cancellables. AxlCancellable waits continue to return AXL_CANCELLED as today.

  • Break during axl_yield with an installed handler that returns normally: yield returns. Caller reacts via axl_interrupted(). Matches POSIX signal-handler semantics.

  • Watchdog default: off. Opt-in via axl_watchdog_enable(60) for apps that want the livelock guard.

10. Deferred items

Phase A7 landed the runtime surface end-to-end. Two design-doc items are deferred to a follow-up phase when the motivating use case appears:

10.1 Release-mode heap auto-sweep

§4.2 tier-2 proposed that release builds walk mAllocList at _axl_cleanup and axl_free each entry – so apps that leak heap on exit don’t bleed memory into the firmware pool across many invocations.

Status: not implemented. The tier-1 (firmware-resource) registry sweep IS implemented and handles the crash-hazard class (events, loops, cancellables, arenas). The tier-2 heap sweep was skipped because mAllocList only exists under AXL_MEM_DEBUG today – release builds use a single-word header with no linked list. Implementing auto-sweep requires promoting the prev/next pointers out of the debug gate (cost: ~16 bytes per allocation on x64 in release).

Implement when: we have a long-running app (e.g. SoftBMC, httpfs running as a persistent service) where leaked heap survives long enough to matter. Short-lived tool-style apps (fetch, sysinfo, etc.) don’t benefit meaningfully – the firmware reboot reclaims pool memory anyway.

10.2 Watchdog as library-livelock guard

§9 locked in “watchdog default: off, opt-in via axl_watchdog_enable(seconds)”. The API doesn’t exist yet. No concrete caller has asked for it. Implement when needed.

11. What this doesn’t help with

  • CPU-bound app code with no axl_yield and no AXL calls: still uninterruptible. Document with a specific example.

  • Code hung inside a firmware call (UEFI protocol deadlock): not our problem; watchdog reset is the only option.

  • Bugs in firmware event handling: platform-specific; document workarounds as they come up.


Appendix: Decision log

Captures the high-level choices made in our design conversations so future contributors don’t re-litigate them.

  • No longjmp. Rejected in the signals discussion for async- signal-unsafety reasons. See §7.

  • No watchdog repurpose. Watchdog is reset-only on every platform; not useful for signal-like semantics. See §7.

  • Yes CRT0-owned runtime. Controlling every AXL API is the right leverage point — cooperative yields in library code approximate POSIX signal responsiveness. See §1 and §3.

  • Default loop is optional, not mandatory. Apps that already manage their own don’t have to change. See §5.

  • Sleep is Ctrl-C interruptible. Landed in commit 72ae173, documented in axl-wait.h. This doc builds on that assumption.

  • Interrupt API prefix is axl_signal_*. The POSIX-flavored name is what users’ muscle memory reaches for first. The existing pub/sub bus occupying that namespace is renamed out of the way (see next entry). Internal plumbing keeps “break” where it refers to the UEFI mechanism (axl_backend_shell_break_*, the firmware event’s own name); the user-facing API is “signal”. See §2.2.

  • Pub/sub bus renamed to axl_pubsub_*. The pre-1.0 rename specifically frees the axl_signal_* prefix for the interrupt API — that’s the whole justification for paying the rename cost. Prefix + verbs change together: publish / subscribe / unsubscribe / register. See §6.

  • Loop inheritance rejected in favor of axl_loop_iterate_until. Inheritance solves the nested-wait outer-loop-starved symptom but introduces lifetime/ownership ambiguity and conflicts with the ephemeral-loop default. The explicit iterate-until primitive gives the same ergonomics with opt-in at the call site where the caller already knows which loop they’re in. See §5.6.

  • Phase A7 prototype landed. sdk/examples/runtime-demo.c implements the mini-runtime with all seven scenarios from §8.3. Validates atexit LIFO, tier-1 registry sweep with caller attribution, axl_yield interruption (≤100ms response), default loop teardown, nested-wait pattern, and identical cleanup on both return and axl_exit paths. Confirmed on X64 + AARCH64; 1313/1313 tests still green; CPU-idle ratio 0.39 (threshold 0.60).