|
|
5 months ago | |
|---|---|---|
| .. | ||
| test | 5 months ago | |
| DESIGN_TIMR.md | 5 months ago | |
| README.md | 5 months ago | |
| active-timers.svelte.ts | 5 months ago | |
| backoff.ts | 5 months ago | |
| clock.ts | 5 months ago | |
| consts.ts | 5 months ago | |
| engine-timers.ts | 5 months ago | |
| errors.ts | 5 months ago | |
| index.ts | 5 months ago | |
| types.ts | 5 months ago | |
README.md
timr
Runtime timer scheduler. Zero external dependencies. Singleton-per-App
(never module-global). Engine layer is runes-free; the Active wrapper
layers reactive state on top via engine.onChange — no $effect for
synchronisation, no loops. Race-safe: every native callback is gated
by an (entryId, key, version) tuple so stale executions become
silent no-ops instead of corrupting state. Fake-clock injectable for
deterministic tests.
import { createActiveTimers } from '$timr';
const Timers = createActiveTimers();
// One-shot
Timers.schedule('demo:hello', 1_000, () => console.log('hello'));
// Debounce via replace:true
input.addEventListener('input', () => {
Timers.schedule('search:debounce', 300, runSearch, { replace: true });
});
// Heartbeat — awaitTask:true (default) prevents overlap
Timers.interval('conn:main:heartbeat', 25_000, () => Conn.send('ping'));
// Fire-and-forget cadence — errors don't stop the interval
Timers.interval('telemetry:flush', 5_000, () => Tele.send(), { awaitTask: false });
// Scoped cleanup
Timers.cancelAll('conn:main');
<!-- ActiveTimers exposes reactive snapshots for debug UI -->
<p>{Timers.size} timers, {Timers.scopes.length} scopes</p>
{#each Timers.entriesSnapshot as e (e.key)}
<li>{e.key} — {e.status} (run {e.runCount})</li>
{/each}
Why another one
- Singleton-per-App, not module-global.
App.Timersis one instance per App/runtime — SSR-safe, test-isolated, multi-App-friendly, cleanup is deterministic (App.dispose()cascades toTimers.dispose()). - Race-safety beyond
clearTimeout+AbortSignal. A native callback already on the event loop, an async task resolving after cancel/replace, an interval next-tick scheduled after dispose, an ack timeout firing after the reply arrived — all four become silent no-ops via the(entryId, key, version)guard.AbortSignalis cooperative; the guard is the primary mechanism. - Engine + Active split. Other artifacts (
sess,conn, futurecache/authn) depend on the minimalTimerSchedulerinterface, so they accept either anEngineTimers, anActiveTimers, or a fake-clock-driven double in tests. None of them depend on Svelte runtime. - Keys are scoped.
'conn:main:heartbeat','sess:auto-refresh','cache:products:gc'.cancelAll('conn:main')matches:-separated prefixes and tears down every timer the connection owned in a single call. - No
$effectfor scheduling. The Active wrapper updates a single$statecell from insideengine.onChange— the same dispatch path every external listener uses. Eliminates by design theeffect_update_depth_exceededclass of bug that crops up when timers are driven from$effectchains. - Fake clock first-class. Every operation flows through a
TimerClockinterface. Tests inject a fake clock that advances time deterministically — novi.useFakeTimers, no real waits, ~50 race-safety tests run in <500 ms.
Architecture
timr/
├── index.ts barrel
├── consts.ts status/kind/event literals + log/error builders
├── types.ts TimerScheduler, EngineTimers, ActiveTimers,
│ Handle, Snapshot, Event, Clock, Backoff
├── errors.ts TimrDisposedError + 4 siblings + guards
├── clock.ts createSystemTimerClock()
├── backoff.ts computeBackoffDelay() — pure helper
├── engine-timers.ts runes-free core, (id,key,version) guard
├── active-timers.svelte.ts runed wrapper — no $effect, no scheduling
└── test/
├── backoff.test.ts (9 tests)
├── engine-timers.test.ts (50 tests, fake clock)
└── active-timers.svelte.test.ts (7 browser tests)
Alias
alias: {
$timr: 'src/arts/timr';
}
Scope
In scope:
- One-shot timers (
schedule(key, delayMs, …)) - Absolute scheduling (
scheduleAt(key, dueAt, …)—dueAt < now⇒ delay 0) - Intervals via recursive
setTimeout(no nativesetInterval) - Two interval modes:
awaitTask:true(sequential, default) vsawaitTask:false(fire-and-forget cadence) maxRunscap on intervals- Replace-by-key (
{ replace: true }) - Reschedule on the handle (
handle.reschedule(newDelayMs)) - Per-entry
AbortController(cooperative task cancellation) - Scoped cancel (
cancelAll('conn:main')) - Tagged events (
scheduled,running,completed,failed,cancelled,disposed) - Snapshots for debug/UI (
entries(),entry(key)) - Backoff helper (
computeBackoffDelay, pure, injectable random) - Active wrapper for reactive snapshots
- Fake clock injection (
{ clock }) - Programmer-error classes with stable names + type guards
Out of scope:
- Cron / calendar / wall-clock-anchored schedules
- Persistence / durable jobs / cross-tab scheduling
- Server-side queues / retry policies
- Background workers (the page must stay open)
- Auto-reconnect / circuit breaking — that's
arts/conn's job (which composestimrfor its own scheduling)
Race safety — the (id, key, version) guard
Every native callback captures three values when armed:
id — unique per entry instance (a new entry from replace:true gets
a fresh id)
key — stable user-facing key
version — bumped every time future execution of the entry is invalidated
Before mutating state, the engine looks up the entry via getLiveEntry(id, key, version). Three checks must all pass:
- An entry with that
keyis still in the registry - Its
idmatches the captured value - Its
versionmatches the captured value
Any mismatch makes the callback a silent no-op. This guards against four
race classes that clearTimeout + AbortSignal alone do not cover:
| Race | What happens | Why the guard catches it |
|---|---|---|
Native timeout fires while cancel() runs |
clearTimeout was called but the callback was already in the event loop |
Cancel deletes the entry → entries.get(key) === undefined → no-op |
Async task resolves after replace:true |
First entry's task continuation runs after a new entry took its key | entry.id doesn't match the captured id → no-op |
Interval next-tick scheduled after dispose() |
Tick was armed in branch A before dispose ran | Cancel-via-dispose increments version → no-op |
| Ack-style timeout firing after reply arrived | Reply called cancel(ackKey); timeout was already queued |
Same as #1 |
There is one subtlety. For finalisation after an in-flight task
(finishEntry), the engine identifies the entry by (id, key) only —
not version. This is because awaitTask: false arms the next
tick before the current task settles, which legitimately bumps
version. Using the version here would silently swallow the
completed/failed event for the just-finished tick. Status checks
(cancelled / replaced) still apply.
AbortSignal is exposed on ctx.signal for cooperative cancellation
of work the task itself can interrupt (fetch, etc.). It is not the
primary guard — it complements the (id, key, version) tuple.
API
createEngineTimers(options?)
Runes-free engine. Safe to import server-side.
const Timers = createEngineTimers({
clock?: TimerClock, // default: system clock
logger?: TimerLogger // optional debug/error sink
});
createActiveTimers(options?)
Reactive wrapper around the engine.
const Timers = createActiveTimers({ clock, logger });
Timers.size; // reactive
Timers.keysSnapshot; // reactive readonly string[]
Timers.entriesSnapshot; // reactive readonly TimerEntrySnapshot[]
Timers.scopes; // reactive readonly string[] (deduplicated)
Scheduling
Timers.schedule(key, delayMs, task, opts?) // one-shot
Timers.scheduleAt(key, dueAt, task, opts?) // absolute; past => delay 0
Timers.interval(key, everyMs, task, opts?) // repeating
opts: {
replace?: boolean, // default false (throws on dup)
removeOnComplete?: boolean, // default true for one-shots
meta?: Record<string, unknown> // surfaced in snapshot
}
// interval-only options:
opts: { ...above,
awaitTask?: boolean, // default true
maxRuns?: number // default infinite
}
All schedulers return a TimerHandle:
handle.key // the key
handle.active // false after cancel/complete/dispose
handle.cancel(): boolean // true if it actually cancelled something
handle.reschedule(delayMs) // throws TimrInactiveTimerError if not active
Cancellation
Timers.cancel(key): boolean // single key
Timers.cancelAll(scope?): number // returns count cancelled
Timers.has(key): boolean
Timers.dispose() // cancels everything
cancelAll('a:b') matches 'a:b' exactly and any key starting with
'a:b:'. It does not match 'a:bb'.
Snapshots
Timers.entries(): readonly TimerEntrySnapshot[]
Timers.entry(key): TimerEntrySnapshot | null
Timers.keys(): readonly string[]
A TimerEntrySnapshot carries the read-only state of one entry —
status, kind, scope, runCount, lastFiredAt, lastCompletedAt,
lastErrorAt, error, meta.
Events
const off = Timers.onChange((event) => {
// event.type ∈ scheduled | running | completed | failed
// | cancelled | disposed
// event.entry: TimerEntrySnapshot (except for 'disposed')
});
off();
Backoff helper
import { computeBackoffDelay } from '$timr';
for (let attempt = 0; ; attempt++) {
if (await tryConnect()) break;
const delay = computeBackoffDelay(attempt, {
minDelayMs: 500,
maxDelayMs: 15_000,
factor: 1.8,
jitterMs: 500
});
await new Promise((r) => setTimeout(r, delay));
}
Pure function. attempt is 0-indexed. random is injectable as the
third arg for deterministic tests.
awaitTask: true vs awaitTask: false
awaitTask: true (default) |
awaitTask: false |
|
|---|---|---|
| Next tick scheduled… | …after the current task settles | …before the current task settles |
| Task overlap | Impossible | Possible (each tick fire-and-forget) |
| Failure stops interval? | No, but next tick respects task duration | No |
| Use for | Sequential work (refresh, checkpoint) | Cadence (heartbeat, telemetry flush) |
The semantics of awaitTask: false continues ticking after task failure
is frozen — see DESIGN_TIMR §12.5.1 + the dedicated test in
engine-timers.test.ts. If a consumer needs a failing interval to
stop, they cancel explicitly from inside the task or use
awaitTask: true.
Errors
| Class | Thrown when | Type guard |
|---|---|---|
TimrDisposedError |
mutator called after dispose() |
isTimrDisposedError |
TimrInvalidKeyError |
key is not a non-empty string | isTimrInvalidKeyError |
TimrDuplicateKeyError |
key already exists, no replace:true |
isTimrDuplicateKeyError |
TimrInvalidDelayError |
delayMs is non-finite or negative |
isTimrInvalidDelayError |
TimrInactiveTimerError |
handle.reschedule() on inactive entry |
isTimrInactiveTimerError |
Task failures at runtime are not thrown — they surface as
TIMER_EVENT_FAILED events on onChange. Reserve exceptions for
programmer errors where catching at the call site is the right
pattern.
SSR
EngineTimers is safe to import server-side. Creating one is fine;
scheduling during SSR should be intentional. If a request-scoped
EngineTimers schedules anything, the request must dispose() it
before completion — otherwise the timer leaks across requests.
There is no module-global singleton. App.Timers is owned by the
ActiveApp instance and torn down by App.dispose().
Testing
Use a fake clock for deterministic tests. The canonical shape from
engine-timers.test.ts:
function createFakeClock(start = 0): FakeClock {
let now = start;
const queue = new Map<number, FakeClockEntry>();
let nextId = 1;
return {
now: () => now,
setTimeout(fn, delayMs) {
/* enqueue with dueAt = now + delayMs */
},
clearTimeout(handle) {
/* mark cancelled */
},
async advanceBy(ms) {
/* fire all entries up to now + ms in order */
},
async advanceTo(t) {
/* … */
},
pendingCount() {
/* … */
}
};
}
const clock = createFakeClock();
const t = createEngineTimers({ clock });
t.schedule('demo', 1_000, () => count++);
await clock.advanceBy(1_000); // fires deterministically
The race-safety tests in engine-timers.test.ts cover every
must-be-safe scenario from DESIGN_TIMR §12.8.10 — run them before
touching any cancellation, replace, reschedule or dispose path.
Integration patterns
sess — auto-refresh (Fase 4)
const stop = withAutoRefresh(Sess, {
timers: App.Timers,
tickMs: 30_000,
marginMs: 90_000
});
// internally: timers.interval('sess:auto-refresh', tickMs, …)
// stop() → timers.cancel('sess:auto-refresh')
conn — reconnect, heartbeat, ack timeouts
// reconnect with backoff
timers.schedule(`conn:${name}:reconnect`, computeBackoffDelay(attempt, opts), () =>
connection.reconnect()
);
// heartbeat
timers.interval(`conn:${name}:heartbeat`, 25_000, () => connection.send('conn.ping'));
// ack timeout per outgoing message
timers.schedule(`conn:${name}:ack:${id}`, 10_000, () => resolveAckTimeout(id));
// when the reply arrives
timers.cancel(`conn:${name}:ack:${id}`);
// disconnect
timers.cancelAll(`conn:${name}`);
Future cache — staleTime / gcTime / refetch
timers.schedule(`cache:${queryKey}:gc`, gcTime, () => evict(queryKey));
timers.interval(`cache:${queryKey}:refetch`, refetchInterval, refetch);
Future authn — OTP expiry / cooldowns
timers.schedule('authn:otp:expiry', 5 * 60_000, expireOtp);
timers.schedule('authn:login-attempt:cooldown', cooldown, unlockUI);
Bundle profile
| Layer | Approx. size (min) |
|---|---|
| Engine + types + errors + consts | ~3.5 KB |
| Active wrapper (runes) | +0.5 KB |
| Backoff helper | +0.2 KB |
| System clock | +0.1 KB |
| Total when everything is reached | ~4.3 KB |
Zero runtime dependencies.
Pitfalls
Don't drive timers from $effect
// ❌ effect_update_depth_exceeded waiting to happen
$effect(() => {
if (Sess.current) timers.schedule(...);
});
// ✓ react to engine events instead
Sess.onChange(() => {
timers.schedule(..., { replace: true });
});
Don't create module-global singletons
// ❌ breaks SSR isolation, test isolation, HMR cleanup
export const Timers = createActiveTimers();
// ✓ one per App
const App = createActiveApp({ /* ... */ });
App.Timers.schedule(...);
Don't forget scoped cleanup
// ❌ orphan timers leak across reconnects
disconnect() { /* ... */ }
// ✓ cancel everything the artifact owns
disconnect() { timers.cancelAll(`conn:${name}`); }
Don't promise durable work
arts/timr is runtime-local scheduling. It does not survive
browser close, process exit, or device sleep beyond what the host's
setTimeout semantics guarantee. For durable jobs you need a server
queue — out of scope.