Skip to content

Add Layer-2 concurrency stress specs#2821

Open
ericproulx wants to merge 1 commit into
masterfrom
layer2-concurrency-stress
Open

Add Layer-2 concurrency stress specs#2821
ericproulx wants to merge 1 commit into
masterfrom
layer2-concurrency-stress

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

Summary

Test-only PR completing the thread-safety test plan started with the Layer-1 invariant specs (#2820). Layer 2 exercises the live request path under real threads: barrier-released concurrent requests against one shared compiled API, where every thread asserts an exact, thread-specific response. Any cross-request state bleed produces a deterministic failure message — the tests never rely on catching a torn write by luck, which is what keeps them non-flaky on MRI.

Independent of the #2817#2819#2820 stack, so it targets master directly.

The three tests

1. Array index tracking isolation. ParamScopeTracker holds per-request bracket indices in fiber-local storage (Fiber[]). Eight threads hit an Array-scope endpoint, each planting the invalid element at its own index; each must get exactly list[<own index>][name] is missing back. If tracker state ever leaked across threads/requests, another thread's index would surface in the error. Teeth-verified: a temporary patch corrupting store_index (what a cross-thread clobber produces) fails the spec.

2. Mutable default isolation. Each request mutates a default: { tags: [] } param and must see exactly its own mutation. Teeth-checking this one produced the most interesting finding of the PR: a patch making DefaultValidator hand out a shared default object does not break the test — because assigning into the params container (HashWithIndifferentAccess) deep-converts the value, an independent second layer of protection. The spec comment documents this honestly: it pins the end-to-end property, which fails only if both layers regress (e.g. a future params builder storing defaults by reference).

3. Cold-boot contention. Four fresh, deliberately uncompiled apps are each hit by eight racing first requests — exercising Instance#compile!'s double-checked LOCK path and the first traversal of the shared coercer graph, the only windows where boot-phase state is still being published.

Mechanics

  • A Queue-based ready/gate barrier holds all threads until every one is spawned, then releases them together, maximizing overlap.
  • One Rack::MockRequest per request — never a shared Rack::Test session, which is not thread-safe and would produce false alarms.
  • Thread#value re-raises anything a worker raised, so an exception in any thread fails the example with its own backtrace.
  • 8 threads × 5 iterations per test; the whole file runs in well under a second.

Layer 3 comes free

The edge workflow runs the full suite (bundle exec rspec) on jruby-head and truffleruby-head, so once merged these specs also run under true parallelism there — no tagging or CI wiring needed. On MRI they catch logic bleed; on JRuby/TruffleRuby they additionally cover memory-visibility races the GVL masks.

Full suite: 2370 examples, 0 failures. RuboCop clean. No CHANGELOG entry (test-only, per #2818/#2820 precedent).

🤖 Generated with Claude Code

Three targeted tests firing barrier-released concurrent requests at one
shared compiled API, each asserting an exact thread-specific response so
cross-request contamination fails deterministically rather than being
observed by luck:

- Array index tracking isolation: each thread plants the invalid element
  at its own index and must get exactly its own index back in the error
  message — pins ParamScopeTracker's fiber-local per-request state
  (teeth-verified: a corrupted store_index fails the spec).

- Mutable default isolation: each request must see exactly its own
  mutation of a default: { tags: [] } param. Pins the end-to-end
  property enforced jointly by DefaultValidator's per-request dup and
  the params container's copy-on-assignment.

- Cold-boot contention: fresh uncompiled apps hit by racing first
  requests, exercising Instance#compile! (double-checked LOCK) and the
  first traversal of the shared coercer graph.

On MRI these catch logic bleed; the edge workflow runs the full suite on
jruby-head/truffleruby-head, so they also run under true parallelism
there (Layer 3) with no extra wiring.

Test-only; no production changes.

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

Copy link
Copy Markdown

Danger Report

Warnings

  • Unless you're refactoring existing code or improving documentation, please update CHANGELOG.md.

Markdowns

Here's an example of a CHANGELOG.md entry:

* [#2821](https://github.com/ruby-grape/grape/pull/2821): Add layer-2 concurrency stress specs - [@ericproulx](https://github.com/ericproulx).

View run

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