Fix #1725: a control request its caller timed out on is withdrawn, not run later
The worker answered "timeout" after 60 s and left the request on the queue, so
the app ran it whenever it next drained the queue: an edit nobody was waiting
for, and a second one if the caller had retried. A request now leaves the
queue once: taken by the app, or given up by its caller, whichever comes
first. The timeout reply says which ("will not run" / "still running").
This commit is contained in:
parent
355bb5c4f6
commit
ea30e411e7
|
|
@ -29,6 +29,21 @@ pub const MAX_CONNECTIONS: usize = 16;
|
||||||
/// Size in bytes of the buffer that [`write_line`] writes a reply line through.
|
/// Size in bytes of the buffer that [`write_line`] writes a reply line through.
|
||||||
const REPLY_CHUNK: usize = 64 * 1024;
|
const REPLY_CHUNK: usize = 64 * 1024;
|
||||||
|
|
||||||
|
/// How long a request waits for the app's reply.
|
||||||
|
const REPLY_WAIT: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// The reply to a request the app didn't answer in [`REPLY_WAIT`]. One it had not started is
|
||||||
|
/// withdrawn, so it never runs after its caller was told it failed; one it is running can't be
|
||||||
|
/// stopped, and the reply says its outcome isn't known.
|
||||||
|
fn timed_out(ticket: &vectorcraft_ui_egui::ControlTicket) -> Value {
|
||||||
|
let error = if ticket.give_up() {
|
||||||
|
"timeout: the app did not start the request in time; it was withdrawn and will not run"
|
||||||
|
} else {
|
||||||
|
"timeout: the app is still running the request; it may yet complete"
|
||||||
|
};
|
||||||
|
json!({"ok": false, "error": error})
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
struct ConnectionTimeouts {
|
struct ConnectionTimeouts {
|
||||||
idle: Duration,
|
idle: Duration,
|
||||||
|
|
@ -106,9 +121,10 @@ fn spawn_connection(
|
||||||
let _slot = slot;
|
let _slot = slot;
|
||||||
serve_socket(stream, timeouts, |method, params| {
|
serve_socket(stream, timeouts, |method, params| {
|
||||||
let (req, rrx) = ControlRequest::new(method, params);
|
let (req, rrx) = ControlRequest::new(method, params);
|
||||||
|
let ticket = req.ticket();
|
||||||
tx.send(req).ok()?;
|
tx.send(req).ok()?;
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
Some(rrx.recv_timeout(REPLY_WAIT).unwrap_or_else(|_| timed_out(&ticket)))
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -455,6 +471,19 @@ mod tests {
|
||||||
json!({"ok": true, "result": {"layers": [{"children": paths, "id": 1, "kind": "Layer", "name": "Layer 1"}]}})
|
json!({"ok": true, "result": {"layers": [{"children": paths, "id": 1, "kind": "Layer", "name": "Layer 1"}]}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #1725: a request its caller gave up before the app took it never runs; one the app took
|
||||||
|
/// first can't be withdrawn, and the timeout says so.
|
||||||
|
#[test]
|
||||||
|
fn a_timed_out_request_is_withdrawn_unless_it_started() {
|
||||||
|
let (waiting, _rx) = ControlRequest::new("engine.execute", json!({}));
|
||||||
|
assert!(timed_out(&waiting.ticket())["error"].as_str().unwrap().contains("will not run"));
|
||||||
|
assert!(!waiting.claim(), "the app skips a withdrawn request");
|
||||||
|
let (running, _rx) = ControlRequest::new("engine.execute", json!({}));
|
||||||
|
assert!(running.claim());
|
||||||
|
assert!(timed_out(&running.ticket())["error"].as_str().unwrap().contains("still running"));
|
||||||
|
assert!(!running.claim(), "a request is taken once");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn requests_are_answered_in_order_with_ids() {
|
fn requests_are_answered_in_order_with_ids() {
|
||||||
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
|
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@
|
||||||
//! - `app.export {path?, format?, …document.export options}`: encoded by the engine, written through
|
//! - `app.export {path?, format?, …document.export options}`: encoded by the engine, written through
|
||||||
//! the host; the document keeps its path. No path → `{dataBase64, format, bytes}` (as headless)
|
//! the host; the document keeps its path. No path → `{dataBase64, format, bytes}` (as headless)
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU8, Ordering};
|
||||||
use std::sync::mpsc::Sender;
|
use std::sync::mpsc::Sender;
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
@ -63,12 +65,41 @@ pub struct ControlRequest {
|
||||||
pub method: String,
|
pub method: String,
|
||||||
pub params: Value,
|
pub params: Value,
|
||||||
pub reply: Sender<ControlResponse>,
|
pub reply: Sender<ControlResponse>,
|
||||||
|
state: Arc<AtomicU8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A request's state: waiting to run, taken by the app ([`ControlRequest::claim`]), or given up
|
||||||
|
/// by its caller before that ([`Ticket::give_up`]). It leaves `QUEUED` once, one way or the other.
|
||||||
|
const QUEUED: u8 = 0;
|
||||||
|
const STARTED: u8 = 1;
|
||||||
|
const GIVEN_UP: u8 = 2;
|
||||||
|
|
||||||
|
/// The caller's side of a queued [`ControlRequest`].
|
||||||
|
pub struct Ticket(Arc<AtomicU8>);
|
||||||
|
|
||||||
|
impl Ticket {
|
||||||
|
/// Stop waiting for the request → `true` when it had not started, and so never runs: a caller
|
||||||
|
/// told "timeout" can then retry without the edit being applied twice, or applied later when
|
||||||
|
/// nobody expects it (#1725). `false`: the app is running it (or has); its outcome is unknown.
|
||||||
|
pub fn give_up(&self) -> bool {
|
||||||
|
self.0.compare_exchange(QUEUED, GIVEN_UP, Ordering::SeqCst, Ordering::SeqCst).is_ok()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ControlRequest {
|
impl ControlRequest {
|
||||||
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
|
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
(Self { method: method.into(), params, reply: tx }, rx)
|
(Self { method: method.into(), params, reply: tx, state: Arc::new(AtomicU8::new(QUEUED)) }, rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The caller's handle on this request, to give it up with.
|
||||||
|
pub fn ticket(&self) -> Ticket {
|
||||||
|
Ticket(self.state.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the request to run it → `false` when its caller gave it up meanwhile (don't run it).
|
||||||
|
pub fn claim(&self) -> bool {
|
||||||
|
self.state.compare_exchange(QUEUED, STARTED, Ordering::SeqCst, Ordering::SeqCst).is_ok()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@ use vectorcraft_engine::cmd::fileio;
|
||||||
use vectorcraft_engine::file_access::{self, AutomationRoots};
|
use vectorcraft_engine::file_access::{self, AutomationRoots};
|
||||||
use vectorcraft_engine::{Session, ViewInfo};
|
use vectorcraft_engine::{Session, ViewInfo};
|
||||||
|
|
||||||
pub use control::{ControlRequest, ControlResponse};
|
pub use control::{ControlRequest, ControlResponse, Ticket as ControlTicket};
|
||||||
pub use state::{UiState, View};
|
pub use state::{UiState, View};
|
||||||
pub use sysclip::{ClipboardProbeFactory, SystemClipboard};
|
pub use sysclip::{ClipboardProbeFactory, SystemClipboard};
|
||||||
|
|
||||||
|
|
@ -769,6 +769,11 @@ impl VectorcraftApp {
|
||||||
let mut handled = 0;
|
let mut handled = 0;
|
||||||
while handled < control::MAX_CONTROL_REQUESTS_PER_FRAME {
|
while handled < control::MAX_CONTROL_REQUESTS_PER_FRAME {
|
||||||
let Ok(req) = rx.try_recv() else { break };
|
let Ok(req) = rx.try_recv() else { break };
|
||||||
|
// Its caller stopped waiting (the app was busy or its window not drawn for too long)
|
||||||
|
// and was told it won't run: an edit applied now would come from nowhere (#1725).
|
||||||
|
if !req.claim() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
handled += 1;
|
handled += 1;
|
||||||
let reply = req.reply.clone();
|
let reply = req.reply.clone();
|
||||||
// Guarded per request: a panic must not drop the channel (taken out of `self` above).
|
// Guarded per request: a panic must not drop the channel (taken out of `self` above).
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,12 @@ and does not append a partial gesture. Clients can retry once earlier input has
|
||||||
scheduled. These limits cover synthetic input admission, separately from socket transport and
|
scheduled. These limits cover synthetic input admission, separately from socket transport and
|
||||||
command-processing timeouts.
|
command-processing timeouts.
|
||||||
|
|
||||||
|
A request the app hasn't answered after **60 s** gets `{"ok": false, "error": "timeout: …"}`. If the
|
||||||
|
app had not started it (it was busy, or its window wasn't being drawn), the request is withdrawn
|
||||||
|
and never runs, and the error says "will not run": retrying is safe. If the app was already
|
||||||
|
running it, the error says "still running": it may yet complete, so check the document before
|
||||||
|
retrying.
|
||||||
|
|
||||||
## Confining file access
|
## Confining file access
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue