From cccac9047689fd25acfbab52c3ae3d7b09aca5a2 Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:39:31 -0500 Subject: [PATCH 1/2] runtime: deliver signals under the threads scheduler when no goroutine sleeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ada7ab6b47f50936c3736da9c8fba63dbbed7179) --- src/runtime/runtime_unix.go | 40 +++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/runtime/runtime_unix.go b/src/runtime/runtime_unix.go index 12a8c265cb..019a959d3e 100644 --- a/src/runtime/runtime_unix.go +++ b/src/runtime/runtime_unix.go @@ -390,10 +390,50 @@ func signal_enable(s uint32) { // scheduler (and therefore there is no parallelism). hasSignals = true + // Under the threads scheduler there is no scheduler idle loop to notice + // signals: checkSignals() is only reached from sleepTicks(), i.e. while + // some goroutine happens to be inside time.Sleep. A program blocked purely + // on I/O or channels would otherwise never observe a signal (Ctrl+C would + // be ignored). Start a dedicated watcher thread to cover that case. This is + // a no-op for every other scheduler. + startSignalWatcher() + // It's easier to implement this function in C. tinygo_signal_enable(s) } +// signalWatcherStarted guards the one-time start of signalWatcher. signal_enable +// is serialized by os/signal's handlers lock, but this stays defensive. +var signalWatcherStarted atomic.Uint32 + +// startSignalWatcher starts the signal watcher thread the first time a signal is +// enabled, but only under the threads scheduler (!hasScheduler && hasParallelism +// is true only there). The cooperative and multicore schedulers process signals +// from their idle loop (waitForEvents), and the "none" scheduler has no +// goroutines, so none of them need this. +func startSignalWatcher() { + if hasScheduler || !hasParallelism { + return + } + if signalWatcherStarted.Swap(1) == 0 { + go signalWatcher() + } +} + +// signalWatcher runs on its own thread under the threads scheduler. It blocks on +// 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() { + for { + // Block until the signal handler bumps the futex from 0 to 1. + signalFutex.Wait(0) + if signalFutex.Swap(0) != 0 { + checkSignals() + } + } +} + //go:linkname signal_ignore os/signal.signal_ignore func signal_ignore(s uint32) { if s >= 32 { From e62620236cc7dac71dadc2dfde9ab9cb8fe1eb8c Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:33:38 -0500 Subject: [PATCH 2/2] runtime: let the signal watcher thread exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/runtime/runtime_unix.go | 61 ++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/src/runtime/runtime_unix.go b/src/runtime/runtime_unix.go index 019a959d3e..938a6e057f 100644 --- a/src/runtime/runtime_unix.go +++ b/src/runtime/runtime_unix.go @@ -396,38 +396,87 @@ func signal_enable(s uint32) { // on I/O or channels would otherwise never observe a signal (Ctrl+C would // be ignored). Start a dedicated watcher thread to cover that case. This is // a no-op for every other scheduler. - startSignalWatcher() + startSignalWatcher(s) // It's easier to implement this function in C. tinygo_signal_enable(s) } -// signalWatcherStarted guards the one-time start of signalWatcher. signal_enable -// is serialized by os/signal's handlers lock, but this stays defensive. +// signalWatcherStarted guards the start of signalWatcher. signal_enable is +// serialized by os/signal's handlers lock, but this stays defensive. var signalWatcherStarted atomic.Uint32 -// startSignalWatcher starts the signal watcher thread the first time a signal is +// signalWatcherStop asks signalWatcher to return. The watcher reads it after +// waking, so stopping it means setting this and then waking the futex. +var signalWatcherStop atomic.Uint32 + +// enabledSignals is the set of signals os/signal currently wants delivered. The +// watcher thread exists only to serve them, so it runs exactly while this is +// non-zero: the last disable/ignore stops it, and a later enable starts a fresh +// one. +var enabledSignals atomic.Uint32 + +// startSignalWatcher starts the signal watcher thread when the first signal is // enabled, but only under the threads scheduler (!hasScheduler && hasParallelism // is true only there). The cooperative and multicore schedulers process signals // from their idle loop (waitForEvents), and the "none" scheduler has no // goroutines, so none of them need this. -func startSignalWatcher() { +func startSignalWatcher(s uint32) { if hasScheduler || !hasParallelism { return } + enabledSignals.Or(uint32(1) << s) + signalWatcherStop.Store(0) if signalWatcherStarted.Swap(1) == 0 { go signalWatcher() } } +// stopSignalWatcher lets the watcher thread return once the signal it was +// serving is the last one to go away. +// +// Without this the thread is unstoppable by construction: it blocks on a futex +// forever, so a program that finishes with signals keeps a thread parked on one +// for the rest of its life. Nothing observable breaks — the thread is idle and +// the process exit tears it down — but "no exit condition" is a property worth +// not having, and it costs a flag and a wake to avoid. +func stopSignalWatcher(s uint32) { + if hasScheduler || !hasParallelism { + return + } + // And returns the value BEFORE the mask was applied, so clear the bit from + // it to get what is left enabled. + bit := uint32(1) << s + if enabledSignals.And(^bit)&^bit != 0 { + return // still serving other signals + } + if signalWatcherStarted.Swap(0) == 0 { + return // not running + } + signalWatcherStop.Store(1) + // Wake it so it can observe the flag. The value bump matters as much as the + // wake: Wait(0) returns immediately if the futex is already non-zero, which + // closes the window between the store above and a watcher about to sleep. + signalFutex.Store(1) + signalFutex.Wake() +} + // signalWatcher runs on its own thread under the threads scheduler. It blocks on // 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. +// +// It returns when stopSignalWatcher says the last enabled signal has gone away. func signalWatcher() { for { // Block until the signal handler bumps the futex from 0 to 1. signalFutex.Wait(0) + if signalWatcherStop.Load() != 0 { + // Leave the futex as we found it for whoever runs next: a later + // signal_enable starts a new watcher, and it must be able to sleep. + signalFutex.Store(0) + return + } if signalFutex.Swap(0) != 0 { checkSignals() } @@ -441,6 +490,7 @@ func signal_ignore(s uint32) { // receivedSignals into a uint32 array. runtimePanicAt(returnAddress(0), "unsupported signal number") } + stopSignalWatcher(s) tinygo_signal_ignore(s) } @@ -451,6 +501,7 @@ func signal_disable(s uint32) { // receivedSignals into a uint32 array. runtimePanicAt(returnAddress(0), "unsupported signal number") } + stopSignalWatcher(s) tinygo_signal_disable(s) }