Skip to content

Commit 8689e73

Browse files
committed
tls: add a package locating thread-local variables
Resolve classifies how a thread-local is accessed, once per ELF, covering both TLS dialects and all four access models. Locate then turns that into a TLSVarInfo for one process: resolved for static TLS, awaiting the DTV layout for dynamic TLS. Locate is tested against synthetic process memory per access model, Resolve against purpose-built fixtures for each relocation shape.
1 parent 5fc5a00 commit 8689e73

13 files changed

Lines changed: 1606 additions & 3 deletions

.github/workflows/unit-test-on-pull-request.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ jobs:
247247
248248
# Not in the qemu matrix: that initramfs cannot load shared libraries.
249249
host-integration-tests:
250-
name: Integration tests (processcontext ${{ matrix.target_arch }})
250+
name: Integration tests (host ${{ matrix.target_arch }})
251251
runs-on: ${{ matrix.runner }}
252252
timeout-minutes: 10
253253
strategy:

Makefile

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
test-junit test-luajit-offsets protobuf docker-image agent legal \
33
integration-test-binaries \
44
codespell lint ebpf-profiler format format-ebpf format-go pprof-execs \
5-
processctx-execs host-integration-tests \
5+
processctx-execs tlsvar-execs host-integration-tests \
66
pprof_1_23 pprof_1_24 pprof_1_24_cgo otelcol-ebpf-profiler \
77
rust-components rust-targets rust-tests vanity-import-check vanity-import-fix \
88
otel-from-tree otel-from-lib
@@ -55,6 +55,7 @@ clean:
5555
@go clean -cache -i
5656
@$(MAKE) -s -C support/ebpf clean
5757
@$(MAKE) -C process/processcontext/integrationtests/testdata clean
58+
@$(MAKE) -C tls/testdata clean
5859
@chmod -Rf u+w go/ || true
5960
@rm -rf go .cache support/*.test interpreter/go/integrationtests/pprof_1_*
6061
@rm -f otelcol-ebpf-profiler cmd/otelcol-ebpf-profiler/{*.go,go.mod,go.sum} || true
@@ -155,10 +156,14 @@ TEST_INTEGRATION_BINARY_DIRS := tracer processmanager/ebpf kallsyms support inte
155156
processctx-execs:
156157
$(MAKE) -C process/processcontext/integrationtests/testdata
157158

159+
tlsvar-execs:
160+
$(MAKE) -C tls/testdata
161+
158162
# Host-only: the qemu initramfs cannot load shared libraries, which the
159163
# lib/dlopen testdata variants will need.
160-
host-integration-tests: processctx-execs
164+
host-integration-tests: processctx-execs tlsvar-execs
161165
go test -exec sudo -v -tags host_integration ./process/processcontext/integrationtests/
166+
go test -v -tags host_integration ./tls/
162167

163168
pprof-execs: pprof_1_23 pprof_1_24 pprof_1_24_cgo pprof_1_24_cgo_pie pprof_stable pprof_stable_buildinfo_cgo pprof_stable_cgo pprof_stable_cgo_pie
164169

tls/locate_test.go

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package tls // import "go.opentelemetry.io/ebpf-profiler/tls"
5+
6+
import (
7+
"encoding/binary"
8+
"io"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
"golang.org/x/sys/unix"
14+
15+
"go.opentelemetry.io/ebpf-profiler/remotememory"
16+
"go.opentelemetry.io/ebpf-profiler/support"
17+
)
18+
19+
// memory is a sparse address space: a read not fully inside one region fails
20+
// the way an unmapped address does, which is what the models below classify on.
21+
type memory map[uint64][]byte
22+
23+
func (m memory) ReadAt(p []byte, off int64) (int, error) {
24+
for base, data := range m {
25+
if uint64(off) >= base && uint64(off)+uint64(len(p)) <= base+uint64(len(data)) {
26+
copy(p, data[uint64(off)-base:])
27+
return len(p), nil
28+
}
29+
}
30+
return 0, unix.EFAULT
31+
}
32+
33+
type errReader struct{ err error }
34+
35+
func (r errReader) ReadAt([]byte, int64) (int, error) { return 0, r.err }
36+
37+
func words(vals ...uint64) []byte {
38+
b := make([]byte, 8*len(vals))
39+
for i, v := range vals {
40+
binary.LittleEndian.PutUint64(b[8*i:], v)
41+
}
42+
return b
43+
}
44+
45+
// mustStatic and mustDynamic build the expected descriptor through the same
46+
// constructors Locate calls: TLSVarInfo's fields are private, so a literal is
47+
// not available. What that leaves under test here is which model Locate picked
48+
// and the offset it fed in, the constructors themselves being covered by
49+
// support's own tests.
50+
func mustStatic(tlsOffset uint64) VarInfo {
51+
v, err := support.NewStaticTLSVarInfo(tlsOffset)
52+
if err != nil {
53+
panic(err)
54+
}
55+
return v
56+
}
57+
58+
func mustDynamic(moduleID, tlsOffset uint64) VarInfo {
59+
v, err := support.NewDynamicTLSVarInfo(moduleID, tlsOffset)
60+
if err != nil {
61+
panic(err)
62+
}
63+
return v
64+
}
65+
66+
const (
67+
bias = 0x7f0000000000
68+
elfAddr = 0x1000
69+
// slotAddr is where the GOT slot or TLS descriptor lands once biased.
70+
slotAddr = bias + elfAddr
71+
// indexAddr stands in for the loader's heap allocation, which is where a
72+
// dynamic descriptor's tls_index lives.
73+
indexAddr = 0x555555560000
74+
// lowIndexAddr is that same allocation in a non-PIE process, whose brk heap
75+
// sits low enough that the address is also a plausible TP-relative offset.
76+
// This is what leaves the resolver's verdict as the only discriminant.
77+
lowIndexAddr = 0x420000
78+
resolverAddr = 0x7f0000100000
79+
)
80+
81+
// staticResolver is musl's __tlsdesc_static / glibc's _dl_tlsdesc_return:
82+
// "ldr x0, [x0, #8]; ret". tlsdesc_aarch64_test.go covers the matching.
83+
var staticResolver = insns(0xf9400400, 0xd65f03c0)
84+
85+
// dynamicResolver opens with musl's __tlsdesc_dynamic "stp x1, x2, [sp, #-16]!",
86+
// so it does not match the static body and the argument decides instead.
87+
var dynamicResolver = insns(0xa9bf0be1, 0xd53bd041, 0xf9400400)
88+
89+
func TestLocate(t *testing.T) {
90+
tests := map[string]struct {
91+
v Var
92+
mem io.ReaderAt
93+
// want is compared only when neither error field is set.
94+
want VarInfo
95+
// wantErr requires that sentinel. wantAnyErr only requires a failure,
96+
// for the cases whose error comes from the VarInfo constructors.
97+
wantErr error
98+
wantAnyErr bool
99+
}{
100+
"local-exec": {
101+
v: Var{access: accessLocalExec, addend: 0x10, variant: variantI},
102+
want: mustStatic(0x10),
103+
},
104+
"initial-exec": {
105+
v: Var{access: accessInitialExec, elfAddr: elfAddr, variant: variantI},
106+
mem: memory{slotAddr: words(0x20)},
107+
want: mustStatic(0x20),
108+
},
109+
"initial-exec zero offset accepted on aarch64": {
110+
// Variant I with no PT_TLS in the executable puts musl's first
111+
// library block at TP+0.
112+
v: Var{access: accessInitialExec, elfAddr: elfAddr, variant: variantI},
113+
mem: memory{slotAddr: words(0)},
114+
want: mustStatic(0),
115+
},
116+
"initial-exec zero offset rejected on x86-64": {
117+
v: Var{access: accessInitialExec, elfAddr: elfAddr, variant: variantII},
118+
mem: memory{slotAddr: words(0)},
119+
wantErr: ErrUnresolved,
120+
},
121+
"initial-exec on x86-64": {
122+
v: Var{access: accessInitialExec, elfAddr: elfAddr, variant: variantII},
123+
mem: memory{slotAddr: words(^uint64(0x10) + 1)},
124+
want: mustStatic(^uint64(0x10) + 1),
125+
},
126+
"initial-exec unreadable slot": {
127+
v: Var{access: accessInitialExec, elfAddr: elfAddr, variant: variantI},
128+
wantErr: unix.EFAULT,
129+
},
130+
"general-dynamic": {
131+
v: Var{access: accessGeneralDynamic, elfAddr: elfAddr, variant: variantII},
132+
mem: memory{slotAddr: words(3, 0x40)},
133+
want: mustDynamic(3, 0x40),
134+
},
135+
"general-dynamic with an unapplied relocation": {
136+
v: Var{access: accessGeneralDynamic, elfAddr: elfAddr, variant: variantII},
137+
mem: memory{slotAddr: words(0, 0x40)},
138+
wantAnyErr: true,
139+
},
140+
"local-dynamic": {
141+
// The relocation resolves the module only. The offset is static.
142+
v: Var{access: accessLocalDynamic, elfAddr: elfAddr, addend: 0x18,
143+
variant: variantII},
144+
mem: memory{slotAddr: words(5)},
145+
want: mustDynamic(5, 0x18),
146+
},
147+
"tlsdesc unrelocated": {
148+
v: Var{access: accessTLSDesc, elfAddr: elfAddr, variant: variantII},
149+
mem: memory{slotAddr: words(0, 0x40)},
150+
wantErr: ErrUnresolved,
151+
},
152+
"tlsdesc static on x86-64": {
153+
// A negative argument is exactly what makes it a TP offset there.
154+
v: Var{access: accessTLSDesc, elfAddr: elfAddr, variant: variantII},
155+
mem: memory{slotAddr: words(resolverAddr, ^uint64(0x28)+1)},
156+
want: mustStatic(^uint64(0x28) + 1),
157+
},
158+
"tlsdesc dynamic on x86-64": {
159+
v: Var{access: accessTLSDesc, elfAddr: elfAddr, variant: variantII},
160+
mem: memory{
161+
slotAddr: words(resolverAddr, indexAddr),
162+
indexAddr: words(2, 0x30),
163+
},
164+
want: mustDynamic(2, 0x30),
165+
},
166+
"tlsdesc argument is neither on x86-64": {
167+
// Readable, so the argument is provably no tls_index, and a
168+
// non-negative one cannot be a TP offset either.
169+
v: Var{access: accessTLSDesc, elfAddr: elfAddr, variant: variantII},
170+
mem: memory{
171+
slotAddr: words(resolverAddr, indexAddr),
172+
indexAddr: words(0, 0x30),
173+
},
174+
wantAnyErr: true,
175+
},
176+
"tlsdesc tls_index with an implausible module": {
177+
v: Var{access: accessTLSDesc, elfAddr: elfAddr, variant: variantII},
178+
mem: memory{
179+
slotAddr: words(resolverAddr, indexAddr),
180+
indexAddr: words(support.MaxTLSModuleID+1, 0x30),
181+
},
182+
wantAnyErr: true,
183+
},
184+
"tlsdesc static on aarch64": {
185+
// A readable tls_index sits at the argument, so dereferencing it
186+
// would answer dynamic. Only the resolver says otherwise.
187+
v: Var{access: accessTLSDesc, elfAddr: elfAddr, variant: variantI},
188+
mem: memory{
189+
slotAddr: words(resolverAddr, lowIndexAddr),
190+
resolverAddr: staticResolver,
191+
lowIndexAddr: words(4, 0x50),
192+
},
193+
want: mustStatic(lowIndexAddr),
194+
},
195+
"tlsdesc local-dynamic on aarch64": {
196+
// The symbol's own offset is added to whatever the module resolves to.
197+
v: Var{access: accessTLSDesc, elfAddr: elfAddr, addend: 0x8,
198+
variant: variantI},
199+
mem: memory{
200+
slotAddr: words(resolverAddr, indexAddr),
201+
resolverAddr: dynamicResolver,
202+
indexAddr: words(4, 0x50),
203+
},
204+
want: mustDynamic(4, 0x58),
205+
},
206+
"tlsdesc argument too low to be a pointer": {
207+
// Settled by the value alone, so the resolver is never read: no
208+
// region is mapped for it here.
209+
v: Var{access: accessTLSDesc, elfAddr: elfAddr, variant: variantI},
210+
mem: memory{slotAddr: words(resolverAddr, 0x60)},
211+
want: mustStatic(0x60),
212+
},
213+
"tlsdesc unreadable resolver": {
214+
// Above the bound, so the resolver has to answer, and its failed
215+
// read must not become a static offset.
216+
v: Var{access: accessTLSDesc, elfAddr: elfAddr, variant: variantI},
217+
mem: memory{slotAddr: words(resolverAddr, lowIndexAddr)},
218+
wantErr: unix.EFAULT,
219+
},
220+
"tlsdesc unreadable argument on aarch64": {
221+
// The non-PIE shape: the argument is a plausible TP offset and a
222+
// plausible pointer, and the failed read tells us nothing. This is
223+
// where a fallback to static would fabricate an address.
224+
v: Var{access: accessTLSDesc, elfAddr: elfAddr, variant: variantI},
225+
mem: memory{
226+
slotAddr: words(resolverAddr, lowIndexAddr),
227+
resolverAddr: dynamicResolver,
228+
},
229+
wantErr: unix.EFAULT,
230+
},
231+
"unclassified variable": {
232+
wantErr: ErrUnsupportedModel,
233+
},
234+
// A vanished process must not be classified as "the argument is not a
235+
// pointer": every errno propagates as itself.
236+
"vanished process": {
237+
v: Var{access: accessTLSDesc, elfAddr: elfAddr, variant: variantII},
238+
mem: errReader{err: unix.ESRCH},
239+
wantErr: unix.ESRCH,
240+
},
241+
}
242+
243+
for name, tc := range tests {
244+
t.Run(name, func(t *testing.T) {
245+
mem := tc.mem
246+
if mem == nil {
247+
mem = memory{}
248+
}
249+
got, err := tc.v.Locate(remotememory.RemoteMemory{ReaderAt: mem}, bias)
250+
switch {
251+
case tc.wantErr != nil:
252+
require.ErrorIs(t, err, tc.wantErr)
253+
case tc.wantAnyErr:
254+
require.Error(t, err)
255+
default:
256+
require.NoError(t, err)
257+
assert.Equal(t, tc.want, got)
258+
}
259+
})
260+
}
261+
}

0 commit comments

Comments
 (0)