Commit graph

76 commits

Author SHA1 Message Date
97ea969075 feat: weft-mount-helper -- setuid helper for EROFS+dm-verity mounts
New crate: weft-mount-helper. A privileged helper binary that sets up
dm-verity devices and mounts EROFS images for app isolation.

Commands:
  mount <img> <hash_dev> <root_hash> <mountpoint>
    - opens a named dm-verity device via veritysetup open
    - mounts the resulting /dev/mapper/<name> as EROFS read-only
    - cleans up the dm device if mount fails
  umount <mountpoint>
    - unmounts the mountpoint
    - closes the dm-verity device via veritysetup close

Device naming: derives a stable name from the mountpoint path, limited
to 31 chars (DM limit), always prefixed weft-. Root check reads
/proc/self/status euid rather than using unsafe libc calls.

Tests: device_name_sanitizes_path, device_name_truncates_long_paths.
2026-03-11 15:43:59 +01:00
add4d92945 feat(pack): build-image and build-verity subcommands
build-image <dir> [--out <img>]: creates an EROFS image by invoking
mkfs.erofs. Output filename defaults to <app_id>.app.img. Fails if
the output file already exists or mkfs.erofs is unavailable.

build-verity <img> [--out <hash>]: creates a dm-verity hash tree by
invoking veritysetup format. Writes the hash device to <stem>.hash
(or --out path). Extracts the root hash from veritysetup output and
writes it to <img>.roothash. Fails if veritysetup is unavailable.

Both subcommands shell out to external tools (erofs-utils and
cryptsetup-bin respectively) and return an error with an installation
hint if the binary is not found.
2026-03-11 15:39:18 +01:00
12fa53a585 feat(pack): bundle and unbundle subcommands for dist packaging
Add bundle <dir> [--out <dir>] and unbundle <archive> [--out <dir>]
subcommands to weft-pack.

bundle: validates the package, reads app_id from wapp.toml, writes
<app_id>.app.tar.zst to the output directory (default: current dir).
Archive root is <app_id>/ so extraction reproduces the package directory.
Fails if the archive already exists.

unbundle: decompresses and extracts a .app.tar.zst into the output
directory (default: current dir).

Compression level 0 (zstd default). No symlinks followed.
Dependencies added: tar 0.4, zstd 0.13.
Test: bundle_and_unbundle_roundtrip.
2026-03-11 15:37:53 +01:00
98a21da734 feat(runtime): seccomp blocklist filter via optional seccomp feature
Add seccomp feature flag (seccompiler + libc, Linux-only, optional).

When compiled with --features seccomp, weft-runtime installs a
SECCOMP_MODE_FILTER immediately after argument parsing, before any
package resolution or WASM execution.

Filter strategy: default-allow with explicit KillProcess rules for
high-risk syscalls a WASM runtime process has no legitimate need for:
ptrace, process_vm_readv/writev, kexec_load, personality, syslog,
reboot, mount/umount2, setuid/setgid/setreuid/setregid/setresuid/
setresgid, chroot, pivot_root, init_module/finit_module/delete_module,
bpf, perf_event_open, acct.

The feature is off by default so the standard build and tests are
unaffected. Enable in production service builds with --features seccomp.
2026-03-11 15:34:21 +01:00
ec4cc272af feat(pack): Ed25519 package signing -- generate-key, sign, verify subcommands 2026-03-11 15:29:49 +01:00
71b7bdf657 feat(appd): wrap runtime in systemd-run cgroup scope when user session is active
supervise() checks /systemd/private to detect an active
user systemd session. When present (and WEFT_DISABLE_CGROUP is unset),
the runtime binary is launched via:

  systemd-run --user --scope --wait --collect --slice=weft-apps.slice     -p CPUQuota=200% -p MemoryMax=512M -- <bin> ...

This places each app in a transient weft-apps.slice scope with default
resource limits from the blueprint. The --wait flag keeps systemd-run
alive so child.wait()/child.kill() remain correct.

When no user session is present the command is built directly as before.
WEFT_DISABLE_CGROUP=1 bypasses the wrapping unconditionally.
2026-03-11 15:25:04 +01:00
c5a47a05b4 feat(appd,pack): capability dispatch -- map wapp.toml capabilities to --preopen args
Add capabilities field to weft-pack PackageMeta (optional Vec<String>).
Print cap: lines in weft-pack info output when capabilities are declared.

In weft-appd:
- Make app_store_roots pub(crate) so runtime.rs can use it.
- Add resolve_preopens(app_id) in runtime.rs: reads wapp.toml from the
  package store, extracts capabilities, maps each to a (host, guest) pair:
    fs:rw:app-data / fs:read:app-data -> ~/.local/share/weft/apps/<id>/data :: /data
    fs:rw:xdg-documents / fs:read:xdg-documents -> ~/Documents :: /xdg/documents
  Unknown capabilities are logged at debug level and skipped.
- supervise() calls resolve_preopens() and appends --preopen HOST::GUEST
  flags before spawning the runtime binary.
2026-03-11 15:20:51 +01:00
84eb39db96 feat(runtime): add weft:app/notify WIT package and notify-ready host interface
Adds crates/weft-runtime/wit/weft-app.wit defining package weft:app@0.1.0
with interface notify { ready: func() }.

In the wasmtime-runtime path:
- Registers weft:app/notify@0.1.0 in the component linker before instantiation
- ready() prints
2026-03-11 15:15:11 +01:00
b2ac279dc5 feat(runtime): add --preopen and --ipc-socket CLI arguments
weft-runtime now parses optional flags after <app_id> <session_id>:
  --preopen HOST::GUEST  pre-opens a host directory at GUEST path in the
                         WASI filesystem (HOST::GUEST or HOST for same path)
  --ipc-socket PATH      sets WEFT_IPC_SOCKET env var inside the component

wasmtime-runtime path applies preopened dirs via cap_std and WasiCtxBuilder,
and injects WEFT_IPC_SOCKET when --ipc-socket is present. Stub path ignores
both flags.

weft-appd: SessionRegistry gains ipc_socket field (set to the appd Unix
socket path in run()), extracted alongside compositor_tx in dispatch(), and
forwarded to supervise() as ipc_socket_path. supervise() passes
--ipc-socket <path> to the spawned runtime when present.

cap-std added as optional dep under wasmtime-runtime feature.
2026-03-11 15:10:11 +01:00
e56daf6570 feat(runtime): upgrade to WASI Preview 2 + Component Model
Replaces the wasmtime-runtime run_module implementation:
- wasmtime::Module → wasmtime::component::Component
- wasmtime::Linker<WasiCtx> → wasmtime::component::Linker<State>
- wasmtime_wasi::add_to_linker → wasmtime_wasi::add_to_linker_sync
- _start typed func call → wasmtime_wasi::bindings::sync::Command::instantiate + call_run

Config now sets wasm_component_model(true). State struct implements WasiView
(ctx + table). app.wasm must be a WASI 0.2 component; core modules are no
longer supported.
2026-03-11 15:03:16 +01:00
d425fa8328 feat(servo-shell): implement weft-shell-protocol Wayland client
Adds src/shell_client.rs: connects to the WEFT compositor via
WAYLAND_DISPLAY, binds zweft_shell_manager_v1, and calls create_window
for the system UI shell slot (app_id org.weft.system.shell, role panel,
wl_surface null until Servo surface is wired in a later task).

Implements Dispatch for WlRegistry, ZweftShellManagerV1, and
ZweftShellWindowV1. Handles all four window events: configure,
focus_changed, window_closed (calls destroy), and presentation_feedback.

run() in main.rs calls ShellClient::connect() best-effort before
embed_servo; logs a warning if the compositor is not running rather than
propagating the error.
2026-03-11 14:59:58 +01:00
2a9f034815 feat(servo-shell): add servo-embed feature gate and embedder contract
Adds src/embedder.rs with the full Servo embedding implementation behind
#[cfg(feature = " servo-embed)]:
2026-03-11 14:52:13 +01:00
6b428e5a47 feat(appd): add compositor IPC client; send AppSurfaceCreated/Destroyed on session lifecycle
Adds crates/weft-appd/src/compositor_client.rs: async Tokio client that connects to the
compositor's Unix socket (/weft/compositor.sock or WEFT_COMPOSITOR_SOCKET),
retrying every 2s on failure and 500ms on write error. Incoming CompositorToAppd frames are
decoded and logged (SurfaceReady, ClientDisconnected).

Wires compositor_tx into SessionRegistry. The supervise task now sends AppSurfaceCreated
(with child PID) immediately after process spawn, and AppSurfaceDestroyed when the process
exits. All three existing supervisor tests updated to pass None for compositor_tx.
2026-03-11 14:40:55 +01:00
69d29ee3a8 feat(protocol): add wl_surface arg to create_window in weft-shell-unstable-v1
Adds an optional (allow-null) wl_surface argument to zweft_shell_manager_v1::create_window.
Shell-owned windows pass null; app-backed windows pass the backing wl_surface so the
compositor can correlate the surface with a session_id from weft-appd. Updates
WeftShellWindowData to store the surface, and updates the CreateWindow handler in state.rs
and the two protocol unit tests.
2026-03-11 14:33:17 +01:00
ca2cc38d4d feat(compositor): add appd IPC server (Unix socket, length-prefixed MessagePack framing)
Adds weft-compositor/src/appd_ipc.rs: WeftAppdIpc state, setup() registers a calloop
UnixListener source. Accepted connections are registered as edge-triggered read sources.
Incoming AppdToCompositor frames are decoded and dispatched; AppSurfaceCreated records
pid->session mapping in pending_pids for later wl_surface association. Wires into both
the DRM and Winit backends. Socket path: /weft/compositor.sock or
WEFT_COMPOSITOR_SOCKET override.
2026-03-11 14:29:22 +01:00
a75c8946fc feat(ipc-types): add weft-ipc-types crate with compositor-appd message types and frame framing 2026-03-11 14:17:48 +01:00
5d7c0bdf79 feat(appd): add version field to AppInfo; surface it in launcher tile tooltip
WappPackage and AppInfo both gain a version field. scan_installed_apps()
reads it from wapp.toml and includes it in InstalledApps responses.
system-ui.html shows it in the title tooltip as 'com.example.app v1.0.0'.
All roundtrip and integration tests updated.
2026-03-11 13:15:09 +01:00
7a2014027a test(pack): add missing-wasm and missing-ui-entry check_package tests 2026-03-11 13:07:45 +01:00
c88c948575 fix(appd): exclude Stopped sessions from running_sessions; add regression test
running_sessions() was returning all sessions regardless of state.
Stopped sessions would reappear in the taskbar on reconnect since
QUERY_RUNNING is sent on every WebSocket open. The filter now matches
the UI expectation: only Starting and Running sessions are returned.
2026-03-11 12:53:07 +01:00
de8939a72e test(appd): add ws_port default and override tests 2026-03-11 12:46:15 +01:00
bded9455f5 test(appd): add appd_socket_path tests; run appd tests single-threaded
Two new tests cover appd_socket_path():
- appd_socket_path_uses_override_env: WEFT_APPD_SOCKET takes precedence
- appd_socket_path_errors_without_xdg_and_no_override: returns error when
  both WEFT_APPD_SOCKET and XDG_RUNTIME_DIR are unset

wsl-test.sh: add --test-threads=1 for weft-appd to prevent WEFT_RUNTIME_BIN
races between the supervisor integration tests.
2026-03-11 12:40:05 +01:00
71597580ba fix(appd): abort TerminateApp during startup phase promptly
Before this fix, TerminateApp sent while a process was waiting for its
READY signal was not acted on until the 30-second timeout fired.
abort_rx is now included in the tokio::select! that wraps wait_for_ready,
so the child is killed and AppState::Stopped broadcast as soon as the
abort is received, regardless of where in the startup sequence it fires.

test: supervisor_abort_during_startup_broadcasts_stopped
2026-03-11 12:30:21 +01:00
488900a5db test(appd): add supervisor spawn-failure test; verifies Stopped broadcast when binary is missing 2026-03-11 12:24:03 +01:00
e80502b184 test(runtime): add resolve_package tests for found and not-found cases 2026-03-11 12:19:17 +01:00
dbcc9965e9 test(appd): add roundtrip tests for TerminateApp, Error, and AppState IPC variants 2026-03-11 12:12:20 +01:00
5e7675c043 test(pack): add list_installed_roots tests; run pack tests single-threaded
Two new tests mirror the weft-runtime package_store_roots tests:
- list_installed_roots_uses_weft_app_store_when_set
- list_installed_roots_includes_system_path

wsl-test.sh: add --test-threads=1 for weft-pack to prevent
WEFT_APP_STORE env var races between install, uninstall, and the
new list_roots tests.
2026-03-11 12:00:44 +01:00
cab3a4a956 feat(pack): add list subcommand to show installed packages
list_installed_roots() searches WEFT_APP_STORE, then
~/.local/share/weft/apps, then /usr/share/weft/apps (same
priority order as weft-runtime and weft-appd). list_installed()
deduplicates by app_id (first root wins), sorts alphabetically within
each root, and prints id/name/version per line. Prints 'no packages
installed' when the store is empty or absent.
2026-03-11 11:57:08 +01:00
826f144d9d test(appd): add roundtrip tests for AppInfo and InstalledApps IPC variants 2026-03-11 11:51:04 +01:00
abdefa3388 test(appd): add QueryAppState dispatch test for unknown session returning NotFound 2026-03-11 11:48:22 +01:00
7a07e46c55 fix(appd): broadcast AppState::Stopped on runtime spawn failure
If WEFT_RUNTIME_BIN is set but the binary cannot be spawned (missing,
not executable, etc.), supervise() now transitions the session to
Stopped and broadcasts AppState::Stopped instead of returning an error
that left the session permanently stuck in Starting.
2026-03-11 11:45:17 +01:00
e5ec05ce2c test(appd): assert AppState::Stopped broadcast in supervisor integration test
supervisor_transitions_through_ready_to_stopped now checks both
broadcast messages: AppReady (on READY signal) and AppState::Stopped
(on process exit), covering the path added in 3315b15.
2026-03-11 11:38:33 +01:00
e83be20798 fix(appd): make appd.wsport write non-fatal when XDG_RUNTIME_DIR is unset
write_ws_port failure is now logged as a warning rather than propagating
an error that would crash the service. Error context strings are added
to create_dir_all and write failures so the warning is actionable.
2026-03-11 11:36:47 +01:00
eef9ecc24a test(appd): add QueryInstalledApps dispatch test; fix weft-runtime test race
main.rs: add dispatch_query_installed_returns_installed_apps to verify
the QueryInstalledApps arm returns Response::InstalledApps.

wsl-test.sh: run weft-runtime tests with --test-threads=1 to prevent
the WEFT_APP_STORE env var race between package_store_roots_includes_
system_path and package_store_roots_uses_weft_app_store_when_set.
2026-03-11 11:32:26 +01:00
0bcb6b1bf6 fix(appd): signal all supervisors to abort on clean shutdown
SessionRegistry::shutdown_all() clears abort_senders, dropping all
oneshot senders. Each supervised process's abort_rx fires, causing
supervise() to kill the child. A 200ms yield after shutdown_all gives
the tokio runtime time to schedule the abort handling before the
process exits and the socket file is removed.
2026-03-11 11:28:29 +01:00
e1c15ea463 feat(appd): add QueryInstalledApps IPC request; wire launcher in system UI 2026-03-11 11:23:46 +01:00
d6de84b4c7 fix(appd): broadcast AppState::Stopped on READY timeout
runtime.rs: the READY-timeout early-return path now broadcasts
AppState::Stopped before returning so WebSocket clients see the
session disappear when a module fails to signal readiness within 30s.
2026-03-11 11:19:09 +01:00
3315b158db feat(appd): broadcast AppState::Stopped when supervised process exits
runtime.rs: after the child exits (natural exit or abort), supervise()
now broadcasts AppState { session_id, state: Stopped } in the same
lock scope as set_state. WebSocket clients receive the notification
without needing to poll QueryAppState or call TerminateApp.
2026-03-11 11:16:28 +01:00
68e1f82ca7 fix(appd): drain module stdout after READY signal to prevent pipe stall
wait_for_ready() now returns the BufReader<ChildStdout> with the READY
line already consumed. supervise() spawns drain_stdout() on that reader
so any subsequent module output is forwarded to the trace log and the
pipe buffer never fills up.

Without this, a long-running Wasm module that writes to stdout after
printing READY would eventually block waiting on a full pipe.
2026-03-11 11:14:18 +01:00
a409b954ab fix(appd): handle SIGTERM for clean shutdown under systemd
run() now registers a SIGTERM handler (unix-only, cfg-gated) alongside
the existing SIGINT handler. Both break the accept loop and allow the
Unix socket to be removed before exit.

On non-Unix targets the SIGTERM arm uses std::future::pending so the
select! shape is unchanged at the type level.
2026-03-11 11:06:01 +01:00
01a4969883 fix(appd): write actual bound WebSocket port to appd.wsport file
ws_listener.local_addr().port() is used instead of the configured
ws_port value. This is correct when WEFT_APPD_WS_PORT=0 lets the OS
assign an ephemeral port; the file reflects the real listening port.
2026-03-11 11:00:13 +01:00
dbe44bd0e0 feat(appd): include app_id in AppReady broadcast
ipc.rs: AppReady { session_id, app_id: String }.

runtime.rs: supervise() passes app_id (already in scope as parameter)
when building the AppReady broadcast message.

main.rs: supervisor integration test updated to use .. to ignore
app_id in the AppReady pattern match.
2026-03-11 10:50:41 +01:00
b5bf2e538a feat(appd): include app_id in LaunchAck response
ipc.rs: LaunchAck gains app_id: String field so callers receive the
app identifier alongside the session handle in a single response.

main.rs: dispatch::LaunchApp constructs LaunchAck { session_id, app_id }
using the app_id that was already in scope.
Tests updated: dispatch_launch_returns_ack now asserts app_id value;
dispatch_terminate_known_returns_stopped and
dispatch_query_app_state_returns_starting use .. to ignore app_id.
2026-03-11 10:46:28 +01:00
fdeb440766 feat(appd): include app_id in RunningApps response; update system UI
ipc.rs:
- Add SessionInfo { session_id: u64, app_id: String } struct.
- Change RunningApps { session_ids: Vec<u64> } to
  RunningApps { sessions: Vec<SessionInfo> } so callers can display
  meaningful app names without a follow-up QueryAppState round-trip.
- Add session_info_roundtrip test.

main.rs:
- Add SessionEntry { app_id: String, state: AppStateKind } to store
  app_id alongside state in SessionRegistry.
- launch() stores app_id in the entry.
- running_sessions() replaces running_ids(); returns Vec<SessionInfo>.
- state() reads from SessionEntry.state.
- set_state() writes to SessionEntry.state.
- QueryRunning dispatch uses running_sessions().
- Test registry_running_ids_reflects_live_sessions renamed to
  registry_running_sessions_reflects_live_sessions and updated to
  assert both session_id and app_id fields.
- dispatch_query_running test asserts app_id values are present.

system-ui.html:
- RUNNING_APPS handler uses msg.sessions[].{session_id,app_id}.
- ensureTaskbarEntry(sessionId, appId): shows the last component of the
  reverse-domain app ID as the taskbar label; sets data-app-id attribute;
  tooltip shows full app ID and session number.
- LAUNCH_ACK handler passes null for appId (session ID only available
  at launch time; app_id arrives in RUNNING_APPS on reconnect).
2026-03-11 10:42:40 +01:00
d6ede23183 feat(servo-shell): wire appd WebSocket port discovery at startup
appd_ws_port() -> u16:
- Checks WEFT_APPD_WS_PORT env var first.
- Falls back to reading XDG_RUNTIME_DIR/weft/appd.wsport.
- Falls back to hardcoded default 7410.

run() now calls appd_ws_port() and passes the result to embed_servo.
embed_servo signature updated to accept ws_port: u16.
When the Servo embedder is implemented, it injects the port as
window.WEFT_APPD_WS_PORT before loading the system UI HTML.
2026-03-11 10:31:33 +01:00
6d88104f28 feat(runtime): add wasmtime-runtime feature gate for real Wasm execution
Cargo.toml:
- New feature: wasmtime-runtime = [dep:wasmtime, dep:wasmtime-wasi]
- Default is off so the normal build remains lightweight.
- wasmtime 30 and wasmtime-wasi 30 added as optional dependencies.

src/main.rs:
- run_module(wasm_path) replaces the inline stub.
- cfg(not(feature = wasmtime-runtime)): prints READY and returns.
  Preserves all existing test and development behaviour unchanged.
- cfg(feature = wasmtime-runtime): creates a Wasmtime Engine + Module,
  builds a WASI linker with inherited stdout/stderr, prints READY, then
  instantiates the module and calls _start.
  READY is printed before _start so weft-appd can record the session as
  Running before the app enters its event loop.

The production service binary is built with:
  cargo build -p weft-runtime --release --features wasmtime-runtime
2026-03-11 10:26:41 +01:00
5cff1f4412 feat(pack): validate Wasm module magic bytes in check
check_package now reads the first 4 bytes of runtime.module and rejects
files that do not begin with the Wasm magic number (0x00 0x61 0x73 0x6D).
An unreadable or too-short file is treated as invalid.

is_wasm_module(path): opens the file, reads 4 bytes, compares to MAGIC.

Test added: check_package_bad_wasm_magic - writes NOT_WASM to app.wasm,
asserts check fails with a message containing bad magic bytes.
2026-03-11 10:21:43 +01:00
b2bb76125f feat(pack): add uninstall subcommand and full install/uninstall tests
weft-pack:
- uninstall <app_id>: validates app ID, resolves store root, removes
  the installed package directory. Fails with an error if the package
  is not present or the app ID is malformed.
- Extracted install_package_to(dir, root) and
  uninstall_package_from(app_id, root) inner functions so tests can
  drive them directly without touching process env vars (avoids parallel
  test env-var races).
- install_package / uninstall_package remain the CLI-facing wrappers
  that call resolve_install_root().

Tests added (2):
- install_package_copies_to_store: writes a valid temp package, calls
  install_package_to, verifies all files are present, confirms a second
  install fails.
- uninstall_package_removes_directory: installs then uninstalls,
  verifies directory is removed, confirms a second uninstall fails.
Both tests use process-ID-derived paths to avoid cross-test collisions.
2026-03-11 09:54:39 +01:00
265868bf67 feat(pack): add install subcommand; clean up servo-shell stub comment
weft-pack:
- install <dir>: validates the package (runs check), resolves the user
  app store root (WEFT_APP_STORE > ~/.local/share/weft/apps), copies
  the package directory to <root>/<app_id>/. Fails if the destination
  already exists.
- resolve_install_root(): replaces the unused _resolve_store_roots;
  returns a single writable root rather than a search list.
- copy_dir(): recursive directory copy using std::fs only; no new deps.
- Updated usage text to include all three subcommands.

weft-servo-shell: removed stale implementation-note comment from
embed_servo stub.
2026-03-11 09:45:31 +01:00
ffae164747 feat(pack): add weft-pack package validator tool
New crate: weft-pack — command-line tool for validating WEFT application
package directories against the app-package-format spec.

src/main.rs:
- check <dir>: loads wapp.toml, validates app ID format, verifies
  package.name is non-empty and <=64 chars, confirms runtime.module and
  ui.entry files exist. Prints 'OK' on success or the list of errors.
- info <dir>: prints all manifest fields to stdout.
- load_manifest(): reads and parses wapp.toml with toml crate.
- is_valid_app_id(): enforces reverse-domain convention (>=3 components,
  each starting with a lowercase letter, digits allowed, no hyphens or
  uppercase).

Tests (5):
- app_id_valid: accepts well-formed reverse-domain IDs.
- app_id_invalid: rejects two-component, uppercase, hyphen, empty IDs.
- check_package_missing_manifest: error when wapp.toml is absent.
- check_package_valid: full happy-path with real temp files.
- check_package_invalid_app_id: error on a hyphenated app ID.

New deps: toml 0.8, serde 1 (derive).
Added weft-pack to workspace Cargo.toml; wsl-test.sh extended.
2026-03-11 09:40:34 +01:00
1e4ced9a39 feat(appd): implement TerminateApp process signaling via abort channel
SessionRegistry now tracks a oneshot abort sender per active session:
- abort_senders: HashMap<u64, oneshot::Sender<()>> field added.
- register_abort(session_id): creates the channel, stores the sender,
  returns the receiver to the supervise task.
- terminate(): removes the session state AND drops the abort sender,
  closing the channel and triggering the receiver in supervise.

runtime::supervise() now accepts abort_rx: oneshot::Receiver<()>:
- After the READY signal is received, the process-wait loop uses
  tokio::select! on child.wait() vs abort_rx.
- On abort: logs intent, calls child.kill(), then sets state Stopped.
- On natural exit: logs exit status, sets state Stopped.

dispatch::LaunchApp: calls register_abort immediately after launch,
passes the receiver to the spawned supervise task.

Integration test updated to pass the abort receiver.
2026-03-11 09:37:09 +01:00