Skip to content

Commit 732bd68

Browse files
author
Alex J Lennon
committed
feat: replay CLI, TCP backend helper, SVD fields/enums
- Add rsgdb replay JSONL mock backend and replay integration test (#10) - Extract connect_tcp_backend; drop unused Backend trait stub (#9) - Index SVD fields with bit overlap; append enum variant names; extend fixture/tests (#11) - Document replay, SVD annotations, and roadmap in README/CONTRIBUTING - Clippy: enumerate mock backend loop Made-with: Cursor
1 parent 2e47b69 commit 732bd68

16 files changed

Lines changed: 569 additions & 57 deletions

File tree

CONTRIBUTING.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ Work is tracked in [GitHub issues](https://github.com/DynamicDevices/rsgdb/issue
109109

110110
**Status (short):** Part A (**#1#3**), session recording (**#4**), SVD baseline (**#5**), breakpoint/semihosting spike (**#6**), flash orchestration (**#7**), RTOS decode/log (**#8**) are **closed**. **Phase A/B** (RSP matrix + proxy tests, gdbinit, backend thread reply logging) are in-tree; see README. **CI:** main workflow (**CI**) + optional **Zephyr E2E** (`native_sim`, debug `rsgdb`, west venv + `pyelftools`) — both green on `main`.
111111

112-
**Next roadmap issues (open):** [#9](https://github.com/DynamicDevices/rsgdb/issues/9) native probe backend, [#10](https://github.com/DynamicDevices/rsgdb/issues/10) session replay from JSONL, [#11](https://github.com/DynamicDevices/rsgdb/issues/11) richer SVD (fields/enums).
112+
**Next roadmap focus (open):** [#9](https://github.com/DynamicDevices/rsgdb/issues/9) native probe / backend beyond TCP. **In-tree:** session replay ([#10](https://github.com/DynamicDevices/rsgdb/issues/10) `rsgdb replay`), SVD field + enum labels in memory annotations ([#11](https://github.com/DynamicDevices/rsgdb/issues/11) — baseline; correlation with recordings may remain on the issue).
113113

114114
**CI jobs (overview):** Workflow **CI**: `test` (matrix), `fmt`, `clippy`, `docs`, `e2e-gdb-smoke`, `coverage`, `build` (artifacts; upload may use `continue-on-error` for transient infra). Workflow **Zephyr E2E**: west + SDK + `scripts/e2e_zephyr_native_sim.sh` (path / schedule / `workflow_dispatch`).
115115

@@ -328,10 +328,10 @@ We welcome contributions in these areas (see **[open issues](https://github.com/
328328

329329
### High priority (roadmap)
330330
- [ ] **Native probe / backend abstraction**[#9](https://github.com/DynamicDevices/rsgdb/issues/9)
331-
- [ ] **Session replay from JSONL**[#10](https://github.com/DynamicDevices/rsgdb/issues/10)
331+
- [x] **Session replay from JSONL**[#10](https://github.com/DynamicDevices/rsgdb/issues/10) (`rsgdb replay`)
332332

333333
### Medium priority
334-
- [ ] **Richer SVD (fields, enums)**[#11](https://github.com/DynamicDevices/rsgdb/issues/11)
334+
- [x] **Richer SVD (fields, enum names in annotations)**[#11](https://github.com/DynamicDevices/rsgdb/issues/11) (value decode / recording correlation still optional follow-ups)
335335
- [ ] TUI, advanced breakpoints, logging export — open an issue before large changes
336336

337337
### Always welcome

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ path = "device.svd"
138138

139139
### SVD labels (read-only)
140140

141-
If `[svd] path` points to a valid CMSIS-SVD file (or use `--svd FILE` / env `RSGDB_SVD`), rsgdb builds a register map and emits **debug** logs for **client** memory packets (`m` read, `M` write) with a human-readable range when the access overlaps known registers — for example `GPIOA.MODER (4 bytes)`. This is **display only**; RSP bytes are unchanged.
141+
If `[svd] path` points to a valid CMSIS-SVD file (or use `--svd FILE` / env `RSGDB_SVD`), rsgdb builds a register map and emits **debug** logs for **client** memory packets (`m` read, `M` write) with a human-readable range when the access overlaps known registers — for example `GPIOA.MODER (4 bytes)` or, when the SVD lists fields, `GPIOA.MODER (4 bytes); fields: GPIOA.MODER.MODE0 [Input, Output], …`. Enumerated value **names** from the SVD are shown alongside fields; decoding actual register **values** against those enums is not implemented yet. This is **display only**; RSP bytes are unchanged.
142142

143143
Enable the log target with e.g. `RUST_LOG=rsgdb::svd=debug,rsgdb=info` (or `-d` / verbose per your logging setup).
144144

@@ -148,7 +148,7 @@ When enabled, each GDB↔backend connection writes one **JSON Lines** file under
148148

149149
**Enable:** `rsgdb --record`, or set `[recording] enabled = true` in config, or `RSGDB_RECORD=1`. Optional directory override: `--record-dir DIR` or `RSGDB_RECORD_DIR`.
150150

151-
**Replay:** There is no built-in replayer yet. Inspect `.jsonl` with your usual tools or `jq`. Work is tracked as [#10](https://github.com/DynamicDevices/rsgdb/issues/10) (mock / automated playback for regression tests).
151+
**Replay:** `rsgdb replay <FILE.jsonl> [--listen ADDR]` (default `127.0.0.1:3334`) loads an `rsgdb-record` v1 session and listens for TCP; the **first** GDB client connection is served by a mock backend that replays `backend_to_client` / expects `client_to_backend` events in order (for regression tests and inspection). You can still inspect raw `.jsonl` with `jq` or other tools. Tracked as [#10](https://github.com/DynamicDevices/rsgdb/issues/10).
152152

153153
### Flash orchestration (`rsgdb flash`)
154154

@@ -290,9 +290,9 @@ Source of truth for ordering and scope: **[GitHub Issues](https://github.com/Dyn
290290
| Milestone (docs) | What it means | Issue |
291291
|------------------|---------------|-------|
292292
| **Foundation + proxy** | RSP codec, TCP proxy, config, logging, CI (incl. GDB + Zephyr E2E), session record (JSONL), SVD labels, flash orchestration, RTOS decode/log | Closed: [#1#8](https://github.com/DynamicDevices/rsgdb/issues?q=is%3Aissue+is%3Aclosed) |
293-
| **Next: native backend** | Probe-facing `Backend` (not only TCP stub) | [#9](https://github.com/DynamicDevices/rsgdb/issues/9) (open) |
294-
| **Next: replay** | Playback / mock backend from `.jsonl` recordings | [#10](https://github.com/DynamicDevices/rsgdb/issues/10) (open) |
295-
| **Next: richer SVD** | Fields, enums, correlation with recordings | [#11](https://github.com/DynamicDevices/rsgdb/issues/11) (open) |
293+
| **Next: native backend** | Probe-facing backend beyond TCP to a stub (see `backends::connect_tcp_backend`) | [#9](https://github.com/DynamicDevices/rsgdb/issues/9) (open) |
294+
| **Replay** | `rsgdb replay` + mock TCP backend from `.jsonl` | [#10](https://github.com/DynamicDevices/rsgdb/issues/10) (implemented; close when shipped) |
295+
| **Richer SVD** | Overlapping fields + enum variant names in annotations; value decode / recording correlation follow-ups | [#11](https://github.com/DynamicDevices/rsgdb/issues/11) (baseline in-tree) |
296296

297297
Older versioned bullets (v0.2–v0.4) below are **aspirational**; issue titles supersede them.
298298

src/backends/mod.rs

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,9 @@
1-
//! Backends module
1+
//! Debug probe backends.
22
//!
3-
//! Debug probe backend implementations (OpenOCD, probe-rs, pyOCD).
3+
//! Today the proxy uses [`tcp::connect_tcp_backend`] to reach a GDB stub over TCP. A fuller
4+
//! backend abstraction for native probes is tracked as
5+
//! [#9](https://github.com/DynamicDevices/rsgdb/issues/9).
46
5-
/// Backend trait for debug probes
6-
pub trait Backend {
7-
/// Connect to the target
8-
fn connect(&mut self) -> anyhow::Result<()>;
7+
mod tcp;
98

10-
/// Disconnect from the target
11-
fn disconnect(&mut self) -> anyhow::Result<()>;
12-
13-
/// Check if connected
14-
fn is_connected(&self) -> bool;
15-
}
16-
17-
// Made with Bob
9+
pub use tcp::connect_tcp_backend;

src/backends/tcp.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//! TCP connection to a remote GDB stub (OpenOCD, probe-rs, pyOCD, etc.).
2+
//!
3+
//! Native probe backends will plug in alongside this path; see [#9](https://github.com/DynamicDevices/rsgdb/issues/9).
4+
5+
use crate::config::ProxyConfig;
6+
use crate::error::RsgdbError;
7+
use crate::protocol::codec::GdbCodec;
8+
use std::time::Duration;
9+
use tokio::net::TcpStream;
10+
use tokio::time::timeout;
11+
use tokio_util::codec::Framed;
12+
13+
/// Connect to `target_host:target_port` and wrap the stream with [`GdbCodec`].
14+
pub async fn connect_tcp_backend(
15+
config: &ProxyConfig,
16+
) -> Result<Framed<TcpStream, GdbCodec>, RsgdbError> {
17+
let backend_addr = format!("{}:{}", config.target_host, config.target_port);
18+
let stream = if config.timeout_secs == 0 {
19+
TcpStream::connect(&backend_addr)
20+
.await
21+
.map_err(RsgdbError::Io)?
22+
} else {
23+
timeout(
24+
Duration::from_secs(config.timeout_secs),
25+
TcpStream::connect(backend_addr.clone()),
26+
)
27+
.await
28+
.map_err(|_| {
29+
RsgdbError::Timeout(format!(
30+
"TCP connect to backend {} exceeded {}s",
31+
backend_addr, config.timeout_secs
32+
))
33+
})?
34+
.map_err(RsgdbError::Io)?
35+
};
36+
Ok(Framed::new(stream, GdbCodec::new()))
37+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ mod logging_setup;
3232
pub mod protocol;
3333
pub mod proxy;
3434
pub mod recorder;
35+
pub mod replay;
3536
pub mod rtos;
3637
pub mod state;
3738
pub mod svd;

src/main.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ struct Cli {
2626

2727
#[derive(Subcommand, Debug)]
2828
enum Commands {
29+
/// Replay a **rsgdb-record v1** JSONL file as a mock TCP backend (issue #10)
30+
Replay {
31+
/// Session file (`.jsonl`)
32+
#[arg(value_name = "FILE")]
33+
file: PathBuf,
34+
/// Mock backend listen address (point `rsgdb` `--target-host` / `--target-port` here)
35+
#[arg(long, default_value = "127.0.0.1:3334", value_name = "ADDR")]
36+
listen: String,
37+
},
2938
/// Flash firmware using `[flash].program` in config (orchestrates OpenOCD, probe-rs, etc.)
3039
Flash {
3140
/// Firmware image (binary, ELF, or whatever your tool expects)
@@ -90,6 +99,7 @@ async fn main() -> anyhow::Result<()> {
9099
let cli = Cli::parse();
91100

92101
match cli.command {
102+
Some(Commands::Replay { file, listen }) => run_replay(&file, &listen).await,
93103
Some(Commands::Flash {
94104
image,
95105
config,
@@ -100,6 +110,40 @@ async fn main() -> anyhow::Result<()> {
100110
}
101111
}
102112

113+
async fn run_replay(file: &Path, listen: &str) -> anyhow::Result<()> {
114+
use rsgdb::replay::{load_session, run_mock_backend};
115+
use tokio::net::TcpListener;
116+
117+
let mut config = Config::default();
118+
config.merge_env();
119+
let _log_guard: LoggingInitGuard = init_from_logging_config(&config.logging, false, false)
120+
.map_err(|e| anyhow::anyhow!("logging init: {}", e))?;
121+
122+
let session = load_session(file).with_context(|| format!("load {}", file.display()))?;
123+
let addr: std::net::SocketAddr = listen
124+
.parse()
125+
.with_context(|| format!("parse listen address {:?}", listen))?;
126+
let listener = TcpListener::bind(addr).await?;
127+
info!(
128+
path = %file.display(),
129+
addr = %listener.local_addr()?,
130+
events = session.events.len(),
131+
"Replay mock backend — connect rsgdb with e.g. --target-host 127.0.0.1 --target-port {}",
132+
listener.local_addr()?.port()
133+
);
134+
135+
loop {
136+
let (sock, peer) = listener.accept().await?;
137+
info!("Replay: backend connection from {}", peer);
138+
let events = session.events.clone();
139+
tokio::spawn(async move {
140+
if let Err(e) = run_mock_backend(sock, events).await {
141+
error!("replay session ended: {}", e);
142+
}
143+
});
144+
}
145+
}
146+
103147
fn run_flash_main(
104148
image: &Path,
105149
config_path: Option<&Path>,

src/proxy/server.rs

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//!
33
//! Handles incoming GDB client connections and forwards commands to the backend.
44
5+
use crate::backends::connect_tcp_backend;
56
use crate::config::{ProxyConfig, RecordingConfig};
67
use crate::error::RsgdbError;
78
use crate::protocol::codec::{GdbCodec, PacketOrAck};
@@ -12,10 +13,8 @@ use crate::svd::SvdIndex;
1213
use futures::StreamExt;
1314
use std::net::SocketAddr;
1415
use std::sync::Arc;
15-
use std::time::Duration;
1616
use tokio::net::{TcpListener, TcpStream};
1717
use tokio::sync::Mutex;
18-
use tokio::time::timeout;
1918
use tokio_util::codec::Framed;
2019
use tracing::{debug, error, info, warn};
2120

@@ -89,28 +88,10 @@ async fn handle_connection(
8988
let peer_addr = client_socket.peer_addr().map_err(RsgdbError::Io)?;
9089
info!("Handling connection from {}", peer_addr);
9190

92-
// Connect to the backend
9391
let backend_addr = format!("{}:{}", config.target_host, config.target_port);
9492
debug!("Connecting to backend at {}", backend_addr);
9593

96-
let backend_socket = if config.timeout_secs == 0 {
97-
TcpStream::connect(&backend_addr)
98-
.await
99-
.map_err(RsgdbError::Io)?
100-
} else {
101-
timeout(
102-
Duration::from_secs(config.timeout_secs),
103-
TcpStream::connect(backend_addr.clone()),
104-
)
105-
.await
106-
.map_err(|_| {
107-
RsgdbError::Timeout(format!(
108-
"TCP connect to backend {} exceeded {}s",
109-
backend_addr, config.timeout_secs
110-
))
111-
})?
112-
.map_err(RsgdbError::Io)?
113-
};
94+
let backend = connect_tcp_backend(&config).await?;
11495

11596
info!("Connected to backend at {}", backend_addr);
11697

@@ -127,7 +108,7 @@ async fn handle_connection(
127108
};
128109

129110
// Create a session to manage the connection
130-
let mut session = ProxySession::new(client_socket, backend_socket, config, recorder, svd);
111+
let mut session = ProxySession::new(client_socket, backend, config, recorder, svd);
131112

132113
// Run the session
133114
session.run().await?;
@@ -161,13 +142,12 @@ impl ProxySession {
161142
/// Create a new proxy session
162143
fn new(
163144
client_socket: TcpStream,
164-
backend_socket: TcpStream,
145+
backend: Framed<TcpStream, GdbCodec>,
165146
config: ProxyConfig,
166147
recorder: Option<SessionRecorder>,
167148
svd: Option<Arc<SvdIndex>>,
168149
) -> Self {
169150
let client = Framed::new(client_socket, GdbCodec::new());
170-
let backend = Framed::new(backend_socket, GdbCodec::new());
171151

172152
Self {
173153
client,

src/recorder/jsonl.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ pub enum RecordKind {
3636
Nack,
3737
}
3838

39-
#[derive(Debug, Serialize, Deserialize)]
39+
#[derive(Debug, Clone, Serialize, Deserialize)]
4040
pub struct RecordEventV1 {
4141
pub ts: DateTime<Utc>,
4242
pub direction: RecordDirection,

src/replay/error.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//! Errors for session replay.
2+
3+
use crate::protocol::ProtocolError;
4+
5+
/// Replay / mock-backend failures.
6+
#[derive(Debug, thiserror::Error)]
7+
pub enum ReplayError {
8+
#[error("I/O: {0}")]
9+
Io(#[from] std::io::Error),
10+
11+
#[error("JSON: {0}")]
12+
Json(#[from] serde_json::Error),
13+
14+
#[error("invalid session header: {0}")]
15+
InvalidHeader(String),
16+
17+
#[error("invalid record event: {0}")]
18+
InvalidEvent(String),
19+
20+
#[error(transparent)]
21+
Protocol(#[from] ProtocolError),
22+
23+
#[error(transparent)]
24+
Hex(#[from] hex::FromHexError),
25+
26+
#[error("replay mismatch at step {step}: expected {expected}, got {got}")]
27+
Mismatch {
28+
step: usize,
29+
expected: String,
30+
got: String,
31+
},
32+
33+
#[error("replay ended before mock backend finished (step {step})")]
34+
UnexpectedEof { step: usize },
35+
}

src/replay/jsonl.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
//! Load **rsgdb-record v1** JSONL files for replay.
2+
3+
use std::path::Path;
4+
5+
use crate::recorder::{RecordEventV1, RecordHeaderV1, FORMAT_NAME, FORMAT_VERSION};
6+
7+
use super::ReplayError;
8+
9+
/// Parsed recording: header + ordered events (excluding the header line).
10+
#[derive(Debug, Clone)]
11+
pub struct LoadedSession {
12+
pub header: RecordHeaderV1,
13+
pub events: Vec<RecordEventV1>,
14+
}
15+
16+
/// Read a `.jsonl` session file; validates format name/version on line 1.
17+
pub fn load_session(path: &Path) -> Result<LoadedSession, ReplayError> {
18+
let text = std::fs::read_to_string(path)?;
19+
load_session_str(&text)
20+
}
21+
22+
fn load_session_str(text: &str) -> Result<LoadedSession, ReplayError> {
23+
let mut lines = text.lines();
24+
let first = lines
25+
.next()
26+
.ok_or_else(|| ReplayError::InvalidHeader("empty file".into()))?;
27+
let header: RecordHeaderV1 = serde_json::from_str(first)?;
28+
if header.format != FORMAT_NAME {
29+
return Err(ReplayError::InvalidHeader(format!(
30+
"expected format {:?}, got {:?}",
31+
FORMAT_NAME, header.format
32+
)));
33+
}
34+
if header.version != FORMAT_VERSION {
35+
return Err(ReplayError::InvalidHeader(format!(
36+
"expected version {}, got {}",
37+
FORMAT_VERSION, header.version
38+
)));
39+
}
40+
41+
let mut events = Vec::new();
42+
for line in lines {
43+
if line.trim().is_empty() {
44+
continue;
45+
}
46+
let ev: RecordEventV1 = serde_json::from_str(line)?;
47+
events.push(ev);
48+
}
49+
50+
Ok(LoadedSession { header, events })
51+
}
52+
53+
#[cfg(test)]
54+
mod tests {
55+
use super::*;
56+
use crate::recorder::{RecordDirection, RecordKind};
57+
use chrono::Utc;
58+
59+
#[test]
60+
fn load_round_trip() {
61+
let h = RecordHeaderV1 {
62+
format: FORMAT_NAME.to_string(),
63+
version: FORMAT_VERSION,
64+
session_id: "t".into(),
65+
started_at: Utc::now(),
66+
};
67+
let ev = RecordEventV1 {
68+
ts: Utc::now(),
69+
direction: RecordDirection::ClientToBackend,
70+
kind: RecordKind::Packet,
71+
payload_hex: Some("71737570706f72746564".into()), // "qsupported"
72+
payload_len: Some(10),
73+
};
74+
let mut s = serde_json::to_string(&h).unwrap();
75+
s.push('\n');
76+
s.push_str(&serde_json::to_string(&ev).unwrap());
77+
78+
let loaded = load_session_str(&s).expect("load");
79+
assert_eq!(loaded.events.len(), 1);
80+
assert!(matches!(loaded.events[0].kind, RecordKind::Packet));
81+
}
82+
}

0 commit comments

Comments
 (0)