Skip to content

swift: gate the listener API so clients are not forced to iOS 18 / macOS 15 - #60

Merged
barnstar merged 1 commit into
tailscale:mainfrom
indiagrams:gate-listener-availability
Aug 31, 2026
Merged

swift: gate the listener API so clients are not forced to iOS 18 / macOS 15#60
barnstar merged 1 commit into
tailscale:mainfrom
indiagrams:gate-listener-availability

Conversation

@prakashrj

@prakashrj prakashrj commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Listener.state() and IncomingConnection.state() return any AsyncSequence<ListenerState, Never>. That parameterised existential needs the Failure associated type, which is iOS 18 / macOS 15 only — and because the requirement is unannotated, it propagates to the entire framework.

Every consumer inherits an iOS 18 floor, including the many that only dial out and never accept an inbound connection. Nothing else in TailscaleKit needs it.

Annotating the two listener actors confines the requirement to them.

Measured

Xcode 26.1.1, by building at each floor rather than inferring:

build result
as-is iOS 18.0 / macOS 15.0 minimum
with this change iOS 17.0 / macOS 14.0 — next constraint is ProxyConfiguration in URLSession+Tailscale.swift
at iOS 13.0 only ProxyConfiguration fails
at iOS 12.0 Swift concurrency itself fails

So this moves the floor down a full major version on both platforms, and what remains is a different, far more central API.

Not a removal

The API is unchanged and still shipped: it stays in the binary and in the .swiftinterface. Callers on iOS 18 / macOS 15 see exactly what they see today. Callers below it now get a clear availability diagnostic on the listener types, instead of an unexplained floor on the whole framework.

The Go layer is not the constraint — swift/script/clangwrap-ios.sh already builds it -mios-version-min=12.0.

Aside, not touched here

TailscaleKit.xcodeproj's own settings are higher than either number: IPHONEOS_DEPLOYMENT_TARGET = 18.1, and MACOSX_DEPLOYMENT_TARGET = 15.0 in six places against 15.6 in two. Those look incidental rather than chosen. This PR leaves them alone, but lowering them would let the project ship the floor it can actually support.

Context

Found while shipping an iOS/macOS app that embeds tsnet in-process via TailscaleKit. It is a pure client — zero references to Listener, IncomingConnection or accept — and was nonetheless paying an iOS 18.1 / macOS 15.6 floor, which meant declaring support for OS versions the binary would refuse to load on.

…cOS 15

`Listener.state()` and `IncomingConnection.state()` return
`any AsyncSequence<ListenerState, Never>`. That parameterised existential needs
the `Failure` associated type, which is iOS 18 / macOS 15 only — and because the
requirement is unannotated, it propagates to the entire framework. Every
consumer inherits an iOS 18 floor, including the many that only dial out and
never accept an inbound connection.

Nothing else in TailscaleKit needs it. Annotating the two listener actors
confines the requirement to them.

Measured on Xcode 26.1.1 by building at each floor:

  as-is                          iOS 18.0 / macOS 15.0 minimum
  with this change               iOS 17.0 / macOS 14.0   <- ProxyConfiguration
                                                            in URLSession+Tailscale
  at iOS 13.0                    only ProxyConfiguration fails
  at iOS 12.0                    Swift concurrency itself fails

So this moves the floor down a full major version on both platforms, and the
next constraint is a different, more central API.

The API is not removed and not changed. It stays in the binary and in the
.swiftinterface; callers on iOS 18 / macOS 15 see exactly what they see today.
Callers below it now get a clear availability diagnostic on the listener types
instead of an unexplained floor on the whole framework.

The Go layer is not the constraint: swift/script/clangwrap-ios.sh already builds
it -mios-version-min=12.0.

Note that TailscaleKit.xcodeproj's own settings are higher than either number —
IPHONEOS_DEPLOYMENT_TARGET 18.1, and MACOSX_DEPLOYMENT_TARGET 15.0 in six places
against 15.6 in two. Those look incidental rather than chosen; this change does
not touch them, but lowering them would let the project ship the floor it can
actually support.
@prakashrj

Copy link
Copy Markdown
Contributor Author

Ran this at runtime rather than only compiling it, since a framework can compile clean, stamp a lower floor, and still be refused at load — so I wanted to see dyld actually accept it.

Built TailscaleKit.xcframework from libtailscale with this PR's diff applied, targeting iOS 17, embedded it in a shipping app, and launched on an iOS 17.5 simulator (Xcode 26.1.1). 17.5 is the interesting version: it sits below the old 18.x floor and at/above the new one, so it can tell the two apart. Anything 18.1+ clears both and proves nothing — an 18.6 launch looked like confirmation to me earlier and was worthless.

build simulator slice result on iOS 17.5
with this PR minos 17.0 loadsTailscaleKit maps into the process, app runs
without it (prior release) minos 18.1 refusedbuilt for iOS-sim 18.1 which is newer than running OS, process dies ~1s in

So the lowered floor is real at load time, not just a declared number.

Two caveats, so the table isn't read as more than it is:

  • The second row is a discrimination check, not an isolation of the gate. That build predates this change and was built at the project's default IPHONEOS_DEPLOYMENT_TARGET = 18.1, so it differs in two ways. Its only job here is to show the 17.5 runtime can detect a too-high floor — without it, the first row would be unfalsifiable. The compile table in the PR description is what establishes the gate is the cause.
  • Only the simulator slice was exercised at runtime. ios-arm64 is stamped minos 17.0 too but I have no iOS 17 device to load it on. The macOS slice is stamped 14.0 and is likewise unverified at runtime — there is no macOS simulator, and any host new enough to run current Xcode clears both the old and new macOS floors.

One aside that may be useful if you act on the deployment-target note in the description: on macOS the Go archive floor has to move with the Swift one. Lowering only MACOSX_DEPLOYMENT_TARGET succeeds and produces a framework stamped with the lower number while containing Go objects built for the higher one. otool reports the number the binary claims, so it looks correct; the only signal is an ld: warning: object file ... was built for newer 'macOS' version. Worth failing the build on that warning if you lower those settings.

Happy to re-run any of this, or to test a specific configuration, if it would help the review.

@barnstar
barnstar merged commit 8564835 into tailscale:main Aug 31, 2026
1 check failed
barnstar pushed a commit that referenced this pull request Aug 31, 2026
`MACOS_TARGET := 15.0` is a simply-expanded assignment, so an environment
variable does not override it. `MACOS_TARGET=14.0 make c-archive` builds 15.0
and reports success — you set the floor, make agrees, and you get the old one.
Only `make MACOS_TARGET=14.0 c-archive` works.

Measured with `make -n` against this Makefile, GOOS=darwin:

                    before      after
  env override      15.0        14.0
  command-line      14.0        14.0
  default           15.0        15.0

`?=` fixes the environment case and changes nothing else: a command-line
override still wins, and the default is untouched for anyone not setting it.

Found while lowering the macOS floor of a TailscaleKit.xcframework built from
this repo. The failure is quiet in a way that matters here — nothing warns, the
build succeeds, and the resulting binary is stamped with a floor its Go objects
do not actually support. On macOS the only signal is an `ld: warning: object
file ... was built for newer 'macOS' version`, which is easy to lose in build
output.

This is the same knob #60's "Aside" points at: if the project lowers its own
deployment targets, whoever does it is likely to reach for the environment
variable first.

Not touched here: the comment above the line still says the wrapper requires
macOS 15.0 features. That is a separate question and depends on #60, which
measures macOS 14.0 as buildable once the listener API is gated.
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