Skip to content

#DF: deliver via a task gate on a dedicated stack (closes #119) - #122

Merged
douglasmun merged 1 commit into
mainfrom
fix/double-fault-task-gate
Aug 23, 2026
Merged

#DF: deliver via a task gate on a dedicated stack (closes #119)#122
douglasmun merged 1 commit into
mainfrom
fix/double-fault-task-gate

Conversation

@douglasmun

Copy link
Copy Markdown
Owner

What

Vector 8 was an ordinary interrupt gate, so a double fault took the same path as every other exception: isr8isr_commonisr_common_handler, whose first act is pushing a register frame onto the current stack.

That is fine for every cause of #DF except kernel stack exhaustion — the one case where the current stack has no room for those pushes. There the push itself re-faults, and the CPU triple-faults and resets. The dedicated handler double_fault_c_handler() — written for a dedicated stack, and saying so in its own comment — had no call site in the linked binary, and the 8 KB double_fault_stack plus tss_get_double_fault_stack_top() were allocated and never used.

i386 has no IST, so the fix is the architectural one: a task gate with its own TSS, which switches ESP as part of delivery rather than after it.

Verified

verify-double-fault.sh drives dftest, which exhausts the kernel stack by construction. The handler now runs and dumps the interrupted task state read from the outgoing TSS:

Handled via task gate on a dedicated stack (top=0x00278010),
so this dump survives even a fully exhausted kernel stack.
  EIP=0x00136f53  EFLAGS=0x00000206
  Ring: 0   esp0=0x004d8000 ss0=0x0018

Closes #119.

🤖 Generated with Claude Code

Vector 8 was an ordinary interrupt gate, so a double fault took the same
path as every other exception: isr8 -> isr_common -> isr_common_handler,
whose first act is pushing a register frame onto the CURRENT stack.
double_fault_c_handler() -- written for a dedicated stack, and saying so in
its own comment -- had no call site in the linked binary, and the 8 KB
double_fault_stack plus tss_get_double_fault_stack_top() were allocated and
never read.

This only mattered in one case, but it is the common one. For a #DF on a
HEALTHY stack the generic path was already fine and printed more than the
dead handler would. The gap was kernel STACK EXHAUSTION: there the stack is
already gone, so isr_common's pushes fault again and the CPU escalates to a
triple fault -- instant reset, no output. TinyOS has hit exactly this (the
signed-exec chain overflowing a 64 KB stack is why KERNEL_TASK_STACK_PAGES
is 32 today), and a recurrence produced nothing to debug.

i386 has no IST -- that is x86-64. The only construct that switches stacks
on an exception is a hardware task switch, so vector 8 becomes a TASK GATE
(type 0x85, selector = a dedicated TSS, offset ignored) pointing at a second
TSS whose esp/eip/cr3/segments the CPU loads wholesale. The handler starts
on a known-good stack whatever the faulting one looks like, and reads the
interrupted context out of the outgoing TSS.

  tss.c    df_tss + tss_init_double_fault(); captures CR3 after paging is
           live, so a #DF inside a user process still finds the handler
           mapped. Uses the KERNEL pdpt, valid in every address space.
  gdt.c    gdt_grow_for_df_tss() extends gdtr.limit to cover the new
           descriptor and re-lgdts. Bounds-checked: a descriptor past the
           limit would #GP inside the gate, i.e. the failure being fixed.
  idt.c    idt_install_double_fault_gate() rewrites entry 8 as a task gate.
  interrupts.c  double_fault_c_handler -> double_fault_task_entry: no args,
           never returns (there is nothing to return to), reads the faulting
           state from the main TSS and the backlink from df_tss.prev_tss.

The stack-exhaustion tell measures DISTANCE FROM esp0, not page equality.
esp0 is the TOP of a stack that grows DOWN, so an exhausted stack puts ESP
as far below esp0 as possible; a same-page test fires only on a nearly EMPTY
stack, i.e. exactly backwards. The first draft had it backwards and the
harness caught it. "Used" can exceed capacity once ESP runs past the base --
labelled, so it is not read as a quota.

Not addressed: a hardware task switch sets the busy bit in the DF TSS
descriptor, so a SECOND #DF would #GP. Acceptable here because the handler
halts and never returns, so there is no second one by construction.

Harness: verify/verify-double-fault.sh (needs -DTINYOS_FAULT_INJECT for the
`dftest` command, which recurses until the stack is gone). Asserts the gate
is installed, the handler prints, the dump carries the interrupted state,
the diagnosis names stack exhaustion, and -- independently, from QEMU rather
than from our own kprintf -- that the CPU never triple-faults.

Both polarities were run. Fixed tree: PASS, with ESP 852 KB below esp0 on a
128 KB stack (through the guard page and onward). Suppressing only the
idt_install_double_fault_gate() call: FAIL, with the trace showing
v=0e -> v=08 -> Triple fault and serial ending at the [FAULT] line. The
watchdog exists because that unfixed kernel REBOOTS and the typist would
otherwise log in and re-run dftest forever, reporting as a hang and not the
FAIL it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEkhAhgTxbE5TgifyYf8v4
@douglasmun
douglasmun merged commit b2ea123 into main Aug 23, 2026
2 checks passed
@douglasmun
douglasmun deleted the fix/double-fault-task-gate branch August 23, 2026 14:16
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.

#DF runs on the shared kernel stack: double_fault_c_handler and its 8 KB stack are dead code

1 participant