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
6 changes: 6 additions & 0 deletions changelog.d/st-gje8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### Added

- The W3C Basic HTTP Event I/O Processor, `Statifier.Send.BasicHTTP`: register it in `:send_types` under `http://www.w3.org/TR/scxml/#BasicHTTPEventProcessor` and `basichttp` as `{Statifier.Send.BasicHTTP, base_url: ...}`, and both `_ioprocessors` keys carry one location, the base URL and the session id. A registration without `:base_url` is refused when the session starts. Sends POST through an injectable `Statifier.Send.BasicHTTP.Transport` (OTP `:httpc` by default; no new dependency); a delayed send is the processor's timer and a `<cancel>` stops it. `decode/1` turns a request into an event for a host's own front, and a failed delivery raises `error.communication` on the sender.
- Every Basic HTTP POST carries the send's dedup key in an `scxml-send-key` header, so a receiver that deduplicates on it delivers each send once; `decode/1` takes the header's value as `:send_key` and refuses a malformed one, and sets no event field from it.
- A `:send_types` value may now be `{module, opts}`: the options reach the processor's callbacks under the plan context's `:opts` key and are recorded as strings. A processor may implement the optional `ioprocessors_entry/2`, which receives the session id and the options.
- `Statifier.Testing.Case.test_scxml/5` takes a `:send_types` option.
63 changes: 63 additions & 0 deletions docs/adr/0075-basichttp-event-io-processor.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,3 +361,66 @@ nothing:
- [ADR-0051](0051-invoke-handlers-are-registered-per-session.md) (decision 4, the planning and performing split)
- [ADR-0057](0057-recording-identity-and-serialization.md) (decision 5, registrations recorded as strings)
- [ADR-0054](0054-durable-timers-consume-the-effect-vocabulary.md) (the processor-owned timer and its cancellation key)

### Amendment 2026-09-30: every POST carries the send's dedup key, and the receiver deduplicates

Status: proposed (2026-09-30) - amends decision 4 (the outbound mapping)
and decision 5 (the inbound decoder) by addition; every other decision,
and the record's own Status above, are unchanged. The header,
at-least-once delivery and deduplication by the receiver were ruled by
the operator, 2026-09-30; what the decoder does with the header is this
record's.

[ADR-0069](0069-host-registered-send-types.md) decision 4 binds every
registered processor: "A processor MUST be idempotent on the ADR-0054
decision 3 dedup key's components read off the effect", because "after a
crash and retry, a host may perform the same effect more than once."
Decision 8 point d above has the processor make one attempt per
`perform/2` and keep no memory between calls, so a host that performs the
same instruction twice POSTs twice. Neither decision 4 nor decision 5 said
how the MUST is met. This Amendment says it.

**The processor is at-least-once, and the receiver deduplicates.** Every
POST the processor makes, immediate or delayed, whatever its body,
carries the send's dedup key in one request header:

- **Name:** `scxml-send-key`.
- **Value:** the eight components of
[ADR-0054](0054-durable-timers-consume-the-effect-vocabulary.md)
decision 3's deduplication key, as that record and ADR-0059 order them,
joined by `/`: the session scope, `send_id`, `macrostep`, `microstep`,
`round`, `c_index`, `owner`, `ordinal`.
- The session scope is the plan context's `session_id` (spec 5.10's
`_sessionid` for a live session, a host's own scope for a
process-less host), percent-encoded.
- `send_id` is percent-encoded. Percent-encoding here escapes every
byte outside RFC 3986's unreserved set (`A-Z a-z 0-9 - . _ ~`), so
neither field can carry a `/`.
- `macrostep`, `microstep`, `round`, `c_index` and `ordinal` are
decimal integers.
- `owner` is spelled `onentry.S.B`, `onexit.S.B` or `finalize.S.B` with
its state and block indexes, or `transition.T` with its transition
index.
- A component the effect does not carry is the empty string.

Every component is a deterministic counter or a static position, so a
re-performed instruction sends a byte-identical value. A receiver that
enqueues a request only when it has not already enqueued one with the same
`scxml-send-key` delivers each send once: for such a receiver ADR-0069's
MUST holds end to end. A receiver that ignores the header sees
at-least-once delivery. The processor itself still keeps no memory across
`perform/2` calls.

**What the decoder does with it.** `decode/1`'s request map takes the
header's value under an optional `:send_key` key, and sets no event field
from it: an inbound event's `sendid` stays unset, as it is for a request
without the header. A value that is not eight `/`-separated fields whose
second field percent-decodes to UTF-8 is
`{:error, {:malformed_send_key, value}}`, answered 400 by decision 5's
status rule; an absent one changes nothing. The decoder does no
deduplicating: it is pure and remembers nothing. A front deduplicates on
the `scxml-send-key` header's value itself, and a request it has already
enqueued is answered 204 again with nothing enqueued. This repository's
loopback front, which lives only as long as one test run, does not
deduplicate; statifier_router's durable front, which must survive a
restart, is the one that will.
128 changes: 128 additions & 0 deletions lib/mix/statifier/basic_http_front.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
defmodule Mix.Statifier.BasicHTTPFront do
@moduledoc """
The loopback inbound front this repository's own runs deliver Basic HTTP
sends through (ADR-0075), on OTP's `:inets` httpd, with no dependency. It
is repository tooling, not part of the package.

`start/0` binds a free port on 127.0.0.1 and answers the base URL a
`Statifier.Send.BasicHTTP` registration takes as its `:base_url`, so a
session's `_ioprocessors` location is `base_url <> "/" <> session_id`.
A request to that location is resolved to the live session registered
under the id in `Statifier.Registry`, decoded by
`Statifier.Send.BasicHTTP.decode/1`, and enqueued on the session as an
external event, with the request's `scxml-send-key` header handed to the
decoder. This front does not deduplicate on that header (ADR-0075's
Amendment of 2026-09-30 leaves that to a front that outlives a restart).
The status rule is the decoder's (ADR-0075 decision 5):

- 204 once the event is enqueued, before it is processed;
- 405 with `Allow: POST` for any other method;
- 400 for a request that forms no event;
- 404 for a path that names no live session.
"""

alias Statifier.Send.BasicHTTP
alias Statifier.Session

require Record

Record.defrecordp(:mod, Record.extract(:mod, from_lib: "inets/include/httpd.hrl"))

# `:inets` is not an application this package lists (ADR-0075 decision 6),
# so dialyzer's PLT does not see httpd; the calls into it are kept to the
# two functions named here.
@dialyzer {:nowarn_function, start: 0, stop: 1}

@prefix "/basichttp"

@typedoc "A running front: the httpd process and the base URL it answers at."
@type t :: %{pid: pid(), base_url: String.t()}

@doc """
Starts a front on a free loopback port. Starts `:inets` first.
"""
@spec start() :: {:ok, t()} | {:error, term()}
def start do
root = String.to_charlist(System.tmp_dir!())

with {:ok, _apps} <- Application.ensure_all_started(:inets),
{:ok, pid} <-
:inets.start(:httpd,
port: 0,
bind_address: {127, 0, 0, 1},
server_name: ~c"statifier-basichttp",
server_root: root,
document_root: root,
modules: [__MODULE__]
) do
[port: port] = :httpd.info(pid, [:port])
{:ok, %{pid: pid, base_url: "http://127.0.0.1:#{port}#{@prefix}"}}
end
end

@doc "Stops a front `start/0` started."
@spec stop(front :: t()) :: :ok | {:error, term()}
def stop(%{pid: pid}), do: :inets.stop(:httpd, pid)

@doc """
The httpd callback, `do/1` (a name only `unquote/1` can define):
answers one request by the status rule in the moduledoc, through
`respond/1`.
"""
@spec unquote(:do)(mod_data :: tuple()) :: {:proceed, list()}
defdelegate unquote(:do)(mod_data), to: __MODULE__, as: :respond

@doc "Answers one httpd request by the status rule in the moduledoc."
@spec respond(mod_data :: tuple()) :: {:proceed, list()}
def respond(mod_data) do
%URI{path: path, query: query} = mod_data |> mod(:request_uri) |> to_string() |> URI.parse()

headers = mod(mod_data, :parsed_header)

request = %{
method: mod_data |> mod(:method) |> to_string(),
content_type: header(headers, ~c"content-type"),
send_key: header(headers, ~c"scxml-send-key"),
body: mod_data |> mod(:entity_body) |> IO.iodata_to_binary(),
query: query
}

{:proceed, [{:response, {:response, head(answer(path, request)), []}}]}
end

@spec answer(path :: String.t() | nil, request :: BasicHTTP.request()) ::
204 | 400 | 404 | 405
defp answer(@prefix <> "/" <> session_id, request) do
with {:ok, pid} <- whereis(session_id),
{:ok, event} <- BasicHTTP.decode(request) do
:ok = Session.send_event(pid, event)
204
else
:no_session -> 404
{:error, {:method_not_allowed, _method}} -> 405
{:error, _reason} -> 400
end
end

defp answer(_path, _request), do: 404

@spec head(status :: 204 | 400 | 404 | 405) :: keyword()
defp head(405), do: [code: 405, allow: ~c"POST", content_length: ~c"0"]
defp head(status), do: [code: status, content_length: ~c"0"]

@spec whereis(session_id :: String.t()) :: {:ok, pid()} | :no_session
defp whereis(session_id) do
case Registry.lookup(Statifier.Registry, session_id) do
[{pid, _value}] -> {:ok, pid}
[] -> :no_session
end
end

@spec header(headers :: [{charlist(), charlist()}], name :: charlist()) :: String.t() | nil
defp header(headers, name) do
case List.keyfind(headers, name, 0) do
{_name, value} -> to_string(value)
nil -> nil
end
end
end
6 changes: 3 additions & 3 deletions lib/statifier/effect/send.ex
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ defmodule Statifier.Effect.Send do
different case entirely: it is an argument failure that discards the
whole `<send>` (ADR-0036), never a value that reaches `data` at all. This
settles the `#_internal`, same-session, and `#_scxml_<sessionid>` routes
that exist today; a future external-wire processor (BasicHTTP or
otherwise) owns its own `:undefined` encoding at its own boundary, not
here.
that exist today; a registered external-wire processor, such as
`Statifier.Send.BasicHTTP` (ADR-0075), owns its own `:undefined`
encoding at its own boundary, not here.
"""

alias Statifier.Machine.Content
Expand Down
41 changes: 36 additions & 5 deletions lib/statifier/evaluator/system_variables.ex
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,13 @@ defmodule Statifier.Evaluator.SystemVariables do
`"location"`, is always there. A session that registers send types
(ADR-0069) supports each of them too, so `_ioprocessors` also carries one
entry per registered type string, whose value the type's processor
supplied (`Statifier.Send.Types.from_send_types/1` read it). With
supplied. A processor that exports the optional
`c:Statifier.Send.Processor.ioprocessors_entry/2` is asked here, with
the type string and a context carrying `session_id` and the
registration's `opts`, so its entry can address this one session
(ADR-0075 decision 3); a processor that exports only
`c:Statifier.Send.Processor.ioprocessors_entry/1` gets the entry
`Statifier.Send.Types.from_send_types/1` read, exactly as before. With
`send_types` `nil`, which is what a session registering nothing carries,
the map holds the SCXML entry alone, exactly as before registered types
existed. A registered entry never replaces the SCXML entry: a set naming
Expand Down Expand Up @@ -108,16 +114,41 @@ defmodule Statifier.Evaluator.SystemVariables do
"_event" => :undefined,
"_ioprocessors" =>
Map.put(
registered_entries(send_types),
registered_entries(send_types, session_id),
@scxml_event_processor,
%{"location" => scxml_location(session_id)}
)
}
end

@spec registered_entries(send_types :: Types.t() | nil) :: %{String.t() => map()}
defp registered_entries(nil), do: %{}
defp registered_entries(%Types{entries: entries}), do: entries
@spec registered_entries(send_types :: Types.t() | nil, session_id :: String.t()) ::
%{String.t() => map()}
defp registered_entries(nil, _session_id), do: %{}

defp registered_entries(%Types{entries: entries, processors: processors}, session_id) do
Map.new(entries, fn {type, entry} ->
case Map.fetch(processors, type) do
{:ok, {module, opts}} -> {type, session_entry(module, type, opts, session_id, entry)}
:error -> {type, entry}
end
end)
end

# ADR-0075 decision 3: a module that exports `/2` is asked for its entry
# with the session id; one that exports only `/1` keeps the entry the
# registered set was built with.
@spec session_entry(
module :: module(),
type :: String.t(),
opts :: keyword(),
session_id :: String.t(),
entry :: map()
) :: map()
defp session_entry(module, type, opts, session_id, entry) do
if function_exported?(module, :ioprocessors_entry, 2),
do: Types.session_entry!(module, type, %{session_id: session_id, opts: opts}),
else: entry
end

@doc """
`_event`'s value for `event` - spec 5.10.1's fields, read straight off
Expand Down
2 changes: 1 addition & 1 deletion lib/statifier/replay.ex
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ defmodule Statifier.Replay do
# plan context's `send_processors` for the same reason
# `invoke_handlers` is, and the live holds `Statifier.Session`
# keeps for its cancel routing, kept here by the same rule.
send_processors: %{String.t() => module()},
send_processors: %{String.t() => SendTypes.registration()},
held_sends: Effects.held_sends()
}
end
Expand Down
Loading
Loading