mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
Merge PR #1574
This commit is contained in:
commit
9f8a17277e
2 files changed
+112
-12
No files matched your search
+109
-12
@@ -65,22 +65,46 @@ impl Remote {
|
||||
Err(last)
|
||||
}
|
||||
|
||||
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
|
||||
/// Send one request line and read the reply line. On failure the flag says whether the request
|
||||
/// may have reached the app (written in full); only an unsent request is safe to resend.
|
||||
fn roundtrip(&mut self, line: &str) -> Result<String, (std::io::Error, bool)> {
|
||||
// A connection the app already closed (it restarted, or closed it after an error reply) is
|
||||
// replaced before anything is sent on it, so that case still reconnects without a resend.
|
||||
if self.conn.as_ref().is_some_and(|(reader, writer)| !reader.buffer().is_empty() || peer_closed(writer)) {
|
||||
self.conn = None;
|
||||
}
|
||||
if self.conn.is_none() {
|
||||
self.reconnect()?;
|
||||
self.reconnect().map_err(|e| (e, false))?;
|
||||
}
|
||||
let Some((reader, writer)) = self.conn.as_mut() else {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected"));
|
||||
return Err((std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected"), false));
|
||||
};
|
||||
writer.write_all(line.as_bytes())?;
|
||||
writer.write_all(b"\n")?;
|
||||
writer.flush()?;
|
||||
let mut data = Vec::with_capacity(line.len() + 1);
|
||||
data.extend_from_slice(line.as_bytes());
|
||||
data.push(b'\n');
|
||||
writer.write_all(&data).map_err(|e| (e, false))?;
|
||||
writer.flush().map_err(|e| (e, true))?;
|
||||
let mut reply = String::new();
|
||||
if reader.read_line(&mut reply)? == 0 {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"));
|
||||
if reader.read_line(&mut reply).map_err(|e| (e, true))? == 0 {
|
||||
return Err((std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"), true));
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
fn unknown_outcome(&self, method: &str, e: &std::io::Error) -> String {
|
||||
let addr = &self.addr;
|
||||
format!("lost the LightCraft app at {addr} after sending `{method}` ({e}); the outcome is unknown and it was not resent, check the app")
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the app has closed `stream` (or it is unusable), checked without blocking or consuming data.
|
||||
fn peer_closed(stream: &TcpStream) -> bool {
|
||||
if stream.set_nonblocking(true).is_err() {
|
||||
return true;
|
||||
}
|
||||
let mut byte = [0u8; 1];
|
||||
let open = matches!(stream.peek(&mut byte), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock);
|
||||
stream.set_nonblocking(false).is_err() || !open
|
||||
}
|
||||
|
||||
impl Backend for Remote {
|
||||
@@ -88,16 +112,26 @@ impl Backend for Remote {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let line = json!({"id": id, "method": method, "params": params}).to_string();
|
||||
// One retry with a fresh connection (the app may have restarted).
|
||||
// Retry once with a fresh connection (the app may have restarted), but only when the request
|
||||
// never reached the app. After a full write the command may have run, so resending it could
|
||||
// apply a mutation twice (toggle, undo, ...): report the unknown outcome instead.
|
||||
let reply = match self.roundtrip(&line) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
Err((_, false)) => {
|
||||
self.conn = None;
|
||||
self.roundtrip(&line).map_err(|e| {
|
||||
self.roundtrip(&line).map_err(|(e, sent)| {
|
||||
self.conn = None;
|
||||
format!("LightCraft app at {} is not reachable: {e} (start it with `lightcraft --control PORT`)", self.addr)
|
||||
if sent {
|
||||
self.unknown_outcome(method, &e)
|
||||
} else {
|
||||
format!("LightCraft app at {} is not reachable: {e} (start it with `lightcraft --control PORT`)", self.addr)
|
||||
}
|
||||
})?
|
||||
}
|
||||
Err((e, true)) => {
|
||||
self.conn = None;
|
||||
return Err(self.unknown_outcome(method, &e));
|
||||
}
|
||||
};
|
||||
let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?;
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
@@ -115,3 +149,66 @@ impl Backend for Remote {
|
||||
format!("remote {}", self.addr)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn lost_reply_is_not_resent() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let seen = Arc::new(AtomicUsize::new(0));
|
||||
let seen2 = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
// Every connection: read the request, count it, drop without replying.
|
||||
while let Ok((s, _)) = listener.accept() {
|
||||
let mut line = String::new();
|
||||
if BufReader::new(s).read_line(&mut line).unwrap_or(0) > 0 {
|
||||
seen2.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut remote = Remote::lazy(&addr);
|
||||
let err = remote.call("engine.execute", json!({})).unwrap_err();
|
||||
assert!(err.contains("outcome is unknown"), "{err}");
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
assert_eq!(seen.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_closed_by_app_reconnects_before_sending() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let seen = Arc::new(AtomicUsize::new(0));
|
||||
let seen2 = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
// Every connection: answer one request, then close (as after an app restart).
|
||||
while let Ok((s, _)) = listener.accept() {
|
||||
let mut writer = s.try_clone().unwrap();
|
||||
let mut line = String::new();
|
||||
if BufReader::new(s).read_line(&mut line).unwrap_or(0) > 0 {
|
||||
seen2.fetch_add(1, Ordering::SeqCst);
|
||||
writer.write_all(b"{\"ok\": true, \"result\": 7}\n").unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut remote = Remote::lazy(&addr);
|
||||
assert_eq!(remote.call("engine.commands", json!({})).unwrap(), json!(7));
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
assert_eq!(remote.call("engine.commands", json!({})).unwrap(), json!(7));
|
||||
assert_eq!(seen.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreachable_app_is_reported() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
drop(listener);
|
||||
let err = Remote::lazy(&addr).call("engine.commands", json!({})).unwrap_err();
|
||||
assert!(err.contains("not reachable"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ it open, `--library` on the same folder fails with "This library is already open
|
||||
`--demo` starts the headless session with the procedurally generated demo library;
|
||||
`--compact` lists only the helper tools (see below). In connect mode the server starts even when
|
||||
the app is not running yet and connects on the first call (and reconnects if the app restarts).
|
||||
A request is sent again only when it never reached the app. If the connection drops after a request
|
||||
was sent but before its reply, the tool fails with "the outcome is unknown" and nothing is resent, so a
|
||||
command such as undo can't run twice: check the app's state before retrying.
|
||||
|
||||
Logs go to stderr; stdout carries only protocol messages.
|
||||
|
||||
|
||||
Reference in new issue
Block a user