Skip to content

Commit 04f1c99

Browse files
Merge branch 'feature/nodejs'
2 parents 7da08fb + 8419338 commit 04f1c99

37 files changed

Lines changed: 5826 additions & 22 deletions

internal/agent/api/action_api.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ func (s *Server) handleActionAPI(req *ActionRequest, resp *ActionResponse) {
126126
if pResult.err == nil {
127127
pResult.output = append(pResult.output, pResult.rUrls...)
128128
} else {
129-
pResult.output = append(pResult.output, err.Error())
129+
pResult.output = append(pResult.output, pResult.err.Error())
130130
}
131131
} else if _, ok := pidAny.(string); ok {
132132
pResult.output = append(pResult.output, "Unsupported Operation")
@@ -141,7 +141,7 @@ func (s *Server) handleActionAPI(req *ActionRequest, resp *ActionResponse) {
141141
for _, r := range processPidsResults {
142142
if r.err != nil {
143143
resp.Code = -1
144-
resp.Msg = err.Error()
144+
resp.Msg = r.err.Error()
145145
} else {
146146
resp.DashboardReportURLs = r.rUrls
147147
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package api
2+
3+
import (
4+
"errors"
5+
"os"
6+
"strconv"
7+
"testing"
8+
9+
"yc-agent/internal/config"
10+
)
11+
12+
func TestActionAPIExplicitPidReachesProcessPids(t *testing.T) {
13+
saved := config.GlobalConfig
14+
t.Cleanup(func() { config.GlobalConfig = saved })
15+
config.GlobalConfig.ApiKey = "test-key"
16+
17+
s := NewServer("localhost", 0)
18+
var gotPids []int
19+
s.ProcessPids = func(pids []int, pid2Name map[int]string, hd bool, tags string) ([]string, error) {
20+
gotPids = append(gotPids, pids...)
21+
return []string{"http://dashboard/report/1"}, nil
22+
}
23+
24+
pid := os.Getpid() // a guaranteed-existing PID (validateActionAPIParseResult requires one)
25+
req := &ActionRequest{
26+
Key: "test-key",
27+
Actions: []string{"capture " + strconv.Itoa(pid)},
28+
WaitFor: true, // run synchronously so the assertion sees the result
29+
}
30+
resp := &ActionResponse{}
31+
s.handleActionAPI(req, resp)
32+
33+
if len(gotPids) != 1 || gotPids[0] != pid {
34+
t.Errorf("ProcessPids received %v, want [%d]", gotPids, pid)
35+
}
36+
if len(resp.DashboardReportURLs) != 1 || resp.DashboardReportURLs[0] != "http://dashboard/report/1" {
37+
t.Errorf("DashboardReportURLs = %v, want one report URL", resp.DashboardReportURLs)
38+
}
39+
}
40+
41+
func TestActionAPIProcessPidsErrorSurfaced(t *testing.T) {
42+
saved := config.GlobalConfig
43+
t.Cleanup(func() { config.GlobalConfig = saved })
44+
config.GlobalConfig.ApiKey = "test-key"
45+
46+
s := NewServer("localhost", 0)
47+
s.ProcessPids = func(pids []int, pid2Name map[int]string, hd bool, tags string) ([]string, error) {
48+
return nil, errors.New("capture blew up")
49+
}
50+
51+
req := &ActionRequest{
52+
Key: "test-key",
53+
Actions: []string{"capture " + strconv.Itoa(os.Getpid())},
54+
WaitFor: true,
55+
}
56+
resp := &ActionResponse{}
57+
s.handleActionAPI(req, resp) // must not panic (goroutine nil-deref before the fix)
58+
59+
if resp.Code != -1 {
60+
t.Errorf("resp.Code = %d, want -1 on a capture error", resp.Code)
61+
}
62+
if resp.Msg != "capture blew up" {
63+
t.Errorf("resp.Msg = %q, want the ProcessPids error surfaced verbatim", resp.Msg)
64+
}
65+
}
66+
67+
func TestActionAPIRejectsBadKey(t *testing.T) {
68+
saved := config.GlobalConfig
69+
t.Cleanup(func() { config.GlobalConfig = saved })
70+
config.GlobalConfig.ApiKey = "right-key"
71+
72+
s := NewServer("localhost", 0)
73+
called := false
74+
s.ProcessPids = func(pids []int, pid2Name map[int]string, hd bool, tags string) ([]string, error) {
75+
called = true
76+
return nil, nil
77+
}
78+
resp := &ActionResponse{}
79+
s.handleActionAPI(&ActionRequest{Key: "wrong-key", Actions: []string{"capture 1"}, WaitFor: true}, resp)
80+
81+
if called {
82+
t.Errorf("ProcessPids must not run when the API key is wrong")
83+
}
84+
if resp.Code != -1 || resp.Msg != "invalid key passed" {
85+
t.Errorf("resp = %+v, want Code=-1 Msg=\"invalid key passed\"", resp)
86+
}
87+
}
88+
89+
func TestActionAPIParseActionsDispatch(t *testing.T) {
90+
// A numeric id parses straight to that PID (runtime-agnostic — a Node PID
91+
// works exactly like a Java one here).
92+
result, _, _, err := parseActions([]string{"capture 424242"})
93+
if err != nil {
94+
t.Fatalf("parseActions numeric: %v", err)
95+
}
96+
if len(result) != 1 || result[0] != 424242 {
97+
t.Errorf("numeric dispatch = %v, want [424242]", result)
98+
}
99+
100+
// A non-numeric token is resolved by command-line substring match via
101+
// GetProcessIds — the runtime-agnostic path by which a Node process CAN be
102+
// targeted (with a unique token). A sentinel that matches no process yields
103+
// nothing.
104+
result, _, _, err = parseActions([]string{"capture zzz_no_such_process_token_zzz"})
105+
if err != nil {
106+
t.Fatalf("parseActions token: %v", err)
107+
}
108+
if len(result) != 0 {
109+
t.Errorf("unmatched token dispatch = %v, want empty", result)
110+
}
111+
112+
if _, _, _, err := parseActions([]string{"capture PROCESS_HIGH_CPU"}); err != nil {
113+
t.Fatalf("parseActions PROCESS_HIGH_CPU dispatch errored: %v", err)
114+
}
115+
}

internal/agent/m3/m3.go

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type M3App struct {
3333
accessLogM3 *capture.AccessLogM3
3434
AsyncDotNetGCCapture *capture.DotnetGCAsync
3535
dotnetGCReadySeen map[int]bool
36+
nodeGCTracker *capture.NodeGCTracker
3637
}
3738

3839
func NewM3App() *M3App {
@@ -49,6 +50,7 @@ func NewM3App() *M3App {
4950
accessLogM3: accessLogM3,
5051
AsyncDotNetGCCapture: capture.NewDotnetGCAsync(dotNetGCBaseDir),
5152
dotnetGCReadySeen: make(map[int]bool),
53+
nodeGCTracker: capture.NewNodeGCTracker(),
5254
}
5355
}
5456

@@ -261,7 +263,8 @@ func (m3 *M3App) captureAndTransmit(pids map[int]string, endpoint string) {
261263
var gcPath string
262264
appRuntime := runtimeByPid[pid]
263265

264-
if appRuntime == "dotnet" {
266+
switch appRuntime {
267+
case "dotnet":
265268
// High-level flow for .NET GC in M3 mode:
266269
// 1) Reconcile() starts/keeps one long-running async Dotnet GC collector per active .NET PID.
267270
// 2) In the same RunSingle cycle, uploadDotnetGCM3() reads collector output from session log path.
@@ -275,7 +278,10 @@ func (m3 *M3App) captureAndTransmit(pids map[int]string, endpoint string) {
275278

276279
logger.Log("uploading dotnet heap stats for pid %d", pid)
277280
uploadDotnetHeapM3(endpoint, pid)
278-
} else {
281+
case "nodejs":
282+
logger.Log("Using Node.js runtime for pid %d", pid)
283+
gcPath = m3.captureNodeM3(endpoint, pid)
284+
default:
279285
logger.Log("Using Java runtime for pid %d", pid)
280286
logger.Log("uploading gc log for pid %d", pid)
281287
gcPath = uploadGCLogM3(endpoint, pid)
@@ -304,6 +310,12 @@ func (m3 *M3App) captureAndTransmit(pids map[int]string, endpoint string) {
304310
delete(m3.dotnetGCReadySeen, pid)
305311
}
306312
}
313+
314+
// Drop Node.js GC offset state for PIDs no longer monitored, so it does
315+
// not leak across process restarts / PID reuse.
316+
if m3.nodeGCTracker != nil {
317+
m3.nodeGCTracker.RetainOnly(pids)
318+
}
307319
}
308320

309321
topResult := <-top
@@ -845,6 +857,67 @@ Resp: %s
845857
return gcPath
846858
}
847859

860+
func (m3 *M3App) captureNodeM3(endpoint string, pid int) string {
861+
nodeCtx := capture.ResolveNodeCapture(pid)
862+
863+
outDir := filepath.Join("yc-node-m3", strconv.Itoa(pid))
864+
if err := os.MkdirAll(outDir, 0o755); err != nil {
865+
logger.Log("WARNING: failed creating node m3 dir %s: %s", outDir, err)
866+
return ""
867+
}
868+
869+
stdoutPath := ""
870+
if capture.NodeHasTraceGCFlag(pid) {
871+
// ResolveNodeGCStdoutPath (not the raw platform resolver) so a
872+
// configured -nodejsGCLogPath is honored here too - this path is used
873+
// below to tell uploadAppLogM3 which file to exclude from generic
874+
// app-log auto-discovery, not just by NodeGC.Run()'s own capture.
875+
if p, err := capture.ResolveNodeGCStdoutPath(pid); err == nil {
876+
stdoutPath = p
877+
}
878+
}
879+
880+
pidParam := strconv.Itoa(pid)
881+
882+
// GC log (continuous delta; no dumpGC fallback under M3).
883+
nodeGC := &capture.NodeGC{
884+
Pid: pid,
885+
Ctx: nodeCtx,
886+
OutDir: outDir,
887+
Tracker: m3.nodeGCTracker,
888+
M3: true,
889+
}
890+
nodeGC.SetEndpointParam("pid", pidParam)
891+
runNodeM3Capture(endpoint, "GC LOG", nodeGC)
892+
893+
// Process overview
894+
nodePO := &capture.NodeProcessOverview{Pid: pid, Ctx: nodeCtx, OutDir: outDir}
895+
nodePO.SetEndpointParam("pid", pidParam)
896+
runNodeM3Capture(endpoint, "PROCESS OVERVIEW", nodePO)
897+
898+
// Heap summary (safe every cycle).
899+
nodeHS := &capture.NodeHeapSummary{Pid: pid, Ctx: nodeCtx, OutDir: outDir}
900+
nodeHS.SetEndpointParam("pid", pidParam)
901+
runNodeM3Capture(endpoint, "HDSUB", nodeHS)
902+
903+
return stdoutPath
904+
}
905+
906+
func runNodeM3Capture(endpoint, label string, task capture.Task) {
907+
ch := capture.GoCapture(endpoint, capture.WrapRun(task))
908+
if ch == nil {
909+
return
910+
}
911+
result := <-ch
912+
logger.Log(
913+
`NODE %s DATA
914+
Is transmission completed: %t
915+
Resp: %s
916+
917+
--------------------------------
918+
`, label, result.Ok, result.Msg)
919+
}
920+
848921
func uploadDotnetThreadM3(endpoint string, pid int) {
849922
dotnetTDCapture := &capture.DotnetThread{
850923
Pid: pid,

0 commit comments

Comments
 (0)