Wymcp.Transport.Stream (Wymcp v0.1.1)

View Source

The chunked SSE connection for one session, run as a receive loop by the GET request process that owns its socket; one active stream per session — a new GET replaces the old.

Design decisions

Every chunk write happens in the request process, because real adapters enforce socket ownership: Bandit raises when any process other than the request's own calls Plug.Conn.chunk/2 (Plug.Test has no such check, which is how a separate writer process ever appeared to work). The stream is still a separate process from Wymcp.Session — the session must keep serving POSTs while the stream blocks — but that process is the GET request process itself, so the conn is never handed over.

serve/3 runs the stream's whole lifetime: register with the session, commit the 200, send the priming event, then serve pushes, keepalives, and replacement from the receive loop until the stream ends — the client disconnects (a chunk write fails), the session dies (monitored), or a new GET replaces this one. The final conn travels back up the plug stack like any other response. A registration failure answers {:error, :session_gone} before anything is committed, so the router still sends a clean 404; a priming write that fails ends the stream right there, before the loop and before a keepalive timer is armed.

A disconnect is the only ending the session cannot see for itself. Ending the stream is not ending this process: the request process belongs to the adapter, which may keep it alive for the connection's next request, so no :DOWN reaches Wymcp.Session's stream monitor and a registration left behind would make every later push wait out its caller's own call timeout before answering. Every failed write therefore goes through one funnel that tells the session (Wymcp.Session.unregister_stream/2, a cast — the loop must never wait on the session) and then closes. Replacement and session death need no such message: the session already holds the successor's pid in the first case and is gone in the second.

The session reaches the loop only through this module's public API. push/3 is a stream-answered push: the session hands the pre-encoded payload and a reply address to the loop and never blocks on the write — the loop answers the address with the push ack (answer_push/2): the pusher directly on a plain push, the session on a server-request round trip's push leg, nobody for a list-changed notification. replace/1 is a plain send: asking, never killing, because the loop's process is serving a committed 200. The loop ends with a catch-all clause that drops unknown messages at debug level — it must never crash on a stray, and strays exist by construction (Plug.Test's adapter notifies the conn owner; ThousandIsland's read timer can fire mid-callback).

sequenceDiagram
    participant Client
    participant Router as Router (GET /)
    participant Stream as Stream loop (request process)
    participant Session

    Client->>Router: GET / (Mcp-Session-Id)
    Router->>Session: lookup + touch
    Router->>Stream: serve(conn, session_pid)
    Stream->>Session: register_stream(self())
    Note over Session: asks a replaced old stream to stop
    alt registration fails (session died or timed out)
        Stream-->>Router: {:error, :session_gone}
        Router-->>Client: 404 (pre-commit)
    else registered
        Stream-->>Client: 200 chunked, priming event, keepalives
        Session->>Stream: push(json, reply_to) / replace
        Stream-->>Client: SSE events
        Note over Stream: answers reply_to with the push ack
        Stream-->>Router: final conn (stream ended)
    end

Event IDs

Each SSE event gets a monotonically increasing integer ID. Clients use Last-Event-ID on reconnection to indicate the last event they received. Full replay is out of scope — no missed events are re-sent; the counter resumes after the client's last seen event (Last-Event-ID: evt-7 makes the priming event evt-8).

The header is raw client input: an id that does not read as evt-<non-negative integer> is not an error — the resumption point is discarded with a warning log naming the raw value, and the counter resumes from 0. evt-0 is a legitimate resumption point, not a discard.

flowchart TD
    subgraph Stream
        ST[Transport.Stream] --> L["receive loop (push / replace / keepalive)"]
        ST --> PR["priming event"]
        ST --> K["keepalive timer"]
    end
    subgraph External
        ST -->|"register_stream / unregister_stream, monitors"| S[Session]
        L --> SSE[Transport.SSE]
    end

Summary

Types

Who awaits one push — the address the loop answers with the push ack.

Functions

Hands one pre-encoded push to the stream loop — a stream-answered push: the caller never blocks here; the loop answers reply_to with the push ack after the chunk write.

Asks the stream loop to end so a new GET can take over the session.

Runs the SSE stream for session_pid in the calling process — the GET request process — and blocks until the stream ends.

Types

reply_to()

@type reply_to() :: {:caller, GenServer.from()} | {:ack, pid(), term()} | :none

Who awaits one push — the address the loop answers with the push ack.

serve_opts()

@type serve_opts() :: %{optional(:keepalive_interval) => pos_integer()}

Functions

push(stream_pid, json, reply_to)

Hands one pre-encoded push to the stream loop — a stream-answered push: the caller never blocks here; the loop answers reply_to with the push ack after the chunk write.

{:caller, from} answers a GenServer.caller directly via GenServer.reply/2 (Wymcp.Session forwards the pusher's own from, so a late ack is dropped by the caller's reply alias); {:ack, session_pid, tag} sends {:push_ack, tag, result} to the session — the server-request round trip's push leg; :none expects no answer (list-changed notifications). The ack vocabulary: :ok — written; {:error, :disconnected} — the chunk write failed and the stream is closing, or the push was still queued when the stream closed (the close drain answers it). A push sent in the gap between registration and loop entry queues in the loop's mailbox and is answered after the priming event.

replace(stream_pid)

Asks the stream loop to end so a new GET can take over the session.

A plain send — never blocks the caller, never kills: the loop's process is a request process serving a committed 200, and it finishes normally by returning its conn up the plug stack.

serve(conn, session_pid, opts \\ %{})

Runs the SSE stream for session_pid in the calling process — the GET request process — and blocks until the stream ends.

Returns {:ok, conn} with the final conn once the stream ends (client disconnect, session death, or replacement by a new GET). Returns {:error, :session_gone} before anything is committed when registering with the session fails — the session died between the router's lookup and registration, or the registration call timed out; the log distinguishes the two. The resumption point comes from Wymcp.Plugs.SingletonHeaders' :wymcp_last_event_id assign, not from the header directly: a single well-formed value resumes the counter, and a missing, malformed, or duplicated one starts fresh from 0 (the last two with a warning). Calling this function on a conn the check never touched raises — the assign is the contract.

opts may carry :keepalive_interval in milliseconds (default 15 000); only tests override it.