chore(caps): single source of truth for the capability registry, plus the guard the code already promised#81
Merged
Conversation
The six capabilities that are un-erased on the Wasm side (Fs / Net / Db / Proc / Env / Clock) were spelled out as a verbatim tuple at 21 sites across the Wasm emitter and the WIT generator, with no named constant anywhere. Adding a seventh would have meant finding all 21 by hand, and missing one would silently drop the cap's handle at whichever slot the site governs. Introduce HANDLE_BEARING_CAPS in ir/_capa_types.py next to BUILTIN_CAPS, plus its deliberately-spelled-out complement ERASED_CAPS (Stdio / Random / Unsafe), and route all 21 sites through it. Every site was read first and means exactly the same set; _emit_wit's private _HANDLE_BEARING_CAPS is folded into the shared one. The near-variant sets are audited but NOT forced into the constant, because each means something genuinely different: - _caps.py "allows" and _discovery.py "allows" (five caps, no Clock) are the handle-bearing caps whose allows takes a STRING argument; Clock's is nullary and has its own emitter. - _caps.py indirect-return (Db / Proc / Env) is the GENERIC path; Fs, Net and Clock are dispatched by dedicated emitters before it. - _discovery.py restrict_to (four caps) splits by ATTENUATOR METHOD NAME, not by cap class: Env narrows via restrict_to_keys and Clock via restrict_to_after. Each of those now carries a registry note saying so. _emit_wit's two copies of the WASI-migrated set DID mean the same thing and are now _WASI_FULLY_MIGRATED_CAPS. Its _KNOWN_CAPABILITIES omits Unsafe deliberately, not by drift: the Wasm emitter rejects Unsafe up front so it never reaches WIT, and adding it would make the interface loop raise instead of skip. Documented in place. Behaviour-neutral: no set changes value.
ir/_capa_types.py claimed a property test cross-checked BUILTIN_CAPS against typesys.CAPABILITY_NAMES. It did not exist, and the two lists had never been compared by anything. Two copies of the built-in set had in fact already drifted. Add TestCapabilityRegistry with five guards: - CAPABILITY_NAMES and BUILTIN_CAPS agree exactly, naming the offending capability in the failure message. - Every built-in cap is classified as handle-bearing or erased, so a new one cannot default silently into "erased". - The two lowering classes are disjoint and contain no strays. - Every handle-bearing cap has a host root handle; a missing one would fall back to the Fs root, a silent cross-cap substitution. - The WIT generator knows every built-in except Unsafe, pinning that omission as intentional rather than letting it rot. They live in tests/test_cap_handles.py rather than the promised tests/test_properties.py because that module skips wholesale when Hypothesis is absent, and a registry guard must never be skippable. The stale comment now points here. Fix the one real drift the guards caught: the LSP completion floor listed seven of the nine capabilities, so Proc and Db were never offered by the editor. The list is now derived from CAPABILITY_NAMES instead of hand-copied.
The guard added in the previous commit docstringed itself as protecting WasmHost._invoke_main's param-name map, but asserted against bootstrap_root_handles' output instead. That dict is strictly larger - it serves the erased caps too - so the guard passed for any cap bootstrap already supports while the param-name map omitted it. Reclassifying Stdio as handle-bearing proved the gap: the guard stayed green even though a main declaring Stdio would have silently received the Fs root. Two changes: - Extract the map into a module-level _root_handle_map so the guard can assert against the real thing, and DERIVE it from HANDLE_BEARING_CAPS instead of hand-listing six entries. A future attenuation slice that un-erases a cap now gets the right root by construction rather than by remembering to edit a literal. - The guard additionally requires each handle-bearing cap to resolve to a NON-ZERO handle. Derivation cannot fix the case where _invoke_main never bootstraps a root instance for the cap at all (Random and Unsafe are not passed), which would hand the guest handle 0, the "no cap" sentinel. Verified both directions: with the map still hand-listed, the fixed guard fails naming Stdio; after derivation that case passes because it is genuinely handled, and reclassifying Random instead fails on the non-zero check. Also correct the guard's second misstatement: the map is keyed by main's PARAM NAME, not by the cap type name. The host never sees the type, only the identifier the program chose - which is the root of a separate pre-existing divergence being ticketed. capa.runtime does not import _wasm_host, so transpiled programs do not pick up the new capa.ir edge; verified no import cycle. Fold in one stale docstring: _floor_completions no longer describes capabilities as curated.
…smtime The root-handle guard reached capa.runtime._wasm_host at module scope, which imports wasmtime at module load. The plain `test` CI job does not install the wasm extra, so the whole module failed to IMPORT - and a failed import is an ERROR, not a skip. All nine Python matrix jobs went red; the WASI job passed because it has wasmtime, which is why a local pytest run did not catch it. Same lesson as PR #73. Move the _wasm_host import inside the one method that needs it and gate that method on a local _has_wasmtime() probe, following the idiom already used by test_aot, test_ir_wasm and the foreign test modules. The point of putting these guards in test_cap_handles.py rather than the skip-happy test_properties.py was that they must always run, so the fix deliberately keeps that property: only the root-handle guard, which genuinely needs the host, is skippable. The five pure-data registry guards (the two registries agree, every cap is classified, the classes are disjoint, the LSP list is complete, the WIT known-caps set) still execute with no wasmtime. Verified against a meta-path shim that blocks wasmtime: the old code fails to import, the new code collects 16 tests, runs 15 and skips only the root-handle guard. Re-proved all three guard directions still bite (fake cap in one registry, fake cap in both, Random reclassified as handle-bearing).
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.
Groundwork before adding a tenth built-in capability. No new capability here, and no behaviour change except one intentional fix.
Why
All nine capabilities date from the initial commit; nobody has ever added one, and the registration has no seam. The handle-bearing set was the literal
("Fs","Net","Db","Proc","Env","Clock")copied verbatim at 21 sites, and the two nominally-authoritative lists (CAPABILITY_NAMESfor the front end,BUILTIN_CAPSfor the backends) were cross-checked by a property test that the code comment described but which did not exist.The drift was already real: LSP completion had been missing
ProcandDbsince those capabilities shipped. Adding a tenth capability by hand through that would have reproduced it.What changed
HANDLE_BEARING_CAPSand a spelled-outERASED_CAPSincapa/ir/_capa_types.py, replacing all 21 verbatim tuples.WasmHost._invoke_main's param-name to root-handle map is now derived fromHANDLE_BEARING_CAPSrather than a parallel hardcoded literal, which removes the possibility of the two diverging rather than merely detecting it.tests/test_cap_handles.pyrather thantests/test_properties.pybecause the latter skips its whole module when Hypothesis is absent and a registry guard must never be skippable. Six guards: the two registries agree, every capability is classified handle-bearing or erased, every handle-bearing capability has a host root handle and that handle is non-zero, and the WIT known-capability set is pinned.Clock.allowsis nullary so it cannot join the string-argument set;EnvandClockspell their attenuatorrestrict_to_keys/restrict_to_afterso that split is by method name, not by capability; andFs/Nethave dedicated emitters so the generic indirect-return set is correctly justDb/Proc/Env. Two sites that genuinely did mean the same thing were derived into_WASI_FULLY_MIGRATED_CAPS._KNOWN_CAPABILITIESomittingUnsafewas judged deliberate rather than drift and left alone, now pinned by a test:Unsafeis rejected before WIT generation, and its surface isCalls rather thanMethodCalls, so it never reaches the interface loop. Forcing it in raisesUnsupportedCapabilityMethod, confirmed empirically.CAPABILITY_NAMES, so it now offers all nine capabilities.Evidence
The guards were proven to bite in every direction: a capability present in one registry only, a capability in both but unclassified, and a handle-bearing capability with no host root. Spelling out
ERASED_CAPSrather than deriving it by subtraction is what makes the second guard work; a derived complement silently classifies every new capability as erased, which was verified.Behaviour-neutrality was checked mechanically and empirically: all 21 substituted sites had an identical member set, and 143 WAT and WIT artefacts were byte-identical against the base commit across the whole examples corpus in both default and WASI modes. A both-backends runtime sweep covering every handle-bearing and erased capability, including attenuated capabilities threaded through structs, traits and closures, matched throughout.
Suite 4263 passed, 18 skipped (4257 baseline plus the six new guards).
Found while in here, not fixed
_invoke_mainkeys that map bymain's parameter name, with a fallback to theFsroot. Renaming a parameter therefore changes behaviour on the Wasm backend: five of six handle-bearing capabilities go from allowed on Python to denied on Wasm, andFsonly appears to work becauseFsis the fallback. It fails closed, since the handle table is type-checked, so there is no confinement break, but it is a silent Python-versus-Wasm divergence and gets its own ticket.