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/4] 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/4] 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) } From fa21ad54b3dc1f7deab3b1b039f441086badb204 Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:16:27 -0500 Subject: [PATCH 3/4] runtime: wake every waiter when stopping the signal watcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The watcher is not the only thing that sleeps on signalFutex. sleepTicks waits on it, and so does waitForEvents. Waking a single waiter could therefore wake a sleeping goroutine instead of the watcher: that goroutine consumes the value with its own Swap, and the watcher is left asleep on a futex that is 0 again, never seeing the stop flag it was told to look at — which is the thread leak this function exists to prevent. WakeAll is what the signal handler already uses on this futex a few lines below, for the same reason. --- src/runtime/runtime_unix.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/runtime/runtime_unix.go b/src/runtime/runtime_unix.go index 938a6e057f..038b38cc3b 100644 --- a/src/runtime/runtime_unix.go +++ b/src/runtime/runtime_unix.go @@ -457,8 +457,15 @@ func stopSignalWatcher(s uint32) { // 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. + // + // WakeAll rather than Wake because the watcher is not the only thing that + // sleeps on this futex: sleepTicks waits on it too, and so does + // waitForEvents. Waking one waiter could wake a sleeping goroutine instead + // — which would consume the value with its own Swap and leave the watcher + // asleep on a futex that is 0 again, never seeing the flag it was told to + // look at. The signal handler wakes this futex the same way. signalFutex.Store(1) - signalFutex.Wake() + signalFutex.WakeAll() } // signalWatcher runs on its own thread under the threads scheduler. It blocks on From 650233785fb2717bbcae2fe6ca4efd362010f8f9 Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:18:16 -0500 Subject: [PATCH 4/4] testdata: receive the signal directly, so the test can fail The sleep this replaces was doing the delivery rather than waiting for it: sleepTicks waits on the same futex the signal handler bumps, and calls checkSignals on the way out, so the signal arrived on the back of the sleep no matter what else was or was not running. The test passed either way, which is the wrong property for the test guarding this fix. Blocking on the receive parks the only goroutine there is, so under the threads scheduler the watcher is the only thing left that can deliver. Checked by disabling the watcher: with the sleep the test still passes, with the receive it hangs and is killed. Output is unchanged, so signal.txt stays as it is. --- testdata/signal.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/testdata/signal.go b/testdata/signal.go index a82991f086..481c4899aa 100644 --- a/testdata/signal.go +++ b/testdata/signal.go @@ -8,28 +8,28 @@ import ( "os" "os/signal" "syscall" - "time" ) func main() { c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGUSR1) - // Wait for signals to arrive. - go func() { - for sig := range c { - if sig == syscall.SIGUSR1 { - println("got expected signal") - } else { - println("got signal:", sig.String()) - } - } - }() - // Send the signal. syscall.Kill(syscall.Getpid(), syscall.SIGUSR1) - time.Sleep(time.Millisecond * 100) + // Receive it directly, with nothing sleeping anywhere. + // + // The sleep this replaces was doing the delivery: sleepTicks waits on the + // same futex the signal handler bumps and calls checkSignals on the way + // out, so a signal arrived on the back of the sleep. That hid whether + // anything else delivers it. Blocking on this receive parks the only + // goroutine there is, so under the threads scheduler the signal watcher is + // the only thing left that can — and if it does not, this hangs. + if sig := <-c; sig == syscall.SIGUSR1 { + println("got expected signal") + } else { + println("got signal:", sig.String()) + } // Stop notifying. // (This is just a smoke test, it's difficult to test the default behavior