Skip to content

Add Layer-1 thread-safety invariant specs#2820

Merged
dblock merged 1 commit into
fix-coercer-request-time-statefrom
layer1-thread-safety-invariants
Jul 26, 2026
Merged

Add Layer-1 thread-safety invariant specs#2820
dblock merged 1 commit into
fix-coercer-request-time-statefrom
layer1-thread-safety-invariants

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

Summary

Test-only PR adding the two deterministic invariant tests from the thread-safety plan ("Layer 1") — the follow-up to #2819. Instead of trying to observe races with threads (which the GVL hides on MRI), these make the invariants that prevent races fail loudly and deterministically when violated. Zero timing sensitivity, zero flakiness.

Stacked on #2819 (freeze-coercers-and-sync-caches): the frozen-graph assertions require FreezeOnNew, and the cache spec file this extends was introduced there. Retargets automatically as the stack merges.

1. Frozen-graph canary (spec/grape/validations/frozen_validation_graph_spec.rb)

Builds one API touching the full taxonomy — every stock validator, primitive/array/set/nested-array/multiple/variant/custom coercers, oneof variants, nested and lateral (given) scopes — then walks every object reachable from the compiled routes' validator lists and asserts each instance of a contract class (validators, iterators, ParamsScope, all coercer hierarchies) is frozen.

Design notes:

  • The walker recurses through ivars, hashes, arrays and sets, skipping value types, callables (user-supplied procs are deliberately unfrozen), open classes/modules, and Dry:: internals (external library, outside grape's contract). Cycle-safe via an identity-keyed seen set.
  • It asserts frozenness by reachability, not by roster — a future shared class that never adopts FreezeOnNew fails this spec, which is stronger than re-testing the classes that already opted in.
  • A census assertion requires the walk to have visited 15 specific classes (including oneof variant validators reached through nesting and MultipleAttributesIterator) — so the spec can't pass vacuously by walking nothing. The prototype run visits ~276 objects.
  • A walker self-test feeds it a deliberately unfrozen iterator and asserts the violation is reported — the canary's own smoke detector.

2. Cache growth canary (in spec/grape/util/cache_spec.rb)

Snapshots every Grape::Util::Cache subclass's key set after definition + compile!, then fires first-ever requests — valid, invalid, wrong-shape payloads, unmatched path (404), OPTIONS, and un-routed PATCH (405) — and asserts no cache gained a key.

This pins the property that makes the caches' unsynchronized reads safe: they are written only at boot. #2819's Monitor makes concurrent writes safe; this spec ensures request-time writes don't (re)appear silently. It would have caught the pre-#2817 ArrayCoercer lazily seeding DryTypes::ParamsCache on the first request. Discovered via Class#subclasses, so future caches are covered automatically.

Results

Both canaries pass on first run against the current stack — independent confirmation that #2816#2819 left no request-time mutation in the validation layer, including on error/404/405 paths.

Full suite: 2388 examples, 0 failures. RuboCop clean. No CHANGELOG entry (test-only, matching #2818's precedent).

🤖 Generated with Claude Code

Two deterministic tests that turn the validation layer's concurrency
invariants into permanent regression guards — no timing or thread
interleaving involved, so no flakiness:

- Frozen-graph canary: walks every object reachable from the compiled
  routes' validator lists (validators, iterators, scopes, coercers —
  including oneof variant validators reached through nesting) and
  asserts each contract-class instance is frozen. Catches classes that
  never adopted the FreezeOnNew contract, not just regressions in those
  that did. A census assertion proves the walk actually swept the whole
  taxonomy, and a self-test proves the walker reports unfrozen objects.

- Cache growth canary: snapshots every Grape::Util::Cache key set after
  definition + compile!, then fires first-ever requests (valid, invalid,
  wrong-shape, 404, OPTIONS, 405) and asserts no cache gained keys.
  Pins the "caches are written only at boot" property that makes their
  unsynchronized reads safe; would have caught the pre-#2817
  ArrayCoercer seeding DryTypes::ParamsCache on first request.

Test-only; no production changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

Base automatically changed from freeze-coercers-and-sync-caches to fix-coercer-request-time-state July 26, 2026 17:16
@dblock
dblock merged commit af778b3 into fix-coercer-request-time-state Jul 26, 2026
70 checks passed
@ericproulx
ericproulx deleted the layer1-thread-safety-invariants branch July 26, 2026 19:04
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.

2 participants