mirror of
https://github.com/hrydgard/ppsspp.git
synced 2026-10-22 02:39:05 +02:00
Debugger: Say in "deferred" when a ticketed reply follows
input.buttons.press, gpu.stats.get, gpu.record.dump and the game.state.* operations with wait answer later with a message carrying the request's ticket. Their deferred acknowledgement carried that ticket too, and wsdbg took it for the answer, so a waited save that failed exited 0 and --sync moved on before it was done. Handlers that answer this way now call ReplyLater(), and the deferred event says "replyFollows": true. wsdbg keeps waiting for the reply in one-shot mode, under --sync and for :nowait lines. An error reply or a missing one to a :nowait line now fails the run, a raw line can't reuse a ticket that is still pending, and auto-assigned tickets skip past ones a raw line chose. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c4fd1a9020
commit
7625f7529a
10 files changed
+122
-20
No files matched your search
@@ -288,7 +288,7 @@ void HandleDebuggerRequest(const http::ServerRequest &request) {
|
||||
// exactly what they saw before; see client.config.set for why it can't be the
|
||||
// default.
|
||||
if (client_info.acknowledgeDeferred)
|
||||
ws->Send(DebuggerDeferredEvent(event, root));
|
||||
ws->Send(DebuggerDeferredEvent(event, root, req.RepliesLater()));
|
||||
// Poll more frequently for a second in case this triggers something.
|
||||
highActivity = 1000;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ void WebSocketGPURecordState::Dump(DebuggerRequest &req) {
|
||||
|
||||
const JsonNode *value = req.data.get("ticket");
|
||||
lastTicket_ = value ? json_stringify(value) : "";
|
||||
req.ReplyLater();
|
||||
}
|
||||
|
||||
// This handles the asynchronous gpu.record.dump response.
|
||||
|
||||
@@ -168,6 +168,7 @@ void WebSocketGPUStatsState::Get(DebuggerRequest &req) {
|
||||
|
||||
const JsonNode *value = req.data.get("ticket");
|
||||
lastTicket_ = value ? json_stringify(value) : "";
|
||||
req.ReplyLater();
|
||||
}
|
||||
|
||||
// Setup GPU stats feed (gpu.stats.feed)
|
||||
|
||||
@@ -191,6 +191,7 @@ void WebSocketGameState::StateOperation(DebuggerRequest &req, StateOp op) {
|
||||
}
|
||||
const JsonNode *ticket = req.data.get("ticket");
|
||||
waiting_.push_back({ event, ticket ? json_stringify(ticket) : "", result });
|
||||
req.ReplyLater();
|
||||
}
|
||||
|
||||
void WebSocketGameState::Broadcast(net::WebSocketServer *ws) {
|
||||
|
||||
@@ -198,6 +198,7 @@ void WebSocketInputState::ButtonsPress(DebuggerRequest &req) {
|
||||
// same however fast the emulator runs. This thread only reports when it's over.
|
||||
press.pressId = __CtrlPressFor(press.button, (int)press.duration);
|
||||
pressTickets_.push_back(press);
|
||||
req.ReplyLater();
|
||||
}
|
||||
|
||||
void WebSocketInputState::Broadcast(net::WebSocketServer *ws) {
|
||||
|
||||
@@ -50,14 +50,16 @@ struct WebSocketClientInfo {
|
||||
// later - a resume, a step, a button held for some frames. Deliberately not named after the
|
||||
// request: the event that reports the actual outcome uses that name, and for input.buttons.press
|
||||
// it carries the same ticket too, so anything less distinct would be impossible to tell apart.
|
||||
// replyFollows says that outcome is a reply carrying the request's ticket (see ReplyLater()).
|
||||
struct DebuggerDeferredEvent {
|
||||
DebuggerDeferredEvent(const char *n, const JsonGet data) : name(n) {
|
||||
DebuggerDeferredEvent(const char *n, const JsonGet data, bool replyFollows) : name(n), replyFollows(replyFollows) {
|
||||
const JsonNode *value = data ? data.get("ticket") : nullptr;
|
||||
if (value)
|
||||
ticketRaw = json_stringify(value);
|
||||
}
|
||||
|
||||
const char *name;
|
||||
bool replyFollows;
|
||||
std::string ticketRaw;
|
||||
|
||||
operator std::string() const {
|
||||
@@ -65,6 +67,9 @@ struct DebuggerDeferredEvent {
|
||||
j.begin();
|
||||
j.writeString("event", "deferred");
|
||||
j.writeString("for", name);
|
||||
if (replyFollows) {
|
||||
j.writeBool("replyFollows", true);
|
||||
}
|
||||
if (!ticketRaw.empty()) {
|
||||
j.writeRaw("ticket", ticketRaw);
|
||||
}
|
||||
@@ -136,11 +141,21 @@ struct DebuggerRequest {
|
||||
void Flush();
|
||||
bool Finish();
|
||||
|
||||
// For a handler that answers later with a message carrying the request's ticket, rather than
|
||||
// a ticketless broadcast. The deferred event then tells the client to keep waiting for it.
|
||||
void ReplyLater() {
|
||||
replyLater_ = true;
|
||||
}
|
||||
bool RepliesLater() const {
|
||||
return replyLater_;
|
||||
}
|
||||
|
||||
private:
|
||||
JsonWriter writer_;
|
||||
bool responseBegun_ = false;
|
||||
bool responseSent_ = false;
|
||||
bool responsePartial_ = false;
|
||||
bool replyLater_ = false;
|
||||
};
|
||||
|
||||
class DebuggerSubscriber {
|
||||
|
||||
@@ -93,6 +93,19 @@ trips would otherwise hang the script forever, so it gives up after `--sync-time
|
||||
(default 30), reports it, and makes the run exit non-zero.
|
||||
|
||||
An `error` reply to a line doesn't stop the script, but makes the run exit non-zero at the end.
|
||||
That includes `:nowait` lines, whose replies are still collected (for up to ten seconds at EOF or
|
||||
`:quit`); one that never arrives fails the run too.
|
||||
|
||||
Some requests answer twice: an acknowledgement saying the reply will follow, then the reply itself,
|
||||
both with the request's ticket. `game.state.save`/`load`/`rewind`/`verify` with `wait=true` do, and
|
||||
so do `input.buttons.press`, `gpu.stats.get` and `gpu.record.dump`. Both one-shot mode and `--sync`
|
||||
wait for the reply, so a failed save shows up as an error. To start one and then let the CPU run
|
||||
the frame that finishes it, send it with `:nowait`:
|
||||
|
||||
```
|
||||
:nowait game.state.save path=checkpoint.ppst wait=true
|
||||
cpu.runUntilTime relativeUs=100000
|
||||
```
|
||||
|
||||
Matching is by ticket, always - `--sync` never waits for "whatever message arrives next", which is
|
||||
what used to quietly desynchronise a script. So prefer the `key=value` form for nested parameters
|
||||
|
||||
+74
-16
@@ -127,14 +127,41 @@ fn compact() -> bool {
|
||||
// Tickets of requests sent without waiting, whose reply hasn't arrived yet, so :quit can wait for
|
||||
// them. Global for the same reason as COMPACT: a reply can turn up inside any of the loops that
|
||||
// read the socket (a --sync wait, :sleep, ...), and they all go through print_incoming.
|
||||
static PENDING: Mutex<BTreeSet<u64>> = Mutex::new(BTreeSet::new());
|
||||
// `failed` records an error reply to one of them, or one that never came.
|
||||
struct Pending {
|
||||
tickets: BTreeSet<u64>,
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
static PENDING: Mutex<Pending> = Mutex::new(Pending { tickets: BTreeSet::new(), failed: false });
|
||||
|
||||
fn add_pending(ticket: u64) {
|
||||
PENDING.lock().unwrap().insert(ticket);
|
||||
PENDING.lock().unwrap().tickets.insert(ticket);
|
||||
}
|
||||
|
||||
fn is_pending(ticket: u64) -> bool {
|
||||
PENDING.lock().unwrap().tickets.contains(&ticket)
|
||||
}
|
||||
|
||||
fn pending_count() -> usize {
|
||||
PENDING.lock().unwrap().len()
|
||||
PENDING.lock().unwrap().tickets.len()
|
||||
}
|
||||
|
||||
fn pending_failed() -> bool {
|
||||
PENDING.lock().unwrap().failed
|
||||
}
|
||||
|
||||
// Whether `v` is the answer to the request with this ticket. A deferred event is one, unless it
|
||||
// says replyFollows: then the real reply, carrying the same ticket, is still to come (a waited
|
||||
// savestate operation, input.buttons.press, ...).
|
||||
fn is_final_reply(v: &serde_json::Value, ticket: u64) -> bool {
|
||||
v.get("ticket").and_then(|t| t.as_u64()) == Some(ticket)
|
||||
&& !(v.get("event").and_then(|e| e.as_str()) == Some("deferred")
|
||||
&& v.get("replyFollows").and_then(|r| r.as_bool()) == Some(true))
|
||||
}
|
||||
|
||||
fn is_error(v: &serde_json::Value) -> bool {
|
||||
v.get("event").and_then(|e| e.as_str()) == Some("error")
|
||||
}
|
||||
|
||||
// The "> " prompt is noise in a piped script, and interleaves with incoming messages.
|
||||
@@ -164,6 +191,12 @@ fn next_ticket() -> u64 {
|
||||
TICKET.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
// Keeps auto-assigned tickets clear of one a raw line picked itself, so a late reply to it
|
||||
// can't be mistaken for the answer to a later command.
|
||||
fn reserve_ticket(ticket: u64) {
|
||||
TICKET.fetch_max(ticket.saturating_add(1), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// A minimal shell-like tokenizer: splits on unquoted whitespace, and lets 'single' or "double"
|
||||
// quotes protect spaces (and each other) from being treated as a separator - e.g.
|
||||
// `logFormat="job func v0={v0} a0={a0}"` becomes one token, not four bogus ones that
|
||||
@@ -396,8 +429,14 @@ fn request_deferred_acks(socket: &mut WebSocket<TcpStream>) -> Result<()> {
|
||||
|
||||
fn print_incoming(text: &str) {
|
||||
let parsed = serde_json::from_str::<serde_json::Value>(text);
|
||||
if let Some(t) = parsed.as_ref().ok().and_then(|v| v.get("ticket")).and_then(|t| t.as_u64()) {
|
||||
PENDING.lock().unwrap().remove(&t);
|
||||
if let Ok(v) = &parsed
|
||||
&& let Some(t) = v.get("ticket").and_then(|t| t.as_u64())
|
||||
&& is_final_reply(v, t)
|
||||
{
|
||||
let mut pending = PENDING.lock().unwrap();
|
||||
if pending.tickets.remove(&t) && is_error(v) {
|
||||
pending.failed = true;
|
||||
}
|
||||
}
|
||||
if compact() {
|
||||
// One line per message, event name first so a script can grep for it without having to
|
||||
@@ -510,7 +549,7 @@ fn is_would_block(e: &tungstenite::Error) -> bool {
|
||||
// With deferred acks turned on (see request_deferred_acks) every request is answered, including
|
||||
// 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.
|
||||
// slow one isn't cut off early. A deferred event that says replyFollows isn't the answer yet.
|
||||
fn run_one_shot(
|
||||
mut socket: WebSocket<TcpStream>,
|
||||
json_text: String,
|
||||
@@ -530,9 +569,11 @@ fn run_one_shot(
|
||||
let mut answered = false;
|
||||
let mut failed = false;
|
||||
pump_until(&mut socket, deadline, |v| {
|
||||
if ticket.is_some() && v.get("ticket").and_then(|t| t.as_u64()) == ticket {
|
||||
if let Some(t) = ticket
|
||||
&& is_final_reply(v, t)
|
||||
{
|
||||
answered = true;
|
||||
failed |= v.get("event").and_then(|e| e.as_str()) == Some("error");
|
||||
failed |= is_error(v);
|
||||
}
|
||||
answered && wait_for_ticket
|
||||
});
|
||||
@@ -620,6 +661,12 @@ fn handle_repl_line(socket: &mut WebSocket<TcpStream>, line: &str) -> Result<Opt
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
if let Some(t) = ticket {
|
||||
if is_pending(t) {
|
||||
return Err(anyhow!("ticket {t} is still waiting for its reply - pick another"));
|
||||
}
|
||||
reserve_ticket(t);
|
||||
}
|
||||
warn_if_stepping_muted(&event, obj);
|
||||
match ticket {
|
||||
Some(t) => println!("-> (ticket {t}) {line}"),
|
||||
@@ -647,7 +694,8 @@ fn handle_repl_line(socket: &mut WebSocket<TcpStream>, line: &str) -> Result<Opt
|
||||
// Every request is answered - either a response or an error, both carrying the request's ticket
|
||||
// (PPSSPP acknowledges even the ones whose real result arrives later). So the ticket is always
|
||||
// the thing to wait on, and matching on it is exact: no guessing from message order, and no list
|
||||
// of events that don't answer.
|
||||
// of events that don't answer. When the acknowledgement says replyFollows, the reply it promises
|
||||
// is the answer instead.
|
||||
//
|
||||
// RESUME_FAMILY events additionally mean "let the CPU run", so after the acknowledgement we also
|
||||
// wait for the following cpu.stepping broadcast - the actual "it stopped again" signal a script
|
||||
@@ -663,18 +711,21 @@ fn wait_for_sync_response(
|
||||
) -> bool {
|
||||
let wants_stepping = RESUME_FAMILY.contains(&event);
|
||||
let mut got_ticket = false;
|
||||
let mut accepted = false;
|
||||
let mut got_stepping = !wants_stepping;
|
||||
let mut failed = false;
|
||||
let deadline = Instant::now() + Duration::from_secs_f64(timeout_secs.max(0.0));
|
||||
|
||||
let done = pump_until(socket, deadline, |v| {
|
||||
if v.get("ticket").and_then(|t| t.as_u64()) == Some(ticket) {
|
||||
if is_final_reply(v, ticket) {
|
||||
got_ticket = true;
|
||||
// An error reply is a final answer - there'll be no cpu.stepping to follow.
|
||||
if v.get("event").and_then(|e| e.as_str()) == Some("error") {
|
||||
if is_error(v) {
|
||||
failed = true;
|
||||
got_stepping = true;
|
||||
}
|
||||
} else if v.get("ticket").and_then(|t| t.as_u64()) == Some(ticket) {
|
||||
accepted = true;
|
||||
}
|
||||
// Only counts once the request has been acknowledged, so a cpu.stepping still in flight
|
||||
// from something earlier can't be mistaken for this command's.
|
||||
@@ -687,7 +738,13 @@ fn wait_for_sync_response(
|
||||
if !done {
|
||||
eprintln!(
|
||||
"! --sync: timed out after {timeout_secs}s waiting for '{event}' (ticket {ticket}){}",
|
||||
if got_ticket { ", acknowledged but never stopped" } else { "" }
|
||||
if got_ticket {
|
||||
", acknowledged but never stopped"
|
||||
} else if accepted {
|
||||
", accepted but the reply never came"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
done && !failed
|
||||
@@ -921,6 +978,7 @@ fn drain_pending(socket: &mut WebSocket<TcpStream>, secs: f64) {
|
||||
pending_count(),
|
||||
secs
|
||||
);
|
||||
PENDING.lock().unwrap().failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -969,7 +1027,7 @@ fn run_repl(mut socket: WebSocket<TcpStream>, sync: bool, sync_timeout: f64) ->
|
||||
match words.next() {
|
||||
Some(":quit") | Some(":q") | Some(":exit") => {
|
||||
drain_pending(&mut socket, 10.0);
|
||||
return Ok(!failed);
|
||||
return Ok(!failed && !pending_failed());
|
||||
}
|
||||
Some(":help") | Some(":h") => print_help(),
|
||||
Some(":snapshot") => {
|
||||
@@ -1036,7 +1094,7 @@ fn run_repl(mut socket: WebSocket<TcpStream>, sync: bool, sync_timeout: f64) ->
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
// Piped script hit EOF - same race as :quit.
|
||||
drain_pending(&mut socket, 10.0);
|
||||
return Ok(!failed);
|
||||
return Ok(!failed && !pending_failed());
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => {}
|
||||
}
|
||||
@@ -1048,13 +1106,13 @@ fn run_repl(mut socket: WebSocket<TcpStream>, sync: bool, sync_timeout: f64) ->
|
||||
}
|
||||
Ok(Message::Close(frame)) => {
|
||||
println!("\n[connection closed by PPSSPP: {frame:?}]");
|
||||
return Ok(!failed);
|
||||
return Ok(!failed && !pending_failed());
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(ref e) if is_would_block(e) => {}
|
||||
Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => {
|
||||
println!("\n[connection closed]");
|
||||
return Ok(!failed);
|
||||
return Ok(!failed && !pending_failed());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("\n[connection error: {e}]");
|
||||
|
||||
@@ -88,6 +88,18 @@ stay that way: an extra message would break a client that correlates purely by
|
||||
ticket, and it can't reuse the request's event name because for
|
||||
`input.buttons.press` that is exactly what the real, later answer looks like.
|
||||
|
||||
A `deferred` event with `"replyFollows": true` means the outcome will arrive as
|
||||
a message carrying the request's ticket - the response, or an `error` - so keep
|
||||
waiting for that ticket. That holds for `input.buttons.press`,
|
||||
`gpu.stats.get`, `gpu.record.dump` and the `game.state.*` operations sent with
|
||||
`wait`. Without the field, the outcome (if any) is a ticketless broadcast.
|
||||
|
||||
```json
|
||||
-> { "event": "game.state.save", "path": "a.ppst", "wait": true, "ticket": 8 }
|
||||
<- { "event": "deferred", "for": "game.state.save", "replyFollows": true, "ticket": 8 }
|
||||
<- { "event": "game.state.save", "message": "Saved State", "metadata": "", "ticket": 8 }
|
||||
```
|
||||
|
||||
Errors look like this:
|
||||
```json
|
||||
{ "event": "error", "message": "...", "level": 2, "ticket": 1 }
|
||||
|
||||
@@ -184,5 +184,5 @@ Rewind deltas went from 4-13 MB to 1.3-3.8 MB each, at 10-24 ms of worker time.
|
||||
Headless, wsdbg and the `game.state.*` debugger commands cover all of it. `game.state.save` and
|
||||
`game.state.load` take a `slot` instead of a `path`, which goes through the slot code like the save
|
||||
and load keys: undo rotation, load undo, repeats. Use a scratch `--memstick` for that, not the app's,
|
||||
or the tests overwrite real slots. wsdbg's `--sync` moves on when a request is acknowledged, not when
|
||||
a `wait` result arrives, so put a `:sleep` between steps whose order matters, and one at the end.
|
||||
or the tests overwrite real slots. With `wait=true`, wsdbg's `--sync` waits for the operation's result
|
||||
(it finishes while the CPU is stepping too), and a failed one makes the run exit non-zero.
|
||||
Reference in new issue
Block a user