Skip to content

kernel: SIGINT handling (Ctrl+C → kill / SIG_IGN / user handler)#331

Closed
bboe wants to merge 22 commits intomainfrom
worktree-timer
Closed

kernel: SIGINT handling (Ctrl+C → kill / SIG_IGN / user handler)#331
bboe wants to merge 22 commits intomainfrom
worktree-timer

Conversation

@bboe
Copy link
Copy Markdown
Owner

@bboe bboe commented May 7, 2026

Summary

  • Adds Linux-shaped SIGINT delivery: PS/2 IRQ 1 cooked-byte path and serial-read path set a global pending_sigint flag. Every kernel-to-user IRET epilogue (5 IRQ handlers + the syscall iret tail) checks the flag via the new SIGINT_TAIL_CHECK macro and dispatches to one of SIG_DFL (kill via address_space_destroy + shell_reload), SIG_IGN (clear and resume), or a user handler delivered via a 48-byte sigcontext on the user stack + vDSO trampoline + SYS_SYS_SIGRETURN resume.
  • Fixes the runaway-program bug: a tight while(1) user loop is now killable from PS/2 within ~1 ms (IRQ 0 epilogue catches it). Shell installs SIG_IGN so its own Ctrl+C is benign; children inherit SIG_DFL from program_enter and are killable by default.
  • Adds cooperative interruption to fd_read_console and MIDI_IOCTL_DRAIN: pending_sigint checked in their wait loops, bail with CF=1, AL=ERROR_INTERRUPTED. libc maps to errno = EINTR.
  • New syscalls SYS_SYS_SIGNAL (0xF5) and SYS_SYS_SIGRETURN (0xF6); libc signal() wrapper.
  • EFLAGS sanitization in SYS_SYS_SIGRETURN (mask 0xDD5, force IF=1) prevents user handlers from escalating IOPL via a forged sigcontext — the one privilege issue the design naturally introduces.

Design spec: docs/superpowers/specs/2026-05-06-sigint-handling-design.md
Implementation plan: docs/superpowers/plans/2026-05-06-sigint-handling.md

Known limitations (documented)

  • Serial-only Ctrl+C cannot kill a runaway program in v1 (detection only fires when something is reading the console fd). PS/2 detection works regardless of user state. A future move of COM1 to IRQ-driven input fixes this.
  • Single signal: only SIGINT delivered. The IRET-rewrite mechanism generalizes — adding SIGTERM / SIGSEGV / SIGCHLD later is mostly a per-program-state extension.
  • Async timer-callback feature (the originally motivating use case for OPL3 in Doom) was deferred — this PR establishes the IRET-rewrite primitive that the timer feature will reuse.

Test plan

  • tests/test_programs.py — 30 / 30 pass under bbfs (includes new sigint_test exercising handler install + sigcontext build + sigreturn end-to-end via serial Ctrl+C)
  • tests/test_asm.py — 20 / 20 pass (no regression in self-hosted assembler suite)
  • Boot smoke tests at every implementation step under both HDD and floppy modes — shell prompt reaches $ cleanly
  • Manual: PS/2 runaway kill — boot in QEMU window, run a while(1) program, press Ctrl+C, confirm shell prompt returns. Cannot be automated (requires real keyboard input)
  • Manual: shell SIG_IGN — press Ctrl+C at a shell prompt with no child running; shell stays alive
  • Manual: handler over PS/2 — register a SIGINT handler, press Ctrl+C in QEMU window, confirm handler runs and program resumes

Commits (20)

Atomic per-task series following the implementation plan: constants → BSS state → kill path → detection → IRQ epilogues → syscall epilogue → SYS_SYS_SIGNAL → libc signal() → shell SIG_IGN → vDSO trampoline → signal_dispatch_user → SYS_SYS_SIGRETURN → cooperative interruption → libc EINTR → automated test → docs → EFLAGS sanitization fix.

🤖 Generated with Claude Code

bboe and others added 22 commits May 6, 2026 23:39
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ce 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.
…, EFLAGS)

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.
@bboe
Copy link
Copy Markdown
Owner Author

bboe commented May 8, 2026

Superseded by the three-PR series #332 (default-kill), #333 (SIG_IGN opt-out), #334 (user-handler + sigreturn) — all merged. Closing the original monolithic PR.

@bboe bboe closed this May 8, 2026
@bboe bboe deleted the worktree-timer branch May 8, 2026 05:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant