wsdbg: one-shot mode waits for the reply, not a fixed sleep

One-shot mode drained the socket for --wait seconds (default 2) and exited,
whatever arrived. So every invocation cost two seconds regardless of how fast
the answer came, and a slower one got cut off with no indication that it had
been. Scripts calling wsdbg in a loop paid that per call.

Now that every request is answered, the reply can be matched by ticket the same
way the REPL does: return as soon as it arrives, and treat --wait (now 10s) as
an upper bound rather than a delay. A cpu.status that used to take 2.0s takes
0.03s. Not getting an answer within the bound is reported and exits non-zero
instead of passing silently.

--wait-all keeps the old behaviour, for when the point is to watch broadcasts
(log lines, gpu.stats.feed) rather than ask a question. --raw without a ticket
of its own falls back to it too, since there's nothing to match.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GZq8ZtJmFY7bkX5FVkr3P9
This commit is contained in:
Henrik RydgårdandClaude Opus 5 committed 2026-08-18 09:32:04 +02:00
1 parent ef943ba205
commit d4e2382e1a
2 files changed
+55 -23

No files matched your search

+9 -2
View File
@@ -38,8 +38,8 @@ cargo run -- 12345
}
```
One-shot mode - send a single event and exit after a short wait, handy for
scripting:
One-shot mode - send a single event and exit as soon as its reply arrives, handy
for scripting:
```bash
cargo run -- 12345 gpu.stats.get
@@ -47,6 +47,13 @@ cargo run -- 12345 cpu.setReg thread=0 name=4 value=1000
cargo run -- 12345 --raw '{"event":"cpu.evaluate","expression":"pc"}'
```
The reply is matched by ticket, so this returns in milliseconds rather than
padding every invocation with a fixed sleep. `--wait` (default 10s) is only the
upper bound before it gives up and exits non-zero. Pass `--wait-all` to go back
to "print everything that arrives for `--wait` seconds", which is what you want
when watching broadcasts (log lines, `gpu.stats.feed`) rather than asking a
question.
Type `:help` in the REPL for a quick reminder, `:quit` to disconnect.
## Scripting a multi-step sequence
+46 -21
View File
@@ -54,10 +54,19 @@ struct Args {
#[arg(long, default_value = "127.0.0.1")]
host: String,
/// How long to wait for responses/broadcasts in one-shot mode, in seconds.
#[arg(long, default_value_t = 2.0)]
/// One-shot mode: how long to wait for the response before giving up, in seconds. Normally
/// it returns as soon as the reply for this request arrives (matched by ticket), so this is
/// only an upper bound, not a fixed delay. Pass --wait-all to go back to "print everything
/// that arrives for N seconds".
#[arg(long, default_value_t = 10.0)]
wait: f64,
/// One-shot mode: keep reading for the whole --wait period instead of stopping at the
/// response. Useful for watching broadcasts (log lines, gpu.stats.feed) rather than asking
/// a question.
#[arg(long)]
wait_all: bool,
/// Send this raw JSON text instead of building one from event/params (one-shot mode).
#[arg(long)]
raw: Option<String>,
@@ -322,29 +331,39 @@ fn is_would_block(e: &tungstenite::Error) -> bool {
)
}
fn run_one_shot(mut socket: WebSocket<TcpStream>, json_text: String, wait_secs: f64) -> Result<()> {
// Returns false if the request went unanswered within the timeout, so one-shot mode can exit
// non-zero rather than looking successful after having printed nothing useful.
//
// Every request is answered (PPSSPP acknowledges even the ones whose result arrives later), so
// waiting for this request's ticket beats sleeping for a fixed --wait: a quick question returns
// immediately instead of padding every invocation, and a slow one isn't cut off early.
fn run_one_shot(
mut socket: WebSocket<TcpStream>,
json_text: String,
ticket: Option<u64>,
wait_secs: f64,
wait_all: bool,
) -> Result<bool> {
println!("-> {json_text}");
socket.send(Message::Text(json_text.into()))?;
socket.get_ref().set_read_timeout(Some(Duration::from_millis(50)))?;
let deadline = Instant::now() + Duration::from_secs_f64(wait_secs.max(0.0));
while Instant::now() < deadline {
match socket.read() {
Ok(Message::Text(text)) => print_incoming(&text),
Ok(Message::Close(frame)) => {
println!("[connection closed: {frame:?}]");
break;
}
Ok(_) => {}
Err(ref e) if is_would_block(e) => {}
Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => break,
Err(e) => {
eprintln!("[connection error: {e}]");
break;
}
}
// Without a ticket (--raw with none supplied) there's nothing to match, so fall back to
// draining until the deadline, same as --wait-all.
let Some(ticket) = ticket.filter(|_| !wait_all) else {
pump_until(&mut socket, deadline, |_| false);
return Ok(true);
};
let answered = pump_until(&mut socket, deadline, |v| {
v.get("ticket").and_then(|t| t.as_u64()) == Some(ticket)
});
if !answered {
eprintln!("! timed out after {wait_secs}s waiting for a reply (ticket {ticket})");
}
Ok(())
Ok(answered)
}
fn print_help() {
@@ -746,13 +765,19 @@ fn main() -> Result<()> {
})?;
if let Some(raw) = &args.raw {
return run_one_shot(socket, raw.clone(), args.wait);
// Recover the ticket if the caller supplied one; there's nothing to wait on otherwise.
let ticket = serde_json::from_str::<serde_json::Value>(raw)
.ok()
.and_then(|v| v.get("ticket").and_then(|t| t.as_u64()));
let ok = run_one_shot(socket, raw.clone(), ticket, args.wait, args.wait_all)?;
return if ok { Ok(()) } else { std::process::exit(1) };
}
if let Some(event) = &args.event {
let ticket = next_ticket();
let json_text = build_event_json(event, &args.params, Some(ticket))?;
return run_one_shot(socket, json_text, args.wait);
let ok = run_one_shot(socket, json_text, Some(ticket), args.wait, args.wait_all)?;
return if ok { Ok(()) } else { std::process::exit(1) };
}
// Non-zero exit when something in the script failed, so a caller doesn't have to grep output