Skip to content
Merged
9 changes: 9 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
77 changes: 77 additions & 0 deletions .rubocop/cops/dexpace/no_weak_references.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# 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"` -- 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 = "`%<offender>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
70 changes: 70 additions & 0 deletions .rubocop/test/cops_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 18 additions & 0 deletions gems/dexpace-core/lib/dexpace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions gems/dexpace-core/lib/dexpace/bounded_map.rb
Original file line number Diff line number Diff line change
@@ -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
72 changes: 72 additions & 0 deletions gems/dexpace-core/lib/dexpace/context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# 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; 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
Loading
Loading