Skip to content

fix(build): pin the macOS deployment floor for the whole native payload, and enforce it at pack time - #536

Open
EtienneLescot wants to merge 3 commits into
claude/openscreen-issue-515-6c0ac4from
claude/macos-deployment-floor-native-payload
Open

fix(build): pin the macOS deployment floor for the whole native payload, and enforce it at pack time#536
EtienneLescot wants to merge 3 commits into
claude/openscreen-issue-515-6c0ac4from
claude/macos-deployment-floor-native-payload

Conversation

@EtienneLescot

@EtienneLescot EtienneLescot commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #527base is that PR's branch, not main, because the floor it declares (mac.minimumSystemVersion: "13.0") is the number this PR pins everything else to. Review or merge #527 first; GitHub will retarget this to main automatically once it lands.

What was wrong

scripts/fetch-ffmpeg-macos.mjs and scripts/build-whisper-stt.sh set no macOS deployment target, so clang and CMake defaulted to the build machine's SDK. Measured on the installed, notarized v1.10.0 arm64 payload:

minos binaries
11.0 compositor_view.node (rustc default)
13.0 both Swift helpers
26.0 all 5 ffmpeg dylibs, all ggml/whisper/parakeet dylibs, whisper-stt-server

Not a stable mistake — a floor that moves whenever GitHub rolls macos-latest (CI measured ~15.x, this machine 26.0).

The mechanism is not the number

dyld does not refuse a binary, or a dylib, whose minos exceeds the running OS. Both were verified here: a binary stamped minos 99.0 execs fine, and a dylib stamped 27.0 loads on 26.5 with only a link-time warning. Anyone "fixing" this by reasoning about the version number will fix the wrong thing.

The deployment target decides which symbols the linker resolves against the OS instead of emitting locally. Rebuilding at a pinned floor and diffing imports against the shipped binaries:

  • ffmpeg — identical import sets, 0 symbols either way. The claim that the compositor addon cannot load on old macOS is not supported; those dylibs would very likely have loaded. Pinned anyway, for determinism.
  • whisper — 9 STRONG (non-weak) undefined references to libc++ symbols the pinned build does not reference at all: __ZTVNSt3__117bad_function_callE and friends, basic_filebuf::open, vtables/VTT for basic_ifstream/basic_ofstream.

Those are version-gated by libc++ itself. The SDK's availability header declares the bad_function_call key function as availability(macos, strict, introduced = 15.4), and the cutovers reproduce exactly on a three-line test program:

deployment target bad_function_call vtable filebuf::open
12.0 local copy local copy
13.0 – 15.3 local copy imported
15.4+ imported imported

So the shipped STT helper strongly references symbols the toolchain says do not exist before macOS 15.4 — well above the Monterey report that started this, and covering Ventura and Sonoma, one of which the README recommends.

After pinning to 13.0

whisper-stt-server   15.4-gated (bad_function_call)   9 -> 0
                     13.0-gated (fstream/filebuf)          7   (correct at this floor)

The second row is why this is a pin and not just a lower number: at a 13.0 target the toolchain still imports the fstream symbols, which exist on 13.0, and stops importing the 15.4 ones. Both halves are the deployment target doing its job.

Every shipped Mach-O now reports 13.0 (compositor_view.node stays at 11.0, below the floor), whisper-stt-server loads and runs, and the compositor addon links the rebuilt ffmpeg.

The pack-time guard

before-pack.cjs already refuses an incomplete macOS payload. "Complete" is not the same property as "runnable on the macOS we claim", and #515 was the second kind. It now also walks electron/native/bin/darwin-* and fails the pack on any Mach-O above MAC_MIN_OS_FLOOR.

Verified in both directions against real binaries, not only fixtures — on the parent branch before its Package.swift fix:

Refusing to package binaries that demand a newer macOS than the 13.0 floor
  - openscreen-macos-cursor-helper is built for macOS 14.0.0 (floor 13.0)

and exit 0 on the corrected payload. Pointed at the installed v1.10.0 payload it names all 25 dylibs at 26.0.

Parses LC_BUILD_VERSION out of the file rather than shelling out to vtool. Same reason neededSymbolVersions() avoids readelf and importedDlls() avoids dumpbin, plus one specific to this hook: it runs for the Windows and Linux packs too, and vtool exists on neither — a subprocess would have to be skipped on exactly the hosts where skipping is silent. Cross-checked against vtool -show-build on all 44 Mach-O files across two real payloads: 0 mismatches. Universal binaries take the highest slice, since an x86_64 half built on a newer machine strands Intel users just as thoroughly.

The failure message names the file, its measured floor, the constant, and #515, and states the mechanism above — without it, the obvious "fix" is to raise the constant until it passes.

Notes for review

  • Tests synthesise Mach-O headers instead of invoking clang, so they run on the Linux and Windows CI legs too, and assert MAC_MIN_OS_FLOOR equals the declared minimumSystemVersion — a pack-time guard looser than the app's own declaration would wave through exactly the binaries LaunchServices then refuses to run.
  • Fixtures derive from the floor rather than hardcoding versions: an earlier revision's literals silently turned from offenders into compliant binaries when the floor moved, so the guard's own tests stopped testing it.
  • Not verified on a pre-15.4 Mac. No such machine here, and no SDK older than 15.4 to check symbol absence against, so the libc++ claim rests on Apple's own introduced = 15.4 annotation plus the references being strong rather than weak. Strong inference, not an observation — worth one run on a Ventura or Sonoma box before this is described as user-facing in a changelog.

Neither build set one, so clang and CMake defaulted to the BUILD MACHINE's SDK
and the shipped binaries inherited whatever macOS compiled them. Measured on
the installed, notarized v1.10.0 arm64 payload: every ffmpeg dylib, every
ggml/whisper/parakeet dylib and whisper-stt-server stamped minos 26.0, inside
an app whose Info.plist declares LSMinimumSystemVersion 12.0.

The minos number is NOT itself the bug. dyld does not refuse a binary — or a
dylib — whose minos exceeds the running OS; both were verified to load here
(a dylib stamped 27.0 loads fine on 26.5, with only a link-time warning). What
the deployment target actually controls is which symbols the toolchain is
willing to import from the OS, and that is where the damage is.

Measured by rebuilding at 12.0 and diffing imports against the shipped
binaries:

  ffmpeg    identical import sets, 0 symbols either way. The claim that the
            compositor addon cannot load on macOS 12 is NOT supported; these
            would very likely have loaded.

  whisper   9 STRONG (non-weak) undefined refs to libc++ symbols that the
            12.0 build does not reference at all:
              __ZTVNSt3__117bad_function_callE and friends
              __ZNSt3__113basic_filebufIcNS_11char_traitsIcEEE4openEPKcj
              vtable/VTT for basic_ifstream / basic_ofstream

Those are version-gated by libc++ itself. The SDK's availability header
declares the bad_function_call key function as
`availability(macos, strict, introduced = 15.4)`, and the cutovers reproduce
exactly on a three-line test program: the fstream symbols start being imported
at a 13.0 target, the bad_function_call ones at 15.4. Below those the
toolchain emits local definitions instead — which is precisely what it does
now.

So the shipped STT helper carries strong references to symbols the toolchain
says do not exist before macOS 15.4, well above the Monterey case that
prompted this. Not observed on an old macOS — no such machine here — but that
annotation is Apple's own statement about where the symbol ships.

After the pin, every rebuilt Mach-O reports minos 12.0, whisper-stt-server
carries 0 of those 9 refs, and it still loads and runs.

This is the macOS twin of the ubuntu-22.04 pin in build-whisper-stt.yml: a
shipped binary's floor decided by the runner rather than by the project.

Refs #515
…floor

before-pack.cjs already refuses an incomplete macOS payload. "Complete" is not
the same property as "runnable on the macOS we claim", and #515 was the second
kind: the payload was whole, and one helper in it was built for macOS 13 while
the app advertised 12. Nothing in the pipeline looked.

Walks electron/native/bin/darwin-* and fails the pack if any Mach-O declares a
minimum macOS above MAC_MIN_OS_FLOOR. Verified both directions against real
binaries rather than only fixtures — on this branch, which still carries the
original .macOS(.v13):

  $ node scripts/build-macos-screencapturekit-helper.mjs && node scripts/before-pack.cjs
  Refusing to package binaries that demand a newer macOS than the 12.0 floor
    - openscreen-macos-cursor-helper is built for macOS 13.0.0 (floor 12.0)
    - openscreen-screencapturekit-helper is built for macOS 13.0.0 (floor 12.0)

and exit 0 once Package.swift is at .v12. Pointed at the installed, notarized
v1.10.0 payload it names all 25 ffmpeg/whisper dylibs at 26.0.

Parses LC_BUILD_VERSION (and LC_VERSION_MIN_MACOSX) out of the file rather than
shelling out to `vtool`. Same reason neededSymbolVersions() does not use readelf
and importedDlls() does not use dumpbin, plus one specific to this hook: it runs
for the Windows and Linux packs too, and vtool exists on neither — so a
subprocess would have to be skipped on exactly the hosts where skipping is
silent. Parsing makes the guard host-independent instead of conditionally
absent. Cross-checked against `vtool -show-build` on all 44 Mach-O files across
two real payloads: 0 mismatches. Universal binaries take the highest slice, since
an x86_64 half built on a newer machine strands Intel users just as thoroughly.

The message names the file, its measured floor, the constant, and #515, and
states the mechanism — dyld does NOT gate on the minos number; the deployment
target decides which symbols get resolved against the OS, and a too-high floor
leaves strong references to symbols the target macOS never had. Without that,
the obvious "fix" is to raise the constant until it passes.

Carries the same parser-sanity assertion as its Linux sibling: reading no
deployment target from any Mach-O means the parser broke, not that the payload
is unusually clean.

Tests synthesise Mach-O headers instead of invoking clang, so they run on the
Linux and Windows CI legs as well, and tie MAC_MIN_OS_FLOOR to README.md. That
assertion is one-directional on purpose — building for older than advertised is
harmless, building for newer is the bug — so it holds both before and after the
README correction in the #515 branch.

Refs #515
…port

Follows the decision in the parent branch to declare macOS 13 rather than
accommodate 12. The pins and the pack-time guard move with it: 13.0 in
fetch-ffmpeg-macos.mjs, build-whisper-stt.sh and MAC_MIN_OS_FLOOR, all now
described as tracking `mac.minimumSystemVersion` in electron-builder.json5,
which is the number the .app actually tells LaunchServices.

This does NOT weaken the fix — it is the whole point of it. The defect was never
Monterey specifically: the shipped v1.10.0 binaries carried 9 strong undefined
references to libc++ symbols that the toolchain dates to macOS 15.4
(`availability(macos, strict, introduced = 15.4)` on the bad_function_call key
function), so STT was expected to fail to load on Ventura and Sonoma too — the
versions this project still supports, one of which the README recommends.

Rebuilt at 13.0 and re-measured rather than assumed:

  whisper-stt-server  15.4-gated (bad_function_call)  9 -> 0
                      13.0-gated (fstream/filebuf)        7  (correct at this floor)

The second row is the point of pinning rather than merely lowering: at a 13.0
target the toolchain still imports the fstream symbols, which exist on 13.0, and
stops importing the 15.4 ones. Both halves are the deployment target doing its
job.

Every shipped Mach-O now reports 13.0 (compositor_view.node stays at rustc's
11.0, below the floor), whisper-stt-server still loads and runs, the compositor
addon links the rebuilt ffmpeg, and `node scripts/before-pack.cjs` exits 0 on the
complete payload.

before-pack.test.mjs now asserts MAC_MIN_OS_FLOOR EQUALS the declared
minimumSystemVersion, not merely that it is no higher: a pack-time guard looser
than the app's own declaration would wave through exactly the binaries
LaunchServices then refuses to run. Its fixtures are derived from the floor
instead of hardcoding versions — the previous literals silently turned from
offenders into compliant binaries when the floor moved, so the guard's own tests
stopped testing it.

Refs #515
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.

1 participant