Skip to content

Implement improved signal handling for more reliable runtime integrations - #66

Open
raggi wants to merge 4 commits into
mainfrom
raggi/libtailscale-signals
Open

raggi wants to merge 4 commits into
mainfrom
raggi/libtailscale-signals

Conversation

@raggi

@raggi raggi commented Oct 13, 2026

Copy link
Copy Markdown
Member

Go wants to take control of signals, but so do runtimes. We can't fix all conditions/cases, but this provides API, docs and implementations that avoid the largest traps and improve outcomes for both the python and ruby wrappers.

raggi added 4 commits October 13, 2026 22:28
tsnet routes most messages through Server.logf, which prefers UserLogf
and falls back to Go's standard log.Printf; Server.Logf alone never
received them. Set both so logfd (or the discard) applies to all of them.
Up and Dial blocked with context.Background(): a host runtime sitting in
the blocking call could never return to a safe point, so there was no way
to time them out or interrupt them (Ctrl+C in irb hangs until Up
completes - without an authkey, forever). Close could not interrupt a
pending Up either.

Add TsnetUpTimeout/TsnetDialTimeout (deadline in milliseconds, <= 0 for
none) and TsnetCancel, which interrupts the most recent pending up and
dial on a server. TsnetClose now also cancels pending operations, so it
cannot hang behind an up that would never finish. The existing function
signatures are unchanged.

Fixes tailscale/tailscale#21167
The Go runtime makes process-global signal changes when the library
loads: it takes over SIGILL/SIGBUS/SIGFPE/SIGSEGV/SIGPIPE/SIGURG, adds
SA_ONSTACK to every other caught host handler, and seeds its thread
masks from the loading thread's (typically empty) mask, so Go threads
accept - and route through the Go runtime - process-directed signals.

Move the mitigation into the library, so embedders no longer have to:

- a prioritized constructor in tailscale.c runs before the Go runtime's
  own constructor: it snapshots the caught host dispositions and blocks
  all signals on the loading thread; the thread the Go runtime creates
  inherits that mask and seeds every Go thread, so Go threads only ever
  accept the signals the runtime needs;

- the Go package init repairs the SA_ONSTACK flags the runtime's initsig
  added to host handlers (asynchronously, right after the load);

- tailscale_signal_restore() restores the loading thread's own mask -
  masks are per-thread, so only the loading thread can. It is called
  automatically from the first tailscale_new()/TsnetNewServer() call, and
  embedders or bindings that want signals fully live immediately (e.g.
  Ctrl+C at an irb prompt before any server is created) call it from the
  thread that loaded the library, right after the load.

If the constructor ordering does not hold on some platform or linker,
the result is a graceful fallback to stock Go behavior.

Also add make check-signals, which fails if os/signal is ever linked in
(it would retroactively steal host signals), and make test-signals,
which runs the new C embedder verification (csigtest: constructor
ordering, mask inheritance and restoration, SA_ONSTACK repair, pending-
signal delivery) plus the Ruby signal suite. See SIGNALS.md.

Updates tailscale/tailscale#7649
Updates tailscale/tailscale#8912
…on at load

Both bindings get timeouts and cancellation wired to the new tailscale
APIs: the Go call runs on a helper thread and is canceled with
tailscale_cancel if the calling thread is interrupted (Ctrl+C in irb,
Timeout.timeout, KeyboardInterrupt), after which the interrupt is
re-raised. Ruby up(timeout:)/dial(..., timeout:) and TSNet.up(timeout_ms)
gain deadline parameters, and Python releases the GIL around the blocking
calls so the interpreter keeps running. Attach the new symbols with a
fallback so older libtailscale libraries keep the legacy blocking
behavior. An interrupted or timed-out up may leave a partially started
server: create a new one.

The bindings no longer implement signal isolation themselves - the
library does it now. The Ruby gem shrinks to a single
tailscale_signal_restore call right after ffi_lib (keeping traps and
Ctrl+C live immediately at require time), and Python to the same call in
the module init, which also makes the linker pull the constructor's
object in from the static archive. The Ruby signal suite now also
asserts the loading thread's mask is already restored when require
returns.
@raggi
raggi force-pushed the raggi/libtailscale-signals branch from c4e5ab9 to 0c7060c Compare October 13, 2026 22:40

@illotum illotum left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The general shape looks sound. A sundry of questions and one significant find.

Comment thread tailscale.go
Comment on lines 741 to 742
fmt.Fprintf(f, format, args...)
fmt.Fprintf(f, "\n")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may interleave between Logf and UserLogf. Embed \n into format?

Comment thread tailscale.c
static sigset_t ts_preload_mask;
static pthread_t ts_loader_thread;
static int ts_signal_isolation = 0; // 1 once the isolate constructor ran
static int ts_mask_restored = 0; // 1 once the loader thread mask is back

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this flag shared between threads? Should it be an atomic?

Comment thread python/tailscale/tsnet.py
Comment on lines -42 to -44
def close(self):
if _tailscale.close_listener(self.ln):
raise TSNetException("Failed to close")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A assume this won't break anyone?

Comment thread ruby/lib/tailscale.rb
# interrupted. Canceling a completed operation is a no-op.
def cancel
assert_open
Libtailscale.TsnetCancel(@t)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Libtailscale.TsnetCancel(@t)
Error.check(self, Libtailscale.TsnetCancel(@t))

Comment thread SIGNALS.md
Comment on lines +92 to +94
If the constructor ordering does not hold on some platform/linker, the
result is a graceful fallback to today's Go behavior (Go threads
unmasked, `SA_ONSTACK` left in place) — never a worse state.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, I tried building it on Darwin, and clang there doesn't care about priorities. The fallback turns out to be not quite graceful. When Go runtime comes up first, it is seeded from the unblocked mask, but the loader still gets blocked. The host does not receive signals. An early return in ts_signal_isolate should probably suffice.

Would be also nice if csigtest ran whatever checks are compatible on Darwin.

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