LemonCore.Testing.AsyncHelpers (lemon_core v0.1.0)

View Source

Shared deterministic synchronization primitives for async test suites.

Replaces ad hoc Process.sleep calls with bounded polling and explicit coordination barriers so that concurrency tests do not depend on wall-clock timing and are therefore reproducible across machines and load conditions.

This module lives in lib/ (not test/) so that every app in the umbrella can import LemonCore.Testing.AsyncHelpers in its test helpers without adding extra compile-time dependencies.

Primitives

Eventual assertions

assert_eventually(fn -> some_condition() end)
assert_eventually(fn -> some_condition() end, timeout: 2_000, interval: 20)

Process-lifecycle helpers

assert_process_dead(pid)            # waits until pid is gone
assert_process_alive(pid)           # waits until pid registers

Latch (single-use barrier, one waiter, one releaser)

latch = latch()
spawn(fn -> do_something(); release(latch) end)
await_latch(latch)

Rendez-vous barrier (N participants, all arrive then all proceed)

barrier = barrier(3)
# Each of the 3 participants calls:
arrive(barrier)
await_barrier(barrier)   # blocks until all 3 have arrived

Ordered task runner

with_ordered_tasks([fn -> work1() end, fn -> work2() end])

Summary

Functions

Signals that a participant has arrived at the barrier. Thread-safe: uses ETS update_counter for atomic increment.

Asserts that condition_fn returns truthy within timeout milliseconds, polling every interval milliseconds.

Waits until pid is alive, up to timeout ms. Fails the test if the process is not alive by the deadline.

Waits until pid is no longer alive, up to timeout ms. Fails the test if the process is still alive at the deadline.

Waits until all participants have arrived at the barrier. Multiple callers can safely call this on the same barrier — the last caller cleans up the ETS table.

Blocks until the latch has been released, polling every interval ms. Fails after timeout ms if the latch was never released.

Creates a barrier that must be arrived at count times before it opens.

Creates a new latch. A latch is a one-shot signal: one caller releases it via release/1, another waits via await_latch/2.

Signals the latch so that await_latch/2 unblocks. Safe to call from any process.

Runs a list of zero-arity functions as async tasks in a controlled, sequential order using latches.

Functions

arrive(ref)

@spec arrive(reference()) :: :ok

Signals that a participant has arrived at the barrier. Thread-safe: uses ETS update_counter for atomic increment.

assert_eventually(condition_fn, opts \\ [])

@spec assert_eventually(
  (-> boolean()),
  keyword()
) :: :ok

Asserts that condition_fn returns truthy within timeout milliseconds, polling every interval milliseconds.

Options:

  • :timeout — maximum wait in ms (default: 2000)
  • :interval — polling interval in ms (default: 10)
  • :message — failure message prefix (default: "condition never became true")

assert_process_alive(pid, opts \\ [])

@spec assert_process_alive(
  pid(),
  keyword()
) :: :ok

Waits until pid is alive, up to timeout ms. Fails the test if the process is not alive by the deadline.

Useful when a pid is obtained before the process has fully started.

assert_process_dead(pid, opts \\ [])

@spec assert_process_dead(
  pid(),
  keyword()
) :: :ok

Waits until pid is no longer alive, up to timeout ms. Fails the test if the process is still alive at the deadline.

await_barrier(ref, opts \\ [])

@spec await_barrier(
  reference(),
  keyword()
) :: :ok

Waits until all participants have arrived at the barrier. Multiple callers can safely call this on the same barrier — the last caller cleans up the ETS table.

await_latch(latch_pid, opts \\ [])

@spec await_latch(
  pid(),
  keyword()
) :: :ok

Blocks until the latch has been released, polling every interval ms. Fails after timeout ms if the latch was never released.

Stops the Agent after returning (the latch is single-use).

barrier(count)

@spec barrier(pos_integer()) :: reference()

Creates a barrier that must be arrived at count times before it opens.

Returns a reference that identifies the barrier. Pass this reference to arrive/1 and await_barrier/2.

latch()

@spec latch() :: pid()

Creates a new latch. A latch is a one-shot signal: one caller releases it via release/1, another waits via await_latch/2.

Implemented as an Agent holding a boolean flag.

release(latch_pid)

@spec release(pid()) :: :ok

Signals the latch so that await_latch/2 unblocks. Safe to call from any process.

with_ordered_tasks(fns)

@spec with_ordered_tasks([(-> any())]) :: [any()]

Runs a list of zero-arity functions as async tasks in a controlled, sequential order using latches.

Each function is started as a task, but blocked on a per-task latch. Latches are released one at a time with a 1 ms gap, allowing each task to make progress before the next one is unblocked.

Returns the list of results in the same order as fns.