tailscale / tailscale/tailscale

libtailscale: tailscale_close returns success while tailscale_up remains blocked

Open
#21,167 0 comments 0 reactions 0 assignees View on GitHub
bug
Dominant language
Go
Stars
36.5k
Forks
3.2k
Avg merge
2d 3h
Merged PRs (30d)
123

Description

### What is the issue?

In libtailscale, `tailscale_close` returns success while an in-flight `tailscale_up` remains pending against a local control endpoint that does not respond. This conflicts with the documented cancellation contract in [tailscale.h](https://github.com/tailscale/libtailscale/blob/59d4bb82744915815178e0f0776d60026a397ee7/tailscale.h#L43-L48): callers should use `tailscale_close` to cancel an in-progress `tailscale_up`.

Expected: closing a node during pending startup causes that startup call to finish with an error, allowing the caller to complete cleanup.

Observed: close returns successfully, but the startup task is still pending when the harness's 20-second **whole-process** deadline expires. This is a bounded local reproduction, not a claim that every login hangs indefinitely.

### Baseline and candidate comparison

| libtailscale source | Invocation while up is pending | Observed result |
| --- | --- | --- |
| `5e89501def80a6579ca5d0f9a02f336be62b8f2e` | Swift actor `loopback()` | Cannot enter actor before process deadline |
| Same baseline | Direct C `tailscale_close(handle)`, bypassing the occupied Swift actor | Returns 0; `up.result` remains pending until process deadline |
| `59d4bb82744915815178e0f0776d60026a397ee7` | Swift `loopback()`, `statusJSON()`, then actor `close()` | All three return; `up.result` remains pending until process deadline |

Each executable links the complete Swift sources, bridge header, and native Go C archive from its own declared revision. Both were built with the **same refreshed module graph: tailscale.com v1.102.2 and Go 1.26.5**. These measurements are not from the revisions' default v1.94.1 dependency graph, which was not tested here. No cancellation shim or native lifecycle patch was applied.

This is a separate remaining native issue from #20997. [The Swift actor fix](https://github.com/tailscale/libtailscale/commit/61e8513bcdc57bb29f7120a3b5e543ba505cbd64) does improve actor availability in this fixture; the old revision's direct-C comparison shows the native completion problem predates that change.

### Steps to reproduce

1. Use a fresh state directory, no auth key, and a local HTTP control endpoint that records receipt of its first request but withholds the response.
2. Create a real `TailscaleNode` and start `up()` in a task.
3. After the endpoint has received a request, call `close()` and then await the startup task's result. On the old revision, invoke `tailscale_close(handle)` directly to isolate native behavior from the separate actor blockage.
4. Observe that close returns, but the startup task has not completed when the harness terminates the process at 20 seconds.

Candidate stage output:

```text
STAGE: control request received; calling loopback
STAGE: loopback returned
STAGE: statusJSON returned
STAGE: actor close returned; waiting up.result
subprocess.TimeoutExpired: ... timed out after 20 seconds
```

Baseline direct-C output:

```text
STAGE: direct C close returned; waiting up.result
subprocess.TimeoutExpired: ... timed out after 20 seconds
```

The local HTTP server releases its handlers and closes in cleanup; the timed-out subprocess is killed and waited for. No real tailnet, account, or auth key is used. `TS_NO_LOGS_NO_SUPPORT=true` disables log upload.

### Relevant source

Both revisions retain the same implementation in [tailscale.go](https://github.com/tailscale/libtailscale/blob/59d4bb82744915815178e0f0776d60026a397ee7/tailscale.go#L120-L158):

```go
_, err := s.s.Up(context.Background()) // cancellation is via TsnetClose
```

`TsnetClose` still contains `// TODO: cancel Up`. This is a source pointer consistent with the observed behavior, not a proposed patch or a claim that changing this line alone is sufficient. A fix needs to preserve handle lifetime and concurrent shutdown behavior.

### Environment and scope

- macOS 26.6.2 (25G83), arm64; Xcode 26.6 (17F113), Swift 6 language mode, macOS 15 deployment target.
- Go 1.26.5; tailscale.com v1.102.2 in both native builds.
- Shared go.mod SHA-256: `def15e6f1f8af91245954cdafa1e439e25c8ee02a845e5ade5521d20a76a1219`.
- Shared go.sum SHA-256: `6340ec4a29289155264326fd357d3a4e580424a9812e0727877dad632a1f6028`.
- No iOS runtime or real enrollment reproduction is claimed. No daemon bugreport ID: this is an isolated in-process fixture without account credentials.

Filing an issue to establish the expected native lifecycle behavior before proposing a fix.

Reproducer files and build commands

Requires Go 1.26.5 on PATH, Xcode command-line tools, and Python 3. Save the three files below together in a `repro` directory inside a clone of `tailscale/libtailscale`. The filenames and scripts are the ones used for the measurements above.

```sh
mkdir -p repro/baseline repro/source
git archive 5e89501def80a6579ca5d0f9a02f336be62b8f2e | tar -x -C repro/baseline
git archive 59d4bb82744915815178e0f0776d60026a397ee7 | tar -x -C repro/source
(cd repro/source && go get tailscale.com@v1.102.2 && go mod tidy && make c-archive)
cp repro/source/go.mod repro/source/go.sum repro/baseline/
(cd repro/baseline && make c-archive)
cd repro
python3 compile-native.py baseline
python3 compile-native.py source
python3 native-regression.py ./native-baseline native-close
python3 native-regression.py ./native-source
```

Run the last two commands separately: each currently exits nonzero because the 20-second process timeout expires.

**native-regression-local.swift**

```swift
import Foundation

@main struct NativeRegression {
static func main() async throws {
let state = CommandLine.arguments[2]
let node = try TailscaleNode(config: Configuration(hostName: "lapis-native-regression", path: state, authKey: nil, controlURL: CommandLine.arguments[1]), logger: nil)
let handle = await node.tailscale!
let up = Task { try await node.up() }
// The local endpoint signals receipt before this file appears.
while !FileManager.default.fileExists(atPath: state + "/request-received") {
try await Task.sleep(for: .milliseconds(20))
}
try await Task.sleep(for: .milliseconds(100))
if CommandLine.arguments.last == "native-close" {
precondition(tailscale_close(handle) == 0)
FileHandle.standardError.write(Data("STAGE: direct C close returned; waiting up.result\n".utf8))
} else {
FileHandle.standardError.write(Data("STAGE: control request received; calling loopback\n".utf8))
let config = try await node.loopback()
precondition(config.port != nil && !config.localAPIKey.isEmpty)
FileHandle.standardError.write(Data("STAGE: loopback returned\n".utf8))
#if CANDIDATE
let status = try await node.statusJSON()
let json = try JSONSerialization.jsonObject(with: status) as! [String: Any]
precondition(json["BackendState"] is String && json["BackendState"] as? String != "Running")
FileHandle.standardError.write(Data("STAGE: statusJSON returned\n".utf8))
#endif
try await node.close()
FileHandle.standardError.write(Data("STAGE: actor close returned; waiting up.result\n".utf8))
}
switch await up.result {
case .success: fatalError("up unexpectedly succeeded against blocked control")
case .failure: break
}
print("PASS: blocked up permits loopback, statusJSON and close; close releases up")
}
}
```

**native-regression.py**

```python
import http.server
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import threading

with tempfile.TemporaryDirectory(prefix='lapis-native-regression-') as state:
release = threading.Event()
class BlockedControl(http.server.BaseHTTPRequestHandler):
def do_GET(self):
Path(state, 'request-received').touch()
release.wait(30)
def log_message(self, *args):
pass
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), BlockedControl)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
subprocess.run([sys.argv[1], f'http://127.0.0.1:{server.server_port}', state, *sys.argv[2:]], check=True, timeout=20,
env={**os.environ, 'TS_NO_LOGS_NO_SUPPORT': 'true'})
finally:
release.set()
server.shutdown()
server.server_close()
```

**compile-native.py**

```python
from pathlib import Path
import subprocess
import sys
b=Path(__file__).resolve().parent
s=b/sys.argv[1]
flags=['-D','CANDIDATE'] if sys.argv[1]=='source' else []
subprocess.run(['xcrun','swiftc','-swift-version','6','-parse-as-library','-target','arm64-apple-macos15.0',*flags,'-import-objc-header',str(s/'swift/TailscaleKit/TailscaleKit.h'),*[str(p) for p in (s/'swift/TailscaleKit').rglob('*.swift')],str(b/'native-regression-local.swift'),str(s/'libtailscale.a'),'-framework','CoreFoundation','-framework','Security','-framework','Foundation','-framework','Network','-framework','SystemConfiguration','-lresolv','-o',str(b/('native-'+sys.argv[1]))],check=True)
```

Contributor guide

Open the contributing guide

Research direction

Read the cancellation contract in tailscale.h and the tailscale.go implementation around TsnetClose and the TODO to cancel Up. Run the supplied compile-native.py and native-regression.py reproduction with the blocked local control endpoint; done means tailscale_close causes the pending tailscale_up to finish with an error while preserving handle lifetime and concurrent shutdown behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
go, python, swift
Domain
api, backend
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.