Skip to content

Commit ab957fd

Browse files
committed
docs: document immediate-reload stress test and streamline stress tool (immediate refresh, precondition SHA, EOF edits); revert to manage_script.read for compatibility
1 parent c0fb8ee commit ab957fd

File tree

2 files changed

+143
-79
lines changed

2 files changed

+143
-79
lines changed

README-DEV.md

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,33 +68,54 @@ Note: In recent builds, the Python server sources are also bundled inside the pa
6868

6969
## MCP Bridge Stress Test
7070

71-
An on-demand stress utility exercises the MCP bridge with multiple concurrent clients while triggering periodic asset refreshes and script reloads.
71+
An on-demand stress utility exercises the MCP bridge with multiple concurrent clients while triggering real script reloads via immediate script edits (no menu calls required).
7272

7373
### Script
7474
- `tools/stress_mcp.py`
7575

7676
### What it does
7777
- Starts N TCP clients against the Unity MCP bridge (default port auto-discovered from `~/.unity-mcp/unity-mcp-status-*.json`).
78-
- Sends a mix of framed `ping`, `execute_menu_item` (e.g., `Assets/Refresh`), and small `manage_gameobject` requests.
79-
- In parallel, toggles a comment in a large C# file to encourage domain reloads, and triggers `Assets/Refresh` via MCP.
78+
- Sends lightweight framed `ping` keepalives to maintain concurrency.
79+
- In parallel, appends a unique marker comment to a target C# file using `manage_script.apply_text_edits` with:
80+
- `options.refresh = "immediate"` to force an import/compile immediately (triggers domain reload), and
81+
- `precondition_sha256` computed from the current file contents to avoid drift.
82+
- Uses EOF insertion to avoid header/`using`-guard edits.
8083

8184
### Usage (local)
8285
```bash
83-
python3 tools/stress_mcp.py --duration 60 --clients 8
86+
# Recommended: use the included large script in the test project
87+
python3 tools/stress_mcp.py \
88+
--duration 60 \
89+
--clients 8 \
90+
--unity-file "TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs"
8491
```
8592

8693
Flags:
8794
- `--project` Unity project path (auto-detected to the included test project by default)
88-
- `--unity-file` C# file to toggle (defaults to the long test script)
95+
- `--unity-file` C# file to edit (defaults to the long test script)
8996
- `--clients` number of concurrent clients (default 10)
9097
- `--duration` seconds to run (default 60)
9198

92-
Expected outcome:
99+
### Expected outcome
93100
- No Unity Editor crashes during reload churn
94-
- Clients reconnect cleanly after reloads
95-
- Script prints a JSON summary of request counts and disconnects
96-
97-
CI guidance:
101+
- Immediate reloads after each applied edit (no `Assets/Refresh` menu calls)
102+
- Some transient disconnects or a few failed calls may occur during domain reload; the tool retries and continues
103+
- JSON summary printed at the end, e.g.:
104+
- `{"port": 6400, "stats": {"pings": 28566, "applies": 69, "disconnects": 0, "errors": 0}}`
105+
106+
### Notes and troubleshooting
107+
- Immediate vs debounced:
108+
- The tool sets `options.refresh = "immediate"` so changes compile instantly. If you only need churn (not per-edit confirmation), switch to debounced to reduce mid-reload failures.
109+
- Precondition required:
110+
- `apply_text_edits` requires `precondition_sha256` on larger files. The tool reads the file first to compute the SHA.
111+
- Edit location:
112+
- To avoid header guards or complex ranges, the tool appends a one-line marker at EOF each cycle.
113+
- Read API:
114+
- The bridge currently supports `manage_script.read` for file reads. You may see a deprecation warning; it's harmless for this internal tool.
115+
- Transient failures:
116+
- Occasional `apply_errors` often indicate the connection reloaded mid-reply. Edits still typically apply; the loop continues on the next iteration.
117+
118+
### CI guidance
98119
- Keep this out of default PR CI due to Unity/editor requirements and runtime variability.
99120
- Optionally run it as a manual workflow or nightly job on a Unity-capable runner.
100121

tools/stress_mcp.py

Lines changed: 112 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@
33
import argparse
44
import json
55
import os
6-
import random
7-
import socket
86
import struct
9-
import sys
107
import time
118
from pathlib import Path
129

@@ -78,27 +75,8 @@ def make_ping_frame() -> bytes:
7875

7976

8077
def make_execute_menu_item(menu_path: str) -> bytes:
81-
payload = {
82-
"type": "execute_menu_item",
83-
"params": {"action": "execute", "menu_path": menu_path},
84-
}
85-
return json.dumps(payload).encode("utf-8")
86-
87-
88-
def make_manage_gameobject_modify_dummy(target_name: str) -> bytes:
89-
payload = {
90-
"type": "manage_gameobject",
91-
"params": {
92-
"action": "modify",
93-
"target": target_name,
94-
"search_method": "by_name",
95-
# Intentionally small and sometimes invalid to exercise error paths safely
96-
"componentProperties": {
97-
"Transform": {"localScale": {"x": 1.0, "y": 1.0, "z": 1.0}},
98-
"Rigidbody": {"velocity": "invalid_type"},
99-
},
100-
},
101-
}
78+
# Retained for manual debugging; not used in normal stress runs
79+
payload = {"type": "execute_menu_item", "params": {"action": "execute", "menu_path": menu_path}}
10280
return json.dumps(payload).encode("utf-8")
10381

10482

@@ -112,26 +90,13 @@ async def client_loop(idx: int, host: str, port: int, stop_time: float, stats: d
11290
await write_frame(writer, make_ping_frame())
11391
_ = await read_frame(reader) # ignore content
11492

115-
# Main activity loop
93+
# Main activity loop (keep-alive + light load). Edit spam handled by reload_churn_task.
11694
while time.time() < stop_time:
117-
r = random.random()
118-
if r < 0.70:
119-
# Ping
120-
await write_frame(writer, make_ping_frame())
121-
_ = await read_frame(reader)
122-
stats["pings"] += 1
123-
elif r < 0.90:
124-
# Lightweight menu execute: Assets/Refresh
125-
await write_frame(writer, make_execute_menu_item("Assets/Refresh"))
126-
_ = await read_frame(reader)
127-
stats["menus"] += 1
128-
else:
129-
# Small manage_gameobject request (may legitimately error if target not found)
130-
await write_frame(writer, make_manage_gameobject_modify_dummy("__MCP_Stress_Object__"))
131-
_ = await read_frame(reader)
132-
stats["mods"] += 1
133-
134-
await asyncio.sleep(0.01)
95+
# Ping-only; edits are sent via reload_churn_task to avoid console spam
96+
await write_frame(writer, make_ping_frame())
97+
_ = await read_frame(reader)
98+
stats["pings"] += 1
99+
await asyncio.sleep(0.02)
135100

136101
except (ConnectionError, OSError, asyncio.IncompleteReadError):
137102
stats["disconnects"] += 1
@@ -150,38 +115,116 @@ async def client_loop(idx: int, host: str, port: int, stop_time: float, stats: d
150115
pass
151116

152117

153-
async def reload_churn_task(project_path: str, stop_time: float, unity_file: str | None, host: str, port: int):
154-
# Toggle a comment in a large .cs file to force a recompilation; then request Assets/Refresh
118+
async def reload_churn_task(project_path: str, stop_time: float, unity_file: str | None, host: str, port: int, stats: dict):
119+
# Use script edit tool to touch a C# file, which triggers compilation reliably
155120
path = Path(unity_file) if unity_file else None
156-
toggle = True
121+
seq = 0
157122
while time.time() < stop_time:
158123
try:
159124
if path and path.exists():
160-
s = path.read_text(encoding="utf-8", errors="ignore")
161-
marker_on = "// MCP_STRESS_ON"
162-
marker_off = "// MCP_STRESS_OFF"
163-
if toggle:
164-
if marker_on not in s:
165-
path.write_text(s + ("\n" if not s.endswith("\n") else "") + marker_on + "\n", encoding="utf-8")
166-
else:
167-
if marker_off not in s:
168-
path.write_text(s + ("\n" if not s.endswith("\n") else "") + marker_off + "\n", encoding="utf-8")
169-
toggle = not toggle
170-
171-
# Ask Unity to refresh assets (safe, Editor main thread)
172-
try:
173-
reader, writer = await asyncio.open_connection(host, port)
174-
await do_handshake(reader)
175-
await write_frame(writer, make_execute_menu_item("Assets/Refresh"))
176-
_ = await read_frame(reader)
177-
writer.close()
178-
await writer.wait_closed()
179-
except Exception:
180-
pass
125+
# Build a tiny ApplyTextEdits request that toggles a trailing comment
126+
relative = None
127+
try:
128+
# Derive Unity-relative path under Assets/
129+
p = str(path)
130+
idx = p.rfind("Assets/")
131+
if idx >= 0:
132+
relative = p[idx:]
133+
except Exception:
134+
pass
135+
136+
if relative:
137+
# Derive name and directory for ManageScript and compute precondition SHA + EOF position
138+
name_base = Path(relative).stem
139+
dir_path = str(Path(relative).parent).replace('\\', '/')
140+
141+
# 1) Read current contents via manage_script.read to compute SHA and true EOF location
142+
try:
143+
reader, writer = await asyncio.open_connection(host, port)
144+
await do_handshake(reader)
145+
read_payload = {
146+
"type": "manage_script",
147+
"params": {
148+
"action": "read",
149+
"name": name_base,
150+
"path": dir_path
151+
}
152+
}
153+
await write_frame(writer, json.dumps(read_payload).encode("utf-8"))
154+
resp = await read_frame(reader)
155+
writer.close()
156+
await writer.wait_closed()
157+
158+
read_obj = json.loads(resp.decode("utf-8", errors="ignore"))
159+
result = read_obj.get("result", read_obj) if isinstance(read_obj, dict) else {}
160+
if not result.get("success"):
161+
stats["apply_errors"] = stats.get("apply_errors", 0) + 1
162+
await asyncio.sleep(0.5)
163+
continue
164+
data_obj = result.get("data", {})
165+
contents = data_obj.get("contents") or ""
166+
except Exception:
167+
stats["apply_errors"] = stats.get("apply_errors", 0) + 1
168+
await asyncio.sleep(0.5)
169+
continue
170+
171+
# Compute SHA and EOF insertion point
172+
import hashlib
173+
sha = hashlib.sha256(contents.encode("utf-8")).hexdigest()
174+
lines = contents.splitlines(keepends=True)
175+
# Insert at true EOF (safe against header guards)
176+
end_line = len(lines) + 1 # 1-based exclusive end
177+
end_col = 1
178+
179+
# Build a unique marker append; ensure it begins with a newline if needed
180+
marker = f"// MCP_STRESS seq={seq} time={int(time.time())}"
181+
seq += 1
182+
insert_text = ("\n" if not contents.endswith("\n") else "") + marker + "\n"
183+
184+
# 2) Apply text edits with immediate refresh and precondition
185+
apply_payload = {
186+
"type": "manage_script",
187+
"params": {
188+
"action": "apply_text_edits",
189+
"name": name_base,
190+
"path": dir_path,
191+
"edits": [
192+
{
193+
"startLine": end_line,
194+
"startCol": end_col,
195+
"endLine": end_line,
196+
"endCol": end_col,
197+
"newText": insert_text
198+
}
199+
],
200+
"precondition_sha256": sha,
201+
"options": {"refresh": "immediate", "validate": "standard"}
202+
}
203+
}
204+
205+
try:
206+
reader, writer = await asyncio.open_connection(host, port)
207+
await do_handshake(reader)
208+
await write_frame(writer, json.dumps(apply_payload).encode("utf-8"))
209+
resp = await read_frame(reader)
210+
try:
211+
data = json.loads(resp.decode("utf-8", errors="ignore"))
212+
result = data.get("result", data) if isinstance(data, dict) else {}
213+
ok = bool(result.get("success", False))
214+
if ok:
215+
stats["applies"] = stats.get("applies", 0) + 1
216+
else:
217+
stats["apply_errors"] = stats.get("apply_errors", 0) + 1
218+
except Exception:
219+
stats["apply_errors"] = stats.get("apply_errors", 0) + 1
220+
writer.close()
221+
await writer.wait_closed()
222+
except Exception:
223+
stats["apply_errors"] = stats.get("apply_errors", 0) + 1
181224

182225
except Exception:
183226
pass
184-
await asyncio.sleep(10.0)
227+
await asyncio.sleep(1.0)
185228

186229

187230
async def main():
@@ -204,7 +247,7 @@ async def main():
204247
tasks.append(asyncio.create_task(client_loop(i, args.host, port, stop_time, stats)))
205248

206249
# Spawn reload churn task
207-
tasks.append(asyncio.create_task(reload_churn_task(args.project, stop_time, args.unity_file, args.host, port)))
250+
tasks.append(asyncio.create_task(reload_churn_task(args.project, stop_time, args.unity_file, args.host, port, stats)))
208251

209252
await asyncio.gather(*tasks, return_exceptions=True)
210253
print(json.dumps({"port": port, "stats": stats}, indent=2))

0 commit comments

Comments
 (0)