From d36ff36e5960030c0d6a84bcefa067e0eed2503d Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 16 Sep 2026 16:49:55 +0300 Subject: [PATCH 1/8] feat: the execution context -- three flavours, the store, the bundle 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. --- gems/dexpace-core/lib/dexpace.rb | 18 ++ gems/dexpace-core/lib/dexpace/bounded_map.rb | 119 +++++++++++++ gems/dexpace-core/lib/dexpace/context.rb | 57 +++++++ .../lib/dexpace/context/call_key.rb | 48 ++++++ .../lib/dexpace/context/dispatch_context.rb | 68 ++++++++ .../lib/dexpace/context/exchange_context.rb | 52 ++++++ .../lib/dexpace/context/request_context.rb | 64 +++++++ .../dexpace-core/lib/dexpace/context_store.rb | 105 ++++++++++++ .../dexpace/error/context_conflict_error.rb | 28 +++ .../lib/dexpace/instrumentation/bundle.rb | 160 ++++++++++++++++++ .../lib/dexpace/instrumentation/no_span.rb | 21 +++ .../lib/dexpace/instrumentation/no_tracer.rb | 57 +++++++ .../instrumentation/trace_id_flavour.rb | 130 ++++++++++++++ gems/dexpace-core/sig/dexpace/bounded_map.rbs | 21 +++ gems/dexpace-core/sig/dexpace/context.rbs | 20 +++ .../sig/dexpace/context/call_key.rbs | 14 ++ .../sig/dexpace/context/dispatch_context.rbs | 22 +++ .../sig/dexpace/context/exchange_context.rbs | 26 +++ .../sig/dexpace/context/request_context.rbs | 27 +++ .../sig/dexpace/context_store.rbs | 17 ++ .../dexpace/error/context_conflict_error.rbs | 9 + .../sig/dexpace/instrumentation/bundle.rbs | 47 +++++ .../sig/dexpace/instrumentation/no_span.rbs | 15 ++ .../sig/dexpace/instrumentation/no_tracer.rbs | 31 ++++ .../instrumentation/trace_id_flavour.rbs | 33 ++++ 25 files changed, 1209 insertions(+) create mode 100644 gems/dexpace-core/lib/dexpace/bounded_map.rb create mode 100644 gems/dexpace-core/lib/dexpace/context.rb create mode 100644 gems/dexpace-core/lib/dexpace/context/call_key.rb create mode 100644 gems/dexpace-core/lib/dexpace/context/dispatch_context.rb create mode 100644 gems/dexpace-core/lib/dexpace/context/exchange_context.rb create mode 100644 gems/dexpace-core/lib/dexpace/context/request_context.rb create mode 100644 gems/dexpace-core/lib/dexpace/context_store.rb create mode 100644 gems/dexpace-core/lib/dexpace/error/context_conflict_error.rb create mode 100644 gems/dexpace-core/lib/dexpace/instrumentation/bundle.rb create mode 100644 gems/dexpace-core/lib/dexpace/instrumentation/no_span.rb create mode 100644 gems/dexpace-core/lib/dexpace/instrumentation/no_tracer.rb create mode 100644 gems/dexpace-core/lib/dexpace/instrumentation/trace_id_flavour.rb create mode 100644 gems/dexpace-core/sig/dexpace/bounded_map.rbs create mode 100644 gems/dexpace-core/sig/dexpace/context.rbs create mode 100644 gems/dexpace-core/sig/dexpace/context/call_key.rbs create mode 100644 gems/dexpace-core/sig/dexpace/context/dispatch_context.rbs create mode 100644 gems/dexpace-core/sig/dexpace/context/exchange_context.rbs create mode 100644 gems/dexpace-core/sig/dexpace/context/request_context.rbs create mode 100644 gems/dexpace-core/sig/dexpace/context_store.rbs create mode 100644 gems/dexpace-core/sig/dexpace/error/context_conflict_error.rbs create mode 100644 gems/dexpace-core/sig/dexpace/instrumentation/bundle.rbs create mode 100644 gems/dexpace-core/sig/dexpace/instrumentation/no_span.rbs create mode 100644 gems/dexpace-core/sig/dexpace/instrumentation/no_tracer.rbs create mode 100644 gems/dexpace-core/sig/dexpace/instrumentation/trace_id_flavour.rbs diff --git a/gems/dexpace-core/lib/dexpace.rb b/gems/dexpace-core/lib/dexpace.rb index d37e06e..ee0acdb 100644 --- a/gems/dexpace-core/lib/dexpace.rb +++ b/gems/dexpace-core/lib/dexpace.rb @@ -79,6 +79,24 @@ require_relative "dexpace/http/body/response_logging_body" require_relative "dexpace/http/typed_response" +# Phase 4a: the execution context, in dependency order -- the conflict error, the module the +# three flavours share, the private bounded map and the store over it, the instrumentation +# subsystem (the flavour table, the two no-op singletons, the bundle that carries them), then the +# private key generator and the three flavours innermost first, since each promotion names its +# successor. +require_relative "dexpace/error/context_conflict_error" +require_relative "dexpace/context" +require_relative "dexpace/bounded_map" +require_relative "dexpace/context_store" +require_relative "dexpace/instrumentation/trace_id_flavour" +require_relative "dexpace/instrumentation/no_span" +require_relative "dexpace/instrumentation/no_tracer" +require_relative "dexpace/instrumentation/bundle" +require_relative "dexpace/context/call_key" +require_relative "dexpace/context/exchange_context" +require_relative "dexpace/context/request_context" +require_relative "dexpace/context/dispatch_context" + # Phase 4b: the recovery layer, in dependency order -- the cause walk, the two flat errors, the # closed outcome and its two variants, the Recovery namespace with its buffering function, the # transform contract, the three transforms, the request chain, the private ownership helper, the diff --git a/gems/dexpace-core/lib/dexpace/bounded_map.rb b/gems/dexpace-core/lib/dexpace/bounded_map.rb new file mode 100644 index 0000000..8a79c6e --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/bounded_map.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "model" + +module Dexpace + # The one bounded-keyed-map implementation XCUT-14's general rule, CTX-11's context store and + # (phase 6) AUTH-19's per-nonce counter store all share -- design §5.4's "one implementation", + # as a class rather than a module function because XCUT-14's auditable unit is the map, and a + # `drain!(hash, cap:)` would leave each consumer to own the hash, the mutex and the decision to + # call it, which is precisely the drift the shared implementation exists to prevent. + # + # Not public API (P4-3): nothing outside this repository's own namespace needs the map, and + # NFR-4 locks every public name at the first release tag. A private_constant on Dexpace is + # reachable by a BARE name from any `module Dexpace; module X` body at any nesting depth, in + # any gem -- the reachability is lexical and per file, and a gem boundary is not a lexical one + # -- and from nowhere else: the compact `module Dexpace::X` form raises NameError, and so does a + # qualified `Dexpace::BoundedMap` even from inside Dexpace. Verified on 3.2.11, 3.4.10 and + # 4.0.6; docs/knowledge/notes/execution-context.md records the condition so phase 6 and phase 9 + # meet it as a stated rule rather than as a NameError. + # + # Insert and drain sit in ONE Thread::Mutex#synchronize, so the drain body runs AT MOST ONCE per + # insert: exactly one key is added per critical section and the loop's invariant on entry is + # size <= cap, so size <= cap + 1 at the top. It is written as a loop because XCUT-14 makes that + # a MUST ("using a loop (not a single pre-insert check-then-evict)"), not because a second + # iteration is reachable under this lock discipline -- and the inverse inference is the trap: + # the loop does not make the mutex removable. See the corpus note above. The mutex is held + # across the hash write and the drain of this map's own hash and nothing else; the map never + # yields to caller code while holding it (concurrency-and-async/f414b864, /c0fab747). Phase 6's + # #update(key) { |old| new } for AUTH-19's counter increment runs its block under the mutex and + # must therefore touch only in-memory state. + # + # Hash#shift removes the oldest FIRST REGISTRATION, and Hash#[]= on an existing key does not + # move it (verified fact 8), so an overwrite does not refresh a key's position. Oldest-first is + # what CTX-13 explicitly permits; what it forbids is relying on any entry surviving, and no + # caller of this map does. + class BoundedMap + # @param cap [Integer] the hard bound, a positive Integer + def initialize(cap:) + Model.required!("cap", cap) + unless cap.is_a?(::Integer) && cap.positive? + raise InvalidArgumentError, "cap must be a positive Integer" + end + + @cap = cap + @h = {} #: Hash[untyped, untyped] + @mutex = ::Thread::Mutex.new + end + + # Unconditional overwrite: install-or-replace. Never raises. + # + # @param key [Object] the slot + # @param value [Object] its new occupant + # @return [Object] `value` + def set(key, value) + @mutex.synchronize do + @h[key] = value + drain + end + value + end + + # Reject-on-duplicate insert: install only if absent. Returns whether THIS call installed the + # value; the caller decides what a false return means. Never raises -- the conflict is a + # return value, not an exception, so ContextStore can raise its own error naming the key + # AFTER this mutex is released (concurrency-and-async/f261a143). + # + # @param key [Object] the slot + # @param value [Object] the occupant to install if the slot is empty + # @return [Boolean] whether the value was installed + def put(key, value) + @mutex.synchronize do + next false if @h.key?(key) + + @h[key] = value + drain + true + end + end + + # @param key [Object] the slot + # @return [Object, nil] the occupant, or nil for an empty or evicted slot + def [](key) + @mutex.synchronize { @h[key] } + end + + # Identity-conditional delete: removes the slot only when its current occupant is the object + # handed in (`equal?`), never by value equality -- a structurally identical live sibling must + # survive a stale context's close (CTX-9). Named for its mechanism, not for CTX-9: phase 6's + # AUTH-19 counter store never calls it, and a general map should not carry a context-shaped + # name. Absence is a no-op, not an error. + # + # @param key [Object] the slot + # @param object [Object] the occupant that must be there for the slot to be cleared + # @return [Boolean] whether the slot was cleared + def delete_if_identical(key, object) + @mutex.synchronize do + next false unless @h.key?(key) && @h[key].equal?(object) + + @h.delete(key) + true + end + end + + # @return [Integer] the number of live entries, at most the cap once inserts quiesce + def size + @mutex.synchronize { @h.size } + end + + private + + # Called under the mutex, after a write. XCUT-14's loop; see the class comment for why it + # runs at most once here. + def drain + @h.shift while @h.size > @cap + end + end + private_constant :BoundedMap +end diff --git a/gems/dexpace-core/lib/dexpace/context.rb b/gems/dexpace-core/lib/dexpace/context.rb new file mode 100644 index 0000000..dc5164c --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/context.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "model" + +module Dexpace + # The module DispatchContext, RequestContext and ExchangeContext all include -- design §5.4's + # "three distinct Data classes sharing a module", read as a module they include and not as a + # namespace containing them (P4-1): Dexpace::Context::Request would shadow phase 1's + # Dexpace::Request for every file inside `module Dexpace; module Context`, which is the hazard + # Dexpace/QualifiedCoreConstant exists for and cannot express a fix for. + # + # CTX-1's terminality -- "the exchange stage is terminal, no method promoting back" -- is + # enforced by ExchangeContext defining no #promote_* method, not by a guard clause here. + # + # #inspect is deliberately NOT overridden, and the consequence is stated rather than + # discovered: `store` is a Data member, so Data's generated #inspect walks it into the map and + # prints one level of every OTHER occupant, each with its own request and response. Ruby's + # recursion guard only elides the second visit to the store itself, so at + # ContextStore::MAX_TRACKED_CONTEXTS any `p ctx`, any assert_equal failure message and any + # string interpolating a context becomes a dump of every in-flight call. Phase 1 shipped no + # #inspect override on Request or Response either, and a redaction-aware rendering is + # OBS-11..OBS-19's and XCUT-19's, which are phase 5's. What 4a owes instead is that no assertion + # in its own suite compares whole contexts where a member comparison would do. + module Context + include Dexpace::Model + + # CTX-9 (identity-conditional eviction), CTX-10 (a promoted intermediate's close is a no-op) + # and CTX-18 (double-close is a well-defined no-op) in one line: every clause of all three is a + # property of ContextStore#release, which this delegates to without a latch (P4-4 -- a frozen + # Data instance cannot carry one; verified on 3.2.11, 3.4.10 and 4.0.6 that a method on a + # frozen Data subclass writing an ivar raises FrozenError). Idempotent through the store: the + # second close finds a different occupant or none. + # + # @return [Boolean] whether this context was the slot's occupant and the slot was cleared + def close + store.release(self) + end + + private + + # The construction validation every flavour's #initialize runs before its own fields and + # before `super`: the two members every flavour carries (SEAM-29's one message form for an + # absent one), and the store, checked for the two methods a context calls on it rather than + # for its class -- the narrowest duck type (api-design/88e6bf12), which is what keeps the + # suite's fake cheap. Private, so it is no part of the NFR-4-locked surface: the design gives + # Context exactly one public method, #close. + def validate_context!(bundle:, call_key:, store:) + Model.required!("bundle", bundle) + Model.required!("call_key", call_key) + raise InvalidArgumentError, "call_key must not be empty" if call_key.empty? + return if store.respond_to?(:set) && store.respond_to?(:release) + + raise InvalidArgumentError, "store must respond to #set and #release" + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/context/call_key.rb b/gems/dexpace-core/lib/dexpace/context/call_key.rb new file mode 100644 index 0000000..daac390 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/context/call_key.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../model" + +module Dexpace + # CTX-4's default key generator: a process-wide, monotonically increasing counter appended to a + # 'traceId:spanId' rendering, so a key is debuggable by eye (design §5.4 rejects a bare UUID for + # losing exactly that) and call-unique whatever the prefix is. With Bundle::NONE -- the shared + # singleton every untraced call carries -- the prefix is constant across the whole process, + # which is the condition CTX-15's last clause names, and the suffix is what keeps the key unique + # anyway. + # + # Not public API (P4-3): CTX-4 says outright that "the exact format and the counter mechanism + # are a reference choice, and a port MAY key differently", and NFR-4 locks every public name at + # the first release tag. The public surface is #call_key on a context, a frozen String. A + # private_constant on Dexpace is reachable by a bare name from any `module Dexpace; ...` body + # and from nowhere else (bounded_map.rb, and docs/knowledge/notes/execution-context.md). + # + # One Thread::Mutex, one Integer, incremented under the lock and read nowhere else. A counter on + # a ContextStore instance would mint colliding keys the moment a second store exists, and CTX-6 + # requires distinctness "across the whole process and across all three context flavors", not + # across a store. The mutex is held across the increment and nothing else, and it stays although + # CRuby's GVL would hide its absence: 16 threads x 5000 increments lose no update with OR + # without it on CRuby (design, verified fact 9), which is precisely how an unguarded counter + # ships and then fails on JRuby or TruffleRuby. Ruby's Integer never overflows, so there is no + # wrap handling (verified fact 10). The key is frozen with #freeze, never String#-@: a + # per-call-unique key deduplicates against nothing, so interning pays for an index entry it + # never reuses (verified fact 11), and a frozen String is stored as a Hash key by identity + # where an unfrozen one is copied on every registration (verified fact 12). + module CallKey + @mutex = ::Thread::Mutex.new + @counter = 0 + + # The bundle is checked here as well as in the flavour's #initialize, because a .build mints + # before it constructs and an absent bundle must fail with SEAM-29's message, not with a + # NoMethodError off nil. + # + # @param bundle [Instrumentation::Bundle] the bundle whose two identifiers render the prefix + # @return [String] a frozen, process-unique key of the form "traceId:spanId:n" + def self.mint(bundle) + Model.required!("bundle", bundle) + n = @mutex.synchronize { @counter += 1 } + "#{bundle.trace_id}:#{bundle.span_id}:#{n}".freeze + end + end + private_constant :CallKey +end diff --git a/gems/dexpace-core/lib/dexpace/context/dispatch_context.rb b/gems/dexpace-core/lib/dexpace/context/dispatch_context.rb new file mode 100644 index 0000000..d702138 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/context/dispatch_context.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../context" +require_relative "../context_store" +require_relative "call_key" +require_relative "request_context" + +module Dexpace + # The chain's head, before any request exists: CTX-1, CTX-2, CTX-5, CTX-17. + # + # Every flavour carries `store` as a member so #close needs no argument and no ambient lookup, + # and CTX-9's identity eviction has an unambiguous target. `store` falls through to identity in + # the generated ==, eql? and hash (ContextStore defines neither), which is what two contexts + # sharing one process-wide store want; a pinned call_key therefore restores value equality only + # between contexts built against the same store object. The one Data-generated method it makes + # expensive is #inspect, stated at Dexpace::Context. + # + # `bundle:` is required and never defaulted to Bundle::NONE: CTX-15 requires the no-op bundle to + # be available as the default, which Bundle::NONE is, not that a context silently acquire it -- + # defaulting it would make an untraced chain and a chain whose tracing was dropped by accident + # indistinguishable at the construction site as well as downstream. + class DispatchContext < Data.define(:bundle, :call_key, :store) + include Dexpace::Context + + private_class_method :new + + # CTX-5's off-chain construction, with its explicit-key affordance and its minting default: + # `call_key:` pins a shared key when a caller needs value equality across contexts (and is + # frozen without aliasing the caller's String), and absent it, CallKey mints a call-unique + # one. Construction registers NOTHING (CTX-17): the first store entry a chain ever has is + # installed by the first promotion, below. + # + # @param bundle [Instrumentation::Bundle] the correlation bundle (CTX-14) + # @param call_key [String, nil] an explicit key, or nil to mint one + # @param store [ContextStore] the store the chain registers in; the process-wide one by + # default, which construction only reads + # @return [DispatchContext] + def self.build(bundle:, call_key: nil, store: ContextStore.default) + key = call_key.nil? ? CallKey.mint(bundle) : Model.frozen_string(call_key) + new(bundle: bundle, call_key: key, store: store) + end + + # The shared validation, then Data's own construction. + def initialize(bundle:, call_key:, store:) + validate_context!(bundle: bundle, call_key: call_key, store: store) + + super + end + + # -> RequestContext. Carries forward the SAME bundle object, the SAME call_key and the SAME + # store (CTX-2, CTX-3), adds exactly the one new artefact, introduces operation_name as an + # argument exactly as CTX-2 says ("the dispatch context has no operationName field, so it is + # not 'carried forward'"), and registers the successor with store.set before returning it -- + # the first store entry the chain ever has (CTX-17). The source is untouched. + # + # @param request [Request] the outgoing request that has been assembled + # @param operation_name [String, nil] CTX-16's advisory operation id, or nil + # @return [RequestContext] the successor, already registered + def promote_to_request(request:, operation_name: nil) + ctx = RequestContext.build( + bundle: bundle, call_key: call_key, store: store, + request: request, operation_name: operation_name, + ) + store.set(ctx) + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/context/exchange_context.rb b/gems/dexpace-core/lib/dexpace/context/exchange_context.rb new file mode 100644 index 0000000..f8f8ac5 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/context/exchange_context.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../context" +require_relative "../context_store" +require_relative "call_key" + +module Dexpace + # The chain's terminus, a response arrived: CTX-1, CTX-2. No #promote_* method anywhere in + # this class -- CTX-1's terminality is the absence, which is the property design §5.4 chose + # three classes to get. Once registered it is what CTX-19 keeps reachable: the Request and the + # Response, whose body can pin a connection, stay in the strong map until #close evicts them or + # the cap does. + class ExchangeContext < Data.define( + :bundle, :call_key, :store, :request, :operation_name, :response, + ) + include Dexpace::Context + + private_class_method :new + + # Off-chain construction (CTX-5), registering nothing (CTX-17). + # + # @param bundle [Instrumentation::Bundle] the correlation bundle (CTX-14) + # @param request [Request] the request that was sent + # @param response [Response] the response that arrived + # @param operation_name [String, nil] CTX-16's advisory operation id, or nil + # @param call_key [String, nil] an explicit key, or nil to mint one + # @param store [ContextStore] the store the chain registers in + # @return [ExchangeContext] + def self.build(bundle:, request:, response:, operation_name: nil, call_key: nil, + store: ContextStore.default) + key = call_key.nil? ? CallKey.mint(bundle) : Model.frozen_string(call_key) + new( + bundle: bundle, call_key: key, store: store, request: request, + operation_name: operation_name.nil? ? nil : Model.frozen_string(operation_name), + response: response, + ) + end + + # The shared validation, the two artefacts, and CTX-16's two-state rule. + def initialize(bundle:, call_key:, store:, request:, operation_name:, response:) + validate_context!(bundle: bundle, call_key: call_key, store: store) + Model.required!("request", request) + Model.required!("response", response) + if !operation_name.nil? && operation_name.empty? + raise InvalidArgumentError, "operation_name must not be empty" + end + + super + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/context/request_context.rb b/gems/dexpace-core/lib/dexpace/context/request_context.rb new file mode 100644 index 0000000..190ddf9 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/context/request_context.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../context" +require_relative "../context_store" +require_relative "call_key" +require_relative "exchange_context" + +module Dexpace + # The chain's middle, an outgoing request assembled: CTX-2, CTX-16. + # + # operation_name is nil or a non-empty frozen String -- CTX-16 gives exactly two states, "a + # schema-defined operation id such as 'GetUser', or absent", and "" is neither. It is advisory + # only: it reaches no request, no dispatch decision and, decisively, not the store key, which + # CallKey.mint derives from the bundle and the counter alone. It is the chain half of SEAM-28's + # postponed operation identifier; phase 5c, Task 4 is the consumer. + class RequestContext < Data.define(:bundle, :call_key, :store, :request, :operation_name) + include Dexpace::Context + + private_class_method :new + + # Off-chain construction (CTX-5), registering nothing (CTX-17). + # + # @param bundle [Instrumentation::Bundle] the correlation bundle (CTX-14) + # @param request [Request] the assembled request + # @param operation_name [String, nil] CTX-16's advisory operation id, or nil + # @param call_key [String, nil] an explicit key, or nil to mint one + # @param store [ContextStore] the store the chain registers in + # @return [RequestContext] + def self.build(bundle:, request:, operation_name: nil, call_key: nil, + store: ContextStore.default) + key = call_key.nil? ? CallKey.mint(bundle) : Model.frozen_string(call_key) + new( + bundle: bundle, call_key: key, store: store, request: request, + operation_name: operation_name.nil? ? nil : Model.frozen_string(operation_name), + ) + end + + # The shared validation, the request, and CTX-16's two-state rule. + def initialize(bundle:, call_key:, store:, request:, operation_name:) + validate_context!(bundle: bundle, call_key: call_key, store: store) + Model.required!("request", request) + if !operation_name.nil? && operation_name.empty? + raise InvalidArgumentError, "operation_name must not be empty" + end + + super + end + + # -> ExchangeContext. Carries bundle, call_key, store, request and operation_name forward + # unchanged (CTX-2's "additionally the same request and operationName"), adds the response, + # and calls store.set, which overwrites the same slot (CTX-3). The source is untouched. + # + # @param response [Response] the response that has arrived + # @return [ExchangeContext] the successor, now the slot's occupant + def promote_to_exchange(response:) + ctx = ExchangeContext.build( + bundle: bundle, call_key: call_key, store: store, + request: request, operation_name: operation_name, response: response, + ) + store.set(ctx) + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/context_store.rb b/gems/dexpace-core/lib/dexpace/context_store.rb new file mode 100644 index 0000000..76cf622 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/context_store.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "bounded_map" +require_relative "error/context_conflict_error" + +module Dexpace + # The process-wide registry of in-flight execution contexts, keyed by call key: CTX-7 through + # CTX-13, CTX-18 and CTX-19. + # + # HAS a BoundedMap rather than BEING one, so CTX-8's and CTX-18's two-operation surface is what + # a caller sees and the map's general operations are not part of it. #set and #put take a + # CONTEXT, not a key/value pair, and read #call_key off it: that is the narrowest duck type the + # operation actually uses (api-design/88e6bf12), it makes CTX-3's "all three flavors register + # under the identical store slot" unforgeable at the call site, and it is what lets the suite + # drive every store rule with a two-member fake. + # + # Nothing in core reads a context back out of this store: promotion writes and returns, #close + # conditionally removes and tolerates absence. #[] exists for CTX-18 and for the suite, and + # core calls it nowhere -- which is how CTX-13's "MUST NOT rely on any specific entry + # surviving" is obeyed structurally rather than by care. CTX-19's reachability holds because the + # map is a strong Hash; Dexpace/NoWeakReferences is what keeps it one, and neither the cop nor + # the suite can reach a third-party reimplementation, which CTX-19 addresses and these gates + # cannot. + class ContextStore + # The bound the process-wide store is built with. CTX-11 and XCUT-14 name no number; AUTH-19 + # names 1024 as the default for a bounded store of exactly this shape, and it is the only + # number the specification supplies for one (P4-9). Phase 5a, Task 13 attaches a + # configuration source without changing a signature. + MAX_TRACKED_CONTEXTS = 1024 + + class << self + # The one process-wide instance, assigned at file load into a class-level ivar (the last + # line of this class body). Not a constant: a live store cannot be frozen at assignment, + # which a mutable constant must be. Assigned eagerly rather than memoised with `||=`: that + # is an unsynchronised read-modify-write over shared state (XCUT-11) with allocations + # inside `new`, so two threads reaching #default first could each publish a store, and + # CTX-11's cap and CTX-19's reachability would then hold per store rather than per process. + # Eager assignment is also what keeps CTX-17 inert for a .build that takes this default: + # construction reads the singleton and writes nothing to it. + # + # @return [ContextStore] the process-wide store + attr_reader :default + end + + # @param cap [Integer] the hard bound, a positive Integer; MAX_TRACKED_CONTEXTS unless a + # caller -- a test, or phase 5's configuration -- says otherwise + def initialize(cap: MAX_TRACKED_CONTEXTS) + @map = BoundedMap.new(cap: cap) + end + + # CTX-8's unconditional overwrite: install-or-replace, never raising. Used by both promotions + # and by nothing else in core. + # + # @param context [Context] the occupant, registered under its own #call_key + # @return [Context] `context` + def set(context) + @map.set(context.call_key, context) + context + end + + # CTX-8's reject-on-duplicate insert: install only if absent, failing the loser with an error + # whose message identifies the key. The conflict is detected under the map's mutex and the + # error is raised after it is released. A separate strict-register affordance, as CTX-8 says; + # no promotion uses it. + # + # @param context [Context] the occupant, registered under its own #call_key + # @return [Context] `context`, when this call installed it + # @raise [ContextConflictError] when the slot was already occupied + def put(context) + return context if @map.put(context.call_key, context) + + raise ContextConflictError, context.call_key + end + + # CTX-18's explicit absent result. nil is legitimate here, and is the documented case + # api-design/6ea28c9c reserves: the requirement demands an absent result and forbids raising. + # + # @param call_key [String] the slot + # @return [Context, nil] the current occupant, or nil for an unknown or evicted key + def [](call_key) + @map[call_key] + end + + # CTX-9's identity-conditional eviction, CTX-10's intermediate no-op and CTX-18's unknown-key + # no-op: the slot is cleared only when its current occupant IS `context` (`equal?`), never by + # value equality. Returns whether a slot was cleared, which is how CTX-10 is observable. + # + # @param context [Context] the closing context + # @return [Boolean] whether it was the occupant and the slot was cleared + def release(context) + @map.delete_if_identical(context.call_key, context) + end + + # The only aggregate read; there is no iteration, deliberately (CTX-13). + # + # @return [Integer] the number of registered contexts + def size + @map.size + end + + # Assigned at file load, once, before any thread exists. See .default above. + @default = new + end +end diff --git a/gems/dexpace-core/lib/dexpace/error/context_conflict_error.rb b/gems/dexpace-core/lib/dexpace/error/context_conflict_error.rb new file mode 100644 index 0000000..fdc1838 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/error/context_conflict_error.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../error" + +module Dexpace + # CTX-8's reject-on-duplicate loser: a ContextStore#put lost the race for a call key another + # registration already occupies. + # + # Not Dexpace::InvalidArgumentError, deliberately. The caller passed nothing invalid -- it lost a + # race -- and a caller that cannot tell those two apart cannot retry correctly. The message + # names the key because CTX-8's own words are "an error whose message identifies the key", and + # the key is carried as a member too, so a caller reads it rather than parsing the message. The + # fourth error in phase 2's shape: `< ::StandardError` with the Dexpace::Error marker included. + class ContextConflictError < ::StandardError + include Dexpace::Error + + # @return [String] the call key the losing registration tried to install under + attr_reader :call_key + + # The one-argument shape is what `raise Dexpace::ContextConflictError, key` needs: Ruby hands + # the second argument of `raise` to the class's constructor, so the key travels as itself. + def initialize(call_key) + @call_key = call_key + super("a context is already registered under call key #{call_key.inspect} (CTX-8)") + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/instrumentation/bundle.rb b/gems/dexpace-core/lib/dexpace/instrumentation/bundle.rb new file mode 100644 index 0000000..f3331ce --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/instrumentation/bundle.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../model" +require_relative "trace_id_flavour" +require_relative "no_span" +require_relative "no_tracer" + +module Dexpace + module Instrumentation + # CTX-14's correlation/instrumentation bundle, the one design §8.1 names and roadmap + # cross-phase obligation 1 fixes in phase 4: what lets logs, metrics and spans across the + # dispatch, request and exchange stages correlate into one logical trace. + # + # Nine things CTX-14 says the bundle must EXPOSE; eight are stored members and the ninth, + # validity, is derived (P4-6) -- OBS-26 makes "an all-zero trace/span id MUST be treated as + # invalid" a MUST, so validity is a function of trace_id and span_id and not an independently + # settable fact, and `Bundle.build(trace_id: , span_id: , valid: false)` is not + # representable. The members and their names are NFR-4-locked at the first release tag, and + # the handshake with phase 5 is the design's R3: phase 5 populates through .build or + # NONE.with (both validate), may add METHODS here and to TraceIdFlavour, may give the two + # no-op classes their protocols, and may not rename or remove a member, add one, store + # validity, change the sentinels, replace the flavour with a Symbol or hand this class a + # second NONE. + # + # trace_state is a list of pairs, not a Hash: OBS-26 says "a vendor trace-state list" and W3C + # tracestate is ordered, most recent vendor first, which a Hash would lose. trace_flags stays + # the two-hex-char wire form OBS-26 fixes; a #sampled? predicate is deliberately not shipped + # (CTX-14 does not ask for it, and phase 5 may add it, since adding a method widens). + class Bundle < Data.define( + :trace_id, :span_id, :trace_flags, :trace_state, :flavour, :remote, :span, :tracer_factory, + ) + include Dexpace::Model + + private_class_method :new + + # OBS-26's reserved span-id sentinel: 16 hex zeros. Flavour-independent -- OBS-26 states + # the span-id rule unqualified while OBS-27's flavours scope only the trace id, whose + # sentinel therefore lives on TraceIdFlavour (P4-7). + INVALID_SPAN_ID = ("0" * 16).freeze + + SPAN_ID_PATTERN = Regexp.new("\\A[0-9a-f]{16}\\z", timeout: 1.0) + private_constant :SPAN_ID_PATTERN + + TRACE_FLAGS_PATTERN = Regexp.new("\\A[0-9a-f]{2}\\z", timeout: 1.0) + private_constant :TRACE_FLAGS_PATTERN + + # The validating factory. trace_id:, span_id: and flavour: are required deliberately -- + # defaulting them would make "I forgot to pass a trace id" produce a silently untraced + # bundle, invisible for exactly the reason CTX-15 names: every untraced call's identifiers + # are identical, so nothing downstream can tell an accident from a policy. A caller who + # wants the untraced value names Bundle::NONE. + # + # @param trace_id [String] the trace id, in the flavour's rendering or its sentinel + # @param span_id [String] 16 lowercase hex chars, or INVALID_SPAN_ID + # @param flavour [TraceIdFlavour] the trace-id encoding flavour + # @param trace_flags [String] the two-hex-char W3C flags byte; "00" is unsampled + # @param trace_state [Array] the vendor trace-state list, in order + # @param remote [Boolean] whether the trace context arrived from another service + # @param span [Object] the active span; NO_SPAN when tracing is off + # @param tracer_factory [#tracer] the per-operation tracer factory; NO_TRACER_FACTORY when + # tracing is off + # @return [Bundle] + def self.build(trace_id:, span_id:, flavour:, trace_flags: "00", trace_state: [], + remote: false, span: NO_SPAN, tracer_factory: NO_TRACER_FACTORY) + new( + trace_id: trace_id, span_id: span_id, trace_flags: trace_flags, + trace_state: trace_state, flavour: flavour, remote: remote, + span: span, tracer_factory: tracer_factory, + ) + end + + # Every clause OBS-26 makes a MUST is checked here and nowhere else: `#valid?` only + # compares against the sentinels and would answer true for a malformed span id. Each + # failure names its field (SEAM-29). The checks are split into the two identifiers and + # the two wire fields so the constructor reads as one line per member. + def initialize(trace_id:, span_id:, trace_flags:, trace_state:, flavour:, remote:, span:, + tracer_factory:) + Model.required!("span", span) + Model.required!("tracer_factory", tracer_factory) + validate_identifiers!(flavour, trace_id, span_id) + validate_wire_fields!(trace_flags, trace_state) + + super( + trace_id: Model.frozen_string(trace_id), span_id: Model.frozen_string(span_id), + trace_flags: Model.frozen_string(trace_flags), trace_state: Model.own(trace_state), + flavour: flavour, remote: remote ? true : false, span: span, + tracer_factory: tracer_factory, + ) + end + + # Derived, not stored (P4-6): a function of trace_id and span_id. False for NONE and for + # any bundle carrying either reserved sentinel (OBS-26). + # + # @return [Boolean] + def valid? + flavour.valid_trace_id?(trace_id) && span_id != INVALID_SPAN_ID + end + + # Whether the trace context was propagated in from another service; false for NONE. + # + # @return [Boolean] + def remote? = remote + + # The flavour must be a TraceIdFlavour, the trace id one that flavour renders -- the + # sentinel or a value its pattern accepts -- and the span id OBS-26's 16 lowercase hex + # chars or its reserved sentinel; OBS-27 does not scope the span id, so those two are the + # accepted shapes under every flavour (P4-7). + def validate_identifiers!(flavour, trace_id, span_id) + Model.required!("trace_id", trace_id) + Model.required!("span_id", span_id) + Model.required!("flavour", flavour) + unless flavour.is_a?(TraceIdFlavour) + raise InvalidArgumentError, "flavour must be a TraceIdFlavour" + end + unless flavour.renders?(trace_id) + raise InvalidArgumentError, "trace_id does not match flavour #{flavour.name.inspect}" + end + return if valid_span_id?(span_id) + + raise InvalidArgumentError, "span_id must be 16 lowercase hex chars (OBS-26)" + end + private :validate_identifiers! + + # OBS-26's two wire fields: the flags byte as two lowercase hex chars, and the vendor + # trace-state list as pairs of Strings. + def validate_wire_fields!(trace_flags, trace_state) + Model.required!("trace_flags", trace_flags) + unless trace_flags.is_a?(::String) && TRACE_FLAGS_PATTERN.match?(trace_flags) + raise InvalidArgumentError, "trace_flags must be two lowercase hex chars (OBS-26)" + end + return if trace_state.is_a?(::Array) && trace_state.all? { |pair| string_pair?(pair) } + + raise InvalidArgumentError, "trace_state must be an array of two-element string pairs" + end + private :validate_wire_fields! + + def valid_span_id?(value) + value == INVALID_SPAN_ID || (value.is_a?(::String) && SPAN_ID_PATTERN.match?(value)) + end + private :valid_span_id? + + def string_pair?(value) + value.is_a?(::Array) && value.size == 2 && value.all?(::String) + end + private :string_pair? + + # CTX-15's disabled-tracing default: the shared frozen singleton every untraced call + # carries, with OBS-26's reserved sentinels as its values and the two no-op singletons in + # its slots. CTX-4's call-key derivation stays call-unique across it because the counter, + # not the bundle, supplies the uniqueness (CallKey.mint). Not defaulted into any context + # builder: a caller names it, so an untraced chain is a decision and not an accident. + NONE = build( + trace_id: TraceIdFlavour::NONE.invalid_trace_id, + span_id: INVALID_SPAN_ID, + flavour: TraceIdFlavour::NONE, + ) + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/instrumentation/no_span.rb b/gems/dexpace-core/lib/dexpace/instrumentation/no_span.rb new file mode 100644 index 0000000..66d8088 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/instrumentation/no_span.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +module Dexpace + module Instrumentation + # CTX-15's no-op span, the object behind NO_SPAN. Responds to nothing beyond Object's own + # surface: phase 4 fixes the slot and the identity of the object filling it; OBS-21 and + # OBS-25 fix the protocol, and that is phase 5's (5c, Tasks 3-5), which gives THIS class its + # methods rather than introducing a second no-op span -- the class is a private_constant and + # so not NFR-4-locked, while the object keeps the identity phase 4 published. + class NoSpan; end # rubocop:disable Lint/EmptyClass -- postponed protocol: phase 5c adds it. + private_constant :NoSpan + + # The one shared, frozen no-op span every untraced bundle carries (CTX-14's "an active span", + # CTX-15). One instance, so OBS-25's "MUST NOT allocate per call" is assertable by reference + # identity: `assert_same Dexpace::Instrumentation::NO_SPAN, bundle.span`. Public, where + # BoundedMap is not, because that assertion is a QUALIFIED reference and a qualified reference + # to a private constant raises even from inside Dexpace (design, verified fact 6). + NO_SPAN = NoSpan.new.freeze + end +end diff --git a/gems/dexpace-core/lib/dexpace/instrumentation/no_tracer.rb b/gems/dexpace-core/lib/dexpace/instrumentation/no_tracer.rb new file mode 100644 index 0000000..a872910 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/instrumentation/no_tracer.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +module Dexpace + module Instrumentation + # CTX-20's no-op tracer, the object behind NO_TRACER, returned by NO_TRACER_FACTORY#tracer. + # Responds to nothing beyond Object's own surface for the reason NoSpan does: the tracer + # protocol is OBS-21..OBS-25, phase 5c's, and it lands as methods on this private class. + class NoTracer; end # rubocop:disable Lint/EmptyClass -- postponed protocol: phase 5c adds it. + private_constant :NoTracer + + # The one shared, frozen no-op tracer. Public rather than private for the same reason + # NO_SPAN is: OBS-25's allocation clause is asserted as + # `assert_same Dexpace::Instrumentation::NO_TRACER, factory.tracer`, a qualified reference. + NO_TRACER = NoTracer.new.freeze + + # CTX-20's no-op tracer factory, the class behind NO_TRACER_FACTORY. Not empty: CTX-20's + # embedded MUST ("Its factory method MUST be safe to invoke concurrently from multiple + # threads") forbids that -- a factory with no factory method cannot satisfy a MUST about that + # method. It holds no state, so the concurrency safety is structural. + # + # #tracer's name and its parameter list mirror opentelemetry-api's own + # OpenTelemetry::Trace::TracerProvider#tracer (opentelemetry-api 1.11.0, + # lib/opentelemetry/trace/tracer_provider.rb, fetched from rubygems.org and read 2026-09-08 + # by the plan and again 2026-09-16 at implementation) rather than this repository's own + # keywords-everywhere rule (api-design/1d9e6e0b), because being call-compatible with a + # foreign TracerProvider is this method's entire purpose (P4-8): an application already + # running OpenTelemetry must be able to pass OpenTelemetry.tracer_provider straight into + # Bundle.build(tracer_factory:). The gem keeps the two legacy positional parameters for + # callers written against its older two-argument form while the keyword arguments take + # precedence on the same slot; all five are ignored here. Named parameters, never a `**` + # splat: Dexpace/NoKeywordSplat, because a splat allocates a Hash per call and OBS-25's no-op + # path may allocate nothing. + class NoTracerFactory + # The one method CTX-20 forces into existence. Every argument is part of the mirrored + # signature and deliberately unused; see the class comment and P4-8. + # + # @param deprecated_name [String, nil] the gem's legacy positional instrumentation name + # @param deprecated_version [String, nil] the gem's legacy positional version + # @param name [String, nil] the instrumentation-library name + # @param version [String, nil] the instrumentation-library version + # @param attributes [Hash, nil] instrumentation-scope attributes + # @return [Object] NO_TRACER, always, so selecting the no-op path allocates nothing + # rubocop:disable-next Lint/UnusedMethodArgument + def tracer(deprecated_name = nil, deprecated_version = nil, name: nil, version: nil, + attributes: nil) + NO_TRACER + end + end + private_constant :NoTracerFactory + + # The one shared, frozen no-op tracer factory (CTX-14's "a per-operation tracer factory", + # CTX-15, CTX-20). Its #tracer returns NO_TRACER from every thread, which is OBS-25's + # allocation clause and CTX-20's thread-safety clause in one assertion. + NO_TRACER_FACTORY = NoTracerFactory.new.freeze + end +end diff --git a/gems/dexpace-core/lib/dexpace/instrumentation/trace_id_flavour.rb b/gems/dexpace-core/lib/dexpace/instrumentation/trace_id_flavour.rb new file mode 100644 index 0000000..ecc4828 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/instrumentation/trace_id_flavour.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../model" + +module Dexpace + # The instrumentation subsystem: the correlation bundle every execution context carries and the + # values it is built from. Namespaced because design §8.1 names Dexpace::Instrumentation::Bundle + # itself, and a subsystem the design names with a namespace keeps it (P1-1). + module Instrumentation + # OBS-27's trace-id encoding flavours, and the value CTX-14's "a trace-id encoding flavor" + # slot holds: a frozen Data over a frozen table with an .of factory (type-system/545949a5), + # never a case statement or a bare Symbol -- the per-flavour behaviour, a pattern, a sentinel + # and a range, is data, not code. + # + # The flavour governs the TRACE id only. CTX-14's words are "a trace-id encoding flavor", + # OBS-27's scope is "trace-id generation", and OBS-26 states the span-id rule unqualified, so + # a Datadog-flavoured bundle carries a decimal trace id and a hex span id. That reads oddly + # and it is what the two requirements say together (Bundle::INVALID_SPAN_ID is the one + # span-id sentinel; P4-7). + class TraceIdFlavour < Data.define(:name, :trace_id_pattern, :invalid_trace_id, :max_value) + include Dexpace::Model + + private_class_method :new + + # The validating factory. `max_value` is nil where the pattern is the whole rule. + # + # @param name [Symbol] the flavour's name, what .of resolves + # @param trace_id_pattern [Regexp] the shape a renderable trace id has, anchored + # @param invalid_trace_id [String] this flavour's reserved invalid sentinel + # @param max_value [Integer, nil] the largest integer a rendering may denote + # @return [TraceIdFlavour] + def self.build(name:, trace_id_pattern:, invalid_trace_id:, max_value: nil) + new( + name: name, trace_id_pattern: trace_id_pattern, invalid_trace_id: invalid_trace_id, + max_value: max_value, + ) + end + + # Every member but the range is required (SEAM-29's one message form). + def initialize(name:, trace_id_pattern:, invalid_trace_id:, max_value:) + Model.required!("name", name) + Model.required!("trace_id_pattern", trace_id_pattern) + Model.required!("invalid_trace_id", invalid_trace_id) + + super( + name: name, trace_id_pattern: trace_id_pattern, + invalid_trace_id: Model.frozen_string(invalid_trace_id), max_value: max_value, + ) + end + + # A trace id that identifies a real trace under this flavour: renderable and not the + # sentinel (OBS-26's "an all-zero trace/span id MUST be treated as invalid/no-trace"). + # + # @param trace_id [String] + # @return [Boolean] + def valid_trace_id?(trace_id) + trace_id != invalid_trace_id && renderable?(trace_id) + end + + # A trace id a bundle of this flavour may carry at all: the sentinel, or a renderable + # value. This is what Bundle's own validation asks; the two predicates disagree at exactly + # one input, the sentinel, which is why both exist. + # + # @param trace_id [String] + # @return [Boolean] + def renders?(trace_id) + trace_id == invalid_trace_id || renderable?(trace_id) + end + + # The pattern fixes the SHAPE and max_value fixes the RANGE, because a digit count cannot + # express one: OBS-27's Datadog flavour is "a 64-bit unsigned integer rendered as a decimal + # string", and \A[0-9]{1,20}\z also accepts 99999999999999999999, which is larger than + # 2**64 - 1. The bound is a member rather than a branch so the table stays the whole of the + # per-flavour behaviour; nil means the pattern is the whole rule (both hex flavours). + def renderable?(trace_id) + return false unless trace_id.is_a?(::String) && trace_id_pattern.match?(trace_id) + + bound = max_value + bound.nil? || trace_id.to_i <= bound + end + private :renderable? + + # OBS-27's no-op flavour: "always yields the invalid sentinel". The pattern matches nothing, + # so only OBS-26's 32 hex zeros renders -- a disabled-tracing bundle carries no trace id of + # its own. The sentinel is OBS-26's exact value and not a flavour-specific zero draw, so + # Bundle::NONE carries the pair CTX-15 names verbatim (P4-7). + NONE = build( + name: :none, + trace_id_pattern: Regexp.new("\\A(?!)\\z", timeout: 1.0), + invalid_trace_id: "0" * 32, + ) + + # OBS-27's W3C flavour: a 128-bit value rendered as 32 lowercase hex chars. Lowercase-only, + # and an uppercase input is rejected rather than folded (OBS-26). + W3C = build( + name: :w3c, + trace_id_pattern: Regexp.new("\\A[0-9a-f]{32}\\z", timeout: 1.0), + invalid_trace_id: "0" * 32, + ) + + # OBS-27's Datadog flavour: a 64-bit unsigned integer rendered as a decimal string. Its own + # zero draw is "0" -- the one OBS-27 says "MUST be coerced to a non-zero value" -- and is + # distinct from OBS-26's 32-hex-zero sentinel (P4-7). The bound is 2**64 - 1, spelled as a + # shift because rbs types Integer#** as Numeric and strict Steep refuses it for an Integer?. + DATADOG = build( + name: :datadog, + trace_id_pattern: Regexp.new("\\A[0-9]{1,20}\\z", timeout: 1.0), + invalid_trace_id: "0", + max_value: (1 << 64) - 1, + ) + + # The closed set, in OBS-27's order. Phase 5 may add a method to the flavour; it may not + # replace the type with a Symbol (roadmap obligation 1). + ALL = [NONE, W3C, DATADOG].freeze + + # Resolves a name against the closed set. Raises on an unrecognised one -- the + # Protocol.parse behaviour, not the deliberately total Status.of one, because a flavour is + # a closed set and a status code is not. + # + # @param name [Symbol] `:none`, `:w3c` or `:datadog` + # @return [TraceIdFlavour] the shared frozen instance + # @raise [InvalidArgumentError] on any other name + def self.of(name) + ALL.find { |flavour| flavour.name == name } || + raise(InvalidArgumentError, "unrecognised trace-id flavour: #{name.inspect}") + end + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/bounded_map.rbs b/gems/dexpace-core/sig/dexpace/bounded_map.rbs new file mode 100644 index 0000000..1dee4ed --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/bounded_map.rbs @@ -0,0 +1,21 @@ +# Dexpace::BoundedMap is a private_constant and not public API (design P4-3): this declaration +# exists because the strict `core` Steep target checks every file under lib/ and needs the class +# declared to type ContextStore's call sites, exactly as hooks.rbs does for Dexpace::Hooks. RBS +# has no visibility for a constant, so the privacy lives in lib/dexpace/bounded_map.rb alone, and +# the class takes no surface-manifest row (Module#constants excludes it) and no test/ mirror. +module Dexpace + class BoundedMap + @cap: Integer + @h: Hash[untyped, untyped] + @mutex: Thread::Mutex + + def initialize: (cap: Integer) -> void + def set: (untyped key, untyped value) -> untyped + def put: (untyped key, untyped value) -> bool + def []: (untyped key) -> untyped + def delete_if_identical: (untyped key, untyped object) -> bool + def size: () -> Integer + + private def drain: () -> void + end +end diff --git a/gems/dexpace-core/sig/dexpace/context.rbs b/gems/dexpace-core/sig/dexpace/context.rbs new file mode 100644 index 0000000..5ebc4c9 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/context.rbs @@ -0,0 +1,20 @@ +module Dexpace + # What an includer of Context must be beyond a Model: a Data whose members include the store + # #close releases through and the key it is registered under. The store is `untyped` because + # #validate_context! accepts any object answering #set and #release -- the narrowest duck type, + # api-design/88e6bf12 -- and strict Steep refuses respond_to? on an interface type (phase 3a). + interface _ContextHost + include _ModelInstance + + def call_key: () -> String + def store: () -> untyped + end + + module Context : _ContextHost + include Model + + def close: () -> bool + + private def validate_context!: (bundle: untyped, call_key: String, store: untyped) -> nil + end +end diff --git a/gems/dexpace-core/sig/dexpace/context/call_key.rbs b/gems/dexpace-core/sig/dexpace/context/call_key.rbs new file mode 100644 index 0000000..e2f66c2 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/context/call_key.rbs @@ -0,0 +1,14 @@ +# Dexpace::CallKey is a private_constant and not public API (design P4-3): this declaration +# exists because the strict `core` Steep target checks every file under lib/ and needs the module +# and its one method declared to type the three .build call sites, exactly as hooks.rbs does for +# Dexpace::Hooks. RBS has no visibility for a constant, so the privacy lives in +# lib/dexpace/context/call_key.rb alone, and the module takes no surface-manifest row and no +# test/ mirror -- its behaviour is asserted at its call sites (dispatch_context_test.rb). +module Dexpace + module CallKey + self.@mutex: Thread::Mutex + self.@counter: Integer + + def self.mint: (Instrumentation::Bundle bundle) -> String + end +end diff --git a/gems/dexpace-core/sig/dexpace/context/dispatch_context.rbs b/gems/dexpace-core/sig/dexpace/context/dispatch_context.rbs new file mode 100644 index 0000000..47508a3 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/context/dispatch_context.rbs @@ -0,0 +1,22 @@ +module Dexpace + class DispatchContext < Data + include Context + + attr_reader bundle: Instrumentation::Bundle + attr_reader call_key: String + # untyped rather than ContextStore: Context.validate! accepts any object answering #set and + # #release (the narrowest duck type), and the suite's FakeStore relies on that. + attr_reader store: untyped + + private def self.new: ( + bundle: Instrumentation::Bundle, call_key: String, store: untyped, + ) -> instance + def initialize: (bundle: Instrumentation::Bundle, call_key: String, store: untyped) -> void + + def self.build: ( + bundle: Instrumentation::Bundle, ?call_key: String?, ?store: untyped, + ) -> DispatchContext + + def promote_to_request: (request: Dexpace::Request, ?operation_name: String?) -> RequestContext + end +end diff --git a/gems/dexpace-core/sig/dexpace/context/exchange_context.rbs b/gems/dexpace-core/sig/dexpace/context/exchange_context.rbs new file mode 100644 index 0000000..c905d81 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/context/exchange_context.rbs @@ -0,0 +1,26 @@ +module Dexpace + class ExchangeContext < Data + include Context + + attr_reader bundle: Instrumentation::Bundle + attr_reader call_key: String + attr_reader store: untyped + attr_reader request: Dexpace::Request + attr_reader operation_name: String? + attr_reader response: Dexpace::Response + + private def self.new: ( + bundle: Instrumentation::Bundle, call_key: String, store: untyped, + request: Dexpace::Request, operation_name: String?, response: Dexpace::Response, + ) -> instance + def initialize: ( + bundle: Instrumentation::Bundle, call_key: String, store: untyped, + request: Dexpace::Request, operation_name: String?, response: Dexpace::Response, + ) -> void + + def self.build: ( + bundle: Instrumentation::Bundle, request: Dexpace::Request, response: Dexpace::Response, + ?operation_name: String?, ?call_key: String?, ?store: untyped, + ) -> ExchangeContext + end +end diff --git a/gems/dexpace-core/sig/dexpace/context/request_context.rbs b/gems/dexpace-core/sig/dexpace/context/request_context.rbs new file mode 100644 index 0000000..73a175e --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/context/request_context.rbs @@ -0,0 +1,27 @@ +module Dexpace + class RequestContext < Data + include Context + + attr_reader bundle: Instrumentation::Bundle + attr_reader call_key: String + attr_reader store: untyped + attr_reader request: Dexpace::Request + attr_reader operation_name: String? + + private def self.new: ( + bundle: Instrumentation::Bundle, call_key: String, store: untyped, + request: Dexpace::Request, operation_name: String?, + ) -> instance + def initialize: ( + bundle: Instrumentation::Bundle, call_key: String, store: untyped, + request: Dexpace::Request, operation_name: String?, + ) -> void + + def self.build: ( + bundle: Instrumentation::Bundle, request: Dexpace::Request, + ?operation_name: String?, ?call_key: String?, ?store: untyped, + ) -> RequestContext + + def promote_to_exchange: (response: Dexpace::Response) -> ExchangeContext + end +end diff --git a/gems/dexpace-core/sig/dexpace/context_store.rbs b/gems/dexpace-core/sig/dexpace/context_store.rbs new file mode 100644 index 0000000..c741aea --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/context_store.rbs @@ -0,0 +1,17 @@ +module Dexpace + class ContextStore + MAX_TRACKED_CONTEXTS: Integer + + self.@default: ContextStore + @map: BoundedMap + + def self.default: () -> ContextStore + + def initialize: (?cap: Integer) -> void + def set: (Context context) -> Context + def put: (Context context) -> Context + def []: (String call_key) -> Context? + def release: (Context context) -> bool + def size: () -> Integer + end +end diff --git a/gems/dexpace-core/sig/dexpace/error/context_conflict_error.rbs b/gems/dexpace-core/sig/dexpace/error/context_conflict_error.rbs new file mode 100644 index 0000000..0d61dc8 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/error/context_conflict_error.rbs @@ -0,0 +1,9 @@ +module Dexpace + class ContextConflictError < ::StandardError + include Dexpace::Error + + attr_reader call_key: String + + def initialize: (String call_key) -> void + end +end diff --git a/gems/dexpace-core/sig/dexpace/instrumentation/bundle.rbs b/gems/dexpace-core/sig/dexpace/instrumentation/bundle.rbs new file mode 100644 index 0000000..e60ebbc --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/instrumentation/bundle.rbs @@ -0,0 +1,47 @@ +module Dexpace + module Instrumentation + class Bundle < Data + include Model + + INVALID_SPAN_ID: String + SPAN_ID_PATTERN: Regexp + TRACE_FLAGS_PATTERN: Regexp + + NONE: Bundle + + attr_reader trace_id: String + attr_reader span_id: String + attr_reader trace_flags: String + attr_reader trace_state: Array[[String, String]] + attr_reader flavour: TraceIdFlavour + attr_reader remote: bool + attr_reader span: _Span + attr_reader tracer_factory: _TracerFactory + + private def self.new: ( + trace_id: String, span_id: String, trace_flags: String, + trace_state: Array[[String, String]], flavour: TraceIdFlavour, remote: bool, + span: _Span, tracer_factory: _TracerFactory, + ) -> instance + def initialize: ( + trace_id: String, span_id: String, trace_flags: String, + trace_state: Array[[String, String]], flavour: TraceIdFlavour, remote: bool, + span: _Span, tracer_factory: _TracerFactory, + ) -> void + + def self.build: ( + trace_id: String, span_id: String, flavour: TraceIdFlavour, + ?trace_flags: String, ?trace_state: Array[[String, String]], ?remote: bool, + ?span: _Span, ?tracer_factory: _TracerFactory, + ) -> Bundle + + def valid?: () -> bool + def remote?: () -> bool + + private def validate_identifiers!: (TraceIdFlavour flavour, String trace_id, String span_id) -> void + private def validate_wire_fields!: (String trace_flags, Array[[String, String]] trace_state) -> void + private def valid_span_id?: (String value) -> bool + private def string_pair?: (untyped value) -> bool + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/instrumentation/no_span.rbs b/gems/dexpace-core/sig/dexpace/instrumentation/no_span.rbs new file mode 100644 index 0000000..feb9f6f --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/instrumentation/no_span.rbs @@ -0,0 +1,15 @@ +module Dexpace + module Instrumentation + # Empty on purpose: phase 4 fixes the slot, phase 5c fixes the protocol, and an empty + # interface says so in the type system rather than in a comment. + interface _Span + end + + # A private_constant with no visibility in RBS; declared because the strict `core` Steep + # target types NO_SPAN's construction. The privacy lives in lib/dexpace/instrumentation/no_span.rb. + class NoSpan + end + + NO_SPAN: _Span + end +end diff --git a/gems/dexpace-core/sig/dexpace/instrumentation/no_tracer.rbs b/gems/dexpace-core/sig/dexpace/instrumentation/no_tracer.rbs new file mode 100644 index 0000000..1490a33 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/instrumentation/no_tracer.rbs @@ -0,0 +1,31 @@ +module Dexpace + module Instrumentation + # Empty on purpose, like _Span: phase 5c fixes the tracer protocol. + interface _Tracer + end + + # The one method CTX-20 forces, in opentelemetry-api 1.11.0's own shape (P4-8). + interface _TracerFactory + def tracer: ( + ?String? deprecated_name, ?String? deprecated_version, + ?name: String?, ?version: String?, ?attributes: Hash[String, untyped]?, + ) -> _Tracer + end + + # Two private_constants with no visibility in RBS; declared because the strict `core` Steep + # target types the two singletons' construction. The privacy lives in + # lib/dexpace/instrumentation/no_tracer.rb. + class NoTracer + end + + class NoTracerFactory + def tracer: ( + ?String? deprecated_name, ?String? deprecated_version, + ?name: String?, ?version: String?, ?attributes: Hash[String, untyped]?, + ) -> _Tracer + end + + NO_TRACER: _Tracer + NO_TRACER_FACTORY: _TracerFactory + end +end diff --git a/gems/dexpace-core/sig/dexpace/instrumentation/trace_id_flavour.rbs b/gems/dexpace-core/sig/dexpace/instrumentation/trace_id_flavour.rbs new file mode 100644 index 0000000..7da12fd --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/instrumentation/trace_id_flavour.rbs @@ -0,0 +1,33 @@ +module Dexpace + module Instrumentation + class TraceIdFlavour < Data + include Model + + NONE: TraceIdFlavour + W3C: TraceIdFlavour + DATADOG: TraceIdFlavour + ALL: Array[TraceIdFlavour] + + attr_reader name: Symbol + attr_reader trace_id_pattern: Regexp + attr_reader invalid_trace_id: String + attr_reader max_value: Integer? + + private def self.new: ( + name: Symbol, trace_id_pattern: Regexp, invalid_trace_id: String, max_value: Integer?, + ) -> instance + def initialize: ( + name: Symbol, trace_id_pattern: Regexp, invalid_trace_id: String, max_value: Integer?, + ) -> void + + def self.build: ( + name: Symbol, trace_id_pattern: Regexp, invalid_trace_id: String, ?max_value: Integer?, + ) -> TraceIdFlavour + def self.of: (Symbol name) -> TraceIdFlavour + + def valid_trace_id?: (String trace_id) -> bool + def renders?: (String trace_id) -> bool + private def renderable?: (String trace_id) -> bool + end + end +end From c5b8d1774f11dee32bfde09e75c9ba94e832b537 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 16 Sep 2026 16:50:15 +0300 Subject: [PATCH 2/8] feat: the eighth cop, the regenerated manifest, the smoke pins 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. --- .rubocop.yml | 9 +++ .rubocop/cops/dexpace/no_weak_references.rb | 74 +++++++++++++++++++++ .rubocop/test/cops_test.rb | 70 +++++++++++++++++++ gems/dexpace-core/test/dexpace_test.rb | 29 ++++++-- test/fixtures/surface/dexpace-core.txt | 65 ++++++++++++++++++ 5 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 .rubocop/cops/dexpace/no_weak_references.rb diff --git a/.rubocop.yml b/.rubocop.yml index 46eeb0a..1cce16b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -19,6 +19,7 @@ require: - ./.rubocop/cops/dexpace/no_thread_interrupt.rb - ./.rubocop/cops/dexpace/no_keyword_splat.rb - ./.rubocop/cops/dexpace/qualified_core_constant.rb + - ./.rubocop/cops/dexpace/no_weak_references.rb AllCops: NewCops: enable @@ -181,3 +182,11 @@ Dexpace/QualifiedCoreConstant: Enabled: true Include: - "gems/*/lib/**/*.rb" +# CTX-19: "reimplementations MUST NOT hold contexts by weak/soft references" (phase 4a's P4-10, +# the eighth custom cop). Scoped to every gem's lib/, not core alone: gates:require_allowlist +# covers core's lib/ only, and an adapter gem can hold a context too. test/** is deliberately +# absent, as for the two cops above. +Dexpace/NoWeakReferences: + Enabled: true + Include: + - "gems/*/lib/**/*.rb" diff --git a/.rubocop/cops/dexpace/no_weak_references.rb b/.rubocop/cops/dexpace/no_weak_references.rb new file mode 100644 index 0000000..a20d137 --- /dev/null +++ b/.rubocop/cops/dexpace/no_weak_references.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +module RuboCop + module Cop + module Dexpace + # CTX-19: "reimplementations MUST NOT hold contexts by weak/soft references ... and MUST + # treat the bounded cap (CTX-11), not garbage collection, as the leak backstop." Design + # §5.4 makes that a lint rule; this is the eighth custom cop (phase 4a, P4-10), a ban list + # with a stated hazard in the shape of Dexpace/NoThreadInterrupt and named after its own + # rule, because every cop here mechanises exactly one. + # + # A source-text cop rather than a runtime check, because RuboCop parses and never + # evaluates: the suite's rejected rows include ObjectSpace::WeakKeyMap, which is UNDEFINED + # on the 3.2.11 floor, and RuboCop::ProcessedSource at TargetRubyVersion 3.2 reports + # valid_syntax? and yields the const nodes this cop matches on for every row regardless. + # + # Three spellings: a constant reference to ObjectSpace::WeakMap or ::WeakKeyMap (qualified, + # cbase-qualified, or bare and lexically inside `module ObjectSpace`); a reference to + # WeakRef, bare or cbase-qualified; and `require "weakref"` in the forms tools/require_scan.rb + # reads. The last is redundant inside dexpace-core, where gates:require_allowlist already + # rejects it, and is not redundant anywhere else -- the allowlist covers core's lib/ alone, + # which is why .rubocop.yml scopes this cop to every gem's lib/. Neither this cop nor the + # CTX-19 reachability test can see a third-party store: a reimplementation is by + # definition outside these gates. + class NoWeakReferences < Base + MSG = "`%s` is forbidden: a context must not be held by a weak reference " \ + "(CTX-19); the bounded cap (CTX-11) is the leak backstop." + + RESTRICT_ON_SEND = %i[require].freeze + WEAK_MAP_NAMES = %i[WeakMap WeakKeyMap].freeze + + # @!method object_space_weak_map?(node) + def_node_matcher :object_space_weak_map?, <<~PATTERN + (const (const {nil? cbase} :ObjectSpace) {:WeakMap :WeakKeyMap}) + PATTERN + + # @!method weak_ref_const?(node) + def_node_matcher :weak_ref_const?, "(const {nil? cbase} :WeakRef)" + + # @!method weakref_require?(node) + def_node_matcher :weakref_require?, <<~PATTERN + (send {nil? (const {nil? cbase} :Kernel)} :require (str "weakref")) + PATTERN + + def on_const(node) + return unless object_space_weak_map?(node) || weak_ref_const?(node) || + bare_weak_map_inside_object_space?(node) + + add_offense(node, message: format(MSG, offender: node.source)) + end + + def on_send(node) + return unless weakref_require?(node) + + add_offense(node, message: format(MSG, offender: node.source)) + end + + private + + # `WeakMap`/`WeakKeyMap` written bare, lexically inside `module ObjectSpace`. RuboCop + # parses and never evaluates, so this fires on the source text regardless of whether the + # constant would resolve at runtime. + def bare_weak_map_inside_object_space?(node) + return false unless node.namespace.nil? && WEAK_MAP_NAMES.include?(node.short_name) + + node.each_ancestor(:module).any? do |mod| + mod.identifier.const_type? && mod.identifier.short_name == :ObjectSpace + end + end + end + end + end +end diff --git a/.rubocop/test/cops_test.rb b/.rubocop/test/cops_test.rb index b26b2c4..4e7351b 100644 --- a/.rubocop/test/cops_test.rb +++ b/.rubocop/test/cops_test.rb @@ -242,4 +242,74 @@ class QualifiedCoreConstantIOTest < CopCase end end end + + # Phase 4a (P4-10): the eighth cop, Dexpace/NoWeakReferences. CTX-19 says "reimplementations + # MUST NOT hold contexts by weak/soft references"; design §5.4 makes that a lint rule. RuboCop + # parses and never evaluates, so ObjectSpace::WeakKeyMap is a valid row even though the + # constant is undefined on the 3.2.11 floor -- CopCase pins the parse target at TARGET_RUBY, + # independent of the interpreter running this suite. Its own nested class, for the 100-line cap. + class NoWeakReferencesTest < CopCase + IN_STORE = [ + "module Dexpace", " class ContextStore", " def initialize", " %s", " end", + " end", "end", "", + ].join("\n") + IN_OBJECT_SPACE = [ + "module ObjectSpace", " class Foo", " def initialize", " %s", " end", " end", + "end", "", + ].join("\n") + + # [source, the fragment the message must carry] + REJECTED = [ + ["ObjectSpace::WeakMap.new\n", "CTX-19"], + ["ObjectSpace::WeakKeyMap.new\n", "CTX-19"], + ["::ObjectSpace::WeakKeyMap.new\n", "CTX-19"], + ["::ObjectSpace::WeakMap.new\n", "CTX-19"], + # A bare WeakMap/WeakKeyMap lexically inside `module ObjectSpace` names the same thing. + [format(IN_OBJECT_SPACE, "@m = WeakMap.new"), "CTX-19"], + ["module ObjectSpace\n X = WeakKeyMap\nend\n", "CTX-19"], + ["WeakRef.new(ctx)\n", "CTX-19"], + ["::WeakRef.new(ctx)\n", "CTX-19"], + ["require \"weakref\"\n", "CTX-19"], + ["require(\"weakref\")\n", "CTX-19"], + ["Kernel.require \"weakref\"\n", "CTX-19"], + # The line the rule exists to stop. + [format(IN_STORE, "@map = ObjectSpace::WeakKeyMap.new"), "CTX-19"], + [format(IN_STORE, "@map = ObjectSpace::WeakMap.new"), "CTX-19"], + # A constant reference with no call at all is still a reference. + ["MAP_CLASS = ObjectSpace::WeakMap\n", "CTX-19"], + ].freeze + + # The accepted half is what proves the cop does not reject every occurrence of the substring. + ACCEPTED = [ + "ObjectSpace.count_objects\n", + "ObjectSpace.each_object { |o| o }\n", + "ObjectSpace.garbage_collect\n", + "# ObjectSpace::WeakMap is banned here\n", + "weak_map = {}\n", + "def weak_ref\nend\n", + "@weak_ref = nil\n", + format(IN_STORE, "@h = Hash.new"), + format(IN_STORE, "@h = {}"), + "require \"set\"\n", + "require \"weakref_something_else\"\n", + "require_relative \"weakref\"\n", + # The scoping the bare-name matcher depends on: a bare WeakMap/WeakKeyMap outside + # `module ObjectSpace` names something else and is not this cop's business. + "module Dexpace\n def self.build\n WeakMap.new\n end\nend\n", + "module Dexpace\n module Store\n def self.of\n WeakKeyMap.of\n end\n end\nend\n", + "Other::WeakMap.new\n", + ].freeze + + REJECTED.each_with_index do |(source, fragment), index| + test "rejects case #{index}: #{source.lines.first.strip}" do + assert_offense(D::NoWeakReferences, source, fragment) + end + end + + ACCEPTED.each_with_index do |source, index| + test "accepts case #{index}: #{source.lines.first.strip}" do + assert_no_offense(D::NoWeakReferences, source) + end + end + end end diff --git a/gems/dexpace-core/test/dexpace_test.rb b/gems/dexpace-core/test/dexpace_test.rb index b2a9f9b..94bacd7 100644 --- a/gems/dexpace-core/test/dexpace_test.rb +++ b/gems/dexpace-core/test/dexpace_test.rb @@ -34,9 +34,10 @@ class DexpaceTest < DexpaceTestCase # Every public constant the surface manifest records, and the check that catches a file added # to lib/ and forgotten in the entry point. Phase 1's domain model, then phase 2's seam layer, - # then phase 3a's byte-streaming layer, then phase 3b's body layer, then phase 4b's recovery - # layer, then phase 4c's pipeline; Dexpace::Hooks, Dexpace::Recovery::Ownership and the two - # pipeline drivers are private_constants and appear in no constants(false) list. + # then phase 3a's byte-streaming layer, then phase 3b's body layer, then phase 4a's execution + # context, phase 4b's recovery layer and phase 4c's pipeline; Dexpace::Hooks, Dexpace::BoundedMap, + # Dexpace::CallKey, Dexpace::Recovery::Ownership and the two pipeline drivers are + # private_constants and appear in no constants(false) list. DOMAIN_MODEL = %i[ Error InvalidArgumentError Model Builder HeaderSyntax HeaderName Headers Status Method Protocol MediaType PercentEncoding Query URL RequestOptions Request Response @@ -50,9 +51,15 @@ class DexpaceTest < DexpaceTestCase Body BytesBody BufferBody StreamBody ChunkedBody FormBody FileBody MultipartBody ResponseBody RequestLoggingBody ResponseLoggingBody TypedResponse ].freeze + CONTEXT_LAYER = %i[ + ContextConflictError Context ContextStore Instrumentation DispatchContext RequestContext + ExchangeContext + ].freeze RECOVERY_LAYER = %i[Suppressible OutcomeError ProtocolError Outcome Recovery].freeze PIPELINE_LAYER = %i[PipelineError Pipeline AsyncPipeline].freeze - LAYERS = [DOMAIN_MODEL, SEAM_LAYER, IO_LAYER, BODY_LAYER, RECOVERY_LAYER, PIPELINE_LAYER].flatten + LAYERS = [ + DOMAIN_MODEL, SEAM_LAYER, IO_LAYER, BODY_LAYER, CONTEXT_LAYER, RECOVERY_LAYER, PIPELINE_LAYER, + ].flatten.freeze test "defines nothing outside the Dexpace namespace" do assert_empty(TOP_LEVEL_ADDED - [:Dexpace], "top-level constants added by the entry file") @@ -104,6 +111,20 @@ class DexpaceTest < DexpaceTestCase assert_raises(::NameError) { Dexpace::Pipeline::SyncDriver } end + # A consumer requires "dexpace" and nothing else: the execution context resolves too (phase + # 4a), the instrumentation subsystem keeps its namespace (design §8.1), and the two private + # constants are as unreachable as Dexpace::Hooks. + test "requiring dexpace alone makes the whole execution context resolve" do + assert_equal(Dexpace::ContextStore, Dexpace.const_get(:ContextStore)) + assert_equal(Dexpace::DispatchContext, Dexpace.const_get(:DispatchContext)) + assert_equal(Dexpace::Instrumentation::Bundle, Dexpace::Instrumentation.const_get(:Bundle)) + assert_equal(1024, Dexpace::ContextStore::MAX_TRACKED_CONTEXTS) + refute_includes(Dexpace.constants(false), :BoundedMap, "Dexpace::BoundedMap is private") + refute_includes(Dexpace.constants(false), :CallKey, "Dexpace::CallKey is private") + assert_raises(::NameError) { Dexpace::BoundedMap } + assert_raises(::NameError) { Dexpace::CallKey } + end + # A consumer requires "dexpace" and nothing else: the seam layer resolves too (phase 2). test "requiring dexpace alone makes the whole seam layer resolve" do assert_equal(Dexpace::Transport, Dexpace.const_get(:Transport)) diff --git a/test/fixtures/surface/dexpace-core.txt b/test/fixtures/surface/dexpace-core.txt index d0ad1d9..43cdf5d 100644 --- a/test/fixtures/surface/dexpace-core.txt +++ b/test/fixtures/surface/dexpace-core.txt @@ -125,8 +125,34 @@ Dexpace::Closeable#closed? Dexpace::Closeable#initialize_closeable Dexpace::Closeable#owned? Dexpace::ClosedError +Dexpace::Context +Dexpace::Context#close +Dexpace::ContextConflictError +Dexpace::ContextConflictError#call_key +Dexpace::ContextStore +Dexpace::ContextStore#[] +Dexpace::ContextStore#put +Dexpace::ContextStore#release +Dexpace::ContextStore#set +Dexpace::ContextStore#size +Dexpace::ContextStore.default +Dexpace::ContextStore::MAX_TRACKED_CONTEXTS : Integer +Dexpace::DispatchContext +Dexpace::DispatchContext#bundle +Dexpace::DispatchContext#call_key +Dexpace::DispatchContext#promote_to_request +Dexpace::DispatchContext#store +Dexpace::DispatchContext.build Dexpace::EndOfStreamError Dexpace::Error +Dexpace::ExchangeContext +Dexpace::ExchangeContext#bundle +Dexpace::ExchangeContext#call_key +Dexpace::ExchangeContext#operation_name +Dexpace::ExchangeContext#request +Dexpace::ExchangeContext#response +Dexpace::ExchangeContext#store +Dexpace::ExchangeContext.build Dexpace::FileBody Dexpace::FileBody#== Dexpace::FileBody#content_length @@ -241,6 +267,37 @@ Dexpace::IO::TypedWrites#write_all Dexpace::IO::TypedWrites#write_from Dexpace::IO::TypedWrites#write_string Dexpace::IO::TypedWrites#write_utf8 +Dexpace::Instrumentation +Dexpace::Instrumentation::Bundle +Dexpace::Instrumentation::Bundle#flavour +Dexpace::Instrumentation::Bundle#remote +Dexpace::Instrumentation::Bundle#remote? +Dexpace::Instrumentation::Bundle#span +Dexpace::Instrumentation::Bundle#span_id +Dexpace::Instrumentation::Bundle#trace_flags +Dexpace::Instrumentation::Bundle#trace_id +Dexpace::Instrumentation::Bundle#trace_state +Dexpace::Instrumentation::Bundle#tracer_factory +Dexpace::Instrumentation::Bundle#valid? +Dexpace::Instrumentation::Bundle.build +Dexpace::Instrumentation::Bundle::INVALID_SPAN_ID : String +Dexpace::Instrumentation::Bundle::NONE : Dexpace::Instrumentation::Bundle +Dexpace::Instrumentation::NO_SPAN : Dexpace::Instrumentation::NoSpan +Dexpace::Instrumentation::NO_TRACER : Dexpace::Instrumentation::NoTracer +Dexpace::Instrumentation::NO_TRACER_FACTORY : Dexpace::Instrumentation::NoTracerFactory +Dexpace::Instrumentation::TraceIdFlavour +Dexpace::Instrumentation::TraceIdFlavour#invalid_trace_id +Dexpace::Instrumentation::TraceIdFlavour#max_value +Dexpace::Instrumentation::TraceIdFlavour#name +Dexpace::Instrumentation::TraceIdFlavour#renders? +Dexpace::Instrumentation::TraceIdFlavour#trace_id_pattern +Dexpace::Instrumentation::TraceIdFlavour#valid_trace_id? +Dexpace::Instrumentation::TraceIdFlavour.build +Dexpace::Instrumentation::TraceIdFlavour.of +Dexpace::Instrumentation::TraceIdFlavour::ALL : Array +Dexpace::Instrumentation::TraceIdFlavour::DATADOG : Dexpace::Instrumentation::TraceIdFlavour +Dexpace::Instrumentation::TraceIdFlavour::NONE : Dexpace::Instrumentation::TraceIdFlavour +Dexpace::Instrumentation::TraceIdFlavour::W3C : Dexpace::Instrumentation::TraceIdFlavour Dexpace::InvalidArgumentError Dexpace::MediaType Dexpace::MediaType#charset @@ -520,6 +577,14 @@ Dexpace::Request::Builder#header Dexpace::Request::Builder#headers= Dexpace::Request::Builder#method= Dexpace::Request::Builder#url= +Dexpace::RequestContext +Dexpace::RequestContext#bundle +Dexpace::RequestContext#call_key +Dexpace::RequestContext#operation_name +Dexpace::RequestContext#promote_to_exchange +Dexpace::RequestContext#request +Dexpace::RequestContext#store +Dexpace::RequestContext.build Dexpace::RequestLoggingBody Dexpace::RequestLoggingBody#== Dexpace::RequestLoggingBody#content_length From dfa469972d1d38e7ec2d2188225cc51f42d1c6ba Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 16 Sep 2026 17:47:16 +0300 Subject: [PATCH 3/8] fix: two stale comments -- the sig's validate! and the cop's spellings 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. --- .rubocop/cops/dexpace/no_weak_references.rb | 15 +++++++++------ .../sig/dexpace/context/dispatch_context.rbs | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.rubocop/cops/dexpace/no_weak_references.rb b/.rubocop/cops/dexpace/no_weak_references.rb index a20d137..fe6974e 100644 --- a/.rubocop/cops/dexpace/no_weak_references.rb +++ b/.rubocop/cops/dexpace/no_weak_references.rb @@ -17,12 +17,15 @@ module Dexpace # # Three spellings: a constant reference to ObjectSpace::WeakMap or ::WeakKeyMap (qualified, # cbase-qualified, or bare and lexically inside `module ObjectSpace`); a reference to - # WeakRef, bare or cbase-qualified; and `require "weakref"` in the forms tools/require_scan.rb - # reads. The last is redundant inside dexpace-core, where gates:require_allowlist already - # rejects it, and is not redundant anywhere else -- the allowlist covers core's lib/ alone, - # which is why .rubocop.yml scopes this cop to every gem's lib/. Neither this cop nor the - # CTX-19 reachability test can see a third-party store: a reimplementation is by - # definition outside these gates. + # WeakRef, bare or cbase-qualified; and `require "weakref"` -- bare, parenthesised, or + # through `Kernel.require` / `::Kernel.require`, and nothing else. An `autoload :WeakRef, + # "weakref"` is NOT matched here: it names the constant as a Symbol, and the reference + # that later triggers it is one the second spelling flags, so the autoload form needs no + # row of its own. The last spelling is redundant inside dexpace-core, where + # gates:require_allowlist already rejects it, and is not redundant anywhere else -- the + # allowlist covers core's lib/ alone, which is why .rubocop.yml scopes this cop to every + # gem's lib/. Neither this cop nor the CTX-19 reachability test can see a third-party + # store: a reimplementation is by definition outside these gates. class NoWeakReferences < Base MSG = "`%s` is forbidden: a context must not be held by a weak reference " \ "(CTX-19); the bounded cap (CTX-11) is the leak backstop." diff --git a/gems/dexpace-core/sig/dexpace/context/dispatch_context.rbs b/gems/dexpace-core/sig/dexpace/context/dispatch_context.rbs index 47508a3..dc62215 100644 --- a/gems/dexpace-core/sig/dexpace/context/dispatch_context.rbs +++ b/gems/dexpace-core/sig/dexpace/context/dispatch_context.rbs @@ -4,7 +4,7 @@ module Dexpace attr_reader bundle: Instrumentation::Bundle attr_reader call_key: String - # untyped rather than ContextStore: Context.validate! accepts any object answering #set and + # untyped rather than ContextStore: #validate_context! accepts any object answering #set and # #release (the narrowest duck type), and the suite's FakeStore relies on that. attr_reader store: untyped From 0b35001807c5ced138e9c0eac08805a12f5168a8 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 16 Sep 2026 18:45:46 +0300 Subject: [PATCH 4/8] fix: a pinned call_key and an operation_name must be Strings 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. --- gems/dexpace-core/lib/dexpace/context.rb | 23 +++++++++++++++---- .../lib/dexpace/context/exchange_context.rb | 4 +--- .../lib/dexpace/context/request_context.rb | 4 +--- gems/dexpace-core/sig/dexpace/context.rbs | 1 + 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/gems/dexpace-core/lib/dexpace/context.rb b/gems/dexpace-core/lib/dexpace/context.rb index dc5164c..4d412a6 100644 --- a/gems/dexpace-core/lib/dexpace/context.rb +++ b/gems/dexpace-core/lib/dexpace/context.rb @@ -41,17 +41,32 @@ def close # The construction validation every flavour's #initialize runs before its own fields and # before `super`: the two members every flavour carries (SEAM-29's one message form for an - # absent one), and the store, checked for the two methods a context calls on it rather than - # for its class -- the narrowest duck type (api-design/88e6bf12), which is what keeps the - # suite's fake cheap. Private, so it is no part of the NFR-4-locked surface: the design gives - # Context exactly one public method, #close. + # absent one; CTX-4's key is a non-empty String, so a Symbol is refused rather than silently + # keying a slot no String lookup can find, and an Integer meets the SDK's error rather than + # escaping as a NoMethodError from #empty?), and the store, checked for the two methods a + # context calls on it rather than for its class -- the narrowest duck type + # (api-design/88e6bf12), which is what keeps the suite's fake cheap. Private, so it is no part + # of the NFR-4-locked surface: the design gives Context exactly one public method, #close. def validate_context!(bundle:, call_key:, store:) Model.required!("bundle", bundle) Model.required!("call_key", call_key) + raise InvalidArgumentError, "call_key must be a String" unless call_key.is_a?(String) raise InvalidArgumentError, "call_key must not be empty" if call_key.empty? return if store.respond_to?(:set) && store.respond_to?(:release) raise InvalidArgumentError, "store must respond to #set and #release" end + + # CTX-16's two-state rule, in one place for the two flavours that carry a name: nil, or a + # non-empty String. "" is neither state, and a Symbol is not the "schema-defined operation id + # such as 'GetUser'" the requirement describes, so both are refused with the field-named + # message every core model uses for a wrong-typed String member. + def validate_operation_name!(operation_name) + return if operation_name.nil? + unless operation_name.is_a?(String) + raise InvalidArgumentError, "operation_name must be a String" + end + raise InvalidArgumentError, "operation_name must not be empty" if operation_name.empty? + end end end diff --git a/gems/dexpace-core/lib/dexpace/context/exchange_context.rb b/gems/dexpace-core/lib/dexpace/context/exchange_context.rb index f8f8ac5..5c6e650 100644 --- a/gems/dexpace-core/lib/dexpace/context/exchange_context.rb +++ b/gems/dexpace-core/lib/dexpace/context/exchange_context.rb @@ -42,9 +42,7 @@ def initialize(bundle:, call_key:, store:, request:, operation_name:, response:) validate_context!(bundle: bundle, call_key: call_key, store: store) Model.required!("request", request) Model.required!("response", response) - if !operation_name.nil? && operation_name.empty? - raise InvalidArgumentError, "operation_name must not be empty" - end + validate_operation_name!(operation_name) super end diff --git a/gems/dexpace-core/lib/dexpace/context/request_context.rb b/gems/dexpace-core/lib/dexpace/context/request_context.rb index 190ddf9..8f06cb6 100644 --- a/gems/dexpace-core/lib/dexpace/context/request_context.rb +++ b/gems/dexpace-core/lib/dexpace/context/request_context.rb @@ -40,9 +40,7 @@ def self.build(bundle:, request:, operation_name: nil, call_key: nil, def initialize(bundle:, call_key:, store:, request:, operation_name:) validate_context!(bundle: bundle, call_key: call_key, store: store) Model.required!("request", request) - if !operation_name.nil? && operation_name.empty? - raise InvalidArgumentError, "operation_name must not be empty" - end + validate_operation_name!(operation_name) super end diff --git a/gems/dexpace-core/sig/dexpace/context.rbs b/gems/dexpace-core/sig/dexpace/context.rbs index 5ebc4c9..315910f 100644 --- a/gems/dexpace-core/sig/dexpace/context.rbs +++ b/gems/dexpace-core/sig/dexpace/context.rbs @@ -16,5 +16,6 @@ module Dexpace def close: () -> bool private def validate_context!: (bundle: untyped, call_key: String, store: untyped) -> nil + private def validate_operation_name!: (String? operation_name) -> nil end end From c098a9587d39367cd8ee2c2270728e35b36d225d Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 16 Sep 2026 23:00:09 +0300 Subject: [PATCH 5/8] fix: split the smoke suite's per-layer cases into a nested class 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 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. --- gems/dexpace-core/test/dexpace_test.rb | 125 +++++++++++++------------ 1 file changed, 65 insertions(+), 60 deletions(-) diff --git a/gems/dexpace-core/test/dexpace_test.rb b/gems/dexpace-core/test/dexpace_test.rb index 94bacd7..2806c07 100644 --- a/gems/dexpace-core/test/dexpace_test.rb +++ b/gems/dexpace-core/test/dexpace_test.rb @@ -83,71 +83,76 @@ class DexpaceTest < DexpaceTestCase end end - # A consumer requires "dexpace" and nothing else: the body layer resolves too (phase 3b), and - # the two constants that are nested rather than flat sit where HTTP-3 and R6 put them. - test "requiring dexpace alone makes the whole body layer resolve" do - assert_equal(Dexpace::Body, Dexpace.const_get(:Body)) - assert_equal(Dexpace::TypedResponse, Dexpace.const_get(:TypedResponse)) - assert_equal(Dexpace::MultipartBody::Part, Dexpace::MultipartBody.const_get(:Part)) - assert_equal(1024 * 1024, Dexpace::Body::MAX_BUFFERED_ERROR_BODY_BYTES) - refute(Dexpace::Body.const_defined?(:Part, false), "Dexpace::Body::Part is not a namespace") - end + # A consumer requires "dexpace" and nothing else, and every layer resolves -- one case per phase, + # in a nested class because the smoke suite outgrew Metrics/ClassLength once the three phase-4 + # lanes landed their pins beside one another (the split every larger suite in this gem uses). + class Layers < DexpaceTestCase + # A consumer requires "dexpace" and nothing else: the body layer resolves too (phase 3b), and + # the two constants that are nested rather than flat sit where HTTP-3 and R6 put them. + test "requiring dexpace alone makes the whole body layer resolve" do + assert_equal(Dexpace::Body, Dexpace.const_get(:Body)) + assert_equal(Dexpace::TypedResponse, Dexpace.const_get(:TypedResponse)) + assert_equal(Dexpace::MultipartBody::Part, Dexpace::MultipartBody.const_get(:Part)) + assert_equal(1024 * 1024, Dexpace::Body::MAX_BUFFERED_ERROR_BODY_BYTES) + refute(Dexpace::Body.const_defined?(:Part, false), "Dexpace::Body::Part is not a namespace") + end - # A consumer requires "dexpace" and nothing else: the recovery layer resolves too (phase 4b), - # the trail module precedes the error root that includes it, and Ownership is private. - test "requiring dexpace alone makes the whole recovery layer resolve" do - assert_operator(Dexpace::Error, :<, Dexpace::Suppressible) - assert_equal(Dexpace::Outcome::Failure, Dexpace::Outcome.const_get(:Failure)) - assert_equal(Dexpace::Recovery::Orchestrator, Dexpace::Recovery.const_get(:Orchestrator)) - refute_includes(Dexpace::Recovery.constants(false), :Ownership, "a private_constant") - assert_raises(::NameError) { Dexpace::Recovery::Ownership } - end + # A consumer requires "dexpace" and nothing else: the recovery layer resolves too (phase 4b), + # the trail module precedes the error root that includes it, and Ownership is private. + test "requiring dexpace alone makes the whole recovery layer resolve" do + assert_operator(Dexpace::Error, :<, Dexpace::Suppressible) + assert_equal(Dexpace::Outcome::Failure, Dexpace::Outcome.const_get(:Failure)) + assert_equal(Dexpace::Recovery::Orchestrator, Dexpace::Recovery.const_get(:Orchestrator)) + refute_includes(Dexpace::Recovery.constants(false), :Ownership, "a private_constant") + assert_raises(::NameError) { Dexpace::Recovery::Ownership } + end - # A consumer requires "dexpace" and nothing else: the pipeline resolves too (phase 4c), the - # runtime's nested vocabulary with it, and the two drivers are private. - test "requiring dexpace alone makes the whole pipeline resolve, its two drivers private" do - assert_equal(16, Dexpace::Pipeline.const_get(:Stages)::ALL.size) - assert_empty(Dexpace::Pipeline.constants(false) & %i[SyncDriver AsyncDriver], "private") - assert_raises(::NameError) { Dexpace::Pipeline::SyncDriver } - end + # A consumer requires "dexpace" and nothing else: the pipeline resolves too (phase 4c), the + # runtime's nested vocabulary with it, and the two drivers are private. + test "requiring dexpace alone makes the whole pipeline resolve, its two drivers private" do + assert_equal(16, Dexpace::Pipeline.const_get(:Stages)::ALL.size) + assert_empty(Dexpace::Pipeline.constants(false) & %i[SyncDriver AsyncDriver], "private") + assert_raises(::NameError) { Dexpace::Pipeline::SyncDriver } + end - # A consumer requires "dexpace" and nothing else: the execution context resolves too (phase - # 4a), the instrumentation subsystem keeps its namespace (design §8.1), and the two private - # constants are as unreachable as Dexpace::Hooks. - test "requiring dexpace alone makes the whole execution context resolve" do - assert_equal(Dexpace::ContextStore, Dexpace.const_get(:ContextStore)) - assert_equal(Dexpace::DispatchContext, Dexpace.const_get(:DispatchContext)) - assert_equal(Dexpace::Instrumentation::Bundle, Dexpace::Instrumentation.const_get(:Bundle)) - assert_equal(1024, Dexpace::ContextStore::MAX_TRACKED_CONTEXTS) - refute_includes(Dexpace.constants(false), :BoundedMap, "Dexpace::BoundedMap is private") - refute_includes(Dexpace.constants(false), :CallKey, "Dexpace::CallKey is private") - assert_raises(::NameError) { Dexpace::BoundedMap } - assert_raises(::NameError) { Dexpace::CallKey } - end + # A consumer requires "dexpace" and nothing else: the execution context resolves too (phase + # 4a), the instrumentation subsystem keeps its namespace (design §8.1), and the two private + # constants are as unreachable as Dexpace::Hooks. + test "requiring dexpace alone makes the whole execution context resolve" do + assert_equal(Dexpace::ContextStore, Dexpace.const_get(:ContextStore)) + assert_equal(Dexpace::DispatchContext, Dexpace.const_get(:DispatchContext)) + assert_equal(Dexpace::Instrumentation::Bundle, Dexpace::Instrumentation.const_get(:Bundle)) + assert_equal(1024, Dexpace::ContextStore::MAX_TRACKED_CONTEXTS) + refute_includes(Dexpace.constants(false), :BoundedMap, "Dexpace::BoundedMap is private") + refute_includes(Dexpace.constants(false), :CallKey, "Dexpace::CallKey is private") + assert_raises(::NameError) { Dexpace::BoundedMap } + assert_raises(::NameError) { Dexpace::CallKey } + end - # A consumer requires "dexpace" and nothing else: the seam layer resolves too (phase 2). - test "requiring dexpace alone makes the whole seam layer resolve" do - assert_equal(Dexpace::Transport, Dexpace.const_get(:Transport)) - assert_equal(Dexpace::AsyncTransport, Dexpace.const_get(:AsyncTransport)) - assert_equal(Dexpace::Serde, Dexpace.const_get(:Serde)) - assert_equal(Dexpace::Operation, Dexpace.const_get(:Operation)) - assert_equal(Dexpace::Registry, Dexpace.const_get(:Registry)) - assert_equal(Dexpace::Cancellation, Dexpace.const_get(:Cancellation)) - assert_equal(Dexpace::Async::Future, Dexpace::Async.const_get(:Future)) - assert_equal(Dexpace::Closeable, Dexpace.const_get(:Closeable)) - refute_includes(Dexpace.constants(false), :Hooks, "Dexpace::Hooks is a private_constant") - assert_raises(::NameError) { Dexpace::Hooks } - end + # A consumer requires "dexpace" and nothing else: the seam layer resolves too (phase 2). + test "requiring dexpace alone makes the whole seam layer resolve" do + assert_equal(Dexpace::Transport, Dexpace.const_get(:Transport)) + assert_equal(Dexpace::AsyncTransport, Dexpace.const_get(:AsyncTransport)) + assert_equal(Dexpace::Serde, Dexpace.const_get(:Serde)) + assert_equal(Dexpace::Operation, Dexpace.const_get(:Operation)) + assert_equal(Dexpace::Registry, Dexpace.const_get(:Registry)) + assert_equal(Dexpace::Cancellation, Dexpace.const_get(:Cancellation)) + assert_equal(Dexpace::Async::Future, Dexpace::Async.const_get(:Future)) + assert_equal(Dexpace::Closeable, Dexpace.const_get(:Closeable)) + refute_includes(Dexpace.constants(false), :Hooks, "Dexpace::Hooks is a private_constant") + assert_raises(::NameError) { Dexpace::Hooks } + end - # A consumer requires "dexpace" and nothing else: the byte-streaming layer resolves too - # (phase 3a). - test "requiring dexpace alone makes the whole streaming layer resolve" do - assert_equal(Dexpace::IO::Buffer, Dexpace::IO.const_get(:Buffer)) - assert_equal(Dexpace::IO::BufferedSource, Dexpace::IO.const_get(:BufferedSource)) - assert_equal(Dexpace::IO::BufferedSink, Dexpace::IO.const_get(:BufferedSink)) - assert_equal(Dexpace::IO::TeeSink, Dexpace::IO.const_get(:TeeSink)) - assert_equal(Dexpace::StreamError, Dexpace.const_get(:StreamError)) - assert_equal(Dexpace::EndOfStreamError, Dexpace.const_get(:EndOfStreamError)) + # A consumer requires "dexpace" and nothing else: the byte-streaming layer resolves too + # (phase 3a). + test "requiring dexpace alone makes the whole streaming layer resolve" do + assert_equal(Dexpace::IO::Buffer, Dexpace::IO.const_get(:Buffer)) + assert_equal(Dexpace::IO::BufferedSource, Dexpace::IO.const_get(:BufferedSource)) + assert_equal(Dexpace::IO::BufferedSink, Dexpace::IO.const_get(:BufferedSink)) + assert_equal(Dexpace::IO::TeeSink, Dexpace::IO.const_get(:TeeSink)) + assert_equal(Dexpace::StreamError, Dexpace.const_get(:StreamError)) + assert_equal(Dexpace::EndOfStreamError, Dexpace.const_get(:EndOfStreamError)) + end end # The shadowing names this SDK never defines: each would make a bare `rescue ArgumentError`, From 9e56ff4d478947d7be257721b5b0762ee7ef8937 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 16 Sep 2026 16:50:34 +0300 Subject: [PATCH 6/8] test: the execution context's ten suites, the fake, the cop-scope pin 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. --- .../dexpace/context/dispatch_context_test.rb | 253 ++++++++++++++ .../dexpace/context/exchange_context_test.rb | 112 ++++++ .../dexpace/context/request_context_test.rb | 170 +++++++++ .../test/dexpace/context_store_test.rb | 324 ++++++++++++++++++ .../dexpace-core/test/dexpace/context_test.rb | 109 ++++++ .../error/context_conflict_error_test.rb | 36 ++ .../dexpace/instrumentation/bundle_test.rb | 250 ++++++++++++++ .../dexpace/instrumentation/no_span_test.rb | 28 ++ .../dexpace/instrumentation/no_tracer_test.rb | 68 ++++ .../instrumentation/trace_id_flavour_test.rb | 168 +++++++++ .../dexpace-core/test/support/fake_context.rb | 28 ++ test/gates/rubocop_config_test.rb | 14 + 12 files changed, 1560 insertions(+) create mode 100644 gems/dexpace-core/test/dexpace/context/dispatch_context_test.rb create mode 100644 gems/dexpace-core/test/dexpace/context/exchange_context_test.rb create mode 100644 gems/dexpace-core/test/dexpace/context/request_context_test.rb create mode 100644 gems/dexpace-core/test/dexpace/context_store_test.rb create mode 100644 gems/dexpace-core/test/dexpace/context_test.rb create mode 100644 gems/dexpace-core/test/dexpace/error/context_conflict_error_test.rb create mode 100644 gems/dexpace-core/test/dexpace/instrumentation/bundle_test.rb create mode 100644 gems/dexpace-core/test/dexpace/instrumentation/no_span_test.rb create mode 100644 gems/dexpace-core/test/dexpace/instrumentation/no_tracer_test.rb create mode 100644 gems/dexpace-core/test/dexpace/instrumentation/trace_id_flavour_test.rb create mode 100644 gems/dexpace-core/test/support/fake_context.rb diff --git a/gems/dexpace-core/test/dexpace/context/dispatch_context_test.rb b/gems/dexpace-core/test/dexpace/context/dispatch_context_test.rb new file mode 100644 index 0000000..88bef40 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/context/dispatch_context_test.rb @@ -0,0 +1,253 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# CTX-1 (the head of the chain), CTX-2, CTX-3, CTX-4, CTX-5, CTX-6, CTX-7, CTX-15, CTX-17. +# +# One class per behaviour group, because Metrics/ClassLength caps a class at 100 lines: the key +# and its equality here, then Promotion and Construction below. +class DexpaceDispatchContextTest < DexpaceTestCase + # The untraced bundle every case in this file builds from (CTX-15). + NONE = Dexpace::Instrumentation::Bundle::NONE + + test "CTX-5/CTX-6: two default-constructed contexts from the same bundle are NOT equal" do + a = Dexpace::DispatchContext.build(bundle: NONE) + b = Dexpace::DispatchContext.build(bundle: NONE) + + refute_equal(a, b) + refute_operator(a, :eql?, b) + refute_equal(a.hash, b.hash) + assert_equal(2, { a => 1, b => 2 }.size) + end + + test "CTX-5: an explicit shared call_key restores value equality" do + a = Dexpace::DispatchContext.build(bundle: NONE, call_key: "shared") + b = Dexpace::DispatchContext.build(bundle: NONE, call_key: "shared") + + assert_equal(a, b) + assert_operator(a, :eql?, b) + assert_equal(a.hash, b.hash) + assert_equal(1, { a => 1, b => 2 }.size) + end + + # This is the test that would fail if the key were ever derived from the bundle: the two + # contexts share the very same Bundle::NONE object (asserted equal?, not merely ==), so every + # bundle field is identical, and the keys still differ. + test "CTX-15: minting from the SAME Bundle::NONE object still yields distinct keys" do + bundle = NONE + a = Dexpace::DispatchContext.build(bundle: bundle) + b = Dexpace::DispatchContext.build(bundle: bundle) + + assert_same(bundle, a.bundle) + assert_same(bundle, b.bundle) + refute_equal(a.call_key, b.call_key) + end + + # CTX-4's reference rendering, 'traceId:spanId:n': a rendered prefix plus a counter, so a key is + # debuggable by eye, and the counter is what makes it call-unique when the prefix is not. + test "CTX-4: a minted key is the bundle's trace id, its span id and a counter" do + ctx = Dexpace::DispatchContext.build(bundle: NONE) + prefix = "#{NONE.trace_id}:#{NONE.span_id}:" + + assert_operator(ctx.call_key, :start_with?, prefix) + assert_match(/\A#{Regexp.escape(prefix)}[1-9][0-9]*\z/, ctx.call_key) + end + + test "CTX-4: successive minted keys carry a strictly increasing counter" do + counters = Array.new(5) do + Dexpace::DispatchContext.build(bundle: NONE).call_key.split(":").last.to_i + end + + assert_equal(counters, counters.sort) + assert_equal(5, counters.uniq.size) + end + + # One counter serves all three flavours: CTX-6 requires distinctness "across the whole + # process and across all three context flavors", not across a store. Distinctness alone is + # not the discriminating assertion -- a counter per flavour yields three distinct keys too, + # whenever the shared counter has already moved past the per-flavour one -- so the suffix is + # asserted to increase strictly ACROSS flavours in build order, which only one counter does. + test "CTX-6: three flavours built from one untraced bundle draw from one counter" do + bundle = NONE + keys = Array.new(3).flat_map do + [ + Dexpace::DispatchContext.build(bundle: bundle).call_key, + Dexpace::RequestContext.build(bundle: bundle, request: :req).call_key, + Dexpace::ExchangeContext.build(bundle: bundle, request: :req, response: :resp).call_key, + ] + end + counters = keys.map { |key| key.split(":").last.to_i } + + assert_equal(9, keys.uniq.size) + assert_equal(counters.sort, counters) + assert_equal(counters.first + 8, counters.last) + end + + # CTX-6 across stores: the counter is process-wide, not per store, so two stores built for two + # tests cannot mint a colliding key. + test "CTX-6: contexts built against two different stores never share a minted key" do + keys = Array.new(2) do + Dexpace::DispatchContext.build(bundle: NONE, store: Dexpace::ContextStore.new(cap: 8)) + .call_key + end + + assert_equal(2, keys.uniq.size) + end + + # The mint is exact under contention (design, verified fact 9): 16 threads minting 250 keys + # each yield 4000 distinct keys. The mutex is what makes that true off CRuby. + test "CTX-4/CTX-6: 16 threads minting concurrently never collide" do + keys = ::Thread::Queue.new + threads = Array.new(16) do + ::Thread.new { 250.times { keys << Dexpace::DispatchContext.build(bundle: NONE).call_key } } + end + threads.each(&:join) + minted = Array.new(4000) { keys.pop } + + assert_equal(4000, minted.uniq.size) + end + + # CTX-2, CTX-3, CTX-7, CTX-17: what a promotion does and what construction does not. + class PromotionTest < DexpaceTestCase + test "CTX-17: constructing a dispatch context registers nothing" do + store = Dexpace::ContextStore.new(cap: 8) + ctx = Dexpace::DispatchContext.build(bundle: NONE, store: store) + + assert_equal(0, store.size) + assert_nil(store[ctx.call_key]) + end + + test "CTX-17: a dispatch context never promoted closes as a harmless no-op" do + store = Dexpace::ContextStore.new(cap: 8) + ctx = Dexpace::DispatchContext.build(bundle: NONE, store: store) + + refute(ctx.close) + assert_equal(0, store.size) + end + + # CTX-2: assert_same, never assert_equal -- "the same InstrumentationContext reference", and + # value equality would pass against an implementation that rebuilt the bundle. The source is + # asserted unchanged in the same test, which is the requirement's own conformance step. + test "CTX-2: promotion is additive, non-mutating, and carries members forward by identity" do + store = Dexpace::ContextStore.new(cap: 8) + source = Dexpace::DispatchContext.build(bundle: NONE, store: store) + request = :the_request + key_before = source.call_key + + promoted = source.promote_to_request(request: request) + + assert_instance_of(Dexpace::RequestContext, promoted) + assert_same(source.bundle, promoted.bundle) + assert_same(source.call_key, promoted.call_key) + assert_same(source.store, promoted.store) + assert_same(request, promoted.request) + assert_nil(promoted.operation_name) + refute_respond_to(source, :request) + assert_same(key_before, source.call_key) + assert_same(NONE, source.bundle) + end + + test "CTX-2: the operation name is an argument to the dispatch->request promotion" do + store = Dexpace::ContextStore.new(cap: 8) + source = Dexpace::DispatchContext.build(bundle: NONE, store: store) + + promoted = source.promote_to_request(request: :req, operation_name: "GetUser") + + assert_equal("GetUser", promoted.operation_name) + refute_respond_to(source, :operation_name) + end + + test "CTX-17: the first promotion is the first store entry the chain ever has" do + store = Dexpace::ContextStore.new(cap: 8) + source = Dexpace::DispatchContext.build(bundle: NONE, store: store) + + assert_equal(0, store.size) + promoted = source.promote_to_request(request: :req) + + assert_equal(1, store.size) + assert_same(promoted, store[source.call_key]) + end + + # CTX-7's first clause -- "Contexts MUST be immutable and safe to share across threads + # without external synchronization" -- which Data supplies and which nothing else asserts. + test "CTX-7: every context flavour is frozen on construction" do + store = Dexpace::ContextStore.new(cap: 8) + dispatch = Dexpace::DispatchContext.build(bundle: NONE, store: store) + request = dispatch.promote_to_request(request: :req) + + assert_predicate(dispatch, :frozen?) + assert_predicate(request, :frozen?) + assert_predicate(request.promote_to_exchange(response: :resp), :frozen?) + end + + test "there is no reverse promotion: DispatchContext has no #promote_to_dispatch" do + refute_respond_to( + Dexpace::DispatchContext.build(bundle: NONE), :promote_to_dispatch, + ) + end + + # #with is not a promotion and cannot be used as one: it reaches only the members its own + # flavour declares, so #promote_to_request is the only way to add a request, which is what + # keeps CTX-17's registration on the promotion path and off the copy path. + test "#with cannot promote: an unknown member is refused, and a derivation keeps the key" do + store = Dexpace::ContextStore.new(cap: 8) + ctx = Dexpace::DispatchContext.build(bundle: NONE, store: store) + + assert_raises(ArgumentError) { ctx.with(request: :req) } + derived = ctx.with(bundle: NONE) + + assert_same(ctx.call_key, derived.call_key) + assert_equal(ctx, derived) + assert_equal(0, store.size) + end + end + + # CTX-4 (the key's frozenness), CTX-5 (the pinned key), and the construction pattern. + class ConstructionTest < DexpaceTestCase + # CTX-4's key is a frozen String on both paths: minted, and pinned by a caller. Verified fact + # 12 -- a frozen String is stored as a Hash key by identity, an unfrozen one is copied and + # frozen on every registration -- so this is correctness and one fewer allocation. + test "CTX-4: the call key is a frozen String whether it is minted or pinned" do + pinned = +"shared" + + assert_predicate(Dexpace::DispatchContext.build(bundle: NONE).call_key, :frozen?) + + ctx = Dexpace::DispatchContext.build(bundle: NONE, call_key: pinned) + + assert_predicate(ctx.call_key, :frozen?) + refute_same(pinned, ctx.call_key) + pinned << "-mutated" + + assert_equal("shared", ctx.call_key) + end + + test "bundle: is required with SEAM-29's message, on the minting path too" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::DispatchContext.build(bundle: nil) + end + + assert_equal("bundle is required", error.message) + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::DispatchContext.build(bundle: nil, call_key: "pinned") + end + assert_equal("bundle is required", error.message) + end + + test "an empty pinned key and a store without #set/#release are refused" do + assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::DispatchContext.build(bundle: NONE, call_key: "") + end + assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::DispatchContext.build(bundle: NONE, store: Object.new) + end + end + + test "the construction pattern: new is private and .build is the one way in" do + refute_respond_to(Dexpace::DispatchContext, :new) + assert_includes(Dexpace::DispatchContext.ancestors, Dexpace::Context) + assert_equal(%i[bundle call_key store], Dexpace::DispatchContext.members) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/context/exchange_context_test.rb b/gems/dexpace-core/test/dexpace/context/exchange_context_test.rb new file mode 100644 index 0000000..da792f0 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/context/exchange_context_test.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# CTX-1: the exchange stage is terminal. Conformance: the exchange type exposes no method +# promoting back. CTX-18 and CTX-19 from the chain's side: a real Response reaches the store and +# stays reachable until the close that evicts it. +class DexpaceExchangeContextTest < DexpaceTestCase + # The untraced bundle every case in this file builds from (CTX-15). + NONE = Dexpace::Instrumentation::Bundle::NONE + + test "CTX-1: ExchangeContext defines no promotion method at all" do + ctx = Dexpace::ExchangeContext.build( + bundle: NONE, request: :req, response: :resp, + ) + + refute_respond_to(ctx, :promote_to_request) + refute_respond_to(ctx, :promote_to_exchange) + refute_respond_to(ctx, :promote_to_dispatch) + assert_empty(Dexpace::ExchangeContext.public_instance_methods(false).grep(/\Apromote_to_/)) + assert_empty(Dexpace::ExchangeContext.public_instance_methods.grep(/\Apromote/)) + end + + test "an empty operation_name is rejected" do + assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::ExchangeContext.build( + bundle: NONE, request: :req, response: :resp, + operation_name: "", + ) + end + end + + test "request: and response: are required, with SEAM-29's one message form" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::ExchangeContext.build(bundle: NONE, request: :req, response: nil) + end + + assert_equal("response is required", error.message) + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::ExchangeContext.build(bundle: NONE, request: nil, response: :resp) + end + assert_equal("request is required", error.message) + end + + test "CTX-18: #close on an exchange context evicts it when it is the current occupant" do + store = Dexpace::ContextStore.new(cap: 8) + dispatch = Dexpace::DispatchContext.build(bundle: NONE, store: store) + exchange = dispatch.promote_to_request(request: :req).promote_to_exchange(response: :resp) + + assert(exchange.close) + assert_nil(store[dispatch.call_key]) + + refute(exchange.close) + end + + # Builds a real phase-1 Request and a real phase-3b Response, promotes a chain over them into + # `store`, and returns only the key: the two objects are then reachable from the store alone. + def register_real_exchange(store) + request_builder = Dexpace::Request.builder + request_builder.url = "https://example.test/pets" + request = request_builder.build + response_builder = Dexpace::Response.builder + response_builder.request = request + response_builder.protocol = Dexpace::Protocol::HTTP_1_1 + response_builder.status = 200 + response_builder.body = Dexpace::ResponseBody.new( + source: Dexpace::IO::BufferedSource.of_bytes("ok"), media_type: nil, + ) + Dexpace::DispatchContext.build(bundle: NONE, store: store) + .promote_to_request(request: request, operation_name: "ListPets") + .promote_to_exchange(response: response_builder.build) + .call_key + end + + # CTX-19's rationale, end to end over phase 1's and 3b's real types: the registered exchange + # context keeps its Request+Response graph -- a body included -- reachable until it is closed. + test "CTX-19: a registered exchange context keeps a real Request and Response reachable" do + store = Dexpace::ContextStore.new(cap: 8) + key = register_real_exchange(store) + + 3.times { GC.start } + + found = store[key] + + refute_nil(found) + assert_equal("https://example.test/pets", found.request.url.to_s) + assert_equal("ok", found.response.body_string) + assert_equal("ListPets", found.operation_name) + assert(found.close) + assert_nil(store[key]) + end + + test "CTX-17: off-chain construction of an exchange context registers nothing" do + store = Dexpace::ContextStore.new(cap: 8) + ctx = Dexpace::ExchangeContext.build( + bundle: NONE, request: :req, response: :resp, store: store, + ) + + assert_equal(0, store.size) + refute(ctx.close) + end + + test "the construction pattern: new is private and the members are the design's" do + refute_respond_to(Dexpace::ExchangeContext, :new) + assert_equal( + %i[bundle call_key store request operation_name response], + Dexpace::ExchangeContext.members, + ) + end +end diff --git a/gems/dexpace-core/test/dexpace/context/request_context_test.rb b/gems/dexpace-core/test/dexpace/context/request_context_test.rb new file mode 100644 index 0000000..15489a0 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/context/request_context_test.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# CTX-2 (the request -> exchange half), CTX-3, CTX-10, CTX-16, CTX-17. +# +# One class per behaviour group, because Metrics/ClassLength caps a class at 100 lines: the +# promotion here, CTX-16's operation name and the construction rules below. +class DexpaceRequestContextTest < DexpaceTestCase + # The untraced bundle every case in this file builds from (CTX-15). + NONE = Dexpace::Instrumentation::Bundle::NONE + + test "CTX-2: request -> exchange carries every source member forward by identity" do + store = Dexpace::ContextStore.new(cap: 8) + request = :the_request + source = Dexpace::RequestContext.build( + bundle: NONE, request: request, + operation_name: "GetUser", store: store, + ) + response = :the_response + + promoted = source.promote_to_exchange(response: response) + + assert_instance_of(Dexpace::ExchangeContext, promoted) + assert_same(source.bundle, promoted.bundle) + assert_same(source.call_key, promoted.call_key) + assert_same(source.store, promoted.store) + assert_same(source.request, promoted.request) + assert_same(source.operation_name, promoted.operation_name) + assert_same(response, promoted.response) + refute_respond_to(source, :response) + end + + test "CTX-3: the promotion overwrites the same store slot" do + store = Dexpace::ContextStore.new(cap: 8) + dispatch = Dexpace::DispatchContext.build(bundle: NONE, store: store) + request_ctx = dispatch.promote_to_request(request: :req) + + assert_equal(1, store.size) + assert_same(request_ctx, store[dispatch.call_key]) + + exchange_ctx = request_ctx.promote_to_exchange(response: :resp) + + assert_equal(1, store.size) + assert_same(exchange_ctx, store[dispatch.call_key]) + assert_same(dispatch.call_key, exchange_ctx.call_key) + end + + # CTX-10 from the chain's side: the intermediate was promoted, so its close is a no-op and the + # successor keeps the slot. + test "CTX-10: closing a promoted request context is a no-op; the exchange keeps the slot" do + store = Dexpace::ContextStore.new(cap: 8) + request_ctx = Dexpace::DispatchContext.build(bundle: NONE, store: store) + .promote_to_request(request: :req) + exchange_ctx = request_ctx.promote_to_exchange(response: :resp) + + refute(request_ctx.close) + assert_same(exchange_ctx, store[request_ctx.call_key]) + assert(exchange_ctx.close) + assert_nil(store[request_ctx.call_key]) + end + + test "there is no reverse promotion: RequestContext has no #promote_to_dispatch" do + refute_respond_to( + Dexpace::RequestContext.build(bundle: NONE, request: :req), + :promote_to_dispatch, + ) + end + + # CTX-16: an optional operation name, carried forward unchanged, advisory only. + class OperationNameTest < DexpaceTestCase + # CTX-16's advisory rule, asserted as a negative: the operation name influences none of the + # three things it is forbidden from influencing. A positive test that the name is carried + # forward proves nothing about those three. + test "CTX-16: operation_name changes neither the call key, the request, nor the slot" do + store = Dexpace::ContextStore.new(cap: 8) + request = :shared_request + dispatch_a = Dexpace::DispatchContext.build( + bundle: NONE, call_key: "shared", store: store, + ) + dispatch_b = Dexpace::DispatchContext.build( + bundle: NONE, call_key: "shared", store: store, + ) + + named = dispatch_a.promote_to_request(request: request, operation_name: "GetUser") + unnamed = dispatch_b.promote_to_request(request: request) + + assert_equal(named.call_key, unnamed.call_key) + assert_same(named.request, unnamed.request) + assert_same(unnamed, store["shared"]) + assert_equal(1, store.size) + end + + test "CTX-16: operation_name is carried forward unchanged across promotion" do + store = Dexpace::ContextStore.new(cap: 8) + source = Dexpace::RequestContext.build( + bundle: NONE, request: :req, + operation_name: "GetUser", store: store, + ) + + promoted = source.promote_to_exchange(response: :resp) + + assert_same(source.operation_name, promoted.operation_name) + end + + test "operation_name may be absent" do + ctx = Dexpace::RequestContext.build(bundle: NONE, request: :req) + + assert_nil(ctx.operation_name) + end + + # CTX-16 gives exactly two states -- "GetUser", or absent -- and "" is neither. + test "an empty operation_name is rejected, off-chain and at promotion" do + assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::RequestContext.build( + bundle: NONE, request: :req, operation_name: "", + ) + end + assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::DispatchContext.build(bundle: NONE, store: Dexpace::ContextStore.new(cap: 8)) + .promote_to_request(request: :req, operation_name: "") + end + end + + test "operation_name is frozen without aliasing the caller's string" do + name = +"GetUser" + ctx = Dexpace::RequestContext.build(bundle: NONE, request: :req, operation_name: name) + name << "!" + + assert_equal("GetUser", ctx.operation_name) + assert_predicate(ctx.operation_name, :frozen?) + end + end + + # CTX-5, CTX-17 and the construction pattern for the middle flavour. + class ConstructionTest < DexpaceTestCase + test "request: is required, with SEAM-29's one message form" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::RequestContext.build(bundle: NONE, request: nil) + end + + assert_equal("request is required", error.message) + end + + test "CTX-17: off-chain construction of a request context registers nothing" do + store = Dexpace::ContextStore.new(cap: 8) + ctx = Dexpace::RequestContext.build(bundle: NONE, request: :req, store: store) + + assert_equal(0, store.size) + refute(ctx.close) + end + + test "CTX-5: a pinned key restores value equality between two request contexts" do + a = Dexpace::RequestContext.build(bundle: NONE, request: :req, call_key: "shared") + b = Dexpace::RequestContext.build(bundle: NONE, request: :req, call_key: "shared") + + assert_equal(a, b) + refute_equal(a, Dexpace::RequestContext.build(bundle: NONE, request: :req)) + end + + test "the construction pattern: new is private and the members are the design's" do + refute_respond_to(Dexpace::RequestContext, :new) + assert_equal( + %i[bundle call_key store request operation_name], Dexpace::RequestContext.members, + ) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/context_store_test.rb b/gems/dexpace-core/test/dexpace/context_store_test.rb new file mode 100644 index 0000000..78be891 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/context_store_test.rb @@ -0,0 +1,324 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../test_helper" +require "dexpace" +require_relative "../support/fake_context" + +# CTX-7, CTX-8, CTX-9, CTX-10, CTX-11, CTX-12, CTX-13, CTX-18, CTX-19, XCUT-14, and the Fiber[] +# boundary docs/knowledge/notes/observability.md draws. Every store here is a fresh +# Dexpace::ContextStore.new(cap:), never .default: this suite passes alone and in any order and +# never touches the process-wide store. Every thread a case starts is joined before it returns, +# which DexpaceTestCase's teardown asserts. +# +# One class per behaviour group, because Metrics/ClassLength caps a class at 100 lines: the two +# operations and the eviction rules here, then Concurrency, Bound and Reachability below. +class DexpaceContextStoreTest < DexpaceTestCase + # THE trap. Two contexts equal by value and distinct by identity are constructible only with a + # pinned explicit call_key (CTX-5's escape hatch) -- FakeContext has no value equality at all + # (plain Object#==), so this is the one case in the suite that needs a real Data context. A test + # over two default-constructed contexts would pass against a ==-based release, because their + # keys differ and the value comparison would never fire. + test "CTX-9: release evicts only the reference-identical occupant, never a value-equal sibling" do + store = Dexpace::ContextStore.new(cap: 8) + bundle = Dexpace::Instrumentation::Bundle::NONE + first = Dexpace::DispatchContext.build(bundle: bundle, call_key: "shared", store: store) + second = Dexpace::DispatchContext.build(bundle: bundle, call_key: "shared", store: store) + + assert_equal(first, second) + refute_same(first, second) + + store.set(first) + + refute(store.release(second)) + assert_same(first, store[first.call_key]) + + assert(store.release(first)) + assert_nil(store[first.call_key]) + end + + test "CTX-8: #set is install-or-replace and never raises; #put is install-only-if-absent" do + store = Dexpace::ContextStore.new(cap: 8) + first = FakeContext.new(call_key: "k", store: store) + second = FakeContext.new(call_key: "k", store: store) + + assert_same(first, store.put(first)) + assert_same(second, store.set(second)) + assert_same(second, store["k"]) + + error = assert_raises(Dexpace::ContextConflictError) { store.put(first) } + + assert_equal("k", error.call_key) + assert_same(second, store["k"], "a losing #put leaves the occupant untouched") + end + + test "CTX-10: releasing a promoted intermediate is a no-op; the successor keeps the slot" do + store = Dexpace::ContextStore.new(cap: 8) + intermediate = FakeContext.new(call_key: "k", store: store) + successor = FakeContext.new(call_key: "k", store: store) + store.set(intermediate) + store.set(successor) + + refute(store.release(intermediate)) + assert_same(successor, store["k"]) + assert_equal(1, store.size) + end + + test "CTX-18: an unknown key resolves to nil and never raises" do + store = Dexpace::ContextStore.new(cap: 8) + + assert_nil(store["nope"]) + end + + test "CTX-18: double-close and cleanup-path close are well-defined no-ops" do + store = Dexpace::ContextStore.new(cap: 8) + ctx = FakeContext.new(call_key: "x", store: store) + + refute(store.release(ctx)) + + store.set(ctx) + + assert(store.release(ctx)) + refute(store.release(ctx)) + assert_equal(0, store.size) + end + + # Verified fact 12: a frozen String is stored as a Hash key by identity, an unfrozen one is + # duplicated and frozen. The store reads whatever #call_key answers, so a fake's unfrozen key + # still resolves -- the freezing is the contexts' own job (CTX-4) and not the store's. + test "the store resolves an unfrozen key the same as a frozen one" do + store = Dexpace::ContextStore.new(cap: 8) + ctx = FakeContext.new(call_key: +"mutable", store: store) + store.set(ctx) + + assert_same(ctx, store["mutable"]) + assert_same(ctx, store[+"mutable"]) + end + + # CTX-7 and CTX-8 under contention, and the Fiber[] boundary. + class ConcurrencyTest < DexpaceTestCase + test "CTX-7: 16 threads registering 1000 distinct keys each lose nothing" do + store = Dexpace::ContextStore.new(cap: 20_000) + + threads = Array.new(16) do |t| + ::Thread.new do + 1000.times { |i| store.set(FakeContext.new(call_key: "t#{t}-#{i}", store: store)) } + end + end + threads.each(&:join) + + assert_equal(16_000, store.size) + end + + # testing/80c44c7f: the raised object is asserted on, not rescued and ignored. + test "CTX-8: 32 threads racing one #put admit exactly one winner; every loser names the key" do + store = Dexpace::ContextStore.new(cap: 64) + gate = ::Thread::Queue.new + outcomes = ::Thread::Queue.new + + threads = Array.new(32) do + ::Thread.new do + gate.pop + outcomes << begin + store.put(FakeContext.new(call_key: "shared", store: store)) + :ok + rescue Dexpace::ContextConflictError => error + error + end + end + end + 32.times { gate << true } + threads.each(&:join) + results = Array.new(32) { outcomes.pop } + + winners = results.count { |r| r == :ok } + losers = results.grep(Dexpace::ContextConflictError) + + assert_equal(1, winners) + assert_equal(31, losers.size) + losers.each do |error| + assert_equal("shared", error.call_key) + assert_includes(error.message, "shared") + end + assert_equal(1, store.size) + end + + # The store is process-wide, not fiber-scoped: the line docs/knowledge/notes/observability.md + # draws between CTX's store and the Fiber[] diagnostic-context carrier. The setup guard is + # what makes the three assertions mean something -- without it, the store being visible + # everywhere would prove nothing about the three being distinct execution contexts. + # Fiber[:probe] = v emits no warning on any supported Ruby (design, verified fact 13); only + # Fiber#storage= does, and nothing here calls it. + test "the store is process-wide, not fiber-scoped -- the Fiber[] boundary" do + store = Dexpace::ContextStore.new(cap: 8) + ctx = FakeContext.new(call_key: "fiber-probe", store: store) + store.set(ctx) + + Fiber[:probe] = :main_fiber + + assert_equal(:main_fiber, Fiber[:probe]) + assert_nil(::Thread.current[:probe]) + assert_equal(:main_fiber, Fiber.new { Fiber[:probe] }.resume) + assert_nil(Fiber.new { ::Thread.current[:probe] }.resume) + + from_fiber = Fiber.new { store["fiber-probe"] }.resume + from_thread = nil + ::Thread.new { from_thread = store["fiber-probe"] }.join + from_enumerator = Enumerator.new { |y| y << store["fiber-probe"] }.next + + assert_same(ctx, from_fiber) + assert_same(ctx, from_thread) + assert_same(ctx, from_enumerator) + end + end + + # CTX-11, CTX-12, CTX-13 and XCUT-14: the bound, the drain and the victim policy. + class BoundTest < DexpaceTestCase + test "CTX-13: cap pressure evicts the first-registered; re-setting a key does not refresh it" do + store = Dexpace::ContextStore.new(cap: 3) + a = FakeContext.new(call_key: "a", store: store) + b = FakeContext.new(call_key: "b", store: store) + c = FakeContext.new(call_key: "c", store: store) + store.set(a) + store.set(b) + store.set(c) + + # Simulates a promotion overwriting "a"'s slot: Hash#[]= on an existing key does not move + # it (verified fact 8), so "a" is still the oldest registration and the first victim. + store.set(FakeContext.new(call_key: "a", store: store)) + store.set(FakeContext.new(call_key: "d", store: store)) + + assert_equal(3, store.size) + assert_nil(store["a"]) + assert_same(b, store["b"]) + assert_same(c, store["c"]) + refute_nil(store["d"]) + end + + test "CTX-13/CTX-18: nothing in the store's own behaviour depends on any entry surviving" do + store = Dexpace::ContextStore.new(cap: 1) + a = FakeContext.new(call_key: "a", store: store) + store.set(a) + store.set(FakeContext.new(call_key: "b", store: store)) + + refute(store.release(a)) + assert_equal(1, store.size) + end + + test "CTX-11/CTX-12/XCUT-14: the cap bound holds under concurrent inserts, from 16 threads" do + store = Dexpace::ContextStore.new(cap: 64) + + threads = Array.new(16) do |t| + ::Thread.new do + 500.times { |i| store.set(FakeContext.new(call_key: "#{t}-#{i}", store: store)) } + end + end + threads.each(&:join) + + assert_equal(64, store.size) + end + + # CTX-12/XCUT-14, and the assertion that is NOT the obvious one. The aggregate + # drain-iteration count (inserts - final size) is an arithmetic identity that any + # one-eviction-per-iteration drain reproduces, split-lock or no loop at all -- + # docs/knowledge/notes/execution-context.md records the measurement and the trap. What + # discriminates single-threaded is this: from a store already at cap, every further insert + # evicts EXACTLY ONE prior occupant and the size is never observed above the cap, which is + # the drain body running at most once per insert. It fails against a drain that evicts two + # per insert and against a #set path with no drain at all. + # + # What this canNOT reach, stated so nobody reads more into it: `while` vs a single `if` is + # behaviourally identical under the one-synchronize insert-and-drain (the loop is written + # because XCUT-14 makes it a MUST), and the split-lock overshoot the note measures is + # invisible through this class's public surface -- #size takes the same mutex. Proving + # CTX-7/CTX-8's drain needs a GVL-free interpreter: docs/first-release.md, Post-release + # triggers, the IO-38 row. + test "CTX-12/XCUT-14: from cap, each insert evicts exactly one, and size never exceeds cap" do + store = Dexpace::ContextStore.new(cap: 8) + seeds = Array.new(8) { |i| "seed-#{i}" } + seeds.each { |key| store.set(FakeContext.new(call_key: key, store: store)) } + + sizes = [] + evictions = [] + keys = seeds.dup + 100.times do |i| + before = keys.count { |key| store[key] } + store.set(FakeContext.new(call_key: "fill-#{i}", store: store)) + sizes << store.size + evictions << (before - keys.count { |key| store[key] }) + keys << "fill-#{i}" + end + + assert_equal([8], sizes.uniq) + assert_equal([1], evictions.uniq) + end + + # XCUT-14 says "after each insert", and #put is an insert. Without this case the drain can + # be deleted from the reject-on-duplicate path with the whole suite still green. + test "CTX-8/CTX-11/XCUT-14: the reject-on-duplicate insert drains to the cap too" do + store = Dexpace::ContextStore.new(cap: 3) + + 6.times { |i| store.put(FakeContext.new(call_key: "p#{i}", store: store)) } + + assert_equal(3, store.size) + assert_nil(store["p0"]) + refute_nil(store["p5"]) + end + + # P4-9's number, asserted rather than assumed: AUTH-19's stated default for a store of this + # shape is the one number the specification supplies, and phase 5a (Task 13) attaches a + # configuration source to it without changing a signature. + test "CTX-11: MAX_TRACKED_CONTEXTS is 1024 and is the cap a default-constructed store uses" do + assert_equal(1024, Dexpace::ContextStore::MAX_TRACKED_CONTEXTS) + assert_predicate(Dexpace::ContextStore::MAX_TRACKED_CONTEXTS, :frozen?) + + store = Dexpace::ContextStore.new + + 2000.times { |i| store.set(FakeContext.new(call_key: "d#{i}", store: store)) } + + assert_equal(Dexpace::ContextStore::MAX_TRACKED_CONTEXTS, store.size) + end + + # A cap of 0 would evict every entry including the one just inserted, and a negative cap + # would make `shift while size > cap` spin forever on an empty hash: both are refused at + # construction, with SEAM-29's message for the absent case. + test "cap: must be a positive Integer, with SEAM-29's one message form when absent" do + error = assert_raises(Dexpace::InvalidArgumentError) { Dexpace::ContextStore.new(cap: nil) } + + assert_equal("cap is required", error.message) + assert_raises(Dexpace::InvalidArgumentError) { Dexpace::ContextStore.new(cap: 0) } + assert_raises(Dexpace::InvalidArgumentError) { Dexpace::ContextStore.new(cap: -1) } + assert_raises(Dexpace::InvalidArgumentError) { Dexpace::ContextStore.new(cap: 1.5) } + end + end + + # CTX-19 and the process-wide instance. + class ReachabilityTest < DexpaceTestCase + # A discriminator against ObjectSpace::WeakMap and NOT a tautology: the same run against a + # WeakMap returns 0 of 1000 after three GC.starts on all three interpreters (design, verified + # fact 3). It is equally NOT a discriminator against ObjectSpace::WeakKeyMap, which passes it + # -- a WeakKeyMap holds its values strongly and the stored context strongly holds the frozen + # String that is its key, so no entry is ever collectable (verified fact 3a). That spelling + # is forbidden by Dexpace/NoWeakReferences and by nothing here. It names no ObjectSpace + # constant, so it runs identically on 3.2.11, where WeakKeyMap is undefined. + test "CTX-19: 1000 registered contexts stay reachable after the caller drops every local" do + store = Dexpace::ContextStore.new(cap: 2048) + keys = Array.new(1000) { |i| "k#{i}" } + keys.each { |key| store.set(FakeContext.new(call_key: key, store: store)) } + + 3.times { GC.start } + + assert_equal(1000, store.size) + refute_nil(store[keys.first]) + refute_nil(store[keys.last]) + end + + # .default is the one process-wide instance, assigned at file load rather than memoised on + # first call; this suite never writes to it (CTX-17 keeps construction off the store), so + # the only things asserted are its identity across calls and that it is a store. + test ".default is one process-wide instance, the same object on every call" do + assert_same(Dexpace::ContextStore.default, Dexpace::ContextStore.default) + assert_instance_of(Dexpace::ContextStore, Dexpace::ContextStore.default) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/context_test.rb b/gems/dexpace-core/test/dexpace/context_test.rb new file mode 100644 index 0000000..72e7aec --- /dev/null +++ b/gems/dexpace-core/test/dexpace/context_test.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../test_helper" +require "dexpace" +require_relative "../support/fake_context" + +# CTX-9, CTX-10, CTX-18: #close is `store.release(self)`, and every clause of all three is a +# property of the store, not of Context itself. The private construction validation every +# flavour's #initialize runs is driven here through an includer shaped exactly like the three. +class DexpaceContextTest < DexpaceTestCase + # The minimal store double: an object answering #set and #release, which is the whole of what a + # context asks of its store (api-design/88e6bf12, the narrowest duck type). + class FakeStore + attr_accessor :released_with + + def set(context) = context + + def release(context) + self.released_with = context + context + end + end + + # A Data includer in the flavours' own shape -- the validation before `super` -- so the private + # #validate_context! is exercised the way DispatchContext, RequestContext and ExchangeContext + # exercise it, and stays off the public surface. + class ProbeContext < Data.define(:bundle, :call_key, :store) + include Dexpace::Context + + def initialize(bundle:, call_key:, store:) + validate_context!(bundle: bundle, call_key: call_key, store: store) + + super + end + end + + test "#close delegates to store.release(self)" do + store = FakeStore.new + ctx = FakeContext.new(call_key: "k", store: store) + + assert_same(ctx, ctx.close) + assert_same(ctx, store.released_with) + end + + test "#close returns what the store returned, so CTX-10's no-op is observable" do + store = FakeStore.new + store.define_singleton_method(:release) { |_context| false } + ctx = FakeContext.new(call_key: "k", store: store) + + refute(ctx.close) + end + + test "the validation requires a non-nil bundle, with SEAM-29's one message form" do + error = assert_raises(Dexpace::InvalidArgumentError) do + ProbeContext.new(bundle: nil, call_key: "k", store: FakeStore.new) + end + + assert_equal("bundle is required", error.message) + end + + test "the validation requires a non-nil, non-empty call_key" do + store = FakeStore.new + + error = assert_raises(Dexpace::InvalidArgumentError) do + ProbeContext.new(bundle: :b, call_key: nil, store: store) + end + assert_equal("call_key is required", error.message) + + assert_raises(Dexpace::InvalidArgumentError) do + ProbeContext.new(bundle: :b, call_key: "", store: store) + end + end + + test "the validation requires a store that responds to #set and #release" do + bad_store = Object.new + only_set = Object.new.tap { |o| o.define_singleton_method(:set) { |c| c } } + + assert_raises(Dexpace::InvalidArgumentError) do + ProbeContext.new(bundle: :b, call_key: "k", store: bad_store) + end + assert_raises(Dexpace::InvalidArgumentError) do + ProbeContext.new(bundle: :b, call_key: "k", store: only_set) + end + end + + test "the validation passes for a store responding to both, and the instance is frozen" do + ctx = ProbeContext.new(bundle: :b, call_key: "k", store: FakeStore.new) + + assert_equal("k", ctx.call_key) + assert_predicate(ctx, :frozen?) + end + + # The design gives Context exactly one public method, #close (P4-11); the validation is + # private and takes no surface-manifest row. + test "Context's public surface is #close alone" do + assert_equal([:close], Dexpace::Context.public_instance_methods(false)) + assert_includes(Dexpace::Context.private_instance_methods(false), :validate_context!) + assert_empty(Dexpace::Context.singleton_methods) + end + + # The module includes Dexpace::Model, so every flavour gets the validating #with by inclusion + # (P1-4) -- the two-level ancestry verified fact 14 rests on. + test "Context includes Dexpace::Model, so an includer's #with is Model's" do + assert_includes(Dexpace::Context.ancestors, Dexpace::Model) + assert_equal(Dexpace::Model, FakeContext.instance_method(:with).owner) + assert_equal(Dexpace::Model, ProbeContext.instance_method(:with).owner) + end +end diff --git a/gems/dexpace-core/test/dexpace/error/context_conflict_error_test.rb b/gems/dexpace-core/test/dexpace/error/context_conflict_error_test.rb new file mode 100644 index 0000000..78e5e29 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/error/context_conflict_error_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# CTX-8: the reject-on-duplicate loser's error names the key. +class DexpaceContextConflictErrorTest < DexpaceTestCase + test "is caught by rescue Dexpace::Error through Module#===" do + caught = begin + raise Dexpace::ContextConflictError, "k1" + rescue Dexpace::Error => error + error + end + + assert_instance_of(Dexpace::ContextConflictError, caught) + end + + test "the message identifies the key, per CTX-8's own words" do + error = Dexpace::ContextConflictError.new("call-key-42") + + assert_equal("call-key-42", error.call_key) + assert_includes(error.message, "call-key-42") + end + + test "is not Dexpace::InvalidArgumentError -- a lost race is not an invalid argument" do + refute_operator(Dexpace::ContextConflictError, :<, Dexpace::InvalidArgumentError) + end + + # Phase 2's three seam errors are all `< ::StandardError` with the marker included; the fourth + # follows, so a `rescue StandardError` at an application boundary still sees it. + test "is a StandardError carrying the Dexpace::Error marker, like phase 2's three" do + assert_operator(Dexpace::ContextConflictError, :<, ::StandardError) + assert_includes(Dexpace::ContextConflictError.ancestors, Dexpace::Error) + end +end diff --git a/gems/dexpace-core/test/dexpace/instrumentation/bundle_test.rb b/gems/dexpace-core/test/dexpace/instrumentation/bundle_test.rb new file mode 100644 index 0000000..5e1f86a --- /dev/null +++ b/gems/dexpace-core/test/dexpace/instrumentation/bundle_test.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# CTX-14, CTX-15, OBS-26, OBS-27; P4-6 (validity derived) and P4-7 (the two sentinels split). +# +# One class per behaviour group, because Metrics/ClassLength caps a class at 100 lines: the +# sentinels and the required members here, then Validation and Ownership below. +class DexpaceInstrumentationBundleTest < DexpaceTestCase + Bundle = Dexpace::Instrumentation::Bundle + Flavour = Dexpace::Instrumentation::TraceIdFlavour + W3C = Flavour::W3C + + test "CTX-15: Bundle::NONE carries OBS-26's reserved sentinels, isValid false, isRemote false" do + bundle = Bundle::NONE + + assert_equal("0" * 32, bundle.trace_id) + assert_equal("0" * 16, bundle.span_id) + assert_equal("00", bundle.trace_flags) + assert_equal([], bundle.trace_state) + assert_same(Flavour::NONE, bundle.flavour) + refute_predicate(bundle, :valid?) + refute_predicate(bundle, :remote?) + refute(bundle.remote) + assert_same(Dexpace::Instrumentation::NO_SPAN, bundle.span) + assert_same(Dexpace::Instrumentation::NO_TRACER_FACTORY, bundle.tracer_factory) + end + + # CTX-14's nine: eight stored members and the derived ninth. The member list is the contract + # phase 5 may not widen (roadmap obligation 1; the design's handshake, clause 4). + test "CTX-14: exactly eight stored members, in the design's order, and validity derived" do + assert_equal( + %i[trace_id span_id trace_flags trace_state flavour remote span tracer_factory], + Bundle.members, + ) + refute_includes(Bundle.members, :valid) + assert_respond_to(Bundle::NONE, :valid?) + end + + test "trace_id:, span_id: and flavour: are required -- there is no silent untraced default" do + error = assert_raises(ArgumentError) do + Bundle.build(span_id: "a" * 16, flavour: W3C) + end + + # Both halves are load-bearing. Dexpace::InvalidArgumentError IS an ::ArgumentError (phase 1, + # P1-3), so `assert_raises(ArgumentError)` alone passes just as well against a .build that + # defaulted trace_id: to nil and let Model.required! reject it -- which is exactly the silent + # untraced default this test exists to forbid. Ruby's own missing-keyword failure is the + # assertion; the refute is what makes it discriminating. + refute_kind_of(Dexpace::InvalidArgumentError, error) + assert_includes(error.message, "missing keyword") + assert_raises(ArgumentError) { Bundle.build(trace_id: "a" * 32, flavour: W3C) } + assert_raises(ArgumentError) { Bundle.build(trace_id: "a" * 32, span_id: "b" * 16) } + end + + test "OBS-26: an all-zero span id makes a bundle invalid even when its trace id is real" do + bundle = Bundle.build( + trace_id: "a" * 32, span_id: Bundle::INVALID_SPAN_ID, flavour: W3C, + ) + + refute_predicate(bundle, :valid?) + end + + test "OBS-26: an all-zero trace id makes a bundle invalid even when its span id is real" do + bundle = Bundle.build(trace_id: W3C.invalid_trace_id, span_id: "b" * 16, flavour: W3C) + + refute_predicate(bundle, :valid?) + end + + # OBS-26's per-member validation and the flavour rule. + class ValidationTest < DexpaceTestCase + test "OBS-26: span_id, trace_flags and trace_state are each validated on their own" do + base = { trace_id: "a" * 32, span_id: "b" * 16, flavour: W3C } + + assert_raises(Dexpace::InvalidArgumentError) { Bundle.build(**base, span_id: "B" * 16) } + assert_raises(Dexpace::InvalidArgumentError) { Bundle.build(**base, span_id: "b" * 15) } + assert_raises(Dexpace::InvalidArgumentError) { Bundle.build(**base, trace_flags: "0") } + assert_raises(Dexpace::InvalidArgumentError) { Bundle.build(**base, trace_flags: "ZZ") } + assert_raises(Dexpace::InvalidArgumentError) { Bundle.build(**base, trace_flags: "0A") } + assert_raises(Dexpace::InvalidArgumentError) { Bundle.build(**base, trace_state: [["v"]]) } + assert_raises(Dexpace::InvalidArgumentError) do + Bundle.build(**base, trace_state: [%w[vendor value], :not_a_pair]) + end + assert_raises(Dexpace::InvalidArgumentError) do + Bundle.build(**base, trace_state: [["vendor", 1]]) + end + assert_raises(Dexpace::InvalidArgumentError) { Bundle.build(**base, trace_state: "a=b") } + assert_raises(Dexpace::InvalidArgumentError) { Bundle.build(**base, flavour: :w3c) } + end + + test "the wire forms are accepted: sampled flags, and a vendor trace-state list" do + bundle = Bundle.build( + trace_id: "a" * 32, span_id: "b" * 16, flavour: W3C, trace_flags: "01", + trace_state: [%w[vendor value], %w[other v2]], remote: true, + ) + + assert_equal("01", bundle.trace_flags) + assert_equal([%w[vendor value], %w[other v2]], bundle.trace_state) + assert_predicate(bundle, :remote?) + assert_predicate(bundle, :valid?) + end + + test "CTX-7: a bundle is frozen on construction, Bundle::NONE included" do + assert_predicate(Bundle::NONE, :frozen?) + assert_predicate( + Bundle.build(trace_id: "a" * 32, span_id: "b" * 16, flavour: W3C), + :frozen?, + ) + end + + test "an explicit nil trace_id gets SEAM-29's message, not a missing-keyword error" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Bundle.build(trace_id: nil, span_id: "a" * 16, flavour: W3C) + end + + assert_equal("trace_id is required", error.message) + error = assert_raises(Dexpace::InvalidArgumentError) do + Bundle.build(trace_id: "a" * 32, span_id: "a" * 16, flavour: nil) + end + assert_equal("flavour is required", error.message) + end + + test "a valid W3C bundle reports valid? true" do + bundle = Bundle.build(trace_id: "a" * 32, span_id: "b" * 16, flavour: W3C) + + assert_predicate(bundle, :valid?) + end + + # OBS-26/OBS-27 together: the flavour governs the trace id only, so a Datadog bundle carries a + # decimal trace id and a hex span id -- odd to read, and what the two requirements say. + test "a DATADOG bundle carries a decimal trace id and a 16-hex span id" do + bundle = Bundle.build(trace_id: "42", span_id: "b" * 16, flavour: Flavour::DATADOG) + + assert_predicate(bundle, :valid?) + assert_raises(Dexpace::InvalidArgumentError) do + Bundle.build(trace_id: "42", span_id: "42", flavour: Flavour::DATADOG) + end + refute_predicate(Bundle.build(trace_id: "0", span_id: "b" * 16, flavour: Flavour::DATADOG), + :valid?,) + end + + test "a malformed span_id is rejected even though valid? would not have caught it" do + assert_raises(Dexpace::InvalidArgumentError) do + Bundle.build(trace_id: "a" * 32, span_id: "not-hex", flavour: W3C) + end + end + + test "a trace_id that does not match its flavour is rejected, naming the flavour" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Bundle.build(trace_id: "42", span_id: "b" * 16, flavour: W3C) + end + + assert_includes(error.message, ":w3c") + assert_raises(Dexpace::InvalidArgumentError) do + Bundle.build(trace_id: "a" * 32, span_id: "b" * 16, flavour: Flavour::NONE) + end + end + end + + # XCUT-15's ownership, the round trip, #with and the two constants. + class OwnershipTest < DexpaceTestCase + # XCUT-15: the model owns its collection outright. Model.own deep-copies then deep-freezes, + # so the caller's array is untouched and the bundle's is frozen at every level -- which Data's + # own shallow freeze would not do (design, verified fact 15). + test "trace_state is deep-frozen through Model.own, independent of the caller's array" do + source = [%w[vendor value]] + bundle = Bundle.build( + trace_id: "a" * 32, span_id: "b" * 16, flavour: W3C, trace_state: source, + ) + source << %w[other value] + source.first << "extra" + + assert_equal([%w[vendor value]], bundle.trace_state) + assert_predicate(bundle.trace_state, :frozen?) + assert_predicate(bundle.trace_state.first, :frozen?) + assert_predicate(bundle.trace_state.first.first, :frozen?) + assert_same(bundle.trace_state, bundle.trace_state) + end + + test "the string members are frozen without aliasing the caller's strings" do + trace_id = +"a" * 32 + bundle = Bundle.build( + trace_id: trace_id, span_id: +"b" * 16, flavour: W3C, trace_flags: +"01", + ) + trace_id << "z" + + assert_equal("a" * 32, bundle.trace_id) + assert_predicate(bundle.trace_id, :frozen?) + assert_predicate(bundle.span_id, :frozen?) + assert_predicate(bundle.trace_flags, :frozen?) + end + + # testing/f36a19cd's round trip, over both hex flavours and the sentinel or a real id. + test "Bundle.build(**bundle.to_h) == bundle for every generated bundle" do + sample(count: 32, seed: 20_260_908) do |rng| + flavour = rng.rand < 0.5 ? W3C : Flavour::DATADOG + real = flavour == W3C ? "a" * 32 : "42" + trace_id = rng.rand < 0.5 ? real : flavour.invalid_trace_id + span_id = rng.rand < 0.5 ? "b" * 16 : Bundle::INVALID_SPAN_ID + bundle = Bundle.build( + trace_id: trace_id, span_id: span_id, flavour: flavour, + trace_flags: rng.rand < 0.5 ? "00" : "01", remote: rng.rand < 0.5, + ) + + assert_equal(bundle, Bundle.build(**bundle.to_h)) + assert_equal(bundle.hash, Bundle.build(**bundle.to_h).hash) + end + end + + test "#with preserves member identity for members it is not handed" do + bundle = Bundle.build(trace_id: "a" * 32, span_id: "b" * 16, flavour: W3C) + changed = bundle.with(remote: true) + + assert_same(bundle.span, changed.span) + assert_same(bundle.tracer_factory, changed.tracer_factory) + assert_same(bundle.trace_id, changed.trace_id) + assert_predicate(changed, :remote?) + refute_predicate(bundle, :remote?) + end + + # P1-4: #with routes through the validating .build on every supported Ruby, which is what lets + # phase 5 populate through Bundle::NONE.with(...) and get the same validation as .build. + test "#with re-validates through .build: NONE.with(trace_id: real) is refused by NONE" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Bundle::NONE.with(trace_id: "a" * 32) + end + + assert_includes(error.message, ":none") + populated = Bundle::NONE.with(trace_id: "a" * 32, span_id: "b" * 16, flavour: W3C) + + assert_predicate(populated, :valid?) + refute_predicate(Bundle::NONE, :valid?) + end + + test "INVALID_SPAN_ID is OBS-26's 16 hex zeros, frozen, and the same across flavours" do + assert_equal("0" * 16, Bundle::INVALID_SPAN_ID) + assert_predicate(Bundle::INVALID_SPAN_ID, :frozen?) + assert_equal( + Bundle::INVALID_SPAN_ID, + Bundle.build(trace_id: "42", span_id: "0" * 16, flavour: Flavour::DATADOG).span_id, + ) + end + + test "the construction pattern: new is private and .build is the one way in" do + refute_respond_to(Bundle, :new) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/instrumentation/no_span_test.rb b/gems/dexpace-core/test/dexpace/instrumentation/no_span_test.rb new file mode 100644 index 0000000..b88d5db --- /dev/null +++ b/gems/dexpace-core/test/dexpace/instrumentation/no_span_test.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# CTX-14's "an active span" slot and CTX-15's "a no-op span": one shared, frozen object, so +# OBS-25's "MUST NOT allocate per call" is assertable by reference identity (phase 5c gives its +# class the protocol; phase 4 fixes the object's identity). +class DexpaceInstrumentationNoSpanTest < DexpaceTestCase + test "NO_SPAN is a single frozen instance" do + assert_predicate(Dexpace::Instrumentation::NO_SPAN, :frozen?) + assert_same(Dexpace::Instrumentation::NO_SPAN, Dexpace::Instrumentation::NO_SPAN) + end + + # The object is public by identity; its class is a private_constant, so phase 5 can widen it + # without an NFR-4 diff and nothing can build a second one. + test "NO_SPAN's class is a private_constant, reachable by no qualified name" do + refute_includes(Dexpace::Instrumentation.constants(false), :NoSpan) + assert_raises(::NameError) { Dexpace::Instrumentation::NoSpan } + end + + # Phase 4 fixes the slot and not the protocol: the object answers nothing beyond Object's own + # surface, and the RBS interface _Span is empty on purpose. + test "NO_SPAN responds to nothing beyond Object's own surface" do + assert_empty(Dexpace::Instrumentation::NO_SPAN.class.public_instance_methods(false)) + end +end diff --git a/gems/dexpace-core/test/dexpace/instrumentation/no_tracer_test.rb b/gems/dexpace-core/test/dexpace/instrumentation/no_tracer_test.rb new file mode 100644 index 0000000..855cc37 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/instrumentation/no_tracer_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# CTX-20: the no-op tracer factory's method MUST be safe to invoke concurrently, and OBS-25's +# "MUST NOT allocate per call" is what the identity assertions below are for. CTX-14's "a +# per-operation tracer factory" slot and CTX-15's "no-op tracer factory". +class DexpaceInstrumentationNoTracerTest < DexpaceTestCase + # P4-8 and the plan's open question 1: the point of the mirrored signature is CALL + # COMPATIBILITY, and the call shapes alone do not assert it. On Ruby 3.x a method that accepts + # no keyword parameters receives `tracer(name: "n")` as a positional Hash, so every keyword call + # below passes just as well against `def tracer(a = nil, b = nil)` -- the whole keyword half of + # the gem's signature can be deleted with the call-shape assertions still green. The parameter + # list is the discriminating assertion; the call shapes are the regression test for what + # callers write. Read from opentelemetry-api 1.11.0's + # lib/opentelemetry/trace/tracer_provider.rb, fetched from rubygems.org and re-read on + # 2026-09-16, still the latest release. + test "the factory method mirrors opentelemetry-api 1.11.0's parameter list exactly" do + assert_equal( + [%i[opt deprecated_name], %i[opt deprecated_version], + %i[key name], %i[key version], %i[key attributes],], + Dexpace::Instrumentation::NO_TRACER_FACTORY.method(:tracer).parameters, + ) + end + + test "the factory method accepts opentelemetry-api 1.11.0's own positional and keyword shape" do + factory = Dexpace::Instrumentation::NO_TRACER_FACTORY + + assert_same(Dexpace::Instrumentation::NO_TRACER, factory.tracer) + assert_same(Dexpace::Instrumentation::NO_TRACER, factory.tracer("name")) + assert_same(Dexpace::Instrumentation::NO_TRACER, factory.tracer("name", "1.0")) + assert_same( + Dexpace::Instrumentation::NO_TRACER, + factory.tracer(name: "name", version: "1.0", attributes: {}), + ) + assert_same(Dexpace::Instrumentation::NO_TRACER, factory.tracer("name", name: "other")) + end + + test "CTX-20: 16 threads calling #tracer concurrently all get the same object" do + factory = Dexpace::Instrumentation::NO_TRACER_FACTORY + results = Array.new(16) + + threads = Array.new(16) { |i| ::Thread.new { results[i] = factory.tracer("t#{i}") } } + threads.each(&:join) + + results.each { |tracer| assert_same(Dexpace::Instrumentation::NO_TRACER, tracer) } + end + + test "both singletons are frozen, and neither class is reachable by a qualified name" do + assert_predicate(Dexpace::Instrumentation::NO_TRACER, :frozen?) + assert_predicate(Dexpace::Instrumentation::NO_TRACER_FACTORY, :frozen?) + refute_includes(Dexpace::Instrumentation.constants(false), :NoTracer) + refute_includes(Dexpace::Instrumentation.constants(false), :NoTracerFactory) + assert_raises(::NameError) { Dexpace::Instrumentation::NoTracerFactory } + end + + # Phase 4 fixes the slot and not the protocol: the tracer answers nothing beyond Object's own + # surface, and the factory answers exactly the one method CTX-20's embedded MUST forces. + test "NO_TRACER responds to nothing beyond Object; the factory defines exactly #tracer" do + assert_empty(Dexpace::Instrumentation::NO_TRACER.class.public_instance_methods(false)) + assert_equal( + [:tracer], + Dexpace::Instrumentation::NO_TRACER_FACTORY.class.public_instance_methods(false), + ) + end +end diff --git a/gems/dexpace-core/test/dexpace/instrumentation/trace_id_flavour_test.rb b/gems/dexpace-core/test/dexpace/instrumentation/trace_id_flavour_test.rb new file mode 100644 index 0000000..6551dfa --- /dev/null +++ b/gems/dexpace-core/test/dexpace/instrumentation/trace_id_flavour_test.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# OBS-27's trace-id flavours and CTX-14's "a trace-id encoding flavor" slot; P4-7's split of the +# reserved trace-id sentinel from the span-id one. +# +# One class per behaviour group, because Metrics/ClassLength caps a class at 100 lines: the table +# and the two predicates here, each flavour's own rule and the construction pattern below. +class DexpaceInstrumentationTraceIdFlavourTest < DexpaceTestCase + Flavour = Dexpace::Instrumentation::TraceIdFlavour + + FLAVOURS = [ + Flavour::NONE, + Flavour::W3C, + Flavour::DATADOG, + ].freeze + + VALID_SAMPLES = { + Flavour::NONE => [], + Flavour::W3C => ["a" * 32, "0123456789abcdef" * 2], + Flavour::DATADOG => %w[1 42 18446744073709551615], + }.freeze + + INVALID_SAMPLES = { + Flavour::NONE => ["a" * 32, "0", ""], + Flavour::W3C => ["A" * 32, "a" * 31, "a" * 33, "", "0"], + Flavour::DATADOG => ["1a", "", "0" * 32, "184467440737095516150", "99999999999999999999"], + }.freeze + + test ".of round-trips a recognised name" do + FLAVOURS.each { |flavour| assert_same(flavour, Flavour.of(flavour.name)) } + end + + test ".of raises Dexpace::InvalidArgumentError on an unrecognised name, like Protocol.parse" do + error = assert_raises(Dexpace::InvalidArgumentError) { Flavour.of(:bogus) } + + assert_includes(error.message, ":bogus") + assert_raises(Dexpace::InvalidArgumentError) { Flavour.of("w3c") } + end + + test "ALL is the frozen closed set, in the design's order" do + assert_equal(FLAVOURS, Flavour::ALL) + assert_predicate(Flavour::ALL, :frozen?) + assert_equal(%i[none w3c datadog], Flavour::ALL.map(&:name)) + end + + test "NONE's sentinel is OBS-26's 32 hex zeros, not a flavour-specific zero draw" do + assert_equal("0" * 32, Flavour::NONE.invalid_trace_id) + assert_equal("0" * 32, Flavour::W3C.invalid_trace_id) + end + + test "DATADOG's own zero draw is '0', distinct from OBS-26's sentinel (P4-7)" do + assert_equal("0", Flavour::DATADOG.invalid_trace_id) + end + + test "NONE renders only the sentinel: a disabled-tracing bundle carries no trace id of its own" do + assert(Flavour::NONE.renders?("0" * 32)) + refute(Flavour::NONE.valid_trace_id?("0" * 32)) + refute(Flavour::NONE.renders?("a" * 32)) + refute(Flavour::NONE.renders?("0")) + end + + # testing/f36a19cd: a parse-constructor invariant gets a round-trip property test. + # #renders?(x) == (#valid_trace_id?(x) || x == invalid_trace_id) for every generated x, and the + # two predicates disagree at exactly one input: the flavour's own sentinel. + test "renders? and valid_trace_id? agree everywhere except at the sentinel" do + sample(count: 64, seed: 20_260_908) do |rng| + flavour = FLAVOURS.sample(random: rng) + candidates = [flavour.invalid_trace_id] + VALID_SAMPLES[flavour] + INVALID_SAMPLES[flavour] + candidate = candidates.sample(random: rng) + + assert_equal( + flavour.valid_trace_id?(candidate) || candidate == flavour.invalid_trace_id, + flavour.renders?(candidate), + "#{flavour.name}: #{candidate.inspect}", + ) + end + end + + test "the sentinel is the one input where the two predicates disagree" do + FLAVOURS.each do |flavour| + assert(flavour.renders?(flavour.invalid_trace_id)) + refute(flavour.valid_trace_id?(flavour.invalid_trace_id)) + end + end + + test "every valid sample is valid and renders; every invalid sample is neither" do + FLAVOURS.each do |flavour| + VALID_SAMPLES[flavour].each do |trace_id| + assert(flavour.valid_trace_id?(trace_id), "#{flavour.name}: #{trace_id.inspect}") + assert(flavour.renders?(trace_id), "#{flavour.name}: #{trace_id.inspect}") + end + INVALID_SAMPLES[flavour].each do |trace_id| + refute(flavour.valid_trace_id?(trace_id), "#{flavour.name}: #{trace_id.inspect}") + refute(flavour.renders?(trace_id), "#{flavour.name}: #{trace_id.inspect}") + end + end + end + + # OBS-26 and OBS-27, per flavour, and the construction pattern. + class PerFlavourTest < DexpaceTestCase + test "DATADOG accepts a 64-bit decimal rendering and nothing wider" do + datadog = Flavour::DATADOG + + assert(datadog.valid_trace_id?("18446744073709551615")) + refute(datadog.valid_trace_id?("184467440737095516150")) + refute(datadog.valid_trace_id?("1a")) + refute(datadog.valid_trace_id?("")) + end + + # OBS-27's Datadog flavour is "a 64-bit unsigned integer rendered as a decimal string", and a + # digit count cannot express that bound: \A[0-9]{1,20}\z accepts both values below, and only + # the second is out of range. The bound is max_value on the flavour, so this is the one test + # that fails if it is dropped -- the digit-count test above passes either way. + test "OBS-27: DATADOG rejects a 20-digit value above 2**64 - 1, at the boundary" do + datadog = Flavour::DATADOG + + assert(datadog.valid_trace_id?(((2**64) - 1).to_s)) + refute(datadog.valid_trace_id?((2**64).to_s)) + refute(datadog.valid_trace_id?("99999999999999999999")) + refute(datadog.renders?("99999999999999999999")) + assert_equal((2**64) - 1, datadog.max_value) + assert_nil(Flavour::W3C.max_value) + assert_nil(Flavour::NONE.max_value) + end + + # OBS-26: "32 lowercase hex chars". Lowercase-only and rejected rather than folded -- a fold + # would silently accept 0A where the wire form is 0a (Dexpace/NoLocaleCaseFold has nothing to + # bite on here because nothing folds). + test "W3C accepts exactly 32 lowercase hex characters" do + assert(Flavour::W3C.valid_trace_id?("a" * 32)) + assert(Flavour::W3C.valid_trace_id?("0123456789abcdef" * 2)) + refute(Flavour::W3C.valid_trace_id?("A" * 32)) + refute(Flavour::W3C.valid_trace_id?("a" * 31)) + refute(Flavour::W3C.valid_trace_id?("a" * 33)) + refute(Flavour::W3C.valid_trace_id?("g" * 32)) + end + + test "every flavour is a frozen Data with its pattern built with a per-pattern timeout" do + FLAVOURS.each do |flavour| + assert_predicate(flavour, :frozen?) + assert_predicate(flavour.invalid_trace_id, :frozen?) + refute_nil(flavour.trace_id_pattern.timeout, "#{flavour.name}: no per-pattern timeout") + assert_in_delta(1.0, flavour.trace_id_pattern.timeout) + end + assert_nil(Regexp.timeout, "no process-global regexp budget is imposed on the host") + end + + test "the construction pattern: private new, validating build, and #with through build" do + refute_respond_to(Flavour, :new) + assert_raises(Dexpace::InvalidArgumentError) do + Flavour.build(name: nil, trace_id_pattern: /x/, invalid_trace_id: "0") + end + error = assert_raises(Dexpace::InvalidArgumentError) do + Flavour::W3C.with(invalid_trace_id: nil) + end + + assert_equal("invalid_trace_id is required", error.message) + derived = Flavour::W3C.with(name: :other) + + assert_equal(:other, derived.name) + assert_same(Flavour::W3C.trace_id_pattern, derived.trace_id_pattern) + end + end +end diff --git a/gems/dexpace-core/test/support/fake_context.rb b/gems/dexpace-core/test/support/fake_context.rb new file mode 100644 index 0000000..a43b3c4 --- /dev/null +++ b/gems/dexpace-core/test/support/fake_context.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../lib/dexpace/context" + +# The one double CTX's store suite needs: every store rule (CTX-7 through CTX-13, CTX-18, CTX-19) +# is about a keyed occupant and nothing else, so driving them through a real chain would couple +# the store's suite to a Bundle, a Request and a Response per case. A real in-memory +# implementation of Dexpace::Context, not a recorder of calls -- a fake by testing/7ecef8e8's +# definition, named Fake* per testing/630ba094. Gets #close free from the module, which is what +# makes the CTX-9/CTX-10 cases readable. It has no value equality (plain Object#==), which is why +# the CTX-9 trap needs a real Data context and lives beside it in context_store_test.rb. +# +# It lives in dexpace-core's test tree and is not public API, for the reason fake_transport.rb +# gives: a published fake is NFR-4-locked surface, and the move to dexpace-conformance was +# declined by phase 8a. +class FakeContext + include Dexpace::Context + + # @return [String] the store key this occupant registers under + # @return [#set, #release] the store #close releases through + attr_reader :call_key, :store + + def initialize(call_key:, store:) + @call_key = call_key + @store = store + end +end diff --git a/test/gates/rubocop_config_test.rb b/test/gates/rubocop_config_test.rb index 0e1cd60..637cb38 100644 --- a/test/gates/rubocop_config_test.rb +++ b/test/gates/rubocop_config_test.rb @@ -35,6 +35,20 @@ class RubocopConfigTest < GateCase assert_equal("nested", CONFIG.dig("Style/ClassAndModuleChildren", "EnforcedStyle")) end + # Phase 4a (P4-10): CTX-19's cop is scoped to EVERY gem's lib/, not core alone, because + # gates:require_allowlist covers core's lib/ only and an adapter gem can hold a context too. + # The same scope as the two lib/-only cops before it; a narrowing to one gem is the mutation + # this test exists to catch. + test "Dexpace/NoWeakReferences is required, enabled, and reaches every gem's lib/" do + assert_includes(CONFIG.fetch("require"), "./.rubocop/cops/dexpace/no_weak_references.rb") + assert(CONFIG.dig("Dexpace/NoWeakReferences", "Enabled")) + assert_equal(["gems/*/lib/**/*.rb"], CONFIG.dig("Dexpace/NoWeakReferences", "Include")) + assert_equal( + CONFIG.dig("Dexpace/QualifiedCoreConstant", "Include"), + CONFIG.dig("Dexpace/NoWeakReferences", "Include"), + ) + end + test "the gate itself runs at --fail-level=convention with no autocorrection" do body = File.read(File.join(ROOT, "tasks/quality.rake")) # The `rubocop:fix` convenience in the same file is allowed to autocorrect; the gate is not. From 52132e1565a9cf74a1bd19294d65b6f1536fb413 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 16 Sep 2026 17:51:59 +0300 Subject: [PATCH 7/8] test: CTX-13 via a real promotion, .default in a fresh process, the guard 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. --- .../test/dexpace/context_store_test.rb | 93 ++++++++++++++++--- 1 file changed, 79 insertions(+), 14 deletions(-) diff --git a/gems/dexpace-core/test/dexpace/context_store_test.rb b/gems/dexpace-core/test/dexpace/context_store_test.rb index 78be891..3ded07e 100644 --- a/gems/dexpace-core/test/dexpace/context_store_test.rb +++ b/gems/dexpace-core/test/dexpace/context_store_test.rb @@ -4,6 +4,8 @@ require_relative "../test_helper" require "dexpace" require_relative "../support/fake_context" +require "open3" +require "rbconfig" # CTX-7, CTX-8, CTX-9, CTX-10, CTX-11, CTX-12, CTX-13, CTX-18, CTX-19, XCUT-14, and the Fiber[] # boundary docs/knowledge/notes/observability.md draws. Every store here is a fresh @@ -145,30 +147,37 @@ class ConcurrencyTest < DexpaceTestCase # The store is process-wide, not fiber-scoped: the line docs/knowledge/notes/observability.md # draws between CTX's store and the Fiber[] diagnostic-context carrier. The setup guard is - # what makes the three assertions mean something -- without it, the store being visible - # everywhere would prove nothing about the three being distinct execution contexts. - # Fiber[:probe] = v emits no warning on any supported Ruby (design, verified fact 13); only - # Fiber#storage= does, and nothing here calls it. + # what makes the three store assertions mean something, and it covers every carrier they do: + # with a fiber-storage slot and a fiber-local slot both written on the main fiber, a child + # Fiber, a new ::Thread and an Enumerator's internal fiber each inherit the first and see + # none of the second (observability/016d9154, verified on 3.2.11, 3.4.10 and 4.0.6). That is + # what establishes the three as distinct execution contexts -- without it, the store being + # visible everywhere would prove nothing. Fiber[:probe] = v emits no warning on any supported + # Ruby (design, verified fact 13); only Fiber#storage= does, and nothing here calls it. test "the store is process-wide, not fiber-scoped -- the Fiber[] boundary" do store = Dexpace::ContextStore.new(cap: 8) ctx = FakeContext.new(call_key: "fiber-probe", store: store) store.set(ctx) + probe = -> { [Fiber[:probe], ::Thread.current[:probe]] } - Fiber[:probe] = :main_fiber + Fiber[:probe] = :fiber_storage + ::Thread.current[:probe] = :fiber_local - assert_equal(:main_fiber, Fiber[:probe]) - assert_nil(::Thread.current[:probe]) - assert_equal(:main_fiber, Fiber.new { Fiber[:probe] }.resume) - assert_nil(Fiber.new { ::Thread.current[:probe] }.resume) + assert_equal(%i[fiber_storage fiber_local], probe.call) + assert_equal([:fiber_storage, nil], Fiber.new { probe.call }.resume) + assert_equal([:fiber_storage, nil], ::Thread.new { probe.call }.value) + assert_equal([:fiber_storage, nil], Enumerator.new { |y| y << probe.call }.next) from_fiber = Fiber.new { store["fiber-probe"] }.resume - from_thread = nil - ::Thread.new { from_thread = store["fiber-probe"] }.join + from_thread = ::Thread.new { store["fiber-probe"] }.value from_enumerator = Enumerator.new { |y| y << store["fiber-probe"] }.next assert_same(ctx, from_fiber) assert_same(ctx, from_thread) assert_same(ctx, from_enumerator) + ensure + Fiber[:probe] = nil + ::Thread.current[:probe] = nil end end @@ -195,6 +204,42 @@ class BoundTest < DexpaceTestCase refute_nil(store["d"]) end + # The same policy through a REAL promotion, which the fake above only simulates. A + # #promote_to_exchange that released its source before setting the successor would pass + # every FakeContext case in this file and still refresh the chain's eviction position (the + # slot is deleted and re-inserted at the end of the hash) -- and leave the slot transiently + # empty between two mutex acquisitions. Cap 3, three chains at the request stage under keys + # a, b and c; a's request context promoted to exchange, re-setting a's slot through the real + # path; a fourth chain promoted; a is still the oldest registration and still the victim, and + # both of a's links close as CTX-18's false. This is the case the release-then-set mutation + # goes red on. + test "CTX-13: a real promotion re-sets the slot without refreshing its eviction position" do + store = Dexpace::ContextStore.new(cap: 3) + bundle = Dexpace::Instrumentation::Bundle::NONE + promote = lambda do |key| + Dexpace::DispatchContext.build(bundle: bundle, call_key: key, store: store) + .promote_to_request(request: :req) + end + a = promote.call("a") + b = promote.call("b") + c = promote.call("c") + + a_exchange = a.promote_to_exchange(response: :resp) + + assert_same(a_exchange, store["a"]) + assert_equal(3, store.size) + + d = promote.call("d") + + assert_equal(%w[b c d], %w[a b c d].select { |key| store[key] }) + assert_equal(3, store.size) + assert_same(b, store["b"]) + assert_same(c, store["c"]) + assert_same(d, store["d"]) + refute(a_exchange.close) + refute(a.close) + end + test "CTX-13/CTX-18: nothing in the store's own behaviour depends on any entry surviving" do store = Dexpace::ContextStore.new(cap: 1) a = FakeContext.new(call_key: "a", store: store) @@ -313,12 +358,32 @@ class ReachabilityTest < DexpaceTestCase refute_nil(store[keys.last]) end - # .default is the one process-wide instance, assigned at file load rather than memoised on - # first call; this suite never writes to it (CTX-17 keeps construction off the store), so - # the only things asserted are its identity across calls and that it is a store. + # .default is the one process-wide instance; this suite never writes to it (CTX-17 keeps + # construction off the store), so what is asserted here is its identity across calls and + # that it is a store. The eager-assignment half is the next case's. test ".default is one process-wide instance, the same object on every call" do assert_same(Dexpace::ContextStore.default, Dexpace::ContextStore.default) assert_instance_of(Dexpace::ContextStore, Dexpace::ContextStore.default) end + + # Why .default is assigned at file load rather than memoised: `@default ||= new` is an + # unsynchronised read-modify-write over shared state (XCUT-11), and eager assignment is also + # what keeps CTX-17 inert for a .build that takes the default store -- construction reads a + # singleton that already exists and writes nothing. The identity case cannot see the + # difference: by the time it runs, some earlier .build in this process has called .default + # and a memoised store would already exist. Only a fresh process can, so this asks one -- + # `require "dexpace"` and nothing else, then whether the ivar is set before any call. A + # memoised .default with the load-time line deleted prints false. + test ".default is assigned at file load: a fresh process holds it before any call" do + lib = File.expand_path("../../lib", __dir__) + out, err, status = Open3.capture3( + RbConfig.ruby, "-w", "-W:deprecated", "-I", lib, "-e", + 'require "dexpace"; print Dexpace::ContextStore.instance_variable_defined?(:@default)', + ) + + assert_predicate(status, :success?, err) + assert_empty(err) + assert_equal("true", out) + end end end From 5752d86af75e4132c2087a5824371bef1dd43a48 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 16 Sep 2026 18:47:52 +0300 Subject: [PATCH 8/8] test: a Symbol or Integer call_key and operation_name are refused 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). --- .../dexpace/context/dispatch_context_test.rb | 17 +++++++++++++++ .../dexpace/context/exchange_context_test.rb | 13 ++++++++++++ .../dexpace/context/request_context_test.rb | 21 +++++++++++++++++++ .../dexpace-core/test/dexpace/context_test.rb | 15 +++++++++++++ 4 files changed, 66 insertions(+) diff --git a/gems/dexpace-core/test/dexpace/context/dispatch_context_test.rb b/gems/dexpace-core/test/dexpace/context/dispatch_context_test.rb index 88bef40..e09a970 100644 --- a/gems/dexpace-core/test/dexpace/context/dispatch_context_test.rb +++ b/gems/dexpace-core/test/dexpace/context/dispatch_context_test.rb @@ -235,6 +235,23 @@ class ConstructionTest < DexpaceTestCase assert_equal("bundle is required", error.message) end + # The .build path hands a frozen Symbol or Integer through Model.frozen_string untouched, so + # the refusal has to come from the shared validation -- on .build and on #with alike. + test "CTX-4: a pinned key that is not a String is refused, on .build and on #with" do + [:sym, 5].each do |key| + error = assert_raises(Dexpace::InvalidArgumentError, key.inspect) do + Dexpace::DispatchContext.build(bundle: NONE, call_key: key) + end + + assert_equal("call_key must be a String", error.message, key.inspect) + end + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::DispatchContext.build(bundle: NONE, call_key: "pinned").with(call_key: :sym) + end + + assert_equal("call_key must be a String", error.message) + end + test "an empty pinned key and a store without #set/#release are refused" do assert_raises(Dexpace::InvalidArgumentError) do Dexpace::DispatchContext.build(bundle: NONE, call_key: "") diff --git a/gems/dexpace-core/test/dexpace/context/exchange_context_test.rb b/gems/dexpace-core/test/dexpace/context/exchange_context_test.rb index da792f0..c11781f 100644 --- a/gems/dexpace-core/test/dexpace/context/exchange_context_test.rb +++ b/gems/dexpace-core/test/dexpace/context/exchange_context_test.rb @@ -32,6 +32,19 @@ class DexpaceExchangeContextTest < DexpaceTestCase end end + test "a non-String operation_name is rejected with the field-named message" do + [:GetUser, 7].each do |name| + error = assert_raises(Dexpace::InvalidArgumentError, name.inspect) do + Dexpace::ExchangeContext.build( + bundle: NONE, request: :req, response: :resp, + operation_name: name, + ) + end + + assert_equal("operation_name must be a String", error.message, name.inspect) + end + end + test "request: and response: are required, with SEAM-29's one message form" do error = assert_raises(Dexpace::InvalidArgumentError) do Dexpace::ExchangeContext.build(bundle: NONE, request: :req, response: nil) diff --git a/gems/dexpace-core/test/dexpace/context/request_context_test.rb b/gems/dexpace-core/test/dexpace/context/request_context_test.rb index 15489a0..1c5a349 100644 --- a/gems/dexpace-core/test/dexpace/context/request_context_test.rb +++ b/gems/dexpace-core/test/dexpace/context/request_context_test.rb @@ -124,6 +124,27 @@ class OperationNameTest < DexpaceTestCase end end + # CTX-16's "schema-defined operation id such as 'GetUser'" is a String: a Symbol spelling of + # the same id is refused with the field-named message, and so is anything without #empty?. + test "a non-String operation_name is rejected, off-chain and at promotion" do + store = Dexpace::ContextStore.new(cap: 8) + + [:GetUser, 7].each do |name| + error = assert_raises(Dexpace::InvalidArgumentError, name.inspect) do + Dexpace::RequestContext.build(bundle: NONE, request: :req, operation_name: name) + end + + assert_equal("operation_name must be a String", error.message, name.inspect) + error = assert_raises(Dexpace::InvalidArgumentError, name.inspect) do + Dexpace::DispatchContext.build(bundle: NONE, store: store) + .promote_to_request(request: :req, operation_name: name) + end + assert_equal("operation_name must be a String", error.message, name.inspect) + end + + assert_equal(0, store.size) + end + test "operation_name is frozen without aliasing the caller's string" do name = +"GetUser" ctx = Dexpace::RequestContext.build(bundle: NONE, request: :req, operation_name: name) diff --git a/gems/dexpace-core/test/dexpace/context_test.rb b/gems/dexpace-core/test/dexpace/context_test.rb index 72e7aec..9af6cef 100644 --- a/gems/dexpace-core/test/dexpace/context_test.rb +++ b/gems/dexpace-core/test/dexpace/context_test.rb @@ -72,6 +72,20 @@ def initialize(bundle:, call_key:, store:) end end + # CTX-4's key is a String. A Symbol is refused rather than silently keying a slot that no String + # lookup finds, and an Integer meets the SDK's error rather than a NoMethodError from #empty?. + test "the validation requires the call_key to be a String, with the field-named message" do + store = FakeStore.new + + [:sym, 5].each do |key| + error = assert_raises(Dexpace::InvalidArgumentError, key.inspect) do + ProbeContext.new(bundle: :b, call_key: key, store: store) + end + + assert_equal("call_key must be a String", error.message, key.inspect) + end + end + test "the validation requires a store that responds to #set and #release" do bad_store = Object.new only_set = Object.new.tap { |o| o.define_singleton_method(:set) { |c| c } } @@ -96,6 +110,7 @@ def initialize(bundle:, call_key:, store:) test "Context's public surface is #close alone" do assert_equal([:close], Dexpace::Context.public_instance_methods(false)) assert_includes(Dexpace::Context.private_instance_methods(false), :validate_context!) + assert_includes(Dexpace::Context.private_instance_methods(false), :validate_operation_name!) assert_empty(Dexpace::Context.singleton_methods) end