Skip to content

async_m monad + driver: non-blocking context I/O foundation - #45

Merged
next-nf merged 9 commits into
mainfrom
async-m-step1
Jul 27, 2026
Merged

async_m monad + driver: non-blocking context I/O foundation#45
next-nf merged 9 commits into
mainfrom
async-m-step1

Conversation

@next-nf

@next-nf next-nf commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Foundation for non-blocking context I/O — step 1 of a larger effort to stop the context gen_statem from blocking in selective receives on external calls (Gx/Gy/PFCP/GTP). Blocking there is a latent reentrant-deadlock generator: a mid-session Gx CCR-U blocks the FSM, the PCRF pushes an RAR for the same session, and if the CCA is gated behind the RAA the context can't answer it → deadlock. Same shape applies to PFCP reports, GTP Delete Session, OCS RAR.

This PR adds only the transport foundation. Nothing is rewired yet, so there is no behavior change — the new machinery is inert until a consumer parks a request id.

What's here

  • async_m — a hand-rolled erlando monad (state + error + suspension) whose value is fun((State, Data) -> {Result, State, Data}), Result :: {ok,_} | {error,_} | {await, ReqId, Conts}. >>= short-circuits on error and, on await, captures the rest of the do-block as a continuation list. Built on the erlando do/error_m stack already used across these modules — no new dependency.
  • Driver — run_async/5, handle_reply/4, resume/2: parks suspended computations in Data.async_pending keyed by request id and resumes them when the matching reply arrives. resume reverses the outermost-first continuation list so the innermost continuation gets the raw reply first, then folds outward.
  • async_apply/1 — spawns a monitored worker with a tagged monitor; success delivers {'$async_reply', ReqId, {ok, Result}}, a crash surfaces as a tagged DOWN mapped to {error, {worker_down, Reason}}.
  • gtp_context wiring — an async_pending slot, two guarded reply-dispatch info clauses plus a normal-exit ignore clause, and an async_dispatch/4 that keeps the existing throw:#ctx_err{}handle_ctx_error bridge alive across suspensions (so a throw from a resumed step still produces its protocol error reply).

Why a monad / continuations

Once calls become non-blocking, a multi-step procedure (send request → await reply → send next) can no longer live on one call stack. The continuation — the "where was I" — has to be reified. The monad makes procedures read as linear do([async_m || ...]) blocks while the process stays fully responsive between steps, which is exactly what lets a reentrant RAR be serviced while a CCR-U is outstanding.

Tests

  • async_m_SUITE: 16/16 — monad laws, three-way bind dispatch, suspend/resume ordering (incl. nested and multi-suspend), empty-continuation resume, and the worker success/crash paths.
  • pgw_SUITE: 124/124 — unchanged, proving no regression.

Scope / follow-ups

Next steps build on this: PFCP async mode (the only always-blocking interface), the establishment chain, terminate, and the per-context procedure gate. Two deferred robustness notes for the first real consumer: a symmetric ignore clause for a stale $async_reply, and error-channel conversion for procedures that commit compensable side effects.

Enables the async plumbing that #22 (UE-requested bearer resource modification) and #23 (overriding PCRF QoS on Modify Bearer Command) need. Does not close them.

🤖 Generated with Claude Code

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