Skip to content

Phase 5a: configuration and the clock — tests and doubles - #64

Merged
Wahbeh-Mohammad merged 10 commits into
mainfrom
18-phase-5a-configuration-tests
Sep 18, 2026
Merged

Wahbeh-Mohammad merged 10 commits into
mainfrom
18-phase-5a-configuration-tests

Conversation

@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor

Part of #18. Second PR of the nine-PR phase-5 stack — the tests for phase 5a's code PR below it. Targets that PR's branch; the phase record is the next PR up.

What lands

22 files, +3,286: nineteen suites under gems/dexpace-core/test/dexpace/ mirroring lib/ one file per file (the three private_constants — ConfigParsers, ProxyResolution, DeepValue — are asserted at their call sites and say so), plus the doubles the design's testing strategy names, one per file under test/support/, and a matrix-fact suite that pins the six floor-straddling facts on every row:

  • FakeClock — a settable monotonic instant the test advances, a recorded sleep, exactly the three _Clock operations; a FakeClock-driven Clock#sleep and a deadline expiry need no wall time.
  • FakeConfigSource — the hermetic env / property seam (CFG-11); renamed from the plan's FakeSource, which is phase 3a's IO-17 double at the same path.
  • ParkingScheduler — the minimal Fiber::Scheduler CFG-18 needs: parks a fiber with a deadline, runs its loop in #close; a new file, because phase 2's ProbeScheduler at the plan's path is a hook recorder whose #block cannot drive a timed pop. All three top-level, the convention of every existing double (P5-58).

Every file's header names the requirement IDs it exercises; every class inherits DexpaceTestCase; every thread is joined; nothing uses assert_nothing_raised; no 5a case reads the real process environment — every source is injected (CFG-11), the slot's env seam is a FakeConfigSource wherever the slot is consulted, and the one case that could not be (the ENVIRONMENT seam itself) writes and restores a uniquely named key. Review round 0's blocking finding was exactly this class — the CFG-28 case resolved through the empty slot's real ENV and failed with HTTPS_PROXY set — and the fix commit made the proxy, ceiling and cap suites hermetic and reproduced each red first.

What the suites prove rather than restate:

  • CFG-1–CFG-4 with every tier populated for one key and removed one at a time; the CFG-2 asymmetry (an empty environment value falls through, an empty override or property resolves to "") in one case so emptiness cannot become chain-wide; #string finding dexpace.max.retries from DEXPACE_MAX_RETRIES and #raw_property not.
  • CFG-13 — 16 threads configuring and reading concurrently with no torn or nil observation; the slot's two publications pinned inside the mutex by text, because the race is unobservable under the GVL.
  • CFG-15/CFG-17 — a 5 s sleep cancelled from another thread at 50 ms returning in 0.050 s with the token cancelled; an already-cancelled token returning at once; the wait re-checking the token after the wake (the mutant that drops it goes red on both interpreters).
  • The bounded wait — Future#value(deadline:, clock: fake) on an unsettled Completer raising CancelledError with reason :deadline_expired and the completer settled cancelled; Completer#await still returning self; deadline: nil behaving exactly as phase 2 (its suite unchanged); a settled future still refusing an invalid deadline (from the review).
  • CFG-18 — under ParkingScheduler, #block fires once and #kernel_sleep never; without a scheduler, SeamError naming Fiber.set_scheduler.
  • The proxy model — '*.example.com' matching A.Example.COM and not example.com; CFG-26's a\|b|c → ['a|b', 'c']; port 65536, garbage and a port-less URL each yielding nil plus exactly one WarningCaptured warning, never a raise; the compiled pattern assert_same across lookups; a blank HTTPS_PROXY from any tier no longer masking HTTP_PROXY; the explicit no-argument .resolve reading the process-wide slot through fake seams installed by Dexpace.configure.
  • CFG-22 — both credentials masked in #to_s and #inspect, with the username-only and password-only shapes (review round 0's should-fix).
  • HTTPDate — GMT/UTC/+0000/+00:00 parsing to one instant; a wrong weekday and a lower-case month tolerated; '', ' ' and a missing comma refused; 31 Nov, 29 Feb 1995, hour 24 and second 60 refused (P5-54); English month names under a localedef'd de_DE.UTF-8.
  • UUID — 10,000 distinct v4 values; 8 threads × 1,000; three distinct Random objects across the main fiber, a child fiber and a thread; a text scan of uuid.rb's non-comment lines for SecureRandom (because securerandom is allowlisted, the require gate cannot catch it).
  • DeepValue through Configuration — NaN equal and hash-equal with two distinct NaN objects (the identity short-circuit trap), {a: 0.0} != {a: -0.0}, [1] != [1.0] (P5-14), a cycle returning; the Float#hash disagreement between the two NaN payloads asserted as the precondition.
  • Retryability over the whole set, a Status object and an Integer; respond_to? false for any throwable-half name.
  • The two wirings — ContextStore.default absent in a fresh process before the first call, sixteen first callers under a 50 ms seam getting one store (a slowed new, since the seams now run outside the lock), no seam consulted after the first call; IO.max_materialized_bytes at the default and configured, the four readers going through it (a reader put back on the constant goes red).
  • The matrix facts, pinned on every row — Time.httpdate's tolerances; Random::DEFAULT gone; Queue#pop(timeout: -1) and a closed queue; RbConfig absent under --disable-gems; a Data keeping an ivar set before super through the freeze; Float#hash on the two NaN payloads.

Verification

  • bundle exec rake on Ruby 4.0.6 at this tip: exit 0, all seventeen gates green — test:gems 1,762 runs / 44,284 assertions, line coverage 99.97% (the one line is phase 2's registry race branch); honest RuboCop 350 files clean.
  • The matrix set on 3.2.11, 3.3.12 and 3.4.10 with a fresh lock: 0 failures; every interpreter-sensitive assertion (#with on every 5a Data re-validating on 3.2.11; the carriers; Queue#pop(timeout:); the scheduler; NaN/signed-zero hashing) green on every row.
  • Four extra seeds on two interpreters: identical run counts; the six hang-shaped suites stable across five repeats each. The two reviewers applied 47 and 53 single-edit mutations; every one caught, ten on 3.2.11 as well.
  • Hermeticity reproduced by the final reviewer: the proxy suite green with HTTPS_PROXY/HTTP_PROXY/NO_PROXY exported and with a port-less HTTPS_PROXY; the ceiling and cap suites green under hostile MAX_MATERIALIZED_BYTES/MAX_TRACKED_CONTEXTS.

Known follow-up (not blocking)

  • R1-1 — builder_test.rb:203: Configuration::Builder.new(property_source: nil).build.string("K") builds over Sources::ENVIRONMENT and reads the host's K (K=hostile makes it fail). Pass env_source: Sources::NONE; the case is about property_source: nil.
  • Phase 3a's and 3b's materialisation suites now read the ceiling through the slot with no fake seam (they assumed the constant before); stated in io_ceiling_test.rb's header rather than fixed here.

Phase 5a, Tasks 2 through 5: the four free-standing utilities the
configuration chapter files beside the chain.

- Dexpace::BuildInfo (CFG-36) resolves the SDK version and the runtime's
  version, vendor and OS name into frozen constants at load, each
  falling back to the non-blank "unknown", and composes IDENTITY_TOKENS
  in the fixed SDK-then-runtime order. Every component is read off a
  constant that needs no require, so the allowlist does not grow.
- Dexpace::UUID.generate (CFG-32) draws sixteen bytes from a ::Random
  kept in the fiber-local Thread.current[:dexpace_prng] -- one generator
  per execution context, none shared -- masks the version and variant
  nibbles and returns the frozen canonical form. Never SecureRandom.
- Dexpace::Retryability.retryable_status? (CFG-35's status half,
  XCUT-5's single classifier) answers for an Integer or anything
  answering #code: 408, 429 and every 5xx but 501 and 505.
- Dexpace::HTTPDate (CFG-29 to CFG-31) formats with Time#httpdate and
  parses with an owned anchored grammar, never Time.httpdate, which
  accepts the obsolete RFC 850 and asctime forms; every component is
  checked back against what Time.utc built, because Time.utc(1994, 11,
  31) is silently 1 December (P5-54). The grammar is one Regexp compiled
  once with a per-pattern timeout.

seam_surface_test.rb now counts four stdlib features: http_date.rb
requires "time" in the file that uses it.
Phase 5a, Tasks 6 through 8: the injectable time source and what waits
on it.

- Dexpace::Clock (CFG-15, CFG-16) exposes exactly the three operations
  the _Clock interface declares -- #now, #monotonic on CLOCK_MONOTONIC's
  scale, and #sleep -- with Clock::SYSTEM as the platform-backed default
  and Clock.deadline_in as the one way to compute an instant, so a
  deadline is never derived from Time.now. #sleep is a per-call
  ::Thread::Queue#pop(timeout:) that the cancellation token's #on_cancel
  hook wakes, with the token re-checked after the wake (CFG-17): never
  Kernel#sleep, which cannot be woken without Thread#raise. The hook is
  detached in an ensure.
- Future#wait, #value and Completer#await take deadline: and clock:
  beside the positional cancellation (SEAM-18, phase 2's P2-5 pick-up).
  The deadline is the same timed gate pop, and expiry settles the
  completer through request_cancel(:deadline_expired) rather than
  raising past the wait; the loop lives in the private Async::Deadline
  helper.
- Dexpace::Async.delay (CFG-18) is the scheduler-conditional
  counterpart: a Future settling with true after the duration elapses on
  the registered Fiber.scheduler, and Dexpace::SeamError without one,
  since a thread-backed fallback would violate the headline it exists
  for. It settles with true, not nil, because SEAM-16 makes a nil-valued
  Settlement unconstructible (P5-52).
Phase 5a, Tasks 9 through 13: the four-tier chain, the process-wide
slot, and the two earlier layers that now read them.

- Dexpace::Configuration (CFG-1 to CFG-12, CFG-37, CFG-38) is a frozen
  Data over the override map and the two seams. #string looks up in the
  strict order -- override, environment, normalised property, default --
  that inverts Ruby's convention on purpose (design ledger entry 16);
  CFG-2's emptiness rule applies to the environment tier alone; and
  #raw_property reads the property tier by the exact name. #integer,
  #boolean and #duration route through #string and never raise, with
  durations in Float seconds and a bare number read as milliseconds.
  Configuration::Builder is a file of its own beside its model, as phase
  1 files every builder (P5-51); an inherited seam passes through by
  reference, added properties compose over it, and the two seam
  operations are mutually exclusive on one builder. Keys declares the
  seven CFG-14 names; Sources holds ENVIRONMENT, NONE and .from_hash.
  ConfigParsers and DeepValue (CFG-33, CFG-34: NaN equals NaN and hashes
  alike, signed zeros differ, Integer and Float arrays differ, cycles
  survive) are private_constants with sig/ mirrors (P5-57).
- Dexpace.configure, .configuration and .reset_config! (CFG-13) swap one
  frozen reference under a ::Thread::Mutex and read it lock-free; the
  builder runs outside the mutex, so a block that reads the slot cannot
  deadlock it.
- ContextStore.default is built on its first call, under one mutex,
  reading Keys::MAX_TRACKED_CONTEXTS then and falling back to the
  constant on a non-positive value (P5-55). Phase 4a's load-time
  assignment could never see a Dexpace.configure at boot, and an
  unsynchronised ||= publishes one store per first caller; phase 4a's
  fresh-process case now asserts no store exists before the first call.
- Dexpace::IO.max_materialized_bytes reads Keys::MAX_MATERIALIZED_BYTES
  with the constant as the fallback, and the five readers of the
  constant -- TypedReads#guard_materialization!, Body.clamp_cap,
  StreamBody#replayable? and BufferBody#== -- read the function per call
  (P5-56), so the live configuration governs every materialisation.
Phase 5a, Tasks 14 and 15: CFG-22 through CFG-28.

- Dexpace::Proxy is a frozen Data over eight members, built through
  .build with the port validated into 0..65535 and every non-proxy entry
  a HostPattern; #to_s renders type://user:****@host:port and #inspect
  masks the password and names the challenge handler by class only.
  #bypass? is true under bypass_all or on any matching pattern.
- Proxy::Type is a closed set of three in the pipeline's Stage shape:
  .of is the only lookup and canonicalises a copy back to its constant,
  .new and .[] are private, there is no .build and #with refuses, so
  identity comparison over the constants holds.
- Proxy::HostPattern is a one-member Data over the glob with the
  compiled Regexp a private instance variable set at construction
  (P5-53): anchored \A...\z, case-insensitive, every character but * and
  ? literal, per-pattern timeout, never the process-global one.
- Proxy.resolve reads the chain through the private ProxyResolution and
  never raises on its content: the property tier first, https.* over
  http.* with the port taken from the same layer as the chosen host and
  credentials from https.* only; the environment second, HTTPS_PROXY
  then HTTP_PROXY, with an explicit port required since CFG-25 forbids
  defaulting to 80 or 443. Every malformed input yields nil after one
  Kernel#warn prefixed "[dexpace]" (P5-8), with a StandardError backstop
  behind the explicit branches. http.nonProxyHosts wins over NO_PROXY,
  an escaped separator is literal, and a bare "*" yields nil because a
  proxy no request will use is not a proxy (CFG-27). Nothing in core
  calls .resolve, which is how CFG-28's prohibition on implicit reads is
  met.

proxy.rb loads its three nested files from inside its own class body;
the entry file's phase-5a block is now complete at ten lines.
Phase 5a, Task 16's surface half. The runtime surface snapshot grows by
84 rows, 730 to 814: the public constants and methods of Configuration
and its Builder, Keys and Sources, the slot's three module methods,
Clock and Clock::SYSTEM, Async.delay, the deadline: and clock: keywords
on the future and the completer, Proxy with Type and HostPattern,
HTTPDate, UUID, Retryability, BuildInfo and IO.max_materialized_bytes.
Every new row was read against the object model; no row was removed, and
the three private_constants contribute none.

The smoke suite pins the configuration layer beside the seven before it
and preloads "time" so its "defines nothing outside Dexpace" case keeps
its meaning: http_date.rb requires the feature, which defines Date and
DateTime at the top level.
Review round 0 of the phase-5a stack. CFG-22's canonical text is "never
emit username/password in cleartext", and Proxy#to_s and #inspect
printed the username verbatim beside a masked password (R0-2): both
credentials now render as "****" when present and as nothing when
absent, so a rendering says only whether a credential is set. Four nits
on the same layer: ContextStore.default reads the chain outside its
mutex and only the `||=` runs under it, with a lock-free read of the
published reference in front, so no caller-supplied seam runs under a
non-reentrant lock and a promotion after the first pays no lock (R0-4);
Completer#await validates deadline: and clock: before the settled
short-circuit, so a settled future ignores an expired deadline and not
an invalid one (R0-5); Configuration::Builder.new routes its seed map
through #override and its seams through the setters' guard, so a nil
seed value or a non-callable seam fails fast under CFG-37 instead of
stringifying to "" (R0-6); and a blank HTTPS_PROXY override or property
no longer masks HTTP_PROXY, since CFG-2's fall-through covers only the
environment tier and a blank is not a URL (R0-8). The RBS mirrors
follow; the manifest is unchanged because every added member is
private.
Phase 5a, Task 1 and the test-support half of the plan.

- matrix_facts_test.rb asserts, on whichever interpreter runs it, the
  floor-straddling facts the design rests on: Time.httpdate accepting
  asctime and RFC 850 while rejecting UTC and a missing comma, Fiber
  storage leaking across threads while Thread.current is isolated,
  Random#bytes returning an unfrozen BINARY buffer, Queue#pop with a
  negative timeout returning nil at once and unmounting the fiber under
  a scheduler, the RUBY_* constants needing no require, and ENV handing
  out a frozen String per call. Each is a guard of its own beside the
  suites, and the three doubles are exercised here too.
- FakeClock is the _Clock double: a settable monotonic instant advanced
  by the test, a recorded sleep, and nothing the interface does not
  declare.
- FakeConfigSource is the configuration-seam double, named so because
  fake_source.rb is phase 3a's IO-17 double and would have been
  overwritten by the plan's name (P5-58).
- ParkingScheduler is the Fiber.scheduler double that parks a fiber with
  a deadline and runs its loop in #close; phase 2's ProbeScheduler
  records hooks and cannot drive a timed pop. It defines
  #fiber_interrupt because 4.0.6 warns without it.

All three are top level, as every double on main is.
Phase 5a, Tasks 2 through 8: the suites for BuildInfo, UUID,
Retryability, HTTPDate, Clock, Async.delay and the deadline keyword,
each opening with the IDs it exercises.

- build_info_test.rb: every constant frozen and non-blank, the token
  order, and the blank guard resolving to "unknown" (CFG-36).
- uuid_test.rb: the version-4 shape and the variant nibble over a
  thousand draws, one generator per context and none shared, and a text
  scan of uuid.rb for SecureRandom, since securerandom is allowlisted
  and the require gate would not catch the substitution (CFG-32).
- retryability_test.rb: 408, 429 and every 5xx but 501 and 505, over an
  Integer and a Status, and XCUT-7's subset (CFG-35).
- http_date_test.rb: the specification's own example byte for byte, the
  four zone tokens as one instant, the RFC 850 and asctime rejections,
  the impossible calendar dates Time.utc would normalise, and the
  grammar's per-pattern timeout (CFG-29 to CFG-31).
- clock_test.rb: the three-method seam, a cancel during a thirty-second
  wait waking it inside five seconds, the token re-asserted after the
  wake, and a text scan for Kernel.sleep (CFG-15 to CFG-17).
- delay_test.rb: the zero-duration fast path, SeamError without a
  scheduler, and settlement under ParkingScheduler (CFG-18).
- future_deadline_test.rb and close_quietly_cfg21_test.rb: expiry
  settling the completer with :deadline_expired rather than raising past
  the wait, non-expiry returning the value, the identical error object
  through #value, and CFG-21's late close (SEAM-18, CFG-19 to CFG-21).
Phase 5a, Tasks 9 through 15: the suites for Configuration and its
Builder, Keys and Sources, the slot, the two wirings into earlier
layers, and the proxy model with its resolver.

- configuration_test.rb: CFG-1's order with every tier populated, the
  CFG-2 asymmetry, CFG-3's one-way normalisation, the never-throw
  accessors over every grammar, and DeepValue driven from inside module
  Dexpace -- NaN payloads, signed zeros, Integer against Float, and two
  self-referential arrays (CFG-1 to CFG-7, CFG-33, CFG-34, CFG-38).
- builder_test.rb, keys_test.rb, sources_test.rb: the builder's contract
  and every fail-fast branch, the inherited seam passed by reference and
  composed over, the seven declared key names, and the hermetic hash
  seam (CFG-8 to CFG-12, CFG-14, CFG-37).
- config_test.rb: the slot's identity default, last-write-wins at the
  key level, a re-entrant block, sixteen writers against sixteen readers
  seeing only whole snapshots, and a text pin that both publications
  happen inside the one mutex, because under the GVL no behavioural case
  can see the mutex dropped (CFG-13, XCUT-11).
- context_store_config_test.rb: the cap read on first use in a fresh
  process, the non-positive fallback, and sixteen threads reaching
  .default first getting one store under a seam slowed to 50 ms, which
  is the case an unsynchronised ||= fails (CTX-11).
- io_ceiling_test.rb: the configured ceiling honoured by every reader,
  including Buffer#snapshot through the materialisation guard and
  Body.buffer_bounded clamping down and never up (IO-9, BODY-32).
- proxy_test.rb, type_test.rb, host_pattern_test.rb: the model's
  refusals and masked renderings, the closed type set, the anchored
  case-insensitive glob with its timeout, and the resolver over both
  tiers -- the same-layer port rule, the credential source, every
  malformed input answered by its own branch and the backstop only by a
  raising seam, port 65536 refused by the resolver's own check, and the
  NO_PROXY and http.nonProxyHosts splitting rules (CFG-22 to CFG-28).
Review round 0 of the phase-5a stack. The CFG-28 case resolved against
the empty slot, whose environment seam is the real ENV, and failed on a
host with HTTPS_PROXY set (R0-1, blocking): the lib scan stays, and the
default-argument half now installs a fake seam in the slot through
Dexpace.configure, asserts .resolve with no argument reads it, and
resets in ensure. The ceiling suite seeds the slot's environment seam
with an empty fake in every setup and after each mid-case reset, and
the cap suite's fresh processes clear MAX_TRACKED_CONTEXTS by default
(R0-7). The masking cases flip to the username masked with the password
and add the one-credential shapes (R0-2). The race case slows `new`
rather than the seams, because the seams now run outside the lock and a
slow seam alone no longer discriminates the mutex; two cases observe
that the seams run with the mutex unowned and that a later .default
runs no seam (R0-4). One case each for a settled future refusing an
invalid deadline (R0-5), Builder.new refusing a nil seed value, a blank
seed key and a non-callable seam (R0-6), and a blank HTTPS_PROXY from
any tier not masking HTTP_PROXY (R0-8).
@Wahbeh-Mohammad
Wahbeh-Mohammad changed the base branch from 18-phase-5a-configuration to main September 18, 2026 08:48
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit 31ec0e6 into main Sep 18, 2026
5 checks passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the 18-phase-5a-configuration-tests branch September 18, 2026 09:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant