Skip to content

runtime: deliver signals under the threads scheduler when blocked on I/O - #5530

Open
0pcom wants to merge 2 commits into
tinygo-org:devfrom
0magnet:fix-threads-signal-delivery
Open

runtime: deliver signals under the threads scheduler when blocked on I/O#5530
0pcom wants to merge 2 commits into
tinygo-org:devfrom
0magnet:fix-threads-signal-delivery

Conversation

@0pcom

@0pcom 0pcom commented Jul 15, 2026

Copy link
Copy Markdown

Problem

Under the threads scheduler (the default on Linux/macOS), a program blocked purely on I/O, channels or mutexes never observes an OS signal. For example, a server that does signal.Notify(c, os.Interrupt); <-c while its goroutines are blocked on network I/O ignores Ctrl+C indefinitely and has to be killed.

The cause: checkSignals() — which resumes the parked os/signal signal_recv goroutine — is only ever reached from sleepTicks() (in runtime_unix.go). So a signal is only noticed while some goroutine happens to be inside time.Sleep. time.NewTicker/time.After go through the timer queue (timerRunner), not sleepTicks, so a program that blocks on I/O/channels can ignore SIGINT indefinitely.

The cooperative and multicore schedulers don't have this problem because they call checkSignals() from their idle loop (waitForEvents). The threads scheduler has no such loop, so nothing consumes signalFutex and resumes signal_recv.

Fix

Start a dedicated signal-watcher thread the first time a signal is enabled, gated to the threads scheduler (!hasScheduler && hasParallelism, which is true only there). It blocks on signalFutex and calls checkSignals() on wake — mirroring the signal half of waitForEvents(). It is a compile-time no-op for every other scheduler: the cooperative/cores schedulers already handle signals from their idle loop, and the none scheduler has no goroutines.

Testing

A channel/Accept-blocked program with no time.Sleep anywhere ignores SIGINT before this change and exits cleanly after it. Also verified against a real network daemon that blocks on I/O: SIGINT now triggers graceful shutdown, both idle and under load.

…e sleeps

Under the threads scheduler there is no cooperative idle loop, so checkSignals()
— which resumes the parked os/signal signal_recv goroutine — was only ever
reached from sleepTicks(). That means a signal (e.g. SIGINT/Ctrl+C) was only
noticed while some goroutine happened to be inside time.Sleep. A program blocked
purely on I/O, channels, mutexes or timers (time.NewTicker uses the timer queue,
not sleepTicks) would never observe the signal at all.

Start a dedicated signal-watcher thread the first time a signal is enabled,
gated to the threads scheduler (!hasScheduler && hasParallelism). It blocks on
signalFutex and calls checkSignals() on wake, mirroring the signal half of
waitForEvents() that the cooperative scheduler runs from its idle loop. Other
schedulers are unaffected (the start is a compile-time no-op for them).

Verified: a channel/Accept-blocked program with no time.Sleep now receives
SIGINT, and the skycoin daemon (previously unkillable with Ctrl+C under TinyGo)
now shuts down cleanly on SIGINT, both idle and during active block sync.

(cherry picked from commit ada7ab6)
@dgryski
dgryski requested a review from aykevl July 15, 2026 20:50
// signalFutex and resumes the signal-receiving goroutine (signal_recv) whenever
// a signal arrives, decoupling signal delivery from sleepTicks(). It mirrors the
// signal half of waitForEvents(), which the threads scheduler never calls.
func signalWatcher() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is called as a Go routine with no exit condition. I know that waitForEvents() already has the same issue, but it would be pretty nice to have a way for a cleaner exit.

@0pcom

0pcom commented Aug 14, 2026

Copy link
Copy Markdown
Author

Thanks for the review — addressed in 7243c59.

You're right that a goroutine with no exit condition isn't great, so the watcher now has a lifetime rather than running forever. It exists only to serve enabled signals, so that is what bounds it: enabledSignals tracks the set os/signal currently wants delivered, the last signal_disable/signal_ignore stops the thread, and a later signal_enable starts a fresh one.

The shutdown itself sets a flag, bumps the futex value, and wakes it. The bump matters as much as the wake — Wait(0) returns immediately when the futex is already non-zero, which closes the window between the store and a watcher that is just about to sleep. On the way out the watcher resets the futex to 0 so the next one can block on it.

Two details worth flagging:

  • I used CAS loops rather than atomic.Uint32.Or/.And, since those methods are newer than some of the Go versions TinyGo builds against.
  • I left waitForEvents() alone. It has the same shape, but it's the scheduler's own idle loop rather than a thread this PR introduces, so bounding it feels like a separate change — happy to take it on if you'd like it in scope.

Verified with a program that blocks on a channel and never on time.Sleep, so delivery can only come through the watcher: the signal arrives, signal.Stop lets the thread exit, and a later signal.Notify starts a new watcher that delivers again.

Review feedback: the watcher was started as a goroutine with no exit
condition. It blocked on a futex forever, so a program that finished with
signals kept a thread parked on one for the rest of its life. Nothing
observable broke — the thread is idle and process exit tears it down —
but a loop with no way out is a property worth not having.

The watcher exists only to serve enabled signals, so that is now its
lifetime: enabledSignals tracks the set os/signal wants delivered, the
last signal_disable/signal_ignore stops the thread, and a later
signal_enable starts a fresh one.

Stopping sets the flag, bumps the futex value and wakes it. The bump
matters as much as the wake: Wait(0) returns immediately when the futex
is already non-zero, which closes the window between the store and a
watcher that is about to sleep. On the way out the watcher resets the
futex to 0 so the next one can block on it.

Verified with a program that blocks on a channel (never time.Sleep, so
delivery can only come from the watcher): the signal arrives, signal.Stop
lets the thread exit, and a later signal.Notify starts a new watcher that
delivers again.
@0pcom
0pcom force-pushed the fix-threads-signal-delivery branch from 7243c59 to e626202 Compare August 14, 2026 01:51
@0pcom

0pcom commented Aug 14, 2026

Copy link
Copy Markdown
Author

Correction to my previous comment: I said I'd used CAS loops because atomic.Uint32.Or/.And are newer than some Go versions TinyGo builds against. That was wrong — this very file already uses receivedSignals.Or(...) and receivedSignals.And(...) on the same type, and TinyGo tracks recent Go.

Amended in e626202 to use Or/And, which matches the surrounding code and drops the CAS boilerplate. Behaviour is unchanged; re-verified with the same test (blocks on a channel, never time.Sleep, so delivery can only come through the watcher — signal arrives, signal.Stop lets the thread exit, later signal.Notify starts a new watcher that delivers again).

One readability note on the stop path: And returns the value before the mask is applied, so the code clears the bit from that result to get what remains enabled.

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