Bridge: a blocking export runs as an app job, so it neither times out nor freezes the app (#91, #92)
file.exportMedia with wait: true was forwarded to the app unchanged. The app then encoded on its UI
thread: it showed no progress and answered no other control request until the encode ended, and the
control server's 60 s reply limit reported "timeout" for any longer export while it went on and
succeeded.
BridgeClient::execute now starts the export without wait and polls jobs.list until the job
finishes, returning the same {job, path, result} (or the export's error). filmcraft-cli --bridge
exec and MCP bridge mode both use it, and MCP command_run in bridge mode now gets the same progress
notifications and cancellation as headless mode.
Tested against a fake app (wait: true never reaches it, one export started, result and error
returned) and end to end against the desktop app: a blocking demo export returned its 642-frame
result with exit 0 while ui.inspect was answered in ~0.05 s throughout.
Fixes #91
Fixes #92
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a273a95ad2
commit
bd12dcae81
|
|
@ -105,7 +105,8 @@ impl Backend {
|
|||
async fn exec(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
match self {
|
||||
Backend::Local(s) => s.execute(id, params).map_err(|e| e.to_string()),
|
||||
Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})).await.map_err(|e| e.to_string()),
|
||||
// a blocking export runs as an app job polled by the client (#91, #92)
|
||||
Backend::Bridge(b) => b.execute(id, params).await.map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ use tokio::sync::Mutex;
|
|||
|
||||
use crate::AutomationError;
|
||||
|
||||
/// How often a blocking export's app job is polled.
|
||||
const JOB_POLL: Duration = Duration::from_millis(250);
|
||||
|
||||
type Conn = (BufReader<tokio::net::tcp::OwnedReadHalf>, tokio::net::tcp::OwnedWriteHalf);
|
||||
|
||||
pub struct BridgeClient {
|
||||
|
|
@ -29,6 +32,40 @@ impl BridgeClient {
|
|||
Ok(Self { addr, conn: Mutex::new(None), next_id: AtomicU64::new(1) })
|
||||
}
|
||||
|
||||
/// Run engine command `id` in the app. A blocking export (`file.exportMedia` with
|
||||
/// `wait: true`, see [`crate::long_job::LONG_COMMANDS`]) is started as an app job and polled
|
||||
/// here until it finishes, so the app keeps repainting (its status bar shows the progress) and
|
||||
/// answering other requests, and no request waits on the control server's 60 s reply limit
|
||||
/// (#91, #92). The reply is the same as a blocking export's: `{job, path, result}`, or the
|
||||
/// export's error.
|
||||
pub async fn execute(&self, id: &str, mut params: Value) -> Result<Value, AutomationError> {
|
||||
if !crate::long_job::is_long(id, ¶ms) {
|
||||
return self.call("engine.execute", json!({"command": id, "params": params})).await;
|
||||
}
|
||||
if let Some(p) = params.as_object_mut() {
|
||||
p.insert("wait".into(), json!(false));
|
||||
}
|
||||
let mut start = self.call("engine.execute", json!({"command": id, "params": params})).await?;
|
||||
let Some(job) = start.get("job").and_then(Value::as_u64) else { return Ok(start) };
|
||||
let result = loop {
|
||||
tokio::time::sleep(JOB_POLL).await;
|
||||
let jobs = self.call("engine.execute", json!({"command": "jobs.list", "params": {}})).await?;
|
||||
let Some(j) = jobs.as_array().and_then(|a| a.iter().find(|j| j["id"].as_u64() == Some(job))).cloned() else {
|
||||
break Value::Null;
|
||||
};
|
||||
if j["finished"].as_bool() == Some(true) {
|
||||
break j["result"].clone();
|
||||
}
|
||||
};
|
||||
if let Some(e) = result.get("error").and_then(Value::as_str) {
|
||||
return Err(AutomationError::App(format!("export failed: {e}")));
|
||||
}
|
||||
if let Some(o) = start.as_object_mut() {
|
||||
o.insert("result".into(), result);
|
||||
}
|
||||
Ok(start)
|
||||
}
|
||||
|
||||
/// Call a control method; returns `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;
|
||||
|
|
@ -74,3 +111,77 @@ impl BridgeClient {
|
|||
Err(AutomationError::Bridge("unreachable".into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
/// A stand-in for the app's control server: the export starts job 7, which `jobs.list` reports
|
||||
/// running twice and then finished with `result`. Every request is recorded.
|
||||
async fn fake_app(result: Value) -> (String, Arc<Mutex<Vec<Value>>>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let log = seen.clone();
|
||||
tokio::spawn(async move {
|
||||
let (s, _) = listener.accept().await.unwrap();
|
||||
let (r, mut w) = s.into_split();
|
||||
let mut lines = BufReader::new(r).lines();
|
||||
let mut polls = 0;
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let req: Value = serde_json::from_str(&line).unwrap();
|
||||
log.lock().await.push(req.clone());
|
||||
let reply = match req["params"]["command"].as_str() {
|
||||
Some("file.exportMedia") => json!({"job": 7, "path": "/tmp/out.mov"}),
|
||||
Some("jobs.list") => {
|
||||
polls += 1;
|
||||
let finished = polls >= 3;
|
||||
json!([{"id": 7, "finished": finished, "result": if finished { result.clone() } else { Value::Null }}])
|
||||
}
|
||||
_ => json!(null),
|
||||
};
|
||||
let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": reply}));
|
||||
w.write_all(line.as_bytes()).await.unwrap();
|
||||
}
|
||||
});
|
||||
(addr, seen)
|
||||
}
|
||||
|
||||
/// A blocking bridge export runs as an app job (#91, #92): the app never sees `wait: true` (it
|
||||
/// would encode on its UI thread and the reply would time out after 60 s), and the caller still
|
||||
/// gets the finished result.
|
||||
#[tokio::test]
|
||||
async fn a_blocking_export_is_polled_as_a_job() {
|
||||
let (addr, seen) = fake_app(json!({"frames": 642})).await;
|
||||
let b = BridgeClient::new(addr).unwrap();
|
||||
let r = b.execute("file.exportMedia", json!({"path": "/tmp/out.mov", "wait": true})).await.unwrap();
|
||||
assert_eq!(r, json!({"job": 7, "path": "/tmp/out.mov", "result": {"frames": 642}}));
|
||||
let seen = seen.lock().await;
|
||||
assert_eq!(seen[0]["params"]["params"]["wait"], json!(false));
|
||||
assert!(seen.iter().all(|q| q["params"]["params"]["wait"] != json!(true)));
|
||||
assert_eq!(seen.iter().filter(|q| q["params"]["command"] == "file.exportMedia").count(), 1, "started once");
|
||||
assert_eq!(seen.iter().filter(|q| q["params"]["command"] == "jobs.list").count(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_blocking_export_is_an_error() {
|
||||
let (addr, _) = fake_app(json!({"error": "encode: disk full"})).await;
|
||||
let b = BridgeClient::new(addr).unwrap();
|
||||
let e = b.execute("file.exportMedia", json!({"path": "/tmp/out.mov", "wait": true})).await.unwrap_err();
|
||||
assert!(e.to_string().contains("export failed: encode: disk full"), "{e}");
|
||||
}
|
||||
|
||||
/// Everything else is forwarded unchanged.
|
||||
#[tokio::test]
|
||||
async fn other_commands_are_forwarded() {
|
||||
let (addr, seen) = fake_app(Value::Null).await;
|
||||
let b = BridgeClient::new(addr).unwrap();
|
||||
b.execute("file.exportMedia", json!({"path": "/tmp/out.mov"})).await.unwrap();
|
||||
b.execute("sequence.inspect", json!({})).await.unwrap();
|
||||
let seen = seen.lock().await;
|
||||
assert_eq!(seen.len(), 2);
|
||||
assert_eq!(seen[1]["params"]["command"], "sequence.inspect");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -249,7 +249,7 @@ impl FilmcraftMcp {
|
|||
.await
|
||||
.map_err(join_error)?
|
||||
}
|
||||
Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})).await,
|
||||
Backend::Bridge(b) => b.execute(id, params).await,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -592,8 +592,8 @@ impl ServerHandler for FilmcraftMcp {
|
|||
return Err(McpError::invalid_params(m, None));
|
||||
}
|
||||
// A blocking export reports progress and can be cancelled (docs/agents.md § Long exports).
|
||||
// Bridge mode too: the app runs it as a job, so it stays responsive (#91, #92).
|
||||
if request.name == "command_run"
|
||||
&& matches!(&*self.backend, Backend::Headless(_))
|
||||
&& let Some(a) = &request.arguments
|
||||
&& let Some(id) = a.get("id").and_then(Value::as_str)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -97,7 +97,10 @@ progress and stop it:
|
|||
no response, as the MCP cancellation utility asks.
|
||||
- Without `wait` the command returns `{job, path}` at once, as before: poll `jobs.list` and stop it
|
||||
with `jobs.cancel`. Other exports (`file.exportFrame`, interchange formats, …) finish quickly and
|
||||
ignore the token. Bridge mode forwards the call unchanged; the app shows its own progress.
|
||||
ignore the token.
|
||||
- Bridge mode works the same way: the export runs as an app job (the app stays responsive and shows
|
||||
"Exporting… NN%" in its status bar) and the call returns when it is written, however long it
|
||||
takes. `filmcraft-cli --bridge … exec file.exportMedia … wait=true` blocks the same way.
|
||||
|
||||
## 2. Control channel
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue