You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dev 6e29d73572
Checkpoint framework artifacts before perm cleanup
5 months ago
..
test Add arts/timr, arts/sess, arts/http; sess actor extension; aapp factory 5 months ago
DESIGN_TIMR.md Add arts/timr, arts/sess, arts/http; sess actor extension; aapp factory 5 months ago
README.md Checkpoint framework artifacts before perm cleanup 5 months ago
active-timers.svelte.ts Add arts/timr, arts/sess, arts/http; sess actor extension; aapp factory 5 months ago
backoff.ts Checkpoint framework artifacts before perm cleanup 5 months ago
clock.ts Add arts/timr, arts/sess, arts/http; sess actor extension; aapp factory 5 months ago
consts.ts Checkpoint framework artifacts before perm cleanup 5 months ago
engine-timers.ts Checkpoint framework artifacts before perm cleanup 5 months ago
errors.ts Checkpoint framework artifacts before perm cleanup 5 months ago
index.ts Add arts/timr, arts/sess, arts/http; sess actor extension; aapp factory 5 months ago
types.ts Checkpoint framework artifacts before perm cleanup 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.Timers is one instance per App/runtime — SSR-safe, test-isolated, multi-App-friendly, cleanup is deterministic (App.dispose() cascades to Timers.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. AbortSignal is cooperative; the guard is the primary mechanism.
  • Engine + Active split. Other artifacts (sess, conn, future cache/authn) depend on the minimal TimerScheduler interface, so they accept either an EngineTimers, an ActiveTimers, 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 $effect for scheduling. The Active wrapper updates a single $state cell from inside engine.onChange — the same dispatch path every external listener uses. Eliminates by design the effect_update_depth_exceeded class of bug that crops up when timers are driven from $effect chains.
  • Fake clock first-class. Every operation flows through a TimerClock interface. Tests inject a fake clock that advances time deterministically — no vi.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 native setInterval)
  • Two interval modes: awaitTask:true (sequential, default) vs awaitTask:false (fire-and-forget cadence)
  • maxRuns cap 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 composes timr for 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:

  1. An entry with that key is still in the registry
  2. Its id matches the captured value
  3. Its version matches 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.

Powered by TurnKey Linux.