Skip to content

Set a canister's freezing threshold to 0 before refunding its cycles - #9629

Merged
hpeebles merged 4 commits into
masterfrom
claude/refund-freezing-threshold
Sep 29, 2026
Merged

hpeebles merged 4 commits into
masterfrom
claude/refund-freezing-threshold

Conversation

@hpeebles

@hpeebles hpeebles commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Before installing the cycles refunder on a deleted (or migrated) user's uninstalled canister, the LocalUserIndex now sets the canister's freezing threshold to 0. That way the cycles the threshold held back get refunded too.

Why

The refunder can only send the canister's liquid balance, which excludes the cycles held back by its freezing threshold (30 days of idle burn by default).

That used to be negligible. But since dfinity/ic#10292, every canister pays a base fee of 10k cycles/s, so even an empty canister's freezing threshold is now about 26B cycles. Each refund was leaving about 108B behind instead of about 82B, as the PocketIC 16 bump (#9624) showed.

It also left MIN_CYCLES_TO_REFUND (100B) below what a refunded canister still holds. So queueing an already refunded canister again, which refund_deleted_user_cycles does for every deleted user, would top it up and refund it all over again, instead of skipping it as intended.

With the threshold at 0:

  • The floor goes back to about 82B, which is the execution prepayment plus the call's response reservation. The IC withholds those regardless.
  • CYCLES_REQUIRED_FOR_INSTALL no longer needs the freezing threshold added on top, so fewer canisters need a top-up before the install.

The canister is left uninstalled with a zero freezing threshold. That's harmless, since it has no code and holds only the unrecoverable floor, and a deleted user's canister is never reused.

The one exception is the LocalUserIndex's canister pool. In 2024, #6047 added deleted users' canisters to it for reuse, and some are still there. A pool canister refunded this way could later become a live canister with a zero freezing threshold. If it ever ran out of cycles, it would be uninstalled instead of frozen. So pool canisters are now skipped, both when refunds are queued and in the job itself. Draining them was pointless anyway, since they're topped up again when reused.

Changes

  • utils::canister::set_freezing_threshold, alongside the existing update_settings helpers.
  • RefundDeletedUserCycles no longer queues canister pool canisters, and refund_cycles refuses them too (RefundError::InCanisterPool).
  • refund_cycles zeroes the threshold (if it isn't already 0) before the top-up check and the install. The top-up is now just CYCLES_REQUIRED_FOR_INSTALL minus the balance.
  • CanisterStatusMinimal::freezing_threshold_cycles is removed, since refund_cycles was its only caller.
  • The refunder README describes the step.
  • wait_for_cycles_to_be_refunded, used by every refund test, asserts that the canister's freezing threshold ends up at 0.
  • The operator refund test restores the default freezing threshold before its second refund, as a canister deleted before refunds existed would have. It then queues the drained canister again and checks that it's left untouched: no refund, and the threshold unchanged.

Tests

The integration tests still run on PocketIC 11, which predates the base fee, so a freezing threshold there is only worth about 20M cycles. The tests therefore check that the threshold is zeroed, not the size of the refund. Once #9624 (PocketIC 16) is merged, the residual limits it raised to 125B can come back down to 100B for the LocalUserIndex refund tests.

  • ./scripts/run-integration-tests.sh build passes on PocketIC 11, apart from invariant_17, which already fails intermittently on master with SettingTimeIntoPast and is fixed in Bump PocketIC to 16.0.0 #9624
  • delete_user_tests and user_migration_tests pass again after the review fixes

🤖 Generated with Claude Code

The refunder can only send the canister's liquid balance, which excludes the cycles held back by
its freezing threshold. Now that every canister pays a base fee, even an empty canister's
threshold holds back ~26B cycles, so zero it first to have those refunded too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

canbench 🏋 (dir: .)

./canbench_results.yml is up to date ✅

---------------------------------------------------

Benchmark: add_reactions
  total:
    instructions: 1.57 B (-0.95%) (change within noise threshold)
    heap_increase: 3 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: import_events
  total:
    instructions: 124.06 M (0.28%) (change within noise threshold)
    heap_increase: 3 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: migrate_imported_user_metrics
  total:
    instructions: 10.78 M (1.05%) (change within noise threshold)
    heap_increase: 0 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: push_simple_text_messages
  total:
    instructions: 640.51 M (0.01%) (change within noise threshold)
    heap_increase: 3 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: search_messages
  total:
    instructions: 128.19 M (0.74%) (change within noise threshold)
    heap_increase: 0 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

hpeebles and others added 2 commits September 29, 2026 01:13
…shold is zeroed

- Deleted users' canisters used to be added to the canister pool, from which they may yet become
  live canisters, so skip any still in it, both when queueing refunds and in the job itself.
  Otherwise one could go live with its freezing threshold at 0, and be uninstalled rather than
  frozen if it ever ran out of cycles
- Restore the default freezing threshold before the operator refund, as a canister deleted before
  refunds would have, so that the zeroing is exercised, and check that queueing a drained canister
  again leaves it untouched

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hpeebles
hpeebles merged commit 2611b2e into master Sep 29, 2026
13 checks passed
@hpeebles
hpeebles deleted the claude/refund-freezing-threshold branch September 29, 2026 07:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant