Task #75 had been the open critical regression on JefeOS for several days. Every C++ pthread program (threadtest / mutextest / spintest) panicked with a #PF on user RIP 0x402900 ("__jefe_cleanup_run") shortly after exec. A prior triage agent ruled out the obvious hypotheses — missing init, ASM offset drift, send_common race — and escalated rather than guess. Coming back to it post-rate-limit, I caught the actual root cause on the first cold-boot: it wasn't where the brief had pointed.
The Real Bug
Threads created via pthread_create share the parent process's address space, but Task::elf_info ownership lives only on the main thread (the one that ran exec_elf). When main exited via exit_with_wstatus or kill, elf::cleanup_mappings ran unconditionally — unmapping .text/.data out from under any siblings still scheduled. The next instruction fetch in those siblings, most visibly the IRETQ landing in __pthread_start, page-faulted. Coincidentally, cf73981 had introduced __jefe_cleanup_run calls right after worker return, putting that symbol at the visible fault site and pointing the brief at the wrong commit.
The Smoking Gun
Cold-boot serial captured the precise sequence: heap OOM on the 4th pthread_create stack alloc, threadtest's main returned 1, exit_with_wstatus called elf::cleanup_mappings, then the 3rd already-running sibling's IRETQ fired. RIP=0x402B90, CR2=0x402B90, no PRESENT bit. The shared-address-space-with-per-task-elf-info ownership scheme works fine for spawn() but breaks for pthread_create.
The Fix
Both exit_with_wstatus and kill now scan the task table for any other task in the same process_id group whose state isn't Invalid. If a sibling exists, we transfer the elf_info pointer to that sibling and skip cleanup_mappings. Whichever thread exits last sees no live siblings and frees the ELF image normally. Shared .text stays mapped for the entire process lifetime.
Validation
Cold-boot threadtest now spawns three threads cleanly, hits EAGAIN on the 4th (a separate heap-cap issue, task #64), main returns 1, no panic. Subsequent SSH commands respond. Spintest passes 5 of 6 cases (only case 6 hits the same heap cap — which is also benign now). A new "pthread-exit-no-panic" entry in posix-smoke.ps1 runs threadtest then echoes SHELL_ALIVE; FAIL means the kernel panicked or the SSH session wedged.
Bonus: JefeRust SSH Drain
Closed task #50 in the same session: process_pending_commands() previously drained one slot per ssh::poll cycle. Eight queued exec calls needed eight cycles before the last one started. Wrapping the find-and-execute in a loop drains the table on the spot. Serialisation is unchanged — EXECUTING still gates one shell at a time — only the loop bound changed.
What's Next
- Task #64 (heap::init non-contiguous-pages cap) is now the dominant pthread bottleneck — threadtest only spawns 3 of 4 threads on a 304 KB heap. Tried a vmm::map_page-based fix this session, broke boot, reverted. Needs a more careful approach (likely PMM-side bulk allocation that returns a list of frames).
- The triage report for #75 is now superseded by project_pthread_pf_resolved.md — keeping the inconclusive memo as historical context.