Skip to content

test: drain a subnet that is "cooling down" - #11371

Draft
mraszyk wants to merge 21 commits into
masterfrom
mraszyk/subnet-cooling-down-test
Draft

test: drain a subnet that is "cooling down"#11371
mraszyk wants to merge 21 commits into
masterfrom
mraszyk/subnet-cooling-down-test

Conversation

@mraszyk

@mraszyk mraszyk commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

A system test covering the cooling_down subnet record flag end to end: while two universal canisters keep calling each other across subnets in a loop, labeling one of their subnets as "cooling down" must bring that subnet to a standstill.

The IC consists of an NNS subnet (with the NNS canisters installed) and two Application subnets S and T, holding one universal canister each. Both canisters are made to call the one on the other subnet in a loop, where the reply (or reject) callback of every call fires a new call. A payload cannot contain itself, so each canister holds the loop body in its global data and the continuation calls the canister itself, passing the global data as the payload to execute; the loop body replies as soon as it has fired the cross-subnet call, which keeps the number of open call contexts bounded.

An UpdateConfigOfSubnet proposal then labels S as "cooling down" and the test waits until S rejects ingress messages and, subsequently, until it is "merge ready" according to the Merge readiness panel of the Subnet merging dashboard (bases/apps/ic-dashboards/core/subnet-merging.json in dfinity-ops/k8s) for V = the registry version created by the proposal and R = 0 cycles: every subnet has reached V, no stream in either direction holds a message (loopback included), the ingress history holds nothing but processing entries, S's subnet input and output queues are empty, its subnet call context manager holds no call context, and the pending anonymous refunds are worth at most R. As in the dashboard, each term is evaluated on the median across the replicas reporting it, and missing data reads as zero. Finally, the test checks that both loops are stalled, so that readiness is the consequence of S cooling down rather than of the loops having stopped.

Only the HEAD NNS variant runs: the mainnet NNS canisters do not know the cooling_down field of UpdateSubnetPayload, so Candid would silently drop it and the proposal would be a no-op.

mraszyk and others added 3 commits August 28, 2026 08:55
If the `subnet_merged` marker is set, `process_batch()` now resets it and
records all not yet responded ingress-induced canister call contexts as
`Processing` in the ingress history, so that the corresponding ingress
messages can be tracked to completion.

Subnet call contexts are ignored, as subnet merging ensures that the subnets
being merged have no in-progress subnet call contexts.

A message that already has an ingress history entry is left alone. Its status
is expected to be `Processing`; anything else raises the new
`mr_unexpected_ingress_status_after_merge` critical error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a `merge_subnets` endpoint to the registry canister. It takes a source
and a destination subnet ID and merges the canister ID ranges of the source
subnet into the canister ID range set of the destination subnet, so that the
canisters hosted by the source subnet are routed to the destination subnet
afterwards.

The routing table is updated via `modify_routing_table`, which assigns the
source subnet's ranges to the destination subnet and coalesces the resulting
adjacent ranges. Only the routing table is touched: neither subnet record is
modified and the source subnet is not deleted, so decommissioning a subnet is
`merge_subnets` followed by `delete_subnet`.

The endpoint is restricted to governance and rejects payloads where the two
subnet IDs are equal, either subnet is unknown, the source subnet hosts no
canister ID range, or an ongoing canister migration overlaps the source
subnet's ranges (which would leave those migrations with a host that is not on
their recorded trace).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A system test covering the `cooling_down` subnet record flag end to end: while
two universal canisters keep calling each other across subnets in a loop,
labeling one of their subnets as "cooling down" must bring that subnet to a
standstill.

The IC consists of an NNS subnet (with the NNS canisters installed) and two
Application subnets S and T, holding one universal canister each. Both
canisters are made to call the one on the other subnet in a loop, where the
reply (or reject) callback of every call fires a new call. A payload cannot
contain itself, so each canister holds the loop body in its global data and the
continuation calls the canister itself, passing the global data as the payload
to execute; the loop body replies as soon as it has fired the cross-subnet
call, which keeps the number of open call contexts bounded.

An `UpdateConfigOfSubnet` proposal then labels S as "cooling down" and the test
waits until S rejects ingress messages and, subsequently, until it is "merge
ready" according to the `Merge readiness` panel of the `Subnet merging`
dashboard (`bases/apps/ic-dashboards/core/subnet-merging.json` in
dfinity-ops/k8s) for `V` = the registry version created by the proposal and
`R` = 0 cycles: every subnet has reached V, no stream in either direction holds
a message (loopback included), the ingress history holds nothing but
`processing` entries, S's subnet input and output queues are empty, its subnet
call context manager holds no call context, and the pending anonymous refunds
are worth at most R. As in the dashboard, each term is evaluated on the median
across the replicas reporting it, and missing data reads as zero. Finally, the
test checks that both loops are stalled, so that readiness is the consequence
of S cooling down rather than of the loops having stopped.

Only the HEAD NNS variant runs: the mainnet NNS canisters do not know the
`cooling_down` field of `UpdateSubnetPayload`, so Candid would silently drop it
and the proposal would be a no-op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the test label Aug 28, 2026
mraszyk and others added 18 commits August 31, 2026 08:50
Extend the `cooling_down` system test with the kinds of work a subnet may be
busy with when it is labeled "cooling down", and rename the subnets after the
roles they would play in a merge: `M` is the subnet being merged, `R` the root
subnet it would be merged into, `T` the subnet `M` exchanges messages with in a
loop, and `S` a third Application subnet, newly added.

Besides the two universal canisters calling each other across the `M`/`T`
boundary in a loop, `M` now also holds:

  - `U1`, which makes five `install_code` calls to the management canister, one
    per empty canister `U2a` .. `U2e` it controls, installing the universal
    canister module with an `arg` that makes `canister_init` burn 295B
    instructions. Only one long-running `install_code` makes progress per round,
    so these five calls alone keep `M` busy for minutes and are what merge
    readiness now waits for, rather than the pruning of the ingress history.
  - `U3`, looping until its global data is set to a value this test never sets.
    Every iteration is a self-targeted `canister_status` call, so the loop stalls
    as soon as `M` stops routing messages out of its canisters' output queues.
  - `U5`, running that same loop in a call made from `U4` on `S`, i.e. in a call
    from another subnet that it can never respond to.
  - `U6`, waiting for the response of a call to `U7` on `S` that never arrives.

The test then asserts that `U2a` .. `U2e` have been installed once `M` is merge
ready, i.e. that cooling down retains messages in canister output queues without
losing the management calls `M` had already accepted. The endless loops leave
nothing behind but `processing` ingress history entries and open canister call
contexts, neither of which is part of the readiness condition.

Two ordering constraints the test has to respect, both of which would otherwise
make it hang or install nothing:

  - Every canister has to be installed before `U1`'s calls start, as no
    `install_code` is executed at all while another one is long-running on the
    same subnet.
  - `U1`'s requests have to leave its output queue before the proposal lands: a
    request still sitting there would never be routed once `M` cools down, not
    even into the loopback stream.

`canister_init` burns 295B rather than the full 300B an `install_code` message
may consume, because that budget also covers compiling the module: 6_000
instructions per byte of the decompressed (~350 KB) universal canister module,
i.e. ~2.2B instructions, plus a 20M base cost. The reduced compilation cost only
applies while the module is in `expected_compiled_wasms`, which is cleared at
every checkpoint, so an `install_code` aborted at a checkpoint and restarted
afterwards pays the full amount.

Supporting changes: `CandidCallBuilder<InstallCodeArgs>` grows a `with_arg`, as
there was no way to set the argument passed to `canister_init`, and
`UniversalCanister` grows a `submit_update` that submits an ingress message
without waiting for it to complete, which most of the calls above need.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…merge

Add the `MergeSubnets` NNS function, mapped to the `merge_subnets` method of the
registry canister under the `SubnetManagement` topic, and extend that method so
that a single such proposal performs the whole registry-side part of a subnet
merge, all in one registry version, so that the destination subnet never
observes a state where only some of it took effect:

  1. the canister ID ranges of the source subnet are merged into the canister ID
     range set of the destination subnet, so that all canisters that used to be
     hosted by the source subnet are routed to the destination subnet;
  2. a recovery catch-up package is created for the destination subnet, at the
     height, time and state hash of the merged state, running a fresh DKG for
     the destination subnet's membership; and
  3. the destination subnet is brought back online.

The payload therefore grows the `height`, `time_ns` and `state_hash` of the
merged state, plus an `initial_dkg_subnet_id` naming the subnet that handles the
`setup_initial_dkg` call: it must not be the destination subnet, which is
offline while the merge is in progress.

The caller is expected to have taken both subnets offline and to have extended
the state of the destination subnet with the state of the canisters of the source
subnet beforehand; `state_hash` is the hash of the manifest of the result. The
source subnet record is not modified and the source subnet is not deleted: it
merely does not host any canister ID range anymore.

The recovery catch-up package needs fresh DKG transcripts because its
`initial_ni_dkg_transcript_{low,high}_threshold` become the current transcripts
of the DKG summary that bootstraps consensus at the recovery height. The registry
only holds the destination subnet's genesis (or last recovery) catch-up package,
whose transcripts its nodes may no longer have the secret key shares for, so
reusing those would risk restarting a subnet that cannot sign.

Merging into a subnet holding chain keys is rejected: recovering such a subnet
requires resharing its keys onto the recovery catch-up package, which this
method does not do, and silently leaving the destination subnet unable to sign
would be worse than refusing.

As the method now makes an inter-canister call half way through, it follows
`do_recover_subnet` in checking that none of the records it is about to
overwrite -- and none of the canister ID ranges it validated -- changed while
that call was in flight. Unlike `do_recover_subnet`, it reports the reject code
and message if that call fails, rather than an opaque `unwrap` panic.

`StateMachine` only answered `setup_initial_dkg` requests from
`do_execute_round`, which `tick()` (and hence `await_ingress`) does not go
through, so any canister awaiting such a call hung there forever. Factor the
fake responses out into `setup_initial_dkg_responses` and produce them from
`tick_with_config` as well, next to the threshold signing requests it already
answers. Without this the integration test of the success path cannot run, which
is why it is part of this commit.

The two success unit tests of `merge_subnets`, which can no longer drive the
method to completion, now exercise the routing table part directly; the success
path as a whole, including the recovery catch-up package and the unhalting, is
covered by the integration test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add a `checkpoint_time` subcommand printing the `batch_time_nanos` of the system
metadata of a checkpoint, i.e. the IC time the subnet had reached when it wrote
that checkpoint.

Merging a subnet into another one has to pick the block time the destination
subnet resumes from, which must be larger than the times of the checkpoints at
which both subnets were taken offline. Nothing printed a checkpoint's time so
far, so there was no way to check that from outside the replica.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…o batches

A subnet halting because of the `halt_at_cup_height` flag of its subnet record
logged nothing observable: the only trace it left was a `debug!`, which the
nodes' log level drops, so nothing outside the replica could tell that such a
subnet had come to a stop. The `is_halted` flag, in contrast, is reported at
info level by `ConsensusImpl::on_state_change`.

Raise that message to info level, so that both ways of halting a subnet can be
observed the same way. It is already emitted at most once every five seconds,
so this does not make the logs noticeably busier, and it names the height of the
first batch that is not delivered, i.e. one past the state the subnet stopped in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Extend the `cooling_down` system test past the point where the subnet is "merge
ready": it now goes on to merge that subnet away and to delete it, which is what
the cooling down and the merge readiness condition exist for. The destination is
a third Application subnet `R`; the NNS subnet stays available throughout, as it
is where the proposals of the test are executed, including the one recovering
`R` at the merged state.

The merge itself, once both subnets are "merge ready":

  - Both subnets are told to halt at their next CUP and are waited for. The wait
    reads each node's journal, where a halted replica reports that it delivers
    no batches, rather than watching for the height of the latest checkpoint to
    stop advancing: checkpoints are written once per DKG interval, i.e. minutes
    apart, so that height looks stable long before the subnet halts. Its
    checkpoint is then hundreds of rounds behind the state the merge readiness
    was established on, and holds, for instance, an `install_code` aborted at
    that checkpoint and only completed afterwards -- whose call context and
    reserved response slot the merge then drops, so that the destination subnet
    hits `[EXC-BUG] Could not find any install code call ...` and panics on
    resuming it.
  - The state of the merge is assembled from the two checkpoints the subnets
    halted at, as a new checkpoint of `R` at the next multiple of its DKG
    interval, so that `R`'s own checkpoint stays untouched: the canisters and
    canister snapshots of `M` are added to those of `R`, and the result is marked
    as the product of a subnet merge. Taking the system metadata and subnet
    queues of `R` wholesale is sound because those of `M` are empty, which is
    what the merge readiness established and what `M` cooling down preserves
    until it halts.
  - The block time of the merged state is picked past both checkpoint times,
    read with `state-tool checkpoint_time`, and its manifest hash with
    `state-tool manifest`.
  - The merged state is added to `R`'s checkpoints while its replica is stopped,
    and the replica is only started after the `MergeSubnets` proposal created the
    recovery CUP; the test then waits for `R` to report exactly that CUP, so that
    a subnet resuming from the wrong state fails here rather than much later.
  - The three endless loops are ended and every ingress message that was in
    progress across the merge is checked to have completed. This is what the
    `subnet_merged` marker is for: the ingress history of `M` is deliberately not
    merged in, the marker makes the replica re-register those messages instead.
  - Finally, once every subnet observed the merge, `M` -- which hosts no canister
    ID range anymore -- is deleted, and is checked to be gone from the registry.

`UniversalCanister::submit_update` now returns the ID of the message it
submitted, which is what lets the test track the ingress messages that outlive
the merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	rs/registry/canister/unreleased_changelog.md
Strengthen the subnet merge system test along the axes it was blind to.

Every subnet now has four nodes rather than one, so that the merge has to get the
merged state to the other nodes of the destination subnet the way a recovery
does, i.e. by state sync from the one node it is uploaded to, and so that the
medians the merge readiness condition is made of are medians of more than one
value.

The destination subnet is no longer empty: it hosts `UR`, which the merge has to
leave alone even though the canisters of the merged subnet are added right next
to it, and which has to be able to call one of those canisters afterwards, both
of them being on the same subnet from then on.

`U8` carries the state that has to survive the merge: a blob in its stable
memory, a canister snapshot, and a cycles balance, all three of which are checked
once the destination subnet serves it. The balance is compared against a share of
itself rather than an amount, as the two readings are however many minutes apart
the waits in between take, and what this catches is a balance that was not
carried over at all rather than the resource charges of an idle canister.

The merge readiness condition is now checked to *not* hold right after the subnet
starts cooling down. A condition that held from the start would be satisfied by a
subnet that never had anything to drain, so waiting for it would prove nothing;
six of its eight terms are in fact unsatisfied at that point.

Finally, a best effort call from `U9` on `T` to `U10` on `M`, which `U10` never
answers, puts a best effort message in flight across the merge: its deadline
passes while `M` is cooling down, and the cycles it carries are held by the open
call context of its callee, which the merge has to carry over like any other
canister state. The test had only guaranteed response calls until now.

The one term of the readiness condition that stays untested is the pending
anonymous refunds, which requires a cycle bearing message to be dropped from one
of the subnet's queues while owed to a canister of another subnet; the cycles of
the best effort call above are not it, as that call is picked up rather than
dropped. See the comment on `MAX_REFUND_VALUE_CYCLES`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…merge

Add the `MergeSubnets` NNS function, mapped to the `merge_subnets` method of the
registry canister under the `SubnetManagement` topic, and extend that method so
that a single such proposal performs the whole registry-side part of a subnet
merge, all in one registry version, so that the destination subnet never
observes a state where only some of it took effect:

  1. the canister ID ranges of the source subnet are merged into the canister ID
     range set of the destination subnet, so that all canisters that used to be
     hosted by the source subnet are routed to the destination subnet;
  2. a recovery catch-up package is created for the destination subnet, at the
     height, time and state hash of the merged state, running a fresh DKG for
     the destination subnet's membership; and
  3. the destination subnet is brought back online.

The payload therefore grows the `height`, `time_ns` and `state_hash` of the
merged state, plus an `initial_dkg_subnet_id` naming the subnet that handles the
`setup_initial_dkg` call: it must not be the destination subnet, which is
offline while the merge is in progress.

The caller is expected to have taken both subnets offline and to have extended
the state of the destination subnet with the state of the canisters of the source
subnet beforehand; `state_hash` is the hash of the manifest of the result. The
source subnet record is not modified and the source subnet is not deleted: it
merely does not host any canister ID range anymore.

The recovery catch-up package needs fresh DKG transcripts because its
`initial_ni_dkg_transcript_{low,high}_threshold` become the current transcripts
of the DKG summary that bootstraps consensus at the recovery height. The registry
only holds the destination subnet's genesis (or last recovery) catch-up package,
whose transcripts its nodes may no longer have the secret key shares for, so
reusing those would risk restarting a subnet that cannot sign.

Merging into a subnet holding chain keys is rejected: recovering such a subnet
requires resharing its keys onto the recovery catch-up package, which this
method does not do, and silently leaving the destination subnet unable to sign
would be worse than refusing.

As the method now makes an inter-canister call half way through, it follows
`do_recover_subnet` in checking that none of the records it is about to
overwrite -- and none of the canister ID ranges it validated -- changed while
that call was in flight. Unlike `do_recover_subnet`, it reports the reject code
and message if that call fails, rather than an opaque `unwrap` panic.

`StateMachine` only answered `setup_initial_dkg` requests from
`do_execute_round`, which `tick()` (and hence `await_ingress`) does not go
through, so any canister awaiting such a call hung there forever. Factor the
fake responses out into `setup_initial_dkg_responses` and produce them from
`tick_with_config` as well, next to the threshold signing requests it already
answers. Without this the integration test of the success path cannot run, which
is why it is part of this commit.

The two success unit tests of `merge_subnets`, which can no longer drive the
method to completion, now exercise the routing table part directly; the success
path as a whole, including the recovery catch-up package and the unhalting, is
covered by the integration test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	rs/registry/canister/unreleased_changelog.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Revalidate, after `setup_initial_dkg` returns, that no canister migration
overlapping the canister ID ranges of the source subnet was prepared while
the call was in flight: `prepare_canister_migration` does not touch the
routing table, so the existing post-call check could not catch it.

Also fix the low/high threshold transcript records being swapped in the
`SetupInitialDKGResponse` that `StateMachine` synthesizes for pending
`setup_initial_dkg` requests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chain key initializations in a CUP take precedence over the chain key
configuration of the subnet record, so carrying over the ones of the CUP
being replaced would make the destination subnet bootstrap obsolete key
material -- contradicting the endpoint's refusal to merge into a subnet
holding chain keys. Clear both initialization fields, as recovering a
subnet without an initial chain key configuration does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Revert `merge_subnets` to what it originally did: merge the canister ID
ranges of the source subnet into the canister ID range set of the
destination subnet, and nothing else. The recovery catch-up package, the
fresh DKG for the destination subnet's membership and the unhalting are
dropped again, and with them the `height`, `time_ns`, `state_hash` and
`initial_dkg_subnet_id` payload fields, the inter-canister call and the
guards around it. Recovering the destination subnet at the merged state
stays with `recover_subnet`.

The `MergeSubnets` NNS function is kept -- without it the endpoint would
be unreachable -- with its description narrowed accordingly.

The `StateMachine` change that answered `setup_initial_dkg` requests from
`tick_with_config` goes away with the inter-canister call that needed it.
Its fix of the swapped `SetupInitialDKGResponse` fields stays, as that is
a bug of its own: the `HighThreshold`-tagged transcript was encoded as
the low-threshold record and vice versa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…wn-test'

`merge_subnets` is restricted to the routing table change again, so the
registry side of the merge is taken from the merged-in branch wholesale:
the endpoint, its payload, the Candid interfaces, the changelogs and the
`StateMachine` handling of `setup_initial_dkg` requests. The extraction of
`setup_initial_dkg_responses` goes away with the inter-canister call that
needed it; the fix of the swapped `SetupInitialDKGResponse` fields stays.

The system test now performs the merge with two proposals instead of one:
`MergeSubnets` reroutes the canister ID ranges of `M` to `R`, and
`RecoverSubnet` creates the recovery CUP for `R` at the merged state.
Recovering a subnet that was instructed to halt at its next CUP replaces
that instruction with a plain halt, so `R` is unhalted with an
`UpdateConfigOfSubnet` proposal once it came up on the recovery CUP --
before the ingress messages that outlived the merge are checked to
complete, as a halted subnet delivers no batches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…time

`await_status_is_healthy` and the SSH helpers without the `_async` suffix run
their own future through `futures::executor::block_on`, which polls it on the
calling thread instead of letting the runtime park on its I/O. A `reqwest`
request that finds no live connection in its pool then busy-polls its connect
future forever: the test spun at 100% CPU in `status_is_healthy_async`, having
logged the unhalting of `R` and nothing after it.

`R` reports `WaitingForRootDelegation` for a few minutes after the recovery,
which is what gave the pooled connection time to go away, so the health check
of step 16 hit this reliably. Use the `_async` variants -- which is what this
file already does for the checkpoint listing of `latest_checkpoint_height` --
for every driver call in the async body of the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant