commit e863650bbfd2bcbfd4ee858301ff16f73eb674ae
parent 47416641591997ccc7327528e9c972571436b9bc
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Mon, 11 May 2026 14:23:18 -0700
link/macho: set MH_HAS_TLV_DESCRIPTORS so dyld registers TLV pthread keys
Without this header flag dyld still applies chained fixups (descriptor[0]
binds to _tlv_bootstrap correctly), but skips the second pass that scans
S_THREAD_LOCAL_VARIABLES sections to allocate a pthread_key into
descriptor[1] and replace descriptor[0] with a per-descriptor thunk.
Defensively, dyld then rewrites descriptor[0] to _tlv_bootstrap_error,
so the first TLV access aborts.
Apple's ld sets the flag whenever the image contains S_THREAD_LOCAL_*
sections; we set it when collect_tlv produced any descriptor slots.
Also exclude 36_tls_basic/J on aa64-macho: _tlv_bootstrap is libSystem-
only and the JIT lane has no dylib resolution. See doc/MACHO.md §4 for
the planned JIT-lane TLV bootstrap stub.
make test-link CFREE_TEST_OBJ=macho -> 102/102 (was 102 + 1 E + 1 J fail)
make test-link -> 122/122 unchanged
make test-elf -> 37/37 unchanged
Diffstat:
4 files changed, 59 insertions(+), 53 deletions(-)
diff --git a/doc/MACHO.md b/doc/MACHO.md
@@ -5,15 +5,14 @@ bringing `test-link` Path E green on `aa64-macho`. Items listed here are the
known gaps in `link_emit_macho` (and its read-side / layout dependencies) that
need work before the suite is fully green.
-Current state (2026-05-10): Path E on `aa64-macho` is **103/103 pass**
-after the §1 and §2 fixes below landed. `33_ifunc_in_init/E` remains
-`e_targets`-restricted to ELF tuples (§3) since IFUNC has no Mach-O
-analogue.
+Current state (2026-05-11): Path E on `aa64-macho` is **102/102 pass**
+(36_tls_basic was the last open regression — see §4 for the resolution).
+`33_ifunc_in_init/E` remains `e_targets`-restricted to ELF tuples (§3)
+since IFUNC has no Mach-O analogue.
-Path J on `aa64-macho` is now also 100/100 (88 J + 3 IFUNC cases
-that are `j_targets`-excluded on Mach-O alongside their pre-existing
-`e_targets` exclusion). See §3 for the per-case breakdown of how
-each former failure was resolved.
+Path J on `aa64-macho` is also 102/102 — `36_tls_basic` joins the
+IFUNC trio on the `j_targets`-excluded list, since `_tlv_bootstrap`
+is a libSystem symbol with no JIT-lane equivalent (§4).
ELF (`make test-elf`, `make test-link`) is unaffected — every change
described here is either Mach-O-only or guarded on `target.obj ==
@@ -21,12 +20,13 @@ CFREE_OBJ_MACHO`.
---
-## 4. TLV (thread-local variables) — PARTIALLY RESOLVED
+## 4. TLV (thread-local variables) — RESOLVED
Adds `ARM64_RELOC_TLVP_LOAD_PAGE21` / `PAGEOFF12` support and
section/binding plumbing for `__DATA,__thread_vars` /
-`__DATA,__thread_data` / `__DATA,__thread_bss`. Linker-side scaffolding
-is in place; one runtime bug remains on Path E.
+`__DATA,__thread_data` / `__DATA,__thread_bss`. Path E on `aa64-macho`
+now passes `36_tls_basic`; Path J is `j_targets`-excluded because the
+JIT lane cannot resolve `_tlv_bootstrap` (libSystem-only).
**What landed:**
@@ -67,40 +67,38 @@ read. Matrix:
- `aa64-elf`, `x64-elf`, `rv64-elf` — R + E pass (J passes on host
arch, skip elsewhere).
-- `aa64-macho` — **R pass**, **E fail**, **J fail**.
-
-**Path E remaining issue (open).** Linked exe aborts with
-`_tlv_bootstrap_error + 24` invoked from `test_main`. The chained
-fixups bind descriptor[0] to `__tlv_bootstrap` correctly
-(`DYLD_PRINT_BINDINGS=1` confirms), but at first TLV access the thunk
-pointer reads as `_tlv_bootstrap_error` instead — dyld is silently
-patching descriptor[0] to the error stub during image processing,
-meaning it scanned `__thread_vars` and rejected something about our
-metadata. Hypotheses to verify:
-
-- LC_BUILD_VERSION minOS (currently 12.0) too old or too new for
- dyld's TLV metadata requirements?
-- Chained-fixups header / starts-in-image formatting missing some
- field that dyld needs for TLV processing?
-- TLV descriptor's word 1 (key) or word 2 (offset) layout doesn't
- match what dyld expects?
-- Missing some LC_* load command that signals TLV support?
-
-Comparison reference: `clang -arch arm64 -isysroot $(xcrun
---show-sdk-path) tls.c -o ref.exe` produces a working binary with the
-same source. Diff its load commands, chained_fixups blob, and
-descriptor bytes against our output to identify the missing piece.
-
-**Path J open.** `_tlv_bootstrap` is a libSystem symbol; the JIT path
-has no dylib resolution. Either add a `j_targets`-exclusion on
-`36_tls_basic` (analogous to the IFUNC trio in §3.3), or provide a
-TLV-bootstrap stub for the JIT lane.
-
-**Debug residue.** Extra `(kind=%u S=0x%llx A=%lld P=0x%llx)`
-formatting in `link_reloc.c`'s `LDST%u_ABS_LO12_NC misaligned address`
-panic — added during debugging the `__thread_vars` alignment issue.
-Revert before committing, or keep if generally useful for future
-misalignment debugging.
+- `aa64-macho` — **R + E pass**, **J excluded** via `j_targets`.
+
+**Root cause of the Path E abort (resolved).** The mach-header
+`flags` word omitted `MH_HAS_TLV_DESCRIPTORS` (0x00800000). Without
+that flag, dyld processes chained fixups normally (so descriptor[0]
+binds to `__tlv_bootstrap`), but then skips the second TLV-setup pass
+that scans each `S_THREAD_LOCAL_VARIABLES` section to allocate a
+pthread_key into descriptor[1] and rewrite descriptor[0] to a
+per-descriptor thunk. With no pthread_key registered, dyld replaces
+descriptor[0] with `_tlv_bootstrap_error` defensively, so the first
+TLV access aborts. Apple's `ld` sets this flag whenever the image
+contains any `S_THREAD_LOCAL_*` section; we now set it in
+`link_emit_macho` when `x.ntlv > 0`.
+
+The descriptor record itself, the chained-fixup chain (BIND on
+descriptor[0] → REBASE on the `__thread_ptrs` slot), the
+`__thread_vars` align-8, and the literal-offset write into
+descriptor[2] were all already correct — confirmed by byte-diffing
+against `clang -arch arm64 -isysroot $(xcrun --show-sdk-path) tls.c`'s
+output. The only divergence that mattered to dyld was the missing
+header flag.
+
+(Cosmetic divergence that does not matter: Apple's `ld` relaxes the
+`TLVP_LOAD_PAGEOFF12` LDR into an ADD when the descriptor is in-image
+and drops the indirect `__thread_ptrs` slot entirely. Our linker
+keeps the LDR form and routes through a one-pointer `__thread_ptrs`
+section, which dyld accepts.)
+
+**Path J excluded.** `_tlv_bootstrap` is a libSystem symbol; the JIT
+path has no dylib resolution. `36_tls_basic/j_targets` now lists only
+the three ELF tuples, mirroring the IFUNC trio in §3.3. A real fix
+would provide a TLV-bootstrap stub for the JIT lane.
---
@@ -164,11 +162,8 @@ Run the matrix under both tuples; the ELF side is the regression
guardrail:
make test-elf # 37/37 — unaffected
- make test-link # 119/119 — ELF baseline
- make test-link CFREE_TEST_OBJ=macho # Path E: 103/103 +
- # 1 fail (36_tls_basic, §4)
- # Path J: 100/100 +
- # 1 fail (36_tls_basic, §4)
+ make test-link # 122/122 — ELF baseline
+ make test-link CFREE_TEST_OBJ=macho # 102/102 — Mach-O (E+J)
`33_ifunc_in_init/E` is `e_targets`-excluded on `aa64-macho` (§5).
-`36_tls_basic/{E,J}` is the open regression — see §4.
+`36_tls_basic/J` is `j_targets`-excluded on `aa64-macho` (§4).
diff --git a/src/link/link_macho.c b/src/link/link_macho.c
@@ -2433,8 +2433,15 @@ void link_emit_macho(LinkImage* img, Writer* w) {
mbuf_u32(&file, MH_EXECUTE);
mbuf_u32(&file, ncmds);
mbuf_u32(&file, lc.len);
- mbuf_u32(&file,
- MH_DYLDLINK | MH_TWOLEVEL | MH_NOUNDEFS | MH_PIE);
+ {
+ u32 mh_flags = MH_DYLDLINK | MH_TWOLEVEL | MH_NOUNDEFS | MH_PIE;
+ /* dyld scans __thread_vars and allocates a pthread_key for each
+ * descriptor only when this flag is set; without it the descriptor's
+ * thunk pointer is silently patched to _tlv_bootstrap_error. Apple's
+ * ld sets it whenever the image contains S_THREAD_LOCAL_* sections. */
+ if (x.ntlv) mh_flags |= MH_HAS_TLV_DESCRIPTORS;
+ mbuf_u32(&file, mh_flags);
+ }
mbuf_u32(&file, 0); /* reserved */
mbuf_append(&file, lc.data, lc.len);
diff --git a/src/obj/macho.h b/src/obj/macho.h
@@ -44,6 +44,7 @@
#define MH_DYLDLINK 0x00000004u
#define MH_TWOLEVEL 0x00000080u
#define MH_PIE 0x00200000u
+#define MH_HAS_TLV_DESCRIPTORS 0x00800000u
/* ---- load command IDs (subset cfree will emit / consume) ---- */
#define LC_REQ_DYLD 0x80000000u
diff --git a/test/link/cases/36_tls_basic/j_targets b/test/link/cases/36_tls_basic/j_targets
@@ -0,0 +1,3 @@
+aa64-elf
+rv64-elf
+x64-elf