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() { ... }

@export("publicName") fn local_name() { ... }
@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.

@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.

@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.

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.