Conversation
| // 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() { |
There was a problem hiding this comment.
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.
|
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: The shutdown itself sets a flag, bumps the futex value, and wakes it. The bump matters as much as the wake — Two details worth flagging:
Verified with a program that blocks on a channel and never on |
7243c59 to
e626202
Compare
|
Correction to my previous comment: I said I'd used CAS loops because Amended in e626202 to use One readability note on the stop path: |
| // 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() |
There was a problem hiding this comment.
I think that this should be WakeAll() here
|
Here is a comment from an automated code review: signalWatcherStarted, signalWatcherStop, and enabledSignals form a state machine that is only coherent because os/signal serializes Notify/Stop/Ignore under handlers.Lock(). The watcher's wake isn't covered by that lock, so start-after-stop can transiently run two watchers (stop sets started=0; a following signal_enable resets signalWatcherStop to 0 and spawns a second watcher before the first has woken and read the flag). It self-heals on the next stop, but signalWatcherStop is redundant the watcher can just test enabledSignals, and own its own started flag: func signalWatcher() {
for enabledSignals.Load() != 0 {
signalFutex.Wait(0)
if signalFutex.Swap(0) != 0 {
checkSignals()
}
}
signalWatcherStarted.Store(0)
}That drops one global, removes the flag-reset race, and makes the exit condition impossible to get stale. Both flags also want atomic.Bool rather than atomic.Uint32. |
|
Both review comments addressed.
"no exit condition" — this should be resolved by I left Verified with |
|
@0pcom I think you still need to add a test that shows this is working, such as the same current test but without the sleep and with a <-c receive. |
|
Done — You were right that the old one proved nothing. The I checked that by disabling the watcher (commenting out the
So the old test could not have caught this and the new one does. Blocking on the receive parks the only goroutine there is, which leaves the watcher as the only thing that can deliver. Output is unchanged, so |
acd4925 to
e55041f
Compare
|
Thanks @0pcom, this is edited from an automated review. I checked the new test by merging the branch onto Three items are still open.
|
…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(). A signal was therefore only noticed while some goroutine happened to be inside time.Sleep, and a program blocked purely on I/O, channels, mutexes or timers (time.NewTicker uses the timer queue, not sleepTicks) never observed it at all. A dedicated signal-watcher thread starts 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. The watcher exists only to serve enabled signals, so that is 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. Without that it blocked on a futex forever, so a program that had finished with signals kept a thread parked on one for the rest of its life — nothing observable broke, since the thread is idle and process exit tears it down, but a loop with no way out is a property worth not having. Stopping sets the flag, bumps the futex value and wakes ALL waiters. 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 about to sleep. WakeAll matters because the watcher is not the only thing sleeping on signalFutex — sleepTicks and waitForEvents do too — and waking a single waiter could wake a sleeping goroutine instead, which consumes the value with its own Swap and leaves the watcher asleep on a futex that is 0 again, never seeing the stop flag. That is the thread leak the stop exists to prevent. The signal handler already uses WakeAll on this futex for the same reason. On the way out the watcher resets the futex to 0 so the next one can block on it. testdata/signal.go now blocks on the receive rather than on a sleep. The sleep 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 whatever else was running, and the test passed either way — 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. Verified: a channel/Accept-blocked program with no time.Sleep receives SIGINT, signal.Stop lets the thread exit, a later signal.Notify starts a new watcher that delivers again, and the skycoin daemon — previously unkillable with Ctrl+C under TinyGo — shuts down cleanly on SIGINT, both idle and during active block sync.
e55041f to
a691dd8
Compare
|
All three done in
signalWatcherStarted.Store(false)
if enabledSignals.Load() == 0 || signalWatcherStarted.Swap(true) {
return
}I could not get either version to fail in 20000 enable/deliver/disable rounds, so that is reasoning rather than a repro. Happy to drop it for the shorter form if you prefer. Rebased onto Checked with tinygo 0.42.0 and this runtime patched into its TINYGOROOT: |
Problem
Under the
threadsscheduler (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 doessignal.Notify(c, os.Interrupt); <-cwhile its goroutines are blocked on network I/O ignores Ctrl+C indefinitely and has to be killed.The cause:
checkSignals()— which resumes the parkedos/signalsignal_recvgoroutine — is only ever reached fromsleepTicks()(inruntime_unix.go). So a signal is only noticed while some goroutine happens to be insidetime.Sleep.time.NewTicker/time.Aftergo through the timer queue (timerRunner), notsleepTicks, 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 consumessignalFutexand resumessignal_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 onsignalFutexand callscheckSignals()on wake — mirroring the signal half ofwaitForEvents(). It is a compile-time no-op for every other scheduler: the cooperative/cores schedulers already handle signals from their idle loop, and thenonescheduler has no goroutines.Testing
A channel/
Accept-blocked program with notime.Sleepanywhere 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.