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

@prakashrjprakashrj 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:

buildresult
as-isiOS 18.0 / macOS 15.0 minimum
with this changeiOS 17.0 / macOS 14.0 — next constraint is ProxyConfiguration in URLSession+Tailscale.swift
at iOS 13.0only ProxyConfiguration fails
at iOS 12.0Swift 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
ContributorAuthor

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.

buildsimulator sliceresult on iOS 17.5
with this PRminos 17.0loadsTailscaleKit maps into the process, app runs
without it (prior release)minos 18.1refusedbuilt 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:mainAug 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 freeto 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

@prakashrj@barnstar
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
swift: gate the listener API so clients are not forced to iOS 18 / macOS 15 by prakashrj · Pull Request #60 · tailscale/libtailscale · GitHub
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

@prakashrjprakashrj 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:

buildresult
as-isiOS 18.0 / macOS 15.0 minimum
with this changeiOS 17.0 / macOS 14.0 — next constraint is ProxyConfiguration in URLSession+Tailscale.swift
at iOS 13.0only ProxyConfiguration fails
at iOS 12.0Swift 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
ContributorAuthor

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.

buildsimulator sliceresult on iOS 17.5
with this PRminos 17.0loadsTailscaleKit maps into the process, app runs
without it (prior release)minos 18.1refusedbuilt 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:mainAug 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 freeto 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

@prakashrj@barnstar
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' swift: gate the listener API so clients are not forced to iOS 18 / macOS 15 by prakashrj · Pull Request #60 · tailscale/libtailscale · GitHub
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

@prakashrjprakashrj 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:

buildresult
as-isiOS 18.0 / macOS 15.0 minimum
with this changeiOS 17.0 / macOS 14.0 — next constraint is ProxyConfiguration in URLSession+Tailscale.swift
at iOS 13.0only ProxyConfiguration fails
at iOS 12.0Swift 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
ContributorAuthor

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.

buildsimulator sliceresult on iOS 17.5
with this PRminos 17.0loadsTailscaleKit maps into the process, app runs
without it (prior release)minos 18.1refusedbuilt 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:mainAug 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 freeto 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

@prakashrj@barnstar
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' swift: gate the listener API so clients are not forced to iOS 18 / macOS 15 by prakashrj · Pull Request #60 · tailscale/libtailscale · GitHub
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

@prakashrjprakashrj 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:

buildresult
as-isiOS 18.0 / macOS 15.0 minimum
with this changeiOS 17.0 / macOS 14.0 — next constraint is ProxyConfiguration in URLSession+Tailscale.swift
at iOS 13.0only ProxyConfiguration fails
at iOS 12.0Swift 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
ContributorAuthor

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.

buildsimulator sliceresult on iOS 17.5
with this PRminos 17.0loadsTailscaleKit maps into the process, app runs
without it (prior release)minos 18.1refusedbuilt 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:mainAug 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 freeto 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

@prakashrj@barnstar
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' swift: gate the listener API so clients are not forced to iOS 18 / macOS 15 by prakashrj · Pull Request #60 · tailscale/libtailscale · GitHub
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

@prakashrjprakashrj 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:

buildresult
as-isiOS 18.0 / macOS 15.0 minimum
with this changeiOS 17.0 / macOS 14.0 — next constraint is ProxyConfiguration in URLSession+Tailscale.swift
at iOS 13.0only ProxyConfiguration fails
at iOS 12.0Swift 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
ContributorAuthor

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.

buildsimulator sliceresult on iOS 17.5
with this PRminos 17.0loadsTailscaleKit maps into the process, app runs
without it (prior release)minos 18.1refusedbuilt 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:mainAug 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 freeto 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

@prakashrj@barnstar
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' swift: gate the listener API so clients are not forced to iOS 18 / macOS 15 by prakashrj · Pull Request #60 · tailscale/libtailscale · GitHub
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

@prakashrjprakashrj 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:

buildresult
as-isiOS 18.0 / macOS 15.0 minimum
with this changeiOS 17.0 / macOS 14.0 — next constraint is ProxyConfiguration in URLSession+Tailscale.swift
at iOS 13.0only ProxyConfiguration fails
at iOS 12.0Swift 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
ContributorAuthor

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.

buildsimulator sliceresult on iOS 17.5
with this PRminos 17.0loadsTailscaleKit maps into the process, app runs
without it (prior release)minos 18.1refusedbuilt 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:mainAug 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 freeto 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

@prakashrj@barnstar
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' swift: gate the listener API so clients are not forced to iOS 18 / macOS 15 by prakashrj · Pull Request #60 · tailscale/libtailscale · GitHub
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

@prakashrjprakashrj 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:

buildresult
as-isiOS 18.0 / macOS 15.0 minimum
with this changeiOS 17.0 / macOS 14.0 — next constraint is ProxyConfiguration in URLSession+Tailscale.swift
at iOS 13.0only ProxyConfiguration fails
at iOS 12.0Swift 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
ContributorAuthor

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.

buildsimulator sliceresult on iOS 17.5
with this PRminos 17.0loadsTailscaleKit maps into the process, app runs
without it (prior release)minos 18.1refusedbuilt 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:mainAug 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 freeto 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

@prakashrj@barnstar
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); swift: gate the listener API so clients are not forced to iOS 18 / macOS 15 by prakashrj · Pull Request #60 · tailscale/libtailscale · GitHub
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

@prakashrjprakashrj 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:

buildresult
as-isiOS 18.0 / macOS 15.0 minimum
with this changeiOS 17.0 / macOS 14.0 — next constraint is ProxyConfiguration in URLSession+Tailscale.swift
at iOS 13.0only ProxyConfiguration fails
at iOS 12.0Swift 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
ContributorAuthor

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.

buildsimulator sliceresult on iOS 17.5
with this PRminos 17.0loadsTailscaleKit maps into the process, app runs
without it (prior release)minos 18.1refusedbuilt 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:mainAug 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 freeto 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

@prakashrj@barnstar