Skip to content

fix(runtime): make the SIGSEGV/SIGBUS trap async-signal-safe - #1655

Merged
Lorenzobattistela merged 11 commits into
bendlang:mainfrom
adeerkhan:fix/err-fail-signal-safe
Oct 20, 2026
Merged

Lorenzobattistela merged 11 commits into
bendlang:mainfrom
adeerkhan:fix/err-fail-signal-safe

Conversation

@adeerkhan

@adeerkhan adeerkhan commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1624

The problem

err_trap, the host SIGSEGV/SIGBUS handler, ended in err_fail, so it called fflush(stdout) and fprintf(stderr, ...) from inside a signal handler. Two things could go wrong:

  • A fault inside stdio could fault again in the flush. SIGSEGV is blocked inside the handler, so that second fault either killed the process with no numbered line (Linux) or hung it (macOS).
  • A fault and a fail-stop on two threads, or two faults at once, raced to print. The wrong cause could win, or the line could be printed twice.

The fix

  • err_claim picks one thread to end the process. It records the first caller's pthread_self() in an atomic with a compare-and-swap, and only that thread flushes stdout. Any other thread waits a second (nanosleep, resumed after a signal) for that thread's _exit. It may be holding the very lock the claimer is flushing on, so after the second it prints its own line and exits rather than hang. A second fault on the claiming thread's own exit path skips the flush and goes straight to the line. err_claim needs no setup and allocates nothing, on any thread. Both err_fail and err_trap call it.
  • The trap never waits. It flushes stdout only if ftrylockfile succeeds. Then it writes the constant bend: <ERRS[7]>\n (the text the JS trap already uses) with one write(2), and calls _exit(1). Stdout goes before the line, so a log that combines stdout and stderr keeps its order.
  • A fault inside that flush still prints the line. The trap is installed with SA_NODEFER, so a fault in the flush re-enters it. err_claim returns 0 on re-entry, so the flush is skipped and the line goes out. The altstack is 4 * SIGSTKSZ so the nested frame fits.
  • err_fail still prints with fprintf, so a message of any length goes out whole. It is now OUTLINE (cold, never inlined) and err_claim is FAR. When err_fail was inlined at its call sites, its larger body made editdist and merkle SEQ-CPU 1.24x and 1.32x slower on the minis, in three runs each.
  • io_errs and IO.print send the text and its newline in one write. They use the byte io_cbuf leaves for the NUL, so a fail-stop cannot land between the text and its newline.

tests/io/stack_fault_trap.bend prints BEFORE before the deep call and expects #|BEFORE above the error line. That pins the trap's flush against removal (stdout is buffered when it goes to a pipe).

Verification

Gates on b66cf64 over main b0d1ca6:

  • repo 54/54 (comp.ts 65,489 / 66,000)
  • test 1692/1692
  • ping 63/63
  • perf 124/124
  • safe: 0 false rejects

A C harness ran the emitted err_claim, err_fail and err_trap verbatim, with stdout and stderr captured as one stream:

case output exit
plain fault BEFORE, line 1
7 threads fault at once BEFORE, exactly one line, 100/100 runs 1
a fail-stop while another thread faults BEFORE, bend: <the fail-stop's message> only 1
a fault inside the trap's flush line 1
a fault inside err_fail's flush line 1
a fault on a thread holding stdout while another is in err_fail line after 1s (hung before this change) 1
the same, with a handled signal sent to the waiting thread every 50ms one line after the full second 1
the faulting thread holds the stdout lock BEFORE, line 1
another thread holds stdout line (the flush is skipped, never waited on) 1
main returns while another thread is faulting line (was exit 0 with no line under a wait-based version) 1
a thread that never set up an altstack faults line, and a malloc hook saw no allocation in the handler 1

The harness ran on macOS (arm64) only. The glibc path has not been run.

Trade-offs

  • The trap drops the flush if another thread holds stdout at that instant. Waiting inside a signal handler lets the program keep running past the fault.
  • A second failing thread waits only a second for the first one's flush. If stdout is a pipe that is slow to drain, it can exit before the flush finishes, cutting stdout short; the line is still printed. Waiting longer brings back the hang this avoids.
  • A flush that blocks forever on a pipe nobody reads hangs before the line, as main does. Writing the line first would put it ahead of stdout in a combined log.

Out of scope

🤖 Generated with Claude Code

@Lorenzobattistela Lorenzobattistela self-assigned this Oct 19, 2026

@Lorenzobattistela Lorenzobattistela left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. The async-signal-safety point is real, but the change as written regresses two things the old path did right, so I'm requesting changes. All gates pass with this merged onto main bedc964 (repo 54/54, test 1656/1656, perf 124/124, ping 62/62, safe 0 false rejects). Neither regression is covered by a test.

  1. A stack fault now loses output the program already printed. Stdout is fully buffered when it goes to a pipe or file, and err_trap no longer flushes it. Take tests/io/stack_fault_trap.bend, add a : Unit <- IO.print("BEFORE") before the deep call, build it with bend x.bend -o x, then run ./x | cat:

    • main: BEFORE on stdout, then bend: memory fault (machine stack overflow?)
    • this PR: only the error line; BEFORE is gone

    This happens with --threads 1 and --threads 4. Running through bend x.bend hides it, so the byte-identical checks in the description didn't catch it.

  2. When several threads fail at once, their error lines interleave. err_write sends the line as three separate write(2) calls. Before, fprintf held the stderr lock and sent the whole line at once. A parallel tree of 2^14 leaves that each overflow a Nat printed garbled stderr in 80 of 100 runs here (e.g. bend: bend: bend: a Nat past the largest immediate 2^48-1a Nat past ...) and in 0 of 100 on main. Since tests compare stderr against their #| lines, any test with parallel failures would become flaky.

Suggested shape:

  • Build "bend: <msg>\n" in a stack buffer and send it with one write(2). That's still async-signal-safe, it can't interleave (the line is under PIPE_BUF), and it drops the ssize_t n ...; (void)n; workaround. Loop on short writes and EINTR so the numbered line is never cut off.
  • Keep the flush in err_trap when it's free: if (ftrylockfile(stdout) == 0) { fflush(stdout); funlockfile(stdout); }. The stdio locks are recursive on macOS and glibc, so a fault on the thread that holds the stdout lock doesn't deadlock. If another thread holds it, the trap skips the flush instead of waiting.
  • Guard msg against NULL. fprintf printed "(null)", but strlen(NULL) faults. From reading the code: gpu_fail passes [[err localizedDescription] UTF8String], which is NULL when err is nil.
  • Drop the "no buffered output worth risking a hang for" comment; point 1 shows there often is.
  • Pin both cases in tests: an IO.print before the fault in stack_fault_trap.bend with #|BEFORE above the error line, and ideally one parallel fail-stop test that expects a single clean line.

Also, this covers only the stdio part of #1624. The atexit/termios restore and the re-raise of the real signal are still open, so "Part of #1624" fits better than "Resolves".

adeerkhan added a commit to adeerkhan/bend that referenced this pull request Oct 19, 2026
@adeerkhan
adeerkhan force-pushed the fix/err-fail-signal-safe branch from 90aa118 to 80a8ea2 Compare October 19, 2026 13:52
@adeerkhan

Copy link
Copy Markdown
Contributor Author

@Lorenzobattistela thanks — both regressions were real, and the review's shape is what the fix is now.

1. The trap lost output it had already printed. err_trap flushes stdout again, under a try-lock:

if (ftrylockfile(stdout) == 0) { fflush(stdout); funlockfile(stdout); }

ftrylockfile never waits and the stdio locks are recursive, so a fault on the thread that holds the stdout lock flushes fine, and another thread's lock is skipped rather than waited for. err_fail keeps its blocking fflush (it is not a signal handler, so it may wait). The two lock calls are declared in the runtime because they are POSIX and -std=c11 does not ask for them; both are in libc, on glibc and on macOS.

Pinned in tests/io/stack_fault_trap.bend: an IO.print("BEFORE") before the deep call, with #|BEFORE above the error line. All three lanes (interpreter, JS, C) print BEFORE then the error, exit 1. On the previous path of this PR the C lane loses BEFORE — the same binary with the flush removed shows exactly that, which is how I checked the test bites.

2. Threads that fail at once interleaved. The line is one write(2) now: "bend: <msg>\n" is built in a stack buffer and sent whole, so it is under PIPE_BUF and cannot interleave; a short write is resumed and EINTR retried, so the line is never cut. Measured on the mechanism itself — 64 threads writing the line at once, 20 runs each, the three-write form against the new one: 14/20 garbled before, 0/20 after.

msg against NULL. err_write prints (null), as fprintf did; strlen(NULL) faults and gpu_fail passes [[err localizedDescription] UTF8String], which is NULL when err is nil.

The comment. Gone — the flush is there, so "no buffered output worth risking a hang for" was wrong.

Tests. tests/io/parallel_fail_stop.bend is new: a task fails on a pool thread while main is parked on the join, and the expected output is one clean line and exit 1. A test with several failing threads cannot pin the count (the first fail-stop kills the process, so how many lines land is not deterministic), which is why this one fails exactly one task and pins the shape of the line.

"Part of #1624". The description says that now: the atexit/termios restore and the re-raise of the real signal are still open.

One thing to flag: bend2/comp.ts lands at 65,880 ttok against its permanent 66,000 cap (main is 65,507), so this PR costs 373 tokens and the file is 120 short. The next change there needs a simplification first, or the cap owner's call.

Lorenzobattistela added a commit to adeerkhan/bend that referenced this pull request Oct 20, 2026
)

A 128-byte buffer cut ERR_CNTS (137 chars) mid-word; 512 holds every
numbered line and stays at PIPE_BUF's floor, so the line goes out whole
in one write(2), with no loop and no errno. stdio.h declares the lock
calls on both hosts (_GNU_SOURCE, Darwin), and the trap exits holding
the stdout lock, so the externs and the funlockfile go. err_exit ends
in _exit for both callers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Lorenzobattistela pushed a commit to adeerkhan/bend that referenced this pull request Oct 20, 2026
Lorenzobattistela added a commit to adeerkhan/bend that referenced this pull request Oct 20, 2026
)

A 128-byte buffer cut ERR_CNTS (137 chars) mid-word; 512 holds every
numbered line and stays at PIPE_BUF's floor, so the line goes out whole
in one write(2), with no loop and no errno. stdio.h declares the lock
calls on both hosts (_GNU_SOURCE, Darwin), and the trap exits holding
the stdout lock, so the externs and the funlockfile go. err_exit ends
in _exit for both callers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Lorenzobattistela
Lorenzobattistela force-pushed the fix/err-fail-signal-safe branch 2 times, most recently from ab848c0 to b8d9e54 Compare October 20, 2026 13:46
Lorenzobattistela pushed a commit to adeerkhan/bend that referenced this pull request Oct 20, 2026
Lorenzobattistela added a commit to adeerkhan/bend that referenced this pull request Oct 20, 2026
)

A 128-byte buffer cut ERR_CNTS (137 chars) mid-word; 512 holds every
numbered line and stays at PIPE_BUF's floor, so the line goes out whole
in one write(2), with no loop and no errno. stdio.h declares the lock
calls on both hosts (_GNU_SOURCE, Darwin), and the trap exits holding
the stdout lock, so the externs and the funlockfile go. err_exit ends
in _exit for both callers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Lorenzobattistela added a commit to adeerkhan/bend that referenced this pull request Oct 20, 2026
…ndlang#1655)

The trap's flush ran on a FILE the faulting thread could be mid-update
on (the stdio locks are recursive, so ftrylockfile lets it through), and
a second fault there, with SIGSEGV blocked, died silently or hung. The
trap now flushes once, under SA_NODEFER: a fault in that flush re-enters
and skips it, and the line still goes out. The line is the constant
ERRS[7], so the trap writes it from a literal, with no buffer.

err_fail is main's again: fprintf prints a message of any length whole
(a 504-byte cap cut Metal's diagnostics) and in one write. io_errs sends
its text and newline in one write too, so a fail-stop cannot land
between them. parallel_fail_stop passed on main as well, so it goes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Lorenzobattistela added a commit to adeerkhan/bend that referenced this pull request Oct 20, 2026
…two frames (bendlang#1655)

Two threads that faulted at once both saw seen == 0, or the loser wrote
its line and _exited mid-flush, cutting stdout. The first fault now
takes an atomic flag and the others pause until its _exit; only its own
thread (a fault in the flush) passes and skips the flush. It waits up
to 100ms for the stdout lock instead of dropping the flush when an
fwrite holds it. The altstack is 4 * SIGSTKSZ, so a nested frame from
SA_NODEFER fits beside fflush's on glibc's minimal size.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Lorenzobattistela added a commit to adeerkhan/bend that referenced this pull request Oct 20, 2026
…endlang#1655)

The 100ms wait for the stdout lock let the program run on after a fault,
so a main that returned in that window exited 0 with no line. The trap
now flushes stdout only if its lock is free, with no wait, then writes
the line (stdout first, so a combined log keeps its order). Faults on
other threads still pause until its _exit. A thread-local flag, set
first by pool_stack so the trap never allocates it, replaces the shared
pthread_t: a fault in the trap's own flush skips to the line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Lorenzobattistela added a commit to adeerkhan/bend that referenced this pull request Oct 20, 2026
…bendlang#1655)

The thread-local flag was first touched inside the trap on threads that
never ran pool_stack (io_help, GCD callbacks), and on Darwin that first
touch calls malloc in the handler. err_claim stores the first thread's
pthread_self in an atomic instead: it needs no setup on any thread, and
err_fail takes it too, so a fail-stop and a fault no longer race to
print. The other thread waits for its _exit; a fault in the claimer's
own exit path skips its flush. IO.print writes text and newline in one
fwrite, as io_errs now does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Lorenzobattistela added a commit to adeerkhan/bend that referenced this pull request Oct 20, 2026
…stays out of hot code (bendlang#1655)

A thread that faulted inside a stdout write, after another had claimed,
paused forever holding the stdout lock, so the claimer's fflush in
err_fail never returned: a hang with no line. The other thread now
waits a second for the claimer's _exit, then prints its own line and
exits. err_fail flushes only when it holds the claim, so a fault in its
own flush goes straight to the line, as the trap's does.

err_fail is OUTLINE and err_claim FAR: inlined at its call sites, the
larger body cost editdist and merkle SEQ-CPU 1.24x and 1.32x on the
minis (3 runs each); outlined, perf is 124 / 124.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
adeerkhan and others added 9 commits October 20, 2026 11:36
err_trap is a signal handler, but it ended in err_fail, which calls
fflush(stdout) and fprintf(stderr, ...) -- neither async-signal-safe. A
fault while another thread holds a stdio lock could hang the process
instead of ending it.

err_write now goes straight to fd 2 with write(2), and err_trap calls it
directly, dropping the fflush: no stdio lock is touched from the handler.
The normal err_fail path still flushes stdout first, so output a program
printed before a fail-stop is preserved.

The emitted message and the exit code are unchanged on both paths, so
every numbered-error test still prints its line and exits 1.

Resolves bendlang#1624
)

A 128-byte buffer cut ERR_CNTS (137 chars) mid-word; 512 holds every
numbered line and stays at PIPE_BUF's floor, so the line goes out whole
in one write(2), with no loop and no errno. stdio.h declares the lock
calls on both hosts (_GNU_SOURCE, Darwin), and the trap exits holding
the stdout lock, so the externs and the funlockfile go. err_exit ends
in _exit for both callers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ndlang#1655)

The trap's flush ran on a FILE the faulting thread could be mid-update
on (the stdio locks are recursive, so ftrylockfile lets it through), and
a second fault there, with SIGSEGV blocked, died silently or hung. The
trap now flushes once, under SA_NODEFER: a fault in that flush re-enters
and skips it, and the line still goes out. The line is the constant
ERRS[7], so the trap writes it from a literal, with no buffer.

err_fail is main's again: fprintf prints a message of any length whole
(a 504-byte cap cut Metal's diagnostics) and in one write. io_errs sends
its text and newline in one write too, so a fail-stop cannot land
between them. parallel_fail_stop passed on main as well, so it goes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…two frames (bendlang#1655)

Two threads that faulted at once both saw seen == 0, or the loser wrote
its line and _exited mid-flush, cutting stdout. The first fault now
takes an atomic flag and the others pause until its _exit; only its own
thread (a fault in the flush) passes and skips the flush. It waits up
to 100ms for the stdout lock instead of dropping the flush when an
fwrite holds it. The altstack is 4 * SIGSTKSZ, so a nested frame from
SA_NODEFER fits beside fflush's on glibc's minimal size.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…endlang#1655)

The 100ms wait for the stdout lock let the program run on after a fault,
so a main that returned in that window exited 0 with no line. The trap
now flushes stdout only if its lock is free, with no wait, then writes
the line (stdout first, so a combined log keeps its order). Faults on
other threads still pause until its _exit. A thread-local flag, set
first by pool_stack so the trap never allocates it, replaces the shared
pthread_t: a fault in the trap's own flush skips to the line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…bendlang#1655)

The thread-local flag was first touched inside the trap on threads that
never ran pool_stack (io_help, GCD callbacks), and on Darwin that first
touch calls malloc in the handler. err_claim stores the first thread's
pthread_self in an atomic instead: it needs no setup on any thread, and
err_fail takes it too, so a fail-stop and a fault no longer race to
print. The other thread waits for its _exit; a fault in the claimer's
own exit path skips its flush. IO.print writes text and newline in one
fwrite, as io_errs now does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…stays out of hot code (bendlang#1655)

A thread that faulted inside a stdout write, after another had claimed,
paused forever holding the stdout lock, so the claimer's fflush in
err_fail never returned: a hang with no line. The other thread now
waits a second for the claimer's _exit, then prints its own line and
exits. err_fail flushes only when it holds the claim, so a fault in its
own flush goes straight to the line, as the trap's does.

err_fail is OUTLINE and err_claim FAR: inlined at its call sites, the
larger body cost editdist and merkle SEQ-CPU 1.24x and 1.32x on the
minis (3 runs each); outlined, perf is 124 / 124.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…bendlang#1655)

sleep(1) returned early on any handled signal, so the waiting thread could
print a second line while the claimer was still flushing. nanosleep keeps
the time left, and its only failure here is EINTR, so the loop resumes
it until the second is up (a fault on the claimer's own thread sleeps 0).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…#1655)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Lorenzobattistela
Lorenzobattistela merged commit cece1cb into bendlang:main Oct 20, 2026
1 check passed
This was referenced Oct 20, 2026
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.

2 participants