Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions src/rdc/capture_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import logging
import shutil
import sys
import time
from collections.abc import Mapping
from dataclasses import dataclass, fields
Expand Down Expand Up @@ -117,6 +118,30 @@ def _get_pid_for_ident(rd: Any, ident: int) -> int:
tc.Shutdown()


def _inject_failure_hint() -> str:
if sys.platform == "darwin":
return (
"SIP may be blocking injection; "
"disable SIP for the target or attach via renderdoccmd; "
"for child processes, use --hook-children"
)
if sys.platform == "win32":
return "try running rdc as Administrator; for child processes, use --hook-children"
return (
"process may be blocked by AppArmor/SELinux or missing privileges; "
"for child processes, use --hook-children"
)


def _remote_inject_failure_hint() -> str:
"""OS-neutral hint for remote inject failures (target OS is unknown from host)."""
return (
"check target permissions (root/Administrator, AppArmor/SELinux/SIP), "
"firewall, and that the target's graphics API matches renderdoc capabilities; "
"for child processes, use --hook-children"
)


def execute_and_capture(
rd: Any,
app: str,
Expand Down Expand Up @@ -161,16 +186,17 @@ def execute_and_capture(
app_path = Path(workdir) / app_path
app = str(app_path.resolve())

_inj_hint = _inject_failure_hint()
result = rd.ExecuteAndInject(app, workdir or "", args, [], output, opts, wait_for_exit)
if result.result != 0:
return CaptureResult(error=f"inject failed (code {result.result})")
return CaptureResult(error=f"inject failed (code {result.result}) -- hint: {_inj_hint}")

ident = result.ident
if ident == 0:
# Some renderdoc builds return ident=0 even on success; discover via enumeration.
ident = _discover_latest_target(rd, timeout=5.0)
if ident == 0:
return CaptureResult(error="inject returned zero ident")
return CaptureResult(error=f"inject returned zero ident -- hint: {_inj_hint}")
log.debug("discovered target ident %d via enumeration", ident)

if trigger:
Expand Down
58 changes: 44 additions & 14 deletions src/rdc/daemon_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ def _load_replay(state: DaemonState) -> str | None:
result, controller = cap.OpenCapture(opts, None)
if result != rd.ResultCode.Succeeded:
cap.Shutdown()
return f"OpenCapture failed: {result}"
return f"OpenCapture failed: {result} -- hint: try 'rdc open --proxy HOST:PORT'"

state.cap = cap
state.rd = rd
Expand Down Expand Up @@ -338,12 +338,20 @@ def _load_remote_replay(state: DaemonState, remote_url: str) -> str | None:
state.is_remote = True
state.remote_url = remote_url

step = "init"
controller = None
cap = None
try:
step = "stage capture"
local_capture = Path(state.capture)
if local_capture.exists():
remote_path = remote.CopyCaptureToRemote(
str(local_capture), make_progress_cb("uploading")
)
try:
remote_path = remote.CopyCaptureToRemote(
str(local_capture), make_progress_cb("uploading")
)
except (RuntimeError, OSError) as exc:
remote.ShutdownConnection()
return f"remote replay setup failed at step 'upload capture': {exc}"
state.local_capture_path = str(local_capture)
else:
remote_path = state.capture
Expand All @@ -354,23 +362,31 @@ def _load_remote_replay(state: DaemonState, remote_url: str) -> str | None:
remote.CopyCaptureFromRemote(
remote_path, str(local_tmp), make_progress_cb("downloading")
)
except Exception as exc: # noqa: BLE001
except (RuntimeError, OSError) as exc:
shutil.rmtree(local_tmp.parent, ignore_errors=True)
remote.ShutdownConnection()
return f"CopyCaptureFromRemote failed: {exc}"
return f"remote replay setup failed at step 'download capture': {exc}"
state.local_capture_path = str(local_tmp)
state.local_capture_is_temp = True

step = "match gpu"
remote_opts = rd.ReplayOptions()
if state.local_capture_path:
tmp_cap = rd.OpenCaptureFile()
if tmp_cap.OpenFile(state.local_capture_path, "", None) == rd.ResultCode.Succeeded:
gpu = _match_capture_gpu(tmp_cap)
if gpu is not None:
remote_opts.forceGPUVendor = gpu.vendor
remote_opts.forceGPUDeviceID = gpu.deviceID
tmp_cap.Shutdown()
try:
tmp_cap = rd.OpenCaptureFile()
try:
open_result = tmp_cap.OpenFile(state.local_capture_path, "", None)
if open_result == rd.ResultCode.Succeeded:
gpu = _match_capture_gpu(tmp_cap)
if gpu is not None:
remote_opts.forceGPUVendor = gpu.vendor
remote_opts.forceGPUDeviceID = gpu.deviceID
finally:
tmp_cap.Shutdown()
except Exception as exc: # noqa: BLE001
_log.warning("GPU probe skipped: %s", exc)

step = "open remote capture"
result, controller = remote.OpenCapture(
rd.RemoteServer.NoPreference,
remote_path,
Expand All @@ -382,11 +398,13 @@ def _load_remote_replay(state: DaemonState, remote_url: str) -> str | None:
remote.ShutdownConnection()
return f"remote OpenCapture failed: {result}"

step = "open local metadata"
cap = rd.OpenCaptureFile()
open_result = cap.OpenFile(state.local_capture_path, "", None)
if open_result != rd.ResultCode.Succeeded:
_cleanup_temp_capture(state)
remote.CloseCapture(controller)
cap.Shutdown()
remote.ShutdownConnection()
return f"local OpenFile (metadata) failed: {open_result}"

Expand All @@ -396,13 +414,25 @@ def _load_remote_replay(state: DaemonState, remote_url: str) -> str | None:
state.adapter = RenderDocAdapter(controller=controller, version=version)
state.structured_file = state.adapter.get_structured_file()

step = "init adapter state"
_init_adapter_state(state)
step = "start ping thread"
_start_ping_thread(state)
except Exception as exc: # noqa: BLE001
_stop_ping_thread(state)
if cap is not None:
try:
cap.Shutdown()
except Exception: # noqa: BLE001
pass
if controller is not None:
try:
remote.CloseCapture(controller)
except Exception: # noqa: BLE001
pass
_cleanup_temp_capture(state)
remote.ShutdownConnection()
return f"remote replay setup failed: {exc}"
return f"remote replay setup failed at step '{step}' ({type(exc).__name__}): {exc}"
return None


Expand Down
33 changes: 19 additions & 14 deletions src/rdc/handlers/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,21 +168,26 @@ def _handle_remote_capture(
output_local = _resolve_output_path(state, output, "remote-capture.rdc")

try:
result = remote_capture(
rd,
remote,
conn_url,
app,
args=params.get("args", ""),
workdir=params.get("workdir", ""),
output=output_local,
opts=params.get("opts", {}) or {},
frame=params.get("frame"),
timeout=float(params.get("timeout", 60.0)),
keep_remote=bool(params.get("keep_remote", False)),
)
try:
result = remote_capture(
rd,
remote,
conn_url,
app,
args=params.get("args", ""),
workdir=params.get("workdir", ""),
output=output_local,
opts=params.get("opts", {}) or {},
frame=params.get("frame"),
timeout=float(params.get("timeout", 60.0)),
keep_remote=bool(params.get("keep_remote", False)),
)
except (RuntimeError, OSError) as exc:
msg = f"remote capture failed at step 'inject/transfer': {exc}"
return _error_response(request_id, -32002, msg), True
except Exception as exc: # noqa: BLE001
return _error_response(request_id, -32002, str(exc)), True
msg = f"remote capture failed at unknown step ({type(exc).__name__}): {exc}"
return _error_response(request_id, -32002, msg), True
finally:
remote.ShutdownConnection()

Expand Down
16 changes: 12 additions & 4 deletions src/rdc/remote_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
import click

from rdc._progress import make_progress_cb
from rdc.capture_core import CaptureResult, build_capture_options, run_target_control_loop
from rdc.capture_core import (
CaptureResult,
_remote_inject_failure_hint,
build_capture_options,
run_target_control_loop,
)

_PRIVATE_NETS = (
re.compile(r"^10\."),
Expand Down Expand Up @@ -100,7 +105,9 @@ def connect_remote_server(rd: Any, url: str) -> Any:
result, remote = rd.CreateRemoteServerConnection(url)
if result != 0:
msg = getattr(result, "Message", lambda: f"code {result}")()
raise RuntimeError(f"connection failed: {msg}")
raise RuntimeError(
f"connection failed: {msg} -- hint: verify 'rdc serve' is running on {url}"
)
return remote


Expand Down Expand Up @@ -151,11 +158,12 @@ def remote_capture(
env_mods: list[Any] = []
exec_result = remote.ExecuteAndInject(app, workdir, args, env_mods, capture_opts)

_inj_hint = _remote_inject_failure_hint()
if exec_result.result != 0:
msg = getattr(exec_result.result, "Message", lambda: f"code {exec_result.result}")()
return CaptureResult(error=f"remote inject failed: {msg}")
return CaptureResult(error=f"remote inject failed: {msg} -- hint: {_inj_hint}")
if exec_result.ident == 0:
return CaptureResult(error="remote inject returned zero ident")
return CaptureResult(error=f"remote inject returned zero ident -- hint: {_inj_hint}")

tc = rd.CreateTargetControl(url, exec_result.ident, "rdc-cli", True)
if tc is None:
Expand Down
4 changes: 2 additions & 2 deletions src/rdc/services/session_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ def open_session(
if stderr and stderr.strip():
detail = stderr.strip()
else:
return False, f"error: daemon failed to start ({detail})"
return False, f"error: daemon failed to start ({detail}) -- hint: run 'rdc doctor'"

create_session(
capture=str(capture),
Expand Down Expand Up @@ -423,7 +423,7 @@ def listen_open_session(
stderr = ""
if stderr and stderr.strip():
detail = stderr.strip()
return False, f"error: daemon failed to start ({detail})"
return False, f"error: daemon failed to start ({detail}) -- hint: run 'rdc doctor'"

create_session(
capture=capture,
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/test_capture_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ def test_capture_inject_failure(self) -> None:
result = execute_and_capture(rd, "/usr/bin/app")
assert result.success is False
assert "inject failed" in result.error
assert "hint:" in result.error

def test_capture_trigger_mode(self) -> None:
from rdc.capture_core import execute_and_capture
Expand Down Expand Up @@ -363,6 +364,7 @@ def test_fallback_fails_returns_error(self, monkeypatch: pytest.MonkeyPatch) ->
result = execute_and_capture(rd, "/usr/bin/app")
assert result.success is False
assert "inject returned zero ident" in result.error
assert "hint:" in result.error

def test_trigger_mode_with_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
from rdc.capture_core import execute_and_capture
Expand Down Expand Up @@ -417,3 +419,35 @@ def test_invalid_pid(self, monkeypatch: pytest.MonkeyPatch) -> None:

assert terminate_process(0) is False
assert calls == []


class TestInjectFailureHint:
"""Platform-specific hint text for inject failures (T24 group A)."""

def test_darwin_hint_mentions_sip_and_renderdoccmd(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
from rdc.capture_core import _inject_failure_hint

monkeypatch.setattr("rdc.capture_core.sys.platform", "darwin")
hint = _inject_failure_hint()
assert "SIP" in hint
assert "renderdoccmd" in hint
assert "hook-children" in hint

def test_win32_hint_mentions_administrator(self, monkeypatch: pytest.MonkeyPatch) -> None:
from rdc.capture_core import _inject_failure_hint

monkeypatch.setattr("rdc.capture_core.sys.platform", "win32")
hint = _inject_failure_hint()
assert "Administrator" in hint
assert "hook-children" in hint

def test_linux_hint_mentions_apparmor_selinux(self, monkeypatch: pytest.MonkeyPatch) -> None:
from rdc.capture_core import _inject_failure_hint

monkeypatch.setattr("rdc.capture_core.sys.platform", "linux")
hint = _inject_failure_hint()
assert "AppArmor" in hint
assert "SELinux" in hint
assert "hook-children" in hint
Loading
Loading