Skip to content

drain the coarse gate on async errors + ignore stale async replies - #59

Merged
next-nf merged 2 commits into
mainfrom
async-gate-51-m3
Jul 27, 2026
Merged

drain the coarse gate on async errors + ignore stale async replies#59
next-nf merged 2 commits into
mainfrom
async-gate-51-m3

Conversation

@next-nf

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

Copy link
Copy Markdown
Owner

Closes #51. Also resolves the step-2 design-review item M3.

The coarse gate postpones procedure-initiating events while async_pending is
non-empty and relies on the drain being a state-term change — that is what makes
gen_statem re-deliver postponed events. Success callbacks honour this
(update_credits_ok/3, br_ok/5 return {next_state, DrainedState, …}). The error
path did not, in two places.

1. handle_ctx_error/4 discarded its State. The head bound _State and the
recoverable branches returned {keep_state, Data} — keeping the state term current
before the event, which still holds the stale registry entry. update_credits_err/3
passed the correctly drained State and it was thrown away.

2. async_dispatch/4's catch ran on a pre-drain State — the worse leak, and not
mentioned in the issue. async_m:handle_reply/4 does maps:take(ReqId, Pending) and
threads the drained S1 into the resumed computation; if a resumed step throws
#ctx_err{}, that S1 dies with the throw and the catch clause gets the State captured
at dispatch — holding a ReqId whose reply has already been consumed and can never
arrive again
. pgw_s5s8:br_err/9's throw(E) re-throw lands exactly there. Fixing
only (1) would have left this in place.

Fix

  • handle_ctx_error/4 binds its State and returns {next_state, State, Data} from the
    recoverable branches. A no-op for synchronous callers: gen_statem retries postponed
    events only when the state term actually changes, and both sync call sites pass their
    clause's State — the state at event entry, which is exactly what keep_state
    preserved. FATAL branches unchanged.
  • async_dispatch/4 pre-computes the drained State for its catch clause. Correct for
    every throw: a procedure that re-parks returns from async_m:dispatch/3 and never
    reaches the catch, so no ReqId registered during that invocation can be lost.
  • M3: explicit ignore for a $async_reply naming a non-pending ReqId, mirroring the
    {'$async_down', _} clause below it.

Reachability. Not reachable today, verified rather than assumed: smf_core contains
exactly one ?CTX_ERR(?WARNING, …) (gtp_context.erl:942, mandatory-IE validation, which
runs before any procedure starts). Every post-await failure is FATAL, and FATAL stops the
process, taking its postponed mailbox with it. This is a landmine fix — any future async
procedure whose error callback carries a recoverable #ctx_err{} would have silently
wedged that session's gate.

Testing. async_error_drains_gate injects a recoverable post-await error with no
production test surface: await_modify/1 lifts smf_pfcp_context:modify_session_result/2
into async_m's error channel, and smf_pfcp_context is already mecked with passthrough
by smf_test_lib:meck_init/1. The UPF is held so the park is observed (async_pending = 1),
then released; the case asserts the registry drains to 0 and that delete_session — a gated
procedure-initiating event — still completes.

Verified failing first for the right reason: before the fix it fails on the =:= 0 wait
with {badmatch, {error, timeout}}, having passed the =:= 1 park assertion. After the
fix it passes.

M3 is deliberately untested: pgw_s5s8, ggsn_gn and saegw_s11 each have an
handle_event(info, _Info, …) catch-all, so such a reply is already dropped today and a
test would pass before and after. The value is making the ignore a property of the driver
rather than an accident of each interface.

pgw_SUITE 135/135, ggsn_SUITE + saegw_s11_SUITE alongside it 235 total, 0 failures —
those three cover handle_ctx_error/4's recoverable branch heavily via ordinary protocol
error replies.

Known adjacent item, deliberately out of scope: update_credits_err/3 has only a
#ctx_err{} clause, so a non-#ctx_err error reaching it is a function_clause crash. A
different failure mode, unreachable today (the PFCP transport posts no monitored-worker DOWN).

Stacked on #58. Merge bottom-up: #45#46#48#56#57#58 → this.

@next-nf
next-nf force-pushed the async-ue-bearer-22-reject branch from 0dac27c to 54053d9 Compare July 27, 2026 12:23
@next-nf
next-nf force-pushed the async-gate-51-m3 branch from 4fb8c8e to 8657782 Compare July 27, 2026 12:23
Base automatically changed from async-ue-bearer-22-reject to main July 27, 2026 12:28
@next-nf
next-nf merged commit 51bb99f into main Jul 27, 2026
@next-nf
next-nf deleted the async-gate-51-m3 branch July 27, 2026 12:29
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.

async error callbacks routing through handle_ctx_error/4 don't drain async_pending -> coarse gate can wedge

1 participant