[lld][WebAssembly] Follow relocations of TLS-base accessors during GC (#206831)

With `--gc-sections` (the default), `wasm-ld` garbage-collects functions
that are only reachable through `__wasm_get_tls_base` /
`__wasm_set_tls_base` in the cooperative-threading (libcall
thread-context) configuration. This produces a linked module that is
invalid or behaves incorrectly: the relocation inside
`__wasm_set_tls_base` is left dangling / mis-resolved, so callers trap
at runtime (e.g. `validation error: ... values remaining on stack at end
of block`, or a call to an unrelated function).

In cooperative-threading mode (`--cooperative-threading`, added in
#200855), per-task thread context is accessed through libcalls rather
than wasm globals. `wasm-ld` synthesizes `__wasm_init_tls` /
`__wasm_init_memory`, which invoke `__wasm_get_tls_base` and
`__wasm_set_tls_base` via **raw `call` instructions that carry no
relocations**. To keep those accessors in the output, the linker marks
them live with `Symbol::markLive()`.

## Error and Repro

```bash
Error: failed to compile: wasm[0]::function[16]::__wasm_set_tls_base

Caused by:
    0: WebAssembly translation error
    1: Invalid input WebAssembly code at offset 778: type mismatch: values remaining on stack at end of block
```

import was dropped and __wasm_set_tls_base was rewritten from

```wat
(func $__wasm_set_tls_base (param i32)
  local.get 0
  call $__wasm_component_model_builtin_context_set_1)   ;; correct
```

to

```wat
(func $__wasm_set_tls_base (param i32)
  local.get 0
  call $__wasm_component_model_builtin_context_get_0)   ;; wrong: get_0 is () -> i32
```

Minimal linker-only repro (no runtime needed) is:
```bash
wasm-ld --cooperative-threading tls.o libc.a   # context-set-1 dropped, set_tls_base calls get_0
wasm-ld --cooperative-threading --no-gc-sections tls.o libc.a   # correct
```

## Root cause

`Symbol::markLive()` sets the live flag (and the chunk's live bit) but
does not push the defining chunk onto the mark queue. The mark phase
only follows relocations of chunks that were enqueued via
`MarkLive::enqueue()`. As a result the accessors' own relocations are
never traversed.

This is the same situation already handled for constructors reached
through the relocation-less `__wasm_call_ctors`, which `MarkLive::run()`
enqueues explicitly.

## Fix

In `MarkLive::run()`, enqueue the defining chunks of
`__wasm_get_tls_base` and `__wasm_set_tls_base` before `mark()`, so
their relocations are followed.

```cpp
for (Symbol *sym : {static_cast<Symbol *>(ctx.sym.getTLSBase),
                    static_cast<Symbol *>(ctx.sym.setTLSBase)})
  if (sym)
    if (InputChunk *c = sym->getChunk())
      enqueue(c);
```

The symbols are only set in the libcall-thread-context configuration and
are null otherwise, so the loop is a no-op for all other builds.

## Testing

Adds `lld/test/wasm/cooperative-threading-gc.s`, which links with
`--cooperative-threading --gc-sections` and checks (via `obj2yaml`) that
the import called by `__wasm_set_tls_base` survives GC. Without this
change the test drops the import and rewrites `__wasm_set_tls_base` to
call an unrelated function; with it the import is retained and the call
is correct.

```sh
ninja check-lld-wasm
```

## Note

Human-in-the-loop with claude opus 4.8. I iterated with this patch and
got to a working component running with a wasi-sdk fork.
This commit is contained in:
Bailey Hayes authored and Douglas Yung committed 2026-08-20 21:01:48 +00:00
1 parent 34319ed257
commit 2c70d35e9b
2 files changed
+71

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
# Verify that --gc-sections preserves functions reachable only through the
# thread-context accessors __wasm_{get,set}_tls_base. These accessors are
# invoked by synthetic init functions (e.g. __wasm_init_tls) via `call`
# instructions that carry no relocations, so the linker marks the accessors
# live explicitly. Their own relocations must still be followed during GC,
# otherwise functions they call (here modeling the cooperative-threading
# context.set builtin) are incorrectly collected and the call is mis-resolved.
# RUN: llvm-mc -filetype=obj -triple=wasm32-unknown-unknown -o %t.o %s
# RUN: wasm-ld --cooperative-threading --gc-sections -o %t.wasm %t.o
# RUN: obj2yaml %t.wasm | FileCheck %s
.functype set_helper (i32) -> ()
.import_module set_helper, "env"
.import_name set_helper, "set_helper"
.globl __wasm_get_tls_base
__wasm_get_tls_base:
.functype __wasm_get_tls_base () -> (i32)
i32.const 0
end_function
# Reachable only via the synthetic __wasm_init_tls. Its call to `set_helper`
# must keep `set_helper` live.
.globl __wasm_set_tls_base
__wasm_set_tls_base:
.functype __wasm_set_tls_base (i32) -> ()
local.get 0
call set_helper
end_function
.globl _start
_start:
.functype _start () -> (i32)
call __wasm_get_tls_base
i32.const tls1@TLSREL
i32.add
i32.load 0
end_function
.section .tdata.tls1,"",@
.globl tls1
tls1:
.int32 1
.size tls1, 4
.section .custom_section.target_features,"",@
.int8 2
.int8 43
.int8 11
.ascii "bulk-memory"
.int8 43
.int8 7
.ascii "atomics"
# The imported helper called by __wasm_set_tls_base must survive GC.
# CHECK: Field: set_helper
+14
View File
@@ -126,6 +126,20 @@ void MarkLive::run() {
enqueueRetainedSegments(obj);
}
// `__wasm_{get,set}_tls_base` are called from synthetic init functions (e.g.
// `__wasm_init_tls`, `__wasm_init_memory`) via raw `call` instructions that
// carry no relocations, so the mark phase below cannot discover the functions
// they in turn call (e.g. the cooperative-threading
// `context.get`/`context.set` builtins). They are already marked live, but
// their defining chunks were never enqueued; enqueue them here so their
// relocations are followed. This mirrors the handling of ctor functions
// reached via `__wasm_call_ctors`.
for (Symbol *sym : {static_cast<Symbol *>(ctx.sym.getTLSBase),
static_cast<Symbol *>(ctx.sym.setTLSBase)})
if (sym)
if (InputChunk *c = sym->getChunk())
enqueue(c);
mark();
// If we have any non-discarded init functions, mark `__wasm_call_ctors` as