# Timer support for tulisp (design notes)
Plan for adding `run-with-timer` / `cancel-timer` to tulisp without new
Rust dependencies. Not yet implemented.
## Constraints
From `CLAUDE.md`:
- std only (no external crates)
- no `unsafe`
- `sync` feature uses `Arc<RwLock>`; default uses `Rc<RefCell>`
`TulispContext` is `!Send` in the default build, so any callback must
ultimately run on the thread that owns the interpreter.
## Options
### A. Cooperative polled timers — recommended
Store a `Vec<Timer>` on `TulispContext`, wrapped in interior mutability
so callbacks can add/cancel timers during `process_timers`.
```rust
struct Timer {
id: u64,
deadline: Instant,
repeat: Option<Duration>,
callback: TulispObject,
args: TulispObject, // already-evaluated arg list
cancelled: bool,
}
```
Exposed lisp API:
- `(run-with-timer DELAY REPEAT CALLBACK &rest ARGS)` → integer id
- `DELAY` in seconds (float ok), `REPEAT` nil or seconds
- `(cancel-timer ID)` → t/nil
- `(timer-list)` → optional, for debugging
Rust-side API for the host:
- `ctx.process_timers() -> Result<usize, Error>` — fires every timer
whose deadline has passed, reschedules repeaters, returns how many
fired. Host calls this once per main-loop iteration.
- `ctx.next_timer_deadline() -> Option<Instant>` — lets the host cap
its poll/sleep timeout so it wakes in time.
Sketch of `process_timers`:
1. `let now = Instant::now();`
2. Borrow the timers vec, collect ready (non-cancelled, deadline ≤ now)
into a local `Vec<Timer>`, drop the borrow. This is the key move:
`ctx.eval` during callback invocation will need `&mut self`, and we
must not hold the inner borrow across it.
3. For each ready timer, call `funcall` with `callback` + `args`.
4. Re-borrow timers, push repeaters back with `deadline += repeat`,
drop any that were cancelled mid-callback.
Handle to storage lives as `Rc<RefCell<TimerState>>` on the context (or
`Arc<RwLock<…>>` under `sync`). Use the existing `Shared` wrapper so
the same code compiles either way.
Roughly 80 lines + tests. No threads.
Tradeoff: callback precision is bounded by the host's poll cadence. For
a 250 ms TUI loop, timers fire accurate to ~250 ms. For microsim's
dashboard and for the existing `every`-based simulator ticks, that's
more than enough.
### B. Thread + mpsc wakeup
Same `Timer` struct and same lisp API. Add a std::thread that sleeps
until the nearest deadline and pushes on an `mpsc::Sender<()>` that the
host selects on alongside its event source.
Callbacks still run on the main thread (required — `TulispContext` is
`!Send` without the `sync` feature, and even with it, a single writer
model is easier to reason about). The thread exists only to translate
"deadline reached" into "wake the main loop now."
Pros: sub-poll-interval accuracy; host doesn't have to think about
`next_timer_deadline`.
Cons:
- Add/cancel must re-wake the scheduler thread (channel send or a
`Condvar`). Classic timer-wheel sync dance; bugs live here.
- Shutdown path has to cleanly drop the scheduler thread.
- ~150 lines, plus a real test story for the threading.
Skip unless sub-100 ms timer accuracy actually matters. For microsim
it doesn't.
### C. Async timers
Would pull `tokio` (or equivalent). Violates the no-dep rule. Out.
## Recommendation
Ship **A**. It matches tulisp's single-threaded, no-dep posture, folds
microsim's existing application-level `every` macro down into the
interpreter, and gives us a proper `cancel-timer` which `every`
doesn't have.
## Notes / gotchas
- `funcall` takes a pre-evaluated callback and an already-evaluated
args list. `run-with-timer` should use `defspecial` so it can
evaluate `DELAY`/`REPEAT`/`CALLBACK` but leave the callback body
unevaluated, then pre-build the args list once (see how `mapcar`
handles this).
- The ids should come from a monotonic `u64` counter on the timer
state, never reused. `cancel-timer` on an unknown id is a no-op
returning nil, not an error.
- `process_timers` must not panic if a callback raises; propagate the
error back to the host after reinserting any still-live repeaters so
the next tick is in a clean state.
- Test strategy: inject a fake clock (`trait Clock` on the timer
state) so tests don't have to sleep. Same pattern as stdlib's
`Instant` replacement in other no-dep projects.
- Under `--features sync`, the timer vec lives behind `Arc<RwLock>`
like everything else. No extra work if we use the existing `Shared`
alias.