Liking cljdoc? Tell your friends :D

loom-otp Design Document

Erlang/OTP-style actor concurrency for Clojure using JVM virtual threads (Project Loom).

Overview

loom-otp provides processes, message passing, links, monitors, gen_server, and supervisors. Unlike otplike which uses core.async, loom-otp uses virtual threads where blocking is cheap and natural.

Key differences from otplike:

  • Virtual threads instead of go blocks
  • Single receive! (always blocking) instead of receive!/receive!!
  • No async/await! - just block directly
  • Links only via spawn-link (no explicit link/unlink API)
  • Selective receive supported via selective-receive!

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                      APPLICATION LAYER                          │
│  supervisor - Supervision trees, restart strategies             │
├─────────────────────────────────────────────────────────────────┤
│                      BEHAVIOR LAYER                             │
│  gen-server - Generic server pattern                            │
│  timer - Delayed/periodic operations                            │
├─────────────────────────────────────────────────────────────────┤
│                      CORE PROCESS LAYER                         │
│  process - Spawning, messaging, monitors, receive               │
│  process.spawn - Process lifecycle (control + user threads)     │
│  process.link - Bidirectional links (stored per-process)        │
│  process.monitor - Unidirectional monitors                      │
│  process.exit - Exit handling                                   │
├─────────────────────────────────────────────────────────────────┤
│                      FOUNDATION LAYER                           │
│  mailbox - Message queue with watch-based notification          │
│  context-mailbox - [ctx msg] pairs for distributed tracing      │
│  registry - Process registration by name                        │
│  state - Global state management (mount.lite)                   │
│  types - Pid, TRef wrapper types                                │
│  trace - Event tracing infrastructure                           │
└─────────────────────────────────────────────────────────────────┘

Process Model

Each process has two virtual threads:

┌─────────────────── Process ───────────────────┐
│                                               │
│  ┌─────────────────┐  ┌───────────────────┐   │
│  │ Control Thread  │  │   User Thread     │   │
│  │                 │  │                   │   │
│  │ • Sets up links │  │ • Runs user code  │   │
│  │ • Starts user   │  │ • Calls receive!  │   │
│  │ • Handles exits │  │ • Pattern matches │   │
│  │ • Runs cleanup  │  │                   │   │
│  │                 │  │ Notifies control  │   │
│  │ Orchestrates    │  │ on termination    │   │
│  │ lifecycle       │  │                   │   │
│  └─────────────────┘  └───────────────────┘   │
│                                               │
│  Shared: mailbox, control, flags, exit-reason │
└───────────────────────────────────────────────┘

Process Lifecycle

spawn-process (caller's thread):
  1. Create process with promises for coordination
  2. Add to process table, register name
  3. Start control thread (virtual)
  4. Wait for :spawned promise
  5. Return pid

control-thread:
  1. Deliver :control-thread promise
  2. Set up link if requested (may fail with :noproc)
  3. Start user thread (virtual)
  4. Wait for :user-thread promise
  5. Deliver :spawned promise
  6. Loop: handle signals via cmb/receive!
     - [:exit from reason] → handle-exit-signal!, maybe interrupt user
     - [:user-terminated-return value] → set exit-reason
     - [:user-terminated-throw exception] → set exit-reason
     - [:user-terminated-interrupted] → exit-reason already set
  7. Cleanup: notify links/monitors, unregister, remove from table

user-thread:
  1. Deliver :user-thread promise
  2. Run user function
  3. Send termination signal to control thread

Process State

{:pid             Pid                    ; Unique identifier
 :mailbox         Atom<Queue>            ; User messages [ctx msg]
 :control         Atom<Queue>            ; Control signals [ctx signal]
 :exit-reason     Promise                ; Delivered on exit (write-once)
 :message-context Atom<Map>              ; Context for distributed tracing
 :last-control-ctx Atom<Map>             ; Context from last exit signal
 :flags           Atom<{:trap-exit bool}>
 :links           Atom<#{pid-ids}>       ; Linked process ids
 :user-thread     Promise<Thread>        ; For interrupt
 :control-thread  Promise<Thread>
 :spawned         Promise<bool>}         ; Coordination

Exit Reasons

ScenarioExit Reason
User function returns value[:normal value]
User calls (exit/exit reason)reason
User throws exception[:exception (Throwable->map e)]
spawn-link to non-existent process:noproc
Exit signal with reason R (not trapped)R
Exit signal with :kill:killed

Normal exits are [:normal value] or bare :normal. The normal-exit-reason? helper checks both forms.

Links

Bidirectional relationships for fault propagation. Stored in each process's :links atom.

Key properties:

  • Created only via spawn-link or spawn-opt {:link true}
  • No explicit link/unlink public API
  • Bidirectional: if A links to B, both have each other in :links
  • On exit: linked processes receive exit signals

Exit signal handling:

Receiver's trap-exitReason = :normalReason = :killReason = other
falseIgnoredDies with :killedDies with reason
trueReceives [:EXIT pid reason]Dies with :killedReceives [:EXIT pid reason]

Monitors

Unidirectional observation without coupling.

Key properties:

  • One-shot: fires once, then removed
  • Always delivers :DOWN message (no trap flag needed)
  • Safe for non-existent targets (immediate :noproc)
  • Stackable: multiple monitors to same target allowed

Message format:

[:DOWN ref :process target reason]

Message Passing

Send (send)

(send dest message)  ; Returns true if delivered, false if process not found

Messages are wrapped with sender's context for distributed tracing.

Receive (receive!)

Pattern-matching receive with optional timeout:

(receive!
  [:hello name] (println "Hello" name)
  [:exit reason] (handle-exit reason)
  (after 1000 :timeout))

Selective Receive (selective-receive!)

Scans mailbox for first matching message:

(selective-receive!
  [:priority msg] (handle-priority msg)
  (after 500 :no-priority))

State Management

Uses mount.lite for lifecycle management.

(require '[mount.lite :as mount])

(mount/start)  ; Initialize system
;; ... use processes ...
(mount/stop)   ; Terminate all processes

Global State

{:processes   Atom<{pid-id -> process-map}>
 :monitors    Atom<{ref-id -> monitor-info}>
 :registry    Atom<{:forward {name -> pid}, :reverse {pid-id -> name}}>
 :trace-fn    Atom<handler-fn>
 :pid-counter AtomicLong
 :ref-counter AtomicLong}

Note: Links are stored per-process, not globally.

Parallel Testing

(use-fixtures :each
  (fn [f]
    (mount/start)
    (try (f) (finally (mount/stop)))))

Public API

loom-otp.process

FunctionDescription
spawn, spawn-link, spawn-optCreate processes
spawn!, spawn-link!, spawn-opt!Macro versions (wrap body in fn)
spawn-trap, spawn-trap!Spawn with trap-exit enabled
selfCurrent process pid
sendSend message
exitExit self or send exit signal to another
monitor, demonitorManage monitors
registerRegister name for process
alive?, processes, process-infoIntrospection
receive!, selective-receive!Receive messages (see process.match)

loom-otp.process.match

MacroDescription
receive!Pattern-matching receive with optional timeout
selective-receive!Scan mailbox for matching message

loom-otp.gen-server

FunctionDescription
start, start-linkStart server
call, castSend requests
replyExplicit reply from handle-call
stopStop server

loom-otp.supervisor

FunctionDescription
start-linkStart supervisor
start-childAdd child dynamically
terminate-childStop a child
which-childrenList children

loom-otp.timer

FunctionDescription
send-afterSend message after delay
exit-after, kill-afterSend exit signal after delay
apply-afterCall function after delay
send-interval, apply-intervalPeriodic operations
cancelCancel timer
read-timerGet remaining time

loom-otp.trace

FunctionDescription
traceSet trace handler
untraceRemove trace handler

Tracing

(trace/trace (fn [event]
               (println (:event event) (:pid event))))

;; Events: :spawn, :exit, :exit-signal, :send

Types

(require '[loom-otp.types :as t])

(t/pid? x)      ; Check if Pid
(t/ref? x)      ; Check if TRef
(t/->pid n)     ; Create Pid from number
(t/->ref n)     ; Create TRef from number
(t/pid->id pid) ; Extract number from Pid (or return number if already)

Dependencies

  • Clojure 1.12+
  • JDK 21+ (for virtual threads)
  • org.clojure/core.match - Pattern matching in receive
  • functionalbytes/mount-lite - State lifecycle management

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close