================================================================================
Signals model for the wasm32 port
================================================================================

This document is the spec-first companion to ``threading-model.rst`` and
``sched-model.rst``, focused on **POSIX signal delivery**: how a signal
sent to a wasm-Worker-backed process or pthread actually gets observed
by user code, how default dispositions are honored, how ``sigaction``
installs handlers, and how the kernel-side ``signal_pending`` machinery
interacts with the in-Worker ``Atomics.wait`` blocking on futexes that
K7-U4e established.

It is written as the **K8 opener** (per the K-phase discipline of
``docs/ARCHITECTURE.md §18``: design before implementation; refutation
is cheaper at spec time than at code time).

Status: K8 OPENER. K8 ships the load-bearing infrastructure for signal
delivery and dispatch; richer signal surface (real-time signals, signal
masks across syscalls, ptrace stop-restart) is K9+ scope. The
implementation sequence is captured in §11 below.

--------------------------------------------------------------------------------
1. Why this document exists separately
--------------------------------------------------------------------------------

``ARCHITECTURE.md §17.5`` ("Signal delivery: deferred") sketched the
intended signal-delivery model at K1 spec time: a wasm32 ``kill(pid, sig)``
would set the bit in ``task->pending.signal`` and ``Atomics.notify`` the
target Worker's wake word; the target Worker would observe
``signal_pending(current)`` at every kernel entry/exit and dispatch via
the upstream signal-delivery path. That framing was correct as a
direction but was never load-bearing because K1-K7 had no use for signals
(K5/K6 demos were single-threaded; K7's multi-threaded demo coordinated
with ``pthread_mutex`` — futexes, not signals).

K8 is the phase where signals become load-bearing for the first time.
Bash needs ``SIGCHLD`` to reap children, ``SIGINT`` to interrupt running
commands, ``SIGTSTP`` for job-control suspension, ``SIGPIPE`` when a
pipe's reader dies. The K8 architecture must answer:

* What is the actual delivery mechanism that survives the "wasm has no
  async interrupt" constraint?

* Where does the signal frame live across the
  handler-runs-on-the-thread's-stack contract?

* How are signals routed to a specific thread vs the process when the
  process is multi-threaded (Regime 1b)?

* How does ``SYS_futex`` interact with signals — i.e., does
  ``Atomics.wait`` parked on a futex word wake when a signal arrives?

These are spec-time questions; getting them wrong at code time is
expensive. K8 deepens this document during its implementation phase
(per the K5/K6/K7 amendment pattern).

Reading order:

* If you are implementing K8-A (signal-delivery infrastructure):
  §2 (delivery model), §3 (HardwareJS API), §4 (signal frame ABI),
  §11 (K8 sub-phase decomposition).

* If you are implementing K8-B (per-thread vs per-process routing):
  §5 (thread routing), §6 (sigaction surface), §7 (futex interaction).

* If you are auditing the framework: §8 (source-site enumeration),
  §9 (refutation budget), §10 (acceptance criteria).

This document expands ``ARCHITECTURE.md §17.5`` — it does NOT refute
prior §17.5 content. The pre-K8 §17.5 framing (cooperative check at
``signal_pending`` boundaries; ``Atomics.notify`` as the wake mechanism)
remains correct as the foundation. K8 fills in the operational details.

--------------------------------------------------------------------------------
2. The delivery model: cooperative check at kernel-entry/exit
--------------------------------------------------------------------------------

**The wasm constraint.** wasm has no asynchronous interrupt mechanism.
Code running inside a Worker cannot be preempted from outside the
Worker, and there is no equivalent to a signal-stack switch or a
hardware-trap-driven interrupt vector. Whatever signal-delivery
mechanism we build MUST be cooperative: the delivering side sets state,
the receiving side observes state at well-defined check points.

**The check points.** Per upstream Linux, ``signal_pending(current)``
is checked at:

(a) Every kernel entry from userspace (``entry.c`` / arch entry
    handlers).
(b) Every kernel exit to userspace (``exit_to_user_mode_loop``).
(c) Every ``cond_resched_*`` / ``schedule()`` voluntary yield in
    long-running syscalls.
(d) Every interruptible wait (``wait_event_interruptible``,
    ``mutex_lock_interruptible``, ``msleep_interruptible``).

On wasm32 those check points are realized as:

(a)+(b) The ``linux.syscall`` import on the user-Worker side. Every
        syscall round-trip is a kernel entry/exit pair; signal
        delivery happens on the exit edge.

(c)+(d) The kernel's cooperative-yield path
        (``arch/wasm32/kernel/asyncify.c::__wasm32_yield``). Long
        kernel syscalls that yield go through here; the yield can
        observe signal state.

**The cross-Worker wake.** When ``kill(pid, sig)`` runs on the kernel
Worker, the kernel sets ``task->pending.signal`` (upstream code) and
the arch hook then must wake the target user Worker if it is parked.
"Parked" on wasm32 means one of:

* Blocked in ``Atomics.wait`` on a futex word in ``env.user_memory``
  (the K7-U4e in-Worker SYS_futex dispatch).

* Blocked in ``Atomics.wait`` on the kernel-syscall ctrl SAB awaiting a
  response.

* Suspended at a host scheduling point (the Worker is runnable but the
  host scheduler hasn't picked it).

For the first two, ``Atomics.notify`` on the parked word wakes the
Worker. For the third, no action is needed — the Worker will reach the
next check point on its own.

**Critical observation.** SYS_futex in K7-U4e is dispatched
**in-Worker** (per ``threading-model.rst §8.5``). The kernel never sees
the wait. This means ``kill`` cannot synthesize a "wake the Worker"
event by setting kernel state alone — the kernel doesn't know which
futex address the Worker is parked on. K8 must surface this through a
HardwareJS-side signal-delivery path that has visibility into the
user-Worker's parked state.

**The K8 contract:**

1. ``kill(pid, sig)`` sets ``task->pending.signal`` (upstream code,
   unchanged).
2. ``kill`` calls into the arch hook
   ``arch_signal_wake_target(struct task_struct *t)`` (NEW HOOK
   landing in patches/0008 — see §11.A).
3. The arch hook submits a SIGNAL request through the spawn-ring's
   sibling ring (or a new dedicated signal-ring) to HardwareJS.
4. HardwareJS-side signal handler looks up the target Worker by pid
   in its process registry, then:

   * If the Worker is parked in ``Atomics.wait`` on a known signal
     wake word: ``Atomics.notify`` that word, Worker wakes, observes
     ``signal_pending`` via a syscall round-trip, kernel handles
     delivery.

   * If the Worker is parked in an in-Worker futex wait
     (Atomics.wait on a user-memory address): HardwareJS does NOT
     know which address. The Worker must voluntarily poll
     ``signal_pending`` between futex waits. Per upstream Linux,
     futex syscall MUST be interruptible (returns ``-ERESTARTSYS`` on
     signal) — this is the K7-U4e in-Worker handler's contract too,
     extended to check a per-Worker signal flag set by HardwareJS
     before re-entering ``Atomics.wait``.

The signal-ring + per-Worker signal-pending flag is K8-A's primary
deliverable. The interruptible-futex behavior is K8-A's secondary
deliverable.

--------------------------------------------------------------------------------
3. HardwareJS-side signal API
--------------------------------------------------------------------------------

The kernel emits SIGNAL events through a new ring or hook. HardwareJS
consumes them and notifies the target Worker.

**Kernel-side producer (new):**

* ``arch/wasm32/include/asm/signal.h`` — kernel-side mirror of the
  SIGNAL ABI.
* ``arch/wasm32/kernel/signal.c`` — ``arch_signal_wake_target(t)``
  implementation; composes the SIGNAL payload (tid, sig number, sender
  pid for ``si_pid``) and submits.
* New patch ``patches/0008-signal-add-arch_signal_wake_target-hook.patch``
  — upstream weak hook in ``kernel/signal.c::__send_signal_locked``
  invoked after the bit is set in ``t->pending.signal``.

**JS-side consumer (new):**

* ``hardwarejs/src/signalRingConsumer.ts`` — analogous to
  ``spawnRingConsumer.ts``; polls the kernel-side signal ring, dispatches
  to the handler.
* ``hardwarejs/src/signalHandler.ts`` — looks up the target Worker
  in the per-pid registry, sets the Worker's signal-pending flag in
  ``env.user_memory`` at the per-Worker signal-pending offset, and
  ``Atomics.notify`` on the per-Worker signal wake word.
* ``hardwarejs/src/worker.mjs`` — signal-pending check in the
  ``SYS_futex`` ``FUTEX_WAIT`` path (between ``Atomics.load`` of the
  user word and ``Atomics.wait``); returns ``-EINTR`` if pending.

**Per-Worker signal-pending region (new):**

Per-Worker state in ``env.user_memory`` at a known offset (analogous
to the per-thread TLS region). The K8 layout:

::

  +------------------+   <- per-Worker signal-pending region base
  | pending_signals  |   u64 bitmap of pending signal numbers (sig 1 = bit 0)
  +------------------+
  | wake_word        |   i32 — incremented + notified when a signal
  |                  |   arrives; Worker Atomics.waits on this when in
  |                  |   the signal-pending check-and-park pattern
  +------------------+
  | sigmask          |   u64 — current signal mask (sigprocmask-managed)
  +------------------+
  | reserved         |   2 * u64 — alignment + K9+ expansion
  +------------------+

Total: 40 bytes per Worker. Aligned to 16 bytes. The kernel knows the
offset via the per-task ``thread_struct`` field.

**The signal-pending check sequence:**

::

  /* In hardwarejs/src/worker.mjs SYS_futex handler */
  function sys_futex_wait(uaddr, val, timeout) {
    /* Check signal-pending BEFORE entering Atomics.wait */
    if (Atomics.load(signalView, signalPendingIdx) !== 0) {
      return -EINTR;  /* Don't park; let kernel do signal delivery */
    }
    /* The race: signal arrives between check and wait. Resolved by
     * Atomics.notify wake_word also being raced into the wait —
     * see §3 race-resolution below. */
    const result = Atomics.wait(memView, uaddr >>> 2, val, timeout);
    /* Re-check signal-pending after wake; if set, return EINTR. */
    if (Atomics.load(signalView, signalPendingIdx) !== 0) {
      return -EINTR;
    }
    return mapWaitResult(result);
  }

**Race-resolution.** Between "Atomics.load signal-pending" and
"Atomics.wait", a signal could arrive. To resolve: HardwareJS sets
``pending_signals``, increments ``wake_word``, and ``Atomics.notify``-s
the wake word. The futex wait uses Atomics.waitAsync-style multi-word
trick: we wait on the wake_word too (or use the existing Atomics.wait
with a short timeout and re-check). The K8-A implementation picks the
cleanest variant.

--------------------------------------------------------------------------------
4. Signal frame ABI
--------------------------------------------------------------------------------

When a signal handler runs in userspace, it runs on the recipient
thread's stack with a "signal frame" on top. The frame contains:

* The saved register state (for return-via-rt_sigreturn).
* The siginfo_t structure (the second arg to SA_SIGINFO handlers).
* The previous signal mask (restored by sigreturn).
* The previous ``current->context_uc``.

On real arches, the frame is constructed on the user stack at signal
entry and torn down by ``rt_sigreturn``. On wasm32, "user stack" is
the user Worker's wasm shadow stack (the ``__stack_pointer`` global
plus the C-level call frames it points to).

**The K8 challenge.** The signal handler must be invoked at the next
userspace check point (after a syscall returns). The handler's call
frame must be a real C frame that the wasm Worker can return from. We
cannot construct a frame "on top of" the existing wasm call chain
without breaking the wasm engine's call-stack invariants.

**The K8 solution.** Signal delivery is a Worker-side cooperative
trampoline:

1. After a syscall returns, before the syscall stub returns to its
   caller, the Worker checks ``pending_signals``.

2. If pending, the Worker calls a hard-coded trampoline
   ``__wasm_signal_dispatch(sig, siginfo_ptr)`` which is defined in
   musl's arch port (``musl-src/src/internal/arch/wasm32/signal.c``,
   to be created at K8).

3. The trampoline does:

   * Save the current signal mask to a Worker-local slot.
   * Apply the handler's ``sa_mask`` to the current mask.
   * Call the userspace handler ``sa->sa_handler(sig)`` or
     ``sa->sa_sigaction(sig, info, ctx)`` directly. This is a regular
     C call — the handler is a function pointer in the user binary.
   * On return, restore the previous signal mask.
   * Set a "handled" bit in ``pending_signals`` for the handled signal
     (or clear it entirely).

4. The trampoline returns into the syscall stub, which returns the
   syscall result to its caller.

**No signal stack alternative at K8.** ``sigaltstack`` allows
specifying a separate stack for signal handlers. K8 returns ``-ENOSYS``
for ``sigaltstack`` (or accepts the call but doesn't honor the
alternate stack — handlers run on the main stack). K9+ may add support
if a needed program (bash, coreutils) requires it.

**No SA_RESTART at K8 for syscalls returning -EINTR.** K8 returns
``-EINTR`` cleanly; if the handler doesn't return (siglongjmp), the
syscall is never retried. Programs that need SA_RESTART must
explicitly set it; the K8 trampoline checks the flag and re-invokes
the interrupted syscall if set (siglongjmp also short-circuits this).
K9 may add a cleaner SA_RESTART loop if needed.

--------------------------------------------------------------------------------
5. Per-thread vs per-process signal routing
--------------------------------------------------------------------------------

POSIX distinguishes:

* **Thread-directed signals** (``pthread_kill(tid, sig)``): the signal
  is delivered to that specific thread, regardless of mask state on
  other threads.

* **Process-directed signals** (``kill(pid, sig)``, ``tgkill(pid, tid,
  sig)``): the signal is delivered to the process. The kernel picks
  any one thread of the process that doesn't have the signal masked.

* **Process-directed unblockable signals** (``SIGSEGV``, ``SIGFPE``,
  etc., when generated synchronously by the kernel for the running
  thread): delivered to the thread that caused them.

**K8 routing rules:**

(a) ``pthread_kill(tid, sig)`` sets the bit in the target task's
    ``pending.signal``; arch hook wakes that specific Worker.

(b) ``kill(pid, sig)`` sets the bit in the thread group leader's
    ``pending.signal`` (or the shared signal queue, depending on
    upstream's choice). The kernel's ``signal_wake_up_state``
    picks a thread to handle it. The arch hook wakes whichever
    Worker that thread is.

(c) Synchronous fault-generated signals (SIGSEGV from
    ``__builtin_trap`` in the user Worker) are NOT K8 scope —
    those land at K9+ with the trap-routing infrastructure.

For (b), the kernel-side ``__send_signal_locked`` already does the
thread-picking. The arch hook just wakes whichever thread the kernel
picked. The HardwareJS-side handler looks up the Worker by tid
(NOT pid) in the per-thread registry.

**Registry shape.** K7-U4c added a per-pid spawn registry in
``hardwarejs/src/kernelSpawnHandler.ts``. K8 adds a per-tid signal
registry (or extends the spawn registry to expose tid lookup). The
mapping is one-to-one between tid and Worker (Regime 1b).

--------------------------------------------------------------------------------
6. sigaction surface
--------------------------------------------------------------------------------

``rt_sigaction(sig, act, oact, sigsetsize)`` is the userspace primitive
for installing handlers. It's upstream-clean: the kernel manages
``current->sighand->action[]``, the userspace musl calls
``__syscall(SYS_rt_sigaction, ...)``, and the kernel ABI is well-defined.

On wasm32 the syscall passes through normally. The kernel-side
``do_sigaction`` updates the per-process ``sighand`` table; no arch
hook needed for the installation path.

**The handler-pointer ABI.** ``struct sigaction.sa_handler`` is a
function pointer. On real arches this is a code-segment address. On
wasm32 it's a wasm function index (the indirect-call table index, since
``-fPIC -Wl,--export-dynamic`` makes the user binary's table-elements
addressable by index from C-pointer space).

The K8 trampoline (``__wasm_signal_dispatch``) must convert the
``sa_handler`` value into an indirect call. This is the same mechanism
musl already uses internally for function pointers; no arch-specific
work needed beyond the trampoline shape.

**Restrictions K8 honors:**

* ``SIGKILL`` (9) and ``SIGSTOP`` (19) cannot be caught, blocked, or
  ignored. Upstream enforces this; K8 inherits.

* ``sa_handler == SIG_DFL`` (0) means default disposition:

  * SIGTERM / SIGINT / SIGHUP / SIGPIPE / SIGUSR1 / SIGUSR2 / SIGALRM /
    SIGCHLD: terminate the process (SIGCHLD = ignore by default).
  * SIGSEGV / SIGBUS / SIGILL / SIGFPE / SIGABRT: terminate with core
    dump (we don't generate core dumps; just terminate).
  * SIGSTOP / SIGTSTP / SIGTTIN / SIGTTOU: stop the process (job
    control — K8 makes this best-effort, K10 makes it real).

* ``sa_handler == SIG_IGN`` (1) means ignore. The trampoline checks
  this before calling the handler.

**Restrictions K8 does NOT honor (deferred to K9):**

* SA_NOCLDSTOP / SA_NOCLDWAIT (SIGCHLD specifics) — K9 waitpid scope.
* SA_RESTORER (real-restorer trampoline customization) — not needed,
  we use our own trampoline.

--------------------------------------------------------------------------------
7. Interaction with K7-U4e in-Worker SYS_futex
--------------------------------------------------------------------------------

K7-U4e's worker.mjs SYS_futex handler MUST be extended to honor
signal-pending. The current handler:

::

  /* K7-U4e shape: */
  case "FUTEX_WAIT":
    const result = Atomics.wait(view, uaddr >>> 2, val, timeout_ms);
    return mapWaitResult(result);  /* 'ok' → 0, 'timed-out' → -110, etc. */

K8 extension:

::

  case "FUTEX_WAIT":
    /* Pre-check: signal already pending? */
    if (signalPending()) return -EINTR;  /* -4 */
    const result = Atomics.wait(view, uaddr >>> 2, val, timeout_ms);
    /* Post-check: did a signal arrive during the wait? */
    if (signalPending()) return -EINTR;
    return mapWaitResult(result);

The pre-check is necessary because the signal may have been delivered
between the user's check-and-park (e.g., the user code that calls
``pthread_cond_wait`` checks the predicate first, then unlocks, then
waits — a signal between unlock and wait would otherwise be missed).

The post-check is necessary because ``Atomics.notify`` from
HardwareJS-side signal delivery wakes ``Atomics.wait`` with verdict
'ok' (not distinguishable from a regular wake by another thread). The
post-check disambiguates.

**Why doesn't the post-check return -EINTR for spurious wakes?** It
does, in the rare race where a regular wake happens
simultaneously with a signal delivery. POSIX permits this — both
``pthread_cond_wait`` and ``futex_wait`` may return early with
spurious wakeups, and the standard pattern is the caller re-checks
the predicate. K8 returns -EINTR rather than 0 in this race, which
causes musl's ``pthread_cond_wait`` to loop and re-check the predicate
— the standard correctness pattern.

--------------------------------------------------------------------------------
8. Source-site enumeration
--------------------------------------------------------------------------------

Per ``ARCHITECTURE.md §20``, K8 implementers run these greps at K8-A1
(probe phase) before writing implementation code:

::

  # Upstream signal-delivery sites
  rg 'signal_pending|recalc_sigpending' linux-wasm/linux/kernel/
  rg 'do_signal|get_signal' linux-wasm/linux/kernel/signal.c
  rg '__send_signal_locked|send_signal' linux-wasm/linux/kernel/signal.c

  # Upstream syscall-return signal-delivery hooks
  rg 'exit_to_user_mode_loop|TIF_SIGPENDING' linux-wasm/linux/kernel/entry/

  # Arch hooks for signal frame construction
  rg 'setup_rt_frame|sigreturn' linux-wasm/linux/arch/x86/kernel/signal.c

  # Musl-side signal surface (where user signal handlers live)
  rg 'sigaction|SIGCHLD|pthread_kill' wackywasm-tools/musl-src/src/signal/
  rg 'sigaltstack|sigprocmask|sigreturn' wackywasm-tools/musl-src/src/signal/

  # Existing wasm32 arch hooks (what's there, what's missing)
  rg 'arch_.*signal|arch_.*pending' linux-wasm/wasm32-port/

These greps land at ``linux-wasm/Documentation/wasm/
signals-model-source-snapshots.txt`` per the K6/K7 pattern (deterministic
re-runnable enumeration).

The §15 "wasm32 doesn't dispatch initcalls" row will likely surface
during K8: ``__init`` signal-subsystem registration paths (if any)
won't run unless arch_kernel_init_pre_run_init bridges them. Track
this as a third instance of the row if it surfaces.

--------------------------------------------------------------------------------
9. K8 sub-phase decomposition
--------------------------------------------------------------------------------

Per ``ARCHITECTURE.md §20.9``, K8 declares its budget and sub-phases
upfront.

**K8 sub-phases:**

K8-A1
  Probes: T_signal_pending (kernel can read pending bits from a
  task_struct), T_arch_signal_wake (arch hook callable), T_worker_msg
  (Worker can receive a signal-pending notification from HardwareJS).
  Establishes the substrate.

K8-A2
  Source-site snapshot. Pinned upstream signal-subsystem sites the K8-B
  patches will modify.

K8-B1
  ``arch_signal_wake_target`` hook + upstream patch
  (patches/0008-signal-add-arch_signal_wake_target-hook.patch).
  Per-Worker signal-pending region in env.user_memory.
  Worker-side signal-pending check in SYS_futex handler.

K8-B2
  Kernel-side signal-ring producer + HardwareJS-side signalRingConsumer.
  Per-tid registry lookup.

K8-B3
  Worker-side ``__wasm_signal_dispatch`` trampoline. Musl arch port
  for wasm32 signal handler invocation.

K8-B4
  Default disposition handlers (SIG_DFL behavior per signal type).
  SIGKILL / SIGSTOP non-catchability enforcement.

K8-B5
  ``sigprocmask`` / ``sigsuspend`` / ``rt_sigtimedwait`` surface
  (the masking and blocking-wait operations).

K8-B6
  User-side syscall bridges. Two small overrides that let POSIX
  programs see the K8-B3 trampoline through the standard
  signal API without belt-and-braces:

  - ``src_overrides/internal/wasm32/syscall_ret.c`` —
    ``__syscall_ret`` calls ``__wasm_signal_dispatch_pending``
    before mapping errno. This is the K8 equivalent of upstream
    Linux's ``exit_to_user_mode_loop`` TIF_SIGPENDING check —
    cooperative on wasm32, mandatory on real arches.
    ``raise(2)`` / ``kill(2)`` / ``tkill(2)`` produce handler
    invocations naturally on the syscall return path.

  - ``src_overrides/signal/wasm32/sigaction.c`` — overrides
    ``__libc_sigaction`` (the upstream stock implementation
    issues SYS_rt_sigaction only). Wasm32 still issues
    SYS_rt_sigaction so the kernel-side ``current->sighand``
    learns the disposition (used by ``__send_signal_locked``'s
    ``sig_ignored`` check), but ALSO calls
    ``__wasm_signal_register_action`` so the user-side
    trampoline cache learns the function pointer to invoke.
    Without this bridge, the trampoline's
    ``__wasm_sigaction_table`` would stay empty across the
    standard ``sigaction(2)`` install.

K8-C1
  End-to-end demo: hello-k8-signals.c — installs SIGUSR1 handler,
  raises SIGUSR1 to self, handler runs, returns, exits 0.
  Multi-threaded variant: pthread_kill from one thread to another,
  handler runs on the target thread.

K8-C2
  Regression sweep: K5-C2 (single-threaded), K6-C2 (initramfs init),
  K7-U4f (multi-threaded futex) all still pass.

K8-D1
  Audit: every §9 R-row maps to at least one ``hardwarejs/test/k8-*``
  sub-test.

K8-D2
  Close ledger + Appendix A flip + K9 surface confirmation.

**K8 refutation budget: ≤ 1**

Reasoning per §20.9 three-input formula:

1. K7 actual: 1 refutation (R-K7-3 CLONE_CHILD_CLEARTID, single §15
   row instance). Trajectory at 0.5 effective (1 ref but recovering
   from K6's cap-hit).

2. Surface novelty: K8 introduces signal-ring infrastructure
   (analogous to K5's spawn-ring; pattern is now established) and
   per-Worker signal-pending state (analogous to per-thread TLS;
   pattern is now established). Upstream signal-subsystem surface
   is well-understood and well-tested. Novel surface estimate: low.

3. Single-axis discipline returns (K7 was the temporary dual-axis
   relaxation). K8 single-axis = signals. ≤ 1 is honest.

Inside the ≤ 1, the natural risk sites are:

* The signal-pending race between ``Atomics.load`` check and
  ``Atomics.wait`` in the SYS_futex handler. The K7-U4e in-Worker
  design assumed no signal interaction; K8 needs to verify the
  race-resolution chosen actually works.

* The handler trampoline's interaction with the wasm shadow stack —
  whether calling a function pointer through the indirect-call table
  preserves all wasm engine invariants when a signal handler raises
  another signal or longjmps.

If K8 closes at 0 actual refutations, the discipline compounds back to
K5-shape. If K8 closes at 2+ refutations, the surface estimate was
optimistic and K9 should re-budget.

--------------------------------------------------------------------------------
10. Acceptance criteria (K8 close)
--------------------------------------------------------------------------------

K8 closes when:

(a) ``hello-k8-signals.c`` (single-threaded SIGUSR1 self-raise) boots
    through K6's path, installs handler, raises signal, handler runs
    once, exits 0.

(b) ``hello-k8-signals-mt.c`` (multi-threaded pthread_kill) spawns 2
    threads; thread 0 pthread_kills thread 1 with SIGUSR1; thread 1's
    SIGUSR1 handler runs once, increments a counter; thread 0 joins
    thread 1; counter == 1 asserted in vitest.

(c) ``hello-k8-default-term.c`` (default-disposition test) raises
    SIGTERM to self with no handler installed; process exits cleanly
    with status indicating SIGTERM (wait status sig == SIGTERM, K9
    will assert this; K8 asserts the worker exits).

(d) SYS_futex with signal pending returns -EINTR cleanly. musl's
    ``pthread_cond_wait`` correctly handles -EINTR by looping.

(e) Regression: K5-C2, K6-C2, K7-U4f all pass unchanged.

(f) ``make hwjs-verify`` clean: tsc + vitest pass; no new warnings.

(g) Cumulative K8 refutation count ≤ 1.

(h) Appendix A (below) populated.

--------------------------------------------------------------------------------
11. Implementation reading-order
--------------------------------------------------------------------------------

K8 implementers read in this order:

1. ``ARCHITECTURE.md §17.5`` (existing signal-delivery framing).
2. ``ARCHITECTURE.md §20`` (enumeration discipline).
3. This document, §2 (delivery model) through §7 (futex interaction).
4. ``threading-model.rst §8.5`` (in-Worker SYS_futex dispatch — the
   primary integration point for signal-pending checks).
5. ``threading-model.rst §6.5`` (fn_arg_block ABI — the pattern for
   load-bearing kernel↔userspace ABI declarations; signal-ring
   payload follows the same shape).
6. ``rootfs-model.rst §16.*`` (the K6 close ledger structure — K8
   close ledger should follow the same pattern).

--------------------------------------------------------------------------------
Appendix A — K8 close checklist
--------------------------------------------------------------------------------

[x] K8-A1 probes pass (T_signal_pending, T_arch_signal_wake, T_worker_msg)
[x] K8-A2 source-site snapshot landed
    (signals-model-source-snapshots.txt)
[x] patches/0008-signal-add-arch_signal_wake_target-hook.patch landed
[x] arch/wasm32/include/asm/signal.h with static_asserts pinning the
    per-Worker signal-pending region layout
[x] arch/wasm32/kernel/signal.c::arch_signal_wake_target implemented
[x] hardwarejs/src/signalRingConsumer.ts (new)
[x] hardwarejs/src/signalHandler.ts (new) with per-tid registry
[x] hardwarejs/src/worker.mjs SYS_futex handler honors signal-pending
[x] musl-src/src_overrides/signal/wasm32/__wasm_signal_dispatch.c
    trampoline implemented
[x] musl-src/src_overrides/signal/wasm32/* arch-specific signal source
    overrides (sigaction.c, pthread_sigmask.c, sigsuspend.c,
    sigtimedwait.c) plus internal/wasm32/syscall_ret.c (K8-B6 user-side
    syscall bridges)
[x] Default-disposition handlers per §6 (K8-B4 __wasm_dfl_table +
    SIGKILL/SIGSTOP non-catchability)
[x] hello-k8-signals.c demo + hardwarejs/test/k8-c1-signals.test.ts
[x] hello-k8-signals-mt.c demo + hardwarejs/test/k8-c1-signals-mt.test.ts
[x] hello-k8-default-term.c demo + test
[x] K5-C2, K6-C2, K7-U4f regressions all green
[x] make hwjs-verify clean
[x] K8 close ledger in §12 (this document, new section)
[x] Refutation budget: actual ≤ 1 (closed at 0/≤1; budget unused)
[x] K9 surface proposal in K8 close ledger (§13)
[x] v0.1.0-rc9 annotated tag placed (K8-D2 tag command)

--------------------------------------------------------------------------------
12. K8 close ledger
--------------------------------------------------------------------------------

This section is the K8 final close. K8's two close criteria from §10
(plus the (d) -EINTR check, (e) regression sweep, (f) hwjs-verify gate,
(g) refutation budget, (h) Appendix A) are all met:

  (a) ``hello-k8-signals.c`` (single-threaded SIGUSR1 self-raise) boots
      through K6's path, installs a handler via standard
      ``sigaction(2)``, ``raise()`` triggers handler, handler runs
      once, returns, exits 0. ✓ Closed at K8-C1(a). Verified by
      ``hardwarejs/test/k8-c1-signals.test.ts`` (K8-C1-R1).

  (b) ``hello-k8-signals-mt.c`` (multi-threaded ``pthread_kill``)
      spawns a worker thread, main blocks SIGUSR1 in its own mask,
      worker publishes its tid via stdatomic, main calls
      ``pthread_kill(worker, SIGUSR1)``, worker's ``sigsuspend`` wakes
      via the K8-B5 ``__wasm32_signal_wait`` block on the worker's
      ``wake_word``, handler runs on the worker's wasm stack,
      ``sigsuspend`` returns -EINTR, main joins; ``worker_handler_count
      == 1`` asserted in vitest. ✓ Closed at K8-C1(b). Verified by
      ``hardwarejs/test/k8-c1-signals-mt.test.ts`` (K8-C1-R2).

  (c) ``hello-k8-default-term.c`` (default-disposition test) raises
      ``SIGTERM`` to self with no handler installed; the K8-B4
      ``__wasm_dfl_table[15] = DFL_TERM`` entry fires, the default
      terminate hook calls ``SYS_exit_group(128 + 15) = exit_group
      (143)``, the worker exits cleanly with code 143 before ``raise()``
      returns; the post-``raise()`` "after raise" line is unreachable.
      ✓ Closed at K8-C1(c). Verified by
      ``hardwarejs/test/k8-c1-default-term.test.ts`` (K8-C1-R3) which
      asserts the "before raise" line is present, the "after raise"
      line is absent, and ``onUserExit`` observes code 143.

  (d) ``SYS_futex`` with signal pending returns -EINTR cleanly. The
      K8-B1 ``handleFutex`` pre-/post-``Atomics.wait`` check observes
      the per-Worker pending bit and returns -EINTR; musl's
      ``pthread_cond_wait`` correctly handles -EINTR by looping, which
      is implicitly verified by the K8-C1(b) test (the worker's
      ``sigsuspend`` is built on the same mechanism — the
      ``__wasm32_signal_wait`` syscall call returns -EINTR after the
      pending bit is stamped). ✓ Closed at K8-B1. Verified by
      ``hardwarejs/test/k8-b1-futex-eintr.test.ts``.

  (e) **Regression: K5-C2, K6-C2, K7-U4f all pass unchanged.** ✓
      Closed at K8-C2. Verified by the full hwjs-verify sweep at K8
      close (37 test files, 99 passed + 2 skipped).

  (f) ``make hwjs-verify`` clean: tsc + vitest pass; no new warnings. ✓
      Closed at K8 close.

  (g) Cumulative K8 refutation count ≤ 1. ✓ Closed at **0** actual
      refutations. K8 closed at the more disciplined edge of its
      ≤ 1 budget.

  (h) Appendix A populated. ✓ Below.

**K8-D1 audit — acceptance / Appendix A row → test mapping.**

  Every Appendix A row is covered by either a sub-test in
  ``hardwarejs/test/k8-*`` or a phase commit:

  * §10 (a)            → ``k8-c1-signals.test.ts``
  * §10 (b)            → ``k8-c1-signals-mt.test.ts``
  * §10 (c)            → ``k8-c1-default-term.test.ts``
  * §10 (d)            → ``k8-b1-futex-eintr.test.ts``
                         (and indirectly via §10(b))
  * §10 (e) / K8-C2    → full hwjs-verify sweep at close
  * §10 (f)            → ``make hwjs-verify`` clean (tsc + vitest)
  * §10 (g)            → cumulative ledger below
  * §10 (h)            → this section

  Appendix A items map to:

  * K8-A1 probes       → ``k8-a1-signals-substrate.test.ts``
                         (T_signal_pending, T_arch_signal_wake,
                         T_worker_msg)
  * K8-A2 snapshot     → K8-A2 commit
                         (signals-model-source-snapshots.txt)
  * patches/0008       → K8-B1 commit
  * arch/wasm32/asm/   → K8-B1 commit
    signal.h                (region layout static_asserts)
  * arch_signal_wake_  → K8-B1 commit + ``k8-b1-futex-eintr``
    target                  test (kernel→Worker stamp path)
  * signalRingConsumer → ``k8-b2-signal-ring.test.ts``
  * signalHandler.ts   → ``k8-b2-signal-ring.test.ts``
                         (per-tid registry) +
                         ``k8-c1-signals-mt.test.ts``
                         (deliverSignalToTarget shared with
                         JS-direct K8-B6 path)
  * worker.mjs futex   → ``k8-b1-futex-eintr.test.ts``
  * trampoline         → ``k8-b3-trampoline.test.ts``
  * src_overrides/     → ``k8-b3-trampoline.test.ts``
    signal/wasm32/        (trampoline source override) +
                          ``k8-c1-signals*.test.ts`` (sigaction
                          + sigsuspend/sigtimedwait covered by
                          mt + default-term demos)
  * default-disposition  → ``k8-b4-default-action.test.ts``
    handlers (§6)         (T1..T8 covering SIGKILL/SIGSTOP
                           reject, DFL_TERM, DFL_IGN, DFL_CORE,
                           DFL_STOP)
  * hello-k8-signals     → ``k8-c1-signals.test.ts``
  * hello-k8-signals-mt  → ``k8-c1-signals-mt.test.ts``
  * hello-k8-default-term→ ``k8-c1-default-term.test.ts``
  * K5/K6/K7 regressions → existing K5-C2/K6-C2/K7-U4f tests
                           re-run as part of the full sweep
  * make hwjs-verify     → CI gate
  * §12 close ledger     → this section
  * Refutation budget    → cumulative ledger below
  * K9 surface proposal  → §13 below
  * v0.1.0-rc9 tag       → K8-D2 tag command

  K8-B5 has its own probe under ``k8-b5-mask.test.ts`` (the
  pthread_sigmask / sigsuspend / sigtimedwait probe). K8-B6 has
  no dedicated probe — it's a transparent layer under the
  K8-C1 demos, which exercise it three different ways:

    * (a) ``sigaction(SIGUSR1, &sa, NULL)`` install →
      ``__libc_sigaction`` override populates the user-side
      table → ``raise(SIGUSR1)`` syscall returns →
      ``__syscall_ret`` auto-dispatch → trampoline finds the
      cached handler.

    * (b) ``pthread_sigmask`` (K8-B5) and ``sigsuspend`` (K8-B5)
      both rely on the K8-B6 ``__syscall_ret`` hook for
      synchronous post-mask delivery; K8-C1(b)'s success
      validates that integration.

    * (c) Zero-init ``__wasm_sigaction_table`` (no override
      installed) → trampoline falls through to
      ``__wasm_dfl_table[15]`` → terminate hook → exit_group(143).
      Validates that the K8-B6 ``sigaction`` override DOESN'T
      run on a process that never calls ``sigaction``, but the
      ``__syscall_ret`` hook DOES.

  No R-row is unrecited; no §20.6 discipline violations
  outstanding.

**K8 cumulative refutation ledger.**

  ::

      K8 opener   0     §9 budget set to ≤1
      K8-A1       0
      K8-A2       0
      K8-B1       0
      K8-B2       0
      K8-B3       0
      K8-B4       0
      K8-B5       0     (new sub-phase added during
                         implementation; not a refutation —
                         spec-shape extension to cover
                         sigprocmask/sigsuspend/sigtimedwait
                         which §6 had folded into K8-B3 and
                         K8-B4 implicitly)
      K8-B6       0     (new sub-phase added during
                         implementation; not a refutation —
                         spec-shape extension for the user-side
                         syscall bridges that fall out of
                         K8-B3/B4/B5 once the trampoline +
                         default-action + mask ops all exist
                         but the standard POSIX install path
                         (sigaction(2)) hadn't been wired to
                         feed them; documented in §11 K8-B6)
      K8-C1       0
      K8-C2       0
      --------    -
      Cumulative  0     Within ≤1 budget; budget unused

  Per the K8 opener's threshold logic:

  > **If K8 closes at 0 actual refutations**: the discipline
  > compounds back to K5-shape.

  K8 closing at **0** validates the single-axis discipline
  return after K7's dual-axis relaxation. Two new sub-phases
  (B5, B6) were added but neither was a refutation: B5 was a
  surface that §6 had folded into B3/B4 implicitly (the spec
  named it but didn't decompose it as a sub-phase), and B6
  fell out of B3+B4+B5 once all three existed but the
  standard ``sigaction(2)`` POSIX install path hadn't been
  wired to feed them. Both are spec-shape additions, not
  spec-shape refutations.

**§15 row scoreboard at K8 close.**

  K8 introduces two new §15 rows (both K8 shortcuts, both
  K9+ retirement targets):

  * **R-K8-1** (K8 SYS_rt_sigaction stub: kernel-side
    ``current->sighand`` not populated; ``sig_ignored`` always
    false at K8) — OPEN. The K8-B6 ``sigaction`` override
    issues ``SYS_rt_sigaction`` so musl is satisfied, but
    ``dispatchSyscall``'s ``__NR_rt_sigaction`` handler
    returns 0 success without copying the disposition into
    a kernel-side table. Net effect at K8: the user-side
    ``__wasm_sigaction_table`` is the source of truth for
    handler dispatch, and signal delivery (also K8 shortcut;
    next row) bypasses kernel-side ``sig_ignored`` entirely.
    Retired by K9+ when the vmlinux RPC for cross-process
    syscalls lands and the kernel's signal-delivery
    decisions actually consult ``current->sighand``.

  * **R-K8-2** (K8 signal delivery via JS-direct stamp —
    ``SYS_kill`` / ``SYS_tkill`` / ``SYS_tgkill`` resolve
    target tid in ``dispatchSyscall`` and call
    ``deliverSignalToTarget`` directly, bypassing the
    kernel-side delivery path) — OPEN. The full kernel-side
    delivery — vmlinux's ``__send_signal_locked`` →
    ``arch_signal_wake_target`` (K8-B1) → kernel→HardwareJS
    signal ring (K8-B2) → consumer → ``deliverSignalToTarget``
    — exists and passes its own probe (``k8-b2-signal-ring``),
    but cross-process syscalls aren't yet routed through
    ``vmlinux.exports.wasm_syscall``. The JS-direct path
    short-circuits the kernel for ``kill``/``tkill``/
    ``tgkill`` until that wiring lands. Retired by K9+ when
    cross-process syscall RPC lands.

  Existing K6/K7 rows status at K8 close:

  * **R-K6-1** (initcall dispatch) — OPEN. K10 candidate.
  * **R-K6-2** (ramdisk_execute_command clobber) — OPEN,
    subsumed by R-K6-1.
  * **R-K7-1** (softfloat imports) — OPEN. K9+ decision.

  Net at K8 close: 5 open rows (R-K6-1, R-K6-2, R-K7-1,
  R-K8-1, R-K8-2), 3 retired/paid. K8 didn't pay any prior
  debt, and added two of its own — but both are tracked
  with explicit retirement signals (K9+ vmlinux RPC) and
  pinned at the call sites with ``§15`` comments.

**K8 acceptance achieved.** First end-to-end POSIX signal delivery
through the wasm32 port: ``sigaction(2)``, ``raise(3)``,
``pthread_kill(3)``, ``pthread_sigmask(3)``, ``sigsuspend(2)``,
``sigtimedwait(2)``, default-disposition table including SIGTERM
exit_group(143), SIGKILL/SIGSTOP non-catchability — all on top of
K7's threading and K6's fs-init path. K9 = process management can
now land on top of this exact path.

--------------------------------------------------------------------------------
13. K9 surface proposal
--------------------------------------------------------------------------------

Per the user's pre-pinned K-phase ledger
(``ARCHITECTURE.md §14``), **K9 = process management**:

  ``wait4`` / ``waitpid``, pipes, stdin wiring, process groups.

Rationale for the bundle:

  1. **wait4 + SIGCHLD** is a signals + processes coupled
     surface. K8 lit up SIGCHLD as a default-IGN signal; K9
     wires the kernel-side reaping side of the contract so
     a parent's ``waitpid()`` actually finds zombie children
     and a child exit triggers SIGCHLD to the parent.

  2. **pipes** are needed for any meaningful shell pipeline.
     The K10 terminal demo presupposes ``ls | cat`` works.

  3. **stdin wiring** is the read-side of the K6/K7 stdout
     wiring that already exists. K9-shaped because shell
     reads from stdin; without it K10's interactive shell
     can't read user input.

  4. **process groups + tcsetpgrp** are needed for K10's
     job control (``Ctrl-C`` to foreground process group).

K9 budget proposal: **≤ 2 refutations** (per the user's K-phase
brief — K9 ≤ 2 declared upfront). K9 introduces three coupled
axes (wait/pipes/stdin/pgrps form one bundle, but the bundle
has structural surface area), so the budget is one larger than
K8's. If K9 closes at 0-1, the trajectory holds; if K9 closes
at 2, the bundle was correctly sized; if K9 closes at 3+, the
"shell needs this" bundle was too tightly coupled and K10
should re-budget.

K9 retires no §15 rows directly — it might naturally retire
**R-K8-2** (signal delivery via JS-direct stamp) if the wait4
implementation requires routing cross-process syscalls through
vmlinux RPC, but that's a K9 implementation choice rather than
a hard dependency.

--------------------------------------------------------------------------------
History
--------------------------------------------------------------------------------

K8 OPENER (rc8 → rc9 work begins)
  Document created. Framework sections (§1–§11) and Appendix A
  defined. Implementation sequence pinned. Budget ≤ 1 declared.

K8 CLOSE (this commit)
  §12 close ledger added (acceptance + audit + cumulative
  refutation ledger + §15 row scoreboard). §13 K9 surface
  proposal pinned. K8-B5 / K8-B6 sub-phase additions absorbed
  (both spec-shape extensions, neither a refutation). Final
  cumulative refutation count: 0/≤1. Two new §15 rows added
  (R-K8-1, R-K8-2; both K9+ retirement). v0.1.0-rc9 annotated
  tag placed.
