Conversation
…ions `execute_mgmt_operation_on_canister` now runs the cycles and memory usage checks and updates for the memory usage change of a management operation itself, instead of every operation having to call `cycles_and_memory_usage_checks_and_updates` at the right point with the right arguments. The memory usage before the operation is read off the saved canister state and the memory usage after the operation off the updated canister state, so it structurally includes any canister history the operation recorded — which every caller previously had to remember to account for, and which four comments had to explain. The checks are skipped if the operation changed the canister's memory usage not at all and used no instructions to be charged for there, so that operations doing neither are unaffected by them (in particular, they do not start failing for an already frozen canister). Eight operations move over: `uninstall_code`, `take_canister_snapshot`, `load_canister_snapshot`, `upload_chunk`, `clear_chunk_store`, `delete_canister_snapshot`, `create_snapshot_from_metadata` and `write_snapshot_data`. Five of them checked *before* applying their change, projecting the new memory usage; they now apply the change and let the caller read it off the canister. Their signatures shrink accordingly, most of them losing `round_limits`, `subnet_cycles_config` and `resource_saturation` altogether. Four of them also charged for their instructions as part of those checks, i.e. only once the operation had succeeded. They return them in the new `CanisterManagerResponse::instructions_to_charge_on_success` instead, which `execute_mgmt_operation_on_canister` charges for together with the memory usage accounting, so that they are still charged for exactly then. A `debug_assert!` pins that the one operation opting out of the accounting here (`update_settings`, whose freezing threshold check must not be unconditional, since it deliberately tolerates the canister becoming frozen by the updated settings) returns none of them, because they would be silently dropped for it. No behaviour change: the checks, the charges and the errors they produce are the same as before, in the same order, just driven from one place. Three tests are added to pin the behaviour of the operations whose accounting moved the furthest, all of which pass before and after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
The broad accounting refactor warrants final human validation.
Pull request overview
This pull request centralizes cycles and memory accounting for management operations while preserving charging behavior.
Changes:
- Adds centralized post-operation accounting.
- Defers applicable instruction charges until success.
- Updates response types and adds regression tests.
File summaries
| File | Changes |
|---|---|
rs/execution_environment/src/execution_environment.rs |
Centralizes management-operation accounting. |
rs/execution_environment/src/canister_manager/types.rs |
Adds deferred instruction metadata. |
rs/execution_environment/src/canister_manager/tests.rs |
Adds accounting and rollback tests. |
rs/execution_environment/src/canister_manager.rs |
Updates operations and deferred charges. |
rs/execution_environment/src/canister_logs.rs |
Initializes the new response field. |
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 0
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
🔵 Needs a closer look
The broad accounting refactor warrants final human review.
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
…ceeds The cycles and memory usage checks are skipped for an operation that neither changes the canister's memory usage nor charges for instructions, so `clear_chunk_store` of an empty chunk store now succeeds for a frozen canister where it used to fail with `InsufficientCyclesInMemoryGrow`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
The changes require final human review because they are too complex or risky for automated approval.
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
`create_snapshot_from_metadata` used to charge its baseline plus snapshot size instructions via `instructions_to_charge_on_success`, i.e. only once the operation had succeeded. Charge them upfront instead, just like `upload_chunk` does, and record the charge in `ConsumedCyclesForInstructions` so that it survives the canister state rollback on failure. Also clarify the docs of `CanisterManagerResponse::instructions_to_charge_on_success` (which now remarks that those instructions cover work done at checkpoint time) and of `execute_mgmt_operation_on_canister`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Upfront metadata-snapshot charging can incorrectly reject a replacement that lowers memory usage.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Balanced
|
✅ No security or compliance issues detected. Reviewed everything up to 52f33a8. Security Overview
Detected Code Changes
|
execute_mgmt_operation_on_canisternow runs the cycles and memory usage checks and updates for the memory usage change of a management operation itself, instead of every operation having to callcycles_and_memory_usage_checks_and_updatesat the right point with the right arguments. The memory usage before the operation is read off the saved canister state and the memory usage after the operation off the updated canister state, so it structurally includes any canister history the operation recorded — which each operation recording one previously had to remember to account for, and which took five comments to explain at the three call sites moved over here (uninstall_code,take_canister_snapshotandload_canister_snapshot). The operations that keep doing the accounting themselves still do it, and still explain it.Which of the two does the accounting is spelled out per operation by the new
CyclesAndMemoryUsageAccounting, so that it cannot be forgotten. The checks are skipped if the operation changed the canister's memory usage not at all and used no instructions to be charged for there, so that operations doing neither are unaffected by them — in particular, they do not start failing for an already frozen canister (see below for the one place where this changes behaviour).Operations moved over
Eight of them:
uninstall_code,take_canister_snapshot,load_canister_snapshot,upload_chunk,clear_chunk_store,delete_canister_snapshot,create_snapshot_from_metadataandwrite_snapshot_data.Five checked before applying their change, projecting the new memory usage; they now apply the change and let the caller read it off the canister.
Three of them also charged for their instructions as part of those checks, i.e. only once the operation had succeeded.
take_canister_snapshotandload_canister_snapshotreturn them in the newCanisterManagerResponse::instructions_to_charge_on_successinstead, whichexecute_mgmt_operation_on_canistercharges for together with the memory usage accounting, so that they are still charged for exactly then.create_snapshot_from_metadatacharges for them upfront instead — see below.update_settingskeeps doing the checks itself (CyclesAndMemoryUsageAccounting::InOperation), because its freezing threshold check must not be unconditional: it deliberately tolerates the canister becoming frozen by the updated settings, and only fails if the compute or memory allocation increases, so that the freezing threshold can be raised to freeze the canister. Adebug_assert!pins that an operation opting out this way returns noinstructions_to_charge_on_success, since they would be silently dropped for it.create_canisterandrename_canisterare unaffected: their canister is not inReplicatedStateunder its final canister id while the operation runs, so they keep calling the checks directly.One behaviour change: a no-op is no longer rejected for a frozen canister
The operations moved over used to run the checks unconditionally, and the freezing threshold check in
cycles_and_memory_usage_checks_and_updatesfails for a frozen canister even if the memory usage does not change at all (can_withdraw_cycles_with_thresholdwith a zero request fails whenever the threshold exceeds the balance). Since the checks are now skipped if the memory usage is unchanged and no instructions are to be charged for, an operation that does not change the memory usage of a frozen canister now succeeds where it used to fail withInsufficientCyclesInMemoryGrow("cannot grow memory by 0 bytes"). This affects:clear_chunk_storeof an empty chunk store;uninstall_codeif it leaves the memory usage unchanged, i.e. of an already uninstalled canister whose canister history is at capacity and evicts an entry of the same size as theCanisterCodeUninstallentry it records.Rejecting a no-op for insufficient cycles was a bug, and the early return is needed anyway so that the operations newly covered by the wrapper (see below) do not start failing for a frozen canister. The new test
clear_chunk_store_of_frozen_canister_with_empty_chunk_store_succeedspins the new behaviour; it fails onmasterwithInsufficientCyclesInMemoryGrow.The other operations moved over hit the early return only where
masterskips the checks anyway:upload_chunkandwrite_snapshot_data(for a Wasm chunk) grow the memory usage by the chunk size, unless the chunk is already in the store — in which case they return early without inserting it and with no instructions to charge for, and onmasterthat early return sits before the checks, so they are skipped there just as well (a frozen canister still fails at the upfront instruction charge before either). The remaining ones cannot hit it:delete_canister_snapshotalways shrinks the memory usage by the snapshot size, which is never zero (a snapshot taken from a canister contains its Wasm module, which the canister must have, andValidatedSnapshotMetadatarejects a zero Wasm module size for an uploaded snapshot), andtake_canister_snapshotandload_canister_snapshotalways charge for their baseline instructions.create_snapshot_from_metadatalikewise grows the memory usage by the new snapshot's (never zero) size, unless it replaces a snapshot of exactly the same size — in which case the checks are a no-op anyway, since it has already paid for its instructions upfront (see below).create_snapshot_from_metadatacharges upfrontcreate_snapshot_from_metadatais the one operation of the three whose instructions do not pay for work done at checkpoint time, so there is no reason to defer them to the end: it charges itscanister_snapshot_baseline_instructionsplus the new snapshot's size upfront, just likeupload_chunkdoes, and records the charge inConsumedCyclesForInstructionsso that it survives the canister state rollback on failure. Itsinstructions_to_charge_on_successis therefore zero, and it takesconsumed_cyclesin place of theresource_saturationit no longer needs.The charge sits after the cheap validations (controller, metadata, replace-snapshot access, snapshot limit, heap delta rate limit) and before any work, i.e. exactly where the instruction count used to be computed, so those failures stay free. What changes is that the instructions are now charged even if the memory usage accounting afterwards fails — the same semantics
upload_chunkhas. Insufficient cycles for the charge itself still surface asCanisterManagerError::NotEnoughCycles, just detected earlier — except for an already frozen canister, whose error code changes: it now fails withNotEnoughCycles(ErrorCode::CanisterOutOfCycles) at the upfront charge, instead of withInsufficientCyclesInMemoryGrowat the freezing threshold check of the memory usage accounting.consume_cycles_for_management_canister_instructionsenforces the freezing threshold just like that check does, so such a canister fails either way.Otherwise no behaviour change
The checks, the charges, and the errors they produce are the same as before, in the same order — just driven from one place:
CanisterSnapshots::{push, remove, insert_chunk}andWasmChunkStore::insert_chunkmovememory_usageby exactly the snapshot size resp. the chunk size,clear_chunk_storeempties the chunk store whose memory usage the operation used to project away, andValidatedSnapshotMetadata::snapshot_size_bytes()is whatCanisterSnapshot::from_metadatastores as the snapshot's size);senderthe wrapper passes is the one the operations passed (CanisterCall::canister_change_originderivesCanisterChangeOriginfrom the same principalmsg.sender()returns);start_canister,stop_canister,add_cycles,fetch_canister_logs,read_snapshot_data, andwrite_snapshot_dataother than for a Wasm chunk) do not change the canister's memory usage, so the early return skips the checks for them;resource_saturationis still computed from the subnet available memory before the operation, as it was at every call site it replaces.Tests
Three tests are added to pin the behaviour of the operations whose accounting moved the furthest. All three pass before and after, by design:
take_canister_snapshot_of_canister_without_wasm_module_is_free— the validation rejects before anything is charged.take_canister_snapshot_of_frozen_canister_fails_for_free—InsufficientCyclesInMemoryGrow, nothing charged, no snapshot.failed_take_canister_snapshot_does_not_charge_for_instructions— with the subnet available execution memory exhausted, the accounting fails; asserts that no snapshot was taken, that the subnet available memory is unchanged, and that the canister is charged nothing, i.e. that the snapshot instructions are still only charged for once the operation succeeded.A fourth test pins the behaviour change above; it fails before and passes after:
clear_chunk_store_of_frozen_canister_with_empty_chunk_store_succeeds— clearing the empty chunk store of a frozen canister succeeds, charges nothing, and leaves the chunk store empty.🤖 Generated with Claude Code