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:
Brandon Thomas 2026-10-07 10:42:28 -04:00
parent a273a95ad2
commit bd12dcae81
4 changed files with 119 additions and 4 deletions

View File

@ -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()),
}
}

View File

@ -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, &params) {
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");
}
}

View File

@ -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)
{

View File

@ -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