Skip to content

Commit 48bf0cf

Browse files
authored
feat(remote): add remote capture commands (Phase 6) (#127)
* feat(remote): add remote capture commands (Phase 6) Add three new commands under `rdc remote` subgroup for remote RenderDoc server operations: - `rdc remote connect <URL>` — connect to remote server, verify with Ping, save state for subsequent commands - `rdc remote list [--url HOST:PORT]` — enumerate capturable apps on remote host via EnumerateRemoteTargets loop - `rdc remote capture <app> -o <local.rdc>` — execute + inject on remote, capture frame via reused target control loop, transfer capture file locally via CopyCaptureFromRemote Architecture: standalone commands (bypass daemon), consistent with existing capture/capture-control pattern. Connection state persisted to ~/.rdc/remote/ between invocations. Key implementation details: - Rename _run_target_control_loop -> run_target_control_loop for cross-module reuse from remote_core - RemoteServer.ExecuteAndInject uses 5-param signature (not 7) - CreateRemoteServerConnection takes single URL string - EnumerateRemoteTargets loop capped at 1000 iterations - ShutdownConnection() used (not ShutdownServerAndConnection) - IPv6 bracket notation supported in URL parser - Public IP warning on non-RFC-1918 addresses New files: remote_state.py, remote_core.py, commands/remote.py Tests: 42 new unit tests across 3 test files (97% coverage on new code) * fix(remote): address review issues in parse_url and test isolation - Validate empty IPv6 brackets [] and trailing garbage [::1]abc - Detect bare IPv6 addresses without brackets (::1, fe80::1) - Set success=False when CopyCaptureFromRemote fails - Remove dead port!=0 condition in remote_connect_cmd - Use monkeypatch.setattr("rdc._platform.data_dir") for Windows CI compat * fix(test): skip POSIX permission test on Windows The test_save_creates_restricted_permissions test checks Unix file mode bits (0o700/0o600) which are not enforced on NTFS, matching the existing pattern in test_session_state.py. * fix(remote): re-bracket IPv6 in conn_url, add ULA/link-local to private nets - Add build_conn_url() helper that re-brackets IPv6 hosts (::1 -> [::1]:port) - Replace 3 inline f"{host}:{port}" with build_conn_url() in commands/remote.py - Add fd00::/8 ULA and fe80::/10 link-local to _PRIVATE_NETS patterns - Add tests for build_conn_url and IPv6 private address recognition * fix(remote): catch ValueError from parse_url for friendly error messages Without this, malformed URLs like [], [::1]abc, ::1 would show raw Python tracebacks instead of clean "error: ..." messages. * fix(remote): fix ResultDetails handling and output path copy (B32, B33, B35) B32: connect_remote_server used int(ResultDetails) which returned SWIG pointer address instead of status code — connections always failed. Fixed to use result != 0 (SWIG __eq__ works correctly). B33: remote capture ignored -o output path when result.local=True (localhost scenario). Added shutil.copy2 fallback for local files. B35: inject failure error message showed raw ResultDetails repr. Fixed to use .Message() with getattr fallback.
1 parent 124d09e commit 48bf0cf

12 files changed

Lines changed: 1516 additions & 4 deletions

File tree

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ rdc close
2929

3030
## Install
3131

32-
**PyPI** (recommended — any Linux distro)
32+
**PyPILinux** (recommended)
3333

3434
```bash
3535
pipx install rdc-cli # install the CLI
@@ -38,6 +38,14 @@ curl -fsSL https://raw.githubusercontent.com/BANANASJIM/rdc-cli/master/scripts/b
3838
rdc doctor # verify everything works
3939
```
4040

41+
**PyPI — Windows** (experimental)
42+
43+
```bash
44+
pipx install rdc-cli
45+
python scripts/build_renderdoc.py # needs cmake + Visual Studio Build Tools
46+
rdc doctor
47+
```
48+
4149
**AUR** (Arch Linux — builds renderdoc automatically, no extra setup)
4250

4351
```bash
@@ -132,6 +140,7 @@ Run `rdc --help` for the full list, or `rdc <command> --help` for details. See
132140
| Assertions | `assert-pixel`, `assert-state`, `assert-image`, `assert-count`, `assert-clean` |
133141
| Diff | `diff` (with `--draws`, `--stats`, `--framebuffer`, `--pipeline`, etc.) |
134142
| VFS | `ls`, `cat`, `tree` |
143+
| Remote | `remote connect`, `remote list`, `remote capture` |
135144
| Utility | `doctor`, `completion`, `capture`, `count`, `script`, `install-skill` |
136145

137146
All list commands output TSV. All commands support `--json`. Footer/summary goes to stderr — stdout is always clean data.

docs-astro/src/data/commands.json

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@
162162
"name": "search",
163163
"id": "search",
164164
"help": "Search shader disassembly text for PATTERN (regex).",
165-
"usage": "rdc search <PATTERN> [--stage TEXT] [--limit INTEGER] [-C INTEGER] [-i] [--json]"
165+
"usage": "rdc search <PATTERN> [--stage TEXT] [--limit INTEGER] [-C INTEGER] [--case-sensitive] [--json]"
166166
},
167167
{
168168
"name": "passes",
@@ -432,6 +432,31 @@
432432
}
433433
]
434434
},
435+
{
436+
"name": "Remote",
437+
"id": "remote",
438+
"description": "Connect to a remote RenderDoc server for remote capture.",
439+
"commands": [
440+
{
441+
"name": "remote connect",
442+
"id": "remote-connect",
443+
"help": "Connect to a remote RenderDoc server.",
444+
"usage": "rdc remote connect <URL> [--json]"
445+
},
446+
{
447+
"name": "remote list",
448+
"id": "remote-list",
449+
"help": "List capturable applications on a remote host.",
450+
"usage": "rdc remote list [--url TEXT] [--json]"
451+
},
452+
{
453+
"name": "remote capture",
454+
"id": "remote-capture",
455+
"help": "Capture on a remote host and transfer to local.",
456+
"usage": "rdc remote capture <APP> -o <PATH> [--url TEXT] [--args TEXT] [--workdir TEXT] [--frame INTEGER] [--timeout FLOAT] [--api-validation] [--callstacks] [--hook-children] [--ref-all-resources] [--soft-memory-limit INTEGER] [--json]"
457+
}
458+
]
459+
},
435460
{
436461
"name": "Utilities",
437462
"id": "utilities",

scripts/gen-commands.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@
6565
("VFS Navigation", "vfs", None, [
6666
"ls", "cat", "tree",
6767
]),
68+
(
69+
"Remote", "remote",
70+
"Connect to a remote RenderDoc server for remote capture.",
71+
["remote connect", "remote list", "remote capture"],
72+
),
6873
("Utilities", "utilities", None, [
6974
"count", "completion", "script", "install-skill",
7075
]),

src/rdc/_skills/references/commands-quick-ref.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,60 @@ Query pixel history at (X, Y) for the current or specified event.
645645
| `--jsonl` | JSONL output | flag | |
646646
| `-q, --quiet` | Print primary key column only | flag | |
647647

648+
## `rdc remote capture`
649+
650+
Capture on a remote host and transfer to local.
651+
652+
**Arguments:**
653+
654+
| Name | Type | Required |
655+
|------|------|----------|
656+
| `app` | text | yes |
657+
658+
**Options:**
659+
660+
| Flag | Help | Type | Default |
661+
|------|------|------|---------|
662+
| `-o, --output` | Local output path. | path | |
663+
| `--url` | Override saved remote (host:port). | text | |
664+
| `--args` | Arguments for remote app. | text | |
665+
| `--workdir` | Remote working directory. | text | |
666+
| `--frame` | Queue capture at frame N. | integer | |
667+
| `--timeout` | Capture timeout in seconds. | float | 60.0 |
668+
| `--api-validation` | Enable API validation. | flag | |
669+
| `--callstacks` | Capture callstacks. | flag | |
670+
| `--hook-children` | Hook child processes. | flag | |
671+
| `--ref-all-resources` | Reference all resources. | flag | |
672+
| `--soft-memory-limit` | Soft memory limit (MB). | integer | |
673+
| `--json` | Output as JSON. | flag | |
674+
675+
## `rdc remote connect`
676+
677+
Connect to a remote RenderDoc server.
678+
679+
**Arguments:**
680+
681+
| Name | Type | Required |
682+
|------|------|----------|
683+
| `url` | text | yes |
684+
685+
**Options:**
686+
687+
| Flag | Help | Type | Default |
688+
|------|------|------|---------|
689+
| `--json` | Output as JSON. | flag | |
690+
691+
## `rdc remote list`
692+
693+
List capturable applications on a remote host.
694+
695+
**Options:**
696+
697+
| Flag | Help | Type | Default |
698+
|------|------|------|---------|
699+
| `--url` | Override saved remote (host:port). | text | |
700+
| `--json` | Output as JSON. | flag | |
701+
648702
## `rdc resource`
649703

650704
Show details of a specific resource.

src/rdc/capture_core.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,15 +131,15 @@ def execute_and_capture(
131131
return CaptureResult(error="failed to connect to target", ident=ident)
132132
try:
133133
pid = tc.GetPID()
134-
cap = _run_target_control_loop(tc, frame=frame, timeout=timeout)
134+
cap = run_target_control_loop(tc, frame=frame, timeout=timeout)
135135
cap.ident = ident
136136
cap.pid = pid
137137
return cap
138138
finally:
139139
tc.Shutdown()
140140

141141

142-
def _run_target_control_loop(
142+
def run_target_control_loop(
143143
tc: Any,
144144
*,
145145
frame: int | None = None,

src/rdc/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from rdc.commands.pick_pixel import pick_pixel_cmd
3939
from rdc.commands.pipeline import bindings_cmd, pipeline_cmd, shader_cmd, shaders_cmd
4040
from rdc.commands.pixel import pixel_cmd
41+
from rdc.commands.remote import remote_group
4142
from rdc.commands.resources import pass_cmd, passes_cmd, resource_cmd, resources_cmd
4243
from rdc.commands.script import script_cmd
4344
from rdc.commands.search import search_cmd
@@ -146,6 +147,7 @@ def main() -> None:
146147
main.add_command(capture_trigger_cmd, name="capture-trigger")
147148
main.add_command(capture_list_cmd, name="capture-list")
148149
main.add_command(capture_copy_cmd, name="capture-copy")
150+
main.add_command(remote_group, name="remote")
149151

150152

151153
if __name__ == "__main__":

src/rdc/commands/remote.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""Remote RenderDoc server commands: connect, list, capture."""
2+
3+
from __future__ import annotations
4+
5+
import dataclasses
6+
import json
7+
import time
8+
from pathlib import Path
9+
from typing import Any
10+
11+
import click
12+
13+
from rdc.discover import find_renderdoc
14+
from rdc.remote_core import (
15+
build_conn_url,
16+
connect_remote_server,
17+
enumerate_remote_targets,
18+
parse_url,
19+
remote_capture,
20+
warn_if_public,
21+
)
22+
from rdc.remote_state import (
23+
RemoteServerState,
24+
load_latest_remote_state,
25+
save_remote_state,
26+
)
27+
28+
29+
def _require_renderdoc() -> Any:
30+
"""Find and return the renderdoc module, or exit with error."""
31+
rd = find_renderdoc()
32+
if rd is None:
33+
click.echo("error: renderdoc module not found", err=True)
34+
raise SystemExit(1)
35+
return rd
36+
37+
38+
def _resolve_url(url: str | None) -> tuple[str, int]:
39+
"""Resolve host/port from --url flag or saved state."""
40+
if url:
41+
try:
42+
return parse_url(url)
43+
except ValueError as exc:
44+
click.echo(f"error: {exc}", err=True)
45+
raise SystemExit(1) from None
46+
state = load_latest_remote_state()
47+
if state is None:
48+
click.echo("error: no remote connection (run 'rdc remote connect' first)", err=True)
49+
raise SystemExit(1)
50+
return state.host, state.port
51+
52+
53+
def _check_public_ip(host: str) -> None:
54+
"""Emit warning to stderr if host appears to be a public IP."""
55+
warning = warn_if_public(host)
56+
if warning:
57+
click.echo(warning, err=True)
58+
59+
60+
@click.group("remote")
61+
def remote_group() -> None:
62+
"""Remote RenderDoc server commands."""
63+
64+
65+
@remote_group.command("connect")
66+
@click.argument("url")
67+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
68+
def remote_connect_cmd(url: str, as_json: bool) -> None:
69+
"""Connect to a remote RenderDoc server."""
70+
try:
71+
host, port = parse_url(url)
72+
except ValueError as exc:
73+
click.echo(f"error: {exc}", err=True)
74+
raise SystemExit(1) from None
75+
_check_public_ip(host)
76+
rd = _require_renderdoc()
77+
78+
conn_url = build_conn_url(host, port)
79+
try:
80+
remote = connect_remote_server(rd, conn_url)
81+
except RuntimeError as exc:
82+
click.echo(f"error: {exc}", err=True)
83+
raise SystemExit(1) from None
84+
85+
try:
86+
remote.Ping()
87+
save_remote_state(RemoteServerState(host=host, port=port, connected_at=time.time()))
88+
finally:
89+
remote.ShutdownConnection()
90+
91+
if as_json:
92+
click.echo(json.dumps({"host": host, "port": port}))
93+
else:
94+
click.echo(f"connected: {host}:{port}")
95+
96+
97+
@remote_group.command("list")
98+
@click.option("--url", default=None, help="Override saved remote (host:port).")
99+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
100+
def remote_list_cmd(url: str | None, as_json: bool) -> None:
101+
"""List capturable applications on a remote host."""
102+
host, port = _resolve_url(url)
103+
_check_public_ip(host)
104+
rd = _require_renderdoc()
105+
106+
conn_url = build_conn_url(host, port)
107+
idents = enumerate_remote_targets(rd, conn_url)
108+
109+
targets: list[dict[str, Any]] = []
110+
for ident in idents:
111+
tc = rd.CreateTargetControl(conn_url, ident, "rdc-cli", False)
112+
if tc is None:
113+
targets.append({"ident": ident, "target": "unknown", "pid": 0, "api": "unknown"})
114+
continue
115+
try:
116+
targets.append(
117+
{
118+
"ident": ident,
119+
"target": tc.GetTarget(),
120+
"pid": tc.GetPID(),
121+
"api": tc.GetAPI(),
122+
}
123+
)
124+
finally:
125+
tc.Shutdown()
126+
127+
if as_json:
128+
click.echo(json.dumps({"targets": targets}))
129+
else:
130+
if not targets:
131+
click.echo("no targets found")
132+
for t in targets:
133+
click.echo(f"ident={t['ident']} target={t['target']} pid={t['pid']} api={t['api']}")
134+
135+
136+
@remote_group.command("capture")
137+
@click.argument("app")
138+
@click.option(
139+
"-o", "--output", required=True, type=click.Path(path_type=Path), help="Local output path."
140+
)
141+
@click.option("--url", default=None, help="Override saved remote (host:port).")
142+
@click.option("--args", "app_args", default="", help="Arguments for remote app.")
143+
@click.option("--workdir", default="", help="Remote working directory.")
144+
@click.option("--frame", type=int, default=None, help="Queue capture at frame N.")
145+
@click.option("--timeout", type=float, default=60.0, help="Capture timeout in seconds.")
146+
@click.option("--api-validation", is_flag=True, help="Enable API validation.")
147+
@click.option("--callstacks", is_flag=True, help="Capture callstacks.")
148+
@click.option("--hook-children", is_flag=True, help="Hook child processes.")
149+
@click.option("--ref-all-resources", is_flag=True, help="Reference all resources.")
150+
@click.option("--soft-memory-limit", type=int, default=None, help="Soft memory limit (MB).")
151+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
152+
def remote_capture_cmd(
153+
app: str,
154+
output: Path,
155+
url: str | None,
156+
app_args: str,
157+
workdir: str,
158+
frame: int | None,
159+
timeout: float,
160+
api_validation: bool,
161+
callstacks: bool,
162+
hook_children: bool,
163+
ref_all_resources: bool,
164+
soft_memory_limit: int | None,
165+
as_json: bool,
166+
) -> None:
167+
"""Capture on a remote host and transfer to local."""
168+
host, port = _resolve_url(url)
169+
_check_public_ip(host)
170+
rd = _require_renderdoc()
171+
172+
opts: dict[str, Any] = {}
173+
if api_validation:
174+
opts["api_validation"] = True
175+
if callstacks:
176+
opts["callstacks"] = True
177+
if hook_children:
178+
opts["hook_children"] = True
179+
if ref_all_resources:
180+
opts["ref_all_resources"] = True
181+
if soft_memory_limit is not None:
182+
opts["soft_memory_limit"] = soft_memory_limit
183+
184+
conn_url = build_conn_url(host, port)
185+
try:
186+
remote = connect_remote_server(rd, conn_url)
187+
except RuntimeError as exc:
188+
click.echo(f"error: {exc}", err=True)
189+
raise SystemExit(1) from None
190+
191+
try:
192+
result = remote_capture(
193+
rd,
194+
remote,
195+
conn_url,
196+
app,
197+
args=app_args,
198+
workdir=workdir,
199+
output=str(output),
200+
opts=opts,
201+
frame=frame,
202+
timeout=timeout,
203+
)
204+
finally:
205+
remote.ShutdownConnection()
206+
207+
if as_json:
208+
click.echo(json.dumps(dataclasses.asdict(result)))
209+
if not result.success:
210+
raise SystemExit(1)
211+
return
212+
213+
if not result.success:
214+
click.echo(f"error: {result.error}", err=True)
215+
raise SystemExit(1)
216+
217+
click.echo(result.path)
218+
click.echo(f"next: rdc open {result.path}", err=True)

0 commit comments

Comments
 (0)