mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-10-31 20:01:32 +08:00
fix(unrealinsights): harden capture session replacement
This commit is contained in:
@@ -49,6 +49,12 @@ cli-anything-unrealinsights --json capture start `
|
||||
--engine-root 'D:\Program Files\Epic Games\UE_5.5' `
|
||||
--output-trace D:\captures\live_session.utrace
|
||||
|
||||
# If a tracked capture is already running, replace it explicitly
|
||||
cli-anything-unrealinsights --json capture start --replace `
|
||||
--project 'D:\Projects\MyGame\MyGame.uproject' `
|
||||
--engine-root 'D:\Program Files\Epic Games\UE_5.5' `
|
||||
--output-trace D:\captures\replacement_session.utrace
|
||||
|
||||
# Check current capture status
|
||||
cli-anything-unrealinsights --json capture status
|
||||
|
||||
@@ -167,6 +173,7 @@ cli-anything-unrealinsights --json capture stop
|
||||
Behavior:
|
||||
|
||||
- `capture start` launches the target in the background and persists the tracked PID/trace/session metadata
|
||||
- `capture start` refuses to overwrite a still-running tracked session unless you pass `--replace`
|
||||
- `capture status` reads the persisted session state and reports whether the process is still running and how large the trace has grown
|
||||
- `capture snapshot` creates a best-effort copy of the current `.utrace` without requiring you to end the session first
|
||||
- `capture stop` performs a best-effort stop of the tracked process tree launched by the harness
|
||||
|
||||
@@ -155,6 +155,14 @@ def run_capture(
|
||||
trace_path = Path(output_trace).expanduser().resolve()
|
||||
trace_exists = trace_path.is_file()
|
||||
trace_size = trace_path.stat().st_size if trace_exists else None
|
||||
waited = bool(result.get("waited", wait))
|
||||
succeeded = True
|
||||
if waited:
|
||||
succeeded = (
|
||||
not bool(result.get("timed_out"))
|
||||
and result.get("exit_code") == 0
|
||||
and trace_exists
|
||||
)
|
||||
|
||||
result.update(
|
||||
{
|
||||
@@ -164,7 +172,7 @@ def run_capture(
|
||||
"channels": channels,
|
||||
"trace_exists": trace_exists,
|
||||
"trace_size": trace_size,
|
||||
"succeeded": bool(trace_exists) if wait else True,
|
||||
"succeeded": succeeded,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -78,6 +78,9 @@ cli-anything-unrealinsights --json capture stop
|
||||
This is the preferred flow when an agent needs to start profiling now and stop
|
||||
or snapshot later in a follow-up turn.
|
||||
|
||||
If a tracked capture session is still running, `capture start` now requires
|
||||
`--replace` so the previous process is stopped before a new one is launched.
|
||||
|
||||
### Offline exporters
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Test Inventory Plan
|
||||
|
||||
- `test_core.py`: 46 unit tests planned
|
||||
- `test_core.py`: 49 unit tests planned
|
||||
- `test_full_e2e.py`: 10 E2E tests planned
|
||||
|
||||
## Unit Test Plan
|
||||
@@ -20,7 +20,7 @@
|
||||
- Validate `-ExecCmds=` joining semantics
|
||||
- Validate `--project + --engine-root` convenience resolution
|
||||
- Validate tracked capture status, snapshot, and stop flows
|
||||
- Planned tests: 11
|
||||
- Planned tests: 12
|
||||
|
||||
### `core/export.py`
|
||||
- Validate exporter command strings for all supported exporters
|
||||
@@ -35,7 +35,7 @@
|
||||
- Validate REPL session trace state
|
||||
- Validate capture convenience-layer argument handling
|
||||
- Validate `capture status`, `capture stop`, and `capture snapshot` JSON flows
|
||||
- Planned tests: 10
|
||||
- Planned tests: 12
|
||||
|
||||
## E2E Test Plan
|
||||
|
||||
@@ -94,7 +94,7 @@ cli-anything-unrealinsights --json backend info
|
||||
|
||||
### Result summary
|
||||
|
||||
- `test_core.py`: 46 passed
|
||||
- `test_core.py`: 49 passed
|
||||
- `test_full_e2e.py`: 1 passed, 9 skipped
|
||||
- Manual smoke: installed entrypoint resolved local UE 5.5 binaries successfully
|
||||
|
||||
|
||||
@@ -265,6 +265,29 @@ class TestCaptureCore:
|
||||
assert any(arg.startswith("-tracefile=") for arg in command)
|
||||
assert any(arg.startswith("-ExecCmds=") for arg in command)
|
||||
|
||||
@patch("cli_anything.unrealinsights.core.capture.backend.run_process")
|
||||
def test_run_capture_wait_requires_clean_exit(self, mock_run_process, tmp_path):
|
||||
from cli_anything.unrealinsights.core.capture import run_capture
|
||||
|
||||
exe = tmp_path / "Game.exe"
|
||||
exe.write_text("x", encoding="utf-8")
|
||||
trace = tmp_path / "capture.utrace"
|
||||
trace.write_text("partial-trace", encoding="utf-8")
|
||||
|
||||
mock_run_process.return_value = {
|
||||
"command": [str(exe.resolve())],
|
||||
"waited": True,
|
||||
"timed_out": False,
|
||||
"exit_code": 1,
|
||||
"stdout": "",
|
||||
"stderr": "boom",
|
||||
"pid": None,
|
||||
}
|
||||
|
||||
result = run_capture(str(exe), str(trace), wait=True)
|
||||
assert result["trace_exists"] is True
|
||||
assert result["succeeded"] is False
|
||||
|
||||
def test_capture_status(self):
|
||||
from cli_anything.unrealinsights.core.capture import capture_status
|
||||
from cli_anything.unrealinsights.core.session import UnrealInsightsSession
|
||||
@@ -659,3 +682,111 @@ class TestCaptureCLIConvenience:
|
||||
assert result.exit_code == 0
|
||||
session = UnrealInsightsSession.load()
|
||||
assert session.capture_pid == 2468
|
||||
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.capture_status")
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.run_capture")
|
||||
def test_capture_start_refuses_running_session_without_replace(self, mock_run_capture, mock_capture_status):
|
||||
from cli_anything.unrealinsights.unrealinsights_cli import cli
|
||||
|
||||
mock_capture_status.return_value = {
|
||||
"active": True,
|
||||
"pid": 1357,
|
||||
"running": True,
|
||||
"target_exe": "C:/UE/UnrealEditor.exe",
|
||||
"target_args": [],
|
||||
"project_path": "C:/Project.uproject",
|
||||
"engine_root": "C:/UE_5.5",
|
||||
"trace_path": "C:/capture.utrace",
|
||||
"trace_exists": True,
|
||||
"trace_size": 1024,
|
||||
"channels": "default",
|
||||
"started_at": "2026-04-16T00:00:00+00:00",
|
||||
}
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--json",
|
||||
"capture",
|
||||
"start",
|
||||
"--project",
|
||||
"C:/Project.uproject",
|
||||
"--engine-root",
|
||||
"C:/UE_5.5",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
data = json.loads(result.output)
|
||||
assert "--replace" in data["error"]
|
||||
mock_run_capture.assert_not_called()
|
||||
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.stop_capture")
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.capture_status")
|
||||
@patch("cli_anything.unrealinsights.unrealinsights_cli.run_capture")
|
||||
def test_capture_start_replace_stops_existing_session(self, mock_run_capture, mock_capture_status, mock_stop_capture, tmp_path):
|
||||
from cli_anything.unrealinsights.unrealinsights_cli import cli
|
||||
from cli_anything.unrealinsights.core.session import UnrealInsightsSession
|
||||
|
||||
editor = tmp_path / "UE_5.5" / "Engine" / "Binaries" / "Win64" / "UnrealEditor.exe"
|
||||
editor.parent.mkdir(parents=True)
|
||||
editor.write_text("x", encoding="utf-8")
|
||||
project = tmp_path / "Project" / "MyGame.uproject"
|
||||
project.parent.mkdir(parents=True)
|
||||
project.write_text("{}", encoding="utf-8")
|
||||
|
||||
mock_capture_status.return_value = {
|
||||
"active": True,
|
||||
"pid": 1357,
|
||||
"running": True,
|
||||
"target_exe": str(editor.resolve()),
|
||||
"target_args": [str(project.resolve())],
|
||||
"project_path": str(project.resolve()),
|
||||
"engine_root": str((tmp_path / "UE_5.5").resolve()),
|
||||
"trace_path": str((tmp_path / "previous.utrace").resolve()),
|
||||
"trace_exists": True,
|
||||
"trace_size": 1024,
|
||||
"channels": "default",
|
||||
"started_at": "2026-04-16T00:00:00+00:00",
|
||||
}
|
||||
mock_stop_capture.return_value = {
|
||||
"termination": {"requested_pid": 1357, "stopped": True, "exit_code": 0},
|
||||
"capture": {"active": False},
|
||||
}
|
||||
mock_run_capture.return_value = {
|
||||
"command": [str(editor.resolve()), str(project.resolve()), "-trace=default"],
|
||||
"waited": False,
|
||||
"timed_out": False,
|
||||
"exit_code": None,
|
||||
"stdout": None,
|
||||
"stderr": None,
|
||||
"pid": 2468,
|
||||
"target_exe": str(editor.resolve()),
|
||||
"target_args": [str(project.resolve())],
|
||||
"trace_path": str((tmp_path / "capture.utrace").resolve()),
|
||||
"channels": "default",
|
||||
"trace_exists": False,
|
||||
"trace_size": None,
|
||||
"succeeded": True,
|
||||
}
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--json",
|
||||
"capture",
|
||||
"start",
|
||||
"--replace",
|
||||
"--project",
|
||||
str(project),
|
||||
"--engine-root",
|
||||
str(tmp_path / "UE_5.5"),
|
||||
"--output-trace",
|
||||
str(tmp_path / "capture.utrace"),
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_stop_capture.assert_called_once()
|
||||
session = UnrealInsightsSession.load()
|
||||
assert session.capture_pid == 2468
|
||||
|
||||
@@ -370,6 +370,23 @@ def capture_run(ctx, target_exe, project, engine_root, target_args, output_trace
|
||||
_handle_exc(ctx, exc)
|
||||
|
||||
|
||||
def _prepare_capture_start(ctx: click.Context, replace: bool):
|
||||
session = _get_session(ctx)
|
||||
status = capture_status(session)
|
||||
if status.get("active") and status.get("running"):
|
||||
if not replace:
|
||||
raise RuntimeError(
|
||||
"A capture session is already running. Use `capture status` to inspect it, "
|
||||
"`capture stop` to end it, or rerun `capture start` with `--replace`."
|
||||
)
|
||||
|
||||
stop_result = stop_capture(session)
|
||||
if not stop_result.get("termination", {}).get("stopped"):
|
||||
raise RuntimeError("Failed to stop the existing capture session before starting a replacement.")
|
||||
elif status.get("active"):
|
||||
session.clear_capture()
|
||||
|
||||
|
||||
@capture_group.command("start")
|
||||
@click.argument("target_exe", required=False, type=click.Path(exists=False))
|
||||
@click.option("--project", type=click.Path(exists=False), default=None, help="Path to a .uproject file.")
|
||||
@@ -383,21 +400,26 @@ def capture_run(ctx, target_exe, project, engine_root, target_args, output_trace
|
||||
@click.option("--output-trace", type=click.Path(exists=False), default=None, help="Output .utrace path.")
|
||||
@click.option("--channels", default=DEFAULT_CHANNELS, show_default=True, help="Comma-separated UE trace channels.")
|
||||
@click.option("--exec-cmd", "exec_cmds", multiple=True, help="Startup UE console command for -ExecCmds.")
|
||||
@click.option("--replace", is_flag=True, help="Stop the currently tracked capture session before starting a new one.")
|
||||
@click.pass_context
|
||||
def capture_start(ctx, target_exe, project, engine_root, target_args, output_trace, channels, exec_cmds):
|
||||
def capture_start(ctx, target_exe, project, engine_root, target_args, output_trace, channels, exec_cmds, replace):
|
||||
"""Launch a traced target in the background and track the session."""
|
||||
ctx.invoke(
|
||||
capture_run,
|
||||
target_exe=target_exe,
|
||||
project=project,
|
||||
engine_root=engine_root,
|
||||
target_args=target_args,
|
||||
output_trace=output_trace,
|
||||
channels=channels,
|
||||
exec_cmds=exec_cmds,
|
||||
wait=False,
|
||||
timeout=None,
|
||||
)
|
||||
try:
|
||||
_prepare_capture_start(ctx, replace=replace)
|
||||
ctx.invoke(
|
||||
capture_run,
|
||||
target_exe=target_exe,
|
||||
project=project,
|
||||
engine_root=engine_root,
|
||||
target_args=target_args,
|
||||
output_trace=output_trace,
|
||||
channels=channels,
|
||||
exec_cmds=exec_cmds,
|
||||
wait=False,
|
||||
timeout=None,
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_exc(ctx, exc)
|
||||
|
||||
|
||||
@capture_group.command("status")
|
||||
|
||||
Reference in New Issue
Block a user