From bad06432aeac4358d89c78f0c4351f920b31d9a8 Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Wed, 6 May 2026 23:39:09 -0700 Subject: [PATCH 01/22] docs: design spec for SIGINT handling Linux-shaped, single-signal SIGINT delivery with cooperative syscall interruption. Detection in IRQ 1 (PS/2) and serial-read paths sets a per-program pending bit; every IRET-to-user epilogue checks it and dispatches to one of {SIG_DFL kill, SIG_IGN, user handler via stack-built sigcontext + vDSO trampoline + sigreturn}. Establishes the IRET-frame-rewrite primitive that a future async timer-callback PR will reuse. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-06-sigint-handling-design.md | 562 ++++++++++++++++++ 1 file changed, 562 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-06-sigint-handling-design.md diff --git a/docs/superpowers/specs/2026-05-06-sigint-handling-design.md b/docs/superpowers/specs/2026-05-06-sigint-handling-design.md new file mode 100644 index 00000000..7ea16b65 --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-sigint-handling-design.md @@ -0,0 +1,562 @@ +# SIGINT handling — design + +Date: 2026-05-06 +Status: Draft + +## Motivation + +A user program executing in ring 3 cannot currently be interrupted from the +keyboard. The shell catches Ctrl+C as a cooked byte (`0x03`) on its own input +stream, but once the shell has exec'd a child via `SYS_SYS_EXEC`, the child +owns the keyboard. A program that enters a tight loop (`while (1) {}`) or +parks on a wait that will never complete (a network read with no inbound +traffic, a `MIDI_IOCTL_DRAIN` waiting for the kernel ring to empty) hangs the +OS until reboot. The only escape today is the QEMU monitor. + +This spec adds SIGINT delivery — Linux-shaped, single-signal — so that: + +- A runaway user loop is killed within ~1 ms by the IRQ 0 timer tick. +- Programs blocked in cooperative kernel waits abort early with `-EINTR` and + return to ring 3 where the signal is delivered. +- Programs that need graceful cleanup (close a socket, flush an OPL queue) + may register a handler that runs at ring 3 before the program dies — or + that lets the program continue running. +- The shell installs `SIG_IGN` so its own `Ctrl+C` does not kill it. + +The mechanism (deflect the IRET frame on the way back to user mode) is the +same primitive that an async timer-callback feature (deferred to a separate +PR) will reuse, so this work is intentionally a stepping stone. + +Out of scope: any signal other than SIGINT, signal masks beyond +"current-signal-blocked-while-handler-runs", `siginfo_t`/`ucontext_t`, +`sigaltstack`, real-time signals, signal queueing beyond a single +pending bit. POSIX function naming (`signal`, `SIG_DFL`, `SIG_IGN`, +`SIGINT`, `EINTR`) is preserved so the API is familiar. + +## Architecture + +Two axes, after Linux: + +- **Detection.** The PS/2 IRQ 1 cooked-byte path and the COM1 serial read + path see byte `0x03` and set a per-program `pending_sigint` flag. Setting + the flag is the only thing that happens in interrupt context. +- **Delivery.** Every kernel-to-user transition — IRQ handler `iretd`, + `INT 30h` syscall `iretd` — runs a tail check of `pending_sigint`. If + set, dispatch fires. + +Dispatch consults the per-program `sigint_handler` slot: + +| Slot value | Meaning | Action | +|---|---|---| +| `SIG_DFL` (0) | default — no handler | tear down the program PD, jump to `shell_reload` | +| `SIG_IGN` (1) | ignore | clear `pending_sigint`, return to user code | +| user-virt addr (≥ `PROGRAM_BASE`) | handler registered | build sigcontext on user stack, redirect IRET to handler | + +When a handler is registered, the kernel rewrites the IRET frame so the +return resumes at the handler's address. The handler runs as a normal C +function, returns into the vDSO trampoline, and the trampoline executes +`SYS_SYS_SIGRETURN` to restore the original register state. While a handler +is on the stack, `in_sigint_handler = 1` blocks re-entry; sigreturn clears +the flag and re-checks `pending_sigint` so a `Ctrl+C` arriving during +handler execution is delivered immediately on resume. + +Cooperative interruption: blocking syscalls (`fd_read_console` polling, +`MIDI_IOCTL_DRAIN` `sti+hlt` loop, future blocking I/O) check +`pending_sigint` between iterations and bail out with `CF=1, EAX=-EINTR`. +The syscall epilogue's same `pending_sigint` check then fires dispatch. +Genuinely uninterruptible waits (FDC sector wait — IRQ 6 is guaranteed) +are left alone; the signal lands when the wait completes. This matches +Linux's `TASK_UNINTERRUPTIBLE` (D-state) — uncommon today and acceptable +to defer. + +## Kernel-side state + +Six new BSS bytes (one dword + two bytes) tracking the current program's +signal state. Only one program runs at a time today, so a single global +slot suffices; the slot is zeroed on every program transition so it +behaves as if it were per-program (handler addresses are user-virt and +only valid in the active PD anyway): + +``` +sigint_handler dd 0 ; 0=SIG_DFL, 1=SIG_IGN, else user-virt address +pending_sigint db 0 ; set in IRQ context, consumed at delivery +in_sigint_handler db 0 ; 1 while a signal frame is on the user stack +``` + +`program_enter` (`src/arch/x86/entry.asm`) zeroes all three on every program +load (boot, `sys_exec` handoff, `sys_exit` shell reload). Each new program +therefore starts in `SIG_DFL` with no pending signal — runaway-program fix +is automatic. + +## Detection + +### PS/2 path + +Hook in `ps2_handle_scancode` (`src/drivers/ps2.c:341`). After the existing +Ctrl+letter cooked-byte computation produces `ascii`, add: + +```c +if (ascii == 0x03) { + pending_sigint = 1; +} +``` + +The byte is **also** enqueued into the per-fd console ring as today. This +matches Linux behavior under `stty -isig` mode (signal delivery without +swallowing the byte). BBoeOS does not have terminal modes; programs that +want only the signal can ignore the byte, programs that want only the byte +can install `SIG_IGN`. + +### Serial path + +Hook in `fd_read_console` (`src/fs/fd/console.c:131`) where the serial LSR +read returns a byte. Before returning the byte to the caller, if it is +`0x03`, set `pending_sigint = 1`. + +This is poll-driven (only fires when something is reading from the console +fd) — a known limitation. A future move of COM1 to IRQ-driven input would +let the kernel set the flag asynchronously like PS/2 does. Sufficient for v1 +because the typical case (a runaway program) eventually pumps IRQ 0 and +delivers via the IRQ epilogue path; the serial-only case (a program parked +on a non-console syscall while the user is on a serial terminal) is rare. + +## Delivery + +### Sigcontext layout on user stack + +When dispatch fires for a registered handler, the kernel reads user ESP +from the IRET frame, decrements it by 48 bytes, writes the following +struct, and rewrites the IRET frame: + +``` +offset field +------ ----- ++0 trampoline_addr ; vDSO __kernel_sigreturn (handler's return address) ++4 signum ; = 2 (SIGINT) — handler's int argument ++8 saved_eip ; original interrupt-frame EIP ++12 saved_eflags ++16 saved_esp ; original user ESP before signal frame ++20 saved_eax ++24 saved_ecx ++28 saved_edx ++32 saved_ebx ++36 saved_ebp ; pushad order minus its ESP slot (saved separately at +16) ++40 saved_esi ++44 saved_edi +``` + +Total: 48 bytes (12 dwords). Seven register slots, not eight: pushad's +own ESP slot is redundant because we save the user ESP explicitly at +offset +16. + +After the write, the kernel rewrites the IRET frame: + +- `EIP ← sigint_handler` +- `ESP ← user_esp - 48` +- `EFLAGS` ← unchanged (handler runs with same flags as interrupted code) +- `CS / SS` ← unchanged (still ring-3) + +It also sets `in_sigint_handler = 1`, clears `pending_sigint`, and `iretd`s. +The handler runs as a normal C function with `signum` on its stack at +`[ESP+4]`, returns via standard `ret` which pops `trampoline_addr` into EIP. + +### IRET-frame rewrite locations + +Two epilogue points need the dispatch check: + +1. **IRQ handlers** (`src/arch/x86/entry.asm`) — IRQ 0 (PIT), IRQ 1 (PS/2), + IRQ 4 (serial), IRQ 5 (SB16), IRQ 6 (FDC). All currently end in + `popad / iretd`. Insert before `iretd`: if interrupted CS is user code + AND `pending_sigint != 0` AND `in_sigint_handler == 0`, jump to + `signal_dispatch`. Otherwise `iretd` as before. + +2. **Syscall handler** (`src/arch/x86/syscall.asm`) — the `.iret_cf` / + `.iret_cf_eax` / `.iret_no_cf` exits from `INT 30h`. Same check inserted + before `iretd`. + +The dispatch routine (in a new `src/arch/x86/signal.asm` or in C in +`src/kernel/signal.c`) reads `sigint_handler`: + +- `0` (SIG_DFL): `mov esp, kernel_stack_top`, call `address_space_destroy`, + jump to `shell_reload`. Same teleport pattern used by `program_enter`'s + OOM cleanup (`entry.asm:530-548`). Optionally print `^C\n` to console + before teardown for UX. +- `1` (SIG_IGN): clear `pending_sigint`, restore registers, `iretd`. +- otherwise: build the sigcontext, rewrite IRET frame, `iretd`. + +### Re-entry + +While `in_sigint_handler == 1`, the epilogue check skips dispatch — the +flag stays pending. This matches Linux's default of blocking the same +signal during its handler. Re-delivery happens on `SYS_SYS_SIGRETURN`, +which clears `in_sigint_handler` and re-runs the dispatch check before +its own `iretd`. + +### Stack overflow + +The handler runs on the same stack as the interrupted code. The 48-byte +sigcontext plus the handler's own frame eat into the 64 KB user stack. If +the user is near the stack guard (PTEs `0xFF7E0..0xFF7EF` are unmapped), +the kernel's write of the sigcontext faults — the kernel detects the page +fault on its `[esi]` write and converts to a kill (same path as SIG_DFL). +`sigaltstack` is out of scope; existing user programs use a fraction of +their stack. + +## Sigreturn + +### vDSO trampoline + +Add a 7-byte trampoline at a fixed offset in the vDSO page (user-virt +`0x10000`). Suggested name: `__kernel_sigreturn`, suggested offset: +`VDSO_VIRT + 0x100` (well past the existing `FUNCTION_*` table). + +```nasm +__kernel_sigreturn: + mov eax, SYS_SYS_SIGRETURN + int 0x30 + ; never returns +``` + +The trampoline address is exposed to the kernel as a constant in +`src/include/constants.asm`; the kernel writes it into the sigcontext +unconditionally. Userland programs do not reference the trampoline +directly — `signal()` and the handler return path are the only entry +points. + +### `SYS_SYS_SIGRETURN` + +The handler's `ret` pops `trampoline_addr` into EIP and increments user +ESP by 4. The trampoline immediately enters the kernel via `INT 30h`. At +syscall entry, user ESP points at `signum` (offset +4 of the original +sigcontext layout); the saved registers begin at user ESP + 4 +(`saved_eip`). + +The syscall: + +1. Reads `saved_eip`, `saved_eflags`, `saved_esp`, `saved_eax..edi` from + `[user_esp + 4 .. user_esp + 44]`. +2. Validates: `saved_eip` is in user range (`PROGRAM_BASE ≤ x < + KERNEL_VIRT_BASE`); `saved_esp` is in user range. On failure, kill the + program (same as `SIG_DFL` — handler corrupted its frame). +3. Rewrites the syscall's IRET frame: `EIP ← saved_eip`, + `EFLAGS ← saved_eflags`, `ESP ← saved_esp`, and the eight pushad slots + from `saved_eax..saved_edi`. +4. Clears `in_sigint_handler`. +5. Re-checks `pending_sigint`. If set, fires dispatch immediately (so a + `Ctrl+C` arriving during handler execution is honored without waiting + for the next IRQ). +6. `iretd`. + +The syscall does not return through the standard `.iret_cf` path because +the IRET frame has been rewritten; control resumes at `saved_eip`, not +at the trampoline's `int 0x30` follow-up. + +## Cooperative interruption + +Three blocking syscall paths gain a `pending_sigint` check in their wait +loop. Each bails with `CF=1, EAX = (uint32_t)-EINTR`. The propagated value +is `0xFFFFFFFC` (= `-4`); libc wrappers translate to `errno = EINTR` (see +"libc surface" below). + +### `fd_read_console` (`src/fs/fd/console.c:131`) + +```c +asm("sti"); +while (1) { + if (pending_sigint) { + return /* CF set, AX = 0xFFFC */; + } + byte = ps2_getc(); + if (byte != '\0') break; + if ((kernel_inb(0x3FD) & 0x01) != 0) { + byte = kernel_inb(0x3F8); + break; + } +} +``` + +The check is placed before the polling iteration so a Ctrl+C arriving via +PS/2 IRQ 1 during the previous spin sees the flag on the next loop top. + +### `MIDI_IOCTL_DRAIN` (`src/fs/fd/midi.c`) + +The `.fd_ioctl_midi_drain_wait` `sti+hlt` loop currently reads +`midi_head/tail` and waits for them to converge. Insert a +`pending_sigint` check after the `sti; hlt; cli` wakeup: + +```nasm +.fd_ioctl_midi_drain_wait: + cli + cmp byte [_g_pending_sigint], 0 + jne .fd_ioctl_midi_drain_eintr + mov al, [_g_midi_head] + cmp al, [_g_midi_tail] + je .fd_ioctl_midi_drain_done + sti + hlt + jmp .fd_ioctl_midi_drain_wait +.fd_ioctl_midi_drain_eintr: + sti + mov eax, 0xFFFFFFFC + stc + ret +``` + +### Future blocking waits + +Any new `sti+hlt`-based wait inherits the same pattern: check +`pending_sigint` before each `hlt`, bail with `-EINTR` when set. Document +this as a kernel-coding convention in `docs/architecture.md` after the PR +lands. + +### Uninterruptible waits + +`fd_read_floppy` / FDC sector wait: IRQ 6 is guaranteed by the FDC +state machine on any in-flight command, so the wait will terminate +without help. The `Ctrl+C` lands when the wait completes and the +syscall epilogue runs. This matches Linux `TASK_UNINTERRUPTIBLE` +behavior. No change needed. + +## Default kill path + +When `sigint_handler == SIG_DFL` and dispatch fires: + +```nasm +signal_dispatch_kill: + mov esp, kernel_stack_top + ; Optional: print ^C\n before teardown + mov al, '^' + call put_character + mov al, 'C' + call put_character + mov al, 0x0A + call put_character + mov eax, [current_pd_phys] + call address_space_destroy + mov dword [current_pd_phys], 0 + jmp shell_reload +``` + +This is the same teardown sequence as the OOM path in `program_enter` +(`entry.asm:530-548`); both are valid because `kernel_idle_pd` has the +kernel direct map and the kmap window, so all kernel data is reachable +without the user PD. `address_space_destroy` walks user PDEs, frees user +pages, frees PTs, frees the PD. `shell_reload` rebuilds the shell. + +The signal source (IRQ vs syscall epilogue) does not matter for the kill +path because we teleport to a known kernel ESP and fall through the +shell-reload entry point. + +## Shell integration + +`src/c/shell.c` calls `signal(SIGINT, SIG_IGN)` at startup. Effect: + +- Ctrl+C still sets `pending_sigint`. +- IRQ epilogue dispatch sees `SIG_IGN`, clears `pending_sigint`, returns. +- The `0x03` byte still arrives in the shell's console reads — the shell's + existing line editor can choose to display `^C\n` and reset the input + buffer (small UX touch; not strictly required). + +When the shell `sys_exec`s a child, `program_enter` zeroes the per-program +state, so the child starts with `SIG_DFL`. Killable by default — runaway +fix. + +## API surface + +### asm constants (`src/include/constants.asm`) + +```nasm +%assign SYS_SYS_SIGNAL 0xF5 ; alphabetical fit in F0..F4 group +%assign SYS_SYS_SIGRETURN 0xF6 +%assign SIGINT 2 ; matches Linux +%assign SIG_DFL 0 +%assign SIG_IGN 1 +%assign EINTR 4 ; matches Linux +``` + +### `SYS_SYS_SIGNAL` — register handler + +| Reg | Direction | Meaning | +|---|---|---| +| `EBX` | in | signum (must be `SIGINT`; CF set on others) | +| `ECX` | in | handler — `SIG_DFL`, `SIG_IGN`, or user-virt address ≥ `PROGRAM_BASE` | +| `EAX` | out | previous handler value | +| CF | out | clear on success; set on bad signum or out-of-range handler | + +Handler-address validation: `ECX = 0`, `ECX = 1`, or `PROGRAM_BASE ≤ ECX < +KERNEL_VIRT_BASE`. The kernel does not verify the address points to +executable code; a bogus handler that triggers an exception kills the +program via the existing exception path. + +### `SYS_SYS_SIGRETURN` — restore from sigcontext + +| Reg | Direction | Meaning | +|---|---|---| +| (none) | | reads sigcontext from user stack at `[user_esp + 4]` | + +Never returns in the normal sense: it rewrites its own IRET frame and +resumes the saved EIP. On validation failure, kills the program (no +return to caller). + +### libc surface (`tools/libc/`) + +Add `tools/libc/include/signal.h`: + +```c +#ifndef _SIGNAL_H +#define _SIGNAL_H + +typedef void (*sighandler_t)(int); +typedef volatile int sig_atomic_t; + +#define SIG_DFL ((sighandler_t)0) +#define SIG_IGN ((sighandler_t)1) +#define SIG_ERR ((sighandler_t)-1) + +#define SIGINT 2 + +sighandler_t signal(int signum, sighandler_t handler); + +#endif +``` + +Add `tools/libc/signal.c`: + +```c +sighandler_t signal(int signum, sighandler_t handler) { + /* INT 30h, AH = SYS_SYS_SIGNAL, EBX = signum, ECX = handler. + * Returns previous handler in EAX, CF set on failure → SIG_ERR. */ + /* asm body elided in spec — see writing-plans for the asm shim. */ +} +``` + +Add `EINTR` to `tools/libc/include/errno.h` if not already present. Wrap +`read`, `ioctl`, and any future blocking syscalls so that `CF=1` with +`EAX = 0xFFFFFFFC` translates to a return value of `-1` and +`errno = EINTR`. + +## Handler reentrancy expectations + +Signal handlers fire at arbitrary user-code instructions, so the same +async-signal-safety rules as POSIX apply. The standard pattern is: + +```c +volatile sig_atomic_t got_sigint = 0; +void on_sigint(int s) { (void)s; got_sigint = 1; } + +int main(void) { + signal(SIGINT, on_sigint); + while (running) { + if (got_sigint) { + printf("interrupted\n"); + cleanup(); + break; + } + do_work(); + } +} +``` + +The handler does the minimum (a single flag write, atomic on x86) and +returns. The main loop sees the flag at its next check point and does +the actual work in a context where stdio and heap are safe. + +Per-function safety in BBoeOS libc: + +| Function | Safe from handler? | Reason | +|---|---|---| +| `write(fd, buf, n)` | yes (visual interleaving possible but no crash) | direct syscall; kernel ANSI parser state may interleave | +| `_exit` / direct `SYS_SYS_EXIT` | yes | kills program; no shared state to corrupt | +| `signal` | yes | registration syscall is idempotent | +| `printf` / `fprintf` / `puts` | **no** (visual + parser interleave) | kernel-side ANSI parser is stateful across writes | +| `malloc` / `free` / `fopen` / `fclose` | **no** (heap corruption) | global heap state | +| `read` | conditional | OK in isolation; not on an fd a main-thread `read` is parked on | + +## Testing + +### Manual + +- **Runaway loop, PS/2.** Build a program containing `int main(void) { + while (1) {} }`, place it on the disk image, run from shell, press + Ctrl+C on the QEMU window. Expect: shell prompt returns within a frame + or two (≤ 1 ms after Ctrl+C, plus shell reload time). +- **Runaway loop, serial.** Same program, run via `qemu -serial stdio`, + send `^C` on the serial input. Expected outcome in v1: **does not + fire**. Serial detection only happens inside `fd_read_console`, which + the runaway program is not calling. PS/2 detection runs from IRQ 1 + regardless of what user code is doing, so a Ctrl+C on the QEMU window + works; serial users on a wedged program have no recourse until a + future PR moves COM1 to IRQ-driven input. Document this limitation in + `docs/architecture.md`. +- **Handler runs.** A program that registers a handler, writes a marker, + spins until a `volatile` flag is set by the handler, then prints "got + it" and exits. Press Ctrl+C; expect the marker, then "got it", then + shell prompt. +- **Drain interrupted.** Run a program that opens `/dev/midi`, queues a + long sequence with delays, calls `MIDI_IOCTL_DRAIN`. While drain is + blocked, press Ctrl+C. Expect: drain returns `-1 / errno = EINTR`, + program either exits (default) or its handler runs and decides what + to do. +- **Shell ignores Ctrl+C.** At a shell prompt with no child running, + press Ctrl+C. Expect: shell continues running; input buffer optionally + resets; no program death. + +### Automated + +`tests/test_programs.py` already drives QEMU via a serial fifo and waits +for the shell prompt. Two new test programs and entries: + +- `tests/sigint_handler_test.c` — registers handler, sets flag, exits + cleanly. Test driver sends `\x03` on the serial input mid-spin, expects + output marker + handler-confirms marker + shell prompt. +- `tests/sigint_runaway_test.c` — `while (1) {}`. Test driver sends + `\x03`, expects shell prompt within a bounded time (e.g., 500 ms). + +### Regression + +`tests/test_programs.py` and `tests/test_asm.py` should pass with no +behavioral changes for any existing program. The cooperative-interruption +hooks add a single conditional branch on the no-signal-pending path — +zero functional change when `pending_sigint == 0`. + +## Risks and limitations + +- **Stack pressure.** The 48-byte sigcontext plus handler frame can push + a near-overflowing user stack into the guard page. Failure mode is a + page fault that the kernel converts to program kill. Documented; no + mitigation in v1. +- **Single signal.** Only `SIGINT` is supported. Adding `SIGTERM`, + `SIGSEGV`, `SIGCHLD` later requires extending the per-program state + to a small array indexed by signum. The IRET-rewrite mechanism already + generalizes. +- **No queueing.** A second Ctrl+C arriving while one is already pending + is coalesced (single bit). Linux does the same for non-realtime + signals; not a regression. +- **Serial Ctrl+C is poll-driven.** Only fires when something is reading + the console fd. A runaway program over serial cannot be killed in v1. + A future move of COM1 to IRQ-driven input fixes this. +- **Cooperative-only interruption.** Programs blocked in + `TASK_UNINTERRUPTIBLE`-equivalent waits (FDC sector wait) miss the + signal until the wait completes. Same as Linux D-state. No mitigation + in v1. + +## Implementation footprint + +| Area | Files | Approx. LOC | +|---|---|---| +| State + reset | `src/arch/x86/entry.asm` (`program_enter`), new BSS | ~10 | +| Detection | `src/drivers/ps2.c`, `src/fs/fd/console.c` | ~10 | +| Dispatch routine | new `src/arch/x86/signal.asm` (or `.c`) | ~120 | +| IRQ epilogue checks | `src/arch/x86/entry.asm` (5 IRQ handlers) | ~30 | +| Syscall epilogue check | `src/arch/x86/syscall.asm` (3 iret exits) | ~15 | +| `SYS_SYS_SIGNAL` | `src/arch/x86/syscall.asm` | ~30 | +| `SYS_SYS_SIGRETURN` | `src/arch/x86/syscall.asm` | ~50 | +| vDSO trampoline | wherever the vDSO page is initialised | ~10 | +| Cooperative checks | `src/fs/fd/console.c`, `src/fs/fd/midi.c` | ~20 | +| libc | `tools/libc/include/signal.h`, `tools/libc/signal.c`, `tools/libc/include/errno.h`, wrappers | ~50 | +| Shell hookup | `src/c/shell.c` | ~5 | +| Constants | `src/include/constants.asm` | ~10 | +| Tests | `tests/sigint_*.c`, entries in `tests/test_programs.py` | ~80 | +| Docs | `docs/syscalls.md`, `docs/architecture.md` | ~30 | + +Total: ~470 lines of new code + tests + docs. From 3ecd5f53c4f444186d3fd1a48418bc01e1728381 Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Wed, 6 May 2026 23:50:12 -0700 Subject: [PATCH 02/22] docs: implementation plan for SIGINT handling 17-task TDD plan across 6 phases: foundation (constants + state), default-kill path (PS/2 detection + IRQ epilogue dispatch), SIG_IGN + shell, real handler delivery (vDSO trampoline + sigcontext + sigreturn), cooperative interruption (read + MIDI drain + EINTR), tests + docs. Each phase produces a manually-verifiable slice; the final phase adds the automated test_programs.py entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-06-sigint-handling.md | 1336 +++++++++++++++++ 1 file changed, 1336 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-06-sigint-handling.md diff --git a/docs/superpowers/plans/2026-05-06-sigint-handling.md b/docs/superpowers/plans/2026-05-06-sigint-handling.md new file mode 100644 index 00000000..fe5f6e81 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-sigint-handling.md @@ -0,0 +1,1336 @@ +# SIGINT Handling Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Linux-shaped SIGINT delivery so a Ctrl+C from PS/2 kills runaway user programs (default), the shell can opt out via `SIG_IGN`, and programs can register a handler that runs at ring 3 with sigreturn-based resume. + +**Architecture:** PS/2 IRQ 1 path and `fd_read_console` serial path set a global `pending_sigint` byte. Every kernel-to-user IRET epilogue (5 IRQ handlers + 3 syscall exits) checks the byte and, if set, dispatches to one of {`SIG_DFL` kill via `address_space_destroy + shell_reload`, `SIG_IGN` clear-and-resume, user handler via stack-built sigcontext + vDSO trampoline + `SYS_SYS_SIGRETURN`}. Cooperative interruption: blocking syscalls (`fd_read_console` poll, `MIDI_IOCTL_DRAIN` `sti+hlt`) check `pending_sigint` in their wait loop and bail with `CF=1, AL=ERROR_INTERRUPTED`. + +**Tech Stack:** NASM (kernel asm), `cc.py` (kernel/userland C, with inline `asm()` for tight register contracts), clang (libc shim), QEMU i386 (boot + manual + automated test), Python `tests/test_programs.py` (serial-fifo-driven QEMU smoke tests). + +**Spec:** `docs/superpowers/specs/2026-05-06-sigint-handling-design.md` + +--- + +## File Structure + +**New files:** +- `src/arch/x86/signal.c` — `signal_dispatch_kill`, sigcontext-build helper, `signal_resume_after_handler` (sigreturn restore). Mostly inline `asm()` because IRET-frame manipulation has tight register contracts. +- `tools/libc/include/signal.h` — `sighandler_t`, `sig_atomic_t`, `SIG_DFL`/`SIG_IGN`/`SIG_ERR`, `SIGINT`, `signal()` prototype. +- `tools/libc/signal.c` — userland `signal()` shim around `SYS_SYS_SIGNAL`. +- `tests/sigint_handler_test.c` — end-to-end automated test program. + +**Modified:** +- `src/include/constants.asm` — `SYS_SYS_SIGNAL` (0xF5), `SYS_SYS_SIGRETURN` (0xF6), `SIGINT` (2), `SIG_DFL` (0), `SIG_IGN` (1), `ERROR_INTERRUPTED` (8h), `VDSO_SIGRETURN` (vDSO trampoline offset). +- `src/arch/x86/entry.asm` — new BSS slots (`sigint_handler`, `pending_sigint`, `in_sigint_handler`); zero them in `program_enter`; tail-check + dispatch in IRQ 0/1/4/5/6 handler epilogues; vDSO trampoline byte writes during vDSO page init. +- `src/arch/x86/syscall.asm` — tail-check + dispatch in `.iret_cf` / `.iret_cf_eax` / `.iret_no_cf` exits; new `.sys_signal` and `.sys_sigreturn` entries. +- `src/drivers/ps2.c` — set `pending_sigint = 1` in `ps2_handle_scancode` when cooked byte is `0x03`. +- `src/fs/fd/console.c` — set `pending_sigint = 1` when serial poll reads `0x03`; check `pending_sigint` in `fd_read_console` wait loop and bail with `CF=1, AL=ERROR_INTERRUPTED`. +- `src/fs/fd/midi.c` — check `pending_sigint` in `.fd_ioctl_midi_drain_wait` `sti+hlt` loop and bail with `CF=1, AL=ERROR_INTERRUPTED`. +- `src/c/shell.c` — call `signal(SIGINT, SIG_IGN)` at startup. +- `tools/libc/include/errno.h` — add `EINTR` (= 4). +- `tools/libc/errno.c` (or `tools/libc/syscall.c`) — extend `_errno_from_al` to map `ERROR_INTERRUPTED` (8h) → `EINTR`. +- `tools/libc/Makefile` — add `signal.c` to `C_SRCS`. +- `tests/test_programs.py` — add `sigint_handler` entry that drives the new test program via the serial fifo. +- `docs/syscalls.md` — add `SYS_SYS_SIGNAL` and `SYS_SYS_SIGRETURN` rows; add `ERROR_INTERRUPTED`. +- `docs/architecture.md` — short subsection documenting SIGINT delivery model + serial-runaway limitation. +- `docs/CHANGELOG.md` — Unreleased entry. + +--- + +## Phase 1 — Foundation + +### Task 1: Add asm + libc constants + +**Files:** +- Modify: `src/include/constants.asm` +- Modify: `tools/libc/include/errno.h` + +- [ ] **Step 1: Add kernel constants in alphabetical/numeric order** + +In `src/include/constants.asm`, find the existing `ERROR_*` block (lines ~13-19) and insert in numeric order: + +```nasm +%assign ERROR_INTERRUPTED 08h ; Cooperative-interrupt return (SIGINT) — maps to EINTR in libc +``` + +Find the existing `SYS_SYS_*` block (lines ~149-153 area, after `SYS_SYS_SHUTDOWN`) and add (numeric order — these slot in after the existing F0..F4): + +```nasm +%assign SYS_SYS_SIGNAL 0F5h ; EBX = signum (SIGINT only); ECX = handler (SIG_DFL/SIG_IGN/user-virt); EAX = previous handler; CF on bad signum / handler +%assign SYS_SYS_SIGRETURN 0F6h ; restore from sigcontext on user stack; never returns to caller +``` + +Add a new SIGNAL section near the end (alphabetical with other groupings is fine — pick a spot consistent with surrounding style): + +```nasm +;;; Signal numbers (POSIX-numbered). Currently only SIGINT is delivered. +%assign SIGINT 2 + +;;; signal() handler sentinels (POSIX-valued). +%assign SIG_DFL 0 +%assign SIG_IGN 1 +``` + +Add the vDSO trampoline offset alongside the existing `VDSO_VIRT` constant: + +```nasm +%assign VDSO_SIGRETURN_OFFSET 0100h ; trampoline lives at VDSO_VIRT + 0x100 +``` + +- [ ] **Step 2: Add libc EINTR** + +In `tools/libc/include/errno.h`, add (in numeric order, between EIO and EBADF): + +```c +#define EINTR 4 +``` + +- [ ] **Step 3: Build and verify clean compile** + +Run: `./make_os.sh` +Expected: builds `drive.img` with no errors. No behavioral change yet. + +- [ ] **Step 4: Commit** + +```bash +git add src/include/constants.asm tools/libc/include/errno.h +git commit -m "kernel+libc: SIGINT/SYS_SIGNAL constants + EINTR/ERROR_INTERRUPTED" +``` + +--- + +### Task 2: Add per-program signal state and zero on program_enter + +**Files:** +- Modify: `src/arch/x86/entry.asm` + +- [ ] **Step 1: Add BSS slots** + +In `src/arch/x86/entry.asm`, find the existing program-state BSS block (search for `current_pd_phys` or `current_program_break` to locate the area) and add nearby: + +```nasm +;;; SIGINT delivery state. One global slot suffices because only one +;;; user program runs at a time — program_enter zeroes the lot on every +;;; load so it behaves as if it were per-program. sigint_handler is a +;;; user-virt address (or SIG_DFL=0 / SIG_IGN=1); the address is only +;;; valid in the active PD, hence the zero-on-transition rule. +sigint_handler dd 0 +pending_sigint db 0 +in_sigint_handler db 0 +align 4 +``` + +- [ ] **Step 2: Zero them in program_enter** + +In `program_enter` (around the existing `current_pd_phys` / `current_program_break` setup), add: + +```nasm + ;; Reset SIGINT state — every new program starts in SIG_DFL with + ;; no pending signal and no handler frame on its stack. + mov dword [sigint_handler], SIG_DFL + mov byte [pending_sigint], 0 + mov byte [in_sigint_handler], 0 +``` + +Place this near the `current_program_break` initialisation (it's part of "fresh program state"). + +- [ ] **Step 3: Build and verify** + +Run: `./make_os.sh` +Expected: builds clean. No behavior change (no reader yet). + +- [ ] **Step 4: Commit** + +```bash +git add src/arch/x86/entry.asm +git commit -m "kernel: per-program SIGINT state in BSS, reset by program_enter" +``` + +--- + +## Phase 2 — Default kill path (the runaway-program fix) + +### Task 3: Implement signal_dispatch_kill + +**Files:** +- Create: `src/arch/x86/signal.c` +- Modify: `make_os.sh` (or wherever the C-file list lives) + +- [ ] **Step 1: Locate the C-file build list** + +Run: `grep -n "fd/midi\|fs/fd/midi\|fd_init" make_os.sh` +Identify how the existing C kernel files (`src/fs/fd/midi.c`, `src/drivers/sb16.c`, etc.) are passed to `cc.py` and added to the build. + +- [ ] **Step 2: Create the signal-dispatch source** + +Create `src/arch/x86/signal.c`: + +```c +// signal.c — SIGINT dispatch primitives. Two entry points: +// signal_dispatch_kill — reset to a known kernel ESP, tear down the +// dying program's PD, jump to shell_reload. +// Reused by the SIG_DFL path and by handler- +// validation failures in SYS_SYS_SIGRETURN. +// signal_dispatch_user — built in Task 11; left as a stub here so +// the Phase 2 path links cleanly. +// +// signal_dispatch_kill never returns. It is reachable only from kernel +// context (IRQ epilogue or syscall epilogue), so it can clobber every +// register and reset ESP without consulting the caller's frame. + +extern uint32_t current_pd_phys; +extern uint32_t kernel_stack_top; +void address_space_destroy(uint32_t pd_phys); +void put_character(char byte); +void shell_reload(); // entry.asm symbol +asm("shell_reload equ _g_shell_reload" "\n"); + +void signal_dispatch_kill(); + +asm("signal_dispatch_kill:\n" + " mov esp, kernel_stack_top\n" + " mov al, '^'\n" + " call put_character\n" + " mov al, 'C'\n" + " call put_character\n" + " mov al, 0x0A\n" + " call put_character\n" + " mov eax, [_g_current_pd_phys]\n" + " test eax, eax\n" + " jz .signal_dispatch_kill_no_pd\n" + " push eax\n" + " call address_space_destroy\n" + " add esp, 4\n" + " mov dword [_g_current_pd_phys], 0\n" + ".signal_dispatch_kill_no_pd:\n" + " jmp shell_reload\n"); +``` + +Note: the `shell_reload equ _g_shell_reload` aliasing line follows the same pattern as `midi_head equ _g_midi_head` in `src/fs/fd/midi.c`; it lets cc.py's name-mangled symbol point at the asm label. Verify the actual symbol-mangling pattern by reading the top of `src/fs/fd/midi.c` first. + +- [ ] **Step 3: Add the new C file to the build** + +Add `src/arch/x86/signal.c` to whatever list `make_os.sh` uses for the kernel C files (look at where `src/fs/fd/midi.c` appears). + +- [ ] **Step 4: Build and verify clean compile + link** + +Run: `./make_os.sh` +Expected: builds clean. No reachable caller yet, but the symbol must link. + +- [ ] **Step 5: Commit** + +```bash +git add src/arch/x86/signal.c make_os.sh +git commit -m "kernel: signal_dispatch_kill — teleport-to-kernel-ESP teardown" +``` + +--- + +### Task 4: PS/2 detects Ctrl+C and sets pending_sigint + +**Files:** +- Modify: `src/drivers/ps2.c` + +- [ ] **Step 1: Declare the kernel-side flag in ps2.c scope** + +Near the top of `src/drivers/ps2.c` (with the other `extern` declarations), add: + +```c +extern uint8_t pending_sigint; +``` + +- [ ] **Step 2: Hook the cooked-byte path** + +In `ps2_handle_scancode` (`src/drivers/ps2.c:341` area), the cooked-byte block computes `ascii` for Ctrl+letter via `upper = ascii & 0x5F; ...`. After the cooked byte is finalised but before it is enqueued onto the per-fd ring, add: + +```c +if (ascii == 0x03) { + pending_sigint = 1; +} +``` + +The 0x03 byte still flows into the per-fd ring as today — programs that want only the byte (not the signal) will install `SIG_IGN` once Phase 3 lands. + +- [ ] **Step 3: Build and verify** + +Run: `./make_os.sh` +Expected: builds clean. The flag will be set on Ctrl+C but no consumer exists yet. + +- [ ] **Step 4: Commit** + +```bash +git add src/drivers/ps2.c +git commit -m "drivers/ps2: set pending_sigint when cooked Ctrl+C is detected" +``` + +--- + +### Task 5: IRQ epilogue dispatch check + +**Files:** +- Modify: `src/arch/x86/entry.asm` + +- [ ] **Step 1: Decide the dispatch macro** + +Each IRQ handler currently ends in `popad / iretd`. Insert a tail check that: +1. Examines the iret frame's saved CS to know whether we're returning to user code. +2. Reads `pending_sigint` and `in_sigint_handler`. +3. Branches to dispatch or falls through to the original `iretd`. + +Define the macro once at the top of `entry.asm`, alphabetical with existing macros if there are any: + +```nasm +;;; SIGINT dispatch tail — invoke from IRQ / syscall handler before iretd. +;;; Stack at invocation: the popad has already executed, so [esp] is +;;; iret EIP, [esp+4] is iret CS. Skips dispatch when: +;;; - interrupted CS is not user code (we'd kill kernel context), +;;; - pending_sigint is clear (nothing to do), +;;; - in_sigint_handler is set (already running a handler — block +;;; re-entry until SYS_SYS_SIGRETURN clears the flag). +;;; On dispatch: SIG_DFL → signal_dispatch_kill (never returns). +;;; SIG_IGN → clear pending_sigint, fall through to iretd. +;;; user-virt → signal_dispatch_user (Task 11; stub for now). +%macro SIGINT_TAIL_CHECK 0 + cmp word [esp + 4], USER_CODE_SELECTOR + jne %%no_dispatch + cmp byte [pending_sigint], 0 + je %%no_dispatch + cmp byte [in_sigint_handler], 0 + jne %%no_dispatch + mov eax, [sigint_handler] + cmp eax, SIG_DFL + je signal_dispatch_kill ; never returns + cmp eax, SIG_IGN + jne %%user_handler + mov byte [pending_sigint], 0 + jmp %%no_dispatch +%%user_handler: + ;; Phase 4 fills this in. Until then, treat unknown handler + ;; values like SIG_DFL — a bare `call signal_dispatch_user` + ;; wouldn't compile yet because the symbol doesn't exist. + jmp signal_dispatch_kill +%%no_dispatch: +%endmacro +``` + +`USER_CODE_SELECTOR` should already be defined in `constants.asm` (search for it). If it's not, use whichever symbolic name the existing `iretd`-to-user code uses. + +- [ ] **Step 2: Insert the check in every IRQ handler** + +For each of `pmode_irq0_handler`, `pmode_irq1_handler`, `pmode_irq4_handler` (if it exists; otherwise skip), `pmode_irq5_handler`, `pmode_irq6_handler`: insert `SIGINT_TAIL_CHECK` between `popad` and `iretd`. + +Example (IRQ 0): + +```nasm +pmode_irq0_handler: + pushad + inc dword [system_ticks] + call midi_drain_due + mov al, PIC_EOI + out PIC1_CMD_PORT, al + popad + SIGINT_TAIL_CHECK + iretd +``` + +Apply the same pattern to the other handlers. If `USER_CODE_SELECTOR` is something like `0x1B` (RPL=3 user code segment), confirm by reading the GDT setup near the top of `entry.asm`. + +- [ ] **Step 3: Build and verify clean compile** + +Run: `./make_os.sh` +Expected: builds clean. The macro should not error on `signal_dispatch_kill` reference (it's defined in Task 3). + +- [ ] **Step 4: Manual verification — runaway program killed by Ctrl+C** + +Write a 3-line user test program. In a scratch dir: + +```c +// /tmp/spin.c — paste then assemble via the OS +int main(void) { while (1) { } } +``` + +Add it to the disk image: +```bash +# Compile via the on-OS cc.py — easier from inside the OS once shell. +# For this manual test, simpler: write a tiny asm spin. +``` + +Or, faster: write a tiny asm program that loops forever: + +```nasm +; static/spin.asm +%include "constants.asm" + org PROGRAM_BASE +.spin: jmp .spin +``` + +Add it to the disk image: +```bash +./add_file.py static/spin.asm +# or whatever the asm-add path is for the test corpus +``` + +Then in QEMU: +```bash +./make_os.sh +qemu-system-i386 -drive file=drive.img,format=raw +``` + +At the shell prompt, run the spin program. Press Ctrl+C in the QEMU window. **Expected:** within ~1 ms (visually instant) the shell reloads with `^C\n` followed by a fresh prompt. + +- [ ] **Step 5: Commit** + +```bash +git add src/arch/x86/entry.asm +git commit -m "kernel: SIGINT_TAIL_CHECK in all IRQ epilogues; default-kill on Ctrl+C" +``` + +--- + +### Task 6: Syscall epilogue dispatch check + +**Files:** +- Modify: `src/arch/x86/syscall.asm` + +- [ ] **Step 1: Identify the syscall iret exits** + +Run: `grep -n "iretd\|.iret_cf\|.iret_no_cf\|.iret_cf_eax" src/arch/x86/syscall.asm` +Identify the central `.iret_*` exit labels. The dispatch's standard exit pattern is the place to splice the check. + +- [ ] **Step 2: Insert SIGINT_TAIL_CHECK before each syscall iretd** + +For each of the centralised iret exit labels (likely `.iret_cf`, `.iret_cf_eax`, `.iret_no_cf`), insert the same macro before the final `iretd`. The macro is defined in `entry.asm`; if `syscall.asm` and `entry.asm` are assembled into one unit (which they are — the build concatenates flat binaries), the macro is visible. Otherwise, copy the macro definition into `syscall.asm` too. + +Example: + +```nasm +.iret_cf: + ... + popad + SIGINT_TAIL_CHECK + iretd +``` + +- [ ] **Step 3: Build and verify** + +Run: `./make_os.sh` +Expected: builds clean. + +- [ ] **Step 4: Manual verification — runaway program over a syscall-rich workload** + +Run an asm test program that loops calling `SYS_RTC_MILLIS` to exercise the syscall path. Press Ctrl+C; the syscall epilogue check fires the kill. Expected: shell reloads. + +- [ ] **Step 5: Commit** + +```bash +git add src/arch/x86/syscall.asm +git commit -m "kernel: SIGINT_TAIL_CHECK in syscall iret epilogues" +``` + +--- + +## Phase 3 — SIG_IGN + shell + +### Task 7: SYS_SYS_SIGNAL syscall (DFL/IGN only) + +**Files:** +- Modify: `src/arch/x86/syscall.asm` + +- [ ] **Step 1: Add the SYS_ENTRY** + +In `src/arch/x86/syscall.asm`, near the existing `SYS_ENTRY SYS_SYS_*` block (around line 118), add: + +```nasm +SYS_ENTRY SYS_SYS_SIGNAL, .sys_signal +SYS_ENTRY SYS_SYS_SIGRETURN, .sys_sigreturn +``` + +- [ ] **Step 2: Implement .sys_signal (DFL/IGN/user-virt validation)** + +Add the handler in alphabetical position among `.sys_*` labels: + +```nasm + ;; SYS_SYS_SIGNAL: register a signal handler. + ;; In: EBX = signum (must be SIGINT) + ;; ECX = handler — SIG_DFL (0), SIG_IGN (1), or user-virt + ;; address (PROGRAM_BASE ≤ ECX < KERNEL_VIRT_BASE). + ;; Out: EAX = previous handler value, CF clear on success. + ;; CF set + AL = ERROR_INVALID on bad signum or out-of-range + ;; handler address. + ;; Phase 3 only validates DFL / IGN; the user-virt branch is + ;; allowed but signal_dispatch_user is a stub until Task 11. + .sys_signal: + cmp ebx, SIGINT + jne .sys_signal_bad + cmp ecx, SIG_IGN + jbe .sys_signal_ok ; ECX in {0, 1} + cmp ecx, PROGRAM_BASE + jb .sys_signal_bad + cmp ecx, KERNEL_VIRT_BASE + jae .sys_signal_bad +.sys_signal_ok: + mov eax, [sigint_handler] ; previous handler -> EAX + mov [sigint_handler], ecx + jmp .iret_no_cf_eax ; or whichever path preserves full EAX +.sys_signal_bad: + mov al, ERROR_INVALID + jmp .iret_cf +``` + +If `ERROR_INVALID` does not exist, add it to `constants.asm` (alphabetical insertion in the `ERROR_*` block) using the next free number. If `.iret_no_cf_eax` does not exist, use `.iret_cf_eax` with a `clc` predecessor or whichever existing exit preserves the full 32-bit EAX without sign-extending AX. + +- [ ] **Step 3: Build and verify clean compile** + +Run: `./make_os.sh` +Expected: builds clean. `.sys_sigreturn` not yet defined — add a temporary stub: + +```nasm + .sys_sigreturn: + jmp .iret_cf ; Phase 4 fills this in +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/arch/x86/syscall.asm src/include/constants.asm +git commit -m "kernel: SYS_SYS_SIGNAL — DFL/IGN/user-virt registration" +``` + +--- + +### Task 8: libc signal() wrapper + +**Files:** +- Create: `tools/libc/include/signal.h` +- Create: `tools/libc/signal.c` +- Modify: `tools/libc/Makefile` + +- [ ] **Step 1: Create the header** + +Create `tools/libc/include/signal.h`: + +```c +#ifndef BBOEOS_LIBC_SIGNAL_H +#define BBOEOS_LIBC_SIGNAL_H + +typedef void (*sighandler_t)(int); +typedef volatile int sig_atomic_t; + +#define SIG_DFL ((sighandler_t)0) +#define SIG_IGN ((sighandler_t)1) +#define SIG_ERR ((sighandler_t)-1) + +#define SIGINT 2 + +sighandler_t signal(int signum, sighandler_t handler); + +#endif +``` + +- [ ] **Step 2: Create the wrapper source** + +Create `tools/libc/signal.c`: + +```c +#include + +#include "include/errno.h" + +sighandler_t signal(int signum, sighandler_t handler) { + unsigned int eax_out; + unsigned int cf; + __asm__ volatile ( + "mov %[handler], %%ecx\n\t" + "mov %[signum], %%ebx\n\t" + "mov $0xF5, %%ah\n\t" /* SYS_SYS_SIGNAL */ + "int $0x30\n\t" + "setc %b[cf]\n\t" + : "=a"(eax_out), [cf]"=&q"(cf) + : [signum]"g"((unsigned int)signum), + [handler]"g"((unsigned int)handler) + : "ebx", "ecx"); + if (cf & 1) { + errno = EINVAL; + return SIG_ERR; + } + return (sighandler_t)eax_out; +} +``` + +- [ ] **Step 3: Add to Makefile** + +In `tools/libc/Makefile`, append `signal.c` to `C_SRCS` (alphabetical): + +```makefile +C_SRCS := builtins.c ctype.c errno.c math.c signal.c stdio.c stdlib.c string.c syscall.c +``` + +- [ ] **Step 4: Build libc and verify** + +```bash +cd tools/libc && make clean && make +``` +Expected: `libbboeos.a` builds with no errors. + +- [ ] **Step 5: Commit** + +```bash +git add tools/libc/include/signal.h tools/libc/signal.c tools/libc/Makefile +git commit -m "libc: signal() wrapper around SYS_SYS_SIGNAL" +``` + +--- + +### Task 9: Shell installs SIG_IGN + +**Files:** +- Modify: `src/c/shell.c` + +- [ ] **Step 1: Find the shell startup** + +Run: `grep -n "int main\|^int main\|FUNCTION_PRINT\|set_exec_arg" src/c/shell.c | head` +Identify the shell's `main` (or equivalent entry). + +- [ ] **Step 2: Add the signal install** + +Near the top of the shell's main, add a SYS_SYS_SIGNAL call. Because the shell is a userland program built with `cc.py` (not the libc shim), call the syscall directly via inline asm, mirroring how other shell syscalls are done: + +```c +// Ignore SIGINT — the shell prefers to keep its line editor alive when +// the user types Ctrl+C at the prompt. Cooked 0x03 still arrives in +// the byte stream so the line editor can choose to display ^C and +// reset its input buffer; without SIG_IGN, the kernel-side default is +// to kill the program (which here would mean reloading the shell). +int previous_handler; +asm("mov ebx, 2\n" // SIGINT + "mov ecx, 1\n" // SIG_IGN + "mov ah, 0xF5\n" // SYS_SYS_SIGNAL + "int 0x30\n" + "mov [ebp-4], eax\n" // stash previous_handler (cc.py local) + : : : "eax", "ebx", "ecx"); +(void)previous_handler; +``` + +If the shell is structured differently (no `int main` but a different entry point), add the call wherever the existing initialisation sits. + +- [ ] **Step 3: Build and verify** + +Run: `./make_os.sh` +Expected: clean build. + +- [ ] **Step 4: Manual verification — shell ignores Ctrl+C** + +Boot in QEMU. At the shell prompt with no child running, press Ctrl+C. **Expected:** shell continues running; no `^C` kill banner; prompt remains active. + +Then exec a child (e.g. `cat` or `ls`); while it's running, Ctrl+C. **Expected:** child dies, shell prompt returns. (Children run with `SIG_DFL` because `program_enter` resets the slot.) + +- [ ] **Step 5: Commit** + +```bash +git add src/c/shell.c +git commit -m "shell: install SIG_IGN at startup so its own Ctrl+C is benign" +``` + +--- + +## Phase 4 — Real handler delivery (sigcontext + sigreturn) + +### Task 10: vDSO trampoline + +**Files:** +- Modify: `src/arch/x86/entry.asm` + +- [ ] **Step 1: Find the vDSO page initialisation** + +Run: `grep -n "vDSO\|VDSO\|FUNCTION_TABLE\|0x10000" src/arch/x86/entry.asm` +Identify where the shared vDSO page is allocated and its function table populated. + +- [ ] **Step 2: Write the trampoline at VDSO_VIRT + VDSO_SIGRETURN_OFFSET** + +After the existing function-table writes, add (kernel writes 7 bytes of code into the vDSO page): + +```nasm + ;; vDSO sigreturn trampoline. Sigreturn handlers `ret` into here + ;; and we immediately re-enter the kernel via SYS_SYS_SIGRETURN. + ;; Bytes: + ;; B8 F6 00 00 00 mov eax, 0x000000F6 + ;; CD 30 int 0x30 + mov edi, [vdso_page_kernel_virt] + add edi, VDSO_SIGRETURN_OFFSET + mov byte [edi + 0], 0xB8 ; mov eax, imm32 + mov dword [edi + 1], SYS_SYS_SIGRETURN + mov word [edi + 5], 0x30CD ; int 0x30 +``` + +The actual variable name holding the vDSO page kernel address (`vdso_page_kernel_virt` above) needs to be substituted with whatever the existing init code uses — read the surrounding lines to pick the right symbol. + +The `mov eax, imm32` form (5 bytes) instead of `mov ah, 0xF6` (2 bytes) is used so the full SYSCALL number occupies AH; for AH-only this would be `B4 F6 CD 30` (4 bytes). Either works; the 7-byte form keeps it explicit. If using the AH form, adjust the offset arithmetic. + +- [ ] **Step 3: Build and verify clean compile** + +Run: `./make_os.sh` +Expected: builds clean. The trampoline is reachable from user-virt 0x10100 but no caller yet. + +- [ ] **Step 4: Commit** + +```bash +git add src/arch/x86/entry.asm +git commit -m "kernel: vDSO sigreturn trampoline at VDSO_VIRT + 0x100" +``` + +--- + +### Task 11: signal_dispatch_user — build sigcontext + redirect IRET + +**Files:** +- Modify: `src/arch/x86/signal.c` +- Modify: `src/arch/x86/entry.asm` (the `SIGINT_TAIL_CHECK` macro's `%%user_handler` branch) + +- [ ] **Step 1: Implement signal_dispatch_user** + +In `src/arch/x86/signal.c`, add `signal_dispatch_user`. It runs in kernel context immediately after the IRQ/syscall handler's `popad` but before `iretd`. Convention: caller jumps here (no return — we rewrite the iret frame and `iretd` ourselves). + +The iret frame at `[esp]` is: `EIP, CS, EFLAGS, ESP, SS` (5 dwords pushed by the CPU when transitioning user→kernel). + +The dispatch: +1. Read `user_esp = [esp + 12]`. +2. Subtract 48 from it. +3. Build 12 dwords at `[user_esp_new..user_esp_new+44]`: + - +0 trampoline_addr (= `VDSO_VIRT + VDSO_SIGRETURN_OFFSET`) + - +4 signum (= SIGINT = 2) + - +8 saved_eip (from iret frame +0) + - +12 saved_eflags (from iret frame +8) + - +16 saved_esp (the original user ESP, before the new -48) + - +20..+44 saved_eax..edi (in pushad order, minus the ESP slot — these came from the pushad we just popped, so we need them to have been preserved; see Step 2) +4. Rewrite iret frame: `EIP ← sigint_handler`, `ESP ← user_esp_new`. +5. Set `in_sigint_handler = 1`, `pending_sigint = 0`. +6. `iretd`. + +Critical detail: the macro pops pushad before invoking the dispatch, which destroys the saved registers. Restructure: the dispatch takes its own snapshot. Easiest approach — inline the snapshot in the macro itself. + +Revise `SIGINT_TAIL_CHECK` (Task 5) so that the user-handler branch jumps **before** popad, with pushad slots still on stack: + +```nasm +%macro SIGINT_TAIL_CHECK 0 + ;; pushad still live on entry (caller hasn't popad'd yet). + cmp word [esp + 32 + 4], USER_CODE_SELECTOR ; iret CS = pushad(8 dwords) + iret EIP + jne %%no_dispatch + cmp byte [pending_sigint], 0 + je %%no_dispatch + cmp byte [in_sigint_handler], 0 + jne %%no_dispatch + mov eax, [sigint_handler] + cmp eax, SIG_DFL + je signal_dispatch_kill + cmp eax, SIG_IGN + jne %%user_handler_dispatch + mov byte [pending_sigint], 0 + jmp %%no_dispatch +%%user_handler_dispatch: + jmp signal_dispatch_user ; never returns to here +%%no_dispatch: + popad +%endmacro +``` + +Then update every IRQ + syscall handler to NOT call `popad` themselves before the macro — the macro now owns the popad. + +For the IRQ 0 example: + +```nasm +pmode_irq0_handler: + pushad + inc dword [system_ticks] + call midi_drain_due + mov al, PIC_EOI + out PIC1_CMD_PORT, al + SIGINT_TAIL_CHECK + iretd +``` + +The pushad slots are at `[esp + 0..28]` (8 dwords); iret frame starts at `[esp + 32]`. The CS check uses `[esp + 32 + 4]`, EIP at `[esp + 32 + 0]`, EFLAGS at `[esp + 32 + 8]`, ESP at `[esp + 32 + 12]`. + +Now the dispatch (in signal.c) can read pushad slots and iret-frame slots in one shot: + +```c +void signal_dispatch_user(); +asm("signal_dispatch_user:\n" + // ESP points at: pushad(8 dwords, EDI..EAX in popad order) + iret(EIP, CS, EFLAGS, ESP, SS) + // Pushad layout (offset from esp): EDI=0, ESI=4, EBP=8, ESP_unused=12, EBX=16, EDX=20, ECX=24, EAX=28 + // iret layout: EIP=32, CS=36, EFLAGS=40, ESP=44, SS=48 + " mov edi, [esp + 44]\n" // user ESP + " sub edi, 48\n" // make room for sigcontext + // Write sigcontext at [edi + 0..44] + " mov dword [edi + 0], VDSO_VIRT + VDSO_SIGRETURN_OFFSET\n" + " mov dword [edi + 4], SIGINT\n" + " mov eax, [esp + 32]\n" // saved EIP + " mov [edi + 8], eax\n" + " mov eax, [esp + 40]\n" // saved EFLAGS + " mov [edi + 12], eax\n" + " mov eax, [esp + 44]\n" // saved ESP (original) + " mov [edi + 16], eax\n" + // Saved registers (pushad order minus ESP slot) + " mov eax, [esp + 28]\n" // EAX + " mov [edi + 20], eax\n" + " mov eax, [esp + 24]\n" // ECX + " mov [edi + 24], eax\n" + " mov eax, [esp + 20]\n" // EDX + " mov [edi + 28], eax\n" + " mov eax, [esp + 16]\n" // EBX + " mov [edi + 32], eax\n" + " mov eax, [esp + 8]\n" // EBP + " mov [edi + 36], eax\n" + " mov eax, [esp + 4]\n" // ESI + " mov [edi + 40], eax\n" + " mov eax, [esp + 0]\n" // EDI + " mov [edi + 44], eax\n" + // Rewrite iret frame: EIP ← handler, ESP ← edi + " mov eax, [_g_sigint_handler]\n" + " mov [esp + 32], eax\n" + " mov [esp + 44], edi\n" + // Mark in-handler, clear pending + " mov byte [_g_in_sigint_handler], 1\n" + " mov byte [_g_pending_sigint], 0\n" + // Skip popad — its values are already captured in sigcontext. + // ESP needs to advance past pushad slots before iretd. + " add esp, 32\n" + " iretd\n"); +``` + +The kernel writes to user-stack memory at `[edi]`. If the user stack is exhausted (page guarded), the write triggers `#PF` which the existing `exc_common` handler routes to `EXC0D` — the program dies. This matches the spec's stack-overflow note. + +- [ ] **Step 2: Build and verify clean compile** + +Run: `./make_os.sh` +Expected: builds clean. + +- [ ] **Step 3: Commit** + +```bash +git add src/arch/x86/signal.c src/arch/x86/entry.asm +git commit -m "kernel: signal_dispatch_user — sigcontext build + iret-frame rewrite" +``` + +--- + +### Task 12: SYS_SYS_SIGRETURN — restore sigcontext + +**Files:** +- Modify: `src/arch/x86/syscall.asm` +- Modify: `src/arch/x86/signal.c` + +- [ ] **Step 1: Implement signal_resume_after_handler** + +Add to `src/arch/x86/signal.c`: + +```c +void signal_resume_after_handler(); + +// Reads sigcontext from user stack at [user_esp + 4] (the trampoline's +// `int 0x30` left ESP one dword past the original sigcontext start +// because the handler's `ret` popped the trampoline_addr). Validates +// saved_eip / saved_esp are in user range. On valid: rewrites the +// syscall iret frame to resume at saved_eip with saved_esp / saved_eflags +// / saved_eax..edi. On invalid: jump to signal_dispatch_kill. +// +// ESP at entry points at the syscall's pushad-and-iret frame: +// pushad EDI..EAX = [esp + 0..28] +// iret EIP / CS / EFLAGS / ESP / SS = [esp + 32..48] +asm("signal_resume_after_handler:\n" + " mov edi, [esp + 44]\n" // user ESP at trampoline entry + " add edi, 4\n" // skip the popped trampoline_addr → start of saved registers + // Validate saved_eip + " mov eax, [edi + 0]\n" // saved EIP + " cmp eax, PROGRAM_BASE\n" + " jb signal_dispatch_kill\n" + " cmp eax, KERNEL_VIRT_BASE\n" + " jae signal_dispatch_kill\n" + // Validate saved_esp + " mov eax, [edi + 8]\n" // saved ESP + " cmp eax, PROGRAM_BASE\n" + " jb signal_dispatch_kill\n" + " cmp eax, KERNEL_VIRT_BASE\n" + " ja signal_dispatch_kill\n" // == KERNEL_VIRT_BASE OK (USER_STACK_TOP) + // Restore iret frame + " mov eax, [edi + 0]\n" // saved EIP + " mov [esp + 32], eax\n" + " mov eax, [edi + 4]\n" // saved EFLAGS + " mov [esp + 40], eax\n" + " mov eax, [edi + 8]\n" // saved ESP + " mov [esp + 44], eax\n" + // Restore pushad slots + " mov eax, [edi + 12]\n" // saved EAX + " mov [esp + 28], eax\n" + " mov eax, [edi + 16]\n" // saved ECX + " mov [esp + 24], eax\n" + " mov eax, [edi + 20]\n" // saved EDX + " mov [esp + 20], eax\n" + " mov eax, [edi + 24]\n" // saved EBX + " mov [esp + 16], eax\n" + " mov eax, [edi + 28]\n" // saved EBP + " mov [esp + 8], eax\n" + " mov eax, [edi + 32]\n" // saved ESI + " mov [esp + 4], eax\n" + " mov eax, [edi + 36]\n" // saved EDI + " mov [esp + 0], eax\n" + // Clear in-handler; re-check pending_sigint (a Ctrl+C arriving + // during the handler should fire immediately on resume). + " mov byte [_g_in_sigint_handler], 0\n" + " cmp byte [_g_pending_sigint], 0\n" + " je .signal_resume_no_pending\n" + // Pending SIGINT — re-dispatch instead of returning to user code. + " mov eax, [_g_sigint_handler]\n" + " cmp eax, SIG_DFL\n" + " je signal_dispatch_kill\n" + " cmp eax, SIG_IGN\n" + " jne signal_dispatch_user\n" + " mov byte [_g_pending_sigint], 0\n" + ".signal_resume_no_pending:\n" + // popad + iretd + " popad\n" + " iretd\n"); +``` + +The `EDI` index dword offsets above assume sigcontext layout: +- [edi + 0..40] = saved registers (eip, eflags, esp, eax, ecx, edx, ebx, ebp, esi, edi) — 10 dwords = 40 bytes after the trampoline pop. + +This matches the layout written by `signal_dispatch_user` from offset +8 onward (which becomes +0 here after the trampoline-addr pop). + +- [ ] **Step 2: Wire .sys_sigreturn to call signal_resume_after_handler** + +Replace the stub `.sys_sigreturn` from Task 7 in `src/arch/x86/syscall.asm`: + +```nasm + .sys_sigreturn: + jmp signal_resume_after_handler ; never returns through .iret_* +``` + +- [ ] **Step 3: Build and verify clean compile** + +Run: `./make_os.sh` +Expected: builds clean. + +- [ ] **Step 4: Manual verification — handler runs and resumes** + +Write a small test program (asm or C via cc.py): + +```asm +; static/sigtest.asm +%include "constants.asm" + org PROGRAM_BASE +.entry: + ;; signal(SIGINT, on_sigint) + mov ebx, SIGINT + mov ecx, on_sigint + mov ah, SYS_SYS_SIGNAL + int 0x30 +.spin: + cmp dword [caught_flag], 0 + je .spin + ;; print "GOT IT\n" via FUNCTION_PRINT_STRING then exit + mov esi, msg + call [VDSO_VIRT + FUNCTION_PRINT_STRING] + mov ah, SYS_SYS_EXIT + int 0x30 +on_sigint: + mov dword [caught_flag], 1 + ret +caught_flag dd 0 +msg db "GOT IT", 10, 0 +``` + +Add to image, boot QEMU, run sigtest, press Ctrl+C. **Expected:** "GOT IT" prints, then shell prompt returns. + +If "GOT IT" does NOT print (handler never ran) or system hangs (sigreturn broken), the dispatch / restore logic needs debugging. Use `-serial stdio` to capture both screen and serial output for triage. + +- [ ] **Step 5: Commit** + +```bash +git add src/arch/x86/signal.c src/arch/x86/syscall.asm +git commit -m "kernel: SYS_SYS_SIGRETURN — restore sigcontext, redeliver pending SIGINT" +``` + +--- + +## Phase 5 — Cooperative interruption + +### Task 13: fd_read_console checks pending_sigint + detects serial 0x03 + +**Files:** +- Modify: `src/fs/fd/console.c` + +- [ ] **Step 1: Add the kernel-side flag declaration** + +Near the existing `extern` declarations at the top of `src/fs/fd/console.c`: + +```c +extern uint8_t pending_sigint; +``` + +- [ ] **Step 2: Hook the wait loop and serial detection** + +Modify `fd_read_console` (around line 131-148): + +```c +__attribute__((carry_return)) +int fd_read_console(int *bytes_read __attribute__((out_register("ax"))), + uint8_t *destination __attribute__((in_register("edi"))), + int max_bytes __attribute__((in_register("ecx")))) { + char byte; + if (max_bytes == 0) { + *bytes_read = 0; + return 1; + } + asm("sti"); + while (1) { + if (pending_sigint) { + // Cooperative interrupt: bail with EINTR. The syscall + // epilogue's tail check will dispatch the signal on iret. + *bytes_read = ERROR_INTERRUPTED; + return 0; // CF set via carry_return convention + } + byte = ps2_getc(); + if (byte != '\0') { + break; + } + if ((kernel_inb(0x3FD) & 0x01) != 0) { + byte = kernel_inb(0x3F8); + if (byte == 0x03) { + // Serial Ctrl+C — set the flag so the next IRQ epilogue + // (or this same syscall's epilogue, after we return) + // delivers. The byte is also returned in the buffer + // so programs that ignore SIGINT (shell with SIG_IGN) + // see it as a normal cooked input. + pending_sigint = 1; + } + break; + } + } + if (byte == '\r') { + byte = '\n'; + } + destination[0] = byte; + *bytes_read = 1; + return 1; +} +``` + +Verify the `carry_return` convention by reading `cc.py`'s docs or other functions using it (e.g., `fd_write_midi` in `src/fs/fd/midi.c`). If the convention is `return 0 → CF=1`, the above is correct; if inverted, swap the `return 0`/`return 1`. + +- [ ] **Step 3: Build and verify clean compile** + +Run: `./make_os.sh` +Expected: clean build. + +- [ ] **Step 4: Manual verification** + +Boot in QEMU with `-serial stdio`. From the serial console, type `cat` (or any program that reads stdin), then press Ctrl+C in the terminal. **Expected:** the read returns EINTR (cat sees 0 bytes / -1), then the syscall epilogue's tail check fires SIGINT default-kill, shell prompt returns. + +- [ ] **Step 5: Commit** + +```bash +git add src/fs/fd/console.c +git commit -m "fs/fd/console: cooperative SIGINT bail in read; detect serial 0x03" +``` + +--- + +### Task 14: MIDI_IOCTL_DRAIN checks pending_sigint + +**Files:** +- Modify: `src/fs/fd/midi.c` + +- [ ] **Step 1: Add the cooperative check to the drain wait loop** + +In `src/fs/fd/midi.c`, modify the inline asm `.fd_ioctl_midi_drain_wait` block: + +```c +asm("...\n" + ".fd_ioctl_midi_drain_wait:\n" + " cli\n" + " cmp byte [_g_pending_sigint], 0\n" + " jne .fd_ioctl_midi_drain_eintr\n" + " mov al, [_g_midi_head]\n" + " cmp al, [_g_midi_tail]\n" + " je .fd_ioctl_midi_drain_done\n" + " sti\n" + " hlt\n" + " jmp .fd_ioctl_midi_drain_wait\n" + ".fd_ioctl_midi_drain_done:\n" + " sti\n" + " xor eax, eax\n" + " clc\n" + " ret\n" + ".fd_ioctl_midi_drain_eintr:\n" + " sti\n" + " mov al, ERROR_INTERRUPTED\n" + " stc\n" + " ret\n" + "..."); +``` + +Apply this as an `Edit` to the existing `asm(...)` block in `fd_ioctl_midi`. Keep the surrounding structure intact. + +Add the `pending_sigint` extern at the top of the file if not already present: + +```c +extern uint8_t pending_sigint; +``` + +- [ ] **Step 2: Build and verify** + +Run: `./make_os.sh` +Expected: clean build. + +- [ ] **Step 3: Manual verification** + +Run a program (e.g. doom or a small test) that opens `/dev/midi`, queues commands with delays, then calls `MIDI_IOCTL_DRAIN`. While drain is blocked, press Ctrl+C. **Expected:** drain returns -1/EINTR and the SIGINT epilogue check fires. + +- [ ] **Step 4: Commit** + +```bash +git add src/fs/fd/midi.c +git commit -m "fs/fd/midi: cooperative SIGINT bail in MIDI_IOCTL_DRAIN sti+hlt loop" +``` + +--- + +### Task 15: libc EINTR translation in syscall wrappers + +**Files:** +- Modify: `tools/libc/syscall.c` + +- [ ] **Step 1: Locate _errno_from_al** + +Run: `grep -n "_errno_from_al" tools/libc/syscall.c` + +- [ ] **Step 2: Add the ERROR_INTERRUPTED → EINTR mapping** + +In the `_errno_from_al` switch / table, add a case for `0x08` returning `EINTR`. Update the comment block at the top to include the new mapping: + +```c + * 08h ERROR_INTERRUPTED -> EINTR +``` + +The existing wrappers (`read`, `ioctl`, `open`, etc.) all funnel CF=1 through `_errno_from_al`, so they pick up EINTR translation automatically — no per-wrapper change needed. + +- [ ] **Step 3: Build libc and verify** + +```bash +cd tools/libc && make clean && make +``` +Expected: clean build. + +- [ ] **Step 4: Commit** + +```bash +git add tools/libc/syscall.c +git commit -m "libc: map ERROR_INTERRUPTED (0x08) -> EINTR in syscall wrappers" +``` + +--- + +## Phase 6 — Tests + docs + +### Task 16: Automated handler test + +**Files:** +- Create: `tests/sigint_handler_test.c` +- Modify: `tests/test_programs.py` + +- [ ] **Step 1: Write the test program** + +Create `tests/sigint_handler_test.c`: + +```c +#include +#include +#include + +volatile sig_atomic_t got_sigint = 0; + +void on_sigint(int signum) { + (void)signum; + got_sigint = 1; +} + +int main(void) { + char byte; + signal(SIGINT, on_sigint); + write(1, "READY\n", 6); /* test driver waits for this */ + /* Read from stdin until either Ctrl+C is delivered (handler sets + * got_sigint and the read returns -1/EINTR) or 'q' arrives. */ + while (!got_sigint) { + if (read(0, &byte, 1) > 0 && byte == 'q') { + break; + } + /* On EINTR, read returns -1 and we loop back; the handler has + * set got_sigint by now. */ + } + if (got_sigint) { + write(1, "CAUGHT\n", 7); + } else { + write(1, "QUIT\n", 5); + } + return 0; +} +``` + +Add to disk image. The exact mechanism depends on how `tests/test_programs.py` builds tests. Read the existing test entries for a C-based program (e.g. anything that uses libc) and mirror its build steps. + +- [ ] **Step 2: Add the test_programs.py entry** + +In `tests/test_programs.py`, add an entry (alphabetically sorted with existing entries): + +```python + ("sigint_handler", { + "filesystems": {"bbfs", "ext2"}, + "run_commands": [ + ("sigint_handler", "READY", "\x03", "CAUGHT"), + ], + # The "\x03" string is sent on the serial fifo after seeing + # "READY"; the test driver must support this multi-step pattern. + # If the existing fixture only supports one input + one output, + # extend it minimally rather than over-engineering. + }), +``` + +The exact dict shape depends on `tests/test_programs.py`'s framework — read other entries first to copy the pattern. The driver must: +1. Wait for the `READY` marker on serial output. +2. Send the byte `0x03` on serial input. +3. Wait for `CAUGHT` on serial output. +4. Wait for the shell prompt `$ ` to confirm clean return. + +If the test driver doesn't support sending raw bytes mid-stream, add a small helper (~10 lines) before adding this test. + +- [ ] **Step 3: Run the test** + +```bash +./tests/test_programs.py sigint_handler +``` +Expected: passes. + +- [ ] **Step 4: Run the full suite to verify no regressions** + +```bash +./tests/test_programs.py +./tests/test_asm.py +./tests/test_bboefs.py +``` +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add tests/sigint_handler_test.c tests/test_programs.py +git commit -m "tests: SIGINT handler delivery + sigreturn end-to-end" +``` + +--- + +### Task 17: Documentation + +**Files:** +- Modify: `docs/syscalls.md` +- Modify: `docs/architecture.md` +- Modify: `docs/CHANGELOG.md` + +- [ ] **Step 1: Add syscall entries** + +In `docs/syscalls.md`, add to the main table (in numeric order, after `F4h sys_shutdown`): + +```markdown +| F5h | sys_signal | Register SIGINT handler, EBX = signum (SIGINT only), ECX = handler (SIG_DFL=0, SIG_IGN=1, or user-virt addr); EAX = previous handler, CF on bad signum/addr | +| F6h | sys_sigreturn| Restore sigcontext from user stack; never returns normally — resumes the saved EIP | +``` + +Add an "Error codes" subsection if one doesn't exist, listing `ERROR_INTERRUPTED` (08h, mapped to `EINTR` in libc). + +- [ ] **Step 2: Document SIGINT in architecture.md** + +Add a short subsection to `docs/architecture.md` describing: +- The two-axis model (detection in IRQ context, delivery at IRET epilogue). +- `SIG_DFL` / `SIG_IGN` / user-handler dispatch. +- Cooperative-interruption convention (`pending_sigint` check in `sti+hlt` loops returning `ERROR_INTERRUPTED`). +- Limitation: serial-only Ctrl+C does not kill runaway programs (PS/2 only); future work is IRQ-driven serial input. + +Aim for ~60-100 lines. + +- [ ] **Step 3: CHANGELOG entry** + +Add to `docs/CHANGELOG.md` under Unreleased: + +```markdown +### Added +- SIGINT handling: Ctrl+C on PS/2 kills runaway user programs by + default; programs may register a handler via `signal(SIGINT, ...)` + with sigcontext-on-stack delivery and `SYS_SYS_SIGRETURN` resume. + Cooperative-interruption convention added to `fd_read_console` and + `MIDI_IOCTL_DRAIN`. See `docs/architecture.md` for the model and + `docs/superpowers/specs/2026-05-06-sigint-handling-design.md` for + the full design. +``` + +- [ ] **Step 4: Commit** + +```bash +git add docs/syscalls.md docs/architecture.md docs/CHANGELOG.md +git commit -m "docs: SIGINT handling — syscalls, architecture, changelog" +``` + +--- + +## Self-review + +This plan covers the spec sections as follows: + +| Spec section | Tasks | +|---|---| +| Architecture | Tasks 1–17 (entire plan) | +| Kernel-side state | 2 | +| Detection (PS/2) | 4 | +| Detection (serial) | 13 | +| Sigcontext layout | 11 | +| IRET-frame rewrite locations | 5, 6 | +| Re-entry | 11 (`in_sigint_handler` set), 12 (cleared by sigreturn) | +| Stack overflow | covered by existing `exc_common` (no new code) | +| Sigreturn (vDSO trampoline) | 10 | +| Sigreturn (syscall) | 12 | +| Cooperative interruption (`fd_read_console`) | 13 | +| Cooperative interruption (`MIDI_IOCTL_DRAIN`) | 14 | +| Default kill path | 3 | +| Shell integration | 9 | +| API surface (asm constants) | 1 | +| API surface (`SYS_SYS_SIGNAL`) | 7 (DFL/IGN), 7 again validates user-virt range | +| API surface (`SYS_SYS_SIGRETURN`) | 12 | +| libc surface (`signal.h` + wrapper) | 8 | +| libc EINTR translation | 15 | +| Handler reentrancy expectations | docs only — covered in 17 | +| Testing (manual) | 5, 6, 9, 12, 13, 14 (each task includes a manual-verify step) | +| Testing (automated) | 16 | + +No spec requirement is unaddressed. + +**Type / signature consistency check:** +- `pending_sigint`, `in_sigint_handler` — `db` (1 byte), checked as `byte`. +- `sigint_handler` — `dd` (4 bytes), checked as `dword`. +- `signal_dispatch_kill` — never returns, no args, kernel-context only. +- `signal_dispatch_user` — never returns to caller (rewrites iret + iretd). +- `signal_resume_after_handler` — never returns to caller. +- `SYS_SYS_SIGNAL` register contract: `EBX=signum, ECX=handler, EAX=prev`. +- `SYS_SYS_SIGRETURN`: no in args; reads from user stack at `[user_esp + 4]`. +- `ERROR_INTERRUPTED = 0x08`, maps to `EINTR = 4`. +- vDSO trampoline at `VDSO_VIRT + VDSO_SIGRETURN_OFFSET` (`= 0x10100`). + +All names consistent across tasks. + +**Placeholder scan:** Every step has either runnable code, an exact command, or a `grep -n` lookup with explicit follow-up instructions. + +**Open assumptions** (worth flagging during execution): +- `cc.py`'s `__attribute__((carry_return))` exact convention — verified by reading existing users in `src/fs/fd/midi.c` before writing Task 13. +- `tests/test_programs.py` test-driver dict shape and serial-fifo input plumbing — verified by reading existing entries before writing Task 16. +- `USER_CODE_SELECTOR` exact symbolic name in `constants.asm` or `entry.asm` — verified by `grep` before writing Task 5. +- Whether `entry.asm` and `syscall.asm` are assembled together so the `SIGINT_TAIL_CHECK` macro is visible across files — verified by reading `make_os.sh` before writing Task 6. + +If any assumption is wrong, the affected task gets a single follow-up edit; no architectural change. From 53eb1ce77c1f9332d1f5f8a113859519bea56381 Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Wed, 6 May 2026 23:54:59 -0700 Subject: [PATCH 03/22] kernel+libc: SIGINT/SYS_SIGNAL constants + EINTR/ERROR_INTERRUPTED --- src/include/constants.asm | 11 +++++++++++ tools/libc/include/errno.h | 1 + 2 files changed, 12 insertions(+) diff --git a/src/include/constants.asm b/src/include/constants.asm index 894bcaf5..4a9c6b15 100644 --- a/src/include/constants.asm +++ b/src/include/constants.asm @@ -13,6 +13,7 @@ %assign ERROR_DIRECTORY_FULL 01h ; Copy error: no free directory entries %assign ERROR_EXISTS 02h ; Rename/copy error: destination name already exists %assign ERROR_FAULT 07h ; Bad user pointer: out of user range, wraps, or filename has no NUL within MAX_PATH + %assign ERROR_INTERRUPTED 08h ; Cooperative-interrupt return (SIGINT) — maps to EINTR in libc %assign ERROR_NOT_EMPTY 06h ; Rmdir error: directory is not empty %assign ERROR_NOT_EXECUTE 03h ; Exec error: file exists but is not executable %assign ERROR_NOT_FOUND 04h ; File not found @@ -72,6 +73,7 @@ %assign FUNCTION_PRINT_STRING FUNCTION_TABLE + 55 ; DI=null-terminated string: write to stdout %assign FUNCTION_PRINTF FUNCTION_TABLE + 60 ; cdecl: push args R-to-L, push fmt, call %assign FUNCTION_WRITE_STDOUT FUNCTION_TABLE + 65 ; SI=buf, CX=len: write to stdout + %assign VDSO_SIGRETURN_OFFSET 0100h ; trampoline lives at VDSO_VIRT + 0x100 %assign IPPROTO_ICMP 1 ; Protocol argument to net_open for SOCK_DGRAM ICMP sockets %assign IPPROTO_UDP 17 ; Protocol argument to net_open for SOCK_DGRAM UDP sockets %assign KERNEL_VIRT_BASE 0FF800000h ; Lowest kernel-virt address. User pointers + lengths must stay strictly below this; idt.asm's user-fault triage and access_ok both gate on it. Equals USER_STACK_TOP and DIRECT_MAP_BASE — all three move in lockstep. @@ -154,6 +156,15 @@ %assign SYS_SYS_EXIT 0F2h %assign SYS_SYS_REBOOT 0F3h %assign SYS_SYS_SHUTDOWN 0F4h + %assign SYS_SYS_SIGNAL 0F5h ; EBX = signum (SIGINT only); ECX = handler (SIG_DFL/SIG_IGN/user-virt); EAX = previous handler; CF on bad signum / handler + %assign SYS_SYS_SIGRETURN 0F6h ; restore from sigcontext on user stack; never returns to caller + + ;; Signal numbers (POSIX-numbered). Currently only SIGINT is delivered. + %assign SIGINT 2 + + ;; signal() handler sentinels (POSIX-valued). + %assign SIG_DFL 0 + %assign SIG_IGN 1 %assign TSS_SELECTOR 28h ; GDT[5]: 32-bit available TSS, DPL=0 %assign USER_CODE_SELECTOR 1Bh ; GDT[3] | RPL=3: ring-3 code segment (flat 4 GB) diff --git a/tools/libc/include/errno.h b/tools/libc/include/errno.h index a937fa20..f3efc1c8 100644 --- a/tools/libc/include/errno.h +++ b/tools/libc/include/errno.h @@ -8,6 +8,7 @@ extern int errno; * syscall.c translate ERROR_* to the matching E* constant here. */ #define EPERM 1 #define ENOENT 2 +#define EINTR 4 #define EIO 5 #define EBADF 9 #define ENOMEM 12 From 6855cac862fe57d784a16538a453c60b3e51cac6 Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 00:02:33 -0700 Subject: [PATCH 04/22] kernel: per-program SIGINT state in BSS, reset by program_enter --- src/arch/x86/entry.asm | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/arch/x86/entry.asm b/src/arch/x86/entry.asm index 8f00e62a..b20f309c 100644 --- a/src/arch/x86/entry.asm +++ b/src/arch/x86/entry.asm @@ -365,6 +365,12 @@ program_enter: mov [current_program_break], eax mov [current_program_break_min], eax + ;; Reset SIGINT state — every new program starts in SIG_DFL with + ;; no pending signal and no handler frame on its stack. + mov dword [sigint_handler], SIG_DFL + mov byte [pending_sigint], 0 + mov byte [in_sigint_handler], 0 + ;; --- Phase 2: BSS-only pages (zero-filled, no disk reads) --- ;; virt_cursor was left at page_align_up(PROGRAM_BASE + binsize) ;; by Phase 1; loop until user_image_end. @@ -741,6 +747,16 @@ user_image_end dd 0 ; PROGRAM_BASE + binsize + bsssize, page-aligned virt_cursor dd 0 ; current user-virt during page-walk loops vdso_code_phys dd 0 ; phys of the shared vDSO code frame + ;; SIGINT delivery state. One global slot suffices because only one + ;; user program runs at a time — program_enter zeroes the lot on every + ;; load so it behaves as if it were per-program. sigint_handler is a + ;; user-virt address (or SIG_DFL=0 / SIG_IGN=1); the address is only + ;; valid in the active PD, hence the zero-on-transition rule. +sigint_handler dd 0 +pending_sigint db 0 +in_sigint_handler db 0 + align 4 + ;; OOM-recovery tracking. pending_frame_phys is set immediately ;; after every frame_alloc that has not yet been mapped via ;; address_space_map_page; the .oom handler frees it before From 473eadb6cdf050428b297fdf3a72d58287c85427 Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 00:08:14 -0700 Subject: [PATCH 05/22] =?UTF-8?q?kernel:=20signal=5Fdispatch=5Fkill=20?= =?UTF-8?q?=E2=80=94=20teleport-to-kernel-ESP=20teardown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/arch/x86/kernel.asm | 1 + src/arch/x86/signal.c | 44 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 src/arch/x86/signal.c diff --git a/src/arch/x86/kernel.asm b/src/arch/x86/kernel.asm index 7e71a752..3b413d5f 100644 --- a/src/arch/x86/kernel.asm +++ b/src/arch/x86/kernel.asm @@ -411,6 +411,7 @@ high_entry: %include "fs/sector_cache.kasm" %include "fs/vfs.kasm" %include "net/net.asm" +%include "signal.kasm" %include "syscall.asm" %include "idt.asm" %include "system.kasm" diff --git a/src/arch/x86/signal.c b/src/arch/x86/signal.c new file mode 100644 index 00000000..6b47a757 --- /dev/null +++ b/src/arch/x86/signal.c @@ -0,0 +1,44 @@ +// signal.c — SIGINT dispatch primitives. Two entry points: +// signal_dispatch_kill — reset to a known kernel ESP, tear down the +// dying program's PD, jump to shell_reload. +// Reused by the SIG_DFL path and by handler- +// validation failures in SYS_SYS_SIGRETURN. +// signal_dispatch_user — Phase 4 (Task 11) fills this in. For now +// the IRQ-epilogue dispatch macro routes +// user-handler-registered cases through +// signal_dispatch_kill, so this file does +// not need a stub yet — but the symbol will +// be added in Task 11. +// +// signal_dispatch_kill never returns. It is reachable only from kernel +// context (IRQ epilogue or syscall epilogue), so it can clobber every +// register and reset ESP without consulting the caller's frame. +// +// Functions are defined in alphabetical order by visible name, per +// CLAUDE.md. + +extern uint32_t current_pd_phys; +asm("_g_current_pd_phys equ current_pd_phys"); + +void address_space_destroy(uint32_t pd_phys); +void put_character(char byte); + +void signal_dispatch_kill(); + +asm("signal_dispatch_kill:\n" + " mov esp, kernel_stack_top\n" + " mov al, '^'\n" + " call put_character\n" + " mov al, 'C'\n" + " call put_character\n" + " mov al, 0x0A\n" + " call put_character\n" + " mov eax, [_g_current_pd_phys]\n" + " test eax, eax\n" + " jz .signal_dispatch_kill_no_pd\n" + " push eax\n" + " call address_space_destroy\n" + " add esp, 4\n" + " mov dword [_g_current_pd_phys], 0\n" + ".signal_dispatch_kill_no_pd:\n" + " jmp shell_reload\n"); From 7da50ca3862477c98fcac8a8c57667189581fe2f Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 00:14:12 -0700 Subject: [PATCH 06/22] drivers/ps2: set pending_sigint when cooked Ctrl+C is detected Co-Authored-By: Claude Sonnet 4.6 --- src/drivers/ps2.c | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/drivers/ps2.c b/src/drivers/ps2.c index c3193b64..004a89a7 100644 --- a/src/drivers/ps2.c +++ b/src/drivers/ps2.c @@ -104,6 +104,13 @@ #define BBKEY_SLASH 63 #define BBKEY_KP_STAR 64 +// pending_sigint is defined as a NASM label (db 0) in entry.asm. +// cc.py mangles global accesses to the _g_ prefix; the equ below +// makes _g_pending_sigint resolve to the entry.asm label so that +// plain C assignments compile and link correctly. +extern uint8_t pending_sigint; +asm("_g_pending_sigint equ pending_sigint"); + // Ring buffer: single-producer (IRQ context, IF=0) / // single-consumer (main loop) so head and tail don't need atomics. // Cooked ASCII path (drained by fd_read_console / TRY_GETC); event @@ -346,6 +353,9 @@ void ps2_handle_scancode(uint8_t scancode __attribute__((in_register("ax")))) { ascii = upper - ('A' - 1); } } + if (ascii == '\x03') { + pending_sigint = 1; + } if (ascii != '\0') { ps2_putc(ascii); } From f3b01a2b4bd5ec1a9d1796fc6cb58c8ff673a676 Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 00:19:43 -0700 Subject: [PATCH 07/22] kernel: SIGINT_TAIL_CHECK in all IRQ epilogues; default-kill on Ctrl+C Co-Authored-By: Claude Sonnet 4.6 --- src/arch/x86/entry.asm | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/arch/x86/entry.asm b/src/arch/x86/entry.asm index b20f309c..9a0a2fce 100644 --- a/src/arch/x86/entry.asm +++ b/src/arch/x86/entry.asm @@ -58,6 +58,34 @@ STACK_VIRT_END equ USER_STACK_TOP ; one past last page; user/kernel boundary (= KERNEL_VIRT_BASE) VDSO_VIRT equ FUNCTION_TABLE ; 0x00010000 +;; SIGINT dispatch tail — invoke from IRQ / syscall handler before iretd. +;; Stack at invocation: popad has already executed, so [esp] is the iret +;; EIP and [esp + 4] is the iret CS. Skips dispatch when: +;; - interrupted CS is not user code (we'd kill kernel context), +;; - pending_sigint is clear (nothing to do), +;; - in_sigint_handler is set (already inside a handler — block re-entry +;; until SYS_SYS_SIGRETURN clears the flag). +;; On dispatch: +;; SIG_DFL → signal_dispatch_kill (never returns) +;; SIG_IGN → clear pending_sigint, fall through to iretd +;; user-virt→ Task 11 will route to signal_dispatch_user; for now +;; treat as SIG_DFL (kill). +%macro SIGINT_TAIL_CHECK 0 + cmp word [esp + 4], USER_CODE_SELECTOR + jne %%no_dispatch + cmp byte [pending_sigint], 0 + je %%no_dispatch + cmp byte [in_sigint_handler], 0 + jne %%no_dispatch + mov eax, [sigint_handler] + cmp eax, SIG_DFL + je signal_dispatch_kill + cmp eax, SIG_IGN + jne signal_dispatch_kill ; user-handler dispatch arrives in Task 11; kill for now + mov byte [pending_sigint], 0 +%%no_dispatch: +%endmacro + pmode_irq0_handler: ;; PIT tick. Increment system_ticks, drain due midi events, ;; EOI the master PIC, iretd. Interrupt gate entry leaves IF=0 @@ -70,6 +98,7 @@ pmode_irq0_handler: mov al, PIC_EOI out PIC1_CMD_PORT, al popad + SIGINT_TAIL_CHECK iretd pmode_irq5_handler: @@ -102,6 +131,7 @@ pmode_irq5_handler: mov al, PIC_EOI out PIC1_CMD_PORT, al popad + SIGINT_TAIL_CHECK iretd pmode_irq6_handler: @@ -110,6 +140,7 @@ pmode_irq6_handler: mov al, PIC_EOI out PIC1_CMD_PORT, al pop eax + SIGINT_TAIL_CHECK iretd ;;; ----------------------------------------------------------------------- From 934a1e6a12a7cfab1b7c0dbf37d58a1e7569ac78 Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 00:24:17 -0700 Subject: [PATCH 08/22] drivers: extend SIGINT_TAIL_CHECK to ps2_irq1_handler + fdc_irq6_handler Move the SIGINT_TAIL_CHECK macro from entry.asm into a new shared header src/include/irq_tail.inc so it is visible before driver kasm files are included (kernel.asm includes drivers before entry.asm). Add the macro invocation between popad/pop-eax and iretd in ps2_irq1_handler (ps2.c) and fdc_irq6_handler (fdc.c), completing SIGINT dispatch coverage for all live IRQ handlers. Co-Authored-By: Claude Sonnet 4.6 --- src/arch/x86/entry.asm | 28 +--------------------- src/arch/x86/kernel.asm | 1 + src/drivers/fdc.c | 1 + src/drivers/ps2.c | 1 + src/include/irq_tail.inc | 50 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 27 deletions(-) create mode 100644 src/include/irq_tail.inc diff --git a/src/arch/x86/entry.asm b/src/arch/x86/entry.asm index 9a0a2fce..508ecb39 100644 --- a/src/arch/x86/entry.asm +++ b/src/arch/x86/entry.asm @@ -58,33 +58,7 @@ STACK_VIRT_END equ USER_STACK_TOP ; one past last page; user/kernel boundary (= KERNEL_VIRT_BASE) VDSO_VIRT equ FUNCTION_TABLE ; 0x00010000 -;; SIGINT dispatch tail — invoke from IRQ / syscall handler before iretd. -;; Stack at invocation: popad has already executed, so [esp] is the iret -;; EIP and [esp + 4] is the iret CS. Skips dispatch when: -;; - interrupted CS is not user code (we'd kill kernel context), -;; - pending_sigint is clear (nothing to do), -;; - in_sigint_handler is set (already inside a handler — block re-entry -;; until SYS_SYS_SIGRETURN clears the flag). -;; On dispatch: -;; SIG_DFL → signal_dispatch_kill (never returns) -;; SIG_IGN → clear pending_sigint, fall through to iretd -;; user-virt→ Task 11 will route to signal_dispatch_user; for now -;; treat as SIG_DFL (kill). -%macro SIGINT_TAIL_CHECK 0 - cmp word [esp + 4], USER_CODE_SELECTOR - jne %%no_dispatch - cmp byte [pending_sigint], 0 - je %%no_dispatch - cmp byte [in_sigint_handler], 0 - jne %%no_dispatch - mov eax, [sigint_handler] - cmp eax, SIG_DFL - je signal_dispatch_kill - cmp eax, SIG_IGN - jne signal_dispatch_kill ; user-handler dispatch arrives in Task 11; kill for now - mov byte [pending_sigint], 0 -%%no_dispatch: -%endmacro +%include "irq_tail.inc" pmode_irq0_handler: ;; PIT tick. Increment system_ticks, drain due midi events, diff --git a/src/arch/x86/kernel.asm b/src/arch/x86/kernel.asm index 3b413d5f..5cc15fbd 100644 --- a/src/arch/x86/kernel.asm +++ b/src/arch/x86/kernel.asm @@ -38,6 +38,7 @@ org 0FF820000h bits 32 %include "constants.asm" + %include "irq_tail.inc" ;; Trampoline + boot stash at the very top of kernel.bin. ;; boot.asm's far-jump targets virt 0xC0020000 = the first byte diff --git a/src/drivers/fdc.c b/src/drivers/fdc.c index a3e13cf7..b53c2c6b 100644 --- a/src/drivers/fdc.c +++ b/src/drivers/fdc.c @@ -140,6 +140,7 @@ asm("fdc_irq6_handler:\n" " mov al, 0x20\n" // PIC_EOI " out 0x20, al\n" // PIC1_CMD_PORT " pop eax\n" + " SIGINT_TAIL_CHECK\n" " iretd"); // Issue a READ or WRITE command with the 9-byte parameter sequence. diff --git a/src/drivers/ps2.c b/src/drivers/ps2.c index 004a89a7..14b60adb 100644 --- a/src/drivers/ps2.c +++ b/src/drivers/ps2.c @@ -418,6 +418,7 @@ asm(" mov al, 0x20 out 0x20, al popad + SIGINT_TAIL_CHECK iretd ps2_broadcast_event: diff --git a/src/include/irq_tail.inc b/src/include/irq_tail.inc new file mode 100644 index 00000000..4a52e6ce --- /dev/null +++ b/src/include/irq_tail.inc @@ -0,0 +1,50 @@ +;; irq_tail.inc — SIGINT dispatch tail macro for IRQ handlers. +;; +;; Include this file before any IRQ handler that needs SIGINT_TAIL_CHECK. +;; kernel.asm %includes this early (before driver kasm files) so the macro +;; is defined when ps2_irq1_handler and fdc_irq6_handler expand it. +;; entry.asm also %includes it so pmode_irq0/5/6 handlers can use the macro +;; (the definition here replaces the one that was inlined in entry.asm). +;; +;; Stack contract at SIGINT_TAIL_CHECK invocation: +;; All general-purpose registers restored (popad or equivalent). +;; [esp + 0] = iret EIP +;; [esp + 4] = iret CS ← checked against USER_CODE_SELECTOR +;; [esp + 8] = iret EFLAGS +;; +;; The symbols pending_sigint, in_sigint_handler, sigint_handler, and +;; signal_dispatch_kill are defined in entry.asm and resolved as forward +;; references (NASM -f bin resolves forward label refs across the flat binary). + +%ifndef IRQ_TAIL_INC +%define IRQ_TAIL_INC + +;; SIGINT dispatch tail — invoke from IRQ / syscall handler before iretd. +;; Stack at invocation: popad has already executed, so [esp] is the iret +;; EIP and [esp + 4] is the iret CS. Skips dispatch when: +;; - interrupted CS is not user code (we'd kill kernel context), +;; - pending_sigint is clear (nothing to do), +;; - in_sigint_handler is set (already inside a handler — block re-entry +;; until SYS_SYS_SIGRETURN clears the flag). +;; On dispatch: +;; SIG_DFL → signal_dispatch_kill (never returns) +;; SIG_IGN → clear pending_sigint, fall through to iretd +;; user-virt→ Task 11 will route to signal_dispatch_user; for now +;; treat as SIG_DFL (kill). +%macro SIGINT_TAIL_CHECK 0 + cmp word [esp + 4], USER_CODE_SELECTOR + jne %%no_dispatch + cmp byte [pending_sigint], 0 + je %%no_dispatch + cmp byte [in_sigint_handler], 0 + jne %%no_dispatch + mov eax, [sigint_handler] + cmp eax, SIG_DFL + je signal_dispatch_kill + cmp eax, SIG_IGN + jne signal_dispatch_kill ; user-handler dispatch arrives in Task 11; kill for now + mov byte [pending_sigint], 0 +%%no_dispatch: +%endmacro + +%endif ; IRQ_TAIL_INC From 1e8005518648695e8e7a84eec7cb554326493be0 Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 00:26:24 -0700 Subject: [PATCH 09/22] kernel: SIGINT_TAIL_CHECK in syscall iret epilogues --- src/arch/x86/syscall.asm | 1 + 1 file changed, 1 insertion(+) diff --git a/src/arch/x86/syscall.asm b/src/arch/x86/syscall.asm index 79fa30bd..9301acb0 100644 --- a/src/arch/x86/syscall.asm +++ b/src/arch/x86/syscall.asm @@ -79,6 +79,7 @@ syscall_handler: .iret_cf_write: mov [esp + SYSCALL_SAVED_EAX], eax popad + SIGINT_TAIL_CHECK iretd ;; Each SYS_ENTRY pads with .iret_invalid up to the requested slot, From 08f7e1ee87f5d3a3e9376b586233eb4600d9e2a1 Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 00:29:29 -0700 Subject: [PATCH 10/22] =?UTF-8?q?kernel:=20SYS=5FSYS=5FSIGNAL=20=E2=80=94?= =?UTF-8?q?=20DFL/IGN/user-virt=20registration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SYS_SYS_SIGNAL (F5h) handler with full input validation: signum must be SIGINT, handler must be SIG_DFL/SIG_IGN or a user-virt address in [PROGRAM_BASE, KERNEL_VIRT_BASE). Returns previous handler in EAX (CF=0) so callers can restore state, or ERROR_INVALID (CF=1) on bad args. Add stub SYS_SYS_SIGRETURN (F6h) returning ERROR_INVALID until Task 12. Update SYSCALL_COUNT bound and add ERROR_INVALID (09h) to constants. Co-Authored-By: Claude Sonnet 4.6 --- src/arch/x86/syscall.asm | 41 ++++++++++++++++++++++++++++++++++++++- src/include/constants.asm | 1 + 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/arch/x86/syscall.asm b/src/arch/x86/syscall.asm index 9301acb0..9c2e35ae 100644 --- a/src/arch/x86/syscall.asm +++ b/src/arch/x86/syscall.asm @@ -29,7 +29,7 @@ ;;; [esp+32] eip / [esp+36] cs / [esp+40] eflags (CPU iretd frame) ;;; ------------------------------------------------------------------------ - SYSCALL_COUNT equ SYS_SYS_SHUTDOWN + 1 ; one past the highest SYS_* — bound for the dispatcher range check + SYSCALL_COUNT equ SYS_SYS_SIGRETURN + 1 ; one past the highest SYS_* — bound for the dispatcher range check SYSCALL_SAVED_EAX equ 28 SYSCALL_SAVED_EDX equ 20 SYSCALL_SAVED_EFLAGS equ 40 @@ -119,6 +119,8 @@ syscall_handler: SYS_ENTRY SYS_SYS_EXIT, .sys_exit SYS_ENTRY SYS_SYS_REBOOT, .sys_reboot SYS_ENTRY SYS_SYS_SHUTDOWN, .sys_shutdown + SYS_ENTRY SYS_SYS_SIGNAL, .sys_signal + SYS_ENTRY SYS_SYS_SIGRETURN, .sys_sigreturn ;; Per-case handler bodies follow. All but the four net_* ;; handlers are inlined here — each one is just a `call @@ -667,6 +669,43 @@ syscall_handler: stc jmp .iret_cf + ;; SYS_SYS_SIGNAL: register a signal handler. + ;; In: EBX = signum (must be SIGINT) + ;; ECX = handler — SIG_DFL (0), SIG_IGN (1), or user-virt + ;; address (PROGRAM_BASE <= ECX < KERNEL_VIRT_BASE). + ;; Out: EAX = previous handler value, CF clear on success. + ;; CF set + AL = ERROR_INVALID on bad signum or out-of-range + ;; handler address. + ;; The previous handler is returned so callers can restore the + ;; prior state on cleanup, mirroring POSIX signal(). + .sys_signal: + cmp ebx, SIGINT + jne .sys_signal_bad + cmp ecx, SIG_IGN + jbe .sys_signal_ok ; ECX in {0, 1} + cmp ecx, PROGRAM_BASE + jb .sys_signal_bad + cmp ecx, KERNEL_VIRT_BASE + jae .sys_signal_bad + .sys_signal_ok: + mov eax, [sigint_handler] ; previous handler -> EAX + mov [sigint_handler], ecx + clc + jmp .iret_cf_eax ; full EAX preserved, CF=0 + .sys_signal_bad: + mov al, ERROR_INVALID + stc + jmp .iret_cf + + ;; SYS_SYS_SIGRETURN: restore from sigcontext on user stack. + ;; Phase 4 (Task 12) fills this in. Until then, treat as no-op + ;; failure: any caller invoking it without a real signal frame + ;; on the stack is buggy. + .sys_sigreturn: + mov al, ERROR_INVALID + stc + jmp .iret_cf + ;;; ------------------------------------------------------------ ;;; Per-program break state, reset on every program load by ;;; program_enter (entry.asm) to user_image_end (page-aligned end diff --git a/src/include/constants.asm b/src/include/constants.asm index 4a9c6b15..61968d8c 100644 --- a/src/include/constants.asm +++ b/src/include/constants.asm @@ -14,6 +14,7 @@ %assign ERROR_EXISTS 02h ; Rename/copy error: destination name already exists %assign ERROR_FAULT 07h ; Bad user pointer: out of user range, wraps, or filename has no NUL within MAX_PATH %assign ERROR_INTERRUPTED 08h ; Cooperative-interrupt return (SIGINT) — maps to EINTR in libc + %assign ERROR_INVALID 09h ; Invalid argument (bad signum, out-of-range handler address, etc.) %assign ERROR_NOT_EMPTY 06h ; Rmdir error: directory is not empty %assign ERROR_NOT_EXECUTE 03h ; Exec error: file exists but is not executable %assign ERROR_NOT_FOUND 04h ; File not found From 1fdea6a08e97cd0cab016c277239b65a14e53f52 Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 00:31:00 -0700 Subject: [PATCH 11/22] libc: signal() wrapper around SYS_SYS_SIGNAL Co-Authored-By: Claude Sonnet 4.6 --- tools/libc/Makefile | 2 +- tools/libc/include/signal.h | 15 +++++++++++++++ tools/libc/signal.c | 23 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 tools/libc/include/signal.h create mode 100644 tools/libc/signal.c diff --git a/tools/libc/Makefile b/tools/libc/Makefile index 34239e53..9c77a8f5 100644 --- a/tools/libc/Makefile +++ b/tools/libc/Makefile @@ -18,7 +18,7 @@ CFLAGS := --target=i386-pc-none-elf -m32 -march=i386 -mno-mmx -mno-sse -mno-sse -mno-implicit-float -fno-vectorize -fno-slp-vectorize \ -ffreestanding -fno-pic -fno-stack-protector \ -nostdlib -nostdinc -O2 -Wall -Wextra -Werror -Iinclude -C_SRCS := builtins.c ctype.c errno.c math.c stdio.c stdlib.c string.c syscall.c +C_SRCS := builtins.c ctype.c errno.c math.c signal.c stdio.c stdlib.c string.c syscall.c OBJS = $(C_SRCS:.c=.o) $(S_SRCS:.S=.o) S_SRCS := _start.S setjmp.S diff --git a/tools/libc/include/signal.h b/tools/libc/include/signal.h new file mode 100644 index 00000000..d0a81aa9 --- /dev/null +++ b/tools/libc/include/signal.h @@ -0,0 +1,15 @@ +#ifndef BBOEOS_LIBC_SIGNAL_H +#define BBOEOS_LIBC_SIGNAL_H + +typedef void (*sighandler_t)(int); +typedef volatile int sig_atomic_t; + +#define SIG_DFL ((sighandler_t)0) +#define SIG_IGN ((sighandler_t)1) +#define SIG_ERR ((sighandler_t)-1) + +#define SIGINT 2 + +sighandler_t signal(int signum, sighandler_t handler); + +#endif diff --git a/tools/libc/signal.c b/tools/libc/signal.c new file mode 100644 index 00000000..6adbcd57 --- /dev/null +++ b/tools/libc/signal.c @@ -0,0 +1,23 @@ +#include + +#include "include/errno.h" + +sighandler_t signal(int signum, sighandler_t handler) { + unsigned int eax_out; + unsigned int cf; + __asm__ volatile ( + "mov %[handler], %%ecx\n\t" + "mov %[signum], %%ebx\n\t" + "mov $0xF5, %%ah\n\t" /* SYS_SYS_SIGNAL */ + "int $0x30\n\t" + "setc %b[cf]\n\t" + : "=a"(eax_out), [cf]"=&q"(cf) + : [signum]"g"((unsigned int)signum), + [handler]"g"((unsigned int)handler) + : "ebx", "ecx"); + if (cf & 1) { + errno = EINVAL; + return SIG_ERR; + } + return (sighandler_t)eax_out; +} From 92455b31d0b6b5ce53030038b7d941f2e2c97329 Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 00:35:19 -0700 Subject: [PATCH 12/22] shell: install SIG_IGN at startup so its own Ctrl+C is benign Co-Authored-By: Claude Sonnet 4.6 --- src/c/shell.c | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/c/shell.c b/src/c/shell.c index f4c2b1e8..c567bc7b 100644 --- a/src/c/shell.c +++ b/src/c/shell.c @@ -75,6 +75,15 @@ int try_exec(char *name) { } int main() { + /* Ignore SIGINT — the shell prefers to keep its line editor alive when + the user types Ctrl+C at the prompt. Cooked 0x03 still arrives in + the byte stream so the line editor can choose to display ^C and + reset its input buffer; without SIG_IGN, the kernel-side default is + to kill the program (which here would mean reloading the shell). */ + asm("mov ebx, SIGINT\n" + "mov ecx, SIG_IGN\n" + "mov ah, SYS_SYS_SIGNAL\n" + "int 30h\n"); char *buf = BUFFER; /* exec_path assembles "bin/" for the fallback lookup. ARGV (32 bytes) is unused by the shell since main() takes no args. */ From 892cbe5c02ddc2875cd867baca2bad0230d3cf62 Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 00:38:27 -0700 Subject: [PATCH 13/22] kernel: vDSO sigreturn trampoline at VDSO_VIRT + 0x450 Place __kernel_sigreturn (mov eax, SYS_SYS_SIGRETURN; int 0x30) at a fixed offset in vdso.asm via a times-padded block. Offset 0x100 is occupied by shared_print_datetime, so VDSO_SIGRETURN_OFFSET is updated to 0x450 (first 16-byte-aligned slot past the existing helpers). Task 11 will wire signal_dispatch_user to point the iret return-address here. Co-Authored-By: Claude Sonnet 4.6 --- src/include/constants.asm | 2 +- src/vdso/vdso.asm | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/include/constants.asm b/src/include/constants.asm index 61968d8c..134ffee4 100644 --- a/src/include/constants.asm +++ b/src/include/constants.asm @@ -74,7 +74,7 @@ %assign FUNCTION_PRINT_STRING FUNCTION_TABLE + 55 ; DI=null-terminated string: write to stdout %assign FUNCTION_PRINTF FUNCTION_TABLE + 60 ; cdecl: push args R-to-L, push fmt, call %assign FUNCTION_WRITE_STDOUT FUNCTION_TABLE + 65 ; SI=buf, CX=len: write to stdout - %assign VDSO_SIGRETURN_OFFSET 0100h ; trampoline lives at VDSO_VIRT + 0x100 + %assign VDSO_SIGRETURN_OFFSET 0450h ; trampoline lives at VDSO_VIRT + 0x450 %assign IPPROTO_ICMP 1 ; Protocol argument to net_open for SOCK_DGRAM ICMP sockets %assign IPPROTO_UDP 17 ; Protocol argument to net_open for SOCK_DGRAM UDP sockets %assign KERNEL_VIRT_BASE 0FF800000h ; Lowest kernel-virt address. User pointers + lengths must stay strictly below this; idt.asm's user-fault triage and access_ok both gate on it. Equals USER_STACK_TOP and DIRECT_MAP_BASE — all three move in lockstep. diff --git a/src/vdso/vdso.asm b/src/vdso/vdso.asm index 31a0eaad..1a38e52b 100644 --- a/src/vdso/vdso.asm +++ b/src/vdso/vdso.asm @@ -625,3 +625,16 @@ shared_write_stdout: align 2 print_datetime_month_lengths: dw 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 + +;;; ----------------------------------------------------------------------- +;;; sigreturn trampoline — fixed at VDSO_VIRT + VDSO_SIGRETURN_OFFSET. +;;; signal_dispatch_user sets IRET return-address to this address so that +;;; when the user-mode signal handler returns, execution falls here and +;;; SYS_SYS_SIGRETURN restores the interrupted context. +;;; ----------------------------------------------------------------------- + + times (VDSO_SIGRETURN_OFFSET - ($ - $$)) db 0 +__kernel_sigreturn: + mov eax, SYS_SYS_SIGRETURN ; 0xF6 + int 0x30 + ;; never returns From 47f2c785934c377facd32f5c02150e67654a62c9 Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 00:45:58 -0700 Subject: [PATCH 14/22] =?UTF-8?q?kernel:=20signal=5Fdispatch=5Fuser=20?= =?UTF-8?q?=E2=80=94=20sigcontext=20build=20+=20iret-frame=20rewrite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure SIGINT_TAIL_CHECK so it runs BEFORE popad with the pushad slots still on the kernel stack. The macro now takes ownership of popad: in the no-dispatch path it pops; in the dispatch path it routes to a kernel routine that either captures the pushad slots into a sigcontext on the user stack (user handler) or doesn't need them (SIG_DFL kill). Each IRQ + syscall handler that uses the macro drops its own popad and ends with `SIGINT_TAIL_CHECK / iretd`. pmode_irq6_handler and fdc_irq6_handler — previously `push eax / pop eax` — are converted to pushad/popad so the macro's pushad-shape stack contract holds. signal_dispatch_user builds a 48-byte sigcontext at user_esp - 48 (trampoline addr, signum, saved EIP/EFLAGS/ESP, then pushad-order registers minus the redundant ESP slot), rewrites the IRET EIP to the registered handler and the IRET ESP to the sigcontext base, sets in_sigint_handler = 1 / pending_sigint = 0, drops the pushad slots, and iretds. SYS_SYS_SIGRETURN (Task 12) will consume the sigcontext to resume the interrupted state. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/arch/x86/entry.asm | 10 ++--- src/arch/x86/signal.c | 93 +++++++++++++++++++++++++++++++++++++--- src/arch/x86/syscall.asm | 1 - src/drivers/fdc.c | 6 ++- src/drivers/ps2.c | 1 - src/include/irq_tail.inc | 52 ++++++++++++++-------- 6 files changed, 130 insertions(+), 33 deletions(-) diff --git a/src/arch/x86/entry.asm b/src/arch/x86/entry.asm index 508ecb39..9dc3f31b 100644 --- a/src/arch/x86/entry.asm +++ b/src/arch/x86/entry.asm @@ -71,7 +71,6 @@ pmode_irq0_handler: call midi_drain_due mov al, PIC_EOI out PIC1_CMD_PORT, al - popad SIGINT_TAIL_CHECK iretd @@ -104,16 +103,17 @@ pmode_irq5_handler: call sb16_refill mov al, PIC_EOI out PIC1_CMD_PORT, al - popad SIGINT_TAIL_CHECK iretd pmode_irq6_handler: - ;; FDC command complete. EOI. - push eax + ;; FDC command complete. EOI. pushad/popad (rather than the + ;; minimal `push eax / pop eax`) so the SIGINT_TAIL_CHECK macro + ;; sees a pushad-shape stack and can capture full register state + ;; into a sigcontext if a user handler is registered. + pushad mov al, PIC_EOI out PIC1_CMD_PORT, al - pop eax SIGINT_TAIL_CHECK iretd diff --git a/src/arch/x86/signal.c b/src/arch/x86/signal.c index 6b47a757..a183dad4 100644 --- a/src/arch/x86/signal.c +++ b/src/arch/x86/signal.c @@ -3,27 +3,42 @@ // dying program's PD, jump to shell_reload. // Reused by the SIG_DFL path and by handler- // validation failures in SYS_SYS_SIGRETURN. -// signal_dispatch_user — Phase 4 (Task 11) fills this in. For now -// the IRQ-epilogue dispatch macro routes -// user-handler-registered cases through -// signal_dispatch_kill, so this file does -// not need a stub yet — but the symbol will -// be added in Task 11. +// signal_dispatch_user — capture interrupted register state into a +// sigcontext on the user stack, rewrite the +// CPU iret frame to enter the registered +// ring-3 handler, and iretd. Reached from +// the SIGINT_TAIL_CHECK macro with pushad +// slots still on the kernel stack. // // signal_dispatch_kill never returns. It is reachable only from kernel // context (IRQ epilogue or syscall epilogue), so it can clobber every // register and reset ESP without consulting the caller's frame. // +// signal_dispatch_user does not return to its caller either — it iretds +// directly into the user handler. It runs on the kernel stack with the +// macro-call frame intact (pushad slots + iret frame), so it can read +// register state straight out of [esp + ...] without any intermediate +// save. +// // Functions are defined in alphabetical order by visible name, per // CLAUDE.md. extern uint32_t current_pd_phys; + asm("_g_current_pd_phys equ current_pd_phys"); +// signal_dispatch_user references pending_sigint, in_sigint_handler, and +// sigint_handler directly by their entry.asm label names (no `_g_` +// prefix) — those globals are %included in kernel.asm before this file +// and ps2.c already publishes its own `_g_pending_sigint` alias, so +// re-equ'ing here would collide. This file's only C-mangled global +// access is current_pd_phys (used by signal_dispatch_kill below). + void address_space_destroy(uint32_t pd_phys); void put_character(char byte); void signal_dispatch_kill(); +void signal_dispatch_user(); asm("signal_dispatch_kill:\n" " mov esp, kernel_stack_top\n" @@ -42,3 +57,69 @@ asm("signal_dispatch_kill:\n" " mov dword [_g_current_pd_phys], 0\n" ".signal_dispatch_kill_no_pd:\n" " jmp shell_reload\n"); + +// signal_dispatch_user — build a 48-byte sigcontext on the user stack, +// rewrite the CPU iret frame to enter the registered ring-3 handler, +// and iretd. Reached from SIGINT_TAIL_CHECK when sigint_handler holds +// a user-virt address (not SIG_DFL / SIG_IGN) and we just interrupted +// CPL=3 with a pending SIGINT. +// +// Stack at entry (pushad slots live, cross-priv iret frame above): +// [esp + 0] saved EDI [esp + 16] saved EBX +// [esp + 4] saved ESI [esp + 20] saved EDX +// [esp + 8] saved EBP [esp + 24] saved ECX +// [esp + 12] saved ESP_pushad [esp + 28] saved EAX +// [esp + 32] iret EIP [esp + 40] iret EFLAGS +// [esp + 36] iret CS [esp + 44] iret ESP +// [esp + 48] iret SS +// +// Sigcontext layout (48 bytes, written at user_esp - 48): +// +0 trampoline_addr (FUNCTION_TABLE + VDSO_SIGRETURN_OFFSET = 0x10450) +// +4 signum (= SIGINT = 2) +// +8 saved EIP (interrupted user EIP) +// +12 saved EFLAGS +// +16 saved ESP (original user ESP, before this 48-byte frame) +// +20 saved EAX +24 ECX +28 EDX +32 EBX +// +36 saved EBP +40 ESI +44 EDI +// +// The user-stack writes go through the active PD (still the user's, since +// CR3 is not touched on IRQ / syscall entry) — a fault while we write +// (e.g. user is near the stack guard) vectors through exc_common, which +// kills the program. That's the right behaviour. +// +// After building the sigcontext we rewrite [esp + 32] (iret EIP) to the +// handler address and [esp + 44] (iret ESP) to the sigcontext base, set +// in_sigint_handler = 1 and pending_sigint = 0, drop the pushad slots +// (their values now live in the sigcontext) by `add esp, 32`, and iretd. +asm("signal_dispatch_user:\n" + " mov edi, [esp + 44]\n" // user ESP + " sub edi, 48\n" // sigcontext base + " mov dword [edi + 0], FUNCTION_TABLE + VDSO_SIGRETURN_OFFSET\n" + " mov dword [edi + 4], SIGINT\n" + " mov eax, [esp + 32]\n" // iret EIP + " mov [edi + 8], eax\n" + " mov eax, [esp + 40]\n" // iret EFLAGS + " mov [edi + 12], eax\n" + " mov eax, [esp + 44]\n" // original user ESP + " mov [edi + 16], eax\n" + " mov eax, [esp + 28]\n" // saved EAX + " mov [edi + 20], eax\n" + " mov eax, [esp + 24]\n" // saved ECX + " mov [edi + 24], eax\n" + " mov eax, [esp + 20]\n" // saved EDX + " mov [edi + 28], eax\n" + " mov eax, [esp + 16]\n" // saved EBX + " mov [edi + 32], eax\n" + " mov eax, [esp + 8]\n" // saved EBP + " mov [edi + 36], eax\n" + " mov eax, [esp + 4]\n" // saved ESI + " mov [edi + 40], eax\n" + " mov eax, [esp + 0]\n" // saved EDI + " mov [edi + 44], eax\n" + " mov eax, [sigint_handler]\n" + " mov [esp + 32], eax\n" // iret EIP <- handler + " mov [esp + 44], edi\n" // iret ESP <- sigcontext base + " mov byte [in_sigint_handler], 1\n" + " mov byte [pending_sigint], 0\n" + " add esp, 32\n" // drop pushad slots + " iretd\n"); diff --git a/src/arch/x86/syscall.asm b/src/arch/x86/syscall.asm index 9c2e35ae..43658698 100644 --- a/src/arch/x86/syscall.asm +++ b/src/arch/x86/syscall.asm @@ -78,7 +78,6 @@ syscall_handler: and dword [esp + SYSCALL_SAVED_EFLAGS], ~1 .iret_cf_write: mov [esp + SYSCALL_SAVED_EAX], eax - popad SIGINT_TAIL_CHECK iretd diff --git a/src/drivers/fdc.c b/src/drivers/fdc.c index b53c2c6b..d4d89289 100644 --- a/src/drivers/fdc.c +++ b/src/drivers/fdc.c @@ -134,12 +134,14 @@ asm("fdc_install_irq:\n" // IRQ 6 stub — flag that the controller has signalled completion, // EOI to the master PIC, iretd. Installed at FDC_IRQ6_VECTOR // (0x26) by fdc_install_irq. Same shape as ps2_irq1_handler. +// pushad/popad (rather than the minimal `push eax / pop eax`) so the +// SIGINT_TAIL_CHECK macro sees a pushad-shape stack and can capture +// full register state into a sigcontext if a user handler is registered. asm("fdc_irq6_handler:\n" - " push eax\n" + " pushad\n" " mov byte [_g_fdc_irq_flag], 1\n" " mov al, 0x20\n" // PIC_EOI " out 0x20, al\n" // PIC1_CMD_PORT - " pop eax\n" " SIGINT_TAIL_CHECK\n" " iretd"); diff --git a/src/drivers/ps2.c b/src/drivers/ps2.c index 14b60adb..ac8c1925 100644 --- a/src/drivers/ps2.c +++ b/src/drivers/ps2.c @@ -417,7 +417,6 @@ asm(" call ps2_handle_scancode mov al, 0x20 out 0x20, al - popad SIGINT_TAIL_CHECK iretd diff --git a/src/include/irq_tail.inc b/src/include/irq_tail.inc index 4a52e6ce..42097871 100644 --- a/src/include/irq_tail.inc +++ b/src/include/irq_tail.inc @@ -6,33 +6,48 @@ ;; entry.asm also %includes it so pmode_irq0/5/6 handlers can use the macro ;; (the definition here replaces the one that was inlined in entry.asm). ;; -;; Stack contract at SIGINT_TAIL_CHECK invocation: -;; All general-purpose registers restored (popad or equivalent). -;; [esp + 0] = iret EIP -;; [esp + 4] = iret CS ← checked against USER_CODE_SELECTOR -;; [esp + 8] = iret EFLAGS +;; Stack contract at SIGINT_TAIL_CHECK invocation (Task 11 reshape): +;; pushad slots are STILL on the stack (the macro owns popad — see below). +;; [esp + 0..28] pushad slots EDI, ESI, EBP, ESP_pushad, EBX, EDX, ECX, EAX +;; [esp + 32] iret EIP +;; [esp + 36] iret CS ← checked against USER_CODE_SELECTOR +;; [esp + 40] iret EFLAGS +;; [esp + 44] iret ESP (only present on cross-priv iret — i.e. when +;; CS at +36 is USER_CODE_SELECTOR) +;; [esp + 48] iret SS ;; -;; The symbols pending_sigint, in_sigint_handler, sigint_handler, and -;; signal_dispatch_kill are defined in entry.asm and resolved as forward -;; references (NASM -f bin resolves forward label refs across the flat binary). +;; The macro takes ownership of popad: in the no-dispatch path it pops the +;; pushad slots before iretd; in the dispatch path it routes to a kernel +;; routine that either captures the pushad slots into a sigcontext (user +;; handler — `signal_dispatch_user`) or doesn't need them (SIG_DFL kill — +;; `signal_dispatch_kill`). Each caller must therefore drop its own popad +;; line and end with `SIGINT_TAIL_CHECK / iretd`. +;; +;; The symbols pending_sigint, in_sigint_handler, sigint_handler, +;; signal_dispatch_kill, and signal_dispatch_user are defined elsewhere +;; (entry.asm / signal.c) and resolved as forward references (NASM -f bin +;; resolves forward label refs across the flat binary). %ifndef IRQ_TAIL_INC %define IRQ_TAIL_INC -;; SIGINT dispatch tail — invoke from IRQ / syscall handler before iretd. -;; Stack at invocation: popad has already executed, so [esp] is the iret -;; EIP and [esp + 4] is the iret CS. Skips dispatch when: +;; SIGINT dispatch tail — invoke from IRQ / syscall handler before its +;; iretd. The macro takes ownership of popad: in the no-dispatch path +;; it pops; in the dispatch path it routes to a kernel routine that +;; captures the pushad slots into a sigcontext (user handler) or +;; doesn't need them (SIG_DFL kill). Skips dispatch when: ;; - interrupted CS is not user code (we'd kill kernel context), ;; - pending_sigint is clear (nothing to do), ;; - in_sigint_handler is set (already inside a handler — block re-entry ;; until SYS_SYS_SIGRETURN clears the flag). ;; On dispatch: -;; SIG_DFL → signal_dispatch_kill (never returns) -;; SIG_IGN → clear pending_sigint, fall through to iretd -;; user-virt→ Task 11 will route to signal_dispatch_user; for now -;; treat as SIG_DFL (kill). +;; SIG_DFL → signal_dispatch_kill (never returns; pushad slots discarded) +;; SIG_IGN → clear pending_sigint, fall through to popad + iretd +;; user-virt → signal_dispatch_user (never returns to here; captures +;; pushad slots into sigcontext, rewrites iret frame, iretds +;; at the handler). %macro SIGINT_TAIL_CHECK 0 - cmp word [esp + 4], USER_CODE_SELECTOR + cmp word [esp + 36], USER_CODE_SELECTOR jne %%no_dispatch cmp byte [pending_sigint], 0 je %%no_dispatch @@ -40,11 +55,12 @@ jne %%no_dispatch mov eax, [sigint_handler] cmp eax, SIG_DFL - je signal_dispatch_kill + je signal_dispatch_kill ; never returns; pushad slots discarded cmp eax, SIG_IGN - jne signal_dispatch_kill ; user-handler dispatch arrives in Task 11; kill for now + jne signal_dispatch_user ; never returns to here; rewrites IRET + iretd mov byte [pending_sigint], 0 %%no_dispatch: + popad %endmacro %endif ; IRQ_TAIL_INC From 237d1a48c381beab0c3d84aeb6e96ac467e68bad Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 00:51:53 -0700 Subject: [PATCH 15/22] =?UTF-8?q?kernel:=20SYS=5FSYS=5FSIGRETURN=20?= =?UTF-8?q?=E2=80=94=20restore=20sigcontext,=20redeliver=20pending=20SIGIN?= =?UTF-8?q?T?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires .sys_sigreturn to a new signal_resume_after_handler asm thunk in signal.c. After the user handler's `ret` pops the trampoline address off the sigcontext, the vDSO trampoline re-enters the kernel via int 0x30 with EAX = 0xF6. At syscall entry user ESP = sigcontext_base + 4, so signal_resume_after_handler reads saved_eip / saved_eflags / saved_esp / saved_eax..edi at offsets [edi + 4..40], validates eip + esp are user-virt, rewrites the syscall iret frame and pushad slots, clears in_sigint_handler, and either popad+iretds back to interrupted user code or, if a SIGINT arrived during the handler, hands off to signal_dispatch_kill / signal_dispatch_user / clears for SIG_IGN. Build clean; test_programs (29) + test_asm (20) pass; manual boot smoke test reaches the shell prompt. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/arch/x86/signal.c | 125 +++++++++++++++++++++++++++++++++++---- src/arch/x86/syscall.asm | 14 ++--- 2 files changed, 121 insertions(+), 18 deletions(-) diff --git a/src/arch/x86/signal.c b/src/arch/x86/signal.c index a183dad4..57e5ed02 100644 --- a/src/arch/x86/signal.c +++ b/src/arch/x86/signal.c @@ -1,14 +1,25 @@ -// signal.c — SIGINT dispatch primitives. Two entry points: -// signal_dispatch_kill — reset to a known kernel ESP, tear down the -// dying program's PD, jump to shell_reload. -// Reused by the SIG_DFL path and by handler- -// validation failures in SYS_SYS_SIGRETURN. -// signal_dispatch_user — capture interrupted register state into a -// sigcontext on the user stack, rewrite the -// CPU iret frame to enter the registered -// ring-3 handler, and iretd. Reached from -// the SIGINT_TAIL_CHECK macro with pushad -// slots still on the kernel stack. +// signal.c — SIGINT dispatch primitives. Three entry points: +// signal_dispatch_kill — reset to a known kernel ESP, tear +// down the dying program's PD, jump +// to shell_reload. Reused by the +// SIG_DFL path and by handler- +// validation failures in +// SYS_SYS_SIGRETURN. +// signal_dispatch_user — capture interrupted register state +// into a sigcontext on the user +// stack, rewrite the CPU iret frame +// to enter the registered ring-3 +// handler, and iretd. Reached from +// the SIGINT_TAIL_CHECK macro with +// pushad slots still on the kernel +// stack. +// signal_resume_after_handler — restore the interrupted register +// state from a sigcontext on the +// user stack, then iretd back to the +// pre-signal user code. Reached +// from .sys_sigreturn (SYS_SYS_- +// SIGRETURN) via the vDSO trampoline +// that the user handler returns to. // // signal_dispatch_kill never returns. It is reachable only from kernel // context (IRQ epilogue or syscall epilogue), so it can clobber every @@ -39,6 +50,7 @@ void put_character(char byte); void signal_dispatch_kill(); void signal_dispatch_user(); +void signal_resume_after_handler(); asm("signal_dispatch_kill:\n" " mov esp, kernel_stack_top\n" @@ -123,3 +135,94 @@ asm("signal_dispatch_user:\n" " mov byte [pending_sigint], 0\n" " add esp, 32\n" // drop pushad slots " iretd\n"); + +// signal_resume_after_handler — service SYS_SYS_SIGRETURN. Restore the +// interrupted register state (saved by signal_dispatch_user into a +// sigcontext on the user stack) into the syscall-entry kernel frame, so +// the syscall epilogue's popad + iretd resumes user code at the point +// the SIGINT preempted it. +// +// Reach path: signal_dispatch_user iretds into the user handler with +// ESP pointing at sigcontext base. The handler runs as a plain C-style +// function and ends with `ret`, which pops the first dword (the +// trampoline address at sigcontext+0) into EIP and lands the CPU at the +// vDSO sigreturn trampoline (FUNCTION_TABLE + VDSO_SIGRETURN_OFFSET). +// The trampoline executes `mov eax, 0xF6 / int 0x30` and we re-enter +// the kernel through the syscall dispatcher. Cross-priv iret + pushad +// produced the standard syscall frame: +// [esp + 0..28] pushad slots EDI..EAX (junk — the trampoline only +// touched EAX) +// [esp + 32] iret EIP (back into the trampoline; unused) +// [esp + 36] iret CS +// [esp + 40] iret EFLAGS +// [esp + 44] iret ESP (== sigcontext base + 4, because the +// handler's `ret` popped the trampoline +// address) +// [esp + 48] iret SS +// +// Let edi = [esp + 44] = sigcontext_base + 4. The remaining sigcontext +// fields (signum at +4, saved_eip at +8, ..., saved_edi at +44) are +// reachable as [edi + 0], [edi + 4], ..., [edi + 40]. +// +// We: +// 1. Validate saved_eip is in user-virt (PROGRAM_BASE..KERNEL_VIRT_BASE). +// Fail → signal_dispatch_kill (corrupt sigcontext == bad program). +// 2. Validate saved_esp is in user-virt (PROGRAM_BASE..=KERNEL_VIRT_BASE, +// since USER_STACK_TOP == KERNEL_VIRT_BASE is the legal high bound). +// 3. Rewrite iret EIP / EFLAGS / ESP from saved_eip / saved_eflags / +// saved_esp. +// 4. Rewrite the kernel-stack pushad slots from saved_eax .. saved_edi +// so the syscall epilogue's popad sees the user's pre-signal regs. +// 5. Clear in_sigint_handler so SIGINT can deliver again. +// 6. If pending_sigint was set during the handler, redeliver it now — +// either kill (SIG_DFL), drop it (SIG_IGN), or jump straight to +// signal_dispatch_user, which reads the just-rewritten [esp + 44] +// and builds a fresh sigcontext on the now-restored user stack. +// 7. popad + iretd to user code at saved_eip. +// +// The function never returns to its caller. .sys_sigreturn jumps here +// rather than calling — we own the popad and iretd. +asm("signal_resume_after_handler:\n" + " mov edi, [esp + 44]\n" // user ESP = sigcontext_base + 4 + " mov eax, [edi + 4]\n" // saved_eip + " cmp eax, PROGRAM_BASE\n" + " jb signal_dispatch_kill\n" + " cmp eax, KERNEL_VIRT_BASE\n" + " jae signal_dispatch_kill\n" + " mov eax, [edi + 12]\n" // saved_esp + " cmp eax, PROGRAM_BASE\n" + " jb signal_dispatch_kill\n" + " cmp eax, KERNEL_VIRT_BASE\n" + " ja signal_dispatch_kill\n" + " mov eax, [edi + 4]\n" // saved_eip + " mov [esp + 32], eax\n" // iret EIP + " mov eax, [edi + 8]\n" // saved_eflags + " mov [esp + 40], eax\n" // iret EFLAGS + " mov eax, [edi + 12]\n" // saved_esp + " mov [esp + 44], eax\n" // iret ESP + " mov eax, [edi + 16]\n" // saved_eax -> pushad EAX slot + " mov [esp + 28], eax\n" + " mov eax, [edi + 20]\n" // saved_ecx + " mov [esp + 24], eax\n" + " mov eax, [edi + 24]\n" // saved_edx + " mov [esp + 20], eax\n" + " mov eax, [edi + 28]\n" // saved_ebx + " mov [esp + 16], eax\n" + " mov eax, [edi + 32]\n" // saved_ebp + " mov [esp + 8], eax\n" + " mov eax, [edi + 36]\n" // saved_esi + " mov [esp + 4], eax\n" + " mov eax, [edi + 40]\n" // saved_edi + " mov [esp + 0], eax\n" + " mov byte [in_sigint_handler], 0\n" + " cmp byte [pending_sigint], 0\n" + " je .signal_resume_no_pending\n" + " mov eax, [sigint_handler]\n" + " cmp eax, SIG_DFL\n" + " je signal_dispatch_kill\n" + " cmp eax, SIG_IGN\n" + " jne signal_dispatch_user\n" + " mov byte [pending_sigint], 0\n" + ".signal_resume_no_pending:\n" + " popad\n" + " iretd\n"); diff --git a/src/arch/x86/syscall.asm b/src/arch/x86/syscall.asm index 43658698..d56611a1 100644 --- a/src/arch/x86/syscall.asm +++ b/src/arch/x86/syscall.asm @@ -696,14 +696,14 @@ syscall_handler: stc jmp .iret_cf - ;; SYS_SYS_SIGRETURN: restore from sigcontext on user stack. - ;; Phase 4 (Task 12) fills this in. Until then, treat as no-op - ;; failure: any caller invoking it without a real signal frame - ;; on the stack is buggy. + ;; SYS_SYS_SIGRETURN: restore the interrupted register state + ;; from a sigcontext on the user stack and iretd back to user + ;; code. signal_resume_after_handler owns the popad and iretd + ;; — it never returns through .iret_cf — so this entry is a + ;; bare jmp. See signal.c for the full sigcontext layout and + ;; offset arithmetic. .sys_sigreturn: - mov al, ERROR_INVALID - stc - jmp .iret_cf + jmp signal_resume_after_handler ;;; ------------------------------------------------------------ ;;; Per-program break state, reset on every program load by From de2f55a33c529055cee861c4a13ed6348f348481 Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 00:56:00 -0700 Subject: [PATCH 16/22] fs/fd/console: cooperative SIGINT bail in read; detect serial 0x03 Check pending_sigint before each polling iteration in fd_read_console; return CF set / ERROR_INTERRUPTED (0x08) so the syscall epilogue's SIGINT_TAIL_CHECK delivers the signal on iret. Also detect 0x03 (Ctrl+C) from the serial port and set pending_sigint so serial users get the same signal path as PS/2 keyboard users. Co-Authored-By: Claude Sonnet 4.6 --- src/fs/fd/console.c | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/fs/fd/console.c b/src/fs/fd/console.c index 4f7238a3..f6e34772 100644 --- a/src/fs/fd/console.c +++ b/src/fs/fd/console.c @@ -114,11 +114,19 @@ void put_character(char byte __attribute__((in_register("ax")))) // user buffer pointer here before jumping to this handler. extern uint8_t *fd_write_buffer; -// Read one byte from PS/2 ring or COM1 into *destination. Always -// returns CF clear; AX = 1 on success, 0 if max_bytes was 0. Polls -// continuously — the syscall handler entered with IF=0 (the INT 30h -// gate clears it) so we sti once before the polling loop to let -// IRQ 1 fire and the keyboard ring populate. +// drivers/ps2.c — set to 1 when a SIGINT is pending delivery. The +// equ alias (_g_pending_sigint equ pending_sigint) is published by +// ps2.c; only the C extern is needed here to avoid a duplicate +// definition. +extern uint8_t pending_sigint; + +// Read one byte from PS/2 ring or COM1 into *destination. Returns CF +// clear (return 1) with AX = 1 on success, or CF clear (return 1) with +// AX = 0 if max_bytes was 0. Returns CF set (return 0) with AX = +// 0x08 (ERROR_INTERRUPTED) if pending_sigint is detected before a byte +// arrives. Polls continuously — the syscall handler entered with IF=0 +// (the INT 30h gate clears it) so we sti once before the polling loop +// to let IRQ 1 fire and the keyboard ring populate. __attribute__((carry_return)) int fd_read_console(int *bytes_read __attribute__((out_register("ax"))), uint8_t *destination __attribute__((in_register("edi"))), @@ -130,12 +138,26 @@ int fd_read_console(int *bytes_read __attribute__((out_register("ax"))), } asm("sti"); while (1) { + if (pending_sigint != 0) { + // Cooperative interrupt: bail out so the syscall epilogue's + // SIGINT_TAIL_CHECK delivers the signal on iret. + // ERROR_INTERRUPTED = 0x08 (constants.asm). + *bytes_read = 0x08; + return 0; + } byte = ps2_getc(); if (byte != '\0') { break; } if ((kernel_inb(0x3FD) & 0x01) != 0) { byte = kernel_inb(0x3F8); + if (byte == '\x03') { + // Serial Ctrl+C — set the flag so the next IRQ epilogue + // (or this same syscall's epilogue, after we return) + // delivers. The byte is also returned in the buffer + // so SIG_IGN'd programs see it as normal input. + pending_sigint = 1; + } break; } } From cd55694fda11f6b9deebe60c8ee7963de6b2c84d Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 00:57:39 -0700 Subject: [PATCH 17/22] fs/fd/midi: cooperative SIGINT bail in MIDI_IOCTL_DRAIN sti+hlt loop Co-Authored-By: Claude Sonnet 4.6 --- src/fs/fd/midi.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/fs/fd/midi.c b/src/fs/fd/midi.c index 46f2e7e1..c37c821d 100644 --- a/src/fs/fd/midi.c +++ b/src/fs/fd/midi.c @@ -21,6 +21,7 @@ extern uint8_t *fd_write_buffer; extern uint8_t opl3_present; +extern uint8_t pending_sigint; extern uint32_t system_ticks; // drivers/opl3.c @@ -92,6 +93,8 @@ asm("fd_ioctl_midi:\n" // or more due events, and we re-check on the loop body. ".fd_ioctl_midi_drain_wait:\n" " cli\n" + " cmp byte [_g_pending_sigint], 0\n" + " jne .fd_ioctl_midi_drain_eintr\n" " mov al, [_g_midi_head]\n" " cmp al, [_g_midi_tail]\n" " je .fd_ioctl_midi_drain_done\n" @@ -103,6 +106,11 @@ asm("fd_ioctl_midi:\n" " xor eax, eax\n" " clc\n" " ret\n" + ".fd_ioctl_midi_drain_eintr:\n" + " sti\n" + " mov al, ERROR_INTERRUPTED\n" + " stc\n" + " ret\n" ".fd_ioctl_midi_flush:\n" " push ecx\n" " push edx\n" From 9cd99ab91050b19d39146a072376849590dcc974 Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 00:59:05 -0700 Subject: [PATCH 18/22] libc: map ERROR_INTERRUPTED (0x08) -> EINTR in syscall wrappers Co-Authored-By: Claude Sonnet 4.6 --- tools/libc/syscall.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/libc/syscall.c b/tools/libc/syscall.c index e92adc24..9a8c412c 100644 --- a/tools/libc/syscall.c +++ b/tools/libc/syscall.c @@ -15,6 +15,7 @@ * 05h ERROR_PROTECTED -> EACCES * 06h ERROR_NOT_EMPTY -> ENOTEMPTY (mapped to EACCES; not in our errno.h) * 07h ERROR_FAULT -> EFAULT + * 08h ERROR_INTERRUPTED -> EINTR */ static unsigned int _current_break = 0; @@ -27,6 +28,7 @@ static int _errno_from_al(int al) { case 0x05: return EACCES; /* ERROR_PROTECTED */ case 0x06: return EACCES; /* ERROR_NOT_EMPTY (no ENOTEMPTY in our errno.h) */ case 0x07: return EFAULT; /* ERROR_FAULT */ + case 0x08: return EINTR; /* ERROR_INTERRUPTED */ default: return EIO; } } From 13caab55fe206d0830433ad97e0d97f7d9bc3bdb Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 01:22:24 -0700 Subject: [PATCH 19/22] tests: SIGINT handler delivery + sigreturn end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests/programs/sigint_test.c: registers on_sigint, calls SYS_IO_READ which detects serial 0x03, sets pending_sigint, and returns the byte; the syscall epilogue dispatches via signal_dispatch_user; on_sigint sets got_sigint and returns through the vDSO sigreturn trampoline; signal_resume_after_handler restores the interrupted state; main checks got_sigint and prints CAUGHT. Fix the vDSO sigreturn trampoline to use `mov ah, SYS_SYS_SIGRETURN` instead of `mov eax, SYS_SYS_SIGRETURN` — the INT 30h dispatcher reads AH for the syscall number, so the mov-eax form left AH=0 and dispatched SYS_FS_CHMOD (0x00) instead of SYS_SYS_SIGRETURN (0xF6), causing an EXC0E page fault in the trampoline. Add sigint_test to tests/test_programs.py with command `"sigint_test\n\x03"`: the \n terminates the shell input so the program launches, then the 0x03 sits in the serial FIFO for the program's SYS_IO_READ to consume. Co-Authored-By: Claude Sonnet 4.6 --- src/vdso/vdso.asm | 2 +- tests/programs/sigint_test.c | 39 ++++++++++++++++++++++++++++++++++++ tests/test_programs.py | 12 +++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/programs/sigint_test.c diff --git a/src/vdso/vdso.asm b/src/vdso/vdso.asm index 1a38e52b..71a03186 100644 --- a/src/vdso/vdso.asm +++ b/src/vdso/vdso.asm @@ -635,6 +635,6 @@ print_datetime_month_lengths: times (VDSO_SIGRETURN_OFFSET - ($ - $$)) db 0 __kernel_sigreturn: - mov eax, SYS_SYS_SIGRETURN ; 0xF6 + mov ah, SYS_SYS_SIGRETURN ; 0xF6 in AH — dispatcher reads AH int 0x30 ;; never returns diff --git a/tests/programs/sigint_test.c b/tests/programs/sigint_test.c new file mode 100644 index 00000000..c123626c --- /dev/null +++ b/tests/programs/sigint_test.c @@ -0,0 +1,39 @@ +/* End-to-end smoke test for SIGINT handler delivery and sigreturn. + Registers on_sigint as the SIGINT handler, then calls SYS_IO_READ. + The serial Ctrl+C (0x03) byte is already queued when the read fires: + fd_read_console reads it, sets pending_sigint, and returns the byte + (AX = 1, CF clear). The syscall epilogue's SIGINT_TAIL_CHECK sees + pending_sigint and calls signal_dispatch_user, which builds a + sigcontext on the user stack and iretds into on_sigint. on_sigint + sets got_sigint = 1 and returns through the vDSO sigreturn + trampoline. signal_resume_after_handler restores the interrupted + register state and iretds back to user code at the instruction + following the SYS_IO_READ int 30h. Main checks got_sigint and + prints CAUGHT to confirm the full delivery and sigreturn round-trip. + + Pairs with the sigint_test entry in tests/test_programs.py. */ + +int got_sigint; +char read_buf[4]; + +void on_sigint(int signum) { + got_sigint = 1; +} + +int main() { + asm("mov ebx, SIGINT\n" + "mov ecx, on_sigint\n" + "mov ah, SYS_SYS_SIGNAL\n" + "int 30h\n"); + asm("mov ebx, 0\n" + "mov edi, _g_read_buf\n" + "mov ecx, 1\n" + "mov ah, SYS_IO_READ\n" + "int 30h\n"); + if (got_sigint) { + printf("CAUGHT\n"); + } else { + printf("NO_SIGNAL\n"); + } + return 0; +} diff --git a/tests/test_programs.py b/tests/test_programs.py index f653550a..22560f00 100755 --- a/tests/test_programs.py +++ b/tests/test_programs.py @@ -826,6 +826,18 @@ def _ext2_pick_straddle_target_offset(*, block_size: int, initial_offset: int) - # block 0, where the straddle_dir test still finds a usable # boundary at 512 — longer names push past 492 and break it. ProgramTest("seek", ["seek"], r"^seek: OK$"), + # Registers an on_sigint handler, calls SYS_IO_READ, and sends a + # Ctrl+C (0x03) byte so fd_read_console detects it, sets + # pending_sigint, and returns the byte. The syscall epilogue's + # SIGINT_TAIL_CHECK dispatches to on_sigint via signal_dispatch_user; + # the handler sets got_sigint and returns through the vDSO sigreturn + # trampoline; signal_resume_after_handler restores the interrupted + # state and iretds back to user code. Main checks got_sigint and + # prints CAUGHT, confirming the full delivery and sigreturn round-trip. + # The "\n" in the command terminates the shell input line; the + # following 0x03 byte arrives in the serial FIFO for the program's + # read call to consume. + ProgramTest("sigint_test", ["sigint_test\n\x03"], r"^CAUGHT$"), ProgramTest("stackbomb", ["stackbomb", "echo recovered"], r"stackbomb: starting recursion[\s\S]*EXC0E[\s\S]*recovered"), # Confirms the user stack lives at the user/kernel boundary # (USER_STACK_TOP = KERNEL_VIRT_BASE). ESP at iretd equals From 48e5361491ce8c2d2fd441b274fec729ce991478 Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 01:25:28 -0700 Subject: [PATCH 20/22] =?UTF-8?q?docs:=20SIGINT=20handling=20=E2=80=94=20s?= =?UTF-8?q?yscalls,=20architecture,=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- docs/CHANGELOG.md | 1 + docs/architecture.md | 54 ++++++++++++++++++++++++++++++++++++++++++++ docs/syscalls.md | 19 ++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index baf50670..180a8998 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,7 @@ at the time. ## [Unreleased](https://github.com/bboe/BBoeOS/compare/0.10.0...main) +- **SIGINT handling**: Ctrl+C on PS/2 kills runaway user programs by default (`SIG_DFL`); programs may register a handler via `signal(SIGINT, handler)` using `sys_signal` (INT 30h AH=F5h). Delivery builds a 48-byte sigcontext on the user stack and redirects the IRET frame to the handler; a two-instruction vDSO trampoline (`__kernel_sigreturn` at user-virt `0x10450`) issues `sys_sigreturn` (AH=F6h) to restore the saved context. Cooperative-interruption convention added to `fd_read_console` and `MIDI_IOCTL_DRAIN`: both return early with `CF=1, AL=ERROR_INTERRUPTED` (maps to `EINTR` in libc) when `pending_sigint` is set mid-wait. See `docs/architecture.md` for the two-axis detection/delivery model and `docs/superpowers/specs/2026-05-06-sigint-handling-design.md` for the full design spec. - **drivers/sb16**: switch SB16 PCM playback from synchronous single-cycle DMA to auto-init double-buffering with a kernel-side software ring. `sb16_open` programs the 8237 in auto-init mode (mode byte `0x59`) and starts the DSP via `0x48` (set block size) + `0x1C` (8-bit auto-init PCM, no args), so the DSP loops the 4 KB DMA buffer indefinitely and fires IRQ 5 every `AUDIO_HALF_SIZE` (2 KB) bytes — at which point `sb16_refill` (called from `pmode_irq5_handler`) drains the 4 KB software ring into the just-finished DMA half and pads with silence on underrun. `fd_write_audio` becomes a non-blocking ring producer: it returns as soon as user bytes are queued instead of blocking for the chunk's playback duration. Doom's per-tick audio write (~315 bytes) used to pin the engine at the SB16's chunk rate (~28 ms blocking per write, magnified by the 1 kHz PIT's hlt thrashing), starving the renderer; with the auto-init ring it's a single memcpy into the ring and Doom hits frame 30 in ~0.5 s instead of dragging through 3 frames in 12 s. - **drivers/rtc**: fix `date` returning a wildly off month/day that drifted forward by ~50 ms of `system_ticks` per call. The C-port `rtc_read` (`drivers: port rtc.asm to C`, 2026-04-28) compiled `kernel_outb(0x70, reg); return kernel_inb(0x71)` into `mov edx, 0x70 / out dx, al / mov edx, 0x71 / in al, dx`, clobbering `EDX` — but `rtc_read_date_internal` writes the month into `DH` and then calls `rtc_read` again for the day, so the second call's `mov edx, 0x71` zeroed `DH` before `mov dl, al` landed. C then read `epoch_month = 0`, computed `month_index = -1`, and `rtc_month_days[-1]` indexed exactly onto `_g_system_ticks` in the global layout. Restore `rtc_read` to the original immediate-port asm shape (`out 0x70, al / in al, 0x71 / ret`) so it preserves `EDX` again. `tests/test_programs.py`'s `date` test now runs the command three times and requires the dates to agree. - **kernel**: widen `SYS_RTC_DATETIME`, `SYS_RTC_MILLIS`, `SYS_RTC_UPTIME` to return the full 32-bit value in `EAX` (was `DX:AX` for the first two, sign-extended `AX` for uptime — which silently truncated past 9 h and zero-wrapped at 18 h). `SYS_RTC_SLEEP` now reads the full duration from `ECX` (was `CX`, capped at 65 535 ms). vDSO `shared_print_datetime`, the libc `gettimeofday`, and the Doom `DG_GetTicksMs` / `DG_SleepMs` helpers updated to match. diff --git a/docs/architecture.md b/docs/architecture.md index 9dc3bacb..afcf6d2d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,3 +39,57 @@ disk: ## Build-time derivation - Kernel sector count and reserved-region base are both derived at build time: `make_os.sh` measures `kernel.bin`, passes the sector count to `boot.asm` as `-DKERNEL_SECTORS=N`, computes `KERNEL_RESERVED_BASE = page_align(0x20000 + sizeof(kernel.bin))`, then re-assembles `kernel.asm` and `boot.asm` with `-DKERNEL_RESERVED_BASE=N`. A size-invariant check between the two `kernel.asm` passes confirms the change cannot shift the binary. A separate VGA-hole assert verifies that `KERNEL_RESERVED_BASE + reserved-region-size < 0xA0000` so the kernel-side fixed-phys regions never cross the VGA aperture (which is what lets the OS boot under QEMU `-m 1`). The boot-time `kernel_bytes` word at MBR offset 508 holds `(BOOT_SECTORS + KERNEL_SECTORS) * 512` so `add_file.py`'s host-side `compute_directory_sector` arithmetic still works. + +## SIGINT handling + +SIGINT delivery is split into two independent axes — detection and delivery — and three dispatch modes depending on the handler registered by the program. + +### Detection + +Two paths set the kernel global `pending_sigint` (a single byte in kernel BSS): + +- **PS/2 IRQ 1** (`src/drivers/ps2.c`): the cooked-byte path recognises the Ctrl+C scancode sequence and sets `pending_sigint` before returning from the IRQ handler. Because IRQ 1 fires for every keypress regardless of what the CPU is executing, this path works unconditionally — even a tight compute loop in user code is interrupted. +- **Serial 0x03 read** (`src/fs/fd/console.c`, `fd_read_console`): the serial poll branch checks each received byte; if it equals `0x03` (ASCII ETX, the byte a terminal sends for Ctrl+C) it sets `pending_sigint` and does not enqueue the byte into the line buffer. + +### Delivery + +Every interrupt and syscall return path passes through the `SIGINT_TAIL_CHECK` macro (defined in `src/arch/x86/sigint.asm`, inlined into the IRET epilogues in `idt.asm` and the INT 30h handler in `entry.asm`). The macro: + +1. Tests `pending_sigint`; if clear, falls through to the normal IRET. +2. Clears `pending_sigint`. +3. Checks the IRET frame's CS: if `RPL != 3` the signal is suppressed (the kernel itself does not receive SIGINT — only user programs do). +4. Dispatches according to the registered handler (`signal_handler` in entry.asm BSS, one dword per program, reset to `SIG_DFL` on each `shell_reload`). + +### Dispatch modes + +- **`SIG_DFL` (0)** — `signal_dispatch_kill`: calls `address_space_destroy` on the current program's PD, prints `^C` to the console, and falls into `shell_reload`. This is the out-of-the-box Ctrl+C behaviour: a runaway program is terminated and the shell prompt reappears. +- **`SIG_IGN` (1)** — clear `pending_sigint` (already done by `SIGINT_TAIL_CHECK`), resume the IRET path unchanged. The signal is silently discarded; the program continues as if nothing happened. +- **User handler (virt addr ≥ `PROGRAM_BASE`)** — `signal_dispatch_user`: builds a 48-byte `sigcontext` record on the user stack (pushed below the current user ESP), rewrites the IRET frame so the CPU returns to the handler address at ring 3, and leaves `[user_esp]` pointing at the `sigcontext`. The saved context captures EIP, EFLAGS, ESP (pre-signal), EAX, EBX, ECX, EDX, ESI, EDI, and EBP so the handler can be transparent to the interrupted code. + +### Handler resume via vDSO trampoline + +The vDSO page (mapped read-only at user-virt `0x10000`) contains a two-instruction trampoline `__kernel_sigreturn` at offset `0x450` (user-virt `0x10450`): + +```nasm +mov ah, SYS_SYS_SIGRETURN ; AH = F6h +int 30h +``` + +`signal_dispatch_user` pushes the trampoline address as the return address on the user stack so the handler executes a plain `ret` to reach it. `sys_sigreturn` (INT 30h AH=F6h) then: + +1. Validates that `[user_esp + 4]` (the saved sigcontext pointer) is within the user address space. +2. Restores EIP, EFLAGS (with `IF` forced to 1 and `IOPL` forced to 0), ESP, and the general-purpose registers from the sigcontext. +3. Issues an `iretd` directly into the restored context, never returning through the normal syscall epilogue. + +### Cooperative interruption of blocking syscalls + +Long-blocking syscalls poll `pending_sigint` between iterations and bail out early rather than forcing a delivery through the IRET path: + +- **`fd_read_console`** (the console read loop in `src/fs/fd/console.c`): checks `pending_sigint` after each character poll cycle. If set, it returns immediately with `CF=1, AL=ERROR_INTERRUPTED` without consuming the flag (the `SIGINT_TAIL_CHECK` epilogue handles final delivery). +- **`MIDI_IOCTL_DRAIN`** (the `sti`/`hlt` drain loop in `src/fs/fd/midi.c`): checks `pending_sigint` after each `hlt` wakeup. Same early-exit convention: `CF=1, AL=ERROR_INTERRUPTED`. + +The libc `errno` layer in `tools/libc/syscall.c` maps `ERROR_INTERRUPTED` to `EINTR`, so portable C programs using `read()` get the standard POSIX interrupted-call semantics. + +### Known limitation (v1) + +Serial Ctrl+C is detected only while `fd_read_console` is actively polling the serial port — a program that never calls `read(0, ...)` over serial cannot be killed via serial Ctrl+C. PS/2 Ctrl+C has no such restriction because IRQ 1 fires unconditionally from hardware. diff --git a/docs/syscalls.md b/docs/syscalls.md index c4056afb..5cc7c895 100644 --- a/docs/syscalls.md +++ b/docs/syscalls.md @@ -37,6 +37,8 @@ Syscall numbers are defined symbolically as `SYS_*` constants in | F2h | sys_exit | Reload and return to shell | | F3h | sys_reboot | Reboot | | F4h | sys_shutdown | Shutdown | +| F5h | sys_signal | Register SIGINT handler. EBX = signum (SIGINT only), ECX = handler (SIG_DFL=0, SIG_IGN=1, or user-virt addr ≥ PROGRAM_BASE); EAX = previous handler. CF set + AL=ERROR_INVALID on bad signum/addr | +| F6h | sys_sigreturn| Restore sigcontext from user stack at [user_esp + 4]; never returns through the regular path — resumes the saved EIP/EFLAGS/ESP/registers. Used only via the vDSO trampoline at the end of a SIGINT handler | ## `/dev/midi` ioctls (FD_TYPE_MIDI = 6) @@ -48,3 +50,20 @@ Syscall numbers are defined symbolically as `SYS_*` constants in Wire format on `/dev/midi` is 6-byte commands: `(delay_lo, delay_hi, bank, reg, value, reserved)`. + +## Error codes + +When a syscall sets CF on return, AL holds one of these codes (symbolic +names in `src/include/constants.asm`): + +| AL | Name | Meaning | +|-----|-----------------------|--------------------------------------------------------------| +| 01h | ERROR_DIRECTORY_FULL | No free directory entries (copy/create) | +| 02h | ERROR_EXISTS | Destination name already exists (rename/copy) | +| 03h | ERROR_NOT_EXECUTE | File exists but is not executable (exec) | +| 04h | ERROR_NOT_FOUND | File not found | +| 05h | ERROR_PROTECTED | File is protected (rename/chmod) | +| 06h | ERROR_NOT_EMPTY | Directory is not empty (rmdir) | +| 07h | ERROR_FAULT | Bad user pointer: out of user range, wraps, or filename has no NUL within MAX_PATH | +| 08h | ERROR_INTERRUPTED | Cooperative-interrupt return (SIGINT pending during blocking syscall) — maps to `EINTR` in libc | +| 09h | ERROR_INVALID | Invalid argument (bad signum, out-of-range handler address, etc.) | From 05f31c7f5349f6adf67550c98d4668660c6489c3 Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 01:43:36 -0700 Subject: [PATCH 21/22] kernel: sanitize EFLAGS in SYS_SYS_SIGRETURN (drop IOPL/VM/NT/RF, force IF=1) A user-installed SIGINT handler controls every byte of the on-stack sigcontext, including saved_eflags. signal_resume_after_handler loaded that dword verbatim into the iret frame, so a handler could return through the vDSO trampoline with IOPL=3 (granting ring-3 unrestricted in/out), VM=1 (Virtual-8086 entry), NT=1, RF=1, etc. Mask saved_eflags down to CF/PF/AF/ZF/SF/TF/DF/OF (USER_EFLAGS_MASK = 0xDD5) and force IF=1 before reloading. Mirrors Linux's restore_sigcontext FIX_EFLAGS rationale. --- src/arch/x86/signal.c | 8 ++++++++ src/include/constants.asm | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/arch/x86/signal.c b/src/arch/x86/signal.c index 57e5ed02..75cfc02d 100644 --- a/src/arch/x86/signal.c +++ b/src/arch/x86/signal.c @@ -197,6 +197,14 @@ asm("signal_resume_after_handler:\n" " mov eax, [edi + 4]\n" // saved_eip " mov [esp + 32], eax\n" // iret EIP " mov eax, [edi + 8]\n" // saved_eflags + // Sanitize before reloading into the iret frame: the user controls + // every bit of saved_eflags via the on-stack sigcontext, so without + // masking a handler could return with IOPL=3 (ring-3 in/out) or + // VM=1 (Virtual-8086 entry) — privilege escalation. Keep only + // CF/PF/AF/ZF/SF/TF/DF/OF; force IF=1 so user code stays + // interruptible. + " and eax, USER_EFLAGS_MASK\n" + " or eax, EFLAGS_IF_BIT\n" " mov [esp + 40], eax\n" // iret EFLAGS " mov eax, [edi + 12]\n" // saved_esp " mov [esp + 44], eax\n" // iret ESP diff --git a/src/include/constants.asm b/src/include/constants.asm index 134ffee4..2feaa864 100644 --- a/src/include/constants.asm +++ b/src/include/constants.asm @@ -167,6 +167,19 @@ %assign SIG_DFL 0 %assign SIG_IGN 1 + ;; EFLAGS sanitization for SYS_SYS_SIGRETURN. The saved EFLAGS + ;; in a sigcontext lives on the user stack and is fully under + ;; user control, so a malicious handler could otherwise return + ;; through the trampoline with IOPL=3 (ring-3 in/out) or VM=1 + ;; (Virtual-8086 entry), etc. We whitelist only the user- + ;; arithmetic flags + TF + DF + OF (forced IF separately) and + ;; discard IOPL (bits 12-13), NT (14), RF (16), VM (17), AC + ;; (18), VIF/VIP/ID (19-21). Mirrors Linux's restore_sigcontext + ;; FIX_EFLAGS rationale. Kept bits: CF=0, PF=2, AF=4, ZF=6, + ;; SF=7, TF=8, DF=10, OF=11 → 0xDD5. + %assign USER_EFLAGS_MASK 0xDD5 + %assign EFLAGS_IF_BIT 0x200 ; IF (bit 9) — forced on after sanitize + %assign TSS_SELECTOR 28h ; GDT[5]: 32-bit available TSS, DPL=0 %assign USER_CODE_SELECTOR 1Bh ; GDT[3] | RPL=3: ring-3 code segment (flat 4 GB) %assign USER_DATA_BASE 1000h ; user-virt of the shell↔program handoff frame (ARGV / EXEC_ARG / BUFFER); PTE[0] (virt 0..0xFFF) stays unmapped so NULL deref faults From 58f5c80a5eba762fbbe0bb5ecfdc347995216a9f Mon Sep 17 00:00:00 2001 From: Bryce Boe Date: Thu, 7 May 2026 01:43:52 -0700 Subject: [PATCH 22/22] docs: correct SIGINT architecture section (file paths, dispatch order, EFLAGS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SIGINT section had several errors caught in code review: - Macro lives in src/include/irq_tail.inc, not src/arch/x86/sigint.asm. - Insertion sites are entry.asm (IRQ 0/5/6), ps2.c (IRQ 1), fdc.c (IRQ 6), syscall.asm (INT 30h) — not idt.asm. - Dispatch order is CS-check first, then pending_sigint, then in_sigint_handler, then read sigint_handler. pending_sigint is cleared by SIG_IGN and signal_dispatch_user; the SIG_DFL kill path does not (the program is dying). - BSS slot is sigint_handler, not signal_handler. - vDSO trampoline lives at user-virt 0x10450 (FUNCTION_TABLE + VDSO_SIGRETURN_OFFSET). - sys_sigreturn validates saved_eip and saved_esp ranges, not a separate "saved sigcontext pointer at [user_esp + 4]" — the sigcontext IS the on-stack frame; saved_eip lives at +4 because the trampoline's `ret` already popped the trampoline address. - After the EFLAGS sanitize fix, restate the EFLAGS-restore wording: arithmetic flags + DF + TF preserved, IF forced on, IOPL/VM/NT/RF cleared via USER_EFLAGS_MASK. --- docs/architecture.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index afcf6d2d..164fe90e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,12 +53,13 @@ Two paths set the kernel global `pending_sigint` (a single byte in kernel BSS): ### Delivery -Every interrupt and syscall return path passes through the `SIGINT_TAIL_CHECK` macro (defined in `src/arch/x86/sigint.asm`, inlined into the IRET epilogues in `idt.asm` and the INT 30h handler in `entry.asm`). The macro: +Every interrupt and syscall return path passes through the `SIGINT_TAIL_CHECK` macro (defined in `src/include/irq_tail.inc`, inlined into the IRQ 0/5/6 handlers in `src/arch/x86/entry.asm`, the IRQ 1 handler `ps2_irq1_handler` in `src/drivers/ps2.c`, the IRQ 6 handler `fdc_irq6_handler` in `src/drivers/fdc.c`, and the INT 30h handler in `src/arch/x86/syscall.asm`). The macro: -1. Tests `pending_sigint`; if clear, falls through to the normal IRET. -2. Clears `pending_sigint`. -3. Checks the IRET frame's CS: if `RPL != 3` the signal is suppressed (the kernel itself does not receive SIGINT — only user programs do). -4. Dispatches according to the registered handler (`signal_handler` in entry.asm BSS, one dword per program, reset to `SIG_DFL` on each `shell_reload`). +1. Checks the IRET frame's CS: if `RPL != 3` the signal is suppressed (the kernel itself does not receive SIGINT — only user programs do). +2. Tests `pending_sigint`; if clear, falls through to popad + IRET. +3. Tests `in_sigint_handler`; if set, falls through to popad + IRET (block re-entry until SYS_SYS_SIGRETURN clears the flag). +4. Reads `sigint_handler` (entry.asm BSS, one dword per program, reset to `SIG_DFL` on each `shell_reload`). +5. Branches: SIG_DFL → `signal_dispatch_kill` (does NOT clear `pending_sigint` — the program is dying anyway); SIG_IGN → clear `pending_sigint`, fall through; user-virt → `signal_dispatch_user` (which clears `pending_sigint` itself). ### Dispatch modes @@ -68,18 +69,18 @@ Every interrupt and syscall return path passes through the `SIGINT_TAIL_CHECK` m ### Handler resume via vDSO trampoline -The vDSO page (mapped read-only at user-virt `0x10000`) contains a two-instruction trampoline `__kernel_sigreturn` at offset `0x450` (user-virt `0x10450`): +The vDSO page (mapped read-only at user-virt `0x10000`) contains a two-instruction trampoline `__kernel_sigreturn` at user-virt `0x10450` (`FUNCTION_TABLE + VDSO_SIGRETURN_OFFSET`): ```nasm mov ah, SYS_SYS_SIGRETURN ; AH = F6h int 30h ``` -`signal_dispatch_user` pushes the trampoline address as the return address on the user stack so the handler executes a plain `ret` to reach it. `sys_sigreturn` (INT 30h AH=F6h) then: +`signal_dispatch_user` writes the trampoline address as the first dword of the on-stack sigcontext so the handler executes a plain `ret` to reach it. After the trampoline pops that return address, the user ESP points one dword into the sigcontext (so saved_eip lives at `[user_esp + 4]`, saved_eflags at `[user_esp + 8]`, saved_esp at `[user_esp + 12]`, etc.). `sys_sigreturn` (INT 30h AH=F6h) then: -1. Validates that `[user_esp + 4]` (the saved sigcontext pointer) is within the user address space. -2. Restores EIP, EFLAGS (with `IF` forced to 1 and `IOPL` forced to 0), ESP, and the general-purpose registers from the sigcontext. -3. Issues an `iretd` directly into the restored context, never returning through the normal syscall epilogue. +1. Validates that the sigcontext's saved_eip and saved_esp are both within the user address space (`PROGRAM_BASE..KERNEL_VIRT_BASE`); failure routes to `signal_dispatch_kill`. +2. Restores EIP, ESP, and a sanitized subset of EFLAGS (arithmetic flags + DF + TF; IF forced on, IOPL/VM/NT/RF cleared per `USER_EFLAGS_MASK` in `src/include/constants.asm`), plus the general-purpose registers from the sigcontext. +3. Clears `in_sigint_handler` and (if a SIGINT arrived during the handler) re-dispatches it before the final `iretd` so back-to-back Ctrl+Cs are not lost. ### Cooperative interruption of blocking syscalls