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
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,16 @@ events on its queues are one value, `Statifier::Event`.
Over the loop is the in-memory driver, `Statifier::Driver` under
`lib/statifier/driver/`, the public surface a host steps a chart with:
`Statifier.start`, `Statifier.step`, `Statifier.advance`,
`Statifier.configuration` and `Statifier.done?`, each answering a result
`Statifier.report_send_failed`, `Statifier.configuration` and
`Statifier.done?`, each answering a result
like the compile's. It keeps the external queue and the pending timers on a
virtual clock in its state, an immutable value, routes every send the core
answers (a send to `#_internal` through the loop's internal delivery), and
hands a send of a registered type to the host's processor, passed per call.
A send the processor cannot deliver comes back to the chart as
`error.communication`: answered from its `deliver` as a
`Statifier::Driver::DeliveryFailure`, or reported later through
`Statifier.report_send_failed`.
The effects and events it answers (`Statifier::Effect`, `Statifier::Event`)
are public with it. Beside it, `Statifier.export_position` and
`Statifier.import_position` (the bodies in `Statifier::Position`,
Expand Down
32 changes: 25 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ Statifier.done?(stopped.value.state).value.configuration # => ["c"]

## The driver surface

Eight calls make up the public surface. Each answers a `Statifier::Result`,
Nine calls make up the public surface. Each answers a `Statifier::Result`,
which has `ok?`, `value` and `error`. A successful result has `error == nil`
and a failed one has `value == nil`; read `ok?` and not the value.

Expand All @@ -106,6 +106,7 @@ and a failed one has `value == nil`; read `ok?` and not the value.
| `Statifier.start(machine, **options)` | a `Statifier::Driver::Moved`: the `state` and the `effects` of starting the chart |
| `Statifier.step(state, name, data:, processors:, clock:)` | a `Moved`, after one external event and every event the chart queues for itself; `:not_running` once the chart has stopped |
| `Statifier.advance(state, milliseconds, processors:, clock:)` | a `Moved`, after the virtual clock moves and every timer due fires, earliest first; `:invalid_duration` for a negative, infinite or non-numeric duration |
| `Statifier.report_send_failed(machine, state, send, **options)` | a `Moved`, after the chart takes `error.communication` for a send the host could not deliver; `:not_running` once the chart has stopped, `:halted` while a round budget has halted it |
| `Statifier.configuration(state)` | the active state ids, sorted, as a frozen list |
| `Statifier.done?(state)` | a `Statifier::Driver::DoneStatus`: `done`, and once the chart has stopped its `donedata` and `configuration` |
| `Statifier.export_position(state)` | the position, plain data ready for JSON, or a `Statifier::Position::Refusal` |
Expand Down Expand Up @@ -222,6 +223,24 @@ outbox.seen # => [["t1", "e.out", "s1"]]
sent.value.effects.map { |effect| effect.class.name.split("::").last } # => ["Send"]
```

A send a processor cannot deliver comes back to the chart as
`error.communication`, its `sendid` the send's id whether or not the author
named it. A processor that knows while it is handed the send answers
`Statifier::Driver::DeliveryFailure.new(reason: ...)` from `deliver`, and the
chart takes the error within that call. A host that learns of the miss later
reports it with the send it was handed; the reason is the host's own and the
chart is not handed it:

```ruby
failed = Statifier.report_send_failed(notify, sent.value.state, sent.value.effects.first,
reason: "unreachable", processors:)
failed.ok? # => true
```

Once the chart has stopped the report is refused with `:not_running`, and while
a round budget has halted it with `:halted`; nothing is written, and the miss is
the host's to record.

An `<invoke>` of the SCXML type runs in process: the driver compiles the
`<content>` markup and starts it as a child session on the parent's virtual
clock, its session id the parent's, a dot and the invoke id. `src` is never
Expand Down Expand Up @@ -299,11 +318,11 @@ claims to pass, and the gate fails if one of them does not.
| Claim | Cases claimed | Of |
|---|---|---|
| `scion` | 119 | 119 cases in the scion suite |
| `statifier` | 20 | 36 cases in the statifier suite |
| `statifier` | 21 | 36 cases in the statifier suite |
| `w3c-mandatory` | 152 | 154 mandatory cases in the w3c suite |
| `w3c-optional` | 2 | 14 optional cases in the w3c suite |

The registry holds 293 entries, which is the sum of the four rows. A claim is
The registry holds 294 entries, which is the sum of the four rows. A claim is
the set of its entries, not a statement that every case of its suite passes.

What is not claimed yet:
Expand All @@ -312,11 +331,10 @@ What is not claimed yet:
the reference's registry does not claim them either.
- Twelve optional w3c cases whose host needs the Basic HTTP Event I/O
Processor, which this package does not provide.
- Sixteen cases of the `statifier` suite, the reference's own: the ten diff
- Fifteen cases of the `statifier` suite, the reference's own: the ten diff
cases and the accepts case, whose host keys the conformance runner does not
read yet; the four cases that state an exported position, which it does not
compare yet; and the one case that asks the host to report a send failed,
which it does not report yet.
read yet; and the four cases that state an exported position, which it does
not compare yet.

Running the gate prints the gap list: every unclaimed case with the features it
needs and whether it passes or why it fails.
Expand Down
4 changes: 4 additions & 0 deletions changelog.d/srb-1xq.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Added

* `Statifier.report_send_failed(machine, state, send, **options)` reports a send the host was handed and could not deliver: the chart takes `error.communication`, its `sendid` the send's id whether or not the author named it, and runs to a stable configuration; the options are `reason:` (not read), `processors:` and `clock:`. It answers the error `:not_running` once the chart has stopped and `:halted` while a round budget has halted it.
* A processor's `deliver` may answer `Statifier::Driver::DeliveryFailure.new(reason:)` for a send it cannot deliver, and the chart takes the same `error.communication` within the call that handed the send.
8 changes: 6 additions & 2 deletions conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,12 @@ order and each with its outcome, must be exactly `expect_sends`. A
`<cancel>` that reaches the host marks the delayed send it names
`"outcome": "cancelled"` when the expected item at that send's position
asks for it, and a step of a case with a host object sends its event's
`data`, when it gives one. An expected send that asks the host to report it
failed (`"outcome": "fail"`), any other host key (the accepts check's
`data`, when it gives one. A send whose expected item asks the host to
report it failed (`"outcome": "fail"`) is marked so and reported through
`Statifier.report_send_failed` once the driver call that handed it returns,
before the configuration is read, as the reference's harness reports it
before it reads the next configuration; a report the driver refuses because
the chart has stopped is dropped. Any other host key (the accepts check's
`declared_events` and `expect_accepts`, the diff pair's `to_source`,
`expect_diff`, `mapping` and `expect_compatible_at`), and a step stating an
exported position each fail the case naming what it asks for. This
Expand Down
1 change: 1 addition & 0 deletions conformance/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@
{"case_id":"statifier/library/patron_reinstated_after_block","suite":"statifier"},
{"case_id":"statifier/send/registered_delayed_cancel","suite":"statifier"},
{"case_id":"statifier/send/registered_immediate","suite":"statifier"},
{"case_id":"statifier/send/registered_send_failed","suite":"statifier"},
{"case_id":"statifier/send/unregistered_type","suite":"statifier"},
{"case_id":"statifier/system_variables/registered_ioprocessors","suite":"statifier"},
{"case_id":"w3c/test144","suite":"w3c"},
Expand Down
41 changes: 40 additions & 1 deletion lib/statifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
# compiler ({Statifier::Compiler}), all internal. The driver
# ({Statifier::Driver}) steps a machine: {Statifier.start} starts a chart,
# {Statifier.step} sends it an event, {Statifier.advance} moves its virtual
# clock, and {Statifier.configuration} and {Statifier.done?} read it.
# clock, {Statifier.report_send_failed} tells it a send failed, and
# {Statifier.configuration} and {Statifier.done?} read it.
# {Statifier.export_position} writes a chart's execution out as plain data a
# host can keep, and {Statifier.import_position} reads it back in. The
# interpreter under the driver ({Statifier::Interpreter}) is internal.
Expand Down Expand Up @@ -185,6 +186,44 @@ def self.advance(state, milliseconds, processors: {}, clock: nil)
Driver::Calls.advance(state, milliseconds, processors:, clock:)
end

# Reports a send the host was handed and could not deliver:
# `error.communication` joins the chart's internal queue, its origin the
# send's content and its `sendid` the send's id whether or not its author
# named it, and the chart runs to a stable configuration, then takes
# whatever its external queue holds, as {step} does. The driver keeps no
# table of the sends it handed, so the report is not checked against one:
# it reaches the session `state` is for. A failure a processor knows of
# while it is handed the send is answered from its `deliver` as a
# {Driver::DeliveryFailure} instead, and fails the send within that call.
#
# Once the chart has stopped, or while a macrostep's round budget has
# halted it, the report is refused and nothing is written, as the
# reference's session drops it; the miss is then the host's to record.
#
# @example
# failed = Statifier.report_send_failed(machine, moved.state, send, processors:)
# failed.value.state # the chart after it took error.communication
#
# @param machine [Machine] the compiled chart `state` is an execution of
# @param state [Driver::State] what the last call answered
# @param send [Effect::Send, Effect::SendDelayed] the send the host's
# processor was handed
# @param options [Hash] the keywords below, each optional
# @option options [Object, nil] :reason the host's own account of the
# failure; not read, and the chart is not handed it
# @option options [Hash{String => #deliver}] :processors as {start} took
# them
# @option options [#call, nil] :clock as {start} took it
# @return [Result] a {Driver::Moved}, or the error `:not_running` once the
# chart has stopped, or `:halted` while a round budget has halted it
# @raise [TypeError] for a machine, a state or a send of the wrong kind
# @raise [ArgumentError] for an option it does not name, a state that is
# not an execution of `machine`, or processors that name other types
# than the chart was started with
def self.report_send_failed(machine, state, send, **options)
Driver::Calls.report_send_failed(machine, state, send, options)
end

# The active states, by id, the `<scxml>` root left out, sorted; a state
# the document gave no id is named `#` and its index. Empty once the
# chart has stopped ({done?} names the states it stopped in).
Expand Down
28 changes: 27 additions & 1 deletion lib/statifier/driver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,20 @@ module Statifier
# processor's `deliver` with the event a delivery would carry and a
# {Context}, and is never routed or scheduled, delayed or not: the
# processor holds a delayed one, and a `<cancel>` of its send id reaches
# that processor's `cancel` once. What `deliver` answers is not read.
# that processor's `cancel` once. What `deliver` answers is not read,
# unless it is a {DeliveryFailure}.
#
# A failed send. A send a processor could not deliver comes back to the
# chart as `error.communication` on its internal queue, its origin the
# send's content and its `sendid` the send's id whether or not its author
# named it, as the reference's `Statifier.Session.failed_send/3` writes it.
# A failure the processor knows of while it is handed the send is answered
# from `deliver` as a {DeliveryFailure}, and the event is raised then, at
# the send's place in the call, as the reference raises the failure its
# processor plans in `deliver/3`. A failure the host learns of later is
# reported through {Statifier.report_send_failed}, with the send it was
# handed: the driver keeps no table of handed sends. The reason the host
# gives is not read, and the chart is not handed it.
#
# A macrostep that spends its round budget halts the driver as the
# reference halts its session: further external events are queued and not
Expand Down Expand Up @@ -161,6 +174,18 @@ def done? = done
# @return [String] the session's id, the one `_sessionid` reads
Context = ::Data.define(:session_id)

# What a processor's `deliver` answers for a send it could not deliver:
# the send then fails within the call that handed it, as
# {Statifier.report_send_failed} fails a send reported later.
#
# @!attribute [r] reason
# @return [Object, nil] the host's own account of the failure; the
# driver does not read it and the chart is not handed it
DeliveryFailure = ::Data.define(:reason) do
# A failure with no reason given.
def initialize(reason: nil) = super
end

# The round budget when the host sets none.
MAX_MACROSTEP_ROUNDS = Interpreter::MachineState::MAX_MACROSTEP_ROUNDS
end
Expand All @@ -169,6 +194,7 @@ def done? = done
require_relative "driver/session_id"
require_relative "driver/work"
require_relative "driver/routing"
require_relative "driver/delivery"
require_relative "driver/checks"
require_relative "driver/invocations"
require_relative "driver/mailbox"
Expand Down
24 changes: 21 additions & 3 deletions lib/statifier/driver/calls.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
module Statifier
module Driver
# The bodies of the driver's calls, which {Statifier.start},
# {Statifier.step}, {Statifier.advance}, {Statifier.configuration} and
# {Statifier.done?} answer through, and the checks on what a host
# passes them.
# {Statifier.step}, {Statifier.advance}, {Statifier.report_send_failed},
# {Statifier.configuration} and {Statifier.done?} answer through, and
# the checks on what a host passes them.
#
# @api private
module Calls
Expand Down Expand Up @@ -49,6 +49,24 @@ def step(state, name, data:, processors:, clock:)
ok(Run.drain(Routing.enqueue(work, Event.external(name, data:), counted: false).stamped).moved)
end

# {Statifier.report_send_failed}'s body: the failed send's
# `error.communication` delivered onto the internal queue and the chart
# run to a stable configuration, then the external queue taken, as a
# step takes it. Refused once the chart has stopped, and while a
# macrostep's round budget has halted it, as the reference's session
# drops a report then.
#
# @return [Result]
def report_send_failed(machine, state, send, options)
processors, clock = Checks.report(machine, state, send, options).values_at(:processors, :clock)
work = Run.begin_call(Checks.state(state, processors), processors, clock)
return refused(:not_running) unless state.machine_state.running
return refused(:halted) unless state.halted.nil?

work, deferred = Delivery.failed(work.stamped, send)
ok(Run.drain(Run.perform(work, deferred)).moved)
end

# {Statifier.advance}'s body: the clock moved `milliseconds` forward, every
# timer due by then, anywhere in the tree of sessions, fired in
# due-then-scheduled order, each run to completion before the next: the
Expand Down
37 changes: 33 additions & 4 deletions lib/statifier/driver/checks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,7 @@ module Checks
# or a round budget that is neither a positive Integer nor
# `:infinity`
def start_options(options)
unknown = options.keys - START_OPTIONS.keys
raise ArgumentError, "unknown options: #{unknown.inspect}" unless unknown.empty?

options = START_OPTIONS.merge(options)
options = known(options, START_OPTIONS)
budget(options[:max_macrostep_rounds])
options.merge(types: registered_types(options[:processors]), session_id: session_id(options[:session_id]))
end
Expand Down Expand Up @@ -58,6 +55,38 @@ def state(state, processors)
raise ArgumentError, "processors must register the types the chart started with: #{state.send_types}"
end

# The options {Statifier.report_send_failed} takes, as they stand when
# the host names none.
REPORT_OPTIONS = { reason: nil, processors: {}.freeze, clock: nil }.freeze

# A failed-send report's options, after checking them and the chart and
# the send it names against `state`.
#
# @raise [TypeError] when `state` is not a driver state, `machine` is
# not a compiled machine or `send` is not a send a processor is handed
# @raise [ArgumentError] for an option {REPORT_OPTIONS} does not name, or
# when `state` is not an execution of `machine`
def report(machine, state, send, options)
options = known(options, REPORT_OPTIONS)
raise TypeError, "expected a compiled machine, got #{machine.class}" unless machine.is_a?(Machine)
raise TypeError, "expected a Statifier::Effect::Send or SendDelayed, got #{send.class}" \
unless send.is_a?(Effect::Send) || send.is_a?(Effect::SendDelayed)
return options if state(state, nil).machine_state.machine == machine

raise ArgumentError, "the state is not an execution of the machine named"
end

# `options` over `defaults`, after checking that `defaults` names
# every one.
#
# @raise [ArgumentError] for an option `defaults` does not name
def known(options, defaults)
unknown = options.keys - defaults.keys
raise ArgumentError, "unknown options: #{unknown.inspect}" unless unknown.empty?

defaults.merge(options)
end

# The session id the host named, or a fresh one, frozen: a host's
# string is copied first, so the state holds nothing the host can
# change and the host's string is never frozen in place.
Expand Down
45 changes: 45 additions & 0 deletions lib/statifier/driver/delivery.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

module Statifier
module Driver
# A send of a registered type handed to the host's processor, and a send
# that processor could not deliver, as the reference's session hands a
# send to its processor's `deliver/3` and writes a failed send in
# `Statifier.Session.failed_send/3`. Each function answers
# `[work, deferred effects]`, as {Routing}'s do.
#
# @api private
module Delivery
module_function

# A registered type's send handed to its processor with the event a
# delivery would carry; a delayed one is held under its send id, so a
# `<cancel>` of that id reaches the processor. A {DeliveryFailure} the
# processor answers fails the send here, at its place in the call, as
# the reference raises the failure its processor plans in `deliver/3`.
# The failure counts against the call's budget as an undelayed send
# onto the internal queue does, so a chart that sends again on every
# failure halts rather than running on within the call.
def hand_off(work, send)
work = hold(work, send) if send.is_a?(Effect::SendDelayed)
event = Routing.delivered_event(send, work.state.session_id)
answered = work.processors.fetch(send.type).deliver(send, event, work.context)
return [work, []] unless answered.is_a?(DeliveryFailure)

Routing.counted(work) { failed(_1, send) }
end

# The work with the send's type holding a delayed send under its id.
def hold(work, send)
types = (work.state.held_sends.fetch(send.send_id, []) | [send.type]).sort.freeze
work.with_state(held_sends: work.state.held_sends.merge(send.send_id => types).freeze)
end

# A send its processor could not deliver, as the reference's
# `Statifier.Session.failed_send/3` writes it: `error.communication`
# on the internal queue, its origin the send's content and its
# `sendid` the send's id whether or not its author named it.
def failed(work, send) = Routing.communication_error(work, send, sendid: send.send_id)
end
end
end
Loading
Loading