From 43d18956bc316ce89f8eccc936fcf3a52ed07cbc Mon Sep 17 00:00:00 2001 From: Martin Raszyk Date: Fri, 3 Jul 2026 06:05:33 +0000 Subject: [PATCH 01/12] feat: management canister's endpoint list_canisters as inter-canister call --- .../wasmtime_embedder/system_api/routing.rs | 3 +- .../system_api/sandbox_safe_system_state.rs | 1 + .../src/canister_manager.rs | 2 + .../src/execution/common.rs | 55 +++++++++++- .../src/execution_environment.rs | 17 +++- .../src/execution_environment/tests.rs | 83 +++++++++++++++++++ .../src/execution_environment_metrics.rs | 1 + .../src/ic00_permissions.rs | 1 + rs/execution_environment/src/query_handler.rs | 51 +----------- rs/execution_environment/src/scheduler.rs | 27 ++++-- .../execution_environment/src/lib.rs | 14 +++- rs/types/management_canister_types/src/lib.rs | 1 + .../types/src/messages/ingress_messages.rs | 3 +- rs/types/types/src/messages/inter_canister.rs | 3 +- 14 files changed, 199 insertions(+), 63 deletions(-) diff --git a/rs/embedders/src/wasmtime_embedder/system_api/routing.rs b/rs/embedders/src/wasmtime_embedder/system_api/routing.rs index d583a858e102..9540b8b59721 100644 --- a/rs/embedders/src/wasmtime_embedder/system_api/routing.rs +++ b/rs/embedders/src/wasmtime_embedder/system_api/routing.rs @@ -65,7 +65,8 @@ pub(super) fn resolve_destination( // Figure out the destination subnet based on the method and the payload. let method = Ic00Method::from_str(method_name); match method { - Ok(Ic00Method::CreateCanister) + Ok(Ic00Method::ListCanisters) + | Ok(Ic00Method::CreateCanister) | Ok(Ic00Method::RawRand) | Ok(Ic00Method::ProvisionalCreateCanisterWithCycles) | Ok(Ic00Method::HttpRequest) diff --git a/rs/embedders/src/wasmtime_embedder/system_api/sandbox_safe_system_state.rs b/rs/embedders/src/wasmtime_embedder/system_api/sandbox_safe_system_state.rs index 7014b8941a34..29dd0ae8ad52 100644 --- a/rs/embedders/src/wasmtime_embedder/system_api/sandbox_safe_system_state.rs +++ b/rs/embedders/src/wasmtime_embedder/system_api/sandbox_safe_system_state.rs @@ -314,6 +314,7 @@ impl SystemStateModifications { | Ok(Ic00Method::CanisterStatus) | Ok(Ic00Method::CanisterInfo) | Ok(Ic00Method::CanisterMetadata) + | Ok(Ic00Method::ListCanisters) | Ok(Ic00Method::StartCanister) | Ok(Ic00Method::StopCanister) | Ok(Ic00Method::DeleteCanister) diff --git a/rs/execution_environment/src/canister_manager.rs b/rs/execution_environment/src/canister_manager.rs index 706359abd06f..a91d0d87d559 100644 --- a/rs/execution_environment/src/canister_manager.rs +++ b/rs/execution_environment/src/canister_manager.rs @@ -138,6 +138,8 @@ impl CanisterManager { Err(_) | Ok(Ic00Method::CanisterInfo) | Ok(Ic00Method::CanisterMetadata) + // `list_canisters` can only be called via inter-canister calls by subnet admins. + | Ok(Ic00Method::ListCanisters) | Ok(Ic00Method::ECDSAPublicKey) | Ok(Ic00Method::SetupInitialDKG) | Ok(Ic00Method::SignWithECDSA) diff --git a/rs/execution_environment/src/execution/common.rs b/rs/execution_environment/src/execution/common.rs index 79b445711ec9..961e7efb4491 100644 --- a/rs/execution_environment/src/execution/common.rs +++ b/rs/execution_environment/src/execution/common.rs @@ -6,6 +6,7 @@ use crate::{ ExecuteMessageResult, HypervisorMetrics, RoundLimits, as_round_instructions, canister_manager::types::CanisterManagerError, metrics::CallTreeMetrics, }; +use candid::Encode; use ic_base_types::{CanisterId, NumBytes, SubnetId}; use ic_embedders::{ wasm_executor::{CanisterStateChanges, ExecutionStateChanges, SliceExecutionOutput}, @@ -18,11 +19,14 @@ use ic_interfaces::execution_environment::{ HypervisorError, HypervisorResult, SubnetAvailableMemory, WasmExecutionOutput, }; use ic_logger::{ReplicaLogger, error, fatal, info, warn}; -use ic_management_canister_types_private::CanisterStatusType; +use ic_management_canister_types_private::{ + CanisterIdRange, CanisterStatusType, EmptyBlob, ListCanistersResponse, Payload as _, +}; +use ic_registry_routing_table::canister_id_into_u64; use ic_registry_subnet_type::SubnetType; use ic_replicated_state::{ CallContext, CallContextAction, CallOrigin, CanisterState, ExecutionState, NetworkTopology, - SystemState, + ReplicatedState, SystemState, }; use ic_types::ingress::{IngressState, IngressStatus, WasmResult}; use ic_types::messages::{ @@ -417,6 +421,53 @@ pub(crate) fn validate_controller_or_subnet_admin( } } +/// Computes the response to the `list_canisters` management canister method. +/// +/// The method takes no arguments and is only available on subnets with subnet +/// admins configured, in which case the caller must be a subnet admin. On +/// success, it returns the Candid-encoded `ListCanistersResponse` listing the +/// ranges of canister IDs hosted on this subnet. +pub(crate) fn list_canisters( + state: &ReplicatedState, + caller: &PrincipalId, + payload: &[u8], +) -> Result, UserError> { + EmptyBlob::decode(payload)?; + match state.get_own_subnet_admins() { + Some(ref admins) => validate_subnet_admin(admins, caller).map_err(UserError::from)?, + None => { + return Err(UserError::new( + ErrorCode::CanisterRejectedMessage, + "list_canisters is only available on subnets with subnet admins", + )); + } + } + let mut canisters: Vec = Vec::new(); + for id in state.canister_states().all_keys() { + let id_u64 = canister_id_into_u64(*id); + match canisters.last_mut() { + Some(last) if canister_id_into_u64(last.end).checked_add(1) == Some(id_u64) => { + last.end = *id; + } + _ => canisters.push(CanisterIdRange { + start: *id, + end: *id, + }), + } + } + let response = ListCanistersResponse { canisters }; + Ok(Encode!(&response).unwrap()) +} + +/// Computes the number of round instructions consumed by executing the +/// `list_canisters` management method against the given state. +/// +/// TODO: Replace this placeholder with an actual cost model (e.g. proportional +/// to the number of canisters hosted on the subnet). +pub(crate) fn list_canisters_instructions(_state: &ReplicatedState) -> NumInstructions { + NumInstructions::new(1) +} + /// Unregisters the callback corresponding to the given response. // // TODO(DSM-95): Consider making this only apply to non-replicated call origins. diff --git a/rs/execution_environment/src/execution_environment.rs b/rs/execution_environment/src/execution_environment.rs index dbff1a82785d..9081d42b5452 100644 --- a/rs/execution_environment/src/execution_environment.rs +++ b/rs/execution_environment/src/execution_environment.rs @@ -6,7 +6,7 @@ use crate::canister_manager::types::{ }; use crate::canister_settings::CanisterSettings; use crate::execution::call_or_task::execute_call_or_task; -use crate::execution::common::validate_controller; +use crate::execution::common::{list_canisters, list_canisters_instructions, validate_controller}; use crate::execution::inspect_message; use crate::execution::response::execute_response; use crate::execution_environment_metrics::{ @@ -1083,6 +1083,21 @@ impl ExecutionEnvironment { } } + Ok(Ic00Method::ListCanisters) => match &msg { + CanisterCall::Request(_) => { + round_limits.instructions -= + as_round_instructions(list_canisters_instructions(&state)); + let res = list_canisters(&state, msg.sender(), payload).map(|res| (res, None)); + ExecuteSubnetMessageResult::Finished { + response: res, + refund: msg.take_cycles(), + } + } + CanisterCall::Ingress(_) => { + self.reject_unexpected_ingress(Ic00Method::ListCanisters) + } + }, + Ok(Ic00Method::CanisterInfo) => match &msg { CanisterCall::Request(_) => { let res = CanisterInfoRequest::decode(payload).and_then(|record| { diff --git a/rs/execution_environment/src/execution_environment/tests.rs b/rs/execution_environment/src/execution_environment/tests.rs index 6f76dcd5e6e9..84f5827df9a5 100644 --- a/rs/execution_environment/src/execution_environment/tests.rs +++ b/rs/execution_environment/src/execution_environment/tests.rs @@ -30,6 +30,7 @@ use ic_test_utilities_execution_environment::{ get_reject, get_reply, }; use ic_test_utilities_metrics::{fetch_histogram_vec_count, metric_vec}; +use ic_test_utilities_state::CanisterStateBuilder; use ic_types::{ CanisterId, CountBytes, PrincipalId, RegistryVersion, canister_http::{CanisterHttpMethod, PricingVersion, Replication, Transform}, @@ -5834,3 +5835,85 @@ fn stopping_canister_not_controlled_by_caller_refunds_cycles() { let res = stop_canister_refunds_cycles(&mut test, proxy, canister_id); let _ = get_reject(res); } + +// A subnet admin canister can call `list_canisters` via an inter-canister call +// and receives the coalesced ranges of canister IDs hosted on the subnet. +#[test] +fn list_canisters_via_inter_canister_call_succeeds() { + let own_subnet = subnet_test_id(1); + let caller_subnet = subnet_test_id(2); + let caller_canister = canister_test_id(1); + let mut test = ExecutionTestBuilder::new() + .with_own_subnet_id(own_subnet) + .with_cost_schedule(CanisterCyclesCostSchedule::Free) + .with_subnet_admins(vec![caller_canister.get()]) + .with_caller(caller_subnet, caller_canister) + .build(); + + // IDs 5, 6, 7 are consecutive (coalesce into [5,7]) and ID 10 is isolated. + for raw_id in [5_u64, 6, 7, 10] { + test.state_mut().put_canister_state( + CanisterStateBuilder::new() + .with_canister_id(CanisterId::from(raw_id)) + .build(), + ); + } + + test.inject_call_to_ic00(Method::ListCanisters, EmptyBlob.encode(), Cycles::new(0)); + test.execute_all(); + + let RequestOrResponse::Response(response) = test.xnet_messages()[0].clone() else { + panic!("Type should be RequestOrResponse::Response"); + }; + assert_eq!(response.originator, caller_canister); + let Payload::Data(data) = &response.response_payload else { + panic!( + "list_canisters was rejected: {:?}", + response.response_payload + ); + }; + let list = ic00::ListCanistersResponse::decode(data).unwrap(); + assert_eq!( + list.canisters, + vec![ + ic00::CanisterIdRange { + start: CanisterId::from(5_u64), + end: CanisterId::from(7_u64), + }, + ic00::CanisterIdRange { + start: CanisterId::from(10_u64), + end: CanisterId::from(10_u64), + }, + ] + ); +} + +// A non-admin canister calling `list_canisters` via an inter-canister call is +// rejected. +#[test] +fn list_canisters_via_inter_canister_call_rejected_for_non_admin() { + let own_subnet = subnet_test_id(1); + let caller_subnet = subnet_test_id(2); + let admin = canister_test_id(1); + let non_admin = canister_test_id(2); + let mut test = ExecutionTestBuilder::new() + .with_own_subnet_id(own_subnet) + .with_cost_schedule(CanisterCyclesCostSchedule::Free) + .with_subnet_admins(vec![admin.get()]) + .with_caller(caller_subnet, non_admin) + .build(); + + test.inject_call_to_ic00(Method::ListCanisters, EmptyBlob.encode(), Cycles::new(0)); + test.execute_all(); + + let RequestOrResponse::Response(response) = test.xnet_messages()[0].clone() else { + panic!("Type should be RequestOrResponse::Response"); + }; + let Payload::Reject(context) = &response.response_payload else { + panic!( + "Expected a reject response, got: {:?}", + response.response_payload + ); + }; + assert_eq!(context.code(), RejectCode::CanisterReject); +} diff --git a/rs/execution_environment/src/execution_environment_metrics.rs b/rs/execution_environment/src/execution_environment_metrics.rs index f4e4836576b3..b5b6d3a4dd32 100644 --- a/rs/execution_environment/src/execution_environment_metrics.rs +++ b/rs/execution_environment/src/execution_environment_metrics.rs @@ -295,6 +295,7 @@ impl ExecutionEnvironmentMetrics { ic00::Method::CanisterStatus | ic00::Method::CanisterInfo | ic00::Method::CanisterMetadata + | ic00::Method::ListCanisters | ic00::Method::CreateCanister | ic00::Method::DeleteCanister | ic00::Method::DepositCycles diff --git a/rs/execution_environment/src/ic00_permissions.rs b/rs/execution_environment/src/ic00_permissions.rs index d9fe996937de..4f515367bfa7 100644 --- a/rs/execution_environment/src/ic00_permissions.rs +++ b/rs/execution_environment/src/ic00_permissions.rs @@ -37,6 +37,7 @@ impl Ic00MethodPermissions { Ic00Method::CanisterStatus | Ic00Method::CanisterInfo | Ic00Method::CanisterMetadata + | Ic00Method::ListCanisters | Ic00Method::DepositCycles | Ic00Method::ECDSAPublicKey | Ic00Method::SignWithECDSA diff --git a/rs/execution_environment/src/query_handler.rs b/rs/execution_environment/src/query_handler.rs index c7c09da86bad..577fd1774dbf 100644 --- a/rs/execution_environment/src/query_handler.rs +++ b/rs/execution_environment/src/query_handler.rs @@ -55,12 +55,10 @@ use tokio::sync::oneshot; use tower::{Service, util::BoxCloneService}; pub(crate) use self::query_scheduler::QueryScheduler; -use crate::execution::common::validate_subnet_admin; +use crate::execution::common::list_canisters; use ic_management_canister_types_private::{ - CanisterIdRange, CanisterIdRecord, CanisterMetricsArgs, EmptyBlob, FetchCanisterLogsRequest, - ListCanistersResponse, Payload, QueryMethod, + CanisterIdRecord, CanisterMetricsArgs, FetchCanisterLogsRequest, Payload, QueryMethod, }; -use ic_registry_routing_table::canister_id_into_u64; pub struct DataCertificateWithDelegationMetadata { pub data_certificate: Vec, @@ -176,47 +174,6 @@ impl InternalHttpQueryHandler { self.local_query_execution_stats.set_epoch(epoch); } - fn list_canisters( - &self, - state: &ReplicatedState, - caller: &ic_types::PrincipalId, - payload: &[u8], - ) -> Result { - match EmptyBlob::decode(payload) { - Err(err) => Err(err), - Ok(EmptyBlob) => { - match state.get_own_subnet_admins() { - Some(ref admins) => { - validate_subnet_admin(admins, caller).map_err(UserError::from)? - } - None => { - return Err(UserError::new( - ErrorCode::CanisterRejectedMessage, - "list_canisters is only available on subnets with subnet admins", - )); - } - } - let mut canisters: Vec = Vec::new(); - for id in state.canister_states().all_keys() { - let id_u64 = canister_id_into_u64(*id); - match canisters.last_mut() { - Some(last) - if canister_id_into_u64(last.end).checked_add(1) == Some(id_u64) => - { - last.end = *id; - } - _ => canisters.push(CanisterIdRange { - start: *id, - end: *id, - }), - } - } - let response = ListCanistersResponse { canisters }; - Ok(WasmResult::Reply(Encode!(&response).unwrap())) - } - } - } - /// Handle a query of type `Query`. pub fn query( &self, @@ -295,8 +252,8 @@ impl InternalHttpQueryHandler { Ok(QueryMethod::ListCanisters) => { let since = Instant::now(); let caller = query.source(); - let result = - self.list_canisters(state.get_ref(), &caller, &query.method_payload); + let result = list_canisters(state.get_ref(), &caller, &query.method_payload) + .map(WasmResult::Reply); self.metrics.observe_subnet_query_message( QueryMethod::ListCanisters, since.elapsed().as_secs_f64(), diff --git a/rs/execution_environment/src/scheduler.rs b/rs/execution_environment/src/scheduler.rs index a498b3238b82..6cee1ed5b1db 100644 --- a/rs/execution_environment/src/scheduler.rs +++ b/rs/execution_environment/src/scheduler.rs @@ -1832,6 +1832,22 @@ fn can_execute_subnet_msg( canister_states: &CanisterStates, round_limits: &mut RoundLimits, ) -> bool { + let msg_method = match msg { + SubnetMessage::Ingress(ingress) => Ic00Method::from_str(ingress.method_name.as_str()).ok(), + SubnetMessage::Request(request) => Ic00Method::from_str(request.method_name.as_str()).ok(), + SubnetMessage::Response { .. } => None, + }; + + // Some heavy methods use round instructions. + let instructions_reached = round_limits.instructions_reached(); + + // `list_canisters` iterates over the subnet's canisters and thus consumes + // round instructions, even though it has no effective canister ID. Defer it + // to a later round if the round instruction limit has already been reached. + if let Some(Ic00Method::ListCanisters) = msg_method { + return !instructions_reached; + } + let Some(effective_canister_id) = msg.effective_canister_id() else { // If there is no effective canister ID, execute the subnet message. return true; @@ -1840,12 +1856,7 @@ fn can_execute_subnet_msg( // If there is no effective canister state, execute the subnet message. return true; }; - let maybe_method = match msg { - SubnetMessage::Ingress(ingress) => Ic00Method::from_str(ingress.method_name.as_str()).ok(), - SubnetMessage::Request(request) => Ic00Method::from_str(request.method_name.as_str()).ok(), - SubnetMessage::Response { .. } => None, - }; - let Some(method) = maybe_method else { + let Some(method) = msg_method else { // If this is a response or the method name is not valid, execute the message. return true; }; @@ -1871,9 +1882,6 @@ fn can_execute_subnet_msg( } }; - // Some heavy methods use round instructions. - let instructions_reached = round_limits.instructions_reached(); - let permissions = Ic00MethodPermissions::new(method); permissions.can_be_executed( instructions_reached, @@ -1908,6 +1916,7 @@ fn get_instruction_limits_for_subnet_message( CanisterStatus | CanisterInfo | CanisterMetadata + | ListCanisters | CreateCanister | DeleteCanister | DepositCycles diff --git a/rs/test_utilities/execution_environment/src/lib.rs b/rs/test_utilities/execution_environment/src/lib.rs index 18cdb905f024..33bba152773f 100644 --- a/rs/test_utilities/execution_environment/src/lib.rs +++ b/rs/test_utilities/execution_environment/src/lib.rs @@ -1724,6 +1724,7 @@ impl ExecutionTest { }; let maybe_canister_id = get_effective_canister_id(message.clone()); let is_install_code = check_is_install_code(message.clone()); + let is_list_canisters = check_is_list_canisters(message.clone()); let mut round_limits = RoundLimits { instructions: RoundInstructions::from(i64::MAX), subnet_available_memory: self.subnet_available_memory, @@ -1808,7 +1809,9 @@ impl ExecutionTest { .insert(canister_id, paused_subnet_message); } } - } else { + } else if !is_list_canisters { + // `list_canisters` has no effective canister ID but still consumes + // round instructions, so it is exempt from this assertion. assert_eq!(slice_instructions_used.get(), 0); } self.check_invariants(); @@ -3292,6 +3295,15 @@ fn check_is_install_code(message: SubnetMessage) -> bool { message.method_name() == "install_code" || message.method_name() == "install_chunked_code" } +fn check_is_list_canisters(message: SubnetMessage) -> bool { + let message = match message { + SubnetMessage::Response(_) => return false, + SubnetMessage::Request(request) => CanisterCall::Request(request), + SubnetMessage::Ingress(ingress) => CanisterCall::Ingress(ingress), + }; + message.method_name() == "list_canisters" +} + pub fn wat_compilation_cost(wat: &str) -> NumInstructions { let wasm = BinaryEncodedWasm::new(wat::parse_str(wat).unwrap()); let config = EmbeddersConfig::default(); diff --git a/rs/types/management_canister_types/src/lib.rs b/rs/types/management_canister_types/src/lib.rs index bea5cb354e86..31e694ba99e5 100644 --- a/rs/types/management_canister_types/src/lib.rs +++ b/rs/types/management_canister_types/src/lib.rs @@ -77,6 +77,7 @@ pub enum Method { CanisterStatus, CanisterInfo, CanisterMetadata, + ListCanisters, CreateCanister, DeleteCanister, DepositCycles, diff --git a/rs/types/types/src/messages/ingress_messages.rs b/rs/types/types/src/messages/ingress_messages.rs index e3e8bb0cd634..9d16d9619ad3 100644 --- a/rs/types/types/src/messages/ingress_messages.rs +++ b/rs/types/types/src/messages/ingress_messages.rs @@ -678,7 +678,8 @@ pub fn extract_effective_canister_id( Err(err) => Err(ParseIngressError::InvalidSubnetPayload(err.to_string())), }, - Ok(Method::SetupInitialDKG) + Ok(Method::ListCanisters) + | Ok(Method::SetupInitialDKG) | Ok(Method::DepositCycles) | Ok(Method::HttpRequest) | Ok(Method::FlexibleHttpRequest) diff --git a/rs/types/types/src/messages/inter_canister.rs b/rs/types/types/src/messages/inter_canister.rs index 91074b85c6ab..ed9beec715bd 100644 --- a/rs/types/types/src/messages/inter_canister.rs +++ b/rs/types/types/src/messages/inter_canister.rs @@ -263,7 +263,8 @@ impl Request { Err(_) => None, } } - Ok(Method::CreateCanister) + Ok(Method::ListCanisters) + | Ok(Method::CreateCanister) | Ok(Method::SetupInitialDKG) | Ok(Method::HttpRequest) | Ok(Method::FlexibleHttpRequest) From f2e640e253c5c74ca7b1a1722bd74d02c35bbe39 Mon Sep 17 00:00:00 2001 From: Martin Raszyk Date: Fri, 3 Jul 2026 07:41:29 +0000 Subject: [PATCH 02/12] benchmark --- .../management_canister/list_canisters.rs | 100 ++++++++++++++++++ .../benches/management_canister/main.rs | 2 + .../test_canister/candid.did | 1 + .../test_canister/src/main.rs | 26 +++++ 4 files changed, 129 insertions(+) create mode 100644 rs/execution_environment/benches/management_canister/list_canisters.rs diff --git a/rs/execution_environment/benches/management_canister/list_canisters.rs b/rs/execution_environment/benches/management_canister/list_canisters.rs new file mode 100644 index 000000000000..1ecb8f22551d --- /dev/null +++ b/rs/execution_environment/benches/management_canister/list_canisters.rs @@ -0,0 +1,100 @@ +use crate::create_canisters::CreateCanistersArgs; +use crate::utils::{CANISTERS_PER_BATCH, expect_reply, test_canister_wasm}; +use candid::{Encode, Principal}; +use criterion::{BenchmarkGroup, Criterion, criterion_group, criterion_main}; +use ic_base_types::CanisterId; +use ic_config::subnet_config::SubnetConfig; +use ic_config::{execution_environment::Config as HypervisorConfig, flag_status::FlagStatus}; +use ic_registry_subnet_type::SubnetType; +use ic_state_machine_tests::{StateMachine, StateMachineBuilder, StateMachineConfig}; +use ic_types_cycles::{CanisterCyclesCostSchedule, Cycles}; + +/// Canister ID assigned to the subnet-admin test canister. On a fresh subnet +/// the first created canister gets the first ID in the subnet's allocation +/// range (i.e. `0`), so the test canister is created first (before the +/// canisters populating the subnet) to receive this ID. +fn admin_canister_id() -> CanisterId { + CanisterId::from_u64(0) +} + +/// Builds a `StateMachine` whose subnet has subnet admins configured (which +/// requires a `Free` cost schedule on an application subnet), installs the test +/// canister as the sole subnet admin, and populates the subnet with +/// `canisters_number` additional canisters. +fn setup_with_canisters(canisters_number: u64) -> (StateMachine, CanisterId) { + let hypervisor_config = HypervisorConfig { + rate_limiting_of_heap_delta: FlagStatus::Disabled, + ..Default::default() + }; + let admin = admin_canister_id(); + let env = StateMachineBuilder::new() + .with_config(Some(StateMachineConfig::new( + SubnetConfig::new(SubnetType::Application), + hypervisor_config, + ))) + .with_checkpoints_enabled(false) + .with_subnet_type(SubnetType::Application) + .with_cost_schedule(CanisterCyclesCostSchedule::Free) + .with_subnet_admins(vec![admin.get()]) + .build(); + + // Create the test canister first so that it receives the first canister ID + // in the subnet's allocation range, which matches the pre-configured + // subnet-admin ID. + let test_canister = env.create_canister_with_cycles(None, Cycles::new(u128::MAX / 2), None); + assert_eq!(test_canister, admin); + env.install_existing_canister(test_canister, test_canister_wasm(), vec![]) + .expect("failed to install the test canister"); + + // Populate the subnet with `canisters_number` additional canisters via the + // test canister (batched inter-canister `create_canister` calls). + if canisters_number > 0 { + let result = env.execute_ingress( + test_canister, + "create_canisters", + Encode!(&CreateCanistersArgs { + canisters_number, + canisters_per_batch: CANISTERS_PER_BATCH, + initial_cycles: 0, + }) + .unwrap(), + ); + let created: Vec = expect_reply(result); + assert_eq!(created.len(), canisters_number as usize); + } + + (env, test_canister) +} + +fn run_bench( + group: &mut BenchmarkGroup, + bench_name: &str, + canisters_number: u64, +) { + // `list_canisters` is read-only, so the environment (and its set of + // canisters) does not change across iterations and can be set up once. + let (env, test_canister) = setup_with_canisters(canisters_number); + group.bench_function(bench_name, |b| { + b.iter(|| { + let result = env.execute_ingress(test_canister, "list_canisters", Encode!().unwrap()); + let ranges: u64 = expect_reply(result); + // At least the range covering the freshly created canisters. + assert!(ranges >= 1); + }); + }); +} + +pub fn list_canisters_benchmark(c: &mut Criterion) { + let mut group = c.benchmark_group("list_canisters"); + + run_bench(&mut group, "10", 10); + run_bench(&mut group, "100", 100); + run_bench(&mut group, "1k", 1_000); + run_bench(&mut group, "10k", 10_000); + run_bench(&mut group, "50k", 50_000); + + group.finish(); +} + +criterion_group!(benchmarks, list_canisters_benchmark); +criterion_main!(benchmarks); diff --git a/rs/execution_environment/benches/management_canister/main.rs b/rs/execution_environment/benches/management_canister/main.rs index 5c2da16b04e3..403120c5f122 100644 --- a/rs/execution_environment/benches/management_canister/main.rs +++ b/rs/execution_environment/benches/management_canister/main.rs @@ -5,6 +5,7 @@ mod create_execution_state; mod ecdsa; mod http_request; mod install_code; +mod list_canisters; mod update_settings; mod utils; @@ -18,6 +19,7 @@ fn all_benchmarks(c: &mut Criterion) { ecdsa::ecdsa_benchmark(c); http_request::http_request_benchmark(c); install_code::install_code_benchmark(c); + list_canisters::list_canisters_benchmark(c); update_settings::update_settings_benchmark(c); } diff --git a/rs/execution_environment/benches/management_canister/test_canister/candid.did b/rs/execution_environment/benches/management_canister/test_canister/candid.did index a743560e0656..da503d6f339b 100644 --- a/rs/execution_environment/benches/management_canister/test_canister/candid.did +++ b/rs/execution_environment/benches/management_canister/test_canister/candid.did @@ -43,4 +43,5 @@ service : { "ecdsa_public_key" : (ecdsa_args) -> (); "sign_with_ecdsa" : (ecdsa_args) -> (); "http_request" : (http_request_args) -> (); + "list_canisters" : () -> (nat64); }; diff --git a/rs/execution_environment/benches/management_canister/test_canister/src/main.rs b/rs/execution_environment/benches/management_canister/test_canister/src/main.rs index 3880ae27d057..18e45ecebd44 100644 --- a/rs/execution_environment/benches/management_canister/test_canister/src/main.rs +++ b/rs/execution_environment/benches/management_canister/test_canister/src/main.rs @@ -14,6 +14,7 @@ use ic_cdk::api::management_canister::main::{ UpdateSettingsArgument, create_canister as ic_cdk_create_canister, install_code as ic_cdk_install_code, update_settings as ic_cdk_update_settings, }; +use ic_cdk::call::Call; use ic_cdk::update; use serde::{Deserialize, Serialize}; @@ -220,4 +221,29 @@ async fn http_request(args: HttpRequestArgs) { }); } +#[derive(Clone, Debug, CandidType, Deserialize, Serialize)] +pub struct CanisterIdRange { + pub start: Principal, + pub end: Principal, +} + +#[derive(Clone, Debug, CandidType, Deserialize, Serialize)] +pub struct ListCanistersResult { + pub canisters: Vec, +} + +/// Calls the management canister's `list_canisters` method (which takes no +/// arguments) and returns the number of canister ID ranges reported for the +/// subnet. This canister must be a subnet admin for the call to succeed. +#[update] +async fn list_canisters() -> u64 { + let result: ListCanistersResult = + Call::unbounded_wait(Principal::management_canister(), "list_canisters") + .await + .expect("list_canisters call failed") + .candid() + .expect("failed to decode list_canisters response"); + result.canisters.len() as u64 +} + fn main() {} From be121f654f8bd397195cf87532a1c3c92d0a73c1 Mon Sep 17 00:00:00 2001 From: Martin Raszyk Date: Fri, 3 Jul 2026 07:44:47 +0000 Subject: [PATCH 03/12] cost model --- rs/execution_environment/src/execution/common.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/rs/execution_environment/src/execution/common.rs b/rs/execution_environment/src/execution/common.rs index 961e7efb4491..1fba046da97d 100644 --- a/rs/execution_environment/src/execution/common.rs +++ b/rs/execution_environment/src/execution/common.rs @@ -462,10 +462,16 @@ pub(crate) fn list_canisters( /// Computes the number of round instructions consumed by executing the /// `list_canisters` management method against the given state. /// -/// TODO: Replace this placeholder with an actual cost model (e.g. proportional -/// to the number of canisters hosted on the subnet). -pub(crate) fn list_canisters_instructions(_state: &ReplicatedState) -> NumInstructions { - NumInstructions::new(1) +/// The cost model was derived from the `list_canisters` benchmark using the +/// conversion `2B instructions = 1 second` (i.e. `2M instructions = 1 ms`): +/// - a base cost of 20M instructions (≈10ms), and +/// - a variable cost of 10K instructions per canister hosted on the subnet +/// (`list_canisters` iterates over all of them to build the ID ranges). +pub(crate) fn list_canisters_instructions(state: &ReplicatedState) -> NumInstructions { + const BASE_INSTRUCTIONS: u64 = 20_000_000; + const INSTRUCTIONS_PER_CANISTER: u64 = 10_000; + let num_canisters = state.num_canisters() as u64; + NumInstructions::new(BASE_INSTRUCTIONS + INSTRUCTIONS_PER_CANISTER * num_canisters) } /// Unregisters the callback corresponding to the given response. From d53e6a962dfdaa67fd5b479b59faab8ebd3b9182 Mon Sep 17 00:00:00 2001 From: Martin Raszyk Date: Fri, 3 Jul 2026 08:44:02 +0000 Subject: [PATCH 04/12] benchmark --- .../management_canister/list_canisters.rs | 31 ++++++--- .../test_canister/candid.did | 1 + .../test_canister/src/main.rs | 63 ++++++++++++++++++- .../src/execution/common.rs | 6 +- 4 files changed, 87 insertions(+), 14 deletions(-) diff --git a/rs/execution_environment/benches/management_canister/list_canisters.rs b/rs/execution_environment/benches/management_canister/list_canisters.rs index 1ecb8f22551d..ec7276596cf9 100644 --- a/rs/execution_environment/benches/management_canister/list_canisters.rs +++ b/rs/execution_environment/benches/management_canister/list_canisters.rs @@ -1,6 +1,6 @@ use crate::create_canisters::CreateCanistersArgs; use crate::utils::{CANISTERS_PER_BATCH, expect_reply, test_canister_wasm}; -use candid::{Encode, Principal}; +use candid::Encode; use criterion::{BenchmarkGroup, Criterion, criterion_group, criterion_main}; use ic_base_types::CanisterId; use ic_config::subnet_config::SubnetConfig; @@ -47,21 +47,33 @@ fn setup_with_canisters(canisters_number: u64) -> (StateMachine, CanisterId) { .expect("failed to install the test canister"); // Populate the subnet with `canisters_number` additional canisters via the - // test canister (batched inter-canister `create_canister` calls). - if canisters_number > 0 { + // test canister (batched inter-canister calls). The canisters are created + // with gaps in between so that the subnet's canister IDs form roughly + // `canisters_number` distinct ranges, which `list_canisters` must report. + // + // The work is split into chunks so that no single ingress message exceeds + // the state machine's per-message tick budget (each chunk creates twice as + // many canisters and then deletes half of them). Gaps are preserved across + // chunk boundaries because each chunk ends with a deleted canister ID. + const CHUNK: u64 = 5_000; + let mut remaining_to_create = canisters_number; + let mut created_ranges = 0; + while remaining_to_create > 0 { + let chunk = remaining_to_create.min(CHUNK); + remaining_to_create -= chunk; let result = env.execute_ingress( test_canister, - "create_canisters", + "create_canisters_with_gaps", Encode!(&CreateCanistersArgs { - canisters_number, + canisters_number: chunk, canisters_per_batch: CANISTERS_PER_BATCH, initial_cycles: 0, }) .unwrap(), ); - let created: Vec = expect_reply(result); - assert_eq!(created.len(), canisters_number as usize); + created_ranges += expect_reply::(result); } + assert_eq!(created_ranges, canisters_number); (env, test_canister) } @@ -78,8 +90,9 @@ fn run_bench( b.iter(|| { let result = env.execute_ingress(test_canister, "list_canisters", Encode!().unwrap()); let ranges: u64 = expect_reply(result); - // At least the range covering the freshly created canisters. - assert!(ranges >= 1); + // The canisters are created with gaps, so there should be roughly + // one range per canister on the subnet. + assert!(ranges >= canisters_number / 2); }); }); } diff --git a/rs/execution_environment/benches/management_canister/test_canister/candid.did b/rs/execution_environment/benches/management_canister/test_canister/candid.did index da503d6f339b..72b2eba18e98 100644 --- a/rs/execution_environment/benches/management_canister/test_canister/candid.did +++ b/rs/execution_environment/benches/management_canister/test_canister/candid.did @@ -38,6 +38,7 @@ type http_request_args = record { service : { "create_canisters" : (create_canisters_args) -> (vec principal); + "create_canisters_with_gaps" : (create_canisters_args) -> (nat64); "install_code" : (install_code_args) -> (); "update_settings" : (update_settings_args) -> (); "ecdsa_public_key" : (ecdsa_args) -> (); diff --git a/rs/execution_environment/benches/management_canister/test_canister/src/main.rs b/rs/execution_environment/benches/management_canister/test_canister/src/main.rs index 18e45ecebd44..e08faff63ce6 100644 --- a/rs/execution_environment/benches/management_canister/test_canister/src/main.rs +++ b/rs/execution_environment/benches/management_canister/test_canister/src/main.rs @@ -10,9 +10,10 @@ use ic_cdk::api::management_canister::http_request::{ http_request as ic_cdk_http_request, }; use ic_cdk::api::management_canister::main::{ - CanisterInstallMode, CanisterSettings, CreateCanisterArgument, InstallCodeArgument, - UpdateSettingsArgument, create_canister as ic_cdk_create_canister, - install_code as ic_cdk_install_code, update_settings as ic_cdk_update_settings, + CanisterIdRecord, CanisterInstallMode, CanisterSettings, CreateCanisterArgument, + InstallCodeArgument, UpdateSettingsArgument, create_canister as ic_cdk_create_canister, + delete_canister as ic_cdk_delete_canister, install_code as ic_cdk_install_code, + stop_canister as ic_cdk_stop_canister, update_settings as ic_cdk_update_settings, }; use ic_cdk::call::Call; use ic_cdk::update; @@ -60,6 +61,62 @@ async fn create_canisters(args: CreateCanistersArgs) -> Vec { result } +/// Creates `2 * args.canisters_number` canisters and then deletes every other +/// one (in canister ID order), leaving `args.canisters_number` canisters +/// separated by gaps. As a result, the management canister's `list_canisters` +/// method reports (roughly) one ID range per remaining canister. Returns the +/// number of remaining canisters. +#[update] +async fn create_canisters_with_gaps(args: CreateCanistersArgs) -> u64 { + let mut canister_ids = create_canisters(CreateCanistersArgs { + canisters_number: args.canisters_number * 2, + canisters_per_batch: args.canisters_per_batch, + initial_cycles: args.initial_cycles, + }) + .await; + + // Canister ID principals encode the canister index in big-endian order, so + // sorting by the principal's bytes yields the numeric canister ID order. + canister_ids.sort_by(|a, b| a.as_slice().cmp(b.as_slice())); + + // Delete every other canister so that the remaining ones are separated by + // gaps (i.e. each remaining canister forms its own ID range). + let to_delete: Vec = canister_ids.iter().skip(1).step_by(2).copied().collect(); + let mut remaining = to_delete.as_slice(); + while !remaining.is_empty() { + let batch_size = (args.canisters_per_batch as usize).min(remaining.len()); + let (batch, rest) = remaining.split_at(batch_size); + remaining = rest; + + // A canister must be stopped before it can be deleted. + let stop_futures: Vec<_> = batch + .iter() + .map(|canister_id| { + ic_cdk_stop_canister(CanisterIdRecord { + canister_id: *canister_id, + }) + }) + .collect(); + join_all(stop_futures).await.into_iter().for_each(|r| { + r.unwrap(); // Reject if there is an error. + }); + + let delete_futures: Vec<_> = batch + .iter() + .map(|canister_id| { + ic_cdk_delete_canister(CanisterIdRecord { + canister_id: *canister_id, + }) + }) + .collect(); + join_all(delete_futures).await.into_iter().for_each(|r| { + r.unwrap(); // Reject if there is an error. + }); + } + + (canister_ids.len() - to_delete.len()) as u64 +} + #[derive(Clone, Debug, CandidType, Deserialize, Serialize)] pub struct InstallCodeArgs { pub canister_ids: Vec, diff --git a/rs/execution_environment/src/execution/common.rs b/rs/execution_environment/src/execution/common.rs index 1fba046da97d..8c45edf53b10 100644 --- a/rs/execution_environment/src/execution/common.rs +++ b/rs/execution_environment/src/execution/common.rs @@ -465,11 +465,13 @@ pub(crate) fn list_canisters( /// The cost model was derived from the `list_canisters` benchmark using the /// conversion `2B instructions = 1 second` (i.e. `2M instructions = 1 ms`): /// - a base cost of 20M instructions (≈10ms), and -/// - a variable cost of 10K instructions per canister hosted on the subnet +/// - a variable cost of 16K instructions per canister hosted on the subnet /// (`list_canisters` iterates over all of them to build the ID ranges). +/// The variable cost reflects the worst case where the canister IDs form +/// gaps so that each canister becomes its own ID range. pub(crate) fn list_canisters_instructions(state: &ReplicatedState) -> NumInstructions { const BASE_INSTRUCTIONS: u64 = 20_000_000; - const INSTRUCTIONS_PER_CANISTER: u64 = 10_000; + const INSTRUCTIONS_PER_CANISTER: u64 = 16_000; let num_canisters = state.num_canisters() as u64; NumInstructions::new(BASE_INSTRUCTIONS + INSTRUCTIONS_PER_CANISTER * num_canisters) } From 97dba826d8154a8fb00cb136aff2dd238647eb01 Mon Sep 17 00:00:00 2001 From: Martin Raszyk Date: Fri, 3 Jul 2026 09:23:12 +0000 Subject: [PATCH 05/12] test --- .../tests/execution_test.rs | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/rs/execution_environment/tests/execution_test.rs b/rs/execution_environment/tests/execution_test.rs index 1f5cc81ce53c..a3b18cfa2433 100644 --- a/rs/execution_environment/tests/execution_test.rs +++ b/rs/execution_environment/tests/execution_test.rs @@ -2832,6 +2832,115 @@ fn canister_status_via_query_call_by_subnet_admin_succeeds() { assert_eq!(canister_status_count(&env), 1); } +// `list_canisters` consumes round instructions according to its cost model (a +// base cost plus a per-canister cost). This test checks that the round +// instruction limit is respected: when many `list_canisters` calls are pending +// at once, the per-round subnet-message instruction budget only allows some of +// them to execute per round, so the rest are deferred to later rounds (i.e. not +// all calls execute in the same round). +#[test] +fn list_canisters_respects_round_instruction_limit() { + // Keep in sync with `list_canisters_instructions` in `execution/common.rs`. + const BASE_INSTRUCTIONS: u64 = 20_000_000; + const INSTRUCTIONS_PER_CANISTER: u64 = 16_000; + // Number of concurrent `list_canisters` calls. Chosen large enough that they + // cannot all fit within a single round's subnet-message instruction budget + // (`max_instructions_per_round / 16`, which with the default configuration + // fits at most ~12 calls of ~20M instructions each). + const NUM_CALLS: u64 = 30; + + // The admin canister is created first so that it gets the first canister ID + // in the subnet's range, matching the configured subnet admin. + let admin = CanisterId::from_u64(0); + let env = StateMachineBuilder::new() + .with_config(Some(StateMachineConfig::new( + SubnetConfig::new(SubnetType::Application), + HypervisorConfig::default(), + ))) + .with_subnet_type(SubnetType::Application) + .with_cost_schedule(CanisterCyclesCostSchedule::Free) + .with_subnet_admins(vec![admin.get()]) + .build(); + + let admin_canister = create_universal_canister_with_cycles( + &env, + Some(CanisterSettingsArgsBuilder::new().build()), + INITIAL_CYCLES_BALANCE, + ); + assert_eq!(admin_canister, admin); + + let num_canisters = env.get_latest_state().num_canisters() as u64; + let cost_per_call = BASE_INSTRUCTIONS + INSTRUCTIONS_PER_CANISTER * num_canisters; + + // Build an update that fires `NUM_CALLS` concurrent `list_canisters` + // inter-canister calls (ignoring their responses) and then replies. After + // this single message executes, all `NUM_CALLS` calls are pending as subnet + // messages at the same time. + let mut update = wasm(); + for _ in 0..NUM_CALLS { + update = update.call_simple( + CanisterId::ic_00(), + "list_canisters", + call_args() + .other_side(EmptyBlob.encode()) + .on_reply(wasm().noop()) + .on_reject(wasm().noop()), + ); + } + let update = update.reply().build(); + + // `send_ingress` executes a single round in which the update runs and + // enqueues all `NUM_CALLS` calls; they are only drained in subsequent rounds. + let baseline = env.subnet_message_instructions(); + let msg_id = env.send_ingress( + PrincipalId::new_anonymous(), + admin_canister, + "update", + update, + ); + + // Execute rounds one at a time, tracking after each round how many + // `list_canisters` calls have been executed so far. Each executed call + // consumes exactly `cost_per_call` round instructions in the subnet-message + // phase (the number of canisters on the subnet does not change), so the + // cumulative charge divided by `cost_per_call` yields the number of executed + // calls. + let executed_so_far = + || ((env.subnet_message_instructions() - baseline) / cost_per_call as f64).round() as u64; + let mut executed_per_round = vec![]; + for _ in 0..100 { + env.tick(); + executed_per_round.push(executed_so_far()); + if executed_so_far() == NUM_CALLS { + break; + } + } + + // The update itself succeeded. + assert_matches!( + env.ingress_status(&msg_id), + IngressStatus::Known { + state: IngressState::Completed(_), + .. + } + ); + + // Not all `list_canisters` calls were executed in the same round: there is a + // round after which some but not all of them had been executed. + assert!( + executed_per_round.iter().any(|&n| n > 0 && n < NUM_CALLS), + "expected list_canisters calls to be spread across rounds, got progression {:?}", + executed_per_round, + ); + // Eventually all of them were executed, each charged exactly per the cost + // model, confirming the round instruction accounting. + assert_eq!(*executed_per_round.last().unwrap(), NUM_CALLS); + assert_eq!( + env.subnet_message_instructions() - baseline, + (NUM_CALLS * cost_per_call) as f64 + ); +} + #[test] fn canister_status_via_query_call_by_neither_controller_nor_subnet_admin_fails() { let subnet_config = SubnetConfig::new(SubnetType::Application); From 571b2f5bb5a4386806d7cae6012686f0bc26efa2 Mon Sep 17 00:00:00 2001 From: Martin Raszyk Date: Fri, 3 Jul 2026 12:38:09 +0000 Subject: [PATCH 06/12] test --- .../tests/execution_test.rs | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/rs/execution_environment/tests/execution_test.rs b/rs/execution_environment/tests/execution_test.rs index a3b18cfa2433..a5b76d94f522 100644 --- a/rs/execution_environment/tests/execution_test.rs +++ b/rs/execution_environment/tests/execution_test.rs @@ -2832,6 +2832,20 @@ fn canister_status_via_query_call_by_subnet_admin_succeeds() { assert_eq!(canister_status_count(&env), 1); } +fn list_canisters_count(env: &StateMachine) -> u64 { + fetch_histogram_vec_stats( + env.metrics_registry(), + "execution_subnet_message_duration_seconds", + ) + .get(&labels(&[ + ("method_name", "ic00_list_canisters"), + ("outcome", "finished"), + ("status", "success"), + ("speed", "fast"), + ])) + .map_or(0, |stats| stats.count) +} + // `list_canisters` consumes round instructions according to its cost model (a // base cost plus a per-canister cost). This test checks that the round // instruction limit is respected: when many `list_canisters` calls are pending @@ -2891,7 +2905,8 @@ fn list_canisters_respects_round_instruction_limit() { // `send_ingress` executes a single round in which the update runs and // enqueues all `NUM_CALLS` calls; they are only drained in subsequent rounds. - let baseline = env.subnet_message_instructions(); + let instructions_baseline = env.subnet_message_instructions(); + let calls_baseline = list_canisters_count(&env); let msg_id = env.send_ingress( PrincipalId::new_anonymous(), admin_canister, @@ -2900,13 +2915,10 @@ fn list_canisters_respects_round_instruction_limit() { ); // Execute rounds one at a time, tracking after each round how many - // `list_canisters` calls have been executed so far. Each executed call - // consumes exactly `cost_per_call` round instructions in the subnet-message - // phase (the number of canisters on the subnet does not change), so the - // cumulative charge divided by `cost_per_call` yields the number of executed - // calls. - let executed_so_far = - || ((env.subnet_message_instructions() - baseline) / cost_per_call as f64).round() as u64; + // `list_canisters` calls have been executed so far, using the + // `execution_subnet_message_duration_seconds` metric to count them + // explicitly (rather than inferring the count from consumed instructions). + let executed_so_far = || list_canisters_count(&env) - calls_baseline; let mut executed_per_round = vec![]; for _ in 0..100 { env.tick(); @@ -2932,11 +2944,12 @@ fn list_canisters_respects_round_instruction_limit() { "expected list_canisters calls to be spread across rounds, got progression {:?}", executed_per_round, ); - // Eventually all of them were executed, each charged exactly per the cost - // model, confirming the round instruction accounting. + // Eventually all of them were executed. assert_eq!(*executed_per_round.last().unwrap(), NUM_CALLS); + // Each executed call was charged exactly per the cost model, confirming the + // round instruction accounting. assert_eq!( - env.subnet_message_instructions() - baseline, + env.subnet_message_instructions() - instructions_baseline, (NUM_CALLS * cost_per_call) as f64 ); } From da58510eec3cf57719ae7b951b02a192b3dc0ef8 Mon Sep 17 00:00:00 2001 From: Martin Raszyk Date: Fri, 3 Jul 2026 12:57:04 +0000 Subject: [PATCH 07/12] tests --- .../management_canister/list_canisters.rs | 3 +- .../src/execution_environment.rs | 9 +- .../tests/execution_test.rs | 95 ++++++++++++++++++- 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/rs/execution_environment/benches/management_canister/list_canisters.rs b/rs/execution_environment/benches/management_canister/list_canisters.rs index ec7276596cf9..ab1622abb40b 100644 --- a/rs/execution_environment/benches/management_canister/list_canisters.rs +++ b/rs/execution_environment/benches/management_canister/list_canisters.rs @@ -20,7 +20,8 @@ fn admin_canister_id() -> CanisterId { /// Builds a `StateMachine` whose subnet has subnet admins configured (which /// requires a `Free` cost schedule on an application subnet), installs the test /// canister as the sole subnet admin, and populates the subnet with -/// `canisters_number` additional canisters. +/// `canisters_number` additional canisters. Returns the `StateMachine` and the +/// test canister ID. fn setup_with_canisters(canisters_number: u64) -> (StateMachine, CanisterId) { let hypervisor_config = HypervisorConfig { rate_limiting_of_heap_delta: FlagStatus::Disabled, diff --git a/rs/execution_environment/src/execution_environment.rs b/rs/execution_environment/src/execution_environment.rs index 9081d42b5452..28d17e37c43e 100644 --- a/rs/execution_environment/src/execution_environment.rs +++ b/rs/execution_environment/src/execution_environment.rs @@ -1085,9 +1085,14 @@ impl ExecutionEnvironment { Ok(Ic00Method::ListCanisters) => match &msg { CanisterCall::Request(_) => { - round_limits.instructions -= - as_round_instructions(list_canisters_instructions(&state)); let res = list_canisters(&state, msg.sender(), payload).map(|res| (res, None)); + // Only charge for the cost of building the response (i.e. the + // canister ID range computation) when access control succeeds; + // a rejected call must not consume round instructions. + if res.is_ok() { + round_limits.instructions -= + as_round_instructions(list_canisters_instructions(&state)); + } ExecuteSubnetMessageResult::Finished { response: res, refund: msg.take_cycles(), diff --git a/rs/execution_environment/tests/execution_test.rs b/rs/execution_environment/tests/execution_test.rs index a5b76d94f522..2a390409b664 100644 --- a/rs/execution_environment/tests/execution_test.rs +++ b/rs/execution_environment/tests/execution_test.rs @@ -13,15 +13,15 @@ use ic_management_canister_types_private::{ CanisterIdRecord, CanisterInstallModeV2, CanisterMetadataRequest, CanisterMetadataResponse, CanisterMetricsArgs, CanisterSettingsArgs, CanisterSettingsArgsBuilder, CanisterStatusResultV2, CreateCanisterArgs, DerivationPath, EcdsaKeyId, EmptyBlob, IC_00, InstallCodeArgsV2, - LoadCanisterSnapshotArgs, MasterPublicKeyId, Method, Payload, SignWithECDSAArgs, - TakeCanisterSnapshotArgs, UpdateSettingsArgs, + ListCanistersResponse, LoadCanisterSnapshotArgs, MasterPublicKeyId, Method, Payload, + SignWithECDSAArgs, TakeCanisterSnapshotArgs, UpdateSettingsArgs, }; use ic_registry_resource_limits::ResourceLimits; use ic_registry_subnet_type::SubnetType; use ic_state_machine_tests::{ ErrorCode, StateMachine, StateMachineBuilder, StateMachineConfig, UserError, }; -use ic_test_utilities_execution_environment::get_reply; +use ic_test_utilities_execution_environment::{get_reject, get_reply}; use ic_test_utilities_metrics::{ fetch_gauge, fetch_histogram_vec_stats, fetch_int_counter, labels, }; @@ -2954,6 +2954,95 @@ fn list_canisters_respects_round_instruction_limit() { ); } +// A subnet admin canister can call `list_canisters` via an inter-canister +// call and receives a successful response. Coalescing of the returned +// canister ID ranges is tested separately by `test_list_canisters_success` in +// `query_handler/tests.rs`. +#[test] +fn list_canisters_via_inter_canister_call_succeeds() { + // The admin canister is created first so that it gets the first canister ID + // in the subnet's range, matching the configured subnet admin. + let admin = CanisterId::from_u64(0); + let env = StateMachineBuilder::new() + .with_config(Some(StateMachineConfig::new( + SubnetConfig::new(SubnetType::Application), + HypervisorConfig::default(), + ))) + .with_subnet_type(SubnetType::Application) + .with_cost_schedule(CanisterCyclesCostSchedule::Free) + .with_subnet_admins(vec![admin.get()]) + .build(); + + let admin_canister = create_universal_canister_with_cycles( + &env, + Some(CanisterSettingsArgsBuilder::new().build()), + INITIAL_CYCLES_BALANCE, + ); + assert_eq!(admin_canister, admin); + + let list_canisters = wasm() + .call_simple( + CanisterId::ic_00(), + "list_canisters", + call_args().other_side(EmptyBlob.encode()), + ) + .build(); + let msg_id = env.send_ingress( + PrincipalId::new_anonymous(), + admin_canister, + "update", + list_canisters, + ); + let reply = get_reply(env.await_ingress(msg_id, 100)); + ListCanistersResponse::decode(&reply).unwrap(); +} + +// A non-admin canister calling `list_canisters` via an inter-canister call is +// rejected, and the rejected call must not consume any round instructions: +// only a successful call pays for computing the response, per the cost model +// checked by `list_canisters_respects_round_instruction_limit` above. +#[test] +fn list_canisters_via_inter_canister_call_rejected_for_non_admin() { + let admin = user_test_id(100).get(); + let env = StateMachineBuilder::new() + .with_config(Some(StateMachineConfig::new( + SubnetConfig::new(SubnetType::Application), + HypervisorConfig::default(), + ))) + .with_subnet_type(SubnetType::Application) + .with_cost_schedule(CanisterCyclesCostSchedule::Free) + .with_subnet_admins(vec![admin]) + .build(); + + let non_admin_canister = create_universal_canister_with_cycles( + &env, + Some(CanisterSettingsArgsBuilder::new().build()), + INITIAL_CYCLES_BALANCE, + ); + + let list_canisters = wasm() + .call_simple( + CanisterId::ic_00(), + "list_canisters", + call_args() + .other_side(EmptyBlob.encode()) + .on_reject(wasm().reject_message().reject()), + ) + .build(); + + let instructions_baseline = env.subnet_message_instructions(); + let msg_id = env.send_ingress( + PrincipalId::new_anonymous(), + non_admin_canister, + "update", + list_canisters, + ); + let reject = get_reject(env.await_ingress(msg_id, 100)); + assert!(reject.contains("Only the subnet admins can perform certain actions")); + + assert_eq!(env.subnet_message_instructions(), instructions_baseline); +} + #[test] fn canister_status_via_query_call_by_neither_controller_nor_subnet_admin_fails() { let subnet_config = SubnetConfig::new(SubnetType::Application); From 88180279e5a9e6ad6abd6a51141abc4798d5eab3 Mon Sep 17 00:00:00 2001 From: Martin Raszyk Date: Fri, 3 Jul 2026 13:13:18 +0000 Subject: [PATCH 08/12] test --- .../src/execution_environment/tests.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/rs/execution_environment/src/execution_environment/tests.rs b/rs/execution_environment/src/execution_environment/tests.rs index 84f5827df9a5..5fd63b3e08a6 100644 --- a/rs/execution_environment/src/execution_environment/tests.rs +++ b/rs/execution_environment/src/execution_environment/tests.rs @@ -5917,3 +5917,27 @@ fn list_canisters_via_inter_canister_call_rejected_for_non_admin() { }; assert_eq!(context.code(), RejectCode::CanisterReject); } + +// `list_canisters` can only be called via an inter-canister call; an ingress +// message is rejected by the ingress filter before execution, since +// `list_canisters` is not among the ic00 methods allowed via ingress, even if +// the caller is a subnet admin. +#[test] +fn list_canisters_via_ingress_fails() { + let admin = user_test_id(1); + let mut test = ExecutionTestBuilder::new() + .with_cost_schedule(CanisterCyclesCostSchedule::Free) + .with_subnet_admins(vec![admin.get()]) + .build(); + test.set_user_id(admin); + + let result = + test.should_accept_ingress_message(IC_00, Method::ListCanisters, EmptyBlob.encode()); + assert_eq!( + result, + Err(UserError::new( + ErrorCode::CanisterRejectedMessage, + "ic00 method list_canisters can not be called via ingress messages" + )) + ); +} From ab76dbde804ffe81fc32412baf7b20464fea73a9 Mon Sep 17 00:00:00 2001 From: Martin Raszyk Date: Fri, 3 Jul 2026 13:15:30 +0000 Subject: [PATCH 09/12] comment --- rs/execution_environment/src/execution_environment.rs | 7 ++++--- rs/execution_environment/tests/execution_test.rs | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/rs/execution_environment/src/execution_environment.rs b/rs/execution_environment/src/execution_environment.rs index 28d17e37c43e..0622386a1b62 100644 --- a/rs/execution_environment/src/execution_environment.rs +++ b/rs/execution_environment/src/execution_environment.rs @@ -1086,9 +1086,10 @@ impl ExecutionEnvironment { Ok(Ic00Method::ListCanisters) => match &msg { CanisterCall::Request(_) => { let res = list_canisters(&state, msg.sender(), payload).map(|res| (res, None)); - // Only charge for the cost of building the response (i.e. the - // canister ID range computation) when access control succeeds; - // a rejected call must not consume round instructions. + // Only deduct round instructions for building the response + // (i.e. the canister ID range computation) when access + // control succeeds; a rejected call must not consume round + // instructions. if res.is_ok() { round_limits.instructions -= as_round_instructions(list_canisters_instructions(&state)); diff --git a/rs/execution_environment/tests/execution_test.rs b/rs/execution_environment/tests/execution_test.rs index 2a390409b664..518c2a8ff5ad 100644 --- a/rs/execution_environment/tests/execution_test.rs +++ b/rs/execution_environment/tests/execution_test.rs @@ -2999,8 +2999,9 @@ fn list_canisters_via_inter_canister_call_succeeds() { // A non-admin canister calling `list_canisters` via an inter-canister call is // rejected, and the rejected call must not consume any round instructions: -// only a successful call pays for computing the response, per the cost model -// checked by `list_canisters_respects_round_instruction_limit` above. +// instructions are only deducted from the round limits for a successful call, +// per the cost model checked by `list_canisters_respects_round_instruction_limit` +// above. #[test] fn list_canisters_via_inter_canister_call_rejected_for_non_admin() { let admin = user_test_id(100).get(); From 347abffc8397b3b4464387385f93b762ad18ad7c Mon Sep 17 00:00:00 2001 From: Martin Raszyk Date: Fri, 3 Jul 2026 13:20:07 +0000 Subject: [PATCH 10/12] test --- .../src/execution_environment/tests.rs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/rs/execution_environment/src/execution_environment/tests.rs b/rs/execution_environment/src/execution_environment/tests.rs index 5fd63b3e08a6..87faad4bff69 100644 --- a/rs/execution_environment/src/execution_environment/tests.rs +++ b/rs/execution_environment/src/execution_environment/tests.rs @@ -5923,7 +5923,7 @@ fn list_canisters_via_inter_canister_call_rejected_for_non_admin() { // `list_canisters` is not among the ic00 methods allowed via ingress, even if // the caller is a subnet admin. #[test] -fn list_canisters_via_ingress_fails() { +fn list_canisters_via_ingress_fails_at_ingress_filter() { let admin = user_test_id(1); let mut test = ExecutionTestBuilder::new() .with_cost_schedule(CanisterCyclesCostSchedule::Free) @@ -5941,3 +5941,25 @@ fn list_canisters_via_ingress_fails() { )) ); } + +// Even if an ingress message reaches `execute_subnet_message`, `list_canisters` +// is still rejected: it can only be called via an inter-canister call, even by +// a subnet admin. +#[test] +fn list_canisters_via_ingress_fails_at_execution() { + let admin = user_test_id(1); + let mut test = ExecutionTestBuilder::new() + .with_cost_schedule(CanisterCyclesCostSchedule::Free) + .with_subnet_admins(vec![admin.get()]) + .build(); + test.set_user_id(admin); + + let err = test + .subnet_message(Method::ListCanisters, EmptyBlob.encode()) + .unwrap_err(); + assert_eq!(err.code(), ErrorCode::CanisterContractViolation); + assert!( + err.description() + .contains("list_canisters cannot be called by a user") + ); +} From 028b57998576c28a5b7f9e235c87b8b60b4a1df1 Mon Sep 17 00:00:00 2001 From: Martin Raszyk Date: Fri, 3 Jul 2026 13:21:33 +0000 Subject: [PATCH 11/12] simplify --- .../src/execution_environment/tests.rs | 83 ------------------- 1 file changed, 83 deletions(-) diff --git a/rs/execution_environment/src/execution_environment/tests.rs b/rs/execution_environment/src/execution_environment/tests.rs index 87faad4bff69..1a4ea6ab6710 100644 --- a/rs/execution_environment/src/execution_environment/tests.rs +++ b/rs/execution_environment/src/execution_environment/tests.rs @@ -30,7 +30,6 @@ use ic_test_utilities_execution_environment::{ get_reject, get_reply, }; use ic_test_utilities_metrics::{fetch_histogram_vec_count, metric_vec}; -use ic_test_utilities_state::CanisterStateBuilder; use ic_types::{ CanisterId, CountBytes, PrincipalId, RegistryVersion, canister_http::{CanisterHttpMethod, PricingVersion, Replication, Transform}, @@ -5836,88 +5835,6 @@ fn stopping_canister_not_controlled_by_caller_refunds_cycles() { let _ = get_reject(res); } -// A subnet admin canister can call `list_canisters` via an inter-canister call -// and receives the coalesced ranges of canister IDs hosted on the subnet. -#[test] -fn list_canisters_via_inter_canister_call_succeeds() { - let own_subnet = subnet_test_id(1); - let caller_subnet = subnet_test_id(2); - let caller_canister = canister_test_id(1); - let mut test = ExecutionTestBuilder::new() - .with_own_subnet_id(own_subnet) - .with_cost_schedule(CanisterCyclesCostSchedule::Free) - .with_subnet_admins(vec![caller_canister.get()]) - .with_caller(caller_subnet, caller_canister) - .build(); - - // IDs 5, 6, 7 are consecutive (coalesce into [5,7]) and ID 10 is isolated. - for raw_id in [5_u64, 6, 7, 10] { - test.state_mut().put_canister_state( - CanisterStateBuilder::new() - .with_canister_id(CanisterId::from(raw_id)) - .build(), - ); - } - - test.inject_call_to_ic00(Method::ListCanisters, EmptyBlob.encode(), Cycles::new(0)); - test.execute_all(); - - let RequestOrResponse::Response(response) = test.xnet_messages()[0].clone() else { - panic!("Type should be RequestOrResponse::Response"); - }; - assert_eq!(response.originator, caller_canister); - let Payload::Data(data) = &response.response_payload else { - panic!( - "list_canisters was rejected: {:?}", - response.response_payload - ); - }; - let list = ic00::ListCanistersResponse::decode(data).unwrap(); - assert_eq!( - list.canisters, - vec![ - ic00::CanisterIdRange { - start: CanisterId::from(5_u64), - end: CanisterId::from(7_u64), - }, - ic00::CanisterIdRange { - start: CanisterId::from(10_u64), - end: CanisterId::from(10_u64), - }, - ] - ); -} - -// A non-admin canister calling `list_canisters` via an inter-canister call is -// rejected. -#[test] -fn list_canisters_via_inter_canister_call_rejected_for_non_admin() { - let own_subnet = subnet_test_id(1); - let caller_subnet = subnet_test_id(2); - let admin = canister_test_id(1); - let non_admin = canister_test_id(2); - let mut test = ExecutionTestBuilder::new() - .with_own_subnet_id(own_subnet) - .with_cost_schedule(CanisterCyclesCostSchedule::Free) - .with_subnet_admins(vec![admin.get()]) - .with_caller(caller_subnet, non_admin) - .build(); - - test.inject_call_to_ic00(Method::ListCanisters, EmptyBlob.encode(), Cycles::new(0)); - test.execute_all(); - - let RequestOrResponse::Response(response) = test.xnet_messages()[0].clone() else { - panic!("Type should be RequestOrResponse::Response"); - }; - let Payload::Reject(context) = &response.response_payload else { - panic!( - "Expected a reject response, got: {:?}", - response.response_payload - ); - }; - assert_eq!(context.code(), RejectCode::CanisterReject); -} - // `list_canisters` can only be called via an inter-canister call; an ingress // message is rejected by the ingress filter before execution, since // `list_canisters` is not among the ic00 methods allowed via ingress, even if From 886c4a9ef03c0bd0bea6b01b6dc4bf74ee389993 Mon Sep 17 00:00:00 2001 From: Martin Raszyk Date: Fri, 3 Jul 2026 13:35:25 +0000 Subject: [PATCH 12/12] note --- rs/execution_environment/src/ic00_permissions.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rs/execution_environment/src/ic00_permissions.rs b/rs/execution_environment/src/ic00_permissions.rs index 4f515367bfa7..76d39db95c7d 100644 --- a/rs/execution_environment/src/ic00_permissions.rs +++ b/rs/execution_environment/src/ic00_permissions.rs @@ -37,6 +37,12 @@ impl Ic00MethodPermissions { Ic00Method::CanisterStatus | Ic00Method::CanisterInfo | Ic00Method::CanisterMetadata + // NOTE: `ListCanisters` does consume round instructions, but it has + // no effective canister ID and therefore never reaches + // `can_be_executed` (see `can_execute_subnet_msg` in `scheduler.rs`), + // so `counts_toward_round_limit` is not consulted for it. Its + // round-instruction deferral is handled by a dedicated special case + // in `can_execute_subnet_msg` instead. | Ic00Method::ListCanisters | Ic00Method::DepositCycles | Ic00Method::ECDSAPublicKey