Repository navigation
fix: Bound subtype checks on shared recursive types; release candid 0.10.39 - #776
Merged
Merged
Conversation
Contributor
|
✅ No security or compliance issues detected. Reviewed everything up to 53f7c03. Security OverviewDetected Code Changes
|
There was a problem hiding this comment.
🟡 Changes recommended
Optional-type probes bypass failure caching and still allow exponential traversal of shared types.
1 open issue
What changed in this PR
Builds on #775’s assumption rollback fix by caching failed subtype pairs to reduce repeated compatibility-check work.
Changes:
- Adds per-call failure caching and abbreviated repeat diagnostics.
- Adds shared-type and multi-method regression tests.
- Documents the optimization under Unreleased.
| File | Description |
|---|---|
| rust/candid/src/types/subtype.rs | Caches failures during incompatibility collection. |
| rust/candid_parser/tests/compatibility.rs | Tests shared types and reporting across methods. |
| CHANGELOG.md | Documents reduced repeated traversal. |
Review effort: Balanced
raymondk
approved these changes
Oct 20, 2026
subtype_check_all keeps going after an error, but it withdrew a failed
pair from Gamma, so every later path to that pair re-checked it from
scratch. A recursive type reachable along many paths, such as
`type T_i = record { T_{i+1}; T_{i+1} }`, then took 2^n time and
produced 2^n errors.
The collector now remembers the pairs it has found incompatible.
Reaching one again adds a single entry that points back to the first
report. The pair still leaves Gamma, so the opt-rule probes that share
Gamma do not take it as an assumption.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The opt rule probes `t <: t'` and falls back when the probe fails. A
failed probe retracted its assumptions but kept no record of the
failure, so a type like `A_i = record { opt A_{i+1}; opt A_{i+1}; nat }`
against the same shape ending in `text` re-explored every level below
from both fields: 2^n work in `subtype_` and in the collector's probes.
`Assumptions` now keeps the pairs a check has refuted. Assumptions only
make a check more permissive, so a pair that failed under some fails
under any, and a later visit can fail at once. Failures that hit the
recursion limit depend on the stack, not on the types, so they are not
recorded: `RecursionDepth` now counts its overflows and `refute` checks
that none happened during the pair's check.
The collector also records its own failures here, but still keys its
"see above" entries on the pairs it reported, so a pair refuted only
inside a probe is reported in full when the collector reaches it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The collector still cached failures that hit the recursion limit, so a pair whose check overflowed below a deep path was reported as "not a subtype (see above)" when reached again from a shallow one, where it may well hold. Not caching those failures is not enough on its own: an opt-rule probe that hits the limit falls back to the special rule and succeeds, so the next path re-explores it, and a shared recursive type with a deep enough field is exponential again (2.5 s -> 39 s from depth 8 to 12). So once the limit is hit, `subtype_` and the collector stop: later calls fail at once, and the opt-rule fallback no longer turns a probe that hit the limit into a success. The check was already failed by then, and no result derived from it is cached. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s handled The collector's ordinary opt arm read an `Err` from `trace_type_with_depth` as "not null, reserved or opt" and passed. The collector stops after an overflow, so a later mismatch went unreported and the report could be empty: `nat <: opt L0`, with `L0 = nat` already proved and the alias chain overflowing as it is traced, then hid a `nat <: text` in the next field. The arm now also requires no overflow. In case another path swallows one, `subtype_with_config` and `subtype_check_all` now check the overflow count when they finish. If the limit was hit, they withdraw the check's assumptions and fail: `subtype` with an error, and `subtype_check_all` with an entry saying that the report stopped at the limit. The test helper now matches the recursion-limit message regardless of case: type lookups capitalise it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Bump candid and candid_derive 0.10.38 -> 0.10.39 and the `=` pin between them, date the Unreleased changelog entries as 2026-10-21 / Candid 0.10.39, and refresh all four lockfiles (version bumps only). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
lwshang
force-pushed
the
fix-subtype-check-all-exponential
branch
from
October 21, 2026 02:25
4649f14 to
53f7c03
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


What
A subtype check could take exponential time on a recursive type reachable along many paths. This happened in both
subtype_check_all(and soservice_compatibility_reportanddidc check) andsubtype/subtype_with_config(and soservice_compatibleand the decoder's check offunc/servicereferences):On
master,didc check new.did old.didruns for about 13 s and then runs out of memory. At depth 18 it reports 262,144 = 2^18 "incompatible changes", all for the samenat→text. TheoptvariantA_i = record { opt A_{i+1}; opt A_{i+1}; nat }is exponential in time through the opt rule's probes.Why
A failed pair is withdrawn from
Gamma, which is necessary because the opt-rule probes share it and must not assume a failed pair. But nothing remembered that the pair had failed, so every later path to it checked it again from scratch. The collector keeps going after errors, and a probe falls back after failing, so both reach the same pair along every path.Fix
Assumptionskeeps the pairs the check has refuted, andsubtype_fails at once on one of them. This is sound because assumptions only make a check more permissive: a pair that fails under some assumptions fails under any.failedset is separate from the refuted pairs. A later path to a reported pair adds one entry,A1 is not a subtype of B1 (see above), so a type shared by two methods is still reported under both. A pair refuted only inside a probe is reported in full when the collector reaches it.RecursionDepthnow counts its overflows. After the first one,subtype_and the collector return at once, the opt fallback no longer accepts, and nothing the overflow touched is cached.subtype_with_configandsubtype_check_allcheck the count when they finish. If the limit was hit, they withdraw the check's assumptions and fail.subtype_check_alladdsrecursion limit exceeded; the check stopped there, so this report may be incomplete.The depth-64 versions of both examples now finish instantly. The first reports 65 entries and the
optone reports 1.Behaviour change: an opt probe that hits the recursion limit now fails the check instead of accepting with a warning. Public signatures are unchanged, and
equalis unchanged.Tests
In
rust/candid_parser/tests/compatibility.rs:shared_recursive_types_are_checked_oncetype_shared_by_two_methods_is_reported_under_bothshared_recursive_types_under_opt_are_probed_oncepair_refuted_by_a_probe_is_still_reportedrust/candid/tests/subtype_recursion_limit.rsruns on a thread with a fixed 1 MiB stack, after measuring the shortest chain that overflows there:probe_that_hits_the_limit_fails_the_check: an opt diamond whose field only fails by hitting the limit.pair_that_hit_the_limit_is_not_reported_as_incompatible: a pair that overflows when reached deep is not reported as "see above" when reached shallow.overflow_while_tracing_an_alias_fails_the_check: tries every wrapper depth around the limit, so the overflow lands while tracing an alias for one of them.Each of these fails or hangs without the change it covers.
cargo testpasses under the three feature setsrust.ymluses, andcargo clippy --features all --tests -D warningsis clean.Release: candid 0.10.39
Patch release of
candid/candid_derive, 0.10.38 → 0.10.39, cut in this PR together with the fix. It also ships #775.candidandcandid_deriveto 0.10.39, and the=pin between them2026-10-21/Candid 0.10.39(fix: Withdraw assumptions derived from a failed subtype step #775 and this PR)Cargo.lock,rust/bench/Cargo.lock,tools/ui/Cargo.lock,rust/candid/fuzz/Cargo.lock(version bumps only, no dependency churn)candid_parserstays at 0.4.1; itscandiddependency is a0.10.16floor.Test plan
cargo check -p candid -p candid_derive -p candid_parsercargo test -p candid --features all: all suites passcd tools/ui && cargo build --target wasm32-unknown-unknown --profile canister --package didjs --locked, the release workflow's exact command (built against placeholderdist/assets, sincenpmwas not available locally)cd rust/candid/fuzz && cargo checkAfter merge
2026-10-21onmaster.candid-ui-release.ymltriggers on it to publish thecandid_uicanister wasmworkflow_dispatch) with thecandidinput checked, which publishescandid_derivethencandid🤖 Generated with Claude Code