fix(unrealinsights): harden capture session replacement

This commit is contained in:
aimidi
2026-04-16 19:41:41 +08:00
parent 66d0b54392
commit cd82c74db4
6 changed files with 189 additions and 18 deletions
@@ -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")