Skip to content

runtime: deliver os/signal notifications under the threads scheduler - #5631

Closed
yohimik wants to merge 1 commit into
tinygo-org:devfrom
yohimik:upstream-pr/signal-threads
Closed

runtime: deliver os/signal notifications under the threads scheduler#5631
yohimik wants to merge 1 commit into
tinygo-org:devfrom
yohimik:upstream-pr/signal-threads

Conversation

@yohimik

@yohimik yohimik commented Aug 30, 2026

Copy link
Copy Markdown

Status: closed in favor of #5530. The implementation and evidence below are retained as a historical record.

runtime: deliver os/signal notifications under the threads scheduler

What this does

signal.Notify delivers a signal on hosted linux and macOS only while some
other goroutine is in time.Sleep. Both hosts default to -scheduler=threads.
A program that installs a handler and then waits on the channel, which is what a
command does to stop on SIGINT, waits for ever.

The receiving goroutine in os/signal calls signal_recv, which parks itself
with task.Pause when nothing is pending. Only checkSignals resumes it, and
on the receive path the callers of checkSignals are waitForEvents, the idle
hook of the cooperative scheduler, and sleepTicks. With threads there is no
scheduler loop, so waitForEvents never runs and only a sleep elsewhere in the
program lets a signal through. This is why the current testdata/signal.go
passes. It sleeps for 100 ms in main.

signal_recv now blocks on a futex of its own that the handler wakes, with the
same 0/1 protocol that the handler already uses on signalFutex. It cannot
share signalFutex, because sleepTicks waits on that one too and swaps it
back to zero, so a time.Sleep anywhere in the program would take the wakeup of
the receiver. The handler only gets an atomic store and a futex wake syscall,
which are both safe in a signal handler on an arbitrary thread. The
stop-the-world signal of the GC uses a different handler that this does not
touch.

signalWaitUntilIdle, which signal.Stop and signal.Reset call before they
return, had the same problem from the other side. It spun on Gosched, which is
a no-op with threads, so it used a core until the receiver emptied the last
signal. It now waits on a futex that signal_recv wakes.

The two versions live in src/runtime/signal_cooperative.go and
src/runtime/signal_threads.go, split on scheduler.threads like the
schedulers themselves.

Evidence

testdata/signal.go gets a first phase that waits on the channel and nothing
else, so no timer and no sleep can carry the delivery. It is already in the
TestBuild host list, so it runs in the linux and the macOS CI jobs with no
test-list change.

Built on macOS 26.6 arm64, default scheduler.

Build Result
current dev no output, still running after 30s, killed
with this change got expected signal / got expected signal / exiting signal program, exit 0

A downstream product also ships binaries built with a fork that carries this
change, in the published release dispat v1.4.0.
https://github.com/yohimik/dispat/releases/tag/services%2Fdispat%2Fv1.4.0

Scope and known gaps

  • The cooperative half keeps the current code. It moves file, and nothing else.
  • On a hosted POSIX host the cooperative half cannot be reached today.
    -scheduler=tasks fails to link on darwin with duplicate symbol: _tinygo_task_exit, and -scheduler=asyncify fails with undefined: task.SystemStack. Both failures also happen on the current dev branch with
    testdata/print.go, so they are separate and are not addressed here.
  • os/signal.signal_ignored is still missing, so tinygo test os/signal does
    not link. That is why the test is a testdata program and not the standard
    library suite. It can be a follow-up.
  • No change for wasm, wasip1, wasip2, windows or baremetal.

Related

This is what a command line program needs for an orderly stop on SIGINT or
SIGTERM. It is one of the pieces that made a real CLI work under TinyGo on
hosted linux and macOS.

Related pull requests

Each open PR in this series has a separate change. A dependency is not a copied commit.

In tinygo-org/net

Full Darwin networking also needs the merged net changes and a later src/net pin update. No upstream merge or current full-suite pass is implied by this list.

signal.Notify delivers a signal on hosted linux and macOS only while some other
goroutine is in time.Sleep. Both hosts default to -scheduler=threads. A program
that installs a handler and then waits on the channel, which is what a command
does to stop on SIGINT, waits for ever.

The receiving goroutine in os/signal calls signal_recv, which parks itself with
task.Pause when nothing is pending. Only checkSignals resumes it, and on the
receive path the callers of checkSignals are waitForEvents, the idle hook of
the cooperative scheduler, and sleepTicks. With threads there is no scheduler
loop, so waitForEvents never runs and only a sleep elsewhere in the program
lets a signal through.

Give the receiver a wait that works on a thread. signal_recv now blocks on a
futex of its own that the handler wakes, with the same 0/1 protocol that the
handler already uses on signalFutex. It cannot share signalFutex, because
sleepTicks waits on that one too and swaps it back to zero, so a time.Sleep
anywhere in the program would take the wakeup of the receiver. The handler only
gets an atomic store and a futex wake syscall, which are both safe in a signal
handler on an arbitrary thread. The stop-the-world signal of the GC uses a
different handler that this does not touch.

signalWaitUntilIdle, which signal.Stop and signal.Reset call before they
return, had the same problem from the other side. It spun on Gosched, which is
a no-op with threads, so it used a core until the receiver emptied the last
signal. It now waits on a futex that signal_recv wakes.

The cooperative path keeps its behaviour. The two versions live in
signal_cooperative.go and signal_threads.go, split on scheduler.threads like
the schedulers themselves.

testdata/signal.go grows a first phase that waits on the channel and nothing
else. Built from the current dev branch on macOS 26.6 arm64 it prints nothing
and hangs. With this change it prints the expected output and exits.
@yohimik
yohimik force-pushed the upstream-pr/signal-threads branch from 545f911 to 369c493 Compare September 2, 2026 08:50
@yohimik

yohimik commented Sep 2, 2026

Copy link
Copy Markdown
Author

Rebased on dev after the 0.42.0 release. The problem is present in v0.42.0 as
released. testdata/signal.go from this branch, built with the official
v0.42.0 tarballs, prints nothing and still runs after 30 seconds on
darwin/arm64 and on linux/arm64. Both report scheduler: threads as the
default.

@0pcom

0pcom commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

This overlaps with #5530 (open since July), which addresses the same defect: under the threads scheduler, signal delivery only happens while some goroutine happens to be inside sleepTicks, so a program blocked purely on a channel receive or I/O never observes a signal.

The approaches differ enough to be worth comparing side by side before review effort lands on both:

The testdata/signal.go changes are near-identical in intent — receive the signal with nothing sleeping anywhere, so nothing else can carry the delivery (that test shape came out of review on #5530).

Flagging so this gets decided once: happy to close #5530 in favor of this one, or rebase mine, whichever direction the maintainers prefer. cc @deadprogram who reviewed #5530.

@yohimik

yohimik commented Sep 5, 2026

Copy link
Copy Markdown
Author

No problem, close if already done (I asked fable to check if there are any open prs related)

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