diff --git a/Makefile b/Makefile index 3fc48af..64a3b6b 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,18 @@ libtailscale_ios_sim_arm64.a: libtailscale_ios_sim_x86_64.a: GOOS=ios GOARCH=amd64 CC=$(PWD)/swift/script/clangwrap-ios-sim-x86.sh go build -v -ldflags -w -tags ios -o $@ -buildmode=c-archive +.PHONY: check-signals +deps = $(shell go list -deps . 2>/dev/null) +check-signals: ## Fails if os/signal is linked in: it would steal host signal handlers + @if echo "$(deps)" | grep -qx 'os/signal'; then \ + echo "FAIL: os/signal is linked into libtailscale."; \ + echo " Signal.Notify in a Go c-archive/c-shared library installs Go handlers"; \ + echo " for host signals (SIGINT, SIGTERM, ...) and breaks the embedding"; \ + echo " runtime's signal handling. See SIGNALS.md."; \ + exit 1; \ + fi; \ + echo "OK: os/signal is not linked into libtailscale" + .PHONY: c-archive-ios c-archive-ios: libtailscale_ios.a ## Builds libtailscale_ios.a for iOS (iOS SDK required) @@ -57,10 +69,10 @@ c-archive: libtailscale.a ## Builds libtailscale.a for the target platform shared: libtailscale.so ## Builds libtailscale.so for the target platform .PHONY: test -test: test-go test-ruby test-swift ## Runs all tests available on the host platform +test: test-go test-ruby test-signals test-swift ## Runs all tests available on the host platform .PHONY: test-go -test-go: ## Runs Go and C integration tests +test-go: check-signals ## Runs Go and C integration tests go test -v ./... .PHONY: test-ruby @@ -71,6 +83,20 @@ test-ruby: ## Runs Ruby binding tests (requires ruby, bundler) echo "skipping ruby tests: bundle not found"; \ fi +.PHONY: test-signals +test-signals: ## Runs host-signal compatibility tests (C embedder, then Ruby) + @if command -v ruby >/dev/null 2>&1 && ruby -e 'require "ffi"' >/dev/null 2>&1; then \ + cd ruby && rake test:signals; \ + cd .. && $(CC) -o /tmp/libtailscale-csigtest csigtest/main.c -ldl -lpthread \ + && /tmp/libtailscale-csigtest ruby/lib/libtailscale.$$(ruby -e 'print RbConfig::CONFIG["DLEXT"]'); \ + elif command -v $(CC) >/dev/null 2>&1 && command -v go >/dev/null 2>&1; then \ + $(MAKE) shared; \ + $(CC) -o /tmp/libtailscale-csigtest csigtest/main.c -ldl -lpthread \ + && /tmp/libtailscale-csigtest libtailscale.so; \ + else \ + echo "skipping signal tests: need ruby with ffi, or go+cc for the C embedder test"; \ + fi + .PHONY: test-swift test-swift: ## Runs Swift binding tests (requires macOS, xcodebuild) @if [ "$(GOOS)" = "darwin" ] && command -v xcodebuild >/dev/null 2>&1; then \ diff --git a/README.md b/README.md index 54375b6..af943e2 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,15 @@ or make shared ``` +## Signal handling + +Embedding a Go runtime into a host process changes process-global signal +state, and libtailscale takes care to keep the host runtime's signal +handling (traps, `Ctrl+C`, stack-overflow guards) working unchanged, plus +provides cancellable blocking calls so hosts like Ruby or Python can +interrupt `up`/`dial`. See [SIGNALS.md](SIGNALS.md) for the details, the +trade-offs, and what C embedders should do. + ## Bugs Please file any issues about this code or the hosted service on diff --git a/SIGNALS.md b/SIGNALS.md new file mode 100644 index 0000000..8e0dd56 --- /dev/null +++ b/SIGNALS.md @@ -0,0 +1,171 @@ +# libtailscale and host signal handling + +libtailscale embeds a Go runtime (`tsnet`) into non-Go host processes, as +a c-shared library (`.so`), a c-archive (`.a`), or through the Ruby and +Python bindings. The Go runtime initializes at library load time and makes +process-global changes to signal state. This document describes exactly +what changes, what libtailscale does to keep host signal handling intact, +and what embedders need to know. + +## What the Go runtime does at library load + +With a modern Go toolchain (this is Go 1.27 behavior; older toolchains were +more invasive), a Go c-shared/c-archive library: + +* **Installs its own handlers only for** `SIGILL`, `SIGBUS`, `SIGFPE`, + `SIGSEGV`, `SIGPIPE` and `SIGURG`. It needs these for its own fault + recovery (stack growth, panics), its preemption signal (`SIGURG`), and + SIGPIPE semantics. Signals raised on host threads are forwarded to the + handler the host had installed at load time, and if the host had no + handler (SIG_DFL), the signal is re-raised with the default action: a + broken-pipe write still kills the process, a stack-overflow guard + (e.g. Ruby's `SystemStackError`) still works. +* **Adds `SA_ONSTACK` to the flags of every other *caught* host handler** + (e.g. Ruby's `SIGINT` trap), so that if such a signal is delivered while + running on a Go stack, the host handler runs on a valid alternate stack. + The handler function itself is left in place; only its flags change. +* **Snapshots the loading thread's signal mask** to seed the mask of every + thread the Go runtime creates. Hosts typically run with no signals + blocked, so **Go threads come up with all signals unblocked** and accept + process-directed signals, routing them through the Go runtime's signal + trampoline (and, because of the `SA_ONSTACK` flag change, onto whatever + alternate signal stack the delivering thread happens to have) instead of + delivering them directly to host threads. + +## What libtailscale does about it + +### Cancelable blocking calls + +The biggest practical problem for host runtimes is not the handler table: +`Ctrl+C` in irb "stops working" mainly because `up()`/`dial()` block in a +cgo call that the host runtime cannot interrupt. The Ruby trap fires, but +the interpreter thread sitting in the FFI call can never return to a safe +point. + +The C API therefore has explicit cancellation: + +* `tailscale_up_timeout(sd, timeout_ms)` — like `tailscale_up`, but blocks + at most `timeout_ms` milliseconds (`<= 0` means no deadline). +* `tailscale_dial_timeout(sd, network, addr, timeout_ms, conn_out)` — + same for dial. +* `tailscale_cancel(sd)` — interrupts the most recent pending up/dial, + making it return a `context canceled` error. `ENOENT` if nothing is + pending. +* `tailscale_close(sd)` also cancels pending operations, so it cannot + hang behind a `up()` that would otherwise never finish (e.g. no + authkey, waiting for interactive login). + +The Ruby and Python bindings run blocking calls on a helper thread and +call `cancel` if the calling thread is interrupted (`Ctrl+C`, +`Timeout.timeout`, `KeyboardInterrupt`), then re-raise the interrupt. A +canceled up may leave the server partially started; create a new server +after such a failure. + +### Built-in signal-state isolation + +The signal-state isolation lives inside the library (`tailscale.c`), so +embedders and bindings do not have to reimplement it: + +1. **`__attribute__((constructor(101)))` in `tailscale.c`** runs before + the Go runtime's own (unprioritized) library constructor: it + snapshots the dispositions of the host's caught signal handlers and + blocks every signal on the loading thread. The thread the Go runtime + creates during its constructor inherits that mask, and it seeds every + thread the Go runtime creates later, so Go threads only ever accept + the signals the Go runtime needs (the synchronous fault signals and + its preemption signal). Host signals are only ever delivered to host + threads, where the host's own handlers run directly. +2. **The Go package `init()`** repairs the `SA_ONSTACK` flag the Go + runtime added to caught host handlers (the library exports its + snapshot to it via `ts_signal_flags_restore`). This runs + asynchronously just after the load completes; the synchronous + guarantee comes from: +3. **`tailscale_signal_restore()`** — restores the loading thread's + signal mask (masks are per-thread, so only the loading thread can do + this) and re-runs the flag repair. It is called automatically by the + first `tailscale_new()` / Go-side `TsnetNewServer()` call — the one + entry point every embedder must use — and bindings call it + explicitly right after loading the library so host signal handling + is fully live immediately (e.g. `Ctrl+C` at the irb prompt between + `require "tailscale"` and the first server creation). + +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. + +Notes on the two pieces embedders used to be told to do themselves: + +* Ruby previously did both steps binding-side via + `Tailscale::SignalSafety`; that module is gone, and the gem's only + remaining obligation is the one `tailscale_signal_restore` call after + `ffi_lib`. +* Python gets the same treatment from the pybind module init + (`main.cpp` calls `tailscale_signal_restore()` at import, which also + makes the linker pull in the object containing the constructor). +* Python deliberately does not rewrite sigaction flags beyond the + library's own repair: CPython installs no alternate stacks, so the + flag would be inert there anyway. + +### Never link `os/signal` + +If Go code in the library calls `signal.Notify`, the Go runtime installs a +handler for the notified signal and steals it from the host — silently +breaking, e.g., `SIGTERM` handling. libtailscale must never (transitively) +import `os/signal`. `make check-signals` enforces this and runs as part of +`make test` and `make test-go`. + +## What embedders should know + +**dlopen (c-shared, ctypes, FFI):** the library's constructor isolates +signal state during the load itself. To have host signal handling fully +live immediately after the load (rather than at the first server +creation), call the one exported function from the thread that loaded +the library: + +```c +void* handle = dlopen("libtailscale.so", RTLD_NOW); +// signals are snapshotted and the loading thread's mask is still blocked: +tailscale_signal_restore(); // resolve from the same handle +``` + +The first `tailscale_new()` (or Go-side `TsnetNewServer()`) call does +this automatically, so embedders that immediately create a server can +skip even that. + +**c-archive (linking libtailscale.a, e.g. Swift/iOS):** same mechanics — +the constructor runs at program startup, before the Go runtime +initializes, and the first `tailscale_new()`/`TsnetNewServer()` call +restores the main thread's mask. Embedders that link the archive but +reference only `Tsnet*` symbols directly (e.g. some FFI-style bindings) +must make sure `tailscale.o` is actually linked in, or they get the +unisolated legacy behavior: the archive's object is only pulled in when +a `tailscale_*` symbol is referenced. + +**fork():** the Go runtime does not survive `fork()`. Do not call into +libtailscale from forked children; `exec` is fine. + +**SIGURG:** the Go runtime uses `SIGURG` for goroutine preemption and +does not forward host `SIGURG` handlers reliably. If your host +application needs `SIGURG`, the process must be started with +`GODEBUG=asyncpreemptoff=1` in the environment (it cannot be set from +within a library, after runtime init), trading Go-side preemption for the +signal. This is rare enough that libtailscale does not do it by default. + +**SIGSEGV/SIGBUS/SIGFPE/SIGILL/SIGPIPE:** these belong to the Go runtime +while the library is loaded. Host handlers are forwarded (host +stack-overflow guards keep working), but installing a *new* host handler +for them after load is not reliable. Hosts that require their own +`SIGSEGV` handler (some JVM configurations) should treat embedding a Go +runtime as incompatible. + +## Testing + +* `make check-signals` — fails the build if `os/signal` is linked. +* `make test-signals` (also part of `make test`) — runs the C embedder + verification (`csigtest/main.c`: constructor ordering, mask inheritance + and restoration, `SA_ONSTACK` repair, pending-signal delivery, all via + `dlopen`) and the Ruby suite `ruby/test/test_signal_safety.rb` (traps + keep firing, flags clean, Go threads masked in a pristine child + process, `up` interruptible). +* `go test ./...` — exercises `tailscale_up_timeout`/`tailscale_cancel`/ + close-cancels-up through the C API (`tsnetctest`). diff --git a/csigtest/main.c b/csigtest/main.c new file mode 100644 index 0000000..068e4aa --- /dev/null +++ b/csigtest/main.c @@ -0,0 +1,167 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// csigtest verifies libtailscale's host-signal isolation from the +// perspective of a C embedder (and of FFI-style bindings, which load the +// library the same way). Usage: csigtest +// +// Run by `make test-signals`. See SIGNALS.md. +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../tailscale.h" + +static volatile sig_atomic_t got_usr1 = 0; +static void usr1_handler(int sig) { got_usr1 = 1; } + +static int failures = 0; +#define CHECK(cond, msg) do { \ + if (!(cond)) { printf("FAIL: %s\n", msg); failures++; } \ + else { printf("ok: %s\n", msg); } \ +} while (0) + +// sigblk_of_tid reads the thread's blocked signal set from /proc, or +// returns (uint64_t)-1 on error. Linux only; the test exits early on +// other platforms. +static uint64_t sigblk_of_tid(int tid) { + char path[64]; + snprintf(path, sizeof(path), "/proc/self/task/%d/status", tid); + FILE* f = fopen(path, "r"); + if (f == NULL) { + return (uint64_t)-1; + } + char line[256]; + uint64_t blk = (uint64_t)-1; + while (fgets(line, sizeof(line), f) != NULL) { + if (sscanf(line, "SigBlk: %llx", (unsigned long long*)&blk) == 1) { + break; + } + } + fclose(f); + return blk; +} + +int main(int argc, char** argv) { + if (access("/proc/self/task", F_OK) != 0) { + printf("skipping: no /proc\n"); + return 0; + } + + const char* lib = argc > 1 ? argv[1] : "./libtailscale.so"; + + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = usr1_handler; // deliberately no SA_ONSTACK + if (sigaction(SIGUSR1, &sa, NULL) != 0) { + perror("sigaction"); + return 1; + } + + void* handle = dlopen(lib, RTLD_NOW); + if (handle == NULL) { + printf("FAIL: dlopen: %s\n", dlerror()); + return 1; + } + + // Right after the load: the library's constructor ran before the Go + // runtime's (that is what gives Go threads the blocked mask below), + // and the Go runtime has initialized. The loading thread's mask is + // still blocked at this point: completing the isolation is the + // embedder's one call — either tailscale_signal_restore(), or the first + // tailscale_new()/TsnetNewServer() call does it automatically. + sigset_t now; + CHECK(pthread_sigmask(SIG_SETMASK, NULL, &now) == 0, "read thread mask"); + CHECK(sigismember(&now, SIGUSR1) == 1, + "loading thread mask still blocked right after load (isolation active)"); + + // A signal delivered now pends; it must arrive once the mask is + // restored, without being lost. + kill(getpid(), SIGUSR1); + + // The one explicit step an embedder can take (bindings do this right + // after loading; it also happens automatically on the first server + // creation): + void (*signal_restore)(void) = (void(*)(void))dlsym(handle, "tailscale_signal_restore"); + CHECK(signal_restore != NULL, "resolve tailscale_signal_restore"); + if (signal_restore != NULL) { + signal_restore(); + } + + // SA_ONSTACK (added by the Go runtime's initsig during the load) must + // be gone, and the host handler back to its exact pre-load disposition. + struct sigaction cur; + CHECK(sigaction(SIGUSR1, NULL, &cur) == 0, "read back SIGUSR1 disposition"); + CHECK(cur.sa_handler == usr1_handler, "host handler still installed"); + CHECK((cur.sa_flags & SA_ONSTACK) == 0, + "tailscale_signal_restore removed the Go runtime's SA_ONSTACK"); + + CHECK(pthread_sigmask(SIG_SETMASK, NULL, &now) == 0, "read thread mask again"); + CHECK(sigismember(&now, SIGUSR1) == 0, "thread mask restored by tailscale_signal_restore"); + usleep(200 * 1000); + CHECK(got_usr1 == 1, "SIGUSR1 sent during the blocked window was delivered after restore"); + + // The Go package init repairs flags again, idempotently; make sure it + // does not re-mutate them after a moment. + usleep(300 * 1000); + CHECK(sigaction(SIGUSR1, NULL, &cur) == 0 && (cur.sa_flags & SA_ONSTACK) == 0, + "SA_ONSTACK still absent after the Go package init ran"); + + tailscale (*new_server)(void) = (tailscale(*)(void))dlsym(handle, "tailscale_new"); + CHECK(new_server != NULL, "resolve tailscale_new"); + tailscale sd = -1; + if (new_server != NULL) { + sd = new_server(); + } + CHECK(sd >= 0, "tailscale_new works"); + + // Go runtime threads (they have SIGURG unblocked for preemption) must + // not accept host signals (SIGINT, SIGUSR1): they must have inherited + // the constructor's blocked mask. + int go_threads = 0, unisolated = 0; + DIR* d = opendir("/proc/self/task"); + if (d != NULL) { + struct dirent* de; + while ((de = readdir(d)) != NULL) { + int tid = atoi(de->d_name); + if (tid <= 0 || tid == (int)getpid()) { + continue; // this (host) thread + } + uint64_t blk = sigblk_of_tid(tid); + if (blk == (uint64_t)-1) { + continue; + } + int urg_unblocked = !((blk >> (SIGURG - 1)) & 1); + int host_blocked = ((blk >> (SIGINT - 1)) & 1) && ((blk >> (SIGUSR1 - 1)) & 1); + if (urg_unblocked) { + go_threads++; + if (!host_blocked) { + unisolated++; + printf("FAIL: go thread %d accepts host signals (SigBlk=%llx)\n", + tid, (unsigned long long)blk); + } + } + } + closedir(d); + } + CHECK(go_threads > 0, "Go runtime threads observed"); + CHECK(unisolated == 0, "Go threads inherited the blocked signal mask"); + + int (*close_server)(tailscale) = (int(*)(tailscale))dlsym(handle, "tailscale_close"); + if (close_server != NULL && sd >= 0) { + close_server(sd); // never started; early-returns + } + + if (failures != 0) { + printf("csigtest: %d FAILURES\n", failures); + return 1; + } + printf("csigtest: all checks passed\n"); + return 0; +} diff --git a/python/README.md b/python/README.md index 65c71a6..9220ac2 100644 --- a/python/README.md +++ b/python/README.md @@ -31,10 +31,26 @@ Build a distributable wheel: ## Usage -The node will need to be authorized in order to function. Set an auth key via +The node will need to be authorized in order for it to function. Set an auth key via the `$TS_AUTHKEY` environment variable, with `TSNet.set_authkey`, or watch the log stream and respond to the printed authorization URL. +`TSNet.up()` blocks until the node is usable, but stays responsive to interrupts +while blocked: a `KeyboardInterrupt` (Ctrl+C) cancels the underlying Go call and +re-raises, and `up(timeout_ms=...)` bounds the wait: + + t = TSNet() + t.up(timeout_ms=30_000) # raises TSNetException after 30 seconds + +A `up()` that was interrupted or timed out may leave the server partially +started; create a new `TSNet` rather than retrying on the same one. + +The package loads a Go runtime as a native extension, which normally takes over +some process-global signal handling. libtailscale isolates this while it loads, +and the package completes it at import time, so Python's own signal handling +(`signal.signal`, `KeyboardInterrupt`) keeps working unchanged. See +[SIGNALS.md](../SIGNALS.md) in the libtailscale repository for details. + ## Contributing Pull requests are welcome on GitHub at https://github.com/tailscale/libtailscale diff --git a/python/src/main.cpp b/python/src/main.cpp index 7c8b55b..fc5a73a 100644 --- a/python/src/main.cpp +++ b/python/src/main.cpp @@ -9,7 +9,16 @@ namespace py = pybind11; +// From libtailscale's tailscale.c (see SIGNALS.md): completes the host- +// signal isolation the library constructor started while loading, by +// restoring this (importing) thread's signal mask. Also ensures the +// object containing the constructor is linked in from the archive. +extern "C" void tailscale_signal_restore(void); + PYBIND11_MODULE(_tailscale, m) { + // Must run on the importing thread, right after load. + tailscale_signal_restore(); + m.doc() = R"pbdoc( Embedded Tailscale ----------------------- @@ -26,15 +35,28 @@ PYBIND11_MODULE(_tailscale, m) { m.def("start", &TsnetStart, R"pbdoc( Starts a tsnet server - )pbdoc"); + )pbdoc", py::call_guard()); + // The up/dial/accept calls below block, possibly indefinitely. They + // must not hold the GIL while doing so, or the interpreter cannot run + // other threads, process signals, or (with the cancel API below) be + // interrupted at all. m.def("up", &TsnetUp, R"pbdoc( Brings the given tsnet server up + )pbdoc", py::call_guard()); + + m.def("up_timeout", &TsnetUpTimeout, R"pbdoc( + Brings the given tsnet server up, blocking at most timeout_ms + milliseconds (timeout_ms <= 0 means no deadline) + )pbdoc", py::call_guard()); + + m.def("cancel", &TsnetCancel, R"pbdoc( + Cancels the most recent pending up or dial on the server )pbdoc"); m.def("close", &TsnetClose, R"pbdoc( Closes a given tsnet server - )pbdoc"); + )pbdoc", py::call_guard()); m.def("err_msg", &TsnetErrmsg, R"pbdoc( @@ -42,15 +64,20 @@ PYBIND11_MODULE(_tailscale, m) { m.def("listen", [](int sd, char* network, char* addr) { int listenerOut; int rv = TsnetListen(sd, network, addr, &listenerOut); return std::make_tuple(listenerOut, rv); }, R"pbdoc( Listen on a given protocol and port - )pbdoc"); + )pbdoc", py::call_guard()); m.def("accept", [](int ld) { int connOut; int rv = TsnetAccept(ld, &connOut); return std::make_tuple(connOut, rv);}, R"pbdoc( Accept a given listener and connection - )pbdoc"); + )pbdoc", py::call_guard()); m.def("dial", &TsnetDial, R"pbdoc( - )pbdoc"); + )pbdoc", py::call_guard()); + + m.def("dial_timeout", [](int sd, char* network, char* addr, long long timeoutMs) { int connOut; int rv = TsnetDialTimeout(sd, network, addr, timeoutMs, &connOut); return std::make_tuple(connOut, rv); }, R"pbdoc( + Dial an address, blocking at most timeout_ms milliseconds + (timeout_ms <= 0 means no deadline) + )pbdoc", py::call_guard()); m.def("set_dir", &TsnetSetDir, R"pbdoc( diff --git a/python/tailscale/__init__.py b/python/tailscale/__init__.py index 945e76f..1105578 100644 --- a/python/tailscale/__init__.py +++ b/python/tailscale/__init__.py @@ -1,4 +1,9 @@ # Copyright (c) Tailscale Inc & AUTHORS # SPDX-License-Identifier: BSD-3-Clause +# The extension module (_tailscale) links the libtailscale c-archive, whose +# constructor isolates host signal state while the Go runtime initializes, +# and whose module init below completes the isolation by restoring this +# thread's signal mask. Python signal handling works unchanged; see +# SIGNALS.md in the libtailscale repository. from .tsnet import TSNet, TSNetException diff --git a/python/tailscale/tsnet.py b/python/tailscale/tsnet.py index a9bc60d..f09f948 100644 --- a/python/tailscale/tsnet.py +++ b/python/tailscale/tsnet.py @@ -2,9 +2,11 @@ # SPDX-License-Identifier: BSD-3-Clause import io +import threading from . import _tailscale + class TSNetException(Exception): pass @@ -14,8 +16,20 @@ def __init__(self, ephemeral=False): if ephemeral and _tailscale.set_ephemeral(self.ts, 1): raise TSNetException("Error setting ephemeral") - def up(self): - if _tailscale.up(self.ts): + def up(self, timeout_ms=None): + """Bring the tailscale server up and wait for it to be usable. + + If timeout_ms is given, the call blocks at most that many + milliseconds. While blocked, the call stays responsive to + interrupts such as KeyboardInterrupt (Ctrl+C): the underlying Go + call runs on a helper thread and is canceled if this thread is + interrupted, after which the interrupt is re-raised. + """ + if timeout_ms is not None: + rv = self._call_interruptible(lambda: _tailscale.up_timeout(self.ts, timeout_ms)) + else: + rv = self._call_interruptible(lambda: _tailscale.up(self.ts)) + if rv: raise TSNetException("Error coming up") def listen(self, proto, addr): @@ -28,6 +42,45 @@ def close(self): if _tailscale.close(self.ts): raise TSNetException("Failed to close") + def cancel(self): + """Cancel the most recent pending up (or dial), making it return + a canceled error. Also performed automatically by close() and + when an interrupted up() is winding down.""" + return _tailscale.cancel(self.ts) + + def _call_interruptible(self, fn): + """Run a blocking libtailscale call on a helper thread so this + thread stays responsive to interrupts (KeyboardInterrupt et al.) + while the Go call blocks. If interrupted, the Go call is canceled + with tailscale cancel and the interrupt is re-raised. + + Requires a libtailscale with the cancel API; falls back to a + plain blocking call otherwise.""" + if not hasattr(_tailscale, "cancel"): + return fn() + + result = {} + + def worker(): + try: + result["rv"] = fn() + except BaseException as e: + result["exc"] = e + + t = threading.Thread(target=worker, daemon=True) + t.start() + try: + t.join() + except BaseException: + _tailscale.cancel(self.ts) + # Bound the wait: a canceled call returns promptly, but a + # wedged runtime must not turn an interrupt into a new hang. + t.join(10) + raise + if "exc" in result: + raise result["exc"] + return result.get("rv") + class TSNetListener: def __init__(self, ln): @@ -38,7 +91,3 @@ def accept(self): if err: raise TSNetException("Failed to accept conn") return fd - - def close(self): - if _tailscale.close_listener(self.ln): - raise TSNetException("Failed to close") diff --git a/ruby/README.md b/ruby/README.md index 14ace91..f2f4e51 100644 --- a/ruby/README.md +++ b/ruby/README.md @@ -33,6 +33,31 @@ while c = l.accept end ``` +### Interrupting blocking calls + +`up` and `dial` block until the node is usable (e.g. authorized). Both +methods stay responsive to interrupts while blocked: `Ctrl+C` in irb, +`Timeout.timeout`, or `Thread#raise` cancel the underlying Go call and +re-raise the interrupt. Both also take an optional `timeout:` (in seconds): + +```ruby +t.up(timeout: 30) # raises Tailscale::Error after 30 seconds +t.cancel # or interrupt the most recent up/dial yourself +``` + +A `up` that was interrupted or timed out may leave the server partially +started; create a new `Tailscale` instance rather than retrying on the +same one. + +### Signal handling + +The gem loads a Go runtime as a shared library, which normally takes over +some process-global signal handling. libtailscale isolates this while it +loads (and the gem completes it with one call right after loading), so +Ruby's own signal handling (`Signal.trap`, `Ctrl+C`, `SystemStackError`) +keeps working unchanged. See [SIGNALS.md](../SIGNALS.md) in the +libtailscale repository for details. + ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. diff --git a/ruby/Rakefile b/ruby/Rakefile index 27540e7..1895ee8 100644 --- a/ruby/Rakefile +++ b/ruby/Rakefile @@ -50,4 +50,14 @@ Rake::TestTask.new(:test) do |t| end task test: :compile -task default: :test \ No newline at end of file +task default: :test + +# test:signals runs only the signal-safety tests. They are self-contained +# (no test control server needed) and can be run standalone, but depend on +# the compiled library like the other tests. +Rake::TestTask.new("test:signals") do |t| + t.libs << "test" + t.libs << "lib" + t.test_files = FileList["test/**/test_signal_safety.rb"] +end +task "test:signals" => :compile diff --git a/ruby/lib/tailscale.rb b/ruby/lib/tailscale.rb index d17c43c..f259e4d 100644 --- a/ruby/lib/tailscale.rb +++ b/ruby/lib/tailscale.rb @@ -27,6 +27,19 @@ module Libtailscale end end + # libtailscale isolates host signal state itself while loading (its + # constructor snapshots handler dispositions and blocks signals for + # the Go runtime's threads; see SIGNALS.md in the libtailscale repo). + # Completing the isolation requires restoring this thread's signal + # mask, which only this thread can do — so do it right after the + # library loads, keeping traps and Ctrl+C fully live even before any + # server is created. Older libraries: no-op fallback. + begin + attach_function :tailscale_signal_restore, [], :void + tailscale_signal_restore + rescue FFI::NotFoundError + end + attach_function :TsnetNewServer, [], :int attach_function :TsnetStart, [:int], :int attach_function :TsnetUp, [:int], :int, blocking: true @@ -44,6 +57,26 @@ module Libtailscale attach_function :TsnetGetRemoteAddr, [:int, :int, :pointer, :size_t], :int attach_function :TsnetErrmsg, [:int, :pointer, :size_t], :int attach_function :TsnetLoopback, [:int, :pointer, :size_t, :pointer, :pointer], :int + + # Cancellation and timeout APIs (libtailscale with TsnetCancel). Older + # libraries don't export them; attach opportunistically and fall back to + # the legacy, non-interruptible blocking behavior when they're missing. + @cancellable = false + begin + attach_function :TsnetUpTimeout, [:int, :int64], :int, blocking: true + attach_function :TsnetDialTimeout, [:int, :string, :string, :int64, :pointer], :int, blocking: true + attach_function :TsnetCancel, [:int], :int + @cancellable = true + rescue FFI::NotFoundError + end + + # Returns whether the loaded libtailscale supports cancelable blocking + # calls (TsnetUpTimeout/TsnetDialTimeout/TsnetCancel). When false, +up+ + # and +dial+ block until the Go call completes and cannot be timed out + # or interrupted. + def self.cancellable? + @cancellable + end end class ClosedError < StandardError @@ -194,8 +227,39 @@ def start # Bring the tailscale server up and wait for it to be usable. This method # blocks until the node is fully authorized. - def up - Error.check(self, Libtailscale::TsnetUp(@t)) + # + # If +timeout+ is given (a number of seconds), the call blocks at most + # that long and raises Tailscale::Error if it expires. + # + # While blocked, this method remains responsive to interrupts such as + # Ctrl+C in irb, Timeout.timeout, or Thread#raise: the underlying Go call + # runs on a helper thread and is canceled with TsnetCancel if this thread + # is interrupted, after which the interrupt is re-raised. + def up(timeout: nil) + assert_open + timeout_ms = timeout ? (timeout * 1000).ceil : -1 + if Libtailscale.cancellable? + run_interruptible do + if timeout_ms >= 0 + Libtailscale.TsnetUpTimeout(@t, timeout_ms) + else + Libtailscale.TsnetUp(@t) + end + end + elsif timeout_ms >= 0 + raise Error.new("tailscale error: up timeout requires a libtailscale with TsnetUpTimeout", -1) + else + Error.check(self, Libtailscale::TsnetUp(@t)) + end + end + + # Cancel the most recent pending blocking operation on this server (+up+ + # or +dial+), making it return a canceled error. This also happens + # automatically when +close+ is called or when +up+ or +dial+ are + # interrupted. Canceling a completed operation is a no-op. + def cancel + assert_open + Libtailscale.TsnetCancel(@t) end # Close the tailscale server. @@ -253,11 +317,34 @@ def get_ips # Dial a network address. +network+ is one of "tcp" or "udp". +addr+ is the # remote address to connect to. This method blocks until the connection is established. - def dial(network, addr) + # + # If +timeout+ is given (a number of seconds), the call blocks at most + # that long and raises Tailscale::Error if it expires. Like +up+, the call + # remains responsive to interrupts while it blocks. + def dial(network, addr, timeout: nil) assert_open - conn = FFI::MemoryPointer.new(:int) - Error.check(self, Libtailscale::TsnetDial(@t, network, addr, conn)) - IO::new(conn.read_int) + timeout_ms = timeout ? (timeout * 1000).ceil : -1 + if Libtailscale.cancellable? + io = nil + run_interruptible do + conn = FFI::MemoryPointer.new(:int) + code = + if timeout_ms >= 0 + Libtailscale.TsnetDialTimeout(@t, network, addr, timeout_ms, conn) + else + Libtailscale.TsnetDial(@t, network, addr, conn) + end + io = IO::new(conn.read_int) if code == 0 + code + end + io + elsif timeout_ms >= 0 + raise Error.new("tailscale error: dial timeout requires a libtailscale with TsnetDialTimeout", -1) + else + conn = FFI::MemoryPointer.new(:int) + Error.check(self, Libtailscale.TsnetDial(@t, network, addr, conn)) + IO::new(conn.read_int) + end end # Listen on a network address. +network+ is one of "tcp" or "udp". +addr+ is @@ -308,4 +395,37 @@ def errmsg def assert_open raise ClosedError if @t <= 0 end + + private + + # Runs the given blocking FFI call on a helper thread so that the calling + # thread stays responsive to interrupts (signals such as Ctrl+C, Timeout, + # Thread#raise) while the Go call blocks. If the calling thread is + # interrupted, the Go call is canceled with TsnetCancel and the interrupt + # is re-raised once the call has wound down. + def run_interruptible + result = nil + worker = Thread.new do + result = yield + rescue Exception => e + result = e + end + begin + # join is an interruptible checkpoint for the calling thread. + worker.join + rescue Exception + begin + Libtailscale.TsnetCancel(@t) + rescue StandardError + nil + end + # Bound the wait: a canceled call returns promptly, but a wedged + # runtime must not turn an interrupt into a new hang. If it expires, + # the worker thread leaks until the Go call completes. + worker.join(10) + raise + end + raise result if result.is_a?(Exception) + Error.check(self, result) + end end diff --git a/ruby/test/test_signal_safety.rb b/ruby/test/test_signal_safety.rb new file mode 100644 index 0000000..7ba271b --- /dev/null +++ b/ruby/test/test_signal_safety.rb @@ -0,0 +1,198 @@ +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause + +# frozen_string_literal: true + +# Signal-safety tests. These are self-contained and deliberately do NOT +# require "test_helper": they must run without the test control server so +# they can be exercised standalone (rake test:signals, make test-signals) +# and assert process-global behavior that requires a pristine process: +# handler flags, thread signal masks, and interruptibility of blocking +# calls. + +require "minitest/autorun" +require "fileutils" +require "tmpdir" + +require "tailscale" + +# Host sigaction probing helpers for the SA_ONSTACK assertions below. +module SigactionProbe + SA_ONSTACK = 0x08000000 # linux + + # glibc struct sigaction on x86_64 linux: handler(8), sa_mask(128), + # sa_flags(int @136), sa_restorer(8). Only the flags offset is read. + SIZE = 152 + FLAGS_OFFSET = 136 + + def self.supported? + RbConfig::CONFIG["host_os"].include?("linux") && + ["x86_64", "aarch64"].include?(RbConfig::CONFIG["target_cpu"]) + end + + def self.flags(sig) + Libc.sigaction(sig, nil, @buf ||= FFI::MemoryPointer.new(SIZE)) + @buf.get_uint32(FLAGS_OFFSET) + end + + module Libc + extend FFI::Library + ffi_lib FFI::Library::LIBC + attach_function :sigaction, [:int, :pointer, :pointer], :int + end +end + +# Tests that the Ruby process's own signal handling keeps working exactly +# as before the libtailscale (Go c-shared) library was loaded, that the +# load-time signal isolation took effect, and that blocking calls can be +# interrupted. +class TestSignalSafety < Minitest::Test + def setup + super + @tmpdir = Dir.mktmpdir + end + + def teardown + super + FileUtils.remove_entry_secure(@tmpdir) if File.directory?(@tmpdir) + end + + def test_traps_still_work_after_library_load + ran = 0 + old_int = Signal.trap("INT") { ran += 1 } + old_usr1 = Signal.trap("USR1") { ran += 1 } + begin + Process.kill("INT", $$) + Process.kill("USR1", $$) + sleep 0.05 # trap handlers run at the next safe point + assert_equal 2, ran, "host signal traps must keep firing after libtailscale loads" + ensure + Signal.trap("INT", old_int) + Signal.trap("USR1", old_usr1) + end + end + + def test_go_did_not_add_sa_onstack_to_host_handlers + skip("host sigaction ABI unknown") unless SigactionProbe.supported? + + # The Go runtime adds SA_ONSTACK to every caught host handler at library + # load; libtailscale must have removed it again (its constructor + # snapshots the host dispositions and tailscale_signal_restore — called + # by the gem at load, or by the Go package init — restores them). + ran = 0 + old = Signal.trap("USR2") { ran += 1 } + begin + flags = SigactionProbe.flags(Signal.list.fetch("USR2")) + assert_equal 0, flags & SigactionProbe::SA_ONSTACK, + "Go runtime SA_ONSTACK leaked onto a host signal handler" + ensure + Signal.trap("USR2", old) + end + end + + # A pristine child process requires the library and reports every + # thread's signal mask. Go threads must have inherited the constructor's + # blocked mask (everything blocked except the signals the Go runtime + # needs), so that host signals are only ever delivered to host threads. + # The main (host) thread's own mask must already be restored when the + # require returns: the gem calls tailscale_signal_restore right after + # loading the library. + def test_go_threads_inherited_the_blocked_mask + skip("linux only") unless RbConfig::CONFIG["host_os"].include?("linux") + + script = <<~'RUBY' + require "tailscale" + main_blk = File.read("/proc/self/status")[/^SigBlk:\s+([0-9a-f]+)/, 1].to_i(16) + puts "main_blocked=#{main_blk != 0}" + Tailscale.new + int_bit = 1 << (Signal.list.fetch("INT") - 1) + urg_bit = 1 << (Signal.list.fetch("URG") - 1) + go_threads = 0 + Dir.children("/proc/self/task").each do |tid| + # The main thread is the host's own thread: host signals + # delivered to it is exactly what the isolation is for. + next if tid.to_i == Process.pid + blk = File.read("/proc/self/task/#{tid}/status")[/^SigBlk:\s+([0-9a-f]+)/, 1].to_i(16) + # Go runtime threads unblock SIGURG (their preemption signal); a + # Go thread that also has SIGINT unblocked means the load-time + # signal isolation did not take effect. + if (blk & urg_bit) == 0 && (blk & int_bit) == 0 + puts "BAD: tid #{tid} accepts host signals (SigBlk=#{blk})" + end + go_threads += 1 if (blk & urg_bit) == 0 + end + puts "go_threads=#{go_threads}" + RUBY + + out, ok = run_child(script) + assert ok, "child ruby failed:\n#{out}" + assert_equal "main_blocked=false", out[/^main_blocked=(\w+)$/, 0], + "the loading thread's mask must be restored when require returns:\n#{out}" + assert_empty out.lines.grep(/^BAD:/), "Go threads with host signals unblocked:\n#{out}" + assert_match(/^go_threads=\d+$/, out) + refute_equal "go_threads=0", out[/^go_threads=(\d+)$/, 0], + "no Go threads observed; test could not verify isolation" + end + + # The irb failure mode this whole exercise exists for: TsnetUp blocking + # forever, with Ctrl+C doing nothing. With the cancellable API, an + # interrupt (here simulated with Thread#raise, the same mechanism Ctrl+C + # uses to interrupt the main thread) must cancel the Go call and surface. + def test_interrupt_cancels_up + ts = new_unreachable_server + main = Thread.current + kicker = Thread.new { sleep 0.5; main.raise(Interrupt, "simulated ctrl+c") } + begin + # The server never comes up (unreachable control URL); without + # cancellation this would hang for minutes. + assert_raises(Interrupt) { ts.up } + ensure + kicker.join + ts.close + end + end + + def test_up_timeout_returns_promptly + ts = new_unreachable_server + begin + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + assert_raises(Tailscale::Error) { ts.up(timeout: 0.5) } + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started + + assert_match(/deadline|timeout/i, ts.errmsg) + assert_operator elapsed, :<, 10, "up(timeout: 0.5) took #{elapsed.round(1)}s to give up" + assert_operator elapsed, :>=, 0.4, "up returned before its deadline expired" + ensure + ts.close + end + end + + def test_cancel_is_a_noop_when_idle + ts = Tailscale.new + begin + assert_equal 2, ts.cancel, "expected ENOENT when there is no pending operation to cancel" + ensure + ts.close + end + end + + private + + # A server pointed at an unreachable control URL, so that up blocks + # indefinitely (until canceled or timed out) without needing testcontrol. + def new_unreachable_server + ts = Tailscale.new + ts.set_dir(@tmpdir) + ts.set_hostname("signal-safety-test") + ts.set_control_url("http://127.0.0.1:1") + ts.set_ephemeral(true) + ts.set_log_fd(IO.sysopen(File::NULL, "w")) + ts + end + + def run_child(script) + out = IO.popen([RbConfig.ruby, "-I", File.expand_path("../lib", __dir__), "-e", script], + err: [:child, :out], &:read) + [out, $?.success?] + end +end diff --git a/tailscale.c b/tailscale.c index 420d67e..fdae24f 100644 --- a/tailscale.c +++ b/tailscale.c @@ -6,13 +6,151 @@ #include #include +// Host-signal-state isolation. See SIGNALS.md. +// +// The Go runtime initializes in a library constructor at load time and +// makes process-global changes to signal state: +// +// - it installs its own handlers for SIGILL, SIGBUS, SIGFPE, SIGSEGV, +// SIGPIPE and SIGURG (forwarding foreign faults back to the host); +// - it adds SA_ONSTACK to the flags of every other *caught* host +// handler (initsig's setsigstack), changing where those handlers run; +// - it snapshots the loading thread's signal mask to seed the mask of +// every thread it creates, so with a typical all-unblocked host those +// Go threads accept (and route through the Go runtime) process- +// directed signals. +// +// To keep the host runtime's signal handling working unchanged: +// +// 1. ts_signal_isolate runs BEFORE the Go runtime's own constructor +// (prioritized constructors are sorted ahead of unprioritized ones): +// it snapshots the host's caught signal dispositions and blocks every +// signal on the loading thread. The thread the Go runtime creates for +// itself inherits that mask, and it in turn seeds every Go thread, so +// Go threads only ever accept the signals the Go runtime needs. +// 2. ts_signal_flags_restore undoes the SA_ONSTACK mutations; it is +// invoked from the Go package init (which runs after the runtime's +// signal setup but before the library load returns). +// 3. tailscale_signal_restore restores the loading thread's own signal +// mask, completing the isolation. It is called automatically from +// the first tailscale_new()/TsnetNewServer() call; embedders that +// want host signals live before creating any server can call it +// explicitly from the thread that loaded the library. +#if !defined(_WIN32) +#include +#include + +#define TS_NSIG 32 // signal dispositions snapshotted: signals 1..31 + +static struct sigaction ts_preload_sigaction[TS_NSIG]; +static int ts_preload_valid[TS_NSIG]; +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 + +// ts_go_owned reports whether sig is one of the signals the Go runtime +// installs its own handler for in c-archive/c-shared mode (fault recovery, +// SIGURG preemption, SIGPIPE semantics). Those dispositions belong to the +// Go runtime and are left alone. +static int ts_go_owned(int sig) { + return sig == SIGILL || sig == SIGBUS || sig == SIGFPE || + sig == SIGSEGV || sig == SIGPIPE || sig == SIGURG; +} + +// ts_signal_isolate snapshots the host's caught signal dispositions and +// blocks all signals on the loading thread, so that the thread the Go +// runtime creates during its own constructor (and every Go thread seeded +// from it) inherits the blocked mask. +// +// Uses only async-signal- and constructor-safe libc calls. +__attribute__((constructor(101))) +static void ts_signal_isolate(void) { + int sig; + for (sig = 1; sig < TS_NSIG; sig++) { + struct sigaction sa; + if (sig == SIGKILL || sig == SIGSTOP || ts_go_owned(sig)) { + continue; + } + if (sigaction(sig, NULL, &sa) != 0) { + continue; + } + if (sa.sa_handler != SIG_DFL && sa.sa_handler != SIG_IGN) { + // The host has caught this signal; protect its disposition. + ts_preload_sigaction[sig] = sa; + ts_preload_valid[sig] = 1; + } + } + sigset_t all; + sigfillset(&all); + if (pthread_sigmask(SIG_BLOCK, &all, &ts_preload_mask) == 0) { + ts_loader_thread = pthread_self(); + ts_signal_isolation = 1; + } +} + +// ts_signal_flags_restore undoes the SA_ONSTACK flag that the Go runtime +// added to caught host signal handlers during its signal setup. Idempotent +// and process-global; called from the Go package init (before the library +// load returns) and from tailscale_signal_restore. +void ts_signal_flags_restore(void) { + int sig; + if (!ts_signal_isolation) { + return; + } + for (sig = 1; sig < TS_NSIG; sig++) { + struct sigaction cur; + if (!ts_preload_valid[sig]) { + continue; + } + if (sigaction(sig, NULL, &cur) != 0) { + continue; + } + if (cur.sa_handler != ts_preload_sigaction[sig].sa_handler) { + continue; // no longer the host's handler + } + if ((cur.sa_flags & SA_ONSTACK) == 0 || + (ts_preload_sigaction[sig].sa_flags & SA_ONSTACK) != 0) { + continue; // untouched, or the host wanted SA_ONSTACK itself + } + // Restore the exact pre-load disposition (handler, flags, mask). + sigaction(sig, &ts_preload_sigaction[sig], NULL); + } +} + +// tailscale_signal_restore restores the signal mask of the thread that +// loaded the library, completing the isolation begun by the constructor. +// +// The mask is per-thread, so only the loading thread can restore its own; +// the process-global flag repair happens regardless. Both parts are +// idempotent, so calling this repeatedly, or from other threads, is +// harmless. +void tailscale_signal_restore(void) { + if (!ts_signal_isolation) { + return; + } + if (!ts_mask_restored && pthread_equal(pthread_self(), ts_loader_thread)) { + pthread_sigmask(SIG_SETMASK, &ts_preload_mask, NULL); + ts_mask_restored = 1; + } + ts_signal_flags_restore(); +} + +#else // _WIN32: no signals to isolate. +void ts_signal_flags_restore(void) {} +void tailscale_signal_restore(void) {} +#endif + // Functions exported by Go. extern int TsnetNewServer(); extern int TsnetStart(int sd); extern int TsnetUp(int sd); +extern int TsnetUpTimeout(int sd, long long timeoutMs); +extern int TsnetCancel(int sd); extern int TsnetClose(int sd); extern int TsnetErrmsg(int sd, char* buf, size_t buflen); extern int TsnetDial(int sd, char* net, char* addr, int* connOut); +extern int TsnetDialTimeout(int sd, char* net, char* addr, long long timeoutMs, int* connOut); extern int TsnetSetDir(int sd, char* str); extern int TsnetSetHostname(int sd, char* str); extern int TsnetSetAuthKey(int sd, char* str); @@ -28,6 +166,9 @@ extern int TsnetStatusJSON(int sd, char** jsonOut); extern int TsnetEnableFunnelToLocalhostPlaintextHttp1(int sd, int localhostPort); tailscale tailscale_new() { + // First call for (almost) every embedder: complete the signal-state + // isolation started by the library constructor. No-op once done. + tailscale_signal_restore(); return TsnetNewServer(); } @@ -39,6 +180,14 @@ int tailscale_up(tailscale sd) { return TsnetUp(sd); } +int tailscale_up_timeout(tailscale sd, long long timeout_ms) { + return TsnetUpTimeout(sd, timeout_ms); +} + +int tailscale_cancel(tailscale sd) { + return TsnetCancel(sd); +} + int tailscale_close(tailscale sd) { return TsnetClose(sd); } @@ -47,6 +196,10 @@ int tailscale_dial(tailscale sd, const char* network, const char* addr, tailscal return TsnetDial(sd, (char*)network, (char*)addr, (int*)conn_out); } +int tailscale_dial_timeout(tailscale sd, const char* network, const char* addr, long long timeout_ms, tailscale_conn* conn_out) { + return TsnetDialTimeout(sd, (char*)network, (char*)addr, timeout_ms, (int*)conn_out); +} + int tailscale_listen(tailscale sd, const char* network, const char* addr, tailscale_listener* listener_out) { return TsnetListen(sd, (char*)network, (char*)addr, (int*)listener_out); } diff --git a/tailscale.go b/tailscale.go index ce7cb74..8b38690 100644 --- a/tailscale.go +++ b/tailscale.go @@ -5,6 +5,9 @@ package main //#include "errno.h" +// +//void ts_signal_flags_restore(void); +//void tailscale_signal_restore(void); import "C" import ( @@ -32,6 +35,15 @@ import ( func main() {} +// The Go runtime's signal setup (initsig, run during library load before +// package init) adds SA_ONSTACK to the flags of every caught host signal +// handler, changing where those handlers run in memory. Undo that, +// restoring the exact host dispositions snapshotted by the constructor +// in tailscale.c before the Go runtime initialized. See SIGNALS.md. +func init() { + C.ts_signal_flags_restore() +} + // servers tracks all the allocated *tsnet.Server objects. var servers struct { mu sync.Mutex @@ -43,6 +55,79 @@ type server struct { s *tsnet.Server lastErr string started bool + + // mu guards the pending-operation cancel slots below. + mu sync.Mutex + upCancel *pendingOp // cancels a pending TsnetUp/TsnetUpTimeout + dialCancel *pendingOp // cancels a pending TsnetDial/TsnetDialTimeout +} + +// pendingOp tracks one blocking up or dial operation so that it can be +// canceled with TsnetCancel (or by TsnetClose, so a close does not block on a +// pending up that would otherwise never finish). +type pendingOp struct { + cancel context.CancelFunc +} + +// beginOp registers a cancelable context for a blocking up or dial call and +// returns it along with a token to pass to endOp. timeoutMs <= 0 means no +// deadline. Only the most recent pending operation of each kind is +// cancellable; earlier pending operations keep running but can no longer be +// targeted individually. +func (s *server) beginOp(up bool, timeoutMs C.longlong) (context.Context, *pendingOp) { + var ctx context.Context + var cancel context.CancelFunc + if timeoutMs > 0 { + ctx, cancel = context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond) + } else { + ctx, cancel = context.WithCancel(context.Background()) + } + pc := &pendingOp{cancel: cancel} + s.mu.Lock() + defer s.mu.Unlock() + if up { + s.upCancel = pc + } else { + s.dialCancel = pc + } + return ctx, pc +} + +// endOp unregisters pc if it is still the pending operation and releases +// its context resources. Safe to call when the operation has already been +// canceled. +func (s *server) endOp(up bool, pc *pendingOp) { + s.mu.Lock() + if up { + if s.upCancel == pc { + s.upCancel = nil + } + } else { + if s.dialCancel == pc { + s.dialCancel = nil + } + } + s.mu.Unlock() + pc.cancel() +} + +// cancelOps cancels any pending blocking up or dial operations and reports +// whether there was anything to cancel. +func (s *server) cancelOps() bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.upCancel == nil && s.dialCancel == nil { + return false + } + if s.upCancel != nil { + s.upCancel.cancel() + s.upCancel = nil + } + if s.dialCancel != nil { + s.dialCancel.cancel() + s.dialCancel = nil + } + return true } func getServer(sd C.int) *server { @@ -96,6 +181,12 @@ func (s *server) recErr(err error) C.int { //export TsnetNewServer func TsnetNewServer() C.int { + // First call for (almost) every embedder (directly or via + // tailscale_new): complete the signal-state isolation started by the + // library constructor by restoring the loading thread's signal mask. + // No-op once done. See SIGNALS.md. + C.tailscale_signal_restore() + servers.mu.Lock() defer servers.mu.Unlock() @@ -126,19 +217,54 @@ func TsnetStart(sd C.int) C.int { return s.recErr(err) } +// Up blocks until the server is up and usable. Unlike TsnetUpTimeout, it has +// no deadline, but it is still cancellable with TsnetCancel. +// //export TsnetUp func TsnetUp(sd C.int) C.int { + return up(sd, -1) +} + +// UpTimeout is TsnetUp with a deadline: it blocks until the server is up and +// usable, or timeoutMs milliseconds have elapsed. timeoutMs <= 0 means no +// deadline. It is also cancellable with TsnetCancel. +// +//export TsnetUpTimeout +func TsnetUpTimeout(sd C.int, timeoutMs C.longlong) C.int { + return up(sd, timeoutMs) +} + +func up(sd C.int, timeoutMs C.longlong) C.int { s := getServer(sd) if s == nil { return C.EBADF } - _, err := s.s.Up(context.Background()) // cancellation is via TsnetClose + // Cancellation is via TsnetCancel or the timeout. + ctx, pc := s.beginOp(true, timeoutMs) + defer s.endOp(true, pc) + _, err := s.s.Up(ctx) if err == nil { s.started = true } return s.recErr(err) } +// Cancel interrupts the most recent pending TsnetUp/TsnetUpTimeout and +// TsnetDial/TsnetDialTimeout on the server, making them return a +// context-canceled error. Canceling completed operations is a no-op. +// +//export TsnetCancel +func TsnetCancel(sd C.int) C.int { + s := getServer(sd) + if s == nil { + return C.EBADF + } + if !s.cancelOps() { + return C.ENOENT + } + return 0 +} + //export TsnetClose func TsnetClose(sd C.int) C.int { servers.mu.Lock() @@ -152,7 +278,11 @@ func TsnetClose(sd C.int) C.int { return C.EBADF } - // TODO: cancel Up + // Cancel any pending up/dial so they return promptly instead of + // relying on tsnet.Close to unblock them (which, without an authkey, + // would be never). + s.cancelOps() + // TODO: close related listeners / conns. if !s.started { // Server was never started, nothing to close. @@ -505,13 +635,33 @@ func extractIP(ipWithPort string) string { return match } +// Dial blocks until the connection is established. Unlike +// TsnetDialTimeout, it has no deadline, but it is still cancellable with +// TsnetCancel. +// //export TsnetDial func TsnetDial(sd C.int, network, addr *C.char, connOut *C.int) C.int { + return dial(sd, network, addr, -1, connOut) +} + +// DialTimeout is TsnetDial with a deadline: it blocks until the connection +// is established or timeoutMs milliseconds have elapsed. timeoutMs <= 0 +// means no deadline. It is also cancellable with TsnetCancel. +// +//export TsnetDialTimeout +func TsnetDialTimeout(sd C.int, network, addr *C.char, timeoutMs C.longlong, connOut *C.int) C.int { + return dial(sd, network, addr, timeoutMs, connOut) +} + +func dial(sd C.int, network, addr *C.char, timeoutMs C.longlong, connOut *C.int) C.int { s := getServer(sd) if s == nil { return C.EBADF } - netConn, err := s.s.Dial(context.Background(), C.GoString(network), C.GoString(addr)) + // Cancellation is via TsnetCancel or the timeout. + ctx, pc := s.beginOp(false, timeoutMs) + defer s.endOp(false, pc) + netConn, err := s.s.Dial(ctx, C.GoString(network), C.GoString(addr)) if err != nil { return s.recErr(err) } @@ -587,10 +737,15 @@ func TsnetSetLogFD(sd, fd C.int) C.int { return 0 } f := os.NewFile(uintptr(fd), "logfd") - s.s.Logf = func(format string, args ...any) { + logf := func(format string, args ...any) { fmt.Fprintf(f, format, args...) fmt.Fprintf(f, "\n") } + // tsnet routes some messages through UserLogf (falling back to Go's + // standard log.Printf, bypassing Logf entirely), so both must be set + // for the fd to receive everything. + s.s.Logf = logf + s.s.UserLogf = logf return 0 } diff --git a/tailscale.h b/tailscale.h index 9bc8e7c..f82ada8 100644 --- a/tailscale.h +++ b/tailscale.h @@ -30,6 +30,20 @@ typedef int tailscale; // No network connection is initialized until tailscale_start is called. extern tailscale tailscale_new(); +// tailscale_signal_restore completes the host-signal isolation that the +// library began in its constructor when it was loaded: it restores the +// signal mask of the thread that loaded the library, and re-repairs the +// flags of the host's signal handlers (the Go runtime adds SA_ONSTACK to +// them at load; the library removes it again before the load returns). +// +// It is called automatically by the first tailscale_new() (or Go-side +// TsnetNewServer()) call, so most embedders never need it. Embedders that +// dlopen the library and want their signals (e.g. SIGINT) handled normally +// before creating any server should call it once from the thread that +// loaded the library, right after the load. Calling it from another +// thread, or repeatedly, is harmless. See SIGNALS.md. +extern void tailscale_signal_restore(); + // tailscale_start connects the server to the tailnet. // // Calling this function is optional as it will be called by the first use @@ -42,11 +56,36 @@ extern int tailscale_start(tailscale sd); // tailscale_up connects the server to the tailnet and waits for it to be usable. // -// To cancel an in-progress call to tailscale_up, use tailscale_close. +// This blocks, possibly indefinitely (e.g. until an interactive login is +// completed). To cancel an in-progress call use tailscale_cancel, or +// tailscale_close to also shut the server down. To bound the wait, use +// tailscale_up_timeout. // // Returns zero on success or -1 on error, call tailscale_errmsg for details. extern int tailscale_up(tailscale sd); +// tailscale_up_timeout is tailscale_up with a deadline. It blocks at most +// timeout_ms milliseconds; timeout_ms <= 0 means no deadline (like +// tailscale_up). It is also cancellable with tailscale_cancel. +// +// Returns zero on success or -1 on error, call tailscale_errmsg for details. +extern int tailscale_up_timeout(tailscale sd, long long timeout_ms); + +// tailscale_cancel interrupts the most recent pending tailscale_up or +// tailscale_dial on sd, making it return a context-canceled error. +// +// This is how embeddings can keep blocking calls responsive to host +// interrupts such as Ctrl+C: run the call on a helper thread and call +// tailscale_cancel when the host wants to interrupt it. +// +// Canceling an already-completed operation is a no-op. +// +// Returns: +// 0 - success (a pending operation was found and canceled) +// ENOENT - no pending operation to cancel +// EBADF - sd is not a valid tailscale +extern int tailscale_cancel(tailscale sd); + // tailscale_close shuts down the server. // // Returns: @@ -107,6 +146,13 @@ extern int tailscale_getips(tailscale sd, char* buf, size_t buflen); // Returns zero on success or -1 on error, call tailscale_errmsg for details. extern int tailscale_dial(tailscale sd, const char* network, const char* addr, tailscale_conn* conn_out); +// tailscale_dial_timeout is tailscale_dial with a deadline. It blocks at +// most timeout_ms milliseconds; timeout_ms <= 0 means no deadline (like +// tailscale_dial). It is also cancellable with tailscale_cancel. +// +// Returns zero on success or -1 on error, call tailscale_errmsg for details. +extern int tailscale_dial_timeout(tailscale sd, const char* network, const char* addr, long long timeout_ms, tailscale_conn* conn_out); + // A tailscale_listener is a socket on the tailnet listening for connections. // // It is much like allocating a system socket(2) and calling listen(2). diff --git a/tailscale_test.go b/tailscale_test.go index ee8e822..6065324 100644 --- a/tailscale_test.go +++ b/tailscale_test.go @@ -84,3 +84,17 @@ func TestExtractIP(t *testing.T) { t.Errorf("ipv6 port stripping failed %s != %s", got6, want6) } } + +func TestUpTimeout(t *testing.T) { + // See tsnetctest: cgo is not allowed in test files, so the + // cancellation tests exercise the C API from that package. + tsnetctest.RunTestUpTimeout(t) +} + +func TestUpCancel(t *testing.T) { + tsnetctest.RunTestUpCancel(t) +} + +func TestCloseCancelsUp(t *testing.T) { + tsnetctest.RunTestCloseCancelsUp(t) +} diff --git a/tsnetctest/tsnetctest.go b/tsnetctest/tsnetctest.go index ed4e5cc..80c924e 100644 --- a/tsnetctest/tsnetctest.go +++ b/tsnetctest/tsnetctest.go @@ -284,18 +284,59 @@ int test_getremoteaddr() { } return 0; } + +// up_cancel_* test helpers: a server pointed at an unreachable control +// URL, so that tailscale_up blocks indefinitely (until canceled or timed +// out) without needing testcontrol. +int up_cancel_sd = 0; + +int up_cancel_setup(const char* dir) { + up_cancel_sd = tailscale_new(); + if (up_cancel_sd < 0) return 1; + if (tailscale_set_control_url(up_cancel_sd, "http://127.0.0.1:1") != 0) return 2; + if (tailscale_set_dir(up_cancel_sd, dir) != 0) return 3; + if (tailscale_set_hostname(up_cancel_sd, "up-cancel-test") != 0) return 4; + if (tailscale_set_ephemeral(up_cancel_sd, 1) != 0) return 5; + if (tailscale_set_logfd(up_cancel_sd, -1) != 0) return 6; + return 0; +} + +int up_cancel_start() { + return tailscale_up(up_cancel_sd); +} + +int up_cancel_timeout(long long ms) { + return tailscale_up_timeout(up_cancel_sd, ms); +} + +// up_cancel_poll returns 0 once a pending up was found and canceled, or +// ENOENT if there is nothing pending yet. Lets tests poll for the up to +// have started without a TOCTOU race. +int up_cancel_poll() { + return tailscale_cancel(up_cancel_sd); +} + +int up_cancel_close() { + return tailscale_close(up_cancel_sd); +} + +int up_cancel_errmsg(char* buf, size_t len) { + return tailscale_errmsg(up_cancel_sd, buf, len);} */ import "C" import ( "context" "flag" + "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" + "unsafe" "tailscale.com/net/netns" "tailscale.com/tstest/integration" @@ -404,3 +445,124 @@ func RunTestGetRemoteAddr(t *testing.T) { t.Fatal(C.GoString(C.err)) } } + +// upCancelErrmsg fetches the last error message for the up_cancel test +// server as a Go string. +func upCancelErrmsg() string { + buf := make([]byte, 512) + if rv := C.up_cancel_errmsg((*C.char)(unsafe.Pointer(&buf[0])), C.size_t(len(buf))); rv != 0 { + return fmt.Sprintf("errmsg failed: %d", rv) + } + return C.GoString((*C.char)(unsafe.Pointer(&buf[0]))) +} + +func upCancelSetup(t *testing.T) { + t.Helper() + dir := t.TempDir() + if rv := C.up_cancel_setup(C.CString(dir)); rv != 0 { + t.Fatalf("up_cancel_setup failed with setup code %d", rv) + } +} + +// RunTestUpTimeout tests that tailscale_up_timeout returns promptly with +// a deadline error, and that canceling after completion is a no-op. +func RunTestUpTimeout(t *testing.T) { + upCancelSetup(t) + + start := time.Now() + if rv := C.up_cancel_timeout(500); rv != -1 { + t.Fatalf("tailscale_up_timeout = %d, want -1", rv) + } + if d := time.Since(start); d > 5*time.Second { + t.Errorf("tailscale_up_timeout took %v; want it to respect the deadline", d) + } + if msg := upCancelErrmsg(); !strings.Contains(msg, "context deadline exceeded") { + t.Errorf("errmsg = %q; want context deadline exceeded", msg) + } + + // After the operation completed, canceling is a no-op (ENOENT). + if rv := C.up_cancel_poll(); rv != C.ENOENT { + t.Errorf("tailscale_cancel after completed up = %d, want ENOENT", rv) + } + + if rv := C.up_cancel_close(); rv != 0 { + t.Errorf("tailscale_close = %d, want 0", rv) + } +} + +// RunTestUpCancel tests that tailscale_cancel interrupts a pending +// tailscale_up, making it return promptly with a canceled error. +func RunTestUpCancel(t *testing.T) { + upCancelSetup(t) + + done := make(chan int32, 1) + go func() { + done <- int32(C.up_cancel_start()) + }() + + // Poll until the up has registered itself as a cancelable operation; + // ENOENT means it has not started yet. + deadline := time.Now().Add(10 * time.Second) + for { + rv := C.up_cancel_poll() + if rv == 0 { + break + } + if rv != C.ENOENT { + t.Fatalf("tailscale_cancel = %d, want 0 or ENOENT", rv) + } + select { + case upRv := <-done: + t.Fatalf("up returned %d before it could be canceled", upRv) + default: + } + if time.Now().After(deadline) { + t.Fatal("up never registered a pending operation") + } + time.Sleep(10 * time.Millisecond) + } + + select { + case rv := <-done: + if rv != -1 { + t.Errorf("canceled tailscale_up = %d, want -1", rv) + } + case <-time.After(10 * time.Second): + t.Fatal("tailscale_up did not return after tailscale_cancel") + } + if msg := upCancelErrmsg(); !strings.Contains(msg, "context canceled") { + t.Errorf("errmsg = %q; want context canceled", msg) + } + + if rv := C.up_cancel_close(); rv != 0 { + t.Errorf("tailscale_close = %d, want 0", rv) + } +} + +// RunTestCloseCancelsUp tests that tailscale_close cancels a pending +// tailscale_up instead of leaving it blocked forever. +func RunTestCloseCancelsUp(t *testing.T) { + upCancelSetup(t) + + done := make(chan int32, 1) + go func() { + done <- int32(C.up_cancel_start()) + }() + + // Give the up a moment to start, then close the server out from + // under it. Even if close lands before the up registers, the closed + // backend makes the up return anyway. + time.Sleep(500 * time.Millisecond) + if rv := C.up_cancel_close(); rv != 0 { + t.Errorf("tailscale_close during pending up = %d, want 0", rv) + } + + select { + case rv := <-done: + if rv != -1 { + t.Errorf("tailscale_up after close = %d, want -1", rv) + } + case <-time.After(10 * time.Second): + t.Fatal("tailscale_up did not return after tailscale_close") + } +}