Skip to content

One stale signal mask, and Claude Code froze on FreeBSD

· 6 min read

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.