Time & Pacing

The TimePort — one monotonic clock, one wall clock, and the schedulers pacing is built on.

The TimePort is the seam every pacing mechanism in the mobile app is built on: one monotonic clock, one wall clock, and a per-run scheduler for timers and frames.

Diagram

TimePort

⏱ nowMs
monotonic

🧺 Coalescer
Publish Gate

🕰 epochMs
wall clock

💾 Session record

🎛 createScheduler

⏲ setTimer / clearTimer

🖼 requestFrame

🔁 every

Quick Start

  1. Pick the real adapter at boot

    createWebTime() builds a TimePort over the browser’s clock and frame loop. Pass it as time: to createApp.

    ts
    import { createWebTime } from "praisonai-mobile/adapters/web";
    
    const booted = await createApp({
      time: createWebTime(),
      // ...the rest of the platform adapters
    });
  2. Read a monotonic instant

    Call time.nowMs() for delay budgets and elapsed measurements. Call time.epochMs() only when a record needs a real timestamp.

    ts
    const startedAt = time.nowMs();      // monotonic: for durations
    const writtenAt = time.epochMs();    // wall clock: for records

    This is what production now does — deps.now is not passed on the real device path, so time.epochMs() runs.

  3. Poll or paint through a scheduler

    Create one scheduler per run, then use every, setTimer + clearTimer, and requestFrame. Dispose it at teardown.

    ts
    const scheduler = time.createScheduler();     // one per run
    const stop = time.every(16, () => coalescer.flush());
    scheduler.requestFrame(() => paint());
    // on teardown
    stop();
    scheduler.clearTimer();

How It Works

The port carries two clocks and a scheduler factory, and each member has one job.

MemberTypePurpose
nowMs()() => numberMonotonic clock. Never goes backwards, never jumps. Use for delay budgets, elapsed-time measurements, the coalescer’s maxDelayMs.
epochMs()() => numberWall clock. Milliseconds since the Unix epoch. Use when a record needs a real timestamp; Session.record writes this. May jump when NTP corrects.
every(ms, cb)(ms: number, cb: () => void) => UnsubscribeFires the callback repeatedly every ms. Returns an unsubscribe. Backs readiness polling, the coalescer’s flush tick, elapsed-time updates.
createScheduler()() => SchedulerReturns a fresh scheduler bound to this port. One per run. Reusing a scheduler across runs carries a closed publish gate into a new answer.
scheduler.setTimer(cb, ms)(cb: () => void, ms: number) => voidArm a one-shot timer. The publish gate uses it as its fallback timeout.
scheduler.clearTimer()() => voidCancel the pending timer. A clearTimer that does nothing reopens the gate.
scheduler.requestFrame(cb)(cb: () => void) => voidPaint on the next animation frame. Backed by requestAnimationFrame in the web adapter.

Why Two Clocks

One clock measures durations, the other stamps records, and collapsing them reintroduces the bug the port was carved to prevent.

Diagram

Two clocks, one reason

System clock
NTP-corrected

Wall clock
epochMs

Monotonic clock
nowMs

💾 Record when the turn ended

🧺 How long has this buffer been open?

When the phone’s clock is corrected mid-turn — a common event on a wake from sleep — a wall-clock elapsed measurement can jump backwards. The publish gate’s MAX_HELD_CHARS and the coalescer’s maxDelayMs are both measured against nowMs, so pacing stays sane through a correction. Records that need to be sortable across devices use epochMs.

The app now reads the port, not Date.now()

main.ts now reads the wall clock through TimePort.epochMs and paces the announcer with the monotonic nowMs — until this landed the shipping app read Date.now() for both, and TimePort.epochMs was implemented, conformance-tested, and had zero callers.

Every wall-clock read in main.ts was Date.now(): the chat record’s updated, the chats-screen “now” argument to buildChatList, and — crucially — the nowMs passed to the screen-reader announcer.

ts
// main.ts — the wall clock resolves through the port on the real device path.
const writtenAt = nowEpochMs();       // platform.time.epochMs() in production
const paced = announce(state, chunk, platform.time.nowMs()); // monotonic

The announcer’s rate-limit nowMs - lastStreamAtMs >= ANNOUNCE_INTERVAL_MS is an elapsed comparison — exactly the conflation core/src/ports/time.ts says in as many words must not be fed a wall clock: “Conflating them is how a clock correction mid-stream makes a coalescer wait forever.”


Executable Specification

The port’s first conformance suite runs the same cases against the fake clock and the web adapter, so the two cannot drift.

TestAsserts
nowMs is monotonicnowMs() never returns a smaller value than the previous call. The coalescer’s delay budget depends on it.
epochMs is wall time and nowMs is not (real-clock only)epochMs() > 1_600_000_000_000; nowMs() < 1_600_000_000_000. An adapter that returns Date.now() for both compiles, passes every other case, and reintroduces the NTP-jump bug.
every() repeats, rather than firing onceFires ≥ 2 times across three periods. Guards the setInterval → setTimeout mutation that ships polling loops that fire exactly once.
the unsubscribe actually stops itAfter stop(), no further callbacks. Guards a leaked interval that keeps the event loop alive for the app’s lifetime. Now also pinned against hollowing by the time_unsubscribe_does_nothing break mode — see Adapter Conformance.
a cleared timer does not fireAfter clearTimer(), the timer never fires. Guards a clearTimer that does nothing, which reopens the publish gate after it was deliberately shut.
an uncleared timer does fireThe pair to the previous case; guards a setTimer that never fires and disables the gate’s only escape from a stalled renderer.
requestFrame runs its callbackThe callback runs within a normal wait. Backed by requestAnimationFrame in the web adapter.
schedulers are independentClearing one scheduler’s timer must not clear another’s. Enforces the “one per run” rule.
the WALL CLOCK is read through TimePort.epochMs, not Date.nowA Platform whose epochMs returns a fixed value stamps that value on the chat record. The production path is exercised — no now dep is passed.
streaming announcements are paced by the MONOTONIC clock, not the wall clockA monotonic clock that advances during a stream that never ends still lets both finished sentences reach the polite live region — a wall-clock Date.now() there rate-limits the second announcement out.

Common Patterns

The coalescer’s flush tick is a repeating every, stopped at dispose.

ts
const stop = time.every(maxDelayMs, () => coalescer.flush());
// on dispose
stop();

The publish gate arms a one-shot timer and clears it when a frame arrives.

ts
const scheduler = time.createScheduler();
scheduler.setTimer(forcePaint, UNPAINTED_REOPEN_MS);
// on frame arrival
scheduler.clearTimer();

Best Practices

Read the monotonic clock for durations, the wall clock for records

Collapsing them makes elapsed measurements jump when the OS corrects the clock.

One scheduler per run, disposed with the run

A scheduler that outlives its run carries closed gates into the next answer. With the tick now gated, a scheduler reused across runs really can carry a closed gate into the next answer — the risk is no longer theoretical.

every() returns an unsubscribe, and you must call it

A leaked interval keeps the event loop alive for the app’s lifetime.

Pair every setTimer with an eventual clearTimer on cancel

A gate that stays armed after a cancel forces a paint that no longer belongs.

Test pacing against a fake clock that actually advances

A TimePort.every fake that never fires the tick makes every pacing test pass for the wrong reason.


Related