From cd82c74db4dcc6d4dfdb4fb6ea83812cd252ff22 Mon Sep 17 00:00:00 2001 From: aimidi Date: Thu, 16 Apr 2026 19:41:41 +0800 Subject: [PATCH] fix(unrealinsights): harden capture session replacement --- .../cli_anything/unrealinsights/README.md | 7 + .../unrealinsights/core/capture.py | 10 +- .../unrealinsights/skills/SKILL.md | 3 + .../cli_anything/unrealinsights/tests/TEST.md | 8 +- .../unrealinsights/tests/test_core.py | 131 ++++++++++++++++++ .../unrealinsights/unrealinsights_cli.py | 48 +++++-- 6 files changed, 189 insertions(+), 18 deletions(-) diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md b/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md index 1153588d4..1539ba0e9 100644 --- a/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md +++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md @@ -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 diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/core/capture.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/capture.py index e873a1bf8..6bfa09704 100644 --- a/unrealinsights/agent-harness/cli_anything/unrealinsights/core/capture.py +++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/capture.py @@ -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 diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md b/unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md index b865a28b8..5986f7444 100644 --- a/unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md +++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md @@ -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 diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/TEST.md b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/TEST.md index 5220c5b5e..6176cb854 100644 --- a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/TEST.md +++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/TEST.md @@ -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 diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_core.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_core.py index 8e1e2683e..f10a3aea7 100644 --- a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_core.py +++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_core.py @@ -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 diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py index c8b1a50a1..c5e7d415e 100644 --- a/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py +++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py @@ -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")