Do not replay bridge requests after an uncertain result (#1527)
This commit is contained in:
parent
9b08da8d98
commit
de9cc5f12c
|
|
@ -1,6 +1,6 @@
|
|||
//! Client for the desktop app's JSON-lines control protocol
|
||||
//! (`docs/control-protocol.md`): one JSON request per line, replies matched
|
||||
//! by `id`. Keeps one connection and reconnects on failure.
|
||||
//! by `id`. Keeps one connection and reconnects for the next call after a failure.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
|
@ -50,58 +50,55 @@ impl BridgeClient {
|
|||
/// Call a control method; returns its `result` or the app's error.
|
||||
pub async fn call(&self, method: &str, params: Value) -> Result<Value, AutomationError> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
// One retry with a fresh connection (the app may have restarted).
|
||||
for attempt in 0..2 {
|
||||
if guard.is_none() {
|
||||
let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr))
|
||||
.await
|
||||
.map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))?
|
||||
.map_err(|e| {
|
||||
AutomationError::Bridge(format!(
|
||||
"cannot connect to {} ({e}); start the app with `photocraft --control <port>` and matching control credentials",
|
||||
self.addr
|
||||
))
|
||||
})?;
|
||||
let (r, w) = s.into_split();
|
||||
let mut conn = (BufReader::new(r), w);
|
||||
let auth_id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let auth = tokio::time::timeout(Duration::from_secs(5), exchange(&mut conn, auth_id, AUTH_METHOD, &json!({"token": self.token})))
|
||||
.await
|
||||
.map_err(|_| AutomationError::Bridge("control authentication timed out".into()))??;
|
||||
auth?;
|
||||
*guard = Some(conn);
|
||||
if guard.is_none() {
|
||||
let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr))
|
||||
.await
|
||||
.map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))?
|
||||
.map_err(|e| {
|
||||
AutomationError::Bridge(format!(
|
||||
"cannot connect to {} ({e}); start the app with `photocraft --control <port>` and matching control credentials",
|
||||
self.addr
|
||||
))
|
||||
})?;
|
||||
let (r, w) = s.into_split();
|
||||
let mut conn = (BufReader::new(r), w);
|
||||
let auth_id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let auth = tokio::time::timeout(Duration::from_secs(5), exchange(&mut conn, auth_id, AUTH_METHOD, &json!({"token": self.token})))
|
||||
.await
|
||||
.map_err(|_| AutomationError::Bridge("control authentication timed out".into()))??;
|
||||
auth?;
|
||||
*guard = Some(conn);
|
||||
}
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(conn) = guard.as_mut() else {
|
||||
return Err(AutomationError::Bridge(format!("not connected to {}", self.addr)));
|
||||
};
|
||||
match tokio::time::timeout(self.timeout, exchange(conn, id, method, ¶ms)).await {
|
||||
Ok(Ok(Err(error @ AutomationError::BadRequest(_)))) => {
|
||||
// An oversized frame leaves unread bytes. Drop this connection and
|
||||
// report the budget failure without retrying a possibly completed edit.
|
||||
*guard = None;
|
||||
Err(error)
|
||||
}
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(conn) = guard.as_mut() else {
|
||||
return Err(AutomationError::Bridge(format!("not connected to {}", self.addr)));
|
||||
};
|
||||
match tokio::time::timeout(self.timeout, exchange(conn, id, method, ¶ms)).await {
|
||||
Ok(Ok(Err(error @ AutomationError::BadRequest(_)))) => {
|
||||
// An oversized frame leaves unread bytes. Drop this connection and
|
||||
// report the budget failure without retrying a possibly completed edit.
|
||||
*guard = None;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(Ok(v)) => return v,
|
||||
Ok(Err(e)) if attempt == 0 => {
|
||||
*guard = None;
|
||||
let _ = e;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
*guard = None;
|
||||
return Err(e);
|
||||
}
|
||||
Err(_) => {
|
||||
*guard = None;
|
||||
return Err(AutomationError::Bridge(format!("`{method}` timed out after {:?}", self.timeout)));
|
||||
}
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Err(e)) => {
|
||||
// A failed write or lost reply does not prove the edit was not applied.
|
||||
// Reconnect on the next call, but never resend this operation (#1007).
|
||||
*guard = None;
|
||||
Err(AutomationError::Bridge(format!("`{method}` failed: {e}; operation may have completed; inspect state before retrying")))
|
||||
}
|
||||
Err(_) => {
|
||||
*guard = None;
|
||||
Err(AutomationError::Bridge(format!(
|
||||
"`{method}` timed out after {:?}; operation may have completed; inspect state before retrying",
|
||||
self.timeout
|
||||
)))
|
||||
}
|
||||
}
|
||||
Err(AutomationError::Bridge("unreachable".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Outer `Err` = transport failure (retryable); inner = app-level result.
|
||||
/// Outer `Err` = transport failure with an uncertain outcome; inner = app-level result.
|
||||
async fn exchange(conn: &mut Conn, id: u64, method: &str, params: &Value) -> Result<Result<Value, AutomationError>, AutomationError> {
|
||||
let mut line = serde_json::to_string(&json!({"id": id, "method": method, "params": params})).map_err(|e| AutomationError::Other(e.to_string()))?;
|
||||
line.push('\n');
|
||||
|
|
@ -115,7 +112,7 @@ async fn exchange(conn: &mut Conn, id: u64, method: &str, params: &Value) -> Res
|
|||
loop {
|
||||
buf.clear();
|
||||
// Read raw bytes so truncation inside a UTF-8 character still reports the
|
||||
// budget error instead of a retryable decoding/transport error.
|
||||
// budget error instead of a decoding/transport error.
|
||||
let n = (&mut conn.0).take((MAX_RESPONSE_BYTES + 1) as u64).read_until(b'\n', &mut buf).await.map_err(io)?;
|
||||
if n > MAX_RESPONSE_BYTES {
|
||||
return Ok(Err(AutomationError::BadRequest(format!("bridge response exceeds {MAX_RESPONSE_BYTES} bytes; operation may have completed"))));
|
||||
|
|
|
|||
|
|
@ -507,6 +507,59 @@ async fn bridge_forwards_to_control_protocol() {
|
|||
app.abort();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_lost_reply_does_not_replay_an_edit_and_next_call_reconnects() {
|
||||
use photocraft_automation::{BridgeClient, Headless};
|
||||
use std::time::Duration;
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let app = tokio::spawn(async move {
|
||||
let mut backend = Headless::new();
|
||||
backend.handle("doc.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let initial_layers = backend.session.active().unwrap().doc.layer_count();
|
||||
let initial_history = backend.session.active().unwrap().history.past_len();
|
||||
let mut seen = Vec::new();
|
||||
for connection in 0..2 {
|
||||
let (socket, _) = listener.accept().await.unwrap();
|
||||
let (read, mut write) = socket.into_split();
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await.unwrap();
|
||||
let (auth, authenticated) = photocraft_automation::security::authentication_reply(&line, CONTROL_TOKEN);
|
||||
assert!(authenticated, "each connection must authenticate");
|
||||
write.write_all(format!("{auth}\n").as_bytes()).await.unwrap();
|
||||
line.clear();
|
||||
reader.read_line(&mut line).await.unwrap();
|
||||
let request: Value = serde_json::from_str(&line).unwrap();
|
||||
seen.push(request["params"]["command"].as_str().unwrap().to_owned());
|
||||
let result = backend.handle(request["method"].as_str().unwrap(), request["params"].clone()).unwrap();
|
||||
if connection == 0 {
|
||||
// The edit has completed, but both socket halves close before its reply.
|
||||
continue;
|
||||
}
|
||||
let reply = json!({"id": request["id"], "ok": true, "result": result});
|
||||
write.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
|
||||
}
|
||||
let document = backend.session.active().unwrap();
|
||||
assert_eq!(document.doc.layer_count(), initial_layers + 1, "the edit was applied once");
|
||||
assert_eq!(document.history.past_len(), initial_history + 1, "one undo step");
|
||||
seen
|
||||
});
|
||||
let bridge = BridgeClient::new(&addr, CONTROL_TOKEN).unwrap();
|
||||
let result = bridge.call("engine.execute", json!({"command": "layer.new.layer", "params": {"name": "Once"}})).await;
|
||||
let error = result.unwrap_err().to_string();
|
||||
assert!(error.contains("operation may have completed"), "{error}");
|
||||
assert!(error.contains("inspect"), "{error}");
|
||||
let document = bridge.call("engine.execute", json!({"command": "document.inspect"})).await.unwrap();
|
||||
assert_eq!(document["layers"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(app.await.unwrap(), ["layer.new.layer", "document.inspect"]);
|
||||
})
|
||||
.await
|
||||
.expect("bridge lost-reply regression must finish");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_previews_downscale_before_enforcing_the_png_budget() {
|
||||
use photocraft_automation::budgets::MAX_PNG_BYTES;
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ magenta alignment lines. `ui.pointer` drives the same code, so agents get identi
|
|||
- **Headless** (`photocraft-cli mcp`): an in-process `photocraft_engine::Session`. There is no window.
|
||||
- **Bridge** (`photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file <path>`): every tool is forwarded to a running `photocraft --control 7878 --control-token-file <path>` over this protocol, so agents see and drive the live app.
|
||||
|
||||
The bridge keeps one authenticated TCP connection open. It reconnects and authenticates once if a request fails, and it skips reply lines whose `id` doesn't match the request (for example, stale replies to requests that timed out). It only accepts loopback addresses, because the app only listens on loopback. Supply its bearer token with `--control-token-file`, `--control-token`, `PHOTOCRAFT_CONTROL_TOKEN_FILE`, or `PHOTOCRAFT_CONTROL_TOKEN`:
|
||||
The bridge keeps one authenticated TCP connection open. A transport failure while sending a request or waiting for its reply, including a reply timeout, drops the connection and reports that the operation may have completed; inspect the document before retrying an edit. It never automatically resends the failed request. The next separate call reconnects and authenticates, and reply lines whose `id` does not match the request are skipped. It only accepts loopback addresses, because the app only listens on loopback. Supply its bearer token with `--control-token-file`, `--control-token`, `PHOTOCRAFT_CONTROL_TOKEN_FILE`, or `PHOTOCRAFT_CONTROL_TOKEN`:
|
||||
|
||||
```sh
|
||||
photocraft-cli mcp --bridge 127.0.0.1:7878 \
|
||||
|
|
|
|||
Loading…
Reference in New Issue