Repository navigation
Phase 4a: execution context — tests and the fake - #58
Merged
Merged
Conversation
This was referenced Sep 16, 2026
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
force-pushed
the
14-phase-4a-execution-context-tests
branch
from
September 16, 2026 20:04
409d2c7 to
5752d86
Compare
Wahbeh-Mohammad
force-pushed
the
14-phase-4a-execution-context
branch
from
September 16, 2026 20:04
d8cf132 to
c098a95
Compare
Wahbeh-Mohammad
changed the base branch from
14-phase-4a-execution-context
to
main
September 16, 2026 20:09
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.
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/**mirroringlib/one file per file — the ten mirrors (context_test.rb,context_store_test.rb,error/context_conflict_error_test.rb, the three undercontext/, the four underinstrumentation/), split into nestedDexpaceTestCaseclasses per behaviour group forMetrics/ClassLength— plus the one double the design's testing strategy names and one gate test:FakeContext—include Dexpace::Context, acall_keyand astore, 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#closefree from the module, which is what makes theCTX-9/CTX-10cases readable. Required byrequire_relativefrom exactly the two suites that use it, never fromtest_helper.rb.test/gates/rubocop_config_test.rb— pins the eighth cop'sIncludeto every gem'slib/, becauseCopCasestructurally cannot see.rubocop.ymland 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 usesassert_nothing_raised.What the suites prove rather than restate:
CTX-9's trap, three lines — two contexts with the same pinnedcall_key, so they are==and notequal?(the only pair that discriminates, R2):setthe first,releasethe second — the slot is untouched and#releasereturnedfalse;releasethe first — gone. Two default-constructed contexts would pass against an==-based implementation.CTX-5/CTX-6/CTX-15, both halves — twoDispatchContext.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 sameequal?Bundle::NONEdiffer.CTX-2by identity —assert_sameon the bundle and the key across both promotions, the source unchanged in the same test;assert_equalwould pass against an implementation that rebuilt them.CTX-16as a negative — aRequestContextpromoted with and without anoperation_nameshares its key, its request and its store slot.CTX-8's race andCTX-7's burst — 32 threads released from one::Thread::Queuebarrier onto one key: exactly one winner, 31ContextConflictErrors 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 atcap, every further insert evicts exactly one and the size is never observed abovecap; the aggregateinserts − capwould pass against a split lock and against no drain at all.CTX-13through a real promotion — cap 3, chains a/b/c at request stage,apromoted to exchange, thend:ais 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#closeon the evicted context returnsfalseand raises nothing.ContextStore.defaultin a fresh process —require "dexpace"alone leaves the singleton assigned before any call, soCTX-17's "construction registers nothing" stays inert by construction (the round-0 finding: a memoised.defaulthad survived every suite).CTX-19's reachability — 1,000 registered contexts, locals dropped, threeGC.starts, size still 1,000 and a sampled key resolving; a discriminator againstObjectSpace::WeakMap, and deliberately not againstWeakKeyMap, which the cop covers.Fiber[]boundary, with its guard —Fiber[:probe]visible andThread.current[:probe]nil inside a childFiber, a new::Threadand anEnumerator'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.tracerfrom 16 threads,assert_sameon all sixteen; the five-parameter call shapes.call_keyoroperation_namerefused on.build,#withand both promotions (the round-1 finding).#sample:TraceIdFlavour.ofround-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.#withre-validation on the floor — on every one of the fiveDatatypes, sinceData#withskipsinitializeon 3.2.11 andModel#withis what re-validates.Verification
bundle exec rakeon Ruby 4.0.6 at this tip: exit 0, all seventeen gates green —test:gems1,272 runs / 6,889 assertions across the six gems (118 of them the ten new suites), line coverage 99.96%;test:gates130 runs;cops:test129 cases;yard402 methods, 0 undocumented.ObjectSpace::WeakKeyMapundefined on 3.2.11,Data#withskippinginitializethere, theFiber[]inheritance, the GC-based reachability — green on every row.timeout.while→ifdrain equivalent. The two that survived round 0 — the release-then-set promotion and the memoised.default— are why the real-promotionCTX-13case and the fresh-process case above exist.Known follow-up from the review (not blocking)
registry.rb:253, a scheduling-dependent branchmain's own suite covers or not run to run — not 4a code and not a 4a test.