One stale signal mask, and Claude Code froze on FreeBSD
Bottom line up front
On a FreeBSD 15.1 laptop, Claude Code started freezing the terminal it ran in.
The process stayed alive, the console stopped accepting input, and finished
child processes piled up as zombies. Its main thread sat in linux_sys_futex
with no timeout.
It was not swap, and it was not a missing system call. It was a kernel bug in
the Linuxulator, FreeBSD's Linux binary compatibility layer:
linux_epoll_wait_ts() put back the wrong signal mask on its way out. A
JavaScriptCore garbage collector that pauses threads with a signal then waited
forever for a thread that could no longer receive that signal.
The bug is
298878, fixed in
main by commit
16a284b1cdfd
on 27 September 2026 and merged to stable/15 as 0143078248bb on 4 October.
It is not in releng/15.1, so freebsd-update will not bring it. Rebuilding
two kernel modules with the patch fixes it today.
The problem
The FreeBSD port misc/claude-code (2.1.283 here) is a shell wrapper around a
Linux ELF: Bun 1.4.3 with its JavaScriptCore engine, run through the
Linuxulator. Signal and Chromium on this laptop are native. Linux Chrome, Brave,
Slack and a few Electron apps live under /compat/linux alongside it.
The visible symptoms were two problems mixed together. Browsers stalled under
load, which turned out to be the simple one: /etc/fstab pointed swap at
da0p3, and the disk is ada0p3, so the machine had been running with no swap
at all. With swap fixed, Chrome and Brave behaved. Claude Code still hung.
A first diagnosis had been made in an earlier session on the laptop itself, by
the same tool that was hanging. It got the swap right, and it found five
orphaned sessions and their unreaped zombies. The cause of the futex wait it
could only guess at. The obvious suspect was pidfd_open(2), which the
Linuxulator does not implement: it returns ENOSYS, and a runtime that tracks
child processes through pidfds would never hear that a child had exited.
Bun's source ruled that out. When pidfd_open fails, Bun falls back to a waiter
thread driven by SIGCHLD and wait4(WNOHANG), and that path works under the
Linuxulator. The zombies were a symptom, not the cause: nothing reaps them once
the main thread is stuck.
A mask restored from the wrong place
JavaScriptCore stops threads to scan their stacks by sending them SIGPWR. The
signal handler parks the thread in sigsuspend(2) with SIGPWR blocked until
the collector is done. After that, the thread's td_oldsigmask in the kernel
holds a mask with SIGPWR in it. That is harmless, because nothing reads it
again unless asked to.
Bun's event loop calls epoll_pwait2(2) with a signal mask every time. In
FreeBSD 15.1, linux_epoll_wait_ts() handles that mask in two inconsistent
ways at once:
if (uset != NULL) {
error = kern_sigprocmask(td, SIG_SETMASK, uset,
&omask, 0);
...
td->td_pflags |= TDP_OLDMASK;
ast_sched(td, TDA_SIGSUSPEND);
}
It saves the caller's mask in a local omask, then tells the return-to-user
path that td_oldsigmask holds the mask to restore. It does not. It holds
whatever the last sigsuspend left there — here, a mask with SIGPWR blocked.
So every epoll_pwait2 call quietly blocked SIGPWR on Bun's main thread. The
next time the garbage collector tried to pause that thread, the signal never
arrived and the collector waited on a futex for good. From outside it looks
exactly like the reported symptom: one thread parked on a futex, everything
behind it frozen.
It shows up from Claude Code 2.1.269 onwards because the newer JavaScriptCore
pauses threads far more often. The same hang is reported in
anthropics/claude-code#96931,
#97063 and
#99331; the first of
those is where it was traced to linux_epoll_wait_ts().
The fix
The upstream patch is three hunks in sys/compat/linux/linux_event.c. It saves
the old mask where the AST code will look for it, and schedules the right AST
once the wait is over:
error = kern_sigprocmask(td, SIG_SETMASK, uset,
- &omask, 0);
+ &td->td_oldsigmask, 0);
At the end, TDA_SIGSUSPEND if the call was interrupted by a signal, so the
handler runs with the caller's mask, and TDA_PSELECT otherwise, which discards
the temporary mask. It applies cleanly to 15.1.
Only two modules change, so a full kernel build is not needed. The script left on the laptop does the minimum:
SRC=/usr/src
KB=/usr/obj/usr/src/amd64.amd64/sys/GENERIC
grep -q 'td->td_oldsigmask, 0);' $SRC/sys/compat/linux/linux_event.c ||
patch -p1 -d $SRC < epoll-sigmask-16a284b1cdfd.patch
for m in linux64 linux; do
make -C $SRC/sys/modules/$m KERNBUILDDIR=$KB clean all
make -C $SRC/sys/modules/$m KERNBUILDDIR=$KB KMODDIR=/boot/kernel install
done
kldxref /boot/kernel
KMODDIR=/boot/kernel matters. Without it, make install puts the modules in
/boot/modules. /boot/kernel comes first in the module path, so the old
modules keep loading and nothing changes. The new modules can be swapped in
without a reboot, as long as no Linux process is running:
kldunload linux64 linux && kldload linux linux64
There is a second, smaller issue on top. JavaScriptCore's concurrent
Collector Thread exits after 60 seconds idle, and on the next command the main
thread can wait on a condition variable nothing will signal (#99331). Turning
the concurrent collector off avoids it. The port's wrapper,
/usr/local/bin/claude, already exports BUN_JSC_useBBQJIT=0. Add one line
after it:
export BUN_JSC_useConcurrentGC=0
A pkg upgrade of claude-code replaces the wrapper, so that line has to go
back in after each upgrade.
A related fdescfs fix,
b3f7a438
(/proc/self/fd/N/... returning ENOTDIR,
#90984), is also in
main. One of its hunks does not apply to 15.1. It caused no symptoms here, so
it was left out.
Proof the trap is real
The patched modules went in on 11 October and were loaded live:
kldstat showed the new linux.ko and linux64.ko, and Linux Chrome ran
headless on them. Nine days later the laptop is on 15.1-p5, released on
13 October without this fix, and freebsd-update has put back the stock module:
-r--r--r-- 1 root wheel 468480 Oct 10 22:18 /boot/linux64.ko.orig-2026-10-11
-r--r--r-- 1 root wheel 468480 Oct 18 02:08 /boot/kernel/linux64.ko
Same size as the original saved before patching. The patched one was 396800
bytes. The source tree still has the fix, since p5 did not touch
linux_event.c, so the script only has to rebuild. This is the same pitfall
as the ig4 touchpad patch: every binary
update quietly reverts a locally built module.
Who else this affects
Anyone on FreeBSD 15.1 running a Bun or JavaScriptCore program through the
Linuxulator — Claude Code from the port, opencode, and anything else built on
Bun. The signature is a thread in linux_sys_futex with no timeout, zombies
under a live parent, and a terminal that has stopped reading.
No new bug report is needed: 298878 is filed and fixed. What is still missing is
an Errata Notice for 15.1. For this group of users it is a deadlock, not a
cosmetic bug, and that is the case worth making on the bug. Until then, or until
15.2, rebuild linux.ko and linux64.ko after every freebsd-update. When the
fix ships in a release, the grep in the script finds it already in the source
and the script can be deleted.