Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions gems/dexpace-core/lib/dexpace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,26 @@
require_relative "dexpace/pipeline/transform_step"
require_relative "dexpace/async_pipeline"

# Phase 5a: configuration and the clock, in dependency order -- the five free-standing utilities
# first (the build descriptor, the UUID generator, the retryability classifier, the HTTP-date
# codec and the private deep-value helper), then the clock, the async delay over it, the
# configuration chain (configuration.rb owns the load order of its keys, sources, private
# parsers and builder, which reopen the class it declares and never appear here), the
# process-wide slot over the chain, and the proxy model last (proxy.rb likewise owns
# proxy/type, proxy/host_pattern and the private resolver). Ten lines for seventeen files.
# `time` is required by http_date.rb and `uri` by proxy/resolution.rb, each in the file that
# uses it; the allowlist does not grow (R5).
require_relative "dexpace/build_info"
require_relative "dexpace/uuid"
require_relative "dexpace/retryability"
require_relative "dexpace/http_date"
require_relative "dexpace/clock"
require_relative "dexpace/async/delay"
require_relative "dexpace/deep_value"
require_relative "dexpace/configuration"
require_relative "dexpace/config"
require_relative "dexpace/proxy"

# The dexpace Ruby SDK: an HTTP-client toolkit, not an HTTP client.
#
# This file issues explicit `require_relative`s for the whole tree rather than using an
Expand Down
80 changes: 78 additions & 2 deletions gems/dexpace-core/lib/dexpace/async/completer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,57 @@
require_relative "../closeable"
require_relative "../cancellation"
require_relative "../hooks"
require_relative "../clock"
require_relative "../error/cancelled_error"
require_relative "../error/invalid_argument_error"

module Dexpace
module Async
# The deadline: keyword's two argument checks, shared by Completer#await and, through it,
# Future#wait and #value. A private module because the checks are the same at every entry.
module Deadline
extend self

# @return [Float, nil] the deadline as a Float instant, or nil for no deadline
def validate(deadline, clock)
return nil if deadline.nil?

unless deadline.is_a?(::Numeric)
raise Dexpace::InvalidArgumentError,
"deadline: takes a monotonic instant (Numeric), got #{deadline.class}"
end
unless clock.respond_to?(:monotonic)
raise Dexpace::InvalidArgumentError,
"clock: takes a Dexpace::_Clock answering #monotonic, got #{clock.class}"
end

instant = deadline #: untyped
instant.to_f
end

# The wait itself: pops `gate` until the block answers settled -- phase 2's unbounded loop
# when `limit` is nil, and otherwise with a timeout recomputed from `clock` on every
# iteration until `limit` passes. A closed gate pops nil at once, so a settlement that lands
# between two iterations ends the loop on the next read.
#
# @return [Boolean] whether the limit passed with the block still answering false
def expired?(gate, limit, clock)
until yield
if limit.nil?
gate.pop
next
end

remaining = limit - clock.monotonic
return true if remaining <= 0

gate.pop(timeout: remaining)
end
false
end
end
private_constant :Deadline

# The write side of the canonical async pivot, and where the pivot's state lives.
#
# Design §10.3: the pivot is core-owned because Ruby's async ecosystem is fragmented across
Expand Down Expand Up @@ -119,12 +165,42 @@ def on_settle(&block)
# Kernel#sleep poll: under a registered Fiber.scheduler a blocking queue pop routes through
# the scheduler's block/unblock hooks instead of parking the OS thread (verified on 3.2.11,
# 3.4.10 and 4.0.6; Task 6 asserts it).
def await(cancellation = nil)
#
# `deadline:` is the keyword phase 2 postponed to phase 5 (P2-5): an INSTANT on
# Clock#monotonic's scale, never a duration, so a wait resumed spuriously cannot extend it
# -- `remaining` is recomputed from `clock` on every iteration and the TOTAL wait is what is
# bounded. Clock.deadline_in is how a caller names the scale; computing one off Time.now is
# the mistake CFG-16 forbids. On expiry this cancels the future through #request_cancel --
# one settlement, published before the hooks run, so the producer's abort hook fires and
# SEAM-30's lost-race close applies to whatever it later delivers -- and returns. It does
# not raise: #await never raised and Future#wait is documented never to raise the failure,
# so raising here would narrow two signatures NFR-4 locks. Future#value raises on its next
# line, because the settlement it finds is a cancellation carrying :deadline_expired, a
# Symbol and not a sentence (XCUT-2: a deadline and a cancel are told apart by #reason,
# never by a string match). A deadline bounds a wait; it aborts nothing in the background,
# and nothing fires when nobody is waiting. The mechanism is a timed gate pop, not a
# deadline-derived token composed through Cancellation.any as phase 2 anticipated.
#
# The two keywords are validated BEFORE the settled short-circuit: a settled future ignores
# an expired deadline, not an invalid one, and a wrong argument type is the caller's error
# whichever state the completer is in. The positional token keeps phase 2's order -- it is
# armed only when there is a wait to arm it for.
#
# @param cancellation [Dexpace::Cancellation, nil] positional, as phase 2 shipped it
# @param deadline [Numeric, nil] a monotonic instant; nil is phase 2's unbounded wait
# @param clock [_Clock] the seam the deadline is measured against
# @return [self]
# @raise [Dexpace::InvalidArgumentError] on a non-Numeric deadline or a clock without
# #monotonic, settled or not
def await(cancellation = nil, deadline: nil, clock: Dexpace::Clock::SYSTEM)
limit = Deadline.validate(deadline, clock)
return self if settled?

subscription = arm(cancellation)
begin
@gate.pop until settled?
# An expiry that loses the race to a real settlement is a no-op: #request_cancel
# returns false on a settled completer.
request_cancel(:deadline_expired) if Deadline.expired?(@gate, limit, clock) { settled? }
ensure
subscription&.detach
end
Expand Down
85 changes: 85 additions & 0 deletions gems/dexpace-core/lib/dexpace/async/delay.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

require_relative "completer"
require_relative "../model"
require_relative "../error/invalid_argument_error"
require_relative "../error/seam_error"

module Dexpace
# The async layer's namespace, reopened here to carry CFG-18's delay beside phase 2's pivot.
module Async
# The void value CFG-18's future settles with. Not nil: SEAM-16 makes Async::Settlement refuse
# a nil response ("MUST NOT complete successfully with a null/absent value", structural), so
# "completing with an empty/void value" is spelled `true` here.
ELAPSED = true
private_constant :ELAPSED

# CFG-18's scheduled non-blocking delay: a Future that completes with the void value after
# `duration` seconds elapse on the registered Fiber.scheduler, WITHOUT blocking a thread.
#
# Scheduler-conditional, and it raises rather than degrades (P5-9). Under a registered
# scheduler Thread::Queue#pop(timeout:) routes through the scheduler's block/unblock hooks and
# unmounts the fiber -- verified on 3.2.11, 3.4.10 and 4.0.6: the pop calls #block exactly
# once and #kernel_sleep never -- so the carrier thread is free. With no scheduler there is no
# non-blocking path at all: a thread-backed delay would satisfy the three MUST clauses while
# violating the SHOULD's headline, so a positive duration raises Dexpace::SeamError naming
# Fiber.set_scheduler and the blocking alternative, Dexpace::Clock#sleep. SeamError is phase
# 2's "a seam in a state the caller must fix but did not pass in" -- a process-level facility
# the caller installs and did not, with a correct argument -- and not a claim that the
# scheduler is one of SEAM-2's five seams.
#
# It lives here and not as a fourth method on Clock (P5-10): CFG-15's seam exposes three
# operations and a fake owes it exactly those, while CFG-18 names "the async layer" as its
# subject. And it takes no clock: keyword, because nothing in its four branches reads a clock
# -- the wait is the queue's own timeout, a duration and not an instant, and no fake clock can
# make a real queue wake early.
#
# @param duration [Numeric] seconds; zero completes the future at once
# @return [Dexpace::Async::Future] completing with the void value `true`
# @raise [Dexpace::InvalidArgumentError] on a negative or non-numeric duration, before
# anything is scheduled
# @raise [Dexpace::SeamError] on a positive duration with no Fiber.scheduler registered
def self.delay(duration)
seconds = validate_delay(duration)
completer = Completer.new
if seconds.zero?
completer.fulfil(ELAPSED)
return completer.future
end
raise SeamError, NO_SCHEDULER if ::Fiber.scheduler.nil?

queue = ::Thread::Queue.new
# CFG-18's fourth clause: cancelling the future cancels the scheduled task so the scheduler
# thread is not held. Completer#on_cancel fires on Future#cancel -> Completer#request_cancel,
# and the completer drops its hook list on settle, so there is nothing to detach.
completer.on_cancel { queue.push(:cancel) }
::Fiber.schedule do
# nil means the duration elapsed and :cancel means the future was cancelled. Sound only
# because this queue is per-call, private and never closed -- a closed queue pops nil too.
completer.fulfil(ELAPSED) unless queue.pop(timeout: seconds) == :cancel
end
completer.future
end

NO_SCHEDULER = "Async.delay needs a registered Fiber.scheduler to complete without blocking " \
"a thread (CFG-18). Register one with Fiber.set_scheduler, or use " \
"Dexpace::Clock#sleep, which blocks the calling thread by design."
private_constant :NO_SCHEDULER

# @return [Float] the duration as seconds
def self.validate_delay(duration)
Model.required!("duration", duration)
unless duration.is_a?(::Numeric)
raise InvalidArgumentError, "duration must be a number of seconds, got #{duration.class}"
end
if duration.negative?
raise InvalidArgumentError, "duration must be non-negative, got #{duration}"
end

seconds = duration #: untyped
seconds.to_f
end
private_class_method :validate_delay
end
end
29 changes: 19 additions & 10 deletions gems/dexpace-core/lib/dexpace/async/future.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# SPDX-License-Identifier: MIT

require_relative "completer"
require_relative "../clock"
require_relative "../error/seam_error"

module Dexpace
Expand Down Expand Up @@ -29,19 +30,27 @@ def settled? = @completer.settled?
# Whether the outcome is a cancellation; false while unsettled.
def cancelled? = @completer.outcome&.cancelled ? true : false

# Settles-or-returns; never raises the failure.
def wait(cancellation: nil)
@completer.await(cancellation)
# Settles-or-returns; never raises the failure, and an expired deadline is not an exception
# to that: it settles the future as cancelled and this still returns self.
#
# @param cancellation [Dexpace::Cancellation, nil] the token that can cut the wait short
# @param deadline [Numeric, nil] a monotonic instant on Clock#monotonic's scale (the keyword
# phase 2 postponed to phase 5a; Clock.deadline_in names the scale)
# @param clock [_Clock] the seam the deadline is measured against
# @return [self]
def wait(cancellation: nil, deadline: nil, clock: Dexpace::Clock::SYSTEM)
@completer.await(cancellation, deadline: deadline, clock: clock)
self
end

# Blocks, then delivers the response or raises the failure. `deadline:` is deliberately
# absent: SEAM-18's interruption clause is about cancellation, and deadlines need phase 5's
# clock and interruptible-delay primitives (CFG-15..CFG-21; phase 5a, Task 8 adds it).
# Adding the keyword later widens this signature rather than narrowing it, so NFR-4's API
# lock is not prejudiced.
def value(cancellation: nil)
wait(cancellation: cancellation)
# Blocks, then delivers the response or raises the failure. On an expired deadline the
# failure IS the cancellation #await settled with, so there is one raise here and no second
# error class: a caller reads CancelledError#reason (:deadline_expired) to tell a deadline
# from a cancel. `deadline:` widens phase 2's signature and narrows nothing (NFR-4).
#
# @return [Object] the settled response
def value(cancellation: nil, deadline: nil, clock: Dexpace::Clock::SYSTEM)
wait(cancellation: cancellation, deadline: deadline, clock: clock)
settlement = @completer.outcome
# Steep cannot see that #wait only returns once the outcome is written.
raise Dexpace::SeamError, "the future returned from its wait unsettled" if settlement.nil?
Expand Down
54 changes: 54 additions & 0 deletions gems/dexpace-core/lib/dexpace/build_info.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

require_relative "version"

module Dexpace
# The static build and host-runtime descriptor (CFG-36): the SDK version and the runtime's
# version, vendor and OS name, resolved once at load into frozen constants, each falling back to
# the non-blank "unknown", and the ordered identity-token pair a User-Agent-style string is
# composed from -- the SDK token first, the runtime token second.
#
# Every component is read off a constant that needs no `require` and is defined under
# `--disable-gems` -- RUBY_ENGINE, RUBY_ENGINE_VERSION and RUBY_PLATFORM -- so the require
# allowlist does not grow (R5). RbConfig would give the same OS token here and is undefined
# without RubyGems; `rbconfig` is allowlistable at the cost of a reviewed one-line diff and
# was not worth the first growth of that list since phase 0. RUBY_PLATFORM's OS half is not
# always what an operator would call the operating system ("darwin24", "x86_64-linux-musl"),
# and that is accepted: the token's destination is a User-Agent, where RUBY_PLATFORM is the
# string the Ruby ecosystem already publishes. Composing the User-Agent itself is not this
# module's job.
module BuildInfo
# The fallback CFG-36 fixes: never an empty token, so a joined identity string is never
# malformed.
UNKNOWN = "unknown"

# The one blank guard, applied to every component so non-blankness is a property of the
# constant rather than of its callers.
def self.non_blank(value)
text = value.to_s.strip
text.empty? ? UNKNOWN : -text
end
private_class_method :non_blank

# The SDK's own version, phase 0's Dexpace::VERSION from the repo-root VERSIONS file.
SDK_VERSION = non_blank(Dexpace::VERSION)

# The interpreter's version -- RUBY_ENGINE_VERSION, which is RUBY_VERSION on CRuby and the
# engine's own on another implementation.
RUNTIME_VERSION = non_blank(::RUBY_ENGINE_VERSION)

# The interpreter's vendor: "ruby" on CRuby, "jruby", "truffleruby".
RUNTIME_VENDOR = non_blank(::RUBY_ENGINE)

# The OS half of RUBY_PLATFORM ("x86_64-linux" -> "linux").
OS_NAME = non_blank(::RUBY_PLATFORM.split("-", 2).last)

# CFG-36's "default ordered identity-token list (SDK token then runtime token)".
# An interpolated literal is not frozen by the magic comment (Ruby >= 3.0), so each is.
IDENTITY_TOKENS = [
"dexpace-ruby/#{SDK_VERSION}".freeze,
"#{RUNTIME_VENDOR}-#{RUNTIME_VERSION}/#{OS_NAME}".freeze,
].freeze
end
end
Loading
Loading