//// Generic teardown helpers for actors that follow the SDK's //// "polite stop message" convention. Used by `credentials_cache` and //// `retry/rate_limiter` — both opaque types wrap a `Subject` whose //// message variant set includes a `Stop` constructor that triggers //// `actor.stop()` next dispatch. //// //// Lives in a separate module so the same fire-and-forget + monitor- //// based synchronous teardown lands in exactly one place. Adding a //// third long-lived actor later (a request-rate limiter, an event- //// stream demuxer) reuses these directly. import gleam/erlang/process.{type Subject} /// A non-crashing replacement for `actor.call` / `process.call`. /// /// `actor.call` *panics* the caller when the callee has exited or fails to /// reply within the timeout. That is fine for a supervised callee, but the /// SDK's long-lived actors (credentials cache, retry rate-limiter) are /// unsupervised — a single crashing provider would otherwise take down the /// consumer's process on every subsequent call. This variant monitors the /// callee and selects over both the reply and the `DOWN` signal, returning /// `Error(Nil)` on a dead owner, an immediate `DOWN`, or a timeout, and /// `Ok(reply)` on a normal reply. Callers map `Error(Nil)` onto their own /// error shape so the consumer sees a recoverable error, never a panic. /// /// Mirrors `gleam_erlang`'s internal `perform_call`, minus the two /// `let assert`/`panic` lines that make the stock `call` crash. pub fn safe_call( subject subject: Subject(msg), waiting timeout_ms: Int, sending make_request: fn(Subject(reply)) -> msg, ) -> Result(reply, Nil) { case process.subject_owner(subject) { Error(_) -> Error(Nil) Ok(callee) -> { let reply_subject = process.new_subject() let monitor = process.monitor(callee) process.send(subject, make_request(reply_subject)) let outcome = process.new_selector() |> process.select_map(reply_subject, Ok) |> process.select_specific_monitor(monitor, fn(_down) { Error(Nil) }) |> process.selector_receive(timeout_ms) process.demonitor_process(monitor) case outcome { Ok(reply_result) -> reply_result Error(Nil) -> Error(Nil) } } } } /// Fire-and-forget teardown: send the supplied `Stop` message and /// return immediately. The actor exits on its next dispatch. Safe to /// call against a dead actor — Erlang silently drops sends to a /// terminated Pid. pub fn shutdown_via_stop(subject: Subject(msg), stop_message: msg) -> Nil { process.send(subject, stop_message) } /// Synchronous teardown: monitor the owning Pid, send `Stop`, then /// wait for the `DOWN` signal up to `timeout_ms`. Returns `Ok(Nil)` /// on clean exit (already-dead actor short-circuits here too — the /// `subject_owner` lookup fails fast). Returns `Error(Nil)` only on /// real timeout — i.e. the actor is alive but didn't exit within the /// window. The monitor is demonitored on the timeout path so the /// caller's mailbox doesn't accumulate stray `DOWN` messages. pub fn shutdown_via_stop_sync( subject: Subject(msg), stop_message: msg, timeout_ms: Int, ) -> Result(Nil, Nil) { case process.subject_owner(subject) { Error(_) -> Ok(Nil) Ok(pid) -> { let monitor = process.monitor(pid) process.send(subject, stop_message) let selector = process.new_selector() |> process.select_specific_monitor(monitor, fn(_down) { Nil }) case process.selector_receive(selector, timeout_ms) { Ok(Nil) -> Ok(Nil) Error(Nil) -> { process.demonitor_process(monitor) Error(Nil) } } } } }