From 5628f1fe0c22cf56c6752bb5d62f5338f0611827 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 13:39:44 -0800 Subject: [PATCH] Plug-ins: no command call is made after a run's failed call A plug-in that went on calling after a failed `vc_call` still had each later call made on the document, although the run fails and is undone at its end. After the run's first failed call, `vc_call` now returns `-11` (CALL_NO_CONTEXT) and the host makes no further call; the run still fails with the first call's reason. --- crates/plugins/src/host.rs | 12 +++++++++--- crates/plugins/tests/call.rs | 19 +++++++++++++++++++ docs/plugins.md | 2 +- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/plugins/src/host.rs b/crates/plugins/src/host.rs index 5d7802d3c..182456aa7 100644 --- a/crates/plugins/src/host.rs +++ b/crates/plugins/src/host.rs @@ -121,8 +121,9 @@ pub const CALL_COMMAND_FAILED: i32 = -8; pub const CALL_LIMIT: i32 = -9; /// What `vc_call_result` returns: no call has a result yet. pub const CALL_NO_RESULT: i32 = -10; -/// What `vc_call` returns: nothing can be called now (while the module is being loaded, or in a -/// run that isn't a filter's run on a document). In a run, this fails it too. +/// What `vc_call` returns: nothing can be called now (while the module is being loaded, in a run +/// that isn't a filter's run on a document, or after a call in the run failed). In a run, this +/// fails it too. pub const CALL_NO_CONTEXT: i32 = -11; /// Most `vc_call` calls one run makes. @@ -429,9 +430,14 @@ impl Run { /// `vc_call`: checks the call to the command at `id` with the arguments at `args` in `memory` /// and leaves it in [`Run::pending`] for the host to make, or → the code for why it can't be - /// made, which fails the run. The size of the arguments is checked before they are read. + /// made, which fails the run. The size of the arguments is checked before they are read. After + /// the run's first failed call, every call returns [`CALL_NO_CONTEXT`] and none is made. fn call_request(&mut self, memory: &[u8], id: (usize, usize), args: (usize, usize)) -> std::result::Result<(), i32> { self.call_result = None; + // No call is made after the run's first failed call. + if self.call_failure.is_some() { + return Err(CALL_NO_CONTEXT); + } if self.allowed.is_none() { let why = "vc_call: no command can be called here (while the module loads, or in a run that can't make calls)"; return Err(self.fail(CALL_NO_CONTEXT, Error::Abi(why.into()))); diff --git a/crates/plugins/tests/call.rs b/crates/plugins/tests/call.rs index d362dd379..2dd2c7c5e 100644 --- a/crates/plugins/tests/call.rs +++ b/crates/plugins/tests/call.rs @@ -138,6 +138,25 @@ fn a_failed_command_fails_the_run_with_its_reason() { assert_eq!(out, Err(Error::Failed("vc_call `select.all`: nothing is selected".into()))); } +/// After the run's first failed call, a plug-in that goes on calling gets [`CALL_NO_CONTEXT`] for +/// each call and the host makes none of them; the run fails with the first call's reason. +#[test] +fn no_call_is_made_after_a_failed_one() { + let rest = [select_all(), select_all(), select_all()].map(|c| expect(&c, CALL_NO_CONTEXT)).concat(); + let p = calling("test.call.after.failed", &format!("{}{rest}", expect(&select_all(), CALL_COMMAND_FAILED))); + let (out, seen) = run_with(&p, |_| Err(CallFailure { code: CALL_COMMAND_FAILED, why: "nothing is selected".into() })); + assert_eq!(out, Err(Error::Failed("vc_call `select.all`: nothing is selected".into()))); + assert_eq!(seen.len(), 1); + // Also after a call the host refused without making it. + let p = calling( + "test.call.after.refused", + &format!("{}{}", expect(&call("0x2010", "13", "0", "0"), CALL_NOT_ALLOWED), expect(&select_all(), CALL_NO_CONTEXT)), + ); + let (out, seen) = run_with(&p, |_| Ok(Value::Null)); + assert!(out.unwrap_err().to_string().contains("vc_call `object.delete`: the manifest's `commandAllowlist` doesn't list it")); + assert!(seen.is_empty()); +} + /// A result larger than [`MAX_CALL_DATA`] fails the run, and so do more than [`MAX_CALLS`] calls. #[test] fn results_and_calls_are_capped() { diff --git a/docs/plugins.md b/docs/plugins.md index fb92da1d1..026dcc0d9 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -407,7 +407,7 @@ arguments as a JSON object, the params the command takes from any request: `-3` when the arguments are not a JSON object, `-4` when `commandAllowlist` doesn't list the id, `-5` when no command has the id, `-6` when a plug-in may not call the command, `-7` when the command can't run now (nothing is selected), `-8` when the command ran and failed, `-9` when a limit below is passed, and `-11` when nothing can be called (from -the module's start function, or in a run that isn't a filter's run on a document). +the module's start function, in a run that isn't a filter's run on a document, or after a call in the run failed). - Every code also fails the run, whatever the plug-in does next, and the error names the call and why it failed. The run's calls and its output are one undo step, named after the plug-in or its command. A run that fails, traps, runs out of budget or is canceled leaves every document, its selection and its history as they were.