Skip to content

Phase 4a: execution context — tests and the fake - #58

Merged
Wahbeh-Mohammad merged 8 commits into
mainfrom
14-phase-4a-execution-context-tests
Sep 16, 2026
Merged

Wahbeh-Mohammad merged 8 commits into
mainfrom
14-phase-4a-execution-context-tests

Conversation

@Wahbeh-Mohammad

@Wahbeh-Mohammad Wahbeh-Mohammad commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Part of #14. Second of three phase 4a PRs — the tests for #57. Targets that PR's branch; the phase record is #59. The same rebase-and-reprove note as #57 applies: the stack predates 4b's merge and rebases before it goes in.

What lands

12 files, +1,691: gems/dexpace-core/test/dexpace/** mirroring lib/ one file per file — the ten mirrors (context_test.rb, context_store_test.rb, error/context_conflict_error_test.rb, the three under context/, the four under instrumentation/), split into nested DexpaceTestCase classes per behaviour group for Metrics/ClassLength — plus the one double the design's testing strategy names and one gate test:

  • FakeContext — include Dexpace::Context, a call_key and a store, nothing else. Every store rule (CTX-7–CTX-13, CTX-18, CTX-19) is about a keyed occupant, so driving them through a real chain would couple the store's suite to three other constructors; it gets #close free from the module, which is what makes the CTX-9/CTX-10 cases readable. Required by require_relative from exactly the two suites that use it, never from test_helper.rb.
  • test/gates/rubocop_config_test.rb — pins the eighth cop's Include to every gem's lib/, because CopCase structurally cannot see .rubocop.yml and narrowing the scope to core alone survived the cop's own table.

Every file's header names the requirement IDs it exercises; every class inherits DexpaceTestCase; every thread is joined (the base class's teardown counts them); nothing uses assert_nothing_raised.

What the suites prove rather than restate:

  • CTX-9's trap, three lines — two contexts with the same pinned call_key, so they are == and not equal? (the only pair that discriminates, R2): set the first, release the second — the slot is untouched and #release returned false; release the first — gone. Two default-constructed contexts would pass against an ==-based implementation.
  • CTX-5/CTX-6/CTX-15, both halves — two DispatchContext.build(bundle: Bundle::NONE) not ==; the same pinned key ==, eql?, hash-equal; three flavours from one counter get three strictly increasing suffixes (strengthened after the per-flavour-counter mutation survived the weaker form); two keys from the same equal? Bundle::NONE differ.
  • CTX-2 by identity — assert_same on the bundle and the key across both promotions, the source unchanged in the same test; assert_equal would pass against an implementation that rebuilt them.
  • CTX-16 as a negative — a RequestContext promoted with and without an operation_name shares its key, its request and its store slot.
  • CTX-8's race and CTX-7's burst — 32 threads released from one ::Thread::Queue barrier onto one key: exactly one winner, 31 ContextConflictErrors each naming the key; 16 threads × 1,000 keys: final size 16,000.
  • CTX-11/CTX-12/XCUT-14's bound, not the vacuous count — from a store at cap, every further insert evicts exactly one and the size is never observed above cap; the aggregate inserts − cap would pass against a split lock and against no drain at all.
  • CTX-13 through a real promotion — cap 3, chains a/b/c at request stage, a promoted to exchange, then d: a is gone, so re-setting a key through promotion does not refresh its position (the round-0 finding: the claim had rested on the fake only), and #close on the evicted context returns false and raises nothing.
  • ContextStore.default in a fresh process — require "dexpace" alone leaves the singleton assigned before any call, so CTX-17's "construction registers nothing" stays inert by construction (the round-0 finding: a memoised .default had survived every suite).
  • CTX-19's reachability — 1,000 registered contexts, locals dropped, three GC.starts, size still 1,000 and a sampled key resolving; a discriminator against ObjectSpace::WeakMap, and deliberately not against WeakKeyMap, which the cop covers.
  • The Fiber[] boundary, with its guard — Fiber[:probe] visible and Thread.current[:probe] nil inside a child Fiber, a new ::Thread and an Enumerator's fiber (the round-0 finding: the guard had covered one carrier of the three), and the store finding the context in all three.
  • CTX-20 — NO_TRACER_FACTORY.tracer from 16 threads, assert_same on all sixteen; the five-parameter call shapes.
  • A Symbol or Integer call_key or operation_name refused on .build, #with and both promotions (the round-1 finding).
  • Property tests through #sample: TraceIdFlavour.of round-trips; #renders?(x) == (#valid_trace_id?(x) || x == invalid_trace_id) over the three flavours — the two predicates disagree at exactly the sentinel, which is why both exist; Bundle.build(**bundle.to_h) == bundle.
  • #with re-validation on the floor — on every one of the five Data types, since Data#with skips initialize on 3.2.11 and Model#with is what re-validates.

Verification

  • bundle exec rake on Ruby 4.0.6 at this tip: exit 0, all seventeen gates green — test:gems 1,272 runs / 6,889 assertions across the six gems (118 of them the ten new suites), line coverage 99.96%; test:gates 130 runs; cops:test 129 cases; yard 402 methods, 0 undocumented.
  • The matrix set on 3.2.11, 3.3.12 and 3.4.10, each with a fresh lock: 1,272 runs, 0 failures, 0 skips. Every interpreter-sensitive assertion — ObjectSpace::WeakKeyMap undefined on 3.2.11, Data#with skipping initialize there, the Fiber[] inheritance, the GC-based reachability — green on every row.
  • The gem suite on four extra seeds (1, 99991, 424242 on 4.0.6; one non-default on 3.2.11): identical run counts, 0 failures; the two thread tests 15/15 under timeout.
  • The three reviewers applied 18, 20 and 24 single-edit mutations themselves; every one caught at this tip on both interpreters except the plan's documented while→if drain equivalent. The two that survived round 0 — the release-then-set promotion and the memoised .default — are why the real-promotion CTX-13 case and the fresh-process case above exist.

Known follow-up from the review (not blocking)

  • One report discrepancy, explained: the floor's tests-tip coverage reads 3,188 / 3,189 (99.96%) in the final review where a fix round reported 100.00%; the line is phase 2's registry.rb:253, a scheduling-dependent branch main's own suite covers or not run to run — not 4a code and not a 4a test.

Phase 4a (issue #14). One module, Dexpace::Context, that the three
flat Data flavours include: CTX-1's one-way chain is DispatchContext
-> RequestContext -> ExchangeContext, with #promote_to_request and
exchange stage terminal by the absence of a third (P4-1). Each
promotion builds the successor through its .build, carries the bundle,
the call key and the store forward by identity, adds exactly one
artefact, and registers the successor with store.set -- the first
store entry a chain ever has, because construction registers nothing
(CTX-2, CTX-3, CTX-17). #close is store.release(self): a frozen Data
cannot carry a latch, and CTX-9's identity-conditional eviction makes
it idempotent through the store instead (P4-4). CTX-16's operation
name is nil or a non-empty frozen String, carried forward unchanged,
advisory only.

Dexpace::CallKey (private_constant) mints CTX-4's key, a frozen
"traceId:spanId:n" with one process-wide counter under one
::Thread::Mutex, so two default-constructed contexts are never ==
and a pinned call_key: is the one way to make them so (CTX-5, CTX-6).
Dexpace::ContextStore HAS the private_constant Dexpace::BoundedMap --
the one bounded-keyed-map implementation XCUT-14 and AUTH-19 will
share, a strong Hash behind one mutex with XCUT-14's drain loop inside
the same synchronize as the insert -- and exposes CTX-8's #set and
after the mutex is released), CTX-18's #[] and the identity-checked
assigned .default. The cap must be a positive Integer, since a
negative one would spin the drain forever.

Dexpace::Instrumentation is roadmap obligation 1 discharged: Bundle,
a frozen Data of eight members with #valid? derived (P4-6) and NONE
carrying OBS-26's sentinels; TraceIdFlavour, a closed set of NONE,
W3C and DATADOG with the trace-id sentinel on the flavour and the
span-id sentinel on Bundle (P4-7); and NO_SPAN, NO_TRACER and
NO_TRACER_FACTORY, three frozen singletons whose classes are private
so phase 5c can widen them without an NFR-4 diff, the factory's
(P4-8). Every sig/ mirror declares every method for the strict core
Steep target, the two private constants included as hooks.rbs is;
_ContextHost is the self-type Context needs (P4-40). The entry file
requires the twelve files in dependency order.
Dexpace/NoWeakReferences is CTX-19 as a lint rule (P4-10): a constant
reference to ObjectSpace::WeakMap or ::WeakKeyMap -- qualified,
cbase-qualified, or bare inside `module ObjectSpace` -- to WeakRef, or
a `require "weakref"` in any form tools/require_scan.rb reads, is an
offense naming CTX-19 and the cap CTX-11 makes the leak backstop. A
source-text cop, because RuboCop parses and never evaluates, so the
WeakKeyMap rows parse at TargetRubyVersion 3.2 where the constant does
not exist. It is the eighth custom cop, not the plan's seventh:
CLAUDE.md, quality-gates.md, phase 2's checklist and cops_test.rb all
count Dexpace/NoKeywordSplat, which phase 0's plan says is uncounted.
Its cases live in a nested class of cops_test.rb under the 100-line
cap, fourteen rejected and fifteen accepted on RuboCop 1.91.0; the
.rubocop.yml entry scopes it to every gem's lib/, since the require
allowlist covers core alone.

The runtime surface manifest of dexpace-core grows from 515 to 580
lines through `rake surface:regenerate`, once, and every one of the
65 added rows was read against the design's object model: the seven
flat constants and the instrumentation subsystem, their .build and
.of and .default singletons, the Data readers of the five value types
(which the shipped walker holds, closing the plan's finding against
phase 0), Bundle's #valid? and #remote?, TraceIdFlavour's two
predicates and four constants, ContextStore's five methods and its
cap, and the three no-op singletons typed by their private classes.
Nothing removed; no private_constant present. Context's construction
validation is a private instance method rather than the plan's public
Context.validate!, so the module's public surface is the one method
the design gives it. The core smoke suite's constant list gains the
seven public constants and asserts BoundedMap and CallKey are as
unreachable as Hooks. Both are files a gate reads at run time, so
they travel with the code.
Review round 0, R0-4 and R0-5. dispatch_context.rbs still named the
plan's public Context.validate!, which the build made the private
instance method #validate_context! (checklist deviation 5); the comment
now names what lib defines. The cop's head comment claimed to match
`require "weakref"` "in the forms tools/require_scan.rb reads", but the
scanner's loader list includes autoload and the cop's matcher does not:
`autoload :WeakRef, "weakref"` produces no offense on its own line. The
comment now states the three require spellings the matcher reads and
why the autoload form needs no row -- it names the constant as a Symbol,
and the reference that later triggers it is the one on_const flags,
verified against a scratch adapter file on 4.0.6.
Context#validate_context! accepted any non-nil object as a pinned
call_key and the two flavours that carry an operation_name checked only
for "": a Symbol passed silently, keying a slot no String lookup could
find, and an Integer escaped as a NoMethodError from #empty? where every
core model raises a field-named InvalidArgumentError. The design says a
given key "must be a non-empty String" and that operation_name is nil or
a non-empty frozen String, and sig/ already types both that way.

Add the "must be a String" guard to #validate_context!, between the
required! check and the empty check, and hoist CTX-16's two-state rule
into one private Context#validate_operation_name! the RequestContext and
ExchangeContext initializers share, so the message form lives in one
place the way Model.required! keeps SEAM-29's. The .build paths pass a
frozen Symbol or Integer through Model.frozen_string untouched, so the
guard in initialize covers .build, #with and both promotions alike.
No public signature changes; context.rbs declares the private helper.
The three phase-4 lanes each added their layer's resolution case to
dexpace_test.rb; alone each kept the class under Metrics/ClassLength's
hundred lines, together they reach 116. The six "requiring dexpace
alone makes the whole <layer> resolve" cases move into a nested
DexpaceTest::Layers, the split every larger suite in this gem already
uses, so the honest RuboCop run is clean again. No case changes.
Ten suites mirroring the ten public lib/ files one for one, each
opening with the IDs it exercises, 112 cases in all, identical counts
on 3.2.11, 3.3.12, 3.4.10 and 4.0.6, and one fake, FakeContext, a
real in-memory Dexpace::Context of two members required explicitly by
the two suites that drive the store through a keyed occupant.

The tests a reader would otherwise write wrong, written the way the
design says: CTX-9's trap over two DispatchContexts with one pinned
key -- == and not equal?, the only way the pair exists -- so a
release by value equality fails where two minted contexts would pass
it; CTX-2's carried-forward members asserted with assert_same, never
assert_equal, so a promotion that rebuilds the bundle fails; CTX-15's
two keys minted from the SAME Bundle::NONE object; CTX-16 asserted as
a negative over a named and an unnamed promotion sharing one key;
CTX-8's race with 32 threads released from one ::Thread::Queue onto
one key, every loser's error asserted on; CTX-12's discriminating
single-threaded form, exactly one eviction per insert from cap and
the size never above it, with the aggregate count it does not use
explained in the comment; CTX-19's 1000 contexts surviving three
GC.starts with every local dropped, plus a real Request and Response
kept reachable through the store; and the Fiber[] boundary with its
setup guard. CTX-6's case asserts the counter suffix increases
strictly across flavours in build order rather than that three keys
are distinct, because a counter per flavour passed the weaker form in
the guard battery. Every thread a case starts is joined, which
DexpaceTestCase's teardown asserts; every suite is one class per
behaviour group under the 100-line cap.

test/gates/rubocop_config_test.rb pins Dexpace/NoWeakReferences's
require: line, its enablement and its every-gem scope, so a narrowing
of Include: to core alone is the one mutation of .rubocop.yml the
gate suite catches.
…uard

Review round 0, R0-1, R0-2 and R0-3, all in context_store_test.rb.

R0-1: the CTX-13 no-refresh claim was proven only through ContextStore#set
on a FakeContext, so a #promote_to_exchange that released its source
before setting the successor -- refreshing the chain's eviction position
and leaving the slot transiently empty between two mutex acquisitions --
survived every suite. A BoundTest case now drives the policy through the
real path: cap 3, three chains at the request stage, a's promoted to
exchange, a fourth chain promoted, a still the victim and both of its
links closing false. The survivor assertion is a key list so the red
never dumps a whole context. Under the mutation: Expected ["b","c","d"]
Actual ["a","c","d"] on 4.0.6 and 3.2.11.

R0-2: a memoised .default (`@default ||= new`, load-time line deleted)
survived, because the identity case runs after some .build has already
called .default in-process. A ReachabilityTest case asks a fresh process
-- RbConfig.ruby -w -W:deprecated, `require "dexpace"` alone -- whether
the ivar is set before any call. Under the mutation: Expected "true"
Actual "false" on both interpreters.

R0-3: the Fiber[] boundary test's setup guard covered the main fiber and
a child Fiber; the design names three carriers. The guard now writes a
fiber-storage slot and a fiber-local slot on the main fiber and asserts
[:fiber_storage, nil] inside a child Fiber, a new ::Thread and an
Enumerator's internal fiber -- observability/016d9154's fact, re-run on
4.0.6 and 3.2.11 -- and resets both slots in an ensure.

Store suite 18 -> 20 runs; ten seeded runs on 4.0.6 and one on 3.2.11
green.
Four cases for review round 1's R1-1, each asserting the field-named
InvalidArgumentError message on :sym and 5 (or :GetUser and 7): the
probe includer in context_test.rb drives #validate_context! directly and
the surface case now names #validate_operation_name! as private too;
dispatch_context_test.rb covers DispatchContext.build(call_key:) and a
#with derivation; request_context_test.rb covers RequestContext.build
and the dispatch->request promotion, and asserts the failed promotion
registered nothing; exchange_context_test.rb covers the terminal
flavour's own build path. Four mutations run red on 4.0.6 and 3.2.11:
the call_key guard deleted (1 failure in each of the first two suites),
the operation_name guard deleted (1 in each of the last two), and the
helper's call removed from either flavour's initializer (2 failures in
that flavour's suite alone, the other suite staying green).
@Wahbeh-Mohammad
Wahbeh-Mohammad force-pushed the 14-phase-4a-execution-context-tests branch from 409d2c7 to 5752d86 Compare September 16, 2026 20:04
@Wahbeh-Mohammad
Wahbeh-Mohammad force-pushed the 14-phase-4a-execution-context branch from d8cf132 to c098a95 Compare September 16, 2026 20:04
@Wahbeh-Mohammad
Wahbeh-Mohammad changed the base branch from 14-phase-4a-execution-context to main September 16, 2026 20:09
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit 124a326 into main Sep 16, 2026
10 checks passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the 14-phase-4a-execution-context-tests branch September 16, 2026 20:11
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