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] 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 {