Logos.Process (Logos v0.2.0)

Copy Markdown

The BEAM-native concurrency primitive family: spawn, spawn-link, spawn-monitor, link, unlink, monitor, demonitor, trap-exits!, exit, self, send, receive-match!. Every function here is plain Elixir, called from Logos.Primitives' (args, runtime) dispatch against already-evaluated argument values -- none of them need macro-hood. (The one related piece that does need macro-hood is the Lisp-level receive macro in concurrency.logos, since its clauses must stay unevaluated until a match is found.)

spawn -- what actually runs

spawn/2 takes a zero-arg %Logos.Fn{} (a thunk) and the calling Logos.Runtime, and starts a genuine new BEAM process (Kernel.spawn/1) that evaluates the thunk's body via Logos.Eval.apply_fn/3 against the same runtime. This works because Logos.Runtime's registry lives in a :public ETS table (see that module's moduledoc): a spawned process can read and write Vars/namespaces directly through the table, with no need to round-trip requests through some single owning process. Zero-arg only -- matching real Clojure/core.async-style thread-spawning convention (a "thunk"), simplest possible calling shape; a caller who wants to pass data into the spawned process closes over it in the fn itself ((spawn (fn [] (worker-body some-captured-value)))), exactly like a real closure would.

Logos.Eval.apply_fn/3's lexical environment handling already gives the spawned process a fresh Logos.Env frame chained under the closure's captured env (see Logos.Eval's own private bind_params/4 -- Env.new(closure_env)) -- nothing extra is needed here for that.

Crash semantics

If the thunk's evaluation raises Logos.EvalError (Logos.Eval's primitive-level failure signal, not a BEAM-level crash by itself), run_thunk/2 rescues it and re-raises a real Logos.ProcessCrash carrying the same reason so the spawned BEAM process terminates abnormally, exactly the way a real crash would -- link/monitor on the spawning side see a genuine :EXIT/:DOWN with that reason, matching real BEAM semantics (a Logos function erroring is conceptually "this process failed", the same as an Elixir process raising). An uncaught Logos-level (throw ...) (Logos.Thrown, a real Elixir exception with no enclosing try) needs no special handling here at all -- it already propagates as an ordinary Elixir exception and crashes the process on its own.

send -- what a message can carry

A send message can carry any Logos.Value.t() -- the full set of runtime values, which is broader than what the reader can produce from source text (Logos.Form.t()) -- including a live %Logos.Fn{} closure or a %Logos.Pid{}/%Logos.Atom{}. concurrency.logos's swap! relies on exactly this: it sends its update function as part of the message to an atom's owning loop process, which then applies that function locally. This module just does a real Erlang send/2; Elixir/Erlang terms (including closures/pids-as-data, since we're always staying on one node) transfer in-VM with no serialization at all. Out of scope: a %Logos.Fn{} closure carries a Logos.Env chain (arbitrary nested Elixir terms, including potentially other pids/refs) that would not serialize sensibly to a different BEAM node. Cross-node Logos usage instead goes through mix logos.remsh, which ships each top-level form as source text via :rpc.call to the target node and evaluates it there -- never raw cross-node message passing of a live Logos closure. Nothing here needs to guard against that; it simply never arises as long as spawn only ever creates local processes (which is all Kernel.spawn/1 does).

receive-match! -- genuine selective receive

A dynamic Lisp predicate can't drive Erlang's own compiled receive clauses, so receive_match!/5 implements the standard Elixir/Erlang "explicit mailbox draining" technique by hand, using real receive/ after: pop one message, test it against the predicate; on a match, run the handler and return its result; on a miss, send/2 the message back to this same process (self()) and keep scanning. This is an accepted tradeoff, not a bug this implementation tries to avoid: because a re-sent message goes to the back of the mailbox, a message that's skipped over on one scan can end up reordered relative to any brand-new message that arrives while the scan is still in progress.

receive_match!/3 (2-arg predicate/handler primitive form) blocks indefinitely (Erlang's own :infinity timeout atom -- equivalent to a bare receive with no after at all: no busy-loop while the mailbox is genuinely empty, real BEAM scheduling blocks the process). The 5-arg form takes an explicit timeout_ms (an integer) and a zero-arg default_fn thunk called once the deadline passes with nothing having matched.

Note the one real caveat of this technique, inherent to the design (not a bug): if the mailbox already contains messages and none of them ever matches the predicate, each receive immediately re-matches its own just-requeued message, which busy-loops (consuming CPU) rather than truly blocking, until the deadline (or, for the indefinite form, forever). This is the well-known drawback of hand-rolled selective receive on the BEAM, and it's accepted here rather than engineered around: avoiding it would require tracking message identity to tell "already scanned and requeued this pass" apart from "genuinely new," which is more machinery than this primitive's use cases justify.

:DOWN/:EXIT message normalization

{:DOWN, ref, :process, pid, reason} and {:EXIT, pid, reason} are raw Erlang tuples generated by the VM itself (from monitor/ trap-exits!) -- not something built via our own send, and not expressible in Logos's own data model at all (Logos has no native tuple type; see Logos.Form.t()'s 13-case table -- lists/vectors/maps/sets only). Left completely raw, Lisp code could receive such a message but could never destructure it (no elem/tuple-access primitive exists). So every message popped off the mailbox by receive_match!/5 is passed through normalize_message/1 first: a raw :DOWN/:EXIT tuple becomes an ordinary Logos list ((:DOWN ref-marker :process pid reason) / (:EXIT pid reason), using first/rest-friendly Elixir lists, not vectors, so plain first/rest/= work on it directly from Lisp) with its pid() field(s) wrapped as %Logos.Pid{} (so it round-trips through link/monitor/send the same way any other Pid does); anything else (an ordinary message sent via our own send primitive, already perfectly good Logos.Value.t()) passes through completely unchanged. A monitor reference has no dedicated Logos wrapper type of its own -- it's carried as an opaque {:logos_ref, ref} marker tuple, the same private-tuple-marker idiom Logos.Eval/ Logos.Primitives already use for {:primitive, name}/{:host_fn, ...} -- Lisp code can hold/pass/=-compare it (needed for demonitor) but never has to (and structurally cannot) look inside it.

Summary

Functions

(exit) / (exit reason) -- terminates the calling process (real Kernel.exit/1; never returns). (exit pid reason) -- sends an exit signal to another process (real Process.exit/2; returns true).

Real Process.monitor/1. Returns the reference wrapped as an opaque {:logos_ref, ref} marker -- see moduledoc.

Indefinite-block form: (receive-match! pred handler).

(receive-match! pred handler timeout-ms default-fn) -- see moduledoc. timeout_ms is either the atom :infinity or a non-negative integer of milliseconds; default_fn (a zero-arg callable) is invoked once the deadline passes with nothing having matched -- nil means "no default, raise Logos.EvalError with reason :receive_timeout instead" (used when a caller wants the indefinite-block behavior but happened to still pass an explicit timeout).

Real send/2. msg can be any Logos.Value.t() -- see moduledoc. Returns msg, matching Erlang send/2's own return value.

Starts a new BEAM process running thunk's zero-arg body against runtime. Returns a %Logos.Pid{}.

Like spawn/2, atomically linked to the calling process (real Kernel.spawn_link/1 semantics).

Like spawn/2, atomically monitored by the calling process (real Kernel.spawn_monitor/1 semantics). Returns a two-element Logos list (pid monitor-ref) -- the natural Lisp-level calling shape for a primitive that hands back two related-but-distinct values (mirroring how Kernel.spawn_monitor/1 itself returns a {pid, ref} tuple; a Logos list rather than a raw Elixir tuple since tuples aren't part of the Logos data model, see moduledoc).

Wraps Process.flag(:trap_exit, bool). Returns the previous value, matching Process.flag/2's own return convention.

Functions

demonitor(arg)

@spec demonitor({:logos_ref, reference()}) :: :ok

exit_pid(pid, reason)

@spec exit_pid(Logos.Pid.t(), term()) :: boolean()

exit_self(reason)

@spec exit_self(term()) :: no_return()

(exit) / (exit reason) -- terminates the calling process (real Kernel.exit/1; never returns). (exit pid reason) -- sends an exit signal to another process (real Process.exit/2; returns true).

Arity choice mirrors Erlang's own exit/1 (self) vs exit/2 (other process) split directly rather than inventing a different Lisp-level convention -- (exit) alone defaults to reason :normal (an unadorned "just stop me" call), (exit reason) self-exits with an explicit reason, (exit pid reason) is the two-arg form aimed at someone else. A bare Logos keyword :normal/:kill is translated to the literal Erlang atom BEAM gives special meaning to (reason_term/1); any other reason value (including an arbitrary keyword/string/whatever) is passed through completely as-is -- exit reasons are opaque terms as far as BEAM is concerned except for those two specific atoms.

link(pid)

@spec link(Logos.Pid.t()) :: :ok

monitor(pid)

@spec monitor(Logos.Pid.t()) :: {:logos_ref, reference()}

Real Process.monitor/1. Returns the reference wrapped as an opaque {:logos_ref, ref} marker -- see moduledoc.

receive_match!(pred, handler, runtime)

@spec receive_match!(term(), term(), Logos.Runtime.t()) :: term()

Indefinite-block form: (receive-match! pred handler).

receive_match!(pred, handler, timeout_ms, default_fn, runtime)

@spec receive_match!(
  term(),
  term(),
  :infinity | non_neg_integer(),
  term() | nil,
  Logos.Runtime.t()
) :: term()

(receive-match! pred handler timeout-ms default-fn) -- see moduledoc. timeout_ms is either the atom :infinity or a non-negative integer of milliseconds; default_fn (a zero-arg callable) is invoked once the deadline passes with nothing having matched -- nil means "no default, raise Logos.EvalError with reason :receive_timeout instead" (used when a caller wants the indefinite-block behavior but happened to still pass an explicit timeout).

self()

@spec self() :: Logos.Pid.t()

send(arg1, msg)

@spec send(Logos.Pid.t() | Logos.Atom.t(), term()) :: term()

Real send/2. msg can be any Logos.Value.t() -- see moduledoc. Returns msg, matching Erlang send/2's own return value.

spawn(thunk, runtime)

@spec spawn(Logos.Fn.t(), Logos.Runtime.t()) :: Logos.Pid.t()

Starts a new BEAM process running thunk's zero-arg body against runtime. Returns a %Logos.Pid{}.

spawn_link(thunk, runtime)

@spec spawn_link(Logos.Fn.t(), Logos.Runtime.t()) :: Logos.Pid.t()

Like spawn/2, atomically linked to the calling process (real Kernel.spawn_link/1 semantics).

spawn_monitor(thunk, runtime)

@spec spawn_monitor(Logos.Fn.t(), Logos.Runtime.t()) :: [term()]

Like spawn/2, atomically monitored by the calling process (real Kernel.spawn_monitor/1 semantics). Returns a two-element Logos list (pid monitor-ref) -- the natural Lisp-level calling shape for a primitive that hands back two related-but-distinct values (mirroring how Kernel.spawn_monitor/1 itself returns a {pid, ref} tuple; a Logos list rather than a raw Elixir tuple since tuples aren't part of the Logos data model, see moduledoc).

trap_exits!(bool)

@spec trap_exits!(boolean()) :: boolean()

Wraps Process.flag(:trap_exit, bool). Returns the previous value, matching Process.flag/2's own return convention.

unlink(pid)

@spec unlink(Logos.Pid.t()) :: :ok