Add native PO Box calls (proposal 3 of #15) - #17
Add native PO Box calls (proposal 3 of #15)#17eric-descourtis-thenvoi wants to merge 10 commits into
Conversation
Add native request/response over a PO Box (issue 15, proposal 3). call/2,3
buffers a {'$pobox_call', ReplyTo, Request} message (ReplyTo is an
alias-monitor ref), then waits for the owner's reply, a drop, box death, or
timeout. The owner drains the wrapped call like any message, distinguishes it
with is_call/1, and answers with reply/2 straight to the caller. call/3 also
accepts a #{timeout => T} options map (weight key reserved for weighted boxes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a full keep_old box rejects a new message that is a call, send the
caller {error, dropped} immediately via maybe_notify_drop/1 instead of
leaving it to time out. The dropped element is the incoming message (already
in hand), so no drop-path enumeration is added and plain posts / bulk drops
are unaffected. keep_old is thus the drop-safe substrate for calls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the active filter returns drop for a call element during a drain, route
it through maybe_notify_drop/1 so the caller gets {error, dropped} instead of
timing out. The element is already popped by filter/7, so no extra work is
added for plain messages.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Characterization tests for the call error paths that call/2,3 already
provides: box death -> {error, noproc} (via the caller's alias-monitor),
no reply -> {error, timeout}, unregistered name -> {error, noproc}. Plus the
cost-aligned degradation contract: a call bumped out of a plain queue by a
later post is NOT notified (bulk overflow drop) and times out — documenting
that keep_old is the type to use when calls must be drop-safe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Twenty clients call the same box concurrently; the owner drains cohorts and
answers each, and every client receives its own {ok, I*I}. Exercises the
per-caller alias-reply routing under concurrency (the fan-out pattern
consumers hand-roll today). Stable across repeated runs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial review HIGH: demonitor(Ref, [flush]) only clears a pending
'DOWN', not our {'$pobox_reply', Ref, _} / {'$pobox_drop', Ref}. If the
owner's reply (or an internal drop) lands in the caller's mailbox in the
preemption window between the receive timing out and demonitor running, the
message would linger forever. Explicitly flush it on the timeout path (the
alias is already deactivated, so no later one can arrive).
The exact sub-instruction race is not deterministically reproducible from a
black-box test; add a 500-round reply-vs-timeout stress test that exercises
the path and asserts each caller ends with exactly one clean outcome and no
orphaned pobox-internal message.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review LOW: call/3 monitors the pid resolved via where/1 but cast to the original name. Cast to the same resolved pid so a name re-registration between resolve and post can't split the monitor and the post across two processes. No behavior change for the common pid/registered-name case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a "Calls" section to the README (call/2,3, reply/2, is_call/1, the owner-answers-directly pattern, and the keep_old drop-safety guidance) and a 1.4.0 changelog entry; bump the app vsn. Sequenced after the weighting PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Self-review — native PO Box calls (proposal 3 of issue 15)Note ✅ Review complete. Multi-pass, 8 lanes, every candidate verified against the branch tip and (for the concurrency claims) against a live OTP 27 shell. Verdict: COMMENT — no blockers. The concurrency core (alias-monitor reply, timeout flush, drop-notify hooks) is correct and empirically verified; the two prior self-review fixes (E1, E2) genuinely hold. Findings are 2 MED (one clean crash on a valid input, one wire-shape trust boundary) + 5 LOW (opt/versioning/coverage polish). Nothing gates merge on its own. Methodology: fresh multi-pass sweep on an isolated worktree at the branch tip (
MED (PR-introduced)M1 —
|
| Behavior | Tested? |
|---|---|
call/2 default 5000 timeout |
GAP (primary arity, zero coverage) |
call/3 #{timeout => T} map form / infinity |
indirect only |
call/3 invalid 3rd arg (float/atom/proplist) → function_clause |
GAP |
is_call/1 false case |
GAP (only the true case, indirectly) |
reply/2 guard reply(NotARef,_) |
GAP |
{mod, Mod} buffer + call drop |
GAP |
stack buffer + calls |
GAP |
| drops under concurrency (concurrent test uses size-100 keep_old, never overflows) | GAP |
pobox_call_SUITE all/0 : 9 cases, all via call/3 with explicit integer timeout
-> call/2 (:233 default 5000) never entered
-> reply/2 guard (:278) never exercised with a bad arg
L4 — E1 stress test rarely reaches the real reply-vs-timeout window
test/pobox_call_SUITE.erl:117-155
call_timeout_leaves_no_stray_message runs 500 rounds at a 1 ms timeout to guard the E1 flush, and its Stray == [] assertion is the right invariant. But the owner's active → wait → reply round-trip is far longer than 1 ms, so in most rounds the caller has already timed out (and run its flush) before the reply is even sent — the reply then hits the already-deactivated alias and is dropped by the VM, never touching the flush branch. The test mostly exercises the clean-timeout path.
Fix: to hit the flush branch deterministically, pre-stage a {'$pobox_reply', Ref, _} into the caller's mailbox and then call with timeout 0.
Evidence & trace
The E1 fix itself is sound — I verified it independently on OTP 27 (see Verified prior fixes): demonitor(Ref,[flush]) deactivates the reply_demonitor alias, so a later reply is dropped, and the after 0 drain catches one already enqueued. The finding is only that this specific test is weak evidence for the fix, not that the fix is wrong. The test's own comment concedes "the exact sub-instruction race … is not deterministically reproducible."
race_round : caller call(...,1ms) ; owner active->{mail}->reply (>>1ms)
-> caller after-1ms fires + demonitor[flush] BEFORE reply is sent (most rounds)
-> reply hits dead alias -> dropped by VM -> flush branch (:264-268) not reached
Runtime-observable behavior (event fan-out)
This PR sends raw messages to caller mailboxes, so the fan-out gate applies. Every consumer was enumerated and verified — statically for existence, live on OTP 27 for the ordering/idempotency/leak consequences.
| Message (site) | path:line |
Consumer | Verdict |
|---|---|---|---|
{'$pobox_call', ReplyTo, Request} (buffered → owner drain) |
src/pobox.erl insert/filter |
owner active filter + owner process | ✅ delivered to owner only; is_call/1 guards it |
{'$pobox_reply', ReplyTo, Reply} — reply/2 |
src/pobox.erl:279 |
caller receive |
✅ unique per-call alias → exactly the originating caller; no cross-talk |
{'$pobox_drop', ReplyTo} — keep_old admission-reject |
src/pobox.erl:501 |
caller receive |
✅ element never buffered (push_drop(keep_old…)→Data), so cannot be re-dropped |
{'$pobox_drop', ReplyTo} — filter drop |
src/pobox.erl:546 |
caller receive |
✅ the only site that drops an already-buffered call |
{'$pobox_drop', SomeRef} — third party via plain post/2 |
src/pobox.erl:511 |
whatever SomeRef aliases |
🟠 spurious → M2 |
{'DOWN', ReplyTo, process, _, _} |
src/pobox.erl:256 |
caller receive |
✅ {error,noproc}; on timeout cleared by demonitor([flush]) |
Cleared invariants (verified live): exactly-one-outcome per caller; no double-notify (alias one-shot); no monitor/alias leak on any path (reply/drop auto-deactivate, timeout demonitors); ordering safe (owner and caller are different mailboxes; reply/drop mutually exclusive per call).
Verified prior fixes
| Fix | Claim | Status |
|---|---|---|
| E1 | timeout path flushes a reply/drop that raced the timeout | ✅ verified live — demonitor(Ref,[flush]) deactivates the reply_demonitor alias on OTP 27 (a send after it is dropped), and the after 0 drains one already enqueued. No orphan. |
| E2 | call casts to the resolved box pid, not the name |
✅ verified — monitor and cast both target the single BoxPid from one where/1; no name-reregistration split. |
Evidence & trace (live OTP 27 probes)
TEST1 (demonitor[flush] then send to alias): received=[] => alias deactivated: true
TEST2 (two sends to reply_demonitor alias): received=[first] => one-shot (2nd dropped): true
TEST3 (monitor already-dead pid): received=[{'DOWN',Ref,process,Pid,noproc}]
These settle the three load-bearing OTP semantics the whole design rests on: the alias is one-shot, demonitor(_,[flush]) deactivates it (so E1's "no later one can arrive" is true), and monitoring a dead pid immediately yields noproc (so the where/1→monitor race resolves to {error,noproc}).
Refuted live (candidates the runtime disproved)
- Double-notify on the README's documented owner pattern — a lane flagged that the canonical loop (
reply(ReplyTo,…)then return{drop, S}) would send the caller both a reply and a{'$pobox_drop'}, leaving a stray message. Refuted: running that exact pattern for 2000 rounds under tight (1-3 ms) timeout races produced 0 stray messages and 0 incoherent outcomes.reply/2fires first and deactivates the one-shot alias, so the subsequent filter-drop notify is silently swallowed. Evidence:client outcome = {ok,answered},stray internal msgs = []. - Unbounded buffer growth under a call flood to a passive box — a lane raised it; refuted:
MaxSize > 0is mandatory at everystart_linkarity, so buffered calls are capped atmaxexactly like plain posts (excess drop; keep_old →{error,dropped}, queue/stack → silent overflow + timeout). No new unbounded surface.
Test execution
CI: none configured on the repo (statusCheckRollup empty) — the reviewer is the gate.
Ran in an isolated detached worktree at the branch tip (0ce2de1), using the repo's .bin/rebar3:
rebar3 ct -> All 76 tests passed
(pobox_SUITE 52, pobox_call_SUITE 9, give_away 7, heir 8)
rebar3 proper -> 3/3 properties passed (100 tests each)
rebar3 dialyzer -> clean (3 files analyzed, 0 warnings)
Tests added by this PR (test/pobox_call_SUITE.erl, 9 cases): call_reply_happy_path, call_dropped_on_keep_old_full, call_dropped_by_filter, call_noproc_on_box_death, call_timeout_when_no_reply, call_noproc_unregistered, call_queue_overflow_degrades_to_timeout, concurrent_calls_each_get_their_own_reply, call_timeout_leaves_no_stray_message. Terminal-outcome coverage is solid; gaps named in L3, stress-test weakness in L4.
Live probes run during the review (throw-away escripts, not committed): the OTP 27 alias-semantics suite (E1/E2 verification), the {local,Name} crash repro (M1), and the 2000-round README-pattern refutation of the double-notify candidate.
Recommended action
No blockers. M1 (one-line where/1 clause) and M2 (a doc line reserving the $pobox_ wire shape, or a nonce check) are the two worth folding into this PR before it goes out for external review — both are cheap and both harden a public surface. The LOWs are polish: L1 (drop or reject unknown opt keys), L3 (add the call/2 + guard tests), L4 (make the E1 test deterministic). L2 is a merge-order decision for the maintainer.
Approval gate — exactly these, nothing else
- M1 — add
where({local, Name}) -> erlang:whereis(Name);—src/pobox.erl:613-616 - M2 — reserve/guard the
$pobox_wire shape sopost/2can't fire a spurious drop —src/pobox.erl:511
Does NOT gate: L1–L4, the version/merge-order note, and all advisory items.
…1,M2]
M1: where/1 had no {local, Name} clause, so call({local, Name}) crashed with
function_clause though {local, atom()} is a valid name(). Add the clause
(resolves via whereis/1); call now accepts every name() form.
M2: the drop-notify discriminates a call purely by wire shape, so a plain
post of a {'$pobox_call', Ref, _} tuple could fire a stray {'$pobox_drop',
Ref}. Document the $pobox_call/reply/drop shapes as reserved (call/2 doc +
README), per the standard reserved-namespace convention.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ew L1-L4]
L1: call/3's options map now rejects unknown keys (e.g. a `timout` typo) with
badarg instead of silently ignoring them; timeout/weight are the allowed
keys (weight reserved for weighted boxes).
L3: add coverage for call/2 default timeout over a {mod,_} buffer, the reply/2
reference guard, and is_call/1 negatives.
L2: changelog notes 1.3.0 is the sibling weighting change, so this lands as
1.4.0 once both merge (the version gap is a merge-order artifact).
L4: the reply-vs-timeout stress test's comment already documents that it
exercises the path but can't deterministically hit the sub-instruction race
(the E1 flush fix is defensive) — left as recorded.
CT green; dialyzer clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review addressed ✅All findings from the review above are fixed (TDD RED→GREEN for the behavioral ones), pushed as MED:
LOW:
|
Summary
Adds native PO Box calls — request/response where the box owner answers the
client directly. This implements proposal 3 of #15. It is purely additive: existing
post/post_syncbehavior is unchanged.Scope
This is proposal 3 (calls) only, a sibling to the message-weighting PR (#16 —
proposal 1). Kept as an independent PR for reviewability. Branched off
master;versioned as 1.4.0, sequenced after #16 (weighting, 1.3.0) — the maintainer can
re-order, it only touches the changelog/vsn.
What's added
call(Box, Request)/call(Box, Request, Timeout | #{timeout => T})— buffersthe request and blocks for the owner's reply. Returns
{ok, Reply},{error, dropped},{error, timeout}, or{error, noproc}.reply(ReplyTo, Reply)— the owner answers the client directly (the box isnot in the reply path).
is_call(Msg)— lets the owner's active filter tell a call(
{'$pobox_call', ReplyTo, Request}) apart from a plain post.Drop-safety (cost-aligned)
If a call is dropped where the dropped element is already in hand, the caller is told
{error, dropped}immediately instead of waiting out the timeout:keep_oldbox rejecting the call at admission, anddrop.A call bumped out of a plain
queue/stackby later posts is not notified — thatwould mean scanning bulk drops on the hot path — and simply times out. So
keep_oldis the drop-safe substrate for calls (bounded admission → an accepted call is never
dropped later), which is exactly the pattern this replaces in downstream consumers that
hand-roll it today.
Backward compatibility
Strictly additive.
post,post_sync,active, the mail tuples, and all dropbehavior are unchanged. The
maybe_notify_drop/1hook is a no-op for any non-callmessage. All 67 original Common Test cases and 3 properties pass unchanged.
Implementation discipline
8 atomic, signed commits: 5 test-driven cycles (B1–B5) + 2 review fixes (E1–E2) from an
adversarial self-review, + docs. The review confirmed the alias-monitor lifecycle,
clause-ordering, double-notify safety, and backward-compat are clean, and found:
DOWN, so a reply/drop thatraced the timeout into the caller's mailbox could linger — now flushed explicitly.
callnow casts to the resolved box pid it monitors (not the name), soa name re-registration can't split the monitor and the post across two processes.
Test plan
admission-reject, filter-drop,
noproc/timeout/unregistered, queue-overflowdegradation, 20 concurrent callers, and a 500-round reply-vs-timeout stress).
rebar3 dialyzerclean; compile warnings-as-errors clean.Files changed
Follow-ups
weightoncall/3and notify weighted-enforcedrops (the
weight => Wopt oncall/3is reserved for this).Implements proposal 3 (PO Box calls) of #15.
🤖 Generated with Claude Code