Skip to content

Commit 440ca1d

Browse files
mraszykclaude
andauthored
fix: tolerate a cycles balance increase on DTS resume (#11141)
Every kind of paused execution re-creates its helper from the current clean canister state when it is resumed and thus compares the cycles balance of that state with the balance at the start of the DTS execution. The comparison used to reject any change of the balance; it now only rejects a decrease, which the recorded steps replayed on the clean canister state might no longer be able to cover. An increase is safe: all cycles changes of a DTS execution are applied relative to the balance of the clean canister state (the Wasm execution reports a `CyclesBalanceChange` delta and the prepaid execution cycles are refunded relative as well), so the additional cycles are preserved. The new tests `dts_resume_succeeds_after_cycles_increase` and `dts_install_code_resume_succeeds_after_cycles_increase` are the counterparts of `dts_resume_fails_due_to_cycles_decrease` and `dts_install_code_resume_fails_due_to_cycles_decrease`: they add cycles to the canister while its execution is paused and assert that the execution completes and that the added cycles are not lost. The former runs every scenario twice, once without adding cycles, and asserts that the two final balances differ by exactly the added cycles. The setup of all the scenarios is now shared by the tests that decrease and increase the cycles balance. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d54472d commit 440ca1d

5 files changed

Lines changed: 446 additions & 119 deletions

File tree

‎rs/execution_environment/src/execution/call_or_task.rs‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -443,8 +443,10 @@ impl CallOrTaskHelper {
443443
}
444444

445445
/// Replays the previous update call steps on the given clean canister.
446-
/// Returns an error if any step fails. Otherwise, it returns an instance of
447-
/// the helper that can be used to continue the update call execution.
446+
/// Returns an error if the cycles balance of the clean canister dropped
447+
/// below the cycles balance at the start of the DTS execution or if any step
448+
/// fails. Otherwise, it returns an instance of the helper that can be used
449+
/// to continue the update call execution.
448450
fn resume(
449451
clean_canister: &CanisterState,
450452
original: &OriginalContext,
@@ -453,7 +455,13 @@ impl CallOrTaskHelper {
453455
) -> Result<Self, UserError> {
454456
let mut helper = Self::new(clean_canister, original, deallocation_sender)?;
455457
helper.executed_wasm_instructions = paused.executed_wasm_instructions;
456-
if helper.initial_cycles_balance != paused.initial_cycles_balance {
458+
// The cycles balance of the clean canister must not decrease during the
459+
// DTS execution: the recorded steps are replayed on the clean canister
460+
// state and a lower balance might no longer be able to cover them.
461+
// An increase is safe: all cycles changes of the DTS execution are
462+
// applied relative to the balance of the clean canister state and hence
463+
// the additional cycles are preserved.
464+
if helper.initial_cycles_balance < paused.initial_cycles_balance {
457465
let msg = match original.call_or_task {
458466
CanisterCallOrTask::Update(_) => {
459467
"Mismatch in cycles balance when resuming an update call".to_string()

‎rs/execution_environment/src/execution/install_code.rs‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ impl InstallCodeHelper {
249249
}
250250

251251
/// Replays the previous `install_code` steps on the given clean canister.
252-
/// Returns an error if the cycles balance of the clean canister differs from
252+
/// Returns an error if the cycles balance of the clean canister dropped below
253253
/// the cycles balance at the start of the DTS execution or if any step
254254
/// fails. Otherwise, it returns an instance of the helper that can be used
255255
/// to continue the `install_code` execution.
@@ -281,9 +281,13 @@ impl InstallCodeHelper {
281281
.saturating_sub(executed_wasm_instructions.get()),
282282
);
283283

284-
// The cycles balance of the clean canister must not change during the
285-
// DTS execution.
286-
if helper.initial_cycles_balance != paused.initial_cycles_balance {
284+
// The cycles balance of the clean canister must not decrease during the
285+
// DTS execution: the recorded steps are replayed on the clean canister
286+
// state and a lower balance might no longer be able to cover them.
287+
// An increase is safe: all cycles changes of the DTS execution are
288+
// applied relative to the balance of the clean canister state and hence
289+
// the additional cycles are preserved.
290+
if helper.initial_cycles_balance < paused.initial_cycles_balance {
287291
let msg = "Mismatch in cycles balance when resuming an install code".to_string();
288292
let err = HypervisorError::WasmEngineError(FailedToApplySystemChanges(msg));
289293
let err = (clean_canister.canister_id(), err).into();

‎rs/execution_environment/src/execution/install_code/tests.rs‎

Lines changed: 123 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use ic_test_utilities_metrics::fetch_int_counter;
2323
use ic_types::ingress::{IngressState, IngressStatus, WasmResult};
2424
use ic_types::messages::MessageId;
2525
use ic_types::{CanisterId, ComputeAllocation, MemoryAllocation, NumBytes, NumInstructions};
26-
use ic_types_cycles::{Cycles, CyclesUseCase, NominalCycles};
26+
use ic_types_cycles::{CompoundCycles, Cycles, CyclesUseCase, Instructions, NominalCycles};
2727
use ic_types_test_utils::ids::{canister_test_id, subnet_test_id, user_test_id};
2828
use ic_universal_canister::{UNIVERSAL_CANISTER_WASM, call_args, wasm};
2929
use maplit::btreemap;
@@ -139,19 +139,38 @@ fn consumed_cycles_for_instructions(
139139
.unwrap_or_default()
140140
}
141141

142-
/// Analogously to `dts_resume_fails_due_to_cycles_decrease` for calls,
143-
/// replicated queries, callbacks, and tasks, resuming a paused `install_code`
144-
/// whose canister lost cycles while it was paused fails instead of replaying the
145-
/// recorded steps on a balance that can no longer cover them. The failed
146-
/// execution is charged for exactly the instructions it had already executed,
147-
/// including those of the paused Wasm execution.
148-
#[test]
149-
fn dts_install_code_resume_fails_due_to_cycles_decrease() {
150-
const INSTRUCTION_LIMIT: u64 = 50_000_000;
151-
const SLICE_INSTRUCTION_LIMIT: u64 = 132_000;
142+
/// The instruction limits of the DTS `install_code` tests that change the cycles
143+
/// balance of the canister while its execution is paused.
144+
const DTS_INSTALL_CODE_INSTRUCTION_LIMIT: u64 = 50_000_000;
145+
const DTS_INSTALL_CODE_SLICE_INSTRUCTION_LIMIT: u64 = 132_000;
146+
147+
/// A canister with a paused `install_code` execution, along with a snapshot of
148+
/// the accounting counters of that canister taken before the execution started.
149+
///
150+
/// Shared by the tests that decrease and increase the cycles balance of the
151+
/// canister while its `install_code` execution is paused.
152+
struct PausedInstallCode {
153+
test: ExecutionTest,
154+
canister_id: CanisterId,
155+
/// The ingress message of the `install_code` subnet message.
156+
ingress_id: MessageId,
157+
/// The cycles balance before the execution cycles were prepaid.
158+
original_balance: Cycles,
159+
/// The cycles consumed for instructions.
160+
original_consumed_cycles: NominalCycles,
161+
/// The instructions executed by all the slices of the canister.
162+
original_executed_instructions: NumInstructions,
163+
/// The accumulated cost of the instructions executed by the canister.
164+
original_execution_cost: CompoundCycles<Instructions>,
165+
}
166+
167+
/// Starts an `install_code` execution that pauses after its first slice: that
168+
/// slice compiles the Wasm module and executes its `(start)` function, which
169+
/// together exceed the slice instruction limit.
170+
fn install_code_paused_after_first_slice() -> PausedInstallCode {
152171
let mut test = ExecutionTestBuilder::new()
153-
.with_install_code_instruction_limit(INSTRUCTION_LIMIT)
154-
.with_install_code_slice_instruction_limit(SLICE_INSTRUCTION_LIMIT)
172+
.with_install_code_instruction_limit(DTS_INSTALL_CODE_INSTRUCTION_LIMIT)
173+
.with_install_code_slice_instruction_limit(DTS_INSTALL_CODE_SLICE_INSTRUCTION_LIMIT)
155174
.with_create_execution_state_base_cost(0)
156175
.with_manual_execution()
157176
.build();
@@ -169,6 +188,7 @@ fn dts_install_code_resume_fails_due_to_cycles_decrease() {
169188
let original_balance = test.canister_state(canister_id).system_state.balance();
170189
let original_consumed_cycles = consumed_cycles_for_instructions(&test, canister_id);
171190
let original_executed_instructions = test.canister_executed_instructions(canister_id);
191+
let original_execution_cost = test.canister_execution_cost(canister_id);
172192

173193
let ingress_id = test.dts_install_code(payload);
174194

@@ -187,13 +207,42 @@ fn dts_install_code_resume_fails_due_to_cycles_decrease() {
187207
- test
188208
.cycles_account_manager()
189209
.execution_cost(
190-
NumInstructions::from(INSTRUCTION_LIMIT),
210+
NumInstructions::from(DTS_INSTALL_CODE_INSTRUCTION_LIMIT),
191211
test.get_own_subnet_cycles_config(),
192212
WASM_EXECUTION_MODE,
193213
)
194214
.real(),
195215
);
196216

217+
PausedInstallCode {
218+
test,
219+
canister_id,
220+
ingress_id,
221+
original_balance,
222+
original_consumed_cycles,
223+
original_executed_instructions,
224+
original_execution_cost,
225+
}
226+
}
227+
228+
/// Analogously to `dts_resume_fails_due_to_cycles_decrease` for calls,
229+
/// replicated queries, callbacks, and tasks, resuming a paused `install_code`
230+
/// whose canister lost cycles while it was paused fails instead of replaying the
231+
/// recorded steps on a balance that can no longer cover them. The failed
232+
/// execution is charged for exactly the instructions it had already executed,
233+
/// including those of the paused Wasm execution.
234+
#[test]
235+
fn dts_install_code_resume_fails_due_to_cycles_decrease() {
236+
let PausedInstallCode {
237+
mut test,
238+
canister_id,
239+
ingress_id,
240+
original_balance,
241+
original_consumed_cycles,
242+
original_executed_instructions,
243+
..
244+
} = install_code_paused_after_first_slice();
245+
197246
// Decrease the cycles balance of the clean canister.
198247
test.canister_state_mut(canister_id)
199248
.system_state
@@ -224,7 +273,10 @@ fn dts_install_code_resume_fails_due_to_cycles_decrease() {
224273
// more than the slice instruction limit.
225274
let executed_instructions =
226275
test.canister_executed_instructions(canister_id) - original_executed_instructions;
227-
assert_gt!(executed_instructions.get(), SLICE_INSTRUCTION_LIMIT);
276+
assert_gt!(
277+
executed_instructions.get(),
278+
DTS_INSTALL_CODE_SLICE_INSTRUCTION_LIMIT
279+
);
228280

229281
// The canister is charged exactly the cost of those instructions, including
230282
// the instructions of the paused Wasm execution: the rest of the prepaid
@@ -245,6 +297,62 @@ fn dts_install_code_resume_fails_due_to_cycles_decrease() {
245297
);
246298
}
247299

300+
/// Counterpart of `dts_install_code_resume_fails_due_to_cycles_decrease`: while
301+
/// resuming a paused `install_code` whose canister lost cycles fails, an
302+
/// increase of the cycles balance while the execution is paused is tolerated and
303+
/// the additional cycles are not lost when the execution completes.
304+
#[test]
305+
fn dts_install_code_resume_succeeds_after_cycles_increase() {
306+
const CYCLES_ADDED_WHILE_PAUSED: Cycles = Cycles::new(1_234_567_890);
307+
308+
let PausedInstallCode {
309+
mut test,
310+
canister_id,
311+
ingress_id,
312+
original_balance,
313+
original_executed_instructions,
314+
original_execution_cost,
315+
..
316+
} = install_code_paused_after_first_slice();
317+
318+
// Increase the cycles balance of the clean canister.
319+
test.canister_state_mut(canister_id)
320+
.system_state
321+
.add_cycles(CYCLES_ADDED_WHILE_PAUSED);
322+
323+
// The remaining slices resume the paused execution, which completes.
324+
while test.canister_state(canister_id).next_execution() == NextExecution::ContinueInstallCode {
325+
test.execute_slice(canister_id);
326+
}
327+
assert_eq!(
328+
test.canister_state(canister_id).next_execution(),
329+
NextExecution::None
330+
);
331+
332+
let result = check_ingress_status(test.ingress_status(&ingress_id)).unwrap();
333+
assert_eq!(result, WasmResult::Reply(EmptyBlob.encode()));
334+
335+
// The code has been installed.
336+
assert!(test.canister_state(canister_id).execution_state.is_some());
337+
338+
// The execution spanned multiple slices.
339+
let executed_instructions =
340+
test.canister_executed_instructions(canister_id) - original_executed_instructions;
341+
assert_gt!(
342+
executed_instructions.get(),
343+
DTS_INSTALL_CODE_SLICE_INSTRUCTION_LIMIT
344+
);
345+
346+
// The canister is charged exactly the cost of the executed instructions: the
347+
// cycles added while the execution was paused are not lost.
348+
assert_eq!(
349+
test.canister_state(canister_id).system_state.balance(),
350+
original_balance
351+
- (test.canister_execution_cost(canister_id) - original_execution_cost).real()
352+
+ CYCLES_ADDED_WHILE_PAUSED
353+
);
354+
}
355+
248356
#[test]
249357
fn dts_abort_works_in_install_code() {
250358
const INSTRUCTION_LIMIT: u64 = 50_000_000;

‎rs/execution_environment/src/execution/response.rs‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -339,8 +339,8 @@ impl ResponseHelper {
339339
/// call context, and execution state because it is not possible to invoke
340340
/// the cleanup callback in such cases.
341341
///
342-
/// It returns an error if the cycles balance of the clean canister differs
343-
/// from the cycles balances at the start of the DTS execution.
342+
/// It returns an error if the cycles balance of the clean canister dropped
343+
/// below the cycles balance at the start of the DTS execution.
344344
#[allow(clippy::result_large_err)]
345345
fn resume(
346346
paused: PausedResponseHelper,
@@ -387,9 +387,13 @@ impl ResponseHelper {
387387
.validate(&call_context, original, round, round_limits)
388388
.expect("Failed to resume DTS response: validation");
389389

390-
// The cycles balance of the clean canister must not change during the
391-
// DTS execution.
392-
if helper.initial_cycles_balance != paused.initial_cycles_balance {
390+
// The cycles balance of the clean canister must not decrease during the
391+
// DTS execution: the initial steps are replayed on the clean canister
392+
// state and a lower balance might no longer be able to cover them.
393+
// An increase is safe: all cycles changes of the DTS execution are
394+
// applied relative to the balance of the clean canister state and hence
395+
// the additional cycles are preserved.
396+
if helper.initial_cycles_balance < paused.initial_cycles_balance {
393397
let msg = "Mismatch in cycles balance when resuming a response call".to_string();
394398
let err = HypervisorError::WasmEngineError(FailedToApplySystemChanges(msg));
395399
return Err((helper, err));

0 commit comments

Comments
 (0)