Skip to content

Commit a2f74b0

Browse files
committed
feat: fix heap snapshot crashes, add restart_iwdp tool
- Increase WebSocket read limit from 64 MB to 512 MB to handle large heap snapshots wrapped in Target routing JSON escaping - HeapSnapshot now saves directly to temp file instead of buffering in memory, with a 5-minute timeout - New restart_iwdp tool to recover after iwdp crashes (kills old process, starts fresh, clears stale WebSocket connection) - Add restart_iwdp to README tools table - Bump version to 0.3.0
1 parent 1c0d405 commit a2f74b0

File tree

9 files changed

+104
-24
lines changed

9 files changed

+104
-24
lines changed

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "iwdp-mcp",
3-
"version": "0.2.4",
3+
"version": "0.3.0",
44
"description": "iOS Safari debugging via ios-webkit-debug-proxy — MCP server with full WebKit Inspector Protocol support",
55
"owner": {
66
"name": "nnemirovsky"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "iwdp-mcp",
3-
"version": "0.2.4",
3+
"version": "0.3.0",
44
"description": "iOS Safari debugging via ios-webkit-debug-proxy — MCP server with full WebKit Inspector Protocol support",
55
"mcpServers": {
66
"iwdp-mcp": {

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ Set a breakpoint in main.js and step through it
281281
| Tool | Description |
282282
|------|-------------|
283283
| `iwdp_status` | Check/auto-start ios-webkit-debug-proxy |
284+
| `restart_iwdp` | Restart iwdp after a crash (e.g., heap snapshot) |
284285
| `list_devices` | List connected iOS devices (HTTP GET :9221) |
285286
| `list_pages` | List Safari tabs (HTTP GET :9222+) |
286287
| `select_page` | Connect to a specific tab |

cmd/iwdp-mcp/main.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func getClient(ctx context.Context) (*webkit.Client, error) {
4141
func main() {
4242
server := mcp.NewServer(&mcp.Implementation{
4343
Name: "iwdp-mcp",
44-
Version: "0.2.4",
44+
Version: "0.3.0",
4545
}, nil)
4646

4747
registerTools(server)
@@ -483,6 +483,22 @@ func registerTools(server *mcp.Server) {
483483
return nil, map[string]any{"running": true, "started": true, "message": "ios-webkit-debug-proxy was not running — started it automatically"}, nil
484484
})
485485

486+
mcp.AddTool(server, &mcp.Tool{
487+
Name: "restart_iwdp", Description: "Restart ios-webkit-debug-proxy. Use this to recover after a crash (e.g., after a large heap snapshot kills the connection).",
488+
}, func(ctx context.Context, req *mcp.CallToolRequest, _ EmptyInput) (*mcp.CallToolResult, any, error) {
489+
// Also disconnect any active WebSocket client since the old connection is dead
490+
sess.mu.Lock()
491+
if sess.client != nil {
492+
_ = sess.client.Close()
493+
sess.client = nil
494+
}
495+
sess.mu.Unlock()
496+
if err := proxy.Restart(ctx); err != nil {
497+
return nil, map[string]any{"restarted": false, "message": fmt.Sprintf("failed to restart iwdp: %v", err)}, nil
498+
}
499+
return nil, map[string]any{"restarted": true, "message": "ios-webkit-debug-proxy restarted. Use list_devices and select_page to reconnect."}, nil
500+
})
501+
486502
// --- Device/Page management ---
487503
mcp.AddTool(server, &mcp.Tool{
488504
Name: "list_devices", Description: "List connected iOS devices (from iwdp listing port 9221). Each device's URL shows which port to use for list_pages.",
@@ -1532,17 +1548,21 @@ func registerTools(server *mcp.Server) {
15321548
})
15331549

15341550
mcp.AddTool(server, &mcp.Tool{
1535-
Name: "heap_snapshot", Description: "Take a heap snapshot",
1551+
Name: "heap_snapshot", Description: "Take a heap snapshot and save to file. Warning: can be very large (50-200+ MB) on heavy pages and may take minutes.",
15361552
}, func(ctx context.Context, req *mcp.CallToolRequest, _ HeapSnapshotInput) (*mcp.CallToolResult, any, error) {
15371553
c, err := getClient(ctx)
15381554
if err != nil {
1539-
return nil, RawOutput{}, err
1555+
return nil, TextOutput{}, err
15401556
}
1541-
result, err := tools.HeapSnapshot(ctx, c)
1557+
filePath, err := tools.HeapSnapshot(ctx, c)
15421558
if err != nil {
1543-
return nil, RawOutput{}, err
1559+
return nil, TextOutput{}, err
15441560
}
1545-
return nil, RawOutput{Result: result}, nil
1561+
return &mcp.CallToolResult{
1562+
Content: []mcp.Content{&mcp.TextContent{
1563+
Text: fmt.Sprintf("Heap snapshot saved to %s — use the Read tool to view it.", filePath),
1564+
}},
1565+
}, TextOutput{Text: filePath}, nil
15461566
})
15471567

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

internal/proxy/proxy.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,23 @@ func Start(ctx context.Context) error {
5959
}
6060

6161
// EnsureRunning checks if iwdp is running and starts it if not.
62+
// If iwdp crashed (e.g., after a large heap snapshot), this will restart it.
6263
func EnsureRunning(ctx context.Context) error {
6364
if IsRunning() {
6465
return nil
6566
}
6667
return Start(ctx)
6768
}
6869

70+
// Restart kills any existing iwdp process and starts a fresh one.
71+
// Use this when iwdp is in a bad state (e.g., after a WebSocket crash).
72+
func Restart(ctx context.Context) error {
73+
// Best-effort kill — ignore errors if not running
74+
_ = exec.Command("pkill", "-f", "ios_webkit_debug_proxy").Run()
75+
time.Sleep(500 * time.Millisecond)
76+
return Start(ctx)
77+
}
78+
6979
// ListDevices fetches connected devices from the listing port (9221).
7080
// Each device entry contains a URL like "localhost:9222" indicating
7181
// which port to query for that device's pages.

internal/tools/memory.go

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,18 @@ package tools
33
import (
44
"context"
55
"encoding/json"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"time"
610

711
"github.com/nnemirovsky/iwdp-mcp/internal/webkit"
812
)
913

14+
// HeapSnapshotTimeout is the maximum time to wait for a heap snapshot.
15+
// Large pages can take a long time to serialize their heap.
16+
var HeapSnapshotTimeout = 5 * time.Minute
17+
1018
// MemoryStartTracking starts memory tracking.
1119
func MemoryStartTracking(ctx context.Context, client *webkit.Client) error {
1220
_, err := client.Send(ctx, "Memory.startTracking", nil)
@@ -19,13 +27,52 @@ func MemoryStopTracking(ctx context.Context, client *webkit.Client) error {
1927
return err
2028
}
2129

22-
// HeapSnapshot takes a heap snapshot and returns the raw result.
23-
func HeapSnapshot(ctx context.Context, client *webkit.Client) (json.RawMessage, error) {
30+
// HeapSnapshot takes a heap snapshot and saves it to a temp file.
31+
// Heap snapshots can be 50-200+ MB on heavy pages, so we stream directly
32+
// to disk instead of holding the entire snapshot in memory.
33+
func HeapSnapshot(ctx context.Context, client *webkit.Client) (string, error) {
34+
ctx, cancel := context.WithTimeout(ctx, HeapSnapshotTimeout)
35+
defer cancel()
36+
2437
result, err := client.Send(ctx, "Heap.snapshot", nil)
2538
if err != nil {
26-
return nil, err
39+
return "", fmt.Errorf("heap snapshot failed (page may be too large): %w", err)
40+
}
41+
42+
tmpDir := filepath.Join(os.TempDir(), "iwdp-mcp")
43+
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
44+
return "", fmt.Errorf("creating temp dir: %w", err)
45+
}
46+
f, err := os.CreateTemp(tmpDir, "heap-snapshot-*.json")
47+
if err != nil {
48+
return "", fmt.Errorf("creating temp file: %w", err)
49+
}
50+
defer func() { _ = f.Close() }()
51+
52+
// Extract snapshotData string if present, otherwise write raw result
53+
var snap struct {
54+
SnapshotData json.RawMessage `json:"snapshotData"`
2755
}
28-
return result, nil
56+
if json.Unmarshal(result, &snap) == nil && len(snap.SnapshotData) > 0 {
57+
// snapshotData is a JSON string — unquote it to get the raw snapshot
58+
var data string
59+
if json.Unmarshal(snap.SnapshotData, &data) == nil {
60+
if _, err := f.WriteString(data); err != nil {
61+
return "", fmt.Errorf("writing snapshot: %w", err)
62+
}
63+
} else {
64+
// Not a string, write as-is
65+
if _, err := f.Write(snap.SnapshotData); err != nil {
66+
return "", fmt.Errorf("writing snapshot: %w", err)
67+
}
68+
}
69+
} else {
70+
if _, err := f.Write(result); err != nil {
71+
return "", fmt.Errorf("writing snapshot: %w", err)
72+
}
73+
}
74+
75+
return f.Name(), nil
2976
}
3077

3178
// HeapStartTracking starts heap tracking.

internal/tools/tools_domains_test.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package tools_test
33
import (
44
"context"
55
"encoding/json"
6+
"os"
67
"testing"
78
"time"
89

@@ -481,24 +482,23 @@ func TestHeapSnapshot(t *testing.T) {
481482
mock, client := setup(t)
482483
mock.HandleFunc("Heap.snapshot", map[string]interface{}{
483484
"snapshotData": "snapshot-content",
484-
"title": "Heap Snapshot",
485485
})
486486

487487
ctx := context.Background()
488-
result, err := tools.HeapSnapshot(ctx, client)
488+
filePath, err := tools.HeapSnapshot(ctx, client)
489489
if err != nil {
490490
t.Fatalf("HeapSnapshot returned error: %v", err)
491491
}
492-
if result == nil {
493-
t.Fatal("expected non-nil result")
492+
if filePath == "" {
493+
t.Fatal("expected non-empty file path")
494494
}
495-
// Verify the raw JSON contains expected data.
496-
var parsed map[string]interface{}
497-
if err := json.Unmarshal(result, &parsed); err != nil {
498-
t.Fatalf("failed to parse result JSON: %v", err)
495+
defer func() { _ = os.Remove(filePath) }()
496+
data, err := os.ReadFile(filePath)
497+
if err != nil {
498+
t.Fatalf("failed to read snapshot file: %v", err)
499499
}
500-
if parsed["title"] != "Heap Snapshot" {
501-
t.Errorf("expected title %q, got %v", "Heap Snapshot", parsed["title"])
500+
if string(data) != "snapshot-content" {
501+
t.Errorf("expected %q, got %q", "snapshot-content", string(data))
502502
}
503503
}
504504

internal/webkit/client.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,10 @@ func NewClientWithDialer(ctx context.Context, wsURL string, dialer *websocket.Di
8181
return c, nil
8282
}
8383

84-
// maxReadSize is the maximum WebSocket message size the client will accept (64 MB).
85-
const maxReadSize = 64 * 1024 * 1024
84+
// maxReadSize is the maximum WebSocket message size the client will accept (512 MB).
85+
// Heap snapshots on heavy pages can be 50-200+ MB, and Target-based routing adds
86+
// ~30% overhead from JSON string escaping, so we need a generous limit.
87+
const maxReadSize = 512 * 1024 * 1024
8688

8789
func (c *Client) connect(ctx context.Context) error {
8890
conn, _, err := c.Dialer.DialContext(ctx, c.url, nil)

iwdp-mcp

11.5 MB
Binary file not shown.

0 commit comments

Comments
 (0)