Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ bump. Currently experimental: project bundling, project dependencies

# Unreleased

* feat(sync-plugin): the compute-time limit for `plugin` sync steps is now configurable via the `ICP_CLI_PLUGIN_COMPUTE_LIMIT_SECS` environment variable (default `60`). Raise it for compute-heavy plugins (e.g. brotli-compressing a large asset bundle) that legitimately exceed the default, especially on slower CI runners. The limit-exceeded error now names the variable and the current limit, and a malformed value is rejected rather than silently ignored.
* feat: `icp canister link` assigns an existing canister principal to a project canister
* feat: `icp canister create --with-icp` (not supported in `icp deploy`) uses the CMC to create canisters. Only needed for deploying to restricted system subnets.
* feat: `icp deploy --no-create` will error if any canisters do not exist, rather than creating them.
Expand Down
57 changes: 57 additions & 0 deletions crates/icp-cli/tests/sync_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,63 @@ async fn sync_plugin_registers_seed_data() {
);
}

/// A malformed `ICP_CLI_PLUGIN_COMPUTE_LIMIT_SECS` must abort the sync with an
/// actionable error rather than being silently ignored. This also exercises the
/// end-to-end wiring: it proves the override is actually read on the real plugin
/// sync path (if it weren't, the bogus value would be ignored and the sync would
/// proceed), which the unit tests can't cover on their own.
#[tokio::test]
async fn sync_plugin_rejects_invalid_compute_limit_env() {
let ctx = TestContext::new();
let project_dir = ctx.create_project_dir("icp");

let (canister_wasm, plugin_wasm) = build_sync_plugin_example();

let seed_data = project_dir.join("seed-data");
create_dir_all(&seed_data).expect("failed to create seed-data");
write_string(&seed_data.join("fruit-01.txt"), "apple").expect("failed to write fruit-01.txt");

let pm = formatdoc! {r#"
canisters:
- name: my-canister
build:
steps:
- type: script
command: cp '{canister_wasm}' "$ICP_WASM_OUTPUT_PATH"
sync:
steps:
- type: plugin
path: {plugin_wasm}
dirs:
- seed-data

{NETWORK_RANDOM_PORT}
{ENVIRONMENT_RANDOM_PORT}
"#};
write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest");

let _g = ctx.start_network_in(&project_dir, "random-network").await;
ctx.ping_until_healthy(&project_dir, "random-network");

clients::icp(&ctx, &project_dir, Some("random-environment".to_string()))
.mint_cycles(10 * TRILLION);

// deploy runs the sync step, which reads ICP_CLI_PLUGIN_COMPUTE_LIMIT_SECS.
// A non-integer value must abort with a message that names the variable and
// echoes the offending value.
ctx.icp()
.current_dir(&project_dir)
.env("NO_COLOR", "1")
.env("ICP_CLI_PLUGIN_COMPUTE_LIMIT_SECS", "not-a-number")
.args(["deploy", "--environment", "random-environment"])
.assert()
.failure()
.stderr(
contains("invalid ICP_CLI_PLUGIN_COMPUTE_LIMIT_SECS value")
.and(contains("not-a-number")),
);
}

/// A `dirs:` entry that is a symlink (here pointing outside the project) is
/// rejected before the plugin runs, so a preopen cannot escape the canister
/// directory. Symlinks are forbidden outright for now — see
Expand Down
5 changes: 5 additions & 0 deletions crates/icp-sync-plugin/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ pub fn run_plugin(
proxy: Option<Principal>,
identity_principal: Principal,
environment: String,
compute_limit_secs: u64,
stdio: Option<Sender<String>>,
) -> Result<Vec<String>, RunPluginError>
```
Expand Down Expand Up @@ -139,6 +140,10 @@ the elapsed time and the `epoch_deadline_callback` grants it back via
`epoch_extension` — so network latency is *not* charged against the limit. The
ticker thread stops when its RAII guard drops at the end of `run_plugin`.

The deadline in seconds is the `compute_limit_secs` parameter. The CLI resolves
it from the `ICP_CLI_PLUGIN_COMPUTE_LIMIT_SECS` environment variable, defaulting
to `DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS` (60) when unset.

### stdio capture

`LineCapture` implements `StdoutStream`/`OutputStream`, splits guest output on
Expand Down
4 changes: 3 additions & 1 deletion crates/icp-sync-plugin/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
mod path;
mod runtime;

pub use runtime::{RunPluginError, run_plugin};
pub use runtime::{
DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, PLUGIN_COMPUTE_LIMIT_ENV, RunPluginError, run_plugin,
};
86 changes: 80 additions & 6 deletions crates/icp-sync-plugin/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@ use std::time::{Duration, Instant};
const MAX_PLUGIN_OUTPUT: usize = 1024 * 1024; // 1 MiB per stream
// Maximum wasm call-stack depth (in bytes).
const MAX_WASM_STACK: usize = 512 * 1024;
// How many seconds of pure wasm compute a plugin may use (host-call latency is excluded).
const PLUGIN_COMPUTE_LIMIT_SECS: u64 = 60;
/// Default seconds of pure wasm compute a plugin may use (host-call latency is
/// excluded). This is a runaway guard, not a security boundary: the plugin runs
/// locally in a read-only WASI sandbox, so the limit only protects the machine
/// running `icp sync` from a plugin that never terminates. Legitimately heavy
/// plugins (e.g. brotli-compressing a large asset bundle) can exceed it,
/// especially on slower CI runners, so it is overridable via the
/// [`PLUGIN_COMPUTE_LIMIT_ENV`] environment variable.
pub const DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS: u64 = 60;
/// Environment variable that overrides [`DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS`].
pub const PLUGIN_COMPUTE_LIMIT_ENV: &str = "ICP_CLI_PLUGIN_COMPUTE_LIMIT_SECS";

use bytes::Bytes;
use camino::Utf8PathBuf;
Expand Down Expand Up @@ -124,8 +132,12 @@ impl SyncPluginImports for HostState {
// must return wasmtime::Error (= anyhow::Error). Snafu derives std::error::Error
// so .into() converts it via anyhow's blanket From<impl StdError + Send + Sync>.
#[derive(Debug, Snafu)]
#[snafu(display("plugin exceeded the {PLUGIN_COMPUTE_LIMIT_SECS}s compute time limit"))]
struct ComputeTimeLimitExceeded;
#[snafu(display(
"plugin exceeded the {limit_secs}s compute-time limit. If this plugin legitimately needs more compute time (e.g. brotli-compressing a large asset bundle), raise the limit by setting {PLUGIN_COMPUTE_LIMIT_ENV} above {limit_secs}s."
))]
struct ComputeTimeLimitExceeded {
limit_secs: u64,
}

#[derive(Debug, Snafu)]
pub enum RunPluginError {
Expand Down Expand Up @@ -189,6 +201,7 @@ pub enum RunPluginError {
PluginFailed { message: String },
}

#[allow(clippy::too_many_arguments)]
pub fn run_plugin(
wasm_path: Utf8PathBuf,
base_dir: Utf8PathBuf,
Expand All @@ -199,6 +212,7 @@ pub fn run_plugin(
proxy: Option<Principal>,
identity_principal: Principal,
environment: String,
compute_limit_secs: u64,
Comment thread
lwshang marked this conversation as resolved.
stdio: Option<Sender<String>>,
) -> Result<Vec<String>, RunPluginError> {
use wasmtime::component::{Component, Linker};
Expand Down Expand Up @@ -312,13 +326,16 @@ pub fn run_plugin(
)?;

let mut store = Store::new(&engine, host_state);
store.set_epoch_deadline(PLUGIN_COMPUTE_LIMIT_SECS);
store.set_epoch_deadline(compute_limit_secs);
store.epoch_deadline_callback(move |_| {
let extra = epoch_extension.swap(0, Ordering::Relaxed);
if extra > 0 {
Ok(wasmtime::UpdateDeadline::Continue(extra))
} else {
Err(ComputeTimeLimitExceeded.into())
Err(ComputeTimeLimitExceeded {
limit_secs: compute_limit_secs,
}
.into())
}
});

Expand Down Expand Up @@ -538,11 +555,25 @@ mod tests {
None,
anon(),
"test".to_string(),
DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS,
None,
);
assert!(matches!(result, Err(RunPluginError::LoadComponent { .. })));
}

#[test]
fn compute_time_limit_error_reflects_the_configured_limit() {
// The remediation must anchor to the actual limit (not a hardcoded
// literal), so it reads correctly whether the limit is the default or
// an env-var override. Use a distinctive value to catch a regression.
let msg = ComputeTimeLimitExceeded { limit_secs: 120 }.to_string();
assert!(msg.contains("exceeded the 120s"), "got: {msg}");
// The suggestion tells the user to go above the current limit — the
// value must flow into the remediation clause too.
assert!(msg.contains("above 120s"), "got: {msg}");
assert!(msg.contains(PLUGIN_COMPUTE_LIMIT_ENV), "got: {msg}");
}

// -------------------------------------------------------------------------
// Fixture-dependent tests
// -------------------------------------------------------------------------
Expand All @@ -562,6 +593,7 @@ mod tests {
None,
anon(),
"test".to_string(),
DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS,
None,
);
assert!(matches!(result, Err(RunPluginError::PreopenDir { .. })));
Expand Down Expand Up @@ -589,6 +621,7 @@ mod tests {
None,
anon(),
"test".to_string(),
DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS,
None,
);
assert!(matches!(result, Err(RunPluginError::SymlinkDir { .. })));
Expand All @@ -609,6 +642,7 @@ mod tests {
None,
anon(),
"test".to_string(),
DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS,
None,
);
assert!(matches!(result, Err(RunPluginError::ReadFile { .. })));
Expand Down Expand Up @@ -636,6 +670,7 @@ mod tests {
None,
anon(),
"test".to_string(),
DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS,
None,
);
assert!(matches!(result, Err(RunPluginError::SymlinkFile { .. })));
Expand All @@ -656,6 +691,7 @@ mod tests {
None,
anon(),
"ok".to_string(),
DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS,
None,
);
assert!(result.is_ok());
Expand All @@ -676,6 +712,7 @@ mod tests {
None,
anon(),
"error".to_string(),
DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS,
None,
);
assert!(matches!(
Expand All @@ -684,6 +721,41 @@ mod tests {
));
}

#[test]
fn plugin_exceeding_compute_limit_is_trapped() {
let Some(wasm_path) = option_env!("TEST_PLUGIN_WASM") else {
return;
};
// The "spin" fixture busy-loops forever; a 1-second limit keeps the
// test fast while still exercising the epoch-interruption trap.
let result = run_plugin(
wasm_path.into(),
".".into(),
vec![],
vec![],
anon(),
dummy_agent(),
None,
anon(),
"spin".to_string(),
1,
None,
);
let err = result.expect_err("spinning plugin should hit the compute limit");
// The trap surfaces through the CallExec source chain, so walk it and
// assert the message names both the limit and the override env var.
let mut chain = err.to_string();
let mut cur: &dyn std::error::Error = &err;
while let Some(src) = cur.source() {
chain = format!("{chain}: {src}");
cur = src;
}
assert!(
chain.contains("compute-time limit") && chain.contains(PLUGIN_COMPUTE_LIMIT_ENV),
"unexpected error chain: {chain}"
);
}

#[tokio::test(flavor = "multi_thread")]
async fn plugin_stdout_forwarded_through_stdio_channel() {
let Some(wasm_path) = option_env!("TEST_PLUGIN_WASM") else {
Expand All @@ -701,6 +773,7 @@ mod tests {
None,
anon(),
"print".to_string(),
DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS,
Some(tx),
)
});
Expand All @@ -726,6 +799,7 @@ mod tests {
None,
anon(),
"hello".to_string(),
DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS,
Some(tx),
)
});
Expand Down
11 changes: 11 additions & 0 deletions crates/icp-sync-plugin/tests/fixtures/test-plugin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ impl Guest for TestPlugin {
println!("stdout from plugin");
Ok(())
}
"spin" => {
// Busy-loop forever to exercise the host's compute-time limit.
// The epoch-interruption check at the loop back-edge traps this,
// so it never returns. `black_box` keeps the loop from being
// optimized away.
let mut x: u64 = 0;
loop {
x = x.wrapping_add(1);
std::hint::black_box(x);
}
}
_ => Ok(()),
}
}
Expand Down
Loading
Loading