Decorators

Two categories: trigger decorators that schedule a function, and wrapping decorators that intercept every call.

Summary

Trigger decorators (@on_start, @on_exit, @every(dur),@cron("..."), @delayed(dur), @watch("path"),@on_signal("INT")) fire the function automatically; the runtime stays alive while persistent triggers are registered. Wrapping decorators (@memoize, @retry(n), @trace, @timed) replace the bound name with a dispatcher that intercepts calls and delegates to the original. Multiple decorators compose in declaration order, outermost first. @once only composes with repeating triggers and makes them fire exactly once.

Canonical

A function declaration can carry one or more decorators. They split into two categories. Trigger decorators answer "what schedules this function?" -- the runtime fires the body without a direct caller. Wrapping decorators answer "what happens around every call?" -- the bound name resolves to a dispatcher that intercepts the call and delegates to the original.

Trigger decorators

@on_start fn boot() { setup_things() }
@on_exit  fn cleanup() { close_handles() }

@every(1s) fn tick() { metrics.flush() }
@cron("0 * * * *") fn hourly() { rotate_logs() }
@delayed(500ms) fn warmup() { prefetch() }

@watch("./config.toml") fn config_changed() { config.reload() }

@on_signal("INT") fn graceful() { state = "shutting_down" }
@on_panic        fn record() { telemetry.flush() }

@bench   fn bench_sort() { ... }
@example fn example_basic_use() { ... }

@once @every(5s) fn one_shot() { ... }

Lifecycle (@on_start, @on_exit, @on_panic) and signal (@on_signal) decorators don't take parameters on the decorated fn. Schedule decorators (@every, @cron, @delayed, @watch) don't either; they fire without a caller. @bench and @example are allowed to take parameters since the runner passes a harness.

@once only composes with a repeating trigger (@every, @cron, @on_signal, @watch); attaching it to a one-shot decorator is a parse error.

The runtime stays alive while any persistent trigger is registered (@every, @cron, @on_signal, @watch). Once all of them have fired or been quiesced by @once, the process exits naturally. xs.exit(n) forces an immediate shutdown and still fires @on_exit handlers.

Wrapping decorators

@memoize fn fib(n) {
    if n < 2 { return n }
    return fib(n-1) + fib(n-2)
}

@retry(5) fn fetch(url) { http.get(url) }

@trace fn handle(req) { ... }
@timed fn build_index() { ... }

@memoize caches results by a string-key derived from the call's argument values; subsequent calls with equal arguments return the cached value without re-running the body. Recursive calls hit the same cache, so fib(10) only invokes the body 11 times. The body must be statically pure (the analyzer described under "Purity inference" decides), since a memoized impure body would silently skip its side effects on cache hits. Decorating an impure function throws PurityError at decoration time.

@retry(n) runs the body up to n attempts, swallowing each thrown exception and retrying. The first attempt that returns normally becomes the call's result. If every attempt throws, the final exception is re-raised so the caller's surrounding try (or the top-level handler) can see it. The default is 3 attempts when the argument is omitted. The body must also be pure, for the same replay reason: a non-deterministic body would fire its side effects once per attempt.

@trace prints the call name and arguments before the body runs and the return value after, both to stderr. @timed measures monotonic wall-clock around the call and prints the elapsed milliseconds. Both pass arguments through unchanged and return the body's result. Neither requires purity, since both delegate the call exactly once and never repeat the body or skip it.

Wrapping decorators compose with each other in declaration order (outermost first):

@timed @memoize fn expensive(x) { ... }

Here @memoize wraps the original fn first, then @timed wraps the memoized dispatcher; the timer measures the cache hit when one exists. The purity gate applies to the underlying function, not the intermediate wrapper, so @trace @memoize fn ... works as long as the wrapped fn itself is pure.