diff --git a/interpreter/customlabels/integrationtests/node_test.go b/interpreter/customlabels/integrationtests/node_test.go index d04bf1ab9..43e4765bd 100644 --- a/interpreter/customlabels/integrationtests/node_test.go +++ b/interpreter/customlabels/integrationtests/node_test.go @@ -13,6 +13,7 @@ package customlabels_test import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -29,6 +30,7 @@ import ( "time" + "github.com/docker/go-connections/nat" "github.com/stretchr/testify/require" testcontainers "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" @@ -50,6 +52,113 @@ var files = []string{ "broken.md", } +func runTest(t *testing.T, ctx context.Context, host string, port nat.Port) { + enabledTracers, err := tracertypes.Parse("labels,v8") + require.NoError(t, err) + + r := &testutils.MockReporter{} + traceCh, trc := testutils.StartTracer(ctx, t, enabledTracers, r, false) + + testHTTPEndpoint(t, host, port) + framesPerWorkerId := make(map[int]int) + framesPerFileName := make(map[string]int) + + totalWorkloadFrames := 0 + unlabeledWorkloadFrames := 0 + + timer := time.NewTimer(3 * time.Second) + defer timer.Stop() + + for { + select { + case <-timer.C: + goto done + case trace := <-traceCh: + if trace == nil { + continue + } + ct, err := trc.TraceProcessor().ConvertTrace(trace) + require.NotNil(t, ct) + require.NoError(t, err) + workerId, okWid := trace.CustomLabels["workerId"] + filePath, okFname := trace.CustomLabels["filePath"] + var fileName string + if okFname { + fileName = path.Base(filePath) + } + knownWorkloadFrames := []string{ + "lex", + "parse", + "blockTokens", + "readFile", + "readFileHandle", + } + hasWorkloadFrame := false + + for i := range ct.Frames { + if ct.Frames[i].Value().Type == libpf.V8Frame { + name := ct.Frames[i].Value().FunctionName.String() + if slices.Contains(knownWorkloadFrames, name) { + hasWorkloadFrame = true + } + } + } + + if hasWorkloadFrame { + totalWorkloadFrames++ + if !(okWid && okFname) { + unlabeledWorkloadFrames++ + } + } + + if okWid { + val, err := strconv.Atoi(workerId) + require.NoError(t, err) + + require.GreaterOrEqual(t, val, 0) + require.Less(t, val, N_WORKERS) + + framesPerWorkerId[val]++ + } + + if okFname { + require.Contains(t, files, fileName) + framesPerFileName[fileName]++ + } + } + } +done: + totalWidFrames := 0 + // for 8 workers, each should have roughly 1/8 + // of the labeled frames. There will be a bit of skew, + // so accept anything above 60% of that. + for i := 0; i < N_WORKERS; i++ { + totalWidFrames += framesPerWorkerId[i] + } + expectedWorkerAvg := float64(totalWidFrames) / float64(N_WORKERS) + for i := 0; i < N_WORKERS; i++ { + require.Less(t, expectedWorkerAvg*0.60, float64(framesPerWorkerId[i])) + } + // Each of the documents should account for some nontrivial amount of time, + // but since they aren't all the same length, we are less strict. + totalFnameFrames := 0 + for _, v := range framesPerFileName { + totalFnameFrames += v + } + expectedFnameAvg := float64(totalFnameFrames) / float64(len(framesPerFileName)) + for _, v := range framesPerFileName { + require.Less(t, expectedFnameAvg*0.2, float64(v)) + } + + // Really, there should be zero frames in the + // `marked` workload that aren't under labels, + // but accept a 5% slop because the unwinder + // isn't perfect (e.g. it might interrupt the + // process when the Node environment is in an + // undefined state) + require.Less(t, 20*unlabeledWorkloadFrames, totalWorkloadFrames) +} + func TestIntegration(t *testing.T) { if !testutils.IsRoot() { t.Skip("root privileges required") @@ -74,112 +183,69 @@ func TestIntegration(t *testing.T) { cont := startContainer(ctx, t, nodeVersion) - enabledTracers, err := tracertypes.Parse("labels,v8") + host, err := cont.Host(ctx) + require.NoError(t, err) + port, err := cont.MappedPort(ctx, "80") require.NoError(t, err) - r := &testutils.MockReporter{} - traceCh, trc := testutils.StartTracer(ctx, t, enabledTracers, r, false) - - testHTTPEndpoint(ctx, t, cont) - framesPerWorkerId := make(map[int]int) - framesPerFileName := make(map[string]int) - - totalWorkloadFrames := 0 - unlabeledWorkloadFrames := 0 - - timer := time.NewTimer(3 * time.Second) - defer timer.Stop() - - for { - select { - case <-timer.C: - goto done - case trace := <-traceCh: - if trace == nil { - continue - } - ct, err := trc.TraceProcessor().ConvertTrace(trace) - require.NotNil(t, ct) - require.NoError(t, err) - workerId, okWid := trace.CustomLabels["workerId"] - filePath, okFname := trace.CustomLabels["filePath"] - var fileName string - if okFname { - fileName = path.Base(filePath) - } - knownWorkloadFrames := []string{ - "lex", - "parse", - "blockTokens", - "readFile", - "readFileHandle", - } - hasWorkloadFrame := false - - for i := range ct.Frames { - if ct.Frames[i].Value().Type == libpf.V8Frame { - name := ct.Frames[i].Value().FunctionName.String() - if slices.Contains(knownWorkloadFrames, name) { - hasWorkloadFrame = true - } - } - } + runTest(t, ctx, host, port) + }) + } + t.Run("node-local-nightly", func(t *testing.T) { + type NodejsNightly struct { + Version string `json:"version"` + Files []string `json:"files"` + } + + resp, err := http.Get("https://nodejs.org/download/nightly/index.json") + require.NoError(t, err) + defer resp.Body.Close() + + var nightlies []NodejsNightly + err = json.NewDecoder(resp.Body).Decode(&nightlies) + require.NoError(t, err) + require.NotEmpty(t, nightlies) + + latest := nightlies[0] + + var nodeArch string + switch runtime.GOARCH { + case "arm64": + nodeArch = "linux-arm64" + case "amd64": + nodeArch = "linux-x64" + default: + t.Fatalf("Unsupported GOARCH: %s", runtime.GOARCH) + } + + tarballName := fmt.Sprintf("node-%s-%s", latest.Version, nodeArch) + + var tarballURL string + for _, file := range latest.Files { + if file == nodeArch { + tarballURL = fmt.Sprintf( + "https://nodejs.org/download/nightly/%s/%s.tar.gz", + latest.Version, tarballName) + break + } + } + require.NotEmpty(t, tarballURL, "No tarball found for latest nightly") - if hasWorkloadFrame { - totalWorkloadFrames++ - if !(okWid && okFname) { - unlabeledWorkloadFrames++ - } - } + ctx, cancel := context.WithCancel(t.Context()) + t.Cleanup(cancel) - if okWid { - val, err := strconv.Atoi(workerId) - require.NoError(t, err) + defer cancel() - require.GreaterOrEqual(t, val, 0) - require.Less(t, val, N_WORKERS) + cont := startNightlyContainer(ctx, t, tarballURL) - framesPerWorkerId[val]++ - } + host, err := cont.Host(ctx) + require.NoError(t, err) + port, err := cont.MappedPort(ctx, "80") + require.NoError(t, err) - if okFname { - require.Contains(t, files, fileName) - framesPerFileName[fileName]++ - } - } - } - done: - totalWidFrames := 0 - // for 8 workers, each should have roughly 1/8 - // of the labeled frames. There will be a bit of skew, - // so accept anything above 60% of that. - for i := 0; i < N_WORKERS; i++ { - totalWidFrames += framesPerWorkerId[i] - } - expectedWorkerAvg := float64(totalWidFrames) / float64(N_WORKERS) - for i := 0; i < N_WORKERS; i++ { - require.Less(t, expectedWorkerAvg*0.60, float64(framesPerWorkerId[i])) - } - // Each of the documents should account for some nontrivial amount of time, - // but since they aren't all the same length, we are less strict. - totalFnameFrames := 0 - for _, v := range framesPerFileName { - totalFnameFrames += v - } - expectedFnameAvg := float64(totalFnameFrames) / float64(len(framesPerFileName)) - for _, v := range framesPerFileName { - require.Less(t, expectedFnameAvg*0.2, float64(v)) - } + runTest(t, ctx, host, port) - // Really, there should be zero frames in the - // `marked` workload that aren't under labels, - // but accept a 5% slop because the unwinder - // isn't perfect (e.g. it might interrupt the - // process when the Node environment is in an - // undefined state) - require.Less(t, 20*unlabeledWorkloadFrames, totalWorkloadFrames) - }) - } + }) } func startContainer(ctx context.Context, t *testing.T, @@ -204,15 +270,34 @@ func startContainer(ctx context.Context, t *testing.T, return cont } -func testHTTPEndpoint(ctx context.Context, t *testing.T, cont testcontainers.Container) { - const numGoroutines = 10 - const requestsPerGoroutine = 10000 +func startNightlyContainer(ctx context.Context, + t *testing.T, + nodeURL string) testcontainers.Container { + t.Log("starting container for node nightly at URL", nodeURL) - host, err := cont.Host(ctx) + //nolint:dogsled + _, path, _, _ := runtime.Caller(0) + cont, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + FromDockerfile: testcontainers.FromDockerfile{ + Dockerfile: "Dockerfile.nightly", + Context: filepath.Dir(path) + "/testdata/node-md-render/", + BuildArgs: map[string]*string{ + "NODE_URL": &nodeURL, + }, + }, + ExposedPorts: []string{"80/tcp"}, + WaitingFor: wait.ForHTTP("/docs/AUTHORS.md"), + }, + Started: true, + }) require.NoError(t, err) + return cont +} - port, err := cont.MappedPort(ctx, "80") - require.NoError(t, err) +func testHTTPEndpoint(t *testing.T, host string, port nat.Port) { + const numGoroutines = 10 + const requestsPerGoroutine = 10000 baseURL := "http://" + net.JoinHostPort(host, port.Port()) diff --git a/interpreter/customlabels/integrationtests/testdata/node-md-render/Dockerfile.nightly b/interpreter/customlabels/integrationtests/testdata/node-md-render/Dockerfile.nightly new file mode 100644 index 000000000..fc3ec4e32 --- /dev/null +++ b/interpreter/customlabels/integrationtests/testdata/node-md-render/Dockerfile.nightly @@ -0,0 +1,31 @@ +FROM ubuntu:24.04 + +RUN apt-get update && apt-get install -y git python3 make g++ libatomic1 curl + + +WORKDIR /app +ENV PATH /app/node/bin:$PATH + +COPY package.json ./ + +RUN git clone https://github.com/markedjs/marked.git /tmp/marked && \ + cd /tmp/marked && \ + git checkout 0a0da515346d2b3dd1662531043fa6925cb73fe3 && \ + cp -r docs /app/docs && \ + rm -rf /tmp/marked + +COPY *.js ./ + +ARG NODE_URL + +RUN curl $NODE_URL > out.tar.gz + +RUN tar zxf out.tar.gz + +RUN mv node* node + +RUN npm install + +EXPOSE 80 + +CMD node index.js diff --git a/interpreter/customlabels/integrationtests/testdata/node-md-render/package.json b/interpreter/customlabels/integrationtests/testdata/node-md-render/package.json index 4090db79c..e968250a4 100644 --- a/interpreter/customlabels/integrationtests/testdata/node-md-render/package.json +++ b/interpreter/customlabels/integrationtests/testdata/node-md-render/package.json @@ -9,7 +9,7 @@ "author": "The Parca Authors", "license": "Apache-2.0", "dependencies": { - "@polarsignals/custom-labels": "^0.3.2", + "@polarsignals/custom-labels": "^0.3.4", "marked": "^16.1.2" } } diff --git a/interpreter/nodev8/v8.go b/interpreter/nodev8/v8.go index b7f497c5c..7191c9ddb 100644 --- a/interpreter/nodev8/v8.go +++ b/interpreter/nodev8/v8.go @@ -2096,12 +2096,6 @@ func (d *v8Data) readIntrospectionData(ef *pfelf.File) error { // loadNodeClData loads various offsets that are needed for custom labels handling. func (d *v8Data) loadNodeClData(ef *pfelf.File) error { - offset, err := ef.LookupTLSSymbolOffset("_ZN2v88internal18g_current_isolate_E") - if err != nil { - return err - } - d.isolateSym = libpf.Address(offset) - syms, err := ef.ReadSymbols() if err != nil { return fmt.Errorf("failed to read symbols: %w", err) @@ -2141,9 +2135,31 @@ func (d *v8Data) loadNodeClData(ef *pfelf.File) error { d.cpedOffset = 640 d.wrappedObjectOffset = 32 } - return nil + } else { + return fmt.Errorf("Unsupported Node major version: %d", major) } - return fmt.Errorf("Unsupported Node major version: %d", major) + + var offset int64 + + if major < 26 { + offset, err = ef.LookupTLSSymbolOffset("_ZN2v88internal18g_current_isolate_E") + if err != nil { + return fmt.Errorf("failed to look up g_current_isolate: %w", err) + } + } else { + // Node started building v8 without external dynamic symbols + // in major version v26. + sym, err = syms.LookupSymbol("_ZN2v88internal18g_current_isolate_E") + if err != nil { + return fmt.Errorf("failed to look up g_current_isolate (in major 26): %w", err) + } + offset, err = ef.AdjustTLSSymbol(sym) + if err != nil { + return fmt.Errorf("failed to adjust TLS symbol: %w", err) + } + } + d.isolateSym = libpf.Address(offset) + return nil } func Loader(ebpf interpreter.EbpfHandler, info *interpreter.LoaderInfo) (interpreter.Data, error) { diff --git a/libpf/pfelf/file.go b/libpf/pfelf/file.go index 57fba12d7..23201fdb5 100644 --- a/libpf/pfelf/file.go +++ b/libpf/pfelf/file.go @@ -990,6 +990,10 @@ func (f *File) LookupTLSSymbolOffset(symbol libpf.SymbolName) (int64, error) { if err != nil { return 0, err } + return f.AdjustTLSSymbol(tlsSym) +} + +func (f *File) AdjustTLSSymbol(tlsSym *libpf.Symbol) (int64, error) { if f.Machine == elf.EM_AARCH64 { return int64(tlsSym.Address), nil }