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
14 changes: 13 additions & 1 deletion src/rdc/_skills/references/commands-quick-ref.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,17 @@ Export buffer raw data.
| `-o, --output` | Write to file | path | |
| `--raw` | Force raw output even on TTY | flag | |

## `rdc callstacks`

Resolve CPU callstack for an event.

**Options:**

| Flag | Help | Type | Default |
|------|------|------|---------|
| `--eid` | Event ID (defaults to current). | integer | |
| `--json` | Output as JSON. | flag | |

## `rdc capture`

Execute application and capture a frame.
Expand Down Expand Up @@ -806,7 +817,7 @@ Search shader disassembly text for PATTERN (regex).

## `rdc section`

Extract named section contents.
Extract or write named section contents.

**Arguments:**

Expand All @@ -819,6 +830,7 @@ Extract named section contents.
| Flag | Help | Type | Default |
|------|------|------|---------|
| `--json` | Output as JSON. | flag | |
| `--write` | Write file contents as this section (use '-' for stdin). | path | |

## `rdc sections`

Expand Down
2 changes: 2 additions & 0 deletions src/rdc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
capture_trigger_cmd,
)
from rdc.commands.capturefile import (
callstacks_cmd,
gpus_cmd,
section_cmd,
sections_cmd,
Expand Down Expand Up @@ -150,6 +151,7 @@ def main() -> None:
main.add_command(capture_copy_cmd, name="capture-copy")
main.add_command(remote_group, name="remote")
main.add_command(serve_cmd, name="serve")
main.add_command(callstacks_cmd, name="callstacks")


if __name__ == "__main__":
Expand Down
5 changes: 3 additions & 2 deletions src/rdc/commands/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,13 @@ def require_session() -> tuple[str, int, str]:
return session.host, session.port, session.token


def call(method: str, params: dict[str, Any]) -> dict[str, Any]:
def call(method: str, params: dict[str, Any], *, timeout: float = 30.0) -> dict[str, Any]:
"""Send a JSON-RPC request to the daemon and return the result.

Args:
method: The JSON-RPC method name.
params: Request parameters.
timeout: Socket timeout in seconds.

Returns:
The result dict from the daemon response.
Expand All @@ -113,7 +114,7 @@ def call(method: str, params: dict[str, Any]) -> dict[str, Any]:
host, port, token = require_session()
payload = _request(method, 1, {"_token": token, **params}).to_dict()
try:
response = send_request(host, port, payload)
response = send_request(host, port, payload, timeout=timeout)
except (OSError, ValueError) as exc:
_emit_error(f"daemon unreachable: {exc}")
if "error" in response:
Expand Down
48 changes: 45 additions & 3 deletions src/rdc/commands/capturefile.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""CaptureFile CLI commands: thumbnail, gpus, sections, section."""
"""CaptureFile CLI commands: thumbnail, gpus, sections, section, callstacks."""

from __future__ import annotations

Expand Down Expand Up @@ -61,10 +61,52 @@ def sections_cmd(use_json: bool) -> None:
@click.command("section")
@click.argument("name")
@click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
def section_cmd(name: str, use_json: bool) -> None:
"""Extract named section contents."""
@click.option(
"--write",
"write_file",
type=click.Path(path_type=Path),
default=None,
help="Write file contents as this section (use '-' for stdin).",
)
def section_cmd(name: str, use_json: bool, write_file: Path | None) -> None:
"""Extract or write named section contents."""
if write_file is not None:
if str(write_file) == "-":
data = click.get_binary_stream("stdin").read()
else:
try:
data = write_file.read_bytes()
except OSError as exc:
click.echo(f"error: {exc}", err=True)
raise SystemExit(1) from exc
encoded = base64.b64encode(data).decode()
result = call("section_write", {"name": name, "data": encoded})
click.echo(f"section '{name}' written ({result['bytes']} bytes)", err=True)
if use_json:
click.echo(json.dumps(result))
return
result = call("capture_section_content", {"name": name})
if use_json:
click.echo(json.dumps(result))
else:
click.echo(result["contents"])


@click.command("callstacks")
@click.option("--eid", type=int, default=None, help="Event ID (defaults to current).")
@click.option("--json", "use_json", is_flag=True, help="Output as JSON.")
def callstacks_cmd(eid: int | None, use_json: bool) -> None:
"""Resolve CPU callstack for an event."""
params: dict[str, int] = {}
if eid is not None:
params["eid"] = eid
result = call("callstack_resolve", params, timeout=60.0)
frames = result.get("frames", [])
if use_json:
click.echo(json.dumps(result))
elif not frames:
click.echo(f"no frames at eid {result.get('eid', '?')}")
else:
click.echo("function\tfile\tline")
for f in frames:
click.echo(f"{f['function']}\t{f['file']}\t{f['line']}")
141 changes: 140 additions & 1 deletion src/rdc/handlers/capturefile.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""CaptureFile handlers: thumbnail, gpus, sections, section content."""
"""CaptureFile handlers: thumbnail, gpus, sections, section content, callstacks, write."""

from __future__ import annotations

import base64
import logging
from typing import TYPE_CHECKING, Any

from rdc.handlers._helpers import _error_response, _result_response
Expand All @@ -11,6 +12,19 @@
if TYPE_CHECKING:
from rdc.daemon_server import DaemonState

_log = logging.getLogger(__name__)

_PROTECTED_SECTION_TYPES: frozenset[str] = frozenset(
{
"FrameCapture",
"ResolveDatabase",
"ExtendedThumbnail",
"EmbeddedLogfile",
"ResourceRenames",
"AMDRGPProfile",
}
)


def _handle_capture_thumbnail(
request_id: int, params: dict[str, Any], state: DaemonState
Expand Down Expand Up @@ -107,9 +121,134 @@ def _handle_capture_section_content(
), True


def _parse_resolve_string(s: str) -> dict[str, Any]:
"""Parse a resolve string like ``'func file.c:42'`` into a frame dict."""
parts = s.rsplit(" ", 1)
func = parts[0] if len(parts) > 1 else s
file_line = parts[1] if len(parts) > 1 else ""
file_name = ""
line = 0
if ":" in file_line:
fl_parts = file_line.rsplit(":", 1)
file_name = fl_parts[0]
try:
line = int(fl_parts[1])
except ValueError:
file_name = file_line
else:
file_name = file_line
return {"function": func, "file": file_name, "line": line}


def _handle_callstack_resolve(
request_id: int, params: dict[str, Any], state: DaemonState
) -> tuple[dict[str, Any], bool]:
"""Resolve CPU callstack for an event ID."""
if state.cap is None:
return _error_response(request_id, -32002, "no capture file open"), True
if not state.cap.HasCallstacks():
return _error_response(request_id, -32002, "no callstacks recorded in this capture"), True
try:
ok = state.cap.InitResolver(interactive=False, progress=None)
except Exception as exc: # noqa: BLE001
_log.debug("InitResolver failed: %s", exc)
return _error_response(request_id, -32002, "symbols not available"), True
if not ok:
return _error_response(request_id, -32002, "symbols not available"), True

eid = params.get("eid", state.current_eid)
if not isinstance(eid, int) or eid < 0:
return _error_response(request_id, -32602, "invalid eid"), True
if eid > state.max_eid:
return _error_response(
request_id, -32602, f"eid {eid} out of range (max {state.max_eid})"
), True

callstack: list[int] = []
ctrl = getattr(state.adapter, "controller", None) if state.adapter else None
get_cs = getattr(ctrl, "GetCallstack", None) if ctrl else None
if callable(get_cs):
try:
callstack = list(get_cs(eid))
except Exception: # noqa: BLE001
_log.debug("GetCallstack(%d) failed, falling back", eid)

frames: list[dict[str, Any]] = []
if callstack:
resolved = state.cap.GetResolve(callstack)
frames = [_parse_resolve_string(s) for s in resolved]

return _result_response(request_id, {"eid": eid, "frames": frames}), True


def _handle_section_write(
request_id: int, params: dict[str, Any], state: DaemonState
) -> tuple[dict[str, Any], bool]:
"""Write a named section to the capture file."""
if state.cap is None:
return _error_response(request_id, -32002, "no capture file open"), True

name = params.get("name")
if not name or not isinstance(name, str):
return _error_response(request_id, -32602, "missing or empty 'name'"), True

data_b64 = params.get("data")
if data_b64 is None or not isinstance(data_b64, str):
return _error_response(request_id, -32602, "missing 'data' parameter"), True

idx = state.cap.FindSectionByName(name)
if idx >= 0:
props = state.cap.GetSectionProperties(idx)
type_name = getattr(getattr(props, "type", None), "name", "")
if type_name in _PROTECTED_SECTION_TYPES:
return _error_response(
request_id,
-32602,
f"cannot overwrite built-in section '{name}'",
), True

try:
decoded = base64.b64decode(data_b64)
except Exception: # noqa: BLE001
return _error_response(request_id, -32602, "invalid base64 data"), True

rd = state.rd
sec_type = getattr(rd, "SectionType", None) if rd else None
unknown_type = getattr(sec_type, "Unknown", 0) if sec_type else 0
sec_flags_cls = getattr(rd, "SectionFlags", None) if rd else None
no_flags = getattr(sec_flags_cls, "NoFlags", 0) if sec_flags_cls else 0
sp_cls = getattr(rd, "SectionProperties", None) if rd else None

if sp_cls is not None:
new_props = sp_cls()
new_props.name = name
new_props.type = unknown_type
new_props.flags = no_flags
else:
from types import SimpleNamespace

new_props = SimpleNamespace(
name=name,
type=unknown_type,
version=1,
flags=no_flags,
compressedSize=0,
uncompressedSize=0,
)

try:
state.cap.WriteSection(new_props, decoded)
except Exception as exc: # noqa: BLE001
_log.debug("WriteSection failed: %s", exc)
return _error_response(request_id, -32002, "section write failed"), True
return _result_response(request_id, {"name": name, "bytes": len(decoded)}), True


HANDLERS: dict[str, Handler] = {
"capture_thumbnail": _handle_capture_thumbnail,
"capture_gpus": _handle_capture_gpus,
"capture_sections": _handle_capture_sections,
"capture_section_content": _handle_capture_section_content,
"callstack_resolve": _handle_callstack_resolve,
"section_write": _handle_section_write,
}
93 changes: 93 additions & 0 deletions tests/e2e/test_capturefile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""E2E tests for capturefile commands (callstacks, section write).

These are black-box tests that invoke the CLI via subprocess and require
a working renderdoc installation for capture open/replay.
"""

from __future__ import annotations

import base64
import uuid

import pytest
from e2e_helpers import VKCUBE, rdc, rdc_fail, rdc_json, rdc_ok

pytestmark = pytest.mark.gpu


def _uid() -> str:
return uuid.uuid4().hex[:8]


class TestCallstacks:
"""T-5C-36/37: callstacks on capture without callstack data."""

def test_callstacks_no_callstacks(self) -> None:
"""``rdc callstacks`` exits 1 on fixture without callstack data."""
name = f"e2e_cs_{_uid()}"
try:
rdc_ok("open", str(VKCUBE), session=name)
out = rdc_fail("callstacks", session=name, exit_code=1)
assert "no callstacks" in out.lower()
finally:
rdc("close", session=name)

def test_callstacks_json_no_callstacks(self) -> None:
"""``rdc callstacks --json`` exits 1, no partial JSON."""
name = f"e2e_csj_{_uid()}"
try:
rdc_ok("open", str(VKCUBE), session=name)
out = rdc_fail("callstacks", "--json", session=name, exit_code=1)
assert "no callstacks" in out.lower()
finally:
rdc("close", session=name)


class TestSectionWrite:
"""T-5C-33/34/35: section write and read-back."""

def test_write_and_readback(self, tmp_path) -> None:
"""Write a custom section, then read it back."""
name = f"e2e_sw_{_uid()}"
note = tmp_path / "notes.txt"
note.write_text("e2e-round-trip")
try:
rdc_ok("open", str(VKCUBE), session=name)
rdc_ok("section", "MyNotes", "--write", str(note), session=name)
out = rdc_ok("section", "MyNotes", session=name)
assert "e2e-round-trip" in out
finally:
rdc("close", session=name)

def test_refuse_system_section(self, tmp_path) -> None:
"""Refuse to overwrite real system section by internal name."""
name = f"e2e_sws_{_uid()}"
note = tmp_path / "bad.txt"
note.write_bytes(b"bad")
try:
rdc_ok("open", str(VKCUBE), session=name)
out = rdc_fail(
"section",
"renderdoc/internal/framecapture",
"--write",
str(note),
session=name,
exit_code=1,
)
assert "built-in" in out.lower()
finally:
rdc("close", session=name)

def test_binary_roundtrip(self, tmp_path) -> None:
"""Write binary section and verify base64 round-trip."""
name = f"e2e_swb_{_uid()}"
binfile = tmp_path / "data.bin"
binfile.write_bytes(b"\xde\xad\xbe\xef")
try:
rdc_ok("open", str(VKCUBE), session=name)
rdc_ok("section", "BinSection", "--write", str(binfile), session=name)
data = rdc_json("section", "BinSection", session=name)
assert data["encoding"] == "base64"
assert base64.b64decode(data["contents"]) == b"\xde\xad\xbe\xef"
finally:
rdc("close", session=name)
Loading