Skip to content

Commit 11d944d

Browse files
committed
Add Heap.enable + pipeline health diagnostic to heap tracking
- Call Heap.enable before Heap.startTracking (required for events) - Add pre-GC to reduce snapshot size before starting tracking - Wait for trackingStart event to confirm pipeline health - Report pipeline status (healthy/warning) from heap_start_tracking - Update tests with mock handlers for Heap.enable and Heap.gc
1 parent 91b9f18 commit 11d944d

3 files changed

Lines changed: 111 additions & 17 deletions

File tree

cmd/iwdp-mcp/main.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1715,7 +1715,7 @@ func registerTools(server *mcp.Server) {
17151715
})
17161716

17171717
mcp.AddTool(server, &mcp.Tool{
1718-
Name: "heap_start_tracking", Description: "Start tracking heap allocations — collects heap snapshot events",
1718+
Name: "heap_start_tracking", Description: "Start tracking heap allocations — collects GC events. Waits up to 5s to confirm event pipeline health.",
17191719
}, func(ctx context.Context, req *mcp.CallToolRequest, _ EmptyInput) (*mcp.CallToolResult, any, error) {
17201720
c, err := getClient(ctx)
17211721
if err != nil {
@@ -1727,7 +1727,19 @@ func registerTools(server *mcp.Server) {
17271727
}
17281728
collector := sess.heapTrackingCollector
17291729
sess.mu.Unlock()
1730-
return nil, ok(), collector.Start(ctx, c)
1730+
if err := collector.Start(ctx, c); err != nil {
1731+
return nil, OKOutput{}, err
1732+
}
1733+
if collector.PipelineHealthy() {
1734+
return nil, struct {
1735+
OK bool `json:"ok"`
1736+
Message string `json:"message"`
1737+
}{true, "Heap tracking started. Event pipeline confirmed healthy — GC events will be captured."}, nil
1738+
}
1739+
return nil, struct {
1740+
OK bool `json:"ok"`
1741+
Warning string `json:"warning"`
1742+
}{true, "Heap tracking started, but trackingStart event not received (iwdp may not support the 50-200MB snapshot relay). GC events may not be captured. Use heap_snapshot and heap_gc directly instead."}, nil
17311743
})
17321744

17331745
mcp.AddTool(server, &mcp.Tool{

internal/tools/memory.go

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -186,30 +186,47 @@ type GarbageCollection struct {
186186
EndTime float64 `json:"endTime"`
187187
}
188188

189+
// HeapTrackingReadyTimeout is how long Start waits for the trackingStart event
190+
// to confirm the event pipeline is healthy. Default 5s.
191+
var HeapTrackingReadyTimeout = 5 * time.Second
192+
189193
// HeapTrackingResult holds the collected heap tracking data.
190-
// Note: Heap.trackingStart/trackingComplete carry 50-200MB+ snapshot payloads
191-
// that crash iwdp's WebSocket relay, so we intentionally skip them and only
192-
// collect lightweight garbageCollected events. Use the dedicated heap_snapshot
193-
// tool for snapshots (it uses Heap.snapshot which returns data in-band).
194194
type HeapTrackingResult struct {
195-
GCEvents []GarbageCollection `json:"gcEvents,omitempty"`
195+
GCEvents []GarbageCollection `json:"gcEvents,omitempty"`
196+
PipelineHealthy bool `json:"pipelineHealthy"`
196197
}
197198

198199
// HeapTrackingCollector collects Heap GC events during tracking.
199-
// Snapshot events (trackingStart/trackingComplete) are intentionally ignored
200-
// because their 50-200MB+ payloads crash iwdp's WebSocket relay.
200+
//
201+
// Heap.startTracking triggers a trackingStart event carrying a full heap
202+
// snapshot (50-200MB+). If iwdp can relay this massive event successfully,
203+
// the event pipeline is healthy and subsequent garbageCollected events will
204+
// arrive. If not, the pipeline is broken and no events will be captured.
205+
// Start() waits for the trackingStart event to diagnose this.
201206
type HeapTrackingCollector struct {
202-
mu sync.Mutex
203-
gcEvents []GarbageCollection
204-
started bool
207+
mu sync.Mutex
208+
gcEvents []GarbageCollection
209+
started bool
210+
ready chan struct{}
211+
pipelineHealthy bool
205212
}
206213

207214
// NewHeapTrackingCollector creates a new HeapTrackingCollector.
208215
func NewHeapTrackingCollector() *HeapTrackingCollector {
209216
return &HeapTrackingCollector{}
210217
}
211218

219+
// PipelineHealthy reports whether the event pipeline survived the massive
220+
// trackingStart event. If false, garbageCollected events won't arrive.
221+
func (c *HeapTrackingCollector) PipelineHealthy() bool {
222+
c.mu.Lock()
223+
defer c.mu.Unlock()
224+
return c.pipelineHealthy
225+
}
226+
212227
// Start begins heap tracking, collecting garbageCollected events.
228+
// It waits up to HeapTrackingReadyTimeout for the trackingStart event to
229+
// confirm the event pipeline is healthy.
213230
func (c *HeapTrackingCollector) Start(ctx context.Context, client *webkit.Client) error {
214231
c.mu.Lock()
215232
if c.started {
@@ -218,8 +235,27 @@ func (c *HeapTrackingCollector) Start(ctx context.Context, client *webkit.Client
218235
}
219236
c.started = true
220237
c.gcEvents = nil
238+
c.ready = make(chan struct{})
239+
c.pipelineHealthy = false
221240
c.mu.Unlock()
222241

242+
// trackingStart handler: signals that the massive snapshot event arrived
243+
// at our client, confirming the event pipeline is healthy. We discard
244+
// the snapshot data (50-200MB+) — use heap_snapshot for snapshots.
245+
client.OnEvent("Heap.trackingStart", func(method string, params json.RawMessage) {
246+
c.mu.Lock()
247+
c.pipelineHealthy = true
248+
ch := c.ready
249+
c.mu.Unlock()
250+
if ch != nil {
251+
select {
252+
case <-ch:
253+
default:
254+
close(ch)
255+
}
256+
}
257+
})
258+
223259
client.OnEvent("Heap.garbageCollected", func(method string, params json.RawMessage) {
224260
var evt struct {
225261
Collection GarbageCollection `json:"collection"`
@@ -231,8 +267,34 @@ func (c *HeapTrackingCollector) Start(ctx context.Context, client *webkit.Client
231267
}
232268
})
233269

270+
// Enable Heap domain — required for events to be dispatched.
271+
_, _ = client.Send(ctx, "Heap.enable", nil)
272+
273+
// Pre-GC: reduce heap size before startTracking to minimize the snapshot
274+
// payload in the trackingStart event. A smaller snapshot is more likely
275+
// to survive iwdp's WebSocket relay (which has a 64MB message limit).
276+
_, _ = client.Send(ctx, "Heap.gc", nil)
277+
234278
_, err := client.Send(ctx, "Heap.startTracking", nil)
235-
return err
279+
if err != nil {
280+
return err
281+
}
282+
283+
// Wait for trackingStart event to confirm the event pipeline survived
284+
// the massive snapshot payload. If it arrives, GC events will work.
285+
// If not, iwdp couldn't relay the 50-200MB+ event and the pipeline is broken.
286+
c.mu.Lock()
287+
ch := c.ready
288+
c.mu.Unlock()
289+
select {
290+
case <-ch:
291+
// Pipeline healthy — trackingStart event arrived.
292+
case <-time.After(HeapTrackingReadyTimeout):
293+
// Timeout — event pipeline likely broken by massive snapshot.
294+
case <-ctx.Done():
295+
}
296+
297+
return nil
236298
}
237299

238300
// Stop stops heap tracking and returns collected GC events.
@@ -244,6 +306,7 @@ func (c *HeapTrackingCollector) Stop(ctx context.Context, client *webkit.Client)
244306
c.mu.Unlock()
245307
return &HeapTrackingResult{}, nil
246308
}
309+
healthy := c.pipelineHealthy
247310
c.mu.Unlock()
248311

249312
// stopTracking triggers trackingComplete with a massive snapshot payload
@@ -253,7 +316,8 @@ func (c *HeapTrackingCollector) Stop(ctx context.Context, client *webkit.Client)
253316
c.mu.Lock()
254317
c.started = false
255318
result := &HeapTrackingResult{
256-
GCEvents: make([]GarbageCollection, len(c.gcEvents)),
319+
GCEvents: make([]GarbageCollection, len(c.gcEvents)),
320+
PipelineHealthy: healthy,
257321
}
258322
copy(result.GCEvents, c.gcEvents)
259323
c.gcEvents = nil

internal/tools/tools_domains_test.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -575,18 +575,33 @@ func TestHeapSnapshot(t *testing.T) {
575575

576576
func TestHeapTrackingCollector(t *testing.T) {
577577
mock, client := setup(t)
578+
mock.HandleFunc("Heap.enable", map[string]interface{}{})
579+
mock.HandleFunc("Heap.gc", map[string]interface{}{})
578580
mock.HandleFunc("Heap.startTracking", map[string]interface{}{})
579581
mock.HandleFunc("Heap.stopTracking", map[string]interface{}{})
580582

581583
collector := tools.NewHeapTrackingCollector()
582584
ctx := context.Background()
585+
586+
// Send trackingStart shortly after startTracking to confirm pipeline health.
587+
// In production, this event carries 50-200MB+ snapshot data; here we use a
588+
// small mock payload. Start() waits for this signal before returning.
589+
go func() {
590+
time.Sleep(50 * time.Millisecond)
591+
_ = mock.SendEvent("Heap.trackingStart", map[string]interface{}{
592+
"timestamp": 1000.0,
593+
"snapshotData": "mock-snapshot",
594+
})
595+
}()
596+
583597
if err := collector.Start(ctx, client); err != nil {
584598
t.Fatalf("HeapTrackingCollector.Start: %v", err)
585599
}
600+
if !collector.PipelineHealthy() {
601+
t.Error("expected pipeline to be healthy after trackingStart event")
602+
}
586603

587-
// Send garbageCollected events (the only events we collect — snapshot
588-
// events from trackingStart/trackingComplete are intentionally ignored
589-
// because their 50-200MB payloads crash iwdp).
604+
// Send garbageCollected events.
590605
if err := mock.SendEvent("Heap.garbageCollected", map[string]interface{}{
591606
"collection": map[string]interface{}{
592607
"type": "full",
@@ -611,6 +626,9 @@ func TestHeapTrackingCollector(t *testing.T) {
611626
if err != nil {
612627
t.Fatalf("HeapTrackingCollector.Stop: %v", err)
613628
}
629+
if !result.PipelineHealthy {
630+
t.Error("expected PipelineHealthy=true in result")
631+
}
614632
if len(result.GCEvents) != 2 {
615633
t.Fatalf("expected 2 GC events, got %d", len(result.GCEvents))
616634
}

0 commit comments

Comments
 (0)