Blog › ICP guides
CML developer on retainer: Concurrent ML event values, synchronous channel communication, select composition with choose and wrapEvt, and SML/NJ thread programming on monthly retainer
September 30, 2026 · ~15 min read
A CML-based concurrent server was being built with multiple worker threads communicating over typed channels. The developer implemented a producer-consumer pattern using direct channel operations: CML.send (ch, value) to transmit data to a consumer thread and CML.recv ch to block and receive the next value. This worked correctly for simple single-channel communication where the producer and consumer each had exactly one channel to interact with. When the developer needed to add a timeout mechanism — where a receiver should either receive a value from a data channel within a deadline or handle the absence of a value after 500 milliseconds — they tried to compose CML.recv dataCh and CML.timeOut delay into a select-like operation. But CML.recv is a synchronous operation that blocks the calling thread and returns a value immediately once a sender is ready; it is not a first-class value that can be passed to a combinator. Similarly, CML.timeOut (if it existed in that form) would execute immediately rather than describe a deferred action. These are performed operations, not event descriptions — and a select-like combinator requires descriptions of operations so it can decide which one to execute based on which becomes ready first. The CML developer on retainer restructured the communication model: CML.recvEvt dataCh creates a receive event value of type 'a CML.event — a first-class description of the synchronization action “wait until a sender is ready on dataCh and receive what they send” without performing that action; CML.timeOutEvt (Time.fromMilliseconds 500) creates a timeout event value describing “wait 500 milliseconds”; CML.select [CML.recvEvt dataCh, CML.timeOutEvt delay] composes both event values and synchronizes on whichever becomes ready first, executing exactly that one and abandoning the other. Composable select operations: 0 → working via event values.
The work log entry read “restructured channel communication to use event values for select composition, 8h.” It names the result and the duration. It cannot explain the fundamental CML design distinction between recv (a function that performs a synchronization immediately, blocking the calling thread until a sender is ready, and returning the received value) versus recvEvt (a function that constructs a first-class value of type 'a CML.event that describes the receive synchronization action without performing it; this event value can then be passed to CML.sync to perform it, or to CML.choose/CML.select to compose it with other events and perform whichever becomes ready first); the select combinator requires event value descriptions because it must set up the synchronization conditions for all candidate events simultaneously and then commit to exactly one — if the operations had already been performed, there is nothing left to select between. It cannot explain the wrapEvt composition pattern: CML.wrapEvt (CML.recvEvt dataCh, fn v => DataReceived v) transforms the result of the receive event — when this event fires, instead of returning the raw 'a value from the channel, it applies the function fn v => DataReceived v to produce a value of the discriminated union type; CML.wrapEvt (CML.timeOutEvt delay, fn () => TimedOut) wraps the unit timeout result into TimedOut; since both wrapped events now have the same result type, they can be placed in a list and passed to select, which returns either DataReceived v or TimedOut for clean pattern-match dispatch. It cannot explain why timeOutEvt requires Time.fromMilliseconds 500 rather than the integer 500 directly — the CML event type system is typed, and CML.timeOutEvt has type Time.time -> unit CML.event, requiring a Time.time value constructed with the Time structure from the SML Basis Library, not a bare integer duration. It cannot explain the concurrency semantics change: the original code used CML.recv dataCh which would block the receiver thread indefinitely until any sender arrived; the restructured code uses select with a 500-millisecond timeout, which changes the observable blocking behavior of the receiver thread — threads waiting on it will now observe a TimedOut result rather than a delayed response, and all downstream logic that previously assumed the receive would eventually succeed must now handle the timeout case with an appropriate fallback or error path. The 8 hours of event model understanding, select composition design, wrapEvt sum type construction, timeout type correction, and downstream error handling restructuring are invisible in the diff.
CML concurrent programming: event values, sync, channels, and the choose combinator
Concurrent ML was designed by John Reppy at Bell Labs in his 1988 doctoral thesis as an extension of Standard ML with first-class synchronous events for concurrent programming. The central insight is that synchronization actions — sending on a channel, receiving from a channel, waiting for a timeout — can be reified as first-class values that are composed and transformed before being executed. This allows select-style concurrent dispatch to be expressed as a function over a list of event values, rather than as a special syntactic form with a fixed number of branches.
CML.channel : unit -> 'a CML.channel creates a new typed synchronous channel. The type parameter 'a is the type of values that flow through the channel. Channels in CML are synchronous by design: there is no internal buffer. A sender and a receiver must both be ready simultaneously — this is the rendezvous semantics that underlies all CML communication. There are no queued messages, no mailboxes, no capacity limits — a send blocks until a receiver arrives, and a receive blocks until a sender arrives. This design enforces explicit synchronization at every channel communication point and makes the communication protocol visible in the program structure.
Event values have type 'a CML.event: a first-class description of a synchronization action that will produce a value of type 'a when performed. The two primary event value constructors for channels are CML.sendEvt : 'a CML.channel * 'a -> unit CML.event, which creates an event value describing “send this value on this channel” (produces unit when the send completes), and CML.recvEvt : 'a CML.channel -> 'a CML.event, which creates an event value describing “receive the next value from this channel” (produces the received value of type 'a when the receive completes). These constructors do not block, do not perform any I/O, and do not interact with any other threads — they simply build a description.
CML.sync : 'a CML.event -> 'a performs the synchronization described by an event value. It blocks the calling thread until the synchronization can complete — for a recvEvt, that means until a sender is simultaneously ready on the same channel; for a sendEvt, that means until a receiver is simultaneously ready; for a timeOutEvt, that means until the specified duration has elapsed. When the synchronization completes, sync returns the result value. CML.recv ch is equivalent to CML.sync (CML.recvEvt ch), and CML.send (ch, v) is equivalent to CML.sync (CML.sendEvt (ch, v)) — the direct operations are convenience wrappers around event value construction and synchronization.
CML.choose : 'a CML.event list -> 'a CML.event composes multiple event values into a single event value that, when synchronized, will complete with the first of the input events that becomes ready, executing exactly that one and discarding the others. All events in the list must have the same result type 'a. CML.select : 'a CML.event list -> 'a is defined as CML.sync (CML.choose evts) — it is a convenience function that composes and immediately synchronizes. The behavior of select when multiple events are simultaneously ready is non-deterministic: CML does not guarantee which event is chosen, and programs that depend on a specific ordering among simultaneously-ready events have an unspecified behavior.
CML.spawn : (unit -> unit) -> CML.thread_id creates a new concurrent thread that runs the given function. The thread runs concurrently with the spawning thread; both are scheduled by the CML runtime. The thread_id returned by spawn can be used for thread identification but CML does not expose a general thread join operation — coordination between threads is expressed via channel communication and event synchronization.
CML.wrapEvt : 'a CML.event * ('a -> 'b) -> 'b CML.event transforms the result of an event value with a function. If event e produces a value of type 'a when synchronized, then wrapEvt (e, f) produces a value of type 'b by applying f to the result of e. The wrapping function is applied after the synchronization completes, as part of the commit phase of the event. This is the primary tool for making heterogeneous event lists type-correct: wrap each event's result with a constructor of a common sum type so all events in the list share the result type.
CML.guard : (unit -> 'a CML.event) -> 'a CML.event defers the construction of an event value to synchronization time. The function argument is called when sync or select begins evaluating the event; the event it returns is then used for the synchronization. This is useful when the event to construct depends on observable side effects: for example, if constructing the event involves checking a condition, incrementing a counter, or acquiring a resource that should only be claimed if this branch is actually being attempted in a select. Without guard, all events in a select list are constructed before the select begins, meaning any construction-time side effects happen regardless of which event wins.
CML.timeOutEvt : Time.time -> unit CML.event creates a timeout event value that becomes ready after the specified duration has elapsed. It takes a Time.time value from the SML Basis Library, constructed with functions like Time.fromMilliseconds, Time.fromSeconds, or Time.fromReal. CML.alwaysEvt : 'a -> 'a CML.event creates an event that is always immediately ready with the given value; it is useful as a default case in a select that should not block, or as a no-op placeholder when building event lists dynamically.
CML programs in SML/NJ are built using the Compilation Manager: CM.make "$/cml-lib.cm" loads the CML library. The program entry point is wrapped with RunCML.doit (fn () => ..., NONE), which initializes the CML runtime and enters the event-driven thread scheduler. Without RunCML.doit, spawning threads and synchronizing on channels will fail because the CML scheduler is not running. MLton has a CML port that integrates CML threading with MLton's whole-program compilation model; the API is substantially the same but the build system integration differs. CML retainer work is closely related to Standard ML developer retainers — CML is built directly on Standard ML and every CML program uses SML data types, pattern matching, modules, and the Basis Library alongside the CML concurrency primitives — and to Erlang developer retainers, which also use message-passing concurrency, but with processes and mailboxes instead of threads and typed synchronous channels, and without the first-class event value model that makes CML's select expression more compositional than Erlang's receive pattern.
CML event composition patterns: wrapEvt, guard, withNack, and discriminated select dispatch
The most common pattern in non-trivial CML programs is discriminated select dispatch: a thread is waiting on multiple channels or conditions simultaneously, and when any one of them fires, it dispatches to the appropriate handler based on which event fired. Because CML.select requires all events in the list to share a result type, and because different events naturally produce different value types — a data receive produces a message, a timeout produces unit, a control channel receive produces a command — the standard approach is to define a sum type that covers all possible outcomes and use wrapEvt to inject each event's result into the appropriate variant.
The pattern looks like this: first define the discriminated union type result = DataReceived of int | TimedOut | Shutdown; then build the wrapped event list [CML.wrapEvt (CML.recvEvt dataCh, DataReceived), CML.wrapEvt (CML.timeOutEvt delay, fn () => TimedOut), CML.wrapEvt (CML.recvEvt controlCh, Shutdown)]; then call CML.select on that list and pattern-match the result. Each wrapEvt call lifts an event of its natural result type into the common result type. This approach scales: adding a new event to the select is a matter of adding a new variant to the sum type and a new wrapEvt expression to the list — no syntactic restructuring of a match statement or special syntax is needed. The event list is a first-class list value and can be built dynamically at runtime, filtered based on runtime conditions, or passed between functions, which is not possible with Go's select statement or Erlang's receive pattern matching, which are syntactic forms with a fixed number of branches determined at compile time.
CML.withNack : (unit CML.event -> 'a CML.event) -> 'a CML.event provides a “negative acknowledgment” mechanism for events that participate in a select. When an event constructed with withNack is included in a select list and another event in that list is chosen instead, the nack event fires on the event that was not chosen. The function argument receives a unit CML.event — the nack event — and returns the actual event to synchronize. The returned event may use the nack event in its construction by spawning a cleanup thread that synchronizes on it: when the nack fires (because this event lost the race), the cleanup thread wakes up and performs any necessary resource release, such as closing a file descriptor, releasing a lock, cancelling a pending network request, or signaling another thread that this particular synchronization branch was abandoned.
A concrete example: a thread offers to deliver a message over either a fast channel or a slow backup channel. CML.withNack (fn nack => CML.wrapEvt (CML.sendEvt fastCh msg, fn () => FastDelivered)) — if fastCh wins the select, the message is delivered via the fast path; if slowCh wins instead, the nack event fires inside the fast-path event, and any fast-path setup work (allocating a buffer, opening a connection) can be cleaned up in a spawned thread that synchronizes on the nack event. Without withNack, there is no way to learn that your event lost the race in a select; your event was simply not chosen and any resources allocated during its construction remain outstanding until they are garbage collected or time out independently.
The rendezvous semantics of CML synchronous channels have important implications for multi-thread protocol design that are invisible in the code text but central to correctness. Because CML.sync (CML.sendEvt ch v) blocks until a receiver does CML.sync (CML.recvEvt ch) simultaneously, the communication is a synchronized handshake: the sender knows that the receiver has the value at the point sync returns. This is stronger than buffered channel semantics where the sender only knows the value was placed in a buffer. But it also means that if the receiver is never ready — because it is blocked on another operation, or has exited, or is waiting on a different channel — the sender blocks indefinitely. In a protocol where thread A sends on channel 1 and expects thread B to receive, while thread B sends on channel 2 and expects thread A to receive, both threads block waiting for the other and neither can proceed — a classic rendezvous deadlock that is not detectable at compile time and not caused by any single incorrect operation but by the overall protocol structure.
The comparison with Go channels is illuminating. Go channels have a configurable buffer: make(chan int, N) creates a channel with buffer size N; sends do not block until the buffer is full; receives do not block until the buffer is empty. CML synchronous channels have effective buffer size 0: every send blocks until a receiver arrives. Go's select statement has a fixed number of case branches determined at the call site in source code; it cannot be parameterized by a runtime-computed list of channels. CML's select takes a first-class 'a CML.event list that can be built at runtime from any number of event values, filtered by runtime conditions, produced by functions that return event values based on current state, and transformed uniformly with wrapEvt before being passed to select. This compositional expressiveness — event values as first-class values that can be stored in data structures, passed as function arguments, and returned as function results — is the core design innovation of CML over Go's concurrency model.
The eXene toolkit, a GUI library for the X Window System built at Bell Labs on top of CML, demonstrates the CML event model at large scale. eXene represents user interface events — button presses, window resize notifications, keyboard input, timer expirations — as CML event values. Widget interaction is expressed as event composition: a button widget exposes a CML.event that fires when the button is activated; a menu exposes a CML.event that fires with the selected menu item; the application loop uses CML.select over event values from multiple widgets simultaneously, dispatching to the appropriate handler using wrapEvt-based discriminated union patterns. This architecture makes the event model of a complex interactive GUI application visible in the types and structure of the program, rather than hidden in callback registrations and mutable state.
The guard combinator becomes essential when event construction itself has observable effects. Consider a resource pool where claiming a resource for a potential send should only happen if the send actually proceeds: CML.guard (fn () => let val resource = Pool.checkout () in CML.wrapEvt (CML.sendEvt ch resource, fn () => Pool.checkin resource) end) — the pool checkout happens at sync time, not at event list construction time, so if select chooses a different event, the checkout never occurs and the pool resource is never consumed. Without guard, all event values in a select list have their construction side effects run before the select begins, which means a checkout would happen for every candidate send event regardless of which one wins. Related functional language retainers — Eff developer retainers and Effekt developer retainers — deal with algebraic effect handler composition that has conceptual parallels to CML's event composition, but the mechanisms are different: Eff handlers operate on effect operations raised by a computation, while CML event values describe synchronization actions between concurrent threads.
How HourTab tracks CML developer retainer hours
CML retainer work carries an invisible-hours problem that is specific to the event value model: the question “should this channel operation use a direct synchronous call (recv/send) or an event value (recvEvt/sendEvt/sync)?” seems like a trivial mechanical choice, but it requires understanding the entire future composition trajectory of the operation. A recv cannot be composed into a select without being restructured, and that restructuring can require changes in multiple downstream call sites. The decision to use event values from the beginning — or to identify which existing direct operations need to become event values — requires reviewing the concurrency protocol design for the entire subsystem, not just the individual call site.
The restructuring described in the opening — from recv/send to recvEvt/sendEvt/sync — appears in the diff as a small mechanical substitution at a handful of call sites. The 8 hours of invisible work that produced it included: identifying all future select-composition points in the codebase (every place where a timeout, a cancel signal, or an alternative channel would be needed; this requires reading the concurrency protocol documentation, interviewing the client about expected future requirements, and auditing every channel receive in the codebase for places where indefinite blocking would be a problem); understanding the rendezvous semantics change and how it affected the overall concurrency design (the original protocol assumed every receive would eventually succeed; the timeout-select protocol requires every caller of the receive operation to handle the new TimedOut path, which propagates through the call graph to every function that previously assumed the value would always be present after the receive); designing the sum type for discriminated dispatch from wrapEvt (choosing the right set of variants, ensuring the type is extensible if more events are added later, deciding whether to use a shared result type across multiple select sites or per-site types); and reviewing all callers of the original blocking operations to ensure the timeout and nack behavior changes were acceptable under the existing error handling and retry policies of the system.
HourTab gives CML developers a per-client public URL for their retainer. Each work log entry should name the mechanism: channel category (channel name, whether the operation is send or recv, whether it uses a direct synchronous call or an event value; if event value, whether it participates in a select composition); compose category (for choose/select: the list of events composed at this call site, which event fired at each observed execution; for wrapEvt: the source event type, the transformation function, the result type in the sum type that select produces); guard category (what function is deferred, what side effect is guarded, whether the event construction itself changes based on runtime state at sync time); timeout category (Time.fromMilliseconds value, which channel the timeout raced with, which won at each observed execution, how the timeout case was handled downstream and whether it triggered a retry, a fallback, or an error). Nack work is logged under its own category: whether a withNack was needed for this event, what cleanup the nack event performs when this event loses the race in a select, whether the cleanup is synchronous or spawns a thread.
CML retainers are often compared to Erlang developer retainers for the shared message-passing concurrency foundation. The distinction is significant: Erlang uses a mailbox model where every process has a single inbox that accumulates any message sent to it, and receive pattern-matches against the accumulated messages; CML uses typed channels where a thread can have any number of channels of different types, and communication is a synchronous rendezvous with no message accumulation. Erlang's receive is a pattern-match over the inbox with optional timeout clause; CML's select is a function over a first-class list of event values with wrapEvt result transformation. Erlang has no equivalent of wrapEvt or guard or withNack — these compositions are unique to CML's first-class event model. CML retainers are also compared to Go developer retainers: Go channels can be buffered; CML channels are always synchronous; Go's select statement has a fixed syntax with case branches; CML's select is a function over a dynamically-constructed list of event values. The invisible hours in CML retainers accumulate around the event model design decisions and the select composition architecture, which leaves no trace in the commit history but determines whether the concurrent system handles its edge cases correctly.
HourTab gives CML developers a public retainer-hours URL they send to clients — typically systems programmers building concurrent servers in SML/NJ with CML, researchers implementing concurrent protocols where typed synchronous communication is the correct semantic model, and teams using CML's compositional event model to build reactive systems where select behavior changes at runtime based on which channels are active. For CML retainers, each work log entry should name the mechanism so that the audit trail of event composition decisions — which channels use event values, which select sites compose how many events, which wrapEvt transformations produce which sum type, which withNack cleanups fire and what they release — is visible to clients who otherwise see only the summary line and do not understand why reasoning about rendezvous semantics, select composition trajectory, and timeout propagation through the call graph constitutes billable work distinct from writing the channel operation itself.
Track CML developer retainer hours without the status emails
HourTab gives CML developers a public URL per client retainer. One link, no login, live burn-down. Your clients stop asking “how many hours do I have left?” and your concurrency audit log — event value composition, select design, wrapEvt dispatch, rendezvous semantics — becomes the proof of value that gets the retainer renewed.
See HourTab pricing →FAQ: CML developer retainers
What does a CML developer on retainer typically do?
A CML developer on monthly retainer covers channel creation (CML.channel : unit -> 'a CML.channel; typed synchronous rendezvous channels with no internal buffer), event value construction (CML.sendEvt : 'a CML.channel * 'a -> unit CML.event and CML.recvEvt : 'a CML.channel -> 'a CML.event; event values are first-class descriptions of synchronization actions, not the actions themselves), synchronization (CML.sync : 'a CML.event -> 'a; performs the synchronization described by the event value; blocks until the other party is simultaneously ready), select and choose composition (CML.choose : 'a CML.event list -> 'a CML.event; CML.select : 'a CML.event list -> 'a defined as sync (choose evts); blocks until one event fires, commits to exactly that one), event transformation (CML.wrapEvt : 'a CML.event * ('a -> 'b) -> 'b CML.event for result transformation into sum types; CML.guard : (unit -> 'a CML.event) -> 'a CML.event for deferred construction at sync time), timeout and nack patterns (CML.timeOutEvt : Time.time -> unit CML.event; CML.withNack : (unit CML.event -> 'a CML.event) -> 'a CML.event for cleanup when an event loses the race), thread spawning (CML.spawn : (unit -> unit) -> CML.thread_id), and runtime setup (RunCML.doit; SML/NJ CM.make for cml-lib.cm; MLton CML port).
What CML work is most commonly underlogged in a retainer?
Event value restructuring (identifying all channels that need select-composition capability; converting direct recv/send to recvEvt/sendEvt/sync; propagating TimedOut result type through downstream callers; 5–9 hrs invisible); wrapEvt dispatch design (constructing a sum type for heterogeneous select results; wrapping each event result with the correct variant constructor; ensuring all select branches share a common result type; 4–7 hrs invisible); withNack cleanup design (identifying which events need cleanup when they lose the race in select; spawning cleanup threads that synchronize on the nack event; releasing locks, file descriptors, pool resources when a select branch is abandoned; 4–6 hrs invisible); rendezvous protocol analysis (tracing which thread sends first and which receives first in each channel pair; detecting potential deadlocks where two threads each wait for the other to send first; designing protocols that avoid indefinite blocking; 5–8 hrs invisible).
What are typical CML developer retainer rates?
Entry-level CML developers (1–2 years, basic channel operations, recvEvt/sendEvt event values, sync) bill at $70–$130/hr. Mid-level CML programmers (2–4 years, wrapEvt dispatch design, choose/select composition, withNack cleanup, rendezvous protocol analysis) bill at $110–$195/hr. Senior CML concurrent systems developers (4–8 years, complex guard-deferred event construction, large-scale runtime-built event lists, multi-thread rendezvous protocol design, SML/NJ or MLton CML integration at production scale) bill at $160–$290/hr. Monthly retainer ranges: $2,200–$4,200/mo advisory (15–25 hrs), $5,500–$14,000/mo for full concurrent systems engineering.
What should a CML developer retainer agreement include?
A CML developer retainer agreement should specify: event model scope (which operations use recvEvt/sendEvt event values vs direct recv/send; which operations need to participate in select composition; which operations need timeOutEvt deadlines); composition scope (choose/select for concurrent dispatch from a list of event values; wrapEvt for result transformation into sum types; guard for deferred event construction at sync time; withNack for cleanup when an event loses the race); channel scope (synchronous rendezvous semantics meaning both parties must be simultaneously ready; channel naming conventions; type parameterization 'a CML.channel; which channels need event value wrappers); runtime scope (SML/NJ with CM.make and cml-lib.cm vs MLton CML port; RunCML.doit entry point; CML.spawn thread patterns and thread_id management); and logging format (channel name, operation: sendEvt vs recvEvt vs direct, which select branch fired; wrapEvt source event type, transformation function, target type in sum; withNack: whether nack fired and what cleanup it performed).
How should CML developer retainer hours be logged?
Log each CML retainer session with: channel category (channel name; operation: sendEvt vs recvEvt vs direct recv/send; event value vs direct synchronous; whether the channel participates in any select composition downstream); compose category (for choose/select: list of events composed at this call site, which event fired at each observed select execution; for wrapEvt: source event type, transformation function applied after sync, result type in the discriminated union that select returns; which select branch won the race at observed executions); guard category (deferred event construction: what function is called at sync time, what side effect is guarded from construction-time execution, whether the event itself differs based on runtime state at sync time); timeout category (Time.fromMilliseconds value used, which channel the timeout raced with, which won, how the timeout case was handled downstream and whether it triggered a retry, a fallback value, or an error path propagation); and nack category (whether withNack was needed for this event, what cleanup action the nack event performs when this event loses the race in a select, whether the cleanup is synchronous or spawns a new thread, what resource or state is released by the cleanup).