AxlService — Long-Running Services

AxlService

Structured-lifecycle wrapper over AxlLoop. A service is a long-running event loop with a setup callback that builds sources/timers/handlers, a teardown that releases them, and an options descriptor that auto-applies values into a consumer struct via offsetof.

This is not a Unix daemon — UEFI has no fork/setsid/chdir/ umask. AxlService is closer in shape to a systemd unit: a thing with a defined lifecycle, supervised by an external dispatcher (the shell’s foreground caller, or the firmware’s notify-timer in driver mode).

Shape

AxlService is a DXE driver. The consumer’s main() is a launcher / supervisor; the actual service body — setup, the long-running loop, teardown — lives in a separate driver .efi image, typically embedded into the foreground via .incbin.

#include <axl.h>

typedef struct { uint16_t port; bool verbose; } MyOpts;
static MyOpts opts;

static const AxlConfigDesc opts_descs[] = {
    { "port",    AXL_CFG_UINT, "8080", "Port",
      offsetof(MyOpts, port), sizeof(uint16_t) },
    { "verbose", AXL_CFG_BOOL, "false", "Verbose",
      offsetof(MyOpts, verbose), sizeof(bool) },
    { 0 }
};

static int my_setup(AxlLoop *loop, void *user) {
    /* build sources against `loop`, e.g. axl_http_server_start */
    return AXL_OK;
}
static int my_teardown(void *user) { /* release */ return AXL_OK; }

static const AxlService my_service = {
    .name           = "my-service",
    .opts_descs     = opts_descs,
    .setup          = my_setup,
    .teardown       = my_teardown,
    .user           = &opts,
    .driver_tick_ms = 50,
};
/* Identity GUID is derived from `.name` via axl_guid_v5 — no
   uuidgen required. Same name in launcher + driver image →
   same derived GUID. */

The driver image’s only line is the AXL_SERVICE_DRIVER macro:

/* my-service-dxe.c, compiled as the embedded driver */
#include <axl.h>
#include "shared.h"   /* same my_service descriptor as the foreground */
AXL_SERVICE_DRIVER(my_service);

Driver-tick mode (raw). Used by the macro to attach the service to the firmware notify-timer:

AxlLoop *loop = axl_loop_new();
axl_service_attach_driver(loop, &my_service, 50);  /* 50 ms tick */
/* ...firmware drives the loop until... */
axl_service_detach_driver(loop, &my_service);
axl_loop_free(loop);

Direct calls are uncommon — AXL_SERVICE_DRIVER covers the DriverEntry/Unload boilerplate.

Embedded-driver launch (foreground side). Foreground app ships the driver image as an embedded blob (via axl-cc --embed or the Makefile’s EMBED_BLOB), serializes its current options to LoadOptions, and axl_service_start_embedded hands them to the driver. AXL_EMBED_* hides the .incbin symbol-naming convention:

AXL_EMBED_DECLARE(my_driver);  /* from <axl/axl-embed.h> */

/* AXL_EMBED_SIZE is a runtime pointer subtraction (not a constant
   expression), so build the deploy at runtime rather than as a
   static const initializer. The protocol GUID lives on the inner
   AxlService — both binaries share the same descriptor, so they
   agree on identity by construction. */
AxlServiceDeploy d = {
    .service         = &my_service,
    .driver_blob     = AXL_EMBED_DATA(my_driver),
    .driver_blob_len = AXL_EMBED_SIZE(my_driver),
    .driver_name     = "my-service-dxe.efi",
};

if (axl_service_is_running(&d)) {
    axl_printf("Already running\n");
    return 0;
}
return axl_service_start_embedded(&d);

Build:

axl-cc --embed my-service-dxe.efi=my_driver launch.c -o launch.efi

The symmetric stop verb is axl_service_stop(&deploy) — resolves the running image’s handle via the protocol GUID and unloads it. AXL_SERVICE_DRIVER publishes the GUID on the driver image’s own handle (not a sentinel), so stop’s LocateHandleBuffer returns the image handle directly. Stop is idempotent — calling it on a not-running deploy returns AXL_OK without doing anything.

return axl_service_stop(&deploy);

The driver image’s DriverEntry is one line — AXL_SERVICE_DRIVER emits the boilerplate (decode LoadOptions magic-prefixed UTF-8 query string back into opts via axl_config_from_string, create loop, attach):

#include <axl.h>
/* same opts_descs and my_service descriptor as the foreground side.
   Tick period comes from my_service.driver_tick_ms (0 = 50 ms
   default); the macro takes only the descriptor. */
AXL_SERVICE_DRIVER(my_service);

Setup-failure contract

setup owns its own unwind on failure (axl_free anything it allocated, axl_protocol_unregister anything it published). The framework calls teardown only after a successful setup. This rule is the same in foreground and driver modes.

Held-protocol hazard

Any UEFI protocol the setup callback opens with OpenProtocol (including the implicit opens that axl_http_server_start, axl_tcp_listen, etc. do via service-binding) MUST be closed before the matching teardown returns. The firmware’s post-callback refcount check at the end of gBS->UnloadImage will refuse the unload with EFI_ACCESS_DENIED otherwise — making the service un-stoppable for the lifetime of the process. axl_service_stop will return AXL_ERR and the SDK will log:

axl_driver_unload: UnloadImage(handle=0x...) returned
EFI_ACCESS_DENIED (0x800000000000000F) — image still holds open
protocol references; if your service opens UEFI protocols in
setup, ensure teardown closes every one (axl_http_server_free,
axl_tcp_close, etc.) before returning

The SDK’s wrappers (axl_http_server_free, axl_tcp_close, etc.) close the protocols they opened. If your setup uses OpenProtocol directly, your teardown owns the matching CloseProtocol.

Teardown is invoked at two sites — all visible

The teardown callback fires from one of:

  1. axl_service_attach_driver’s failure path (rolls back setup)

  2. AXL_SERVICE_DRIVER’s unload stub after axl_service_detach_driver

Each call goes through axl_service_teardown, which logs teardown ENTER / teardown EXIT rc=N at debug level so consumers can confirm without adding their own printfs. Bump AXL_LOG_LEVEL=debug to see the trace.

axl_service_detach_driver does NOT call teardown — the caller follows up with axl_service_teardown explicitly. This is the P1 contract change; previously detach_driver invoked teardown internally, which made the macro unreadable (no literal teardown(...) call appeared in the unload stub source) and silently skipped teardown if axl_loop_detach_driver returned ERR.

Cross-binary ABI tripwire

When the same static const AxlService descriptor is initialized in two binaries (foreground app + embedded driver image), the option struct’s layout MUST match across both. Build both from the same source tree with identical compile flags (AXL_TLS, AXL_MEM_DEBUG, arch). The Makefile’s AXL_TLS toggle detection wipes AXL-internal stale objects automatically; it cannot see consumer-side struct shifts.

The wire format prefixes each LoadOptions payload with the 8-byte magic AXLSVC1\0 so a shell-launched UCS-2 LoadOptions buffer doesn’t get misparsed as the AXL UTF-8 format. The driver-image macro logs and falls back to descriptor defaults on missing-magic.

See sdk/examples/service-demo.c for the one-line AXL_SERVICE shape and sdk/examples/service-demo-custom.c for a hand-written main() mixing the standard verbs with consumer-specific verbs. <axl/axl-service.h> has the full API doxygen.

API Reference

Defines

AXL_SERVICE_DEFAULT_TICK_MS

Default driver-tick dispatch period (milliseconds).

Used when AxlService.driver_tick_ms is 0. 50 ms balances loop responsiveness against the firmware notify-timer’s overhead.

Typedefs

typedef int (*AxlServiceSetup)(AxlLoop *loop, void *user)

Setup callback — builds sources, timers, handlers against loop.

axl-service.h:

Driver-shaped lifecycle wrapper over AxlLoop. An AxlService is the typed shape for a long-running event loop: a setup callback that builds sources/timers/handlers, a teardown that releases them, and an options descriptor that auto-applies into the consumer’s option struct via offsetof.

AxlService is a DXE driver. Long-running work in UEFI lives in driver images — foreground apps die when their main returns. AxlService doesn’t try to hide that; it embraces it. There is no in-process foreground “run” path. The consumer’s main() is a launcher / supervisor, not the body of the service.

Two operational entry points:

axl_service_attach_driver(loop, &svc) Driver-tick mode. Used by the AXL_SERVICE_DRIVER macro to attach the service to the firmware notify-timer. Tick period comes from svc->driver_tick_ms (0 = 50 ms default). Direct use is rare — the macro covers the common case.

axl_service_start_embedded(deploy) Foreground-side: load an embedded driver image carrying this service via .incbin, serialize the foreground’s options into LoadOptions, pass to the driver. The driver’s DriverEntry (built with AXL_SERVICE_DRIVER) decodes them on entry. Pair with axl_service_stop.

Cross-binary ABI tripwire. When the same service descriptor is initialized in two binaries (foreground app + driver image), the option struct’s layout must match across both. Build both from the same source tree with identical compile flags (AXL_TLS, AXL_MEM_DEBUG, arch). The Makefile’s AXL_TLS state detection only catches AXL-internal struct shifts; it cannot see consumer struct shifts caused by toggling the consumer’s own conditional fields.

Called once at service startup, BEFORE the loop starts dispatching. Receives the same loop the service will run on, so the consumer can add accept/recv/timer sources directly.

Held-protocol hazard. Any UEFI protocol the setup callback opens with OpenProtocol (including the implicit opens that axl_http_server_start, axl_tcp_listen, etc. do via service-binding) MUST be closed before the matching teardown returns, or the firmware’s post-callback refcount check will refuse gBS->UnloadImage with EFI_ACCESS_DENIED. This makes the service un-stoppable for the lifetime of the process; the consumer’s axl_service_stop will fail with the SDK warning pointing at this exact case. The SDK’s wrappers (axl_http_server_free, axl_tcp_close, etc.) close the protocols they opened — if your setup uses OpenProtocol directly, your teardown owns the matching CloseProtocol.

Failure contract: on AXL_ERR return, the consumer is responsible for unwinding any partial state (axl_free, axl_protocol_unregister, etc.). The framework will NOT call teardown — teardown only fires after a successful setup.

Param loop:

the event loop the service runs on

Param user:

the descriptor’s user pointer (typically the consumer’s options struct, populated by AxlConfig auto-apply)

Return:

AXL_OK if the service is ready to dispatch; AXL_ERR on any setup failure (consumer has already cleaned up).

typedef int (*AxlServiceTeardown)(void *user)

Teardown callback — releases what setup built.

Called at service shutdown ONLY if setup returned AXL_OK. Receives the same user pointer setup got. Return value is logged but doesn’t affect the framework’s teardown sequence.

Must close every protocol setup opened. See the “Held-protocol hazard” note on AxlServiceSetup. The framework cannot detect a missed CloseProtocol; the symptom is axl_service_stop returning AXL_ERR with the SDK’s EFI_ACCESS_DENIED warning at axl-driver.c.

Functions

int axl_service_guid(const AxlService *svc, AxlGuid *out)

Resolve a service’s UEFI protocol identity GUID.

AxlService publishes its identity by deriving a deterministic GUID from svc->name via axl_guid_v5 with the AXL_SERVICE namespace. Consumers normally don’t need this — axl_service_is_running, axl_service_stop, and the AXL_SERVICE_DRIVER macro do the derivation internally — but if you want to call LocateProtocol from outside AxlService (e.g. to publish a service-specific child interface on the same handle), use this to get the same GUID those code paths use.

Returns:

AXL_OK on success (out populated); AXL_ERR if svc is NULL, svc->name is NULL, or out is NULL.

int axl_service_attach_driver(AxlLoop *loop, const AxlService *svc)

Attach a service to firmware-tick dispatch on the supplied loop.

Calls setup(loop, svc->user) then axl_loop_attach_driver. The tick period comes from svc->driver_tick_ms; if that field is 0, AXL_SERVICE_DEFAULT_TICK_MS (50 ms) applies. Returns immediately — the firmware notify-timer drives the loop from here. Pair with axl_service_detach_driver from DriverUnload.

The caller (typically the AXL_SERVICE_DRIVER macro) owns the loop; this function does NOT free it on failure — the caller must axl_loop_free if attach fails.

Returns:

AXL_OK if setup + attach both succeeded; AXL_ERR otherwise.

int axl_service_detach_driver(AxlLoop *loop, const AxlService *svc)

Detach a service’s firmware-tick dispatch.

Cancels the periodic notify-timer the matching axl_service_attach_driver installed. Returns AXL_ERR if the loop was never attached.

Does NOT call svc->teardown. That’s the caller’s responsibility — invoke axl_service_teardown after this returns AXL_OK if setup ran. (The contract changed under P1: previously detach_driver also called teardown internally, but the call was hidden inside detach_driver’s success path which made the macro unread-able and silently skipped teardown if detach_driver gained any new failure mode. Splitting makes each function’s responsibility narrow.)

The AXL_SERVICE_DRIVER macro’s unload stub does this in sequence; consumers using axl_service_attach_driver directly must mirror it.

Do NOT call this if axl_service_attach_driver returned AXL_ERR.

The attach-failure path already invoked teardown internally; calling detach + teardown manually would re-fire teardown. The safe pattern is “only detach what attach succeeded

for.”

Parameters:
  • svc – accepted but currently only NULL-checked; reserved for future per-service detach work and kept in the signature for source-compat

int axl_service_teardown(const AxlService *svc)

Invoke a service’s teardown callback with consistent logging.

Single source of truth for teardown invocation across the SDK:

  • axl_service_attach_driver's failure path

  • the AXL_SERVICE_DRIVER macro’s unload stub after detach

Logs teardown ENTER / teardown EXIT rc=N at debug level, promotes a non-OK rc to a warning. Safe to call with svc == NULL or svc->teardown == NULL — both return AXL_OK without invoking anything.

Consumers normally don’t call this directly — the three public sites above invoke it. Public so consumers rolling their own dispatch (custom firmware-tick driver, alternative deploy mode) can match the framework’s logging shape.

Idempotency is the consumer’s responsibility. Calling teardown twice on a setup that wasn’t designed for it is undefined behavior (axl_free of an already-freed handle, etc.).

Returns:

The teardown callback’s return code (AXL_OK if no callback or svc is NULL). The AXL_SERVICE_DRIVER macro propagates this into the EFI_STATUS gBS->UnloadImage sees, so a teardown failure surfaces to the firmware rather than getting absorbed into a “successful” unload.

int _axl_service_driver_init(void *image_handle, void *system_table, const AxlService *svc)

Internal driver-image entry. Implements everything the AXL_SERVICE_DRIVER macro used to inline: backend init, LoadOptions decode, protocol publish, loop creation, attach. Library-side per-image static state holds svc, the loop, the cfg, and the published handle for the matching unload.

Not user API — call AXL_SERVICE_DRIVER instead.

Parameters:
  • image_handle – EFI_HANDLE for this driver image

  • system_table – EFI_SYSTEM_TABLE *

  • svc – service descriptor (non-NULL)

Returns:

EFI_STATUS suitable to return from a firmware DriverEntry.

int axl_service_start_embedded(const AxlServiceDeploy *deploy)

Launch the embedded driver image carrying this service.

Serializes the foreground app’s currently-set option values via axl_config_to_string, then calls axl_driver_ensure_with_embedded(…) with that payload as LoadOptions. The driver image’s DriverEntry (built with AXL_SERVICE_DRIVER) decodes the payload back into its own AxlConfig and the framework auto-applies it into svc->user.

Callers wanting argv -> svc->user overlay before launch must populate *deploy->service->user themselves. Two patterns:

  • Standard verbs only: axl_service_main packages argv-parse + populate + start in one call (and the AXL_SERVICE convenience macro emits a one-line main() that calls it). This is what most consumers want.

  • Custom verb tree: copy AxlArgs values into the user struct yourself via axl_args_get_uint / _bool / _string from your verb handler, then call this. STRING pointers returned by axl_args_get_string live at least until your verb handler returns — call this BEFORE return so the serialize pass reads them while still valid. See sdk/examples/service-demo-custom.c for the worked example.

The AxlConfig path (axl_config_new(descs, NULL, user) + axl_config_from_string per argv element) also works and adds default-application + STRING strdup ownership; reach for it when the consumer is already AxlConfig-shaped.

If the deploy’s protocol GUID is already published when this is called (e.g. a previous launch is still active), AXL’s ensure_with_embedded short-circuits and this returns AXL_OK without re-loading. Use axl_service_is_running to detect that case explicitly if your CLI wants to report “already serving”.

Returns:

AXL_OK if the protocol is registered (was already, or after loading); AXL_ERR on serialize overflow, deploy descriptor incomplete, or driver-load failure.

int axl_service_stop(const AxlServiceDeploy *deploy)

Stop a running service launched via axl_service_start_embedded.

Resolves the running driver image’s handle by looking up the GUID derived from deploy->service->name via LocateHandleBuffer, then calls axl_driver_unload on each match. The driver image’s AXL_SERVICE_DRIVER unload stub fires synchronously inside UnloadImage and runs the framework’s teardown sequence in this order:

  1. axl_loop_detach_driver — stops the firmware notify-timer

  2. service teardown(user) — releases what setup built

  3. axl_protocol_unregister_guid — removes the marker protocol

  4. axl_config_free — drops cached LoadOptions strings

  5. axl_loop_free

Idempotent: if the protocol isn’t currently published (the service was never launched, or was already stopped), returns AXL_OK without doing anything. Use axl_service_is_running first if you want to differentiate “stopped it” from “wasn’t

running.”

Dangling-interface hazard. UEFI doesn’t ref-count protocols. If a third-party consumer obtained service->user (or any other interface) via LocateProtocol and held the pointer past the stop call, that pointer dangles. The same hazard applies to the shell’s unload -n and is fundamental to UEFI’s lifetime model. Callers that publish service interfaces meant for live cross-image consumption should advertise their stop semantics to those consumers (e.g. a marker GUID change or an explicit “I am about to unload” event) before calling stop.

Returns:

AXL_OK on success (or already-stopped); AXL_ERR if deploy or deploy->service is NULL, or any axl_driver_unload call returned an error. If multiple handles publish the GUID, all are unloaded; rc reflects whether any of them failed (the loop continues on individual failures rather than aborting on the first — partial cleanup beats leaking the rest).

bool axl_service_is_running(const AxlServiceDeploy *deploy)

Predicate: is this deploy’s service currently running?

Internally derives the service GUID from deploy->service->name and calls LocateProtocol — succeeds if any image (firmware-shipped or a previous axl_service_start_embedded of this deploy) has published the protocol. Useful before axl_service_start_embedded to differentiate “started fresh” from “was already running.”

Returns:

true if a previous launch is still publishing the protocol.

int axl_service_supervise(const AxlServiceDeploy *deploy)

Block on the default loop, then stop the service.

The standard supervise-and-stop body: blocks on axl_loop_default until axl_loop_run returns (Ctrl-C, axl_loop_quit, or any source removing itself with no others left), then calls axl_service_stop. Returns process-exit-shaped int (0 on clean shutdown, 1 on stop failure or unexpected loop error).

Intended for consumers that want the standard supervise body but still need to register their own loop sources (ESC key handler, a status-print timer, etc.) before blocking. Add those to axl_loop_default first; call this once you’re ready to block:

int rc = axl_service_start_embedded(&deploy);
if (rc != AXL_OK) return 1;
if (detach) return 0;
axl_loop_add_key_press(axl_loop_default(), esc_handler, NULL);
return axl_service_supervise(&deploy);
axl_service_main uses this internally; consumers writing their own main() can compose this directly instead of duplicating the loop-run / stop / rc-translate dance.

Returns:

0 if axl_loop_run quit cleanly (rc 0 or -1 from a Ctrl-C break) AND axl_service_stop succeeded; 1 otherwise.

int axl_service_main(const AxlServiceDeploy *deploy, int argc, char **argv)

Standard main() body for a service consumer.

Builds a default axl_args_run verb tree (launch [--detach], stop, status) from deploy and dispatches argv. The launch verb populates deploy->service->user from the parsed args (synthesizing an AxlArgDesc[] from svc->opts_descs so the consumer doesn’t repeat the descriptor in two formats), calls axl_service_start_embedded, and either:

  • exits if --detach was passed (driver continues to run);

  • blocks on axl_loop_default until Ctrl-C, then calls axl_service_stop on its way out.

Most service consumers don’t need their own main() — the AXL_SERVICE macro emits one for you that calls this. Direct use of axl_service_main is for consumers who want to mix the default verbs with their own (extra verbs, custom help prolog, etc.) — they wire axl_args_run themselves and dispatch the standard verbs into axl_service_main.

Parameters:
  • deploy – deploy descriptor (service + driver blob)

  • argc – argc from main()

  • argv – argv from main()

Returns:

process-exit-shaped int: 0 on success, 1 on failure (matching axl_args_run conventions).

struct AxlService
#include <axl-service.h>

Service descriptor. Static-initializable; safe to share by reference between foreground app and driver image (same source tree, identical compile flags).

The service’s UEFI protocol identity is derived from name via axl_guid_v5 with a fixed AXL_SERVICE namespace — consumers don’t allocate a UUID per service. The AXL_SERVICE_DRIVER macro auto-publishes the derived GUID on the driver image’s handle when it starts, which gives axl_driver_ensure_with_embedded (called from axl_service_start_embedded) something to verify against AND gives axl_service_is_running a way to detect a live deploy. Two binaries that share the same name string see the same derived GUID by construction.

Keep all fields trivially copyable — the descriptor is read in both binaries’ startup paths and any pointer ABI shift is hard to diagnose. AxlConfig descriptors are themselves static const.

Public Members

const char *name

short identifier, e.g. “axl-webfs”. REQUIRED — not just a log label. AxlService derives the service’s UEFI protocol identity from this via axl_guid_v5 with a fixed AXL_SERVICE namespace, so the name uniquely picks out a service. Two binaries that share the same source tree’s name string see the same derived GUID by construction.

const AxlConfigDesc *opts_descs

NULL = no configurable options.

AxlServiceSetup setup

required (must not be NULL)

AxlServiceTeardown teardown

NULL = nothing to release.

void *user

-> caller’s options struct; AxlConfig auto-applies into here

uint64_t driver_tick_ms

driver-mode dispatch period in milliseconds; 0 means use the 50 ms default. Single source of truth for both axl_service_attach_driver and the AXL_SERVICE_DRIVER macro.

struct AxlServiceDeploy
#include <axl-service.h>

Cross-binary deployment glue. Combines a service descriptor (the part both binaries share) with the embedded driver image and disk-search filename (foreground-only fields).

The driver image links the same service descriptor; the foreground app additionally fills in the blob fields. Protocol identity is derived from service->name via axl_service_guid in both binaries — they see the same GUID because they share the descriptor (and therefore the name string).

Public Members

const AxlService *service

shared descriptor

const unsigned char *driver_blob

embedded driver .efi bytes

size_t driver_blob_len

length in bytes

const char *driver_name

filename for disk-search fallback