diff --git a/packages/SwingSet/docs/timer.md b/packages/SwingSet/docs/timer.md index ac362fd39e9..1ee5521045a 100644 --- a/packages/SwingSet/docs/timer.md +++ b/packages/SwingSet/docs/timer.md @@ -2,14 +2,14 @@ There's documentation elsewhere about [how devices fit into the SwingSet architecture](devices.md). In order to install a Timer device, you first build -a timer object in order to create the timer's endowments, source code, and +a timer object in order to create the timer's endowments, source code, and `poll()` function. ## Kernel Configuration The timer service consists of a device (`device-timer`) and a helper vat (`vat-timer`). The host application must configure the device as it builds the swingset kernel, and then the bootstrap vat must finish the job by wiring the device and vat together. -``` +```js import { buildTimer } from `@agoric/swingset-vat`; const timer = buildTimer(); ``` @@ -67,42 +67,88 @@ A single application might have multiple sources of time, which would require th The `timerService` object can be distributed to other vats as necessary. ```js - // for this example, assume poll() provides seconds-since-epoch as a BigInt + // for this example, assume poll() provides seconds-since-epoch const now = await E(timerService).getCurrentTimestamp(); - - // simple non-cancellable Promise-based delay - const p = E(timerService).delay(30); // fires 30 seconds from now - await p; - // to cancel wakeups, first build a handler + // simple one-shot Promise-based relative delay + const p1 = E(timerService).delay(30n); // fires 30 seconds from now + await p1; + + // same, but cancellable + const cancel2 = Far('cancel', {}); // any pass-by-reference object + // the cancelToken is always optional + const p2 = E(timerService).delay(30n, cancel2); + // E(timerService).cancel(cancel2) will cancel that + + // same, but absolute instead of relative-to-now + const monday = 1_660_000_000; + const p3 = E(timerService).wakeAt(monday, cancel2); + await p3; // fires Mon Aug 8 16:06:40 2022 PDT + // non-Promise API functions needs a handler callback const handler = Far('handler', { - wake(t) { console.log(`woken up at ${t}`); }, + wake(t) { console.log(`woken up, scheduled for ${t}`); }, }); - - // then for one-shot wakeups: - await E(timerService).setWakeup(startTime, handler); - // handler.wake(t) will be called shortly after 'startTime' + + // then for one-shot absolute wakeups: + await E(timerService).setWakeup(monday, handler, cancel2); + // handler.wake(t) will be called shortly after monday // cancel early: - await E(timerService).removeWakeup(handler); + await E(timerService).cancel(cancel2); // wake up at least 60 seconds from now: - await E(timerService).setWakeup(now + 60n, handler); - + await E(timerService).setWakeup(now + 60n, handler, cancel2); - // makeRepeater() creates a repeating wakeup service: the handler will - // fire somewhat after 80 seconds from now (delay+interval), and again - // every 60 seconds thereafter. Individual wakeups might be delayed, - // but the repeater will not accumulate drift. + // repeatAfter() creates a repeating wakeup service: the handler will + // fire somewhat after 20 seconds from now (now+delay), and again + // every 60 seconds thereafter. The next wakeup will not be scheduled + // until the handler message is acknowledged (when its return promise is + // fulfilled), so wakeups might be skipped, but they will always be + // scheduled for the next 'now + delay + k * interval', so they will not + // accumulate drift. If the handler rejects, the repeater will be + // cancelled. const delay = 20n; const interval = 60n; + E(timerService).repeatAfter(delay, interval, handler, cancel2); + + // repeating wakeup service, Notifier-style . This supports both the + // native 'E(notifierP).getUpdateSince()' Notifier protocol, and an + // asyncIterator. To use it in a for/await loop (which does not know how + // to make `E()`-style eventual sends to the remote notifier), you must + // wrap it in a local "front-end" Notifier by calling the `makeNotifier()` + // you get from the '@agoric/notifier' package. + + const notifierP = E(timerService).makeNotifier(delay, interval, cancel2); + // import { makeNotifier } from '@agoric/notifier'; + const notifier = makeNotifier(notifierP); + + for await (const scheduled of notifier) { + console.log(`woken up, scheduled for ${scheduled}`); + // note: runs forever, once per 'interval' + break; // unless you escape early + } + + // `makeRepeater` creates a "repeater object" with .schedule + // and .disable methods to turn it on and off + const r = E(timerService).makeRepeater(delay, interval); E(r).schedule(handler); E(r).disable(); // cancel and delete entire repeater - - // repeating wakeup service, Notifier-style - const notifier = E(timerService).makeNotifier(delay, interval); + + // the 'clock' facet offers `getCurrentTimestamp` and nothing else + const clock = await E(timerService).getClock(); + const now2 = await E(clock).getCurrentTimestamp(); + + // a "Timer Brand" is an object that identifies the source of time + // used by any given TimerService, without exposing any authority + // to get the time or schedule wakeups + + const brand1 = await E(timerService).getTimerBrand(); + const brand2 = await E(clock).getTimerBrand(); + assert.equal(brand1, brand2); + assert(await E(brand1).isMyTimerService(timerService)); + assert(await E(brand1).isMyClock(clock)); ``` diff --git a/packages/SwingSet/src/vats/timer/types.d.ts b/packages/SwingSet/src/vats/timer/types.d.ts index 7b0a868a51a..a3e43dec684 100644 --- a/packages/SwingSet/src/vats/timer/types.d.ts +++ b/packages/SwingSet/src/vats/timer/types.d.ts @@ -9,18 +9,17 @@ import type { RankComparison } from '@agoric/store'; // meant to be globally accessible as a side-effect of importing this module. declare global { /** - * TODO As of PR #5821 there is no `TimerBrand` yet. The purpose of #5821 - * is to prepare the ground for time objects labeled by `TimerBrand` in - * much the same way that `Amounts` are asset/money values labeled by - * `Brands`. - * As of #5821 (the time of this writing), a `TimerService` is actually - * used everywhere a `TimerBrand` is called for. + * TODO Timestamps are not yet labeled with the TimerBrand (in much + * the same way that `Amounts` are asset/money values labeled by + * `Brands`), and a `TimerService` is still used everywhere a + * `TimerBrand` is called for. * * See https://github.com/Agoric/agoric-sdk/issues/5798 * and https://github.com/Agoric/agoric-sdk/pull/5821 */ type TimerBrand = { isMyTimer: (timer: TimerService) => ERef; + isMyClock: (clock: Clock) => ERef; }; /** @@ -74,6 +73,13 @@ declare global { */ type RelativeTime = RelativeTimeRecord | RelativeTimeValue; + /** + * A CancelToken is an arbitrary marker object, passed in with + * each API call that creates a wakeup or repeater, and passed to + * cancel() to cancel them all. + */ + type CancelToken = object; + /** * Gives the ability to get the current time, * schedule a single wake() call, create a repeater that will allow scheduling @@ -87,13 +93,27 @@ declare global { /** * Return value is the time at which the call is scheduled to take place */ - setWakeup: (baseTime: Timestamp, waker: ERef) => Timestamp; + setWakeup: ( + baseTime: Timestamp, + waker: ERef, + cancelToken?: CancelToken, + ) => Timestamp; /** - * Remove the waker - * from all its scheduled wakeups, whether produced by `timer.setWakeup(h)` or - * `repeater.schedule(h)`. + * Create and return a promise that will resolve after the absolte + * time has passed. */ - removeWakeup: (waker: ERef) => Array; + wakeAt: ( + baseTime: Timestamp, + cancelToken?: CancelToken, + ) => Promise; + /** + * Create and return a promise that will resolve after the relative time has + * passed. + */ + delay: ( + delay: RelativeTime, + cancelToken?: CancelToken, + ) => Promise; /** * Create and return a repeater that will schedule `wake()` calls * repeatedly at times that are a multiple of interval following delay. @@ -106,7 +126,17 @@ declare global { makeRepeater: ( delay: RelativeTime, interval: RelativeTime, + cancelToken?: CancelToken, ) => TimerRepeater; + /** + * Create a repeater with a handler directly. + */ + repeatAfter: ( + delay: RelativeTime, + interval: RelativeTime, + handler: TimerWaker, + cancelToken?: CancelToken, + ) => void; /** * Create and return a Notifier that will deliver updates repeatedly at times * that are a multiple of interval following delay. @@ -114,12 +144,31 @@ declare global { makeNotifier: ( delay: RelativeTime, interval: RelativeTime, + cancelToken?: CancelToken, ) => Notifier; /** - * Create and return a promise that will resolve after the relative time has - * passed. + * Cancel a previously-established wakeup or repeater. + */ + cancel: (cancelToken: CancelToken) => void; + /** + * Retrieve the read-only Clock facet. + */ + getClock: () => Clock; + /** + * Retrieve the Brand for this timer service. + */ + getTimerBrand: () => TimerBrand; + }; + + type Clock = { + /** + * Retrieve the latest timestamp + */ + getCurrentTimestamp: () => Timestamp; + /** + * Retrieve the Brand for this timer service. */ - delay: (delay: RelativeTime) => Promise; + getTimerBrand: () => TimerBrand; }; type TimerWaker = { diff --git a/packages/SwingSet/src/vats/timer/vat-timer.js b/packages/SwingSet/src/vats/timer/vat-timer.js index 84a7787136b..5bc6b7e39ba 100644 --- a/packages/SwingSet/src/vats/timer/vat-timer.js +++ b/packages/SwingSet/src/vats/timer/vat-timer.js @@ -1,98 +1,977 @@ +/* eslint-disable no-use-before-define */ // @ts-check -import { Nat } from '@agoric/nat'; -import { assert, details as X } from '@agoric/assert'; +import { E } from '@endo/eventual-send'; import { Far, passStyleOf } from '@endo/marshal'; -import { makeNotifierFromAsyncIterable } from '@agoric/notifier'; import { makePromiseKit } from '@endo/promise-kit'; - -import { makeTimedIterable } from './timed-iteration.js'; +import { Nat } from '@agoric/nat'; +import { assert } from '@agoric/assert'; +import { + provideKindHandle, + provideDurableMapStore, + provideDurableWeakMapStore, + defineDurableKindMulti, + vivifyKind, + vivifySingleton, +} from '@agoric/vat-data'; +import { makeScalarWeakMapStore } from '@agoric/store'; import { TimeMath } from './timeMath.js'; -export function buildRootObject(vatPowers) { +// This consumes O(N) RAM only for outstanding promises, via wakeAt(), +// delay(), and Notifiers/Iterators (for each actively-waiting +// client). Everything else should remain in the DB. + +/** + * @typedef {object} Handler + * Handler is a user-provided Far object with .wake(time) used for callbacks + * @property {(scheduled: Timestamp) => unknown} wake + * + * @typedef {unknown} CancelToken + * CancelToken must be pass-by-reference and durable, either local or remote + * + * @typedef {{ + * scheduleYourself: () => void, + * fired: () => void, + * cancel: () => void, + * }} Event + * + * @typedef {MapStore} Schedule + * + * @typedef {{ cancel: () => void }} Cancellable + * + * @typedef {WeakMapStore} CancelTable + * + * @typedef {unknown} PromiseEvent + * + * @typedef {{ + * resolve: (scheduled: Timestamp) => void + * reject: (err: unknown) => void + * }} WakeupPromiseControls + * + * @typedef {LegacyWeakMap} WakeupPromiseTable + */ + +// These (pure) internal functions are exported for unit tests. + +/** + * Insert an event into the schedule at its given time. + * + * @param {Schedule} schedule + * @param {TimestampValue} when + * @param {Event} event + */ +function addEvent(schedule, when, event) { + assert.typeof(when, 'bigint'); + if (!schedule.has(when)) { + schedule.init(when, harden([event])); + } else { + // events track their .scheduled time, so if addEvent() is called, + // it is safe to assume the event isn't already scheduled + schedule.set(when, harden([...schedule.get(when), event])); + } +} + +/** + * Remove an event from the schedule + * + * @param {Schedule} schedule + * @param {TimestampValue} when + * @param {Event} event + */ +function removeEvent(schedule, when, event) { + if (schedule.has(when)) { + /** @typedef { Event[] } */ + const originalEvents = schedule.get(when); + /** @typedef { Event[] } */ + const remainingEvents = originalEvents.filter(ev => ev !== event); + if (remainingEvents.length === 0) { + schedule.delete(when); + } else if (remainingEvents.length < originalEvents.length) { + schedule.set(when, harden(remainingEvents)); + } + } +} + +/** + * Add a CancelToken->Event registration + * + * @param {CancelTable} cancels + * @param {CancelToken} cancelToken + * @param {Event} event + */ +function addCancel(cancels, cancelToken, event) { + if (!cancels.has(cancelToken)) { + cancels.init(cancelToken, harden([event])); + } else { + // Each cancelToken can cancel multiple events, but we only + // addCancel() for each event once, so it is safe to assume the + // event is not already there. This was useful for debugging. + // const oldEvents = cancels.get(cancelToken); + // assert(oldEvents.indexOf(event) === -1, 'addCancel duplicate event'); + const events = [...cancels.get(cancelToken), event]; + cancels.set(cancelToken, harden(events)); + } +} + +/** + * Remove a CancelToken->Event registration + * + * @param {CancelTable} cancels + * @param {CancelToken} cancelToken + * @param {Event} event + */ +function removeCancel(cancels, cancelToken, event) { + assert(cancelToken !== undefined); // that would be super confusing + // this check is to tolerate a race between cancel and timer, but it + // also means we ignore a bogus cancelToken + if (cancels.has(cancelToken)) { + const oldEvents = cancels.get(cancelToken); + const newEvents = oldEvents.filter(oldEvent => oldEvent !== event); + if (newEvents.length === 0) { + cancels.delete(cancelToken); + } else if (newEvents.length < oldEvents.length) { + cancels.set(cancelToken, harden(newEvents)); + } + } +} + +/** + * @param {Schedule} schedule + * @returns {TimestampValue | undefined} + */ +function firstWakeup(schedule) { + // console.log(`--fW:`); + // for (const [time, events] of schedule.entries()) { + // console.log(` ${time} ${events.map(e => e.toString()).join(',')}`); + // } + const iter = schedule.keys()[Symbol.iterator](); + const first = iter.next(); + if (first.done) { + return undefined; + } + return first.value; +} + +// if you really must replace "time <= upto" below, use this +// function timeLTE(a, b) { +// return TimeMath.compareAbs(a, b) !== 1; +// } +// if (timeLTE(time, upto)) { + +/** + * return list of events for time <= upto + * + * @param {Schedule} schedule + * @param {TimestampValue} upto + * @returns { Event[] } + */ +function removeEventsUpTo(schedule, upto) { + assert.typeof(upto, 'bigint'); + let ready = []; + for (const [time, events] of schedule.entries()) { + if (time <= upto) { + ready = ready.concat(events); + schedule.delete(time); + } else { + break; // don't walk the entire future + } + } + return ready; +} + +/* + * measureInterval: used to schedule repeaters + * + * given (start=10, interval=10), i.e. 10,20,30,.. + * + * | now | latest?.count | latest?.time | next.time | next.count | + * |-----+---------------+--------------+-----------+------------| + * | 0 | undefined | undefined | 10 | 1 | + * | 1 | undefined | undefined | 10 | 1 | + * | 9 | undefined | undefined | 10 | 1 | + * | 10 | 1 | 10 | 20 | 2 | + * | 11 | 1 | 10 | 20 | 2 | + * | 19 | 1 | 10 | 20 | 2 | + * | 20 | 2 | 20 | 30 | 3 | + * | 21 | 2 | 20 | 30 | 3 | + * | 29 | 2 | 20 | 30 | 3 | + * | 30 | 3 | 30 | 40 | 4 | + * + * @param {TimestampValue} start + * @param {RelativeTimeValue} interval + * @param {TimestampValue} now + * @returns { latest: [{ time: TimestampValue, count: bigint }], + * next: { time: TimestampValue, count: bigint } } + */ +function measureInterval(start, interval, now) { + // Used to schedule repeaters. + assert.typeof(start, 'bigint'); + assert.typeof(interval, 'bigint'); + assert.typeof(now, 'bigint'); + let latest; + const next = { time: start, count: 1n }; + if (now >= start) { + // 'latestTime' is the largest non-future value of + // start+k*interval + const latestTime = now - ((now - start) % interval); + // 'latestCount' is the 1-indexed counter of events at or before + // the current time + const age = Number(now) - Number(start); + const latestCount = BigInt(Math.floor(age / Number(interval))) + 1n; + latest = { time: latestTime, count: latestCount }; + + // 'next.time' is the smallest future value of start+k*interval + next.time = latest.time + interval; + next.count = latest.count + 1n; + } + return { latest, next }; +} + +export function buildRootObject(vatPowers, _vatParameters, baggage) { const { D } = vatPowers; - const repeaters = new Map(); - async function createTimerService(timerNode) { - /** @type {TimerService} */ - const timerService = Far('timerService', { - getCurrentTimestamp() { - return Nat(D(timerNode).getLastPolled()); - }, - setWakeup(baseTime, handler) { - baseTime = TimeMath.toAbs(baseTime); - assert(passStyleOf(handler) === 'remotable', 'bad setWakeup() handler'); - return D(timerNode).setWakeup(baseTime, handler); - }, - // can be used after setWakeup(h) or schedule(h) - removeWakeup(handler) { + let timerDevice; + function insistDevice() { + assert(timerDevice, 'TimerService used before createTimerService()'); + } + + if (baggage.has('timerDevice')) { + // we're an upgraded version: use the previously-stored device + timerDevice = baggage.get('timerDevice'); + } + + // These Kinds are the ongoing obligations of the vat: all future + // versions must define behaviors for these. Search for calls to + // 'vivifyKind', 'vivifySingleton', or 'defineDurableKindMulti'. + // * oneShotEvent + // * promiseEvent + // * repeaterEvent + // * timerRepeater + // * timerNotifier + // * timerIterator + // * wakeupHandler + // * timerservice + // * timerClock + // * timerBrand + + const repeaterHandle = provideKindHandle(baggage, 'timerRepeater'); + const notifierHandle = provideKindHandle(baggage, 'timerNotifier'); + + // we have two durable tables: 'schedule' and 'cancels' + + // The 'schedule' maps upcoming timestamps to the Event that should + // be fired. We rely upon the earlier-vs-later sortability of BigInt + // keys, and upon our Stores performing efficient iteration. + + /** @type {Schedule} */ + const schedule = provideDurableMapStore(baggage, 'schedule'); + + // 'cancels' maps cancel handles to Cancellables that will be + // removed. Cancellables are either Events (and each event knows its + // own .scheduled time, so we can find and remove it from + // 'schedule'), or a Notifier's 'canceller' facet (to mark + // unscheduled / idle Notifiers for the next time they're invoked). + + /** @type {CancelTable} */ + const cancels = provideDurableWeakMapStore(baggage, 'cancels'); + + // Then an *ephemeral* WeakMap to hang on to the ephemeral Promise + // resolve/reject functions for delay/wakeAt. We can't hold these + // bare functions in the (durable) PromiseEvent, but we *can* use + // the PromiseEvent as a key to fetch them when the event + // fires. It's ok for these to be ephemeral: all promises get + // rejected (with { name: 'vatUpgraded' }) during an upgrade, so if + // the timer fires *after* an upgrade, we no longer need to reject + // it ourselves. The RAM usage will be O(N) on the number of pending + // Promise-based wakeups currently scheduled. + + /** @type {WakeupPromiseTable} */ + const wakeupPromises = makeScalarWeakMapStore('promises'); + + // -- helper functions + + /** + * convert an internal TimestampValue into a branded Timestamp + * + * @param {TimestampValue} when + * @returns {Timestamp} + */ + function toTimestamp(when) { + return TimeMath.toAbs(when); // TODO (when, brand) + } + + /** + * convert external (branded) Timestamp to internal bigint + * + * @param {Timestamp} when + * @returns {TimestampValue} + */ + function fromTimestamp(when) { + return TimeMath.absValue(when); // TODO: brand + } + + /** + * convert external (branded) RelativeTime to internal bigint + * + * @param {RelativeTime} delta + * @returns {RelativeTimeValue} + */ + function fromRelativeTime(delta) { + return TimeMath.relValue(delta); + } + + function reschedule() { + // the first wakeup should be in the future: the device will not + // immediately fire when given a stale request + const newFirstWakeup = firstWakeup(schedule); + // idempotent and ignored if not currently registered + D(timerDevice).removeWakeup(wakeupHandler); + if (newFirstWakeup) { + D(timerDevice).setWakeup(newFirstWakeup, wakeupHandler); + } + } + + /** + * @returns {TimestampValue} + */ + function getNow() { + insistDevice(); + return Nat(D(timerDevice).getLastPolled()); + } + + // this gets called when the device's wakeup message reaches us + function processAndReschedule() { + // first, service everything that is ready + const now = getNow(); + // console.log(`--pAR ${now}`); + removeEventsUpTo(schedule, now).forEach(event => event.fired()); + // then, reschedule for whatever is up next + reschedule(); + } + + // we have three kinds of events in our 'schedule' table: "one-shot" + // (for setWakeup), "promise" (for wakeAt and delay, also used by + // makeNotifier), and repeaters (for makeRepeater and repeatAfter) + + // -- Event (one-shot) + + /** + * @param {TimestampValue} when + * @param {Handler} handler + * @param {CancelToken} [cancelToken] + */ + function initOneShotEvent(when, handler, cancelToken) { + const scheduled = undefined; // set by scheduleYourself() + const cancelled = false; + return { when, handler, scheduled, cancelled, cancelToken }; + } + + const oneShotEventBehavior = { + scheduleYourself({ self, state }) { + const { when, cancelToken } = state; + state.scheduled = when; // cleared if fired/cancelled + if (when <= getNow()) { + self.fired(); + return; + } + addEvent(schedule, when, self); + if (cancelToken) { + addCancel(cancels, cancelToken, self); + } + reschedule(); + }, + + fired({ self, state }) { + const { cancelled, handler, cancelToken } = state; + state.scheduled = undefined; + if (cancelled) { + return; + } + // we tell the client the most recent time available + const p = E(handler).wake(getCurrentTimestamp()); + // one-shots ignore errors, but note this does make handler + // errors harder to notice. TODO we'd use E.sendOnly() for + // non-repeaters, if it existed + p.catch(_err => undefined); + if (cancelToken) { + self.cancel(); // stop tracking cancelToken + } + }, + + cancel({ self, state }) { + removeCancel(cancels, state.cancelToken, self); + state.cancelled = true; + if (state.scheduled) { + removeEvent(schedule, state.scheduled, self); + state.scheduled = undefined; + reschedule(); + } + }, + }; + + const makeOneShotEvent = vivifyKind( + baggage, + 'oneShotEvent', + initOneShotEvent, + oneShotEventBehavior, + ); + + // -- Event (promise) + + function initPromiseEvent(when, cancelToken) { + const scheduled = undefined; + const cancelled = false; + return { when, scheduled, cancelled, cancelToken }; + } + + const promiseEventBehavior = { + scheduleYourself({ self, state }) { + const { when, cancelToken } = state; + state.scheduled = when; // cleared if fired/cancelled + addEvent(schedule, when, self); + if (cancelToken) { + addCancel(cancels, cancelToken, self); + } + reschedule(); + }, + + fired({ self, state }) { + const { cancelled, cancelToken } = state; + state.scheduled = undefined; + if (cancelled) { + return; + } + if (wakeupPromises.has(self)) { + wakeupPromises.get(self).resolve(getCurrentTimestamp()); + // else we were upgraded and promise was rejected/disconnected + } + if (cancelToken) { + self.cancel(); // stop tracking the cancelToken + } + }, + + cancel({ self, state }) { + const { scheduled, cancelToken } = state; + removeCancel(cancels, cancelToken, self); + state.cancelled = true; + if (scheduled) { + removeEvent(schedule, scheduled, self); + state.scheduled = undefined; + reschedule(); + if (wakeupPromises.has(self)) { + // TODO: don't want our stack trace here, not helpful. Maybe + // create singleton Error at module scope? + wakeupPromises.get(self).reject(Error('TimerCancelled')); + } + } + }, + }; + + /** + * @returns { PromiseEvent } + */ + const makePromiseEvent = vivifyKind( + baggage, + 'promiseEvent', + initPromiseEvent, + promiseEventBehavior, + ); + + // -- Event (repeaters) + + function initRepeaterEvent(start, interval, handler, cancelToken) { + const scheduled = undefined; + const cancelled = false; + return { start, interval, handler, scheduled, cancelled, cancelToken }; + } + + const repeaterEventBehavior = { + scheduleYourself({ self, state }) { + // first time + const { start, interval, cancelToken } = state; + const now = getNow(); + if (start === now) { + state.scheduled = now; + self.fired(); + return; + } + const { next } = measureInterval(start, interval, now); + state.scheduled = next.time; // cleared if fired/cancelled + addEvent(schedule, next.time, self); + if (cancelToken) { + addCancel(cancels, cancelToken, self); + } + reschedule(); + }, + + rescheduleYourself({ self, state }) { + const { cancelled, start, interval } = state; + if (cancelled) { + // cancelled while waiting for handler to finish + return; + } + const now = getNow(); + const { next } = measureInterval(start, interval, now); + state.scheduled = next.time; // cleared if fired/cancelled + addEvent(schedule, next.time, self); + reschedule(); + }, + // TODO: consider waker.onError + + fired({ self, state }) { + const { cancelled, handler } = state; + state.scheduled = undefined; + if (cancelled) { + return; + } + // repeaters stay in "waiting" until their promise resolves, + // at which point we either reschedule or cancel + E(handler) + .wake(getCurrentTimestamp()) + .then( + _res => self.rescheduleYourself(), + _err => self.cancel(), + ) + .catch(err => console.log(`timer repeater error`, err)); + }, + + cancel({ self, state }) { + const { scheduled, cancelToken } = state; + removeCancel(cancels, cancelToken, self); + state.cancelled = true; + if (scheduled) { + removeEvent(schedule, scheduled, self); + state.scheduled = undefined; + reschedule(); + } + }, + }; + + const makeRepeaterEvent = vivifyKind( + baggage, + 'repeaterEvent', + initRepeaterEvent, + repeaterEventBehavior, + ); + + // -- more helper functions + + /** + * @param {TimestampValue} when + * @param {CancelToken} cancelToken + * @returns {Promise} + */ + function wakeAtInternal(when, cancelToken) { + const event = makePromiseEvent(when, cancelToken); + const { resolve, reject, promise } = makePromiseKit(); + // these 'controls' are never shared off-vat, but we wrap them as + // Far to appease WeakMapStore's value requirements + const controls = Far('controls', { resolve, reject }); + wakeupPromises.init(event, controls); + event.scheduleYourself(); + return promise; // disconnects upon upgrade + } + + // -- now we can define the public API functions + + /** + * @returns {Timestamp} + */ + function getCurrentTimestamp() { + return toTimestamp(getNow()); + } + + /** + * @param {Timestamp} when + * @param {Handler} handler + * @param {CancelToken} [cancelToken] + * @returns {Timestamp} + */ + function setWakeup(when, handler, cancelToken = undefined) { + when = fromTimestamp(when); + assert.equal(passStyleOf(handler), 'remotable', 'bad setWakeup() handler'); + if (cancelToken) { + assert.equal(passStyleOf(cancelToken), 'remotable', 'bad cancel token'); + } + + const event = makeOneShotEvent(when, handler, cancelToken); + event.scheduleYourself(); + // TODO this is the documented behavior, but is it useful? + return toTimestamp(when); + } + + /** + * wakeAt(when): return a Promise that fires (with the scheduled + * wakeup time) somewhat after 'when'. If a 'cancelToken' is + * provided, calling ts.cancel(cancelToken) before wakeup will cause + * the Promise to be rejected instead. + * + * @param {Timestamp} when + * @param {CancelToken} [cancelToken] + * @returns { Promise } + */ + function wakeAt(when, cancelToken = undefined) { + when = fromTimestamp(when); + const now = getNow(); + if (when <= now) { + return Promise.resolve(toTimestamp(now)); + } + return wakeAtInternal(when, cancelToken); + } + + /** + * delay(delay): return a Promise that fires (with the scheduled + * wakeup time) at 'delay' time units in the future. + * + * @param {RelativeTime} delay + * @param {CancelToken} [cancelToken] + * @returns { Promise } + */ + function addDelay(delay, cancelToken = undefined) { + delay = fromRelativeTime(delay); + assert(delay >= 0n, 'delay must not be negative'); + const now = getNow(); + if (delay === 0n) { + return Promise.resolve(toTimestamp(now)); + } + const when = now + delay; + return wakeAtInternal(when, cancelToken); + } + + /** + * cancel(token): Cancel an outstanding one-shot, or a Notifier + * (outstanding or idle), or new-style repeater (not `makeRepeater`, + * which has .disable). For things that return Promises, the Promise + * is rejected with Error('TimerCancelled'). + * + * @param {CancelToken} cancelToken + */ + function cancel(cancelToken) { + // silently ignore multiple cancels and bogus token + if (cancels.has(cancelToken)) { + cancels.get(cancelToken).forEach(thing => thing.cancel()); + } + } + + /** + * Internal function to register a handler, which will be invoked as + * handler.wake(scheduledTime) at the earliest non-past instance of + * `start + k*interval`. When the wake() result promise + * fulfills, the repeater will be rescheduled for the next such + * instance (there may be gaps). If that promise rejects, the + * repeater will be cancelled. The repeater can also be cancelled by + * providing `cancelToken` and calling + * `E(timerService).cancel(cancelToken)`. + * + * @param {TimestampValue} start + * @param {RelativeTimeValue} interval + * @param {Handler} handler + * @param {CancelToken} [cancelToken] + */ + function repeat(start, interval, handler, cancelToken) { + assert.typeof(start, 'bigint'); + assert.typeof(interval, 'bigint'); + assert(interval > 0n, 'interval must be positive'); + const event = makeRepeaterEvent(start, interval, handler, cancelToken); + // computes first wakeup, inserts into schedule, updates alarm. If + // start has passed already, fires immediately. + event.scheduleYourself(); + } + + // --- Repeaters: legacy "distinct Repeater object" API --- + + // The durable Repeater object is built from (delay, interval) + // arguments which requests a wakeup at the earliest non-past + // instance of `now + delay + k*interval`. The returned object + // provides {schedule, disable} methods. We build an Event from it. + + function initRepeater(delay, interval) { + // first wakeup at now+delay, then now+delay+k*interval + delay = fromRelativeTime(delay); + assert(delay >= 0n, 'delay must be non-negative'); + interval = fromRelativeTime(interval); + assert(interval > 0n, 'interval must be nonzero'); + const start = getNow() + delay; + const active = false; + return { start, interval, active }; + } + const repeaterFacets = { + cancel: {}, // internal CancelToken + repeater: { + schedule({ state, facets }, handler) { assert( passStyleOf(handler) === 'remotable', - 'bad removeWakeup() handler', + 'bad repeater.schedule() handler', ); - return D(timerNode).removeWakeup(handler); + assert(!state.active, 'repeater already scheduled'); + state.active = true; + repeat(state.start, state.interval, handler, facets.cancel); }, - makeRepeater(delay, interval) { - delay = TimeMath.toRel(delay); - interval = TimeMath.toRel(interval); - assert( - TimeMath.relValue(interval) > 0n, - X`makeRepeater's second parameter must be a positive integer: ${interval}`, - ); + disable({ state, facets }) { + if (state.active) { + cancel(facets.cancel); + state.active = false; + } + }, + }, + }; + const createRepeater = defineDurableKindMulti( + repeaterHandle, + initRepeater, + repeaterFacets, + ); + function makeRepeater(delay, interval) { + return createRepeater(delay, interval).repeater; + } - const index = D(timerNode).makeRepeater(delay, interval); + /** + * @param {RelativeTime} delay + * @param {RelativeTime} interval + * @param {Handler} handler + * @param {CancelToken} [cancelToken] + */ + function repeatAfter(delay, interval, handler, cancelToken) { + // first wakeup at now+delay, then now+delay+k*interval + delay = fromRelativeTime(delay); + interval = fromRelativeTime(interval); + const now = getNow(); + const start = now + delay; + return repeat(start, interval, handler, cancelToken); + } - const vatRepeater = Far('vatRepeater', { - schedule(h) { - return D(timerNode).schedule(index, h); - }, - disable() { - repeaters.delete(index); - return D(timerNode).deleteRepeater(index); - }, + // -- notifiers + + // First we define the Iterator, since Notifiers are Iterable. + + function initIterator(notifier) { + return { notifier, updateCount: undefined, active: false }; + } + const iteratorBehavior = { + next({ state }) { + const { notifier, updateCount, active } = state; + assert(!active, 'timer iterator dislikes overlapping next()'); + state.active = true; + return notifier + .getUpdateSince(updateCount) + .then(({ value, updateCount: newUpdateCount }) => { + state.active = false; + state.updateCount = newUpdateCount; + return harden({ value, done: newUpdateCount === undefined }); }); - repeaters.set(index, vatRepeater); - return vatRepeater; - }, - makeNotifier(delay, interval) { - delay = TimeMath.toRel(delay); - interval = TimeMath.toRel(interval); - assert( - TimeMath.relValue(interval) > 0n, - X`makeNotifier's second parameter must be a positive integer: ${interval}`, - ); + }, + }; + const createIterator = vivifyKind( + baggage, + 'timerIterator', + initIterator, + iteratorBehavior, + ); - // Find when the first notification will fire. - const baseTime = TimeMath.addAbsRel( - TimeMath.addAbsRel(timerService.getCurrentTimestamp(), delay), - interval, - ); + // Our Notifier behaves as if it was fed with an semi-infinite + // series of Timestamps, starting at 'start' (= 'delay' + the moment + // at which the makeNotifier() message was received), and emitting a + // new value every 'interval', until the Notifier is cancelled + // (which might never happen). The 'updateCount' begins at '1' for + // 'start', then '2' for 'start+interval', etc. We start at '1' + // instead of '0' as defense against clients who incorrectly + // interpret any falsy 'updateCount' as meaning "notifier has + // finished" instead of using the correct `=== undefined` test. A + // cancelled Notifier is switched into a state where all + // getUpdateSince() calls return a Promise which immediately fires + // with time of cancellation. - const iterable = makeTimedIterable( - timerService.delay, - timerService.getCurrentTimestamp, - baseTime, - interval, - ); + // Each update reports the time at which the update was scheduled, + // even if vat-timer knows it is delivering the update a little + // late. - const notifier = makeNotifierFromAsyncIterable(iterable); + /** + * @param {RelativeTime} delay + * @param {RelativeTime} interval + * @param {CancelToken} [cancelToken] + */ + function initNotifier(delay, interval, cancelToken = undefined) { + // first wakeup at now+delay, then now+delay+k*interval + delay = fromRelativeTime(delay); + assert(delay >= 0n, 'delay must be non-negative'); + interval = fromRelativeTime(interval); + assert(interval > 0n, 'interval must be nonzero'); + const now = getNow(); + const start = now + delay; + if (cancelToken) { + assert.equal(passStyleOf(cancelToken), 'remotable', 'bad cancel token'); + } + const final = undefined; // set when cancelled + return { start, interval, cancelToken, final }; + } - return notifier; + const notifierFacets = { + notifier: { + [Symbol.asyncIterator]({ facets }) { + return createIterator(facets.notifier); }, - delay(delay) { - delay = TimeMath.toRel(delay); - const now = timerService.getCurrentTimestamp(); - const baseTime = TimeMath.addAbsRel(now, delay); - const promiseKit = makePromiseKit(); - const delayHandler = Far('delayHandler', { - wake: promiseKit.resolve, - }); - timerService.setWakeup(baseTime, delayHandler); - return promiseKit.promise; + async getUpdateSince({ facets, state }, updateCount = -1n) { + // if the Notifier has never fired, they have no business + // giving us a non-undefined updateCount, but we don't hold + // that against them: we treat it as stale, not an error + const { start, interval, cancelToken, final } = state; + if (final) { + return final; + } + const now = getNow(); + const mi = measureInterval(start, interval, now); + const unstarted = mi.latest === undefined; + const wantNext = + unstarted || (mi.latest && mi.latest.count === updateCount); + + // notifier || client-submitted updateCount | + // state || undefined | stale | fresh | + // |------------||--------------+------------+------------| + // | started || latest | latest | next | + // | unstarted || next (first) | next (err) | next (err) | + // | cancelled || final | final | final | + + if (wantNext) { + // wakeAtInternal() will fire with a 'thenTS' Timestamp of + // when vat-timer receives the device wakeup, which could be + // somewhat later than the scheduled time (even if the + // device is triggered exactly on time). + return wakeAtInternal(mi.next.time, cancelToken).then( + thenTS => { + // We recompute updateCount at firing time, as if their + // getUpdateSince() arrived late, to maintain the 1:1 + // pairing of 'value' and 'updateCount'. + const then = fromTimestamp(thenTS); + const { latest } = measureInterval(start, interval, then); + assert(latest); + const value = toTimestamp(latest.time); + return harden({ value, updateCount: latest.count }); + }, + // Else, our (active) promiseEvent was cancelled, so this + // rejection will race with canceller.cancel() below (and + // any other getUpdateSince() Promises that are still + // waiting). Exactly one will create the "final value" for + // all current and future getUpdateSince() clients. + _cancelErr => facets.canceller.cancel({ state }), + ); + } else { + // fire with the latest (previous) event time + assert(mi.latest); + const value = toTimestamp(mi.latest.time); + return harden({ value, updateCount: mi.latest.count }); + } }, - }); + }, + + canceller: { + cancel({ state }) { + if (!state.final) { + // We report the cancellation time, and an updateCount of + // 'undefined', which indicates the Notifier is exhausted. + const value = toTimestamp(getNow()); + state.final = harden({ value, updateCount: undefined }); + } + return state.final; // for convenience of waitForNext() + }, + }, + }; + + function finishNotifier({ state, facets }) { + const { cancelToken } = state; + if (cancelToken) { + addCancel(cancels, cancelToken, facets.canceller); + } + } + const createNotifier = defineDurableKindMulti( + notifierHandle, + initNotifier, + notifierFacets, + { finish: finishNotifier }, + ); + + /** + * makeNotifier(delay, interval): return a Notifier that fires on + * the same schedule as makeRepeater() + * + * @param {RelativeTime} delay + * @param {RelativeTime} interval + * @param {CancelToken} cancelToken + * @returns { BaseNotifier } + */ + function makeNotifier(delay, interval, cancelToken) { + return createNotifier(delay, interval, cancelToken).notifier; + } + + // -- now we finally build the TimerService + + const wakeupHandler = vivifySingleton(baggage, 'wakeupHandler', { + wake: processAndReschedule, + }); + + const timerService = vivifySingleton(baggage, 'timerService', { + getCurrentTimestamp, + setWakeup, // one-shot with handler (absolute) + wakeAt, // one-shot with Promise (absolute) + delay: addDelay, // one-shot with Promise (relative) + makeRepeater, // repeater with Repeater control object (old) + repeatAfter, // repeater without control object + makeNotifier, // Notifier + cancel, // cancel setWakeup/wakeAt/delay/repeat + getClock: () => timerClock, + getTimerBrand: () => timerBrand, + }); + + // attenuated read-only facet + const timerClock = vivifySingleton(baggage, 'timerClock', { + getCurrentTimestamp, + getTimerBrand: () => timerBrand, + }); + + // powerless identifier + const timerBrand = vivifySingleton(baggage, 'timerBrand', { + isMyTimerService: alleged => alleged === timerService, + isMyClock: alleged => alleged === timerClock, + }); + + // If we needed to do anything during upgrade, now is the time, + // since all our Kind obligations are met. + + // if (baggage.has('timerDevice')) { + // console.log(`--post-upgrade wakeup`); + // for (const [time, events] of schedule.entries()) { + // console.log(` -- ${time}`, events); + // } + // } + + /** + * createTimerService() registers devices.timer and returns the + * timer service. This must called at least once, to connect the + * device, but we don't prohibit it from being called again (to + * replace the device), just in case that's useful someday + * + * @returns {Promise} + */ + + // TODO: maybe change the name though + function createTimerService(timerNode) { + timerDevice = timerNode; + if (baggage.has('timerDevice')) { + baggage.set('timerDevice', timerDevice); + } else { + baggage.init('timerDevice', timerDevice); + } + // @ts-expect-error Type mismatch hard to diagnose return timerService; } return Far('root', { createTimerService }); } + +export const debugTools = harden({ + addEvent, + removeEvent, + addCancel, + removeCancel, + removeEventsUpTo, + firstWakeup, + measureInterval, +}); diff --git a/packages/SwingSet/test/test-manual-timer.js b/packages/SwingSet/test/test-manual-timer.js new file mode 100644 index 00000000000..179dc79b15e --- /dev/null +++ b/packages/SwingSet/test/test-manual-timer.js @@ -0,0 +1,12 @@ +// eslint-disable-next-line import/order +import { test } from '../tools/prepare-test-env-ava.js'; + +import { buildManualTimer } from '../tools/manual-timer.js'; + +test('buildManualTimer', async t => { + const mt = buildManualTimer(); + const p = mt.wakeAt(10n); + mt.advanceTo(15n); + const result = await p; + t.is(result, 15n); +}); diff --git a/packages/SwingSet/test/test-vat-timer.js b/packages/SwingSet/test/test-vat-timer.js new file mode 100644 index 00000000000..446359e646e --- /dev/null +++ b/packages/SwingSet/test/test-vat-timer.js @@ -0,0 +1,1166 @@ +// eslint-disable-next-line import/order +import { test } from '../tools/prepare-test-env-ava.js'; + +import { E } from '@endo/eventual-send'; +import { Far } from '@endo/marshal'; +import { makePromiseKit } from '@endo/promise-kit'; +import { makeScalarMapStore } from '@agoric/store'; +import { buildRootObject, debugTools } from '../src/vats/timer/vat-timer.js'; +import { TimeMath } from '../src/vats/timer/timeMath.js'; +import { waitUntilQuiescent } from '../src/lib-nodejs/waitUntilQuiescent.js'; + +test('schedule', t => { + const schedule = makeScalarMapStore(); + + function addEvent(when, event) { + return debugTools.addEvent(schedule, when, event); + } + function removeEvent(when, event) { + return debugTools.removeEvent(schedule, when, event); + } + function firstWakeup() { + return debugTools.firstWakeup(schedule); + } + function removeEventsUpTo(upto) { + return debugTools.removeEventsUpTo(schedule, upto); + } + + // exercise the ordered list, without concern about the durability + // the handlers + addEvent(10n, 'e10'); + addEvent(30n, 'e30'); + addEvent(20n, 'e20'); + addEvent(30n, 'e30x'); + t.deepEqual(schedule.get(10n), ['e10']); + t.deepEqual(schedule.get(20n), ['e20']); + t.deepEqual(schedule.get(30n), ['e30', 'e30x']); + t.is(firstWakeup(), 10n); + + let done = removeEventsUpTo(5n); + t.deepEqual(done, []); + done = removeEventsUpTo(10n); + t.deepEqual(done, ['e10']); + t.is(firstWakeup(), 20n); + done = removeEventsUpTo(10n); + t.deepEqual(done, []); + done = removeEventsUpTo(35n); + t.deepEqual(done, ['e20', 'e30', 'e30x']); + t.is(firstWakeup(), undefined); + done = removeEventsUpTo(40n); + t.deepEqual(done, []); + + addEvent(50n, 'e50'); + addEvent(50n, 'e50x'); + addEvent(60n, 'e60'); + addEvent(70n, 'e70'); + addEvent(70n, 'e70x'); + t.deepEqual(schedule.get(50n), ['e50', 'e50x']); + t.is(firstWakeup(), 50n); + removeEvent(50n, 'e50'); + t.deepEqual(schedule.get(50n), ['e50x']); + // removing a bogus event is ignored + removeEvent('50n', 'bogus'); + t.deepEqual(schedule.get(50n), ['e50x']); + removeEvent(50n, 'e50x'); + t.not(schedule.has(50n)); + t.is(firstWakeup(), 60n); +}); + +test('cancels', t => { + const cancels = makeScalarMapStore(); + function addCancel(cancelToken, event) { + return debugTools.addCancel(cancels, cancelToken, event); + } + function removeCancel(cancelToken, event) { + return debugTools.removeCancel(cancels, cancelToken, event); + } + + const cancel1 = Far('cancel token', {}); + const cancel2 = Far('cancel token', {}); + const cancel3 = Far('cancel token', {}); + addCancel(cancel1, 'e10'); + addCancel(cancel2, 'e20'); + addCancel(cancel3, 'e30'); + addCancel(cancel3, 'e30x'); // cancels can be shared among events + + t.deepEqual(cancels.get(cancel1), ['e10']); + t.deepEqual(cancels.get(cancel2), ['e20']); + t.deepEqual(cancels.get(cancel3), ['e30', 'e30x']); + + removeCancel(cancel1, 'e10'); + t.not(cancels.has(cancel1)); + + // bogus cancels are ignored + removeCancel('bogus', 'e20'); + t.deepEqual(cancels.get(cancel2), ['e20']); + // unrecognized events are ignored + removeCancel(cancel2, 'bogus'); + t.deepEqual(cancels.get(cancel2), ['e20']); + + removeCancel(cancel3, 'e30x'); + t.deepEqual(cancels.get(cancel3), ['e30']); + removeCancel(cancel3, 'e30'); + t.not(cancels.has(cancel3)); + + t.throws(() => removeCancel(undefined)); // that would be confusing +}); + +test('measureInterval', t => { + const { measureInterval } = debugTools; // mi(start, interval, now) + const interval = 10n; + let start; + function mi(now) { + const { latest, next } = measureInterval(start, interval, now); + return [latest?.time, latest?.count, next.time, next.count]; + } + + start = 0n; // 0,10,20,30 + t.deepEqual(mi(0n), [0n, 1n, 10n, 2n]); + t.deepEqual(mi(1n), [0n, 1n, 10n, 2n]); + t.deepEqual(mi(9n), [0n, 1n, 10n, 2n]); + t.deepEqual(mi(10n), [10n, 2n, 20n, 3n]); + t.deepEqual(mi(11n), [10n, 2n, 20n, 3n]); + t.deepEqual(mi(20n), [20n, 3n, 30n, 4n]); + + start = 5n; // 5,15,25,35 + t.deepEqual(mi(0n), [undefined, undefined, 5n, 1n]); + t.deepEqual(mi(4n), [undefined, undefined, 5n, 1n]); + t.deepEqual(mi(5n), [5n, 1n, 15n, 2n]); + t.deepEqual(mi(14n), [5n, 1n, 15n, 2n]); + t.deepEqual(mi(15n), [15n, 2n, 25n, 3n]); + t.deepEqual(mi(25n), [25n, 3n, 35n, 4n]); + + start = 63n; // 63,73,83,93 + t.deepEqual(mi(0n), [undefined, undefined, 63n, 1n]); + t.deepEqual(mi(9n), [undefined, undefined, 63n, 1n]); + t.deepEqual(mi(62n), [undefined, undefined, 63n, 1n]); + t.deepEqual(mi(63n), [63n, 1n, 73n, 2n]); + t.deepEqual(mi(72n), [63n, 1n, 73n, 2n]); + t.deepEqual(mi(73n), [73n, 2n, 83n, 3n]); + t.deepEqual(mi(83n), [83n, 3n, 93n, 4n]); +}); + +async function setup() { + const state = { + now: 0n, // current time, updated during test + currentWakeup: undefined, + currentHandler: undefined, + }; + const deviceMarker = harden({}); + const timerDeviceFuncs = harden({ + getLastPolled: () => state.now, + setWakeup: (when, handler) => { + assert.equal(state.currentWakeup, undefined, 'one at a time'); + assert.equal(state.currentHandler, undefined, 'one at a time'); + if (state.currentWakeup !== undefined) { + assert( + state.currentWakeup > state.now, + `too late: ${state.currentWakeup} <= ${state.now}`, + ); + } + state.currentWakeup = when; + state.currentHandler = handler; + return when; + }, + removeWakeup: _handler => { + state.currentWakeup = undefined; + state.currentHandler = undefined; + }, + }); + function D(node) { + assert.equal(node, deviceMarker, 'fake D only supports devices.timer'); + return timerDeviceFuncs; + } + const vatPowers = { D }; + + const vatParameters = {}; + // const baggage = makeScalarBigMapStore(); + const baggage = makeScalarMapStore(); + + const root = buildRootObject(vatPowers, vatParameters, baggage); + const ts = await E(root).createTimerService(deviceMarker); + + const fired = {}; + function makeHandler(which) { + return Far('handler', { + wake(time) { + // handlers/promises get the most recent timestamp + fired[which] = time; + }, + }); + } + + function thenFire(p, which) { + p.then( + value => (fired[which] = ['fulfill', value]), + err => (fired[which] = ['reject', err]), + ); + } + + function toTS(value) { + return TimeMath.toAbs(value); // TODO (when, brand) + } + function fromTS(when) { + return TimeMath.absValue(when); // TODO: brand + } + + return { ts, state, fired, makeHandler, thenFire, toTS, fromTS }; +} + +test('getCurrentTimestamp', async t => { + // now = ts.getCurrentTimestamp() + const { ts, state } = await setup(); + t.not(ts, undefined); + state.now = 10n; + t.is(ts.getCurrentTimestamp(), 10n); + t.is(ts.getCurrentTimestamp(), 10n); + state.now = 20n; + t.is(ts.getCurrentTimestamp(), 20n); +}); + +test('brand', async t => { + // ts.getTimerBrand(), brand.isMyTimerService() + const { ts } = await setup(); + const brand = ts.getTimerBrand(); + const clock = ts.getClock(); + + t.is(ts.getTimerBrand(), brand); + t.true(brand.isMyTimerService(ts)); + t.false(brand.isMyTimerService({})); + t.false(brand.isMyTimerService(brand)); + t.false(brand.isMyTimerService(clock)); + + t.true(brand.isMyClock(clock)); + t.false(brand.isMyClock({})); + t.false(brand.isMyClock(brand)); + t.false(brand.isMyClock(ts)); +}); + +test('clock', async t => { + // clock.getCurrentTimestamp() (and nothing else) + const { ts, state } = await setup(); + const clock = ts.getClock(); + + state.now = 10n; + t.is(clock.getCurrentTimestamp(), 10n); + t.is(clock.getCurrentTimestamp(), 10n); + state.now = 20n; + t.is(clock.getCurrentTimestamp(), 20n); + + t.is(clock.setWakeup, undefined); + t.is(clock.wakeAt, undefined); + t.is(clock.makeRepeater, undefined); + + const brand = ts.getTimerBrand(); + t.is(clock.getTimerBrand(), brand); + t.true(brand.isMyClock(clock)); + t.false(brand.isMyClock({})); +}); + +test('setWakeup', async t => { + // ts.setWakeup(when, handler, cancelToken) -> when + const { ts, state, fired, makeHandler } = await setup(); + + t.not(ts, undefined); + t.is(state.currentWakeup, undefined); + + t.is(ts.getCurrentTimestamp(), state.now); + + // the first setWakeup sets the alarm + const t30 = ts.setWakeup(30n, makeHandler(30)); + t.is(t30, 30n); + t.is(state.currentWakeup, 30n); + t.not(state.currentHandler, undefined); + + // an earlier setWakeup brings the alarm forward + const cancel20 = Far('cancel token', {}); + ts.setWakeup(20n, makeHandler(20), cancel20); + t.is(state.currentWakeup, 20n); + + // deleting the earlier pushes the alarm back + ts.cancel(cancel20); + t.is(state.currentWakeup, 30n); + + // later setWakeups do not change the alarm + ts.setWakeup(40n, makeHandler(40)); + ts.setWakeup(50n, makeHandler(50)); + ts.setWakeup(50n, makeHandler('50x')); + // cancel tokens can be shared + const cancel6x = Far('cancel token', {}); + ts.setWakeup(60n, makeHandler(60n), cancel6x); + ts.setWakeup(60n, makeHandler('60x')); + ts.setWakeup(61n, makeHandler(61n), cancel6x); + t.is(state.currentWakeup, 30n); + + // wake up exactly on time (30n) + state.now = 30n; + state.currentHandler.wake(30n); + await waitUntilQuiescent(); + t.is(fired[20], undefined); // was removed + t.is(fired[30], 30n); // fired + t.is(fired[40], undefined); // not yet fired + // resets wakeup to next alarm + t.is(state.currentWakeup, 40n); + t.not(state.currentHandler, undefined); + + // wake up a little late (41n), then message takes a while to arrive + // (51n), all wakeups before/upto the arrival time are fired, and + // they all get the most recent timestamp + state.now = 51n; + state.currentHandler.wake(41n); + await waitUntilQuiescent(); + t.is(fired[40], 51n); + t.is(fired[50], 51n); + t.is(fired['50x'], 51n); + t.is(fired[60], undefined); + t.is(state.currentWakeup, 60n); + t.not(state.currentHandler, undefined); + + // a setWakeup in the past will be fired immediately + ts.setWakeup(21n, makeHandler(21)); + await waitUntilQuiescent(); + t.is(fired[21], 51n); + + // as will a setWakeup for the exact present + ts.setWakeup(51n, makeHandler(51)); + await waitUntilQuiescent(); + t.is(fired[51], 51n); + + // the remaining time-entry handler should still be there + state.now = 65n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(fired['60x'], 65n); +}); + +test('wakeAt', async t => { + // p = ts.wakeAt(absolute, cancelToken=undefined) + const { ts, state, fired, thenFire } = await setup(); + + const cancel10 = Far('cancel token', {}); + const cancel20 = Far('cancel token', {}); + thenFire(ts.wakeAt(10n, cancel10), '10'); + thenFire(ts.wakeAt(10n), '10x'); + thenFire(ts.wakeAt(20n, cancel20), '20'); + + t.is(state.currentWakeup, 10n); + + state.now = 10n; + state.currentHandler.wake(10n); + await waitUntilQuiescent(); + t.deepEqual(fired['10'], ['fulfill', 10n]); + t.deepEqual(fired['10x'], ['fulfill', 10n]); + t.deepEqual(fired['20'], undefined); + + // late cancel is ignored + ts.cancel(cancel10); + + // adding a wakeAt in the past will fire immediately + thenFire(ts.wakeAt(5n), '5'); + await waitUntilQuiescent(); + t.deepEqual(fired['5'], ['fulfill', 10n]); + + // as will a wakeAt for exactly now + thenFire(ts.wakeAt(10n), '10y'); + await waitUntilQuiescent(); + t.deepEqual(fired['10y'], ['fulfill', 10n]); + + // cancelling a wakeAt causes the promise to reject + ts.cancel(cancel20); + await waitUntilQuiescent(); + t.deepEqual(fired['20'], ['reject', Error('TimerCancelled')]); + + // duplicate cancel is ignored + ts.cancel(cancel20); +}); + +test('delay', async t => { + // p = ts.delay(relative, cancelToken=undefined) + const { ts, state, fired, thenFire } = await setup(); + + state.now = 100n; + + const cancel10 = Far('cancel token', {}); + const cancel20 = Far('cancel token', {}); + thenFire(ts.delay(10n, cancel10), '10'); // =110 + thenFire(ts.delay(10n), '10x'); // =110 + thenFire(ts.delay(20n, cancel20), '20'); // =120 + + t.is(state.currentWakeup, 110n); + + state.now = 110n; + state.currentHandler.wake(110n); + await waitUntilQuiescent(); + t.deepEqual(fired['10'], ['fulfill', 110n]); + t.deepEqual(fired['10x'], ['fulfill', 110n]); + t.deepEqual(fired['20'], undefined); + + // late cancel is ignored + ts.cancel(cancel10); + + // delay=0 fires immediately + thenFire(ts.delay(0n), '0'); + await waitUntilQuiescent(); + t.deepEqual(fired['0'], ['fulfill', 110n]); + + // delay must be non-negative + t.throws(() => ts.delay(-1n), { message: '-1 is negative' }); + + // cancelling a delay causes the promise to reject + ts.cancel(cancel20); + await waitUntilQuiescent(); + t.deepEqual(fired['20'], ['reject', Error('TimerCancelled')]); + + // duplicate cancel is ignored + ts.cancel(cancel20); +}); + +test('makeRepeater', async t => { + // r=ts.makeRepeater(delay, interval); r.schedule(handler); r.disable(); + const { ts, state, fired, makeHandler } = await setup(); + + state.now = 3n; + + // fire at T=25,35,45,.. + const r1 = ts.makeRepeater(22n, 10n); + t.is(state.currentWakeup, undefined); // not scheduled yet + // interval starts at now+delay as computed during ts.makeRepeater, + // not recomputed during r1.schedule() + state.now = 4n; + r1.schedule(makeHandler(1)); + t.is(state.currentWakeup, 25n); + + // duplicate .schedule throws + const h2 = makeHandler(2); + t.throws(() => r1.schedule(h2), { message: 'repeater already scheduled' }); + + state.now = 5n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(fired[1], undefined); // not yet + + state.now = 24n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(fired[1], undefined); // wait for it + + state.now = 25n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(fired[1], 25n); // fired + t.is(state.currentWakeup, 35n); // primed for next time + + // if we miss a couple, next wakeup is in the future + state.now = 50n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(fired[1], 50n); + t.is(state.currentWakeup, 55n); + + // likewise if device-timer message takes a while to reach vat-timer + state.now = 60n; + // sent at T=50, received by vat-timer at T=60 + state.currentHandler.wake(50n); + await waitUntilQuiescent(); + t.is(fired[1], 60n); + t.is(state.currentWakeup, 65n); + + r1.disable(); + t.is(state.currentWakeup, undefined); + + // duplicate .disable is ignored + r1.disable(); + + ts.setWakeup(70n, makeHandler(70)); + t.is(state.currentWakeup, 70n); + state.now = 70n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(fired[70], 70n); + t.is(fired[1], 60n); // not re-fired + t.is(state.currentWakeup, undefined); + + let pk = makePromiseKit(); + let slowState = 'uncalled'; + const slowHandler = Far('slow', { + wake(time) { + slowState = time; + return pk.promise; + }, + }); + // we can .schedule a new handler if the repeater is not active + r1.schedule(slowHandler); + await waitUntilQuiescent(); + t.is(state.currentWakeup, 75n); + + state.now = 80n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + + // while the handler is running, the repeater is not scheduled + t.is(slowState, 80n); + t.is(state.currentWakeup, undefined); + + // if time passes while the handler is running.. + state.now = 100n; + // .. then the repeater will skip some intervals + pk.resolve('ignored'); + await waitUntilQuiescent(); + t.is(state.currentWakeup, 105n); // not 85n + + r1.disable(); + + // if the handler rejects, the repeater is cancelled + const brokenHandler = Far('broken', { + wake(_time) { + throw Error('expected error'); + }, + }); + r1.schedule(brokenHandler); + await waitUntilQuiescent(); + t.is(state.currentWakeup, 105n); + + state.now = 110n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(state.currentWakeup, undefined); // no longer scheduled + + const h115 = makeHandler(115); + + // TODO: unfortunately, a handler rejection puts the repeater in a + // funny state where we can't directly restart + // it. `repeaterFacets.repeater` tracks its own state.active, which + // is not cleared when a handler rejection does cancel() . I'd like + // to see this fixed some day, but I don't think it's too important + // right now, especially because unless the client is catching their + // own exceptions, they have no way to discover the cancellation. + + t.throws(() => r1.schedule(h115), { message: 'repeater already scheduled' }); + + // however, we *can* .disable() and then re-.schedule() + r1.disable(); + r1.schedule(makeHandler(115)); + await waitUntilQuiescent(); + t.is(state.currentWakeup, 115n); + state.now = 115n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(fired[115], 115n); + + r1.disable(); + r1.schedule(brokenHandler); + await waitUntilQuiescent(); + t.is(state.currentWakeup, 125n); + state.now = 130n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(state.currentWakeup, undefined); + r1.disable(); + + // we can .disable() while the handler is running + pk = makePromiseKit(); + slowState = 'uncalled'; + r1.schedule(slowHandler); + await waitUntilQuiescent(); + t.is(state.currentWakeup, 135n); + state.now = 140n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(slowState, 140n); + r1.disable(); + pk.resolve('ignored'); + await waitUntilQuiescent(); + t.is(state.currentWakeup, undefined); +}); + +test('makeRepeater from now', async t => { + // r=ts.makeRepeater(delay, interval); r.schedule(handler); r.disable(); + const { ts, state, fired, makeHandler } = await setup(); + + state.now = 0n; + // creating a repeater with delay=0, and doing schedule() right now, + // will fire immediately + const r = ts.makeRepeater(0n, 10n); + r.schedule(makeHandler(0)); + t.is(state.currentWakeup, undefined); + await waitUntilQuiescent(); + t.is(fired[0], 0n); +}); + +test('repeatAfter', async t => { + // ts.repeatAfter(delay, interval, handler, cancelToken); + const { ts, state, fired, makeHandler } = await setup(); + + state.now = 3n; + + // fire at T=25,35,45,.. + const cancel1 = Far('cancel', {}); + ts.repeatAfter(22n, 10n, makeHandler(1), cancel1); + t.is(state.currentWakeup, 25n); + + state.now = 4n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(fired[1], undefined); // not yet + + state.now = 24n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(fired[1], undefined); // wait for it + + state.now = 25n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(fired[1], 25n); // fired + t.is(state.currentWakeup, 35n); // primed for next time + + // if we miss a couple, next wakeup is in the future + state.now = 50n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(fired[1], 50n); + t.is(state.currentWakeup, 55n); + + // likewise if device-timer message takes a while to reach vat-timer + state.now = 60n; + // sent at T=50, received by vat-timer at T=60 + state.currentHandler.wake(50n); + await waitUntilQuiescent(); + t.is(fired[1], 60n); + t.is(state.currentWakeup, 65n); + + // we can cancel the repeater while it is scheduled + ts.cancel(cancel1); + await waitUntilQuiescent(); + t.is(state.currentWakeup, undefined); + + // duplicate cancel is ignored + ts.cancel(cancel1); + + ts.setWakeup(70n, makeHandler(70)); + t.is(state.currentWakeup, 70n); + state.now = 70n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(fired[70], 70n); + t.is(fired[1], 60n); // not re-fired + t.is(state.currentWakeup, undefined); + + let pk = makePromiseKit(); + let slowState = 'uncalled'; + const slowHandler = Far('slow', { + wake(time) { + slowState = time; + return pk.promise; + }, + }); + + const cancel2 = Far('cancel', {}); + ts.repeatAfter(5n, 10n, slowHandler, cancel2); + await waitUntilQuiescent(); + t.is(state.currentWakeup, 75n); + + state.now = 80n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + + // while the handler is running, the repeater is not scheduled + t.is(slowState, 80n); + t.is(state.currentWakeup, undefined); + + // if time passes while the handler is running.. + state.now = 100n; + // .. then the repeater will skip some intervals + pk.resolve('ignored'); + await waitUntilQuiescent(); + t.is(state.currentWakeup, 105n); // not 85n + + ts.cancel(cancel2); + await waitUntilQuiescent(); + + // if the handler rejects, the repeater is cancelled + const brokenHandler = Far('broken', { + wake(_time) { + throw Error('expected error'); + }, + }); + // we can re-use cancel tokens too + ts.repeatAfter(5n, 10n, brokenHandler, cancel1); + await waitUntilQuiescent(); + t.is(state.currentWakeup, 105n); + + state.now = 110n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(state.currentWakeup, undefined); // no longer scheduled + + // cancel is ignored, already cancelled + ts.cancel(cancel1); + await waitUntilQuiescent(); + + // we can cancel while the handler is running + pk = makePromiseKit(); + slowState = 'uncalled'; + const cancel3 = Far('cancel', {}); + ts.repeatAfter(5n, 10n, slowHandler, cancel3); + await waitUntilQuiescent(); + t.is(state.currentWakeup, 115n); + state.now = 120n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(slowState, 120n); + ts.cancel(cancel3); // while handler is running + await waitUntilQuiescent(); + pk.resolve('ignored'); + await waitUntilQuiescent(); + t.is(state.currentWakeup, undefined); + // still ignores duplicate cancels + ts.cancel(cancel3); +}); + +test('repeatAfter from now', async t => { + // ts.repeatAfter(delay, interval, handler, cancelToken); + const { ts, state, fired, makeHandler } = await setup(); + + state.now = 3n; + + // delay=0 fires right away + ts.repeatAfter(0n, 10n, makeHandler(3)); + t.is(state.currentWakeup, undefined); + await waitUntilQuiescent(); + t.is(fired[3], 3n); +}); + +test('repeatAfter shared cancel token', async t => { + // ts.repeatAfter(delay, interval, handler, cancelToken); + const { ts, state, fired, makeHandler } = await setup(); + + state.now = 0n; + + const throwingHandler = Far('handler', { + wake(time) { + fired.thrower = time; + throw Error('boom'); + }, + }); + + const cancel1 = Far('cancel', {}); + // first repeater fires at T=5,15,25,35 + ts.repeatAfter(5n, 10n, makeHandler(1), cancel1); + // second repeater fires at T=10,20,30,40 + ts.repeatAfter(10n, 10n, throwingHandler, cancel1); + t.is(state.currentWakeup, 5n); + + // let both fire + state.now = 12n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(fired[1], 12n); + t.is(fired.thrower, 12n); + + // second should be cancelled because the handler threw + t.is(state.currentWakeup, 15n); + + state.now = 22n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(fired[1], 22n); + t.is(fired.thrower, 12n); // not re-fired + t.is(state.currentWakeup, 25n); + + // second should still be cancellable + ts.cancel(cancel1); + t.is(state.currentWakeup, undefined); +}); + +// the timer's Notifiers pretend to track an infinite series of events +// at start+k*interval , where start=now+delay + +test('notifier in future', async t => { + // n = ts.makeNotifier(delay, interval, cancelToken); + const { ts, state } = await setup(); + + state.now = 100n; + + // fire at T=125,135,145,.. + const cancel1 = Far('cancel', {}); + const n = ts.makeNotifier(25n, 10n, cancel1); + t.is(state.currentWakeup, undefined); // not active yet + + // getUpdateSince(undefined) before 'start' waits until start + const p1 = n.getUpdateSince(undefined); + let done1; + p1.then(res => (done1 = res)); + await waitUntilQuiescent(); + t.is(state.currentWakeup, 125n); + + state.now = 130n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.deepEqual(done1, { value: 125n, updateCount: 1n }); + // inactive until polled again + t.is(state.currentWakeup, undefined); + + // fast handler turnaround waits for the next event + const p2 = n.getUpdateSince(done1.updateCount); + let done2; + p2.then(res => (done2 = res)); + await waitUntilQuiescent(); + // notifier waits when updateCount matches + t.is(done2, undefined); + t.is(state.currentWakeup, 135n); + + state.now = 140n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.deepEqual(done2, { value: 135n, updateCount: 2n }); + t.is(state.currentWakeup, undefined); + + // slow turnaround gets the most recent missed event right away + state.now = 150n; + const p3 = n.getUpdateSince(done2.updateCount); + let done3; + p3.then(res => (done3 = res)); + await waitUntilQuiescent(); + // fires immediately + t.deepEqual(done3, { value: 145n, updateCount: 3n }); + t.is(state.currentWakeup, undefined); + + // a really slow handler will miss multiple events + state.now = 180n; // missed 155 and 165 + const p4 = n.getUpdateSince(done3.updateCount); + let done4; + p4.then(res => (done4 = res)); + await waitUntilQuiescent(); + t.deepEqual(done4, { value: 175n, updateCount: 6n }); + t.is(state.currentWakeup, undefined); +}); + +test('notifier from now', async t => { + // n = ts.makeNotifier(delay, interval, cancelToken); + const { ts, state } = await setup(); + + state.now = 100n; + + // delay=0 fires right away: T=100,110,120,.. + let done1; + const n = ts.makeNotifier(0n, 10n); + const p1 = n.getUpdateSince(undefined); + p1.then(res => (done1 = res)); + await waitUntilQuiescent(); + t.deepEqual(done1, { value: 100n, updateCount: 1n }); + + // but doesn't fire forever + const p2 = n.getUpdateSince(done1.updateCount); + let done2; + p2.then(res => (done2 = res)); + await waitUntilQuiescent(); + t.is(done2, undefined); + t.is(state.currentWakeup, 110n); + + // move forward a little bit, not enough to fire + state.now = 101n; + // premature wakeup, off-spec but nice to tolerate + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.is(state.currentWakeup, 110n); + t.is(done2, undefined); + + // a second subscriber who queries elsewhen in the window should get + // the same update values + + const p3 = n.getUpdateSince(done1.updateCount); + let done3; + p3.then(res => (done3 = res)); + await waitUntilQuiescent(); + t.is(done3, undefined); + // still waiting + t.is(state.currentWakeup, 110n); + + state.now = 116n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.deepEqual(done2, { value: 110n, updateCount: 2n }); + t.deepEqual(done3, { value: 110n, updateCount: 2n }); +}); + +test('cancel notifier', async t => { + // n = ts.makeNotifier(delay, interval, cancelToken); + const { ts, state } = await setup(); + state.now = 0n; + + // cancel n1 while inactive, before it ever fires + const cancel1 = Far('cancel', {}); + const n1 = ts.makeNotifier(5n - state.now, 10n, cancel1); // T=5,15, + t.is(state.currentWakeup, undefined); // not active yet + const p1a = n1.getUpdateSince(undefined); + state.now = 1n; + ts.cancel(cancel1); // time of cancellation = 1n + const p1b = n1.getUpdateSince(undefined); + state.now = 2n; + const p1c = n1.getUpdateSince(undefined); + t.deepEqual(await p1a, { value: 1n, updateCount: undefined }); + t.deepEqual(await p1b, { value: 1n, updateCount: undefined }); + t.deepEqual(await p1c, { value: 1n, updateCount: undefined }); + + // cancel n2 while active, but before it ever fires + const cancel2 = Far('cancel', {}); + const n2 = ts.makeNotifier(5n - state.now, 10n, cancel2); // T=5,15, + t.is(state.currentWakeup, undefined); // not active yet + const p2a = n2.getUpdateSince(undefined); + t.is(state.currentWakeup, 5n); // primed + state.now = 3n; + const p2b = n2.getUpdateSince(undefined); + ts.cancel(cancel2); // time of cancellation = 3n + t.is(state.currentWakeup, undefined); // no longer active + const p2c = n2.getUpdateSince(undefined); + state.now = 4n; + const p2d = n2.getUpdateSince(undefined); + t.deepEqual(await p2a, { value: 3n, updateCount: undefined }); + t.deepEqual(await p2b, { value: 3n, updateCount: undefined }); + t.deepEqual(await p2c, { value: 3n, updateCount: undefined }); + t.deepEqual(await p2d, { value: 3n, updateCount: undefined }); + + // cancel n3 while idle, immediately after first firing + const cancel3 = Far('cancel', {}); + const n3 = ts.makeNotifier(5n - state.now, 10n, cancel3); // T=5,15, + const p3a = n3.getUpdateSince(undefined); + t.is(state.currentWakeup, 5n); // primed + state.now = 5n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + const res3a = await p3a; + t.deepEqual(res3a, { value: 5n, updateCount: 1n }); + t.is(state.currentWakeup, undefined); // no longer active + const p3b = n3.getUpdateSince(res3a.updateCount); + ts.cancel(cancel3); // time of cancellation = 5n + const p3c = n3.getUpdateSince(res3a.updateCount); + t.is(state.currentWakeup, undefined); // not reactivated + t.deepEqual(await p3b, { value: 5n, updateCount: undefined }); + t.deepEqual(await p3c, { value: 5n, updateCount: undefined }); + + // cancel n4 while idle, slightly after first firing + + const cancel4 = Far('cancel', {}); + const n4 = ts.makeNotifier(10n - state.now, 10n, cancel4); // T=10,20, + const p4a = n4.getUpdateSince(undefined); + t.is(state.currentWakeup, 10n); // primed + state.now = 10n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + const res4a = await p4a; + t.deepEqual(res4a, { value: 10n, updateCount: 1n }); + t.is(state.currentWakeup, undefined); // no longer active + const p4b = n4.getUpdateSince(res4a.updateCount); + state.now = 11n; + ts.cancel(cancel4); // time of cancellation = 11n + const p4c = n4.getUpdateSince(res4a.updateCount); + const p4d = n4.getUpdateSince(undefined); + t.is(state.currentWakeup, undefined); // not reactivated + t.deepEqual(await p4b, { value: 11n, updateCount: undefined }); + t.deepEqual(await p4c, { value: 11n, updateCount: undefined }); + t.deepEqual(await p4d, { value: 11n, updateCount: undefined }); + + // cancel n5 while active, after first firing + const cancel5 = Far('cancel', {}); + const n5 = ts.makeNotifier(20n - state.now, 10n, cancel5); // fire at T=20,30, + const p5a = n5.getUpdateSince(undefined); + t.is(state.currentWakeup, 20n); // primed + state.now = 21n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + const res5a = await p5a; + t.deepEqual(res5a, { value: 20n, updateCount: 1n }); + t.is(state.currentWakeup, undefined); // no longer active + state.now = 22n; + const p5b = n5.getUpdateSince(res5a.updateCount); + t.is(state.currentWakeup, 30n); // reactivated + ts.cancel(cancel5); // time of cancellation = 22n + t.is(state.currentWakeup, undefined); // no longer active + const p5c = n5.getUpdateSince(res5a.updateCount); + t.deepEqual(await p5b, { value: 22n, updateCount: undefined }); + t.deepEqual(await p5c, { value: 22n, updateCount: undefined }); +}); + +test('iterator', async t => { + // n = ts.makeNotifier(delay, interval, cancelToken); + const { ts, state } = await setup(); + + state.now = 100n; + + // fire at T=125,135,145,.. + const n = ts.makeNotifier(25n, 10n); + + // iterator interface + const iter = n[Symbol.asyncIterator](); + const p1 = iter.next(); + let done1; + p1.then(res => (done1 = res)); + await waitUntilQuiescent(); + t.is(state.currentWakeup, 125n); + t.is(done1, undefined); + + // concurrent next() is rejected + t.throws(iter.next, { + message: 'timer iterator dislikes overlapping next()', + }); + + state.now = 130n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.deepEqual(done1, { value: 125n, done: false }); + t.is(state.currentWakeup, undefined); + + // fast turnaround will wait for next event + const p2 = iter.next(); + let done2; + p2.then(res => (done2 = res)); + await waitUntilQuiescent(); + t.is(done2, undefined); + t.is(state.currentWakeup, 135n); + + state.now = 140n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.deepEqual(done2, { value: 135n, done: false }); + t.is(state.currentWakeup, undefined); + const p3 = iter.next(); // before state.now changes + let done3; + p3.then(res => (done3 = res)); + await waitUntilQuiescent(); + t.is(done3, undefined); + t.is(state.currentWakeup, 145n); // waits for next event + + state.now = 150n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + t.deepEqual(done3, { value: 145n, done: false }); + t.is(state.currentWakeup, undefined); + + // slow turnaround will get the missed event immediately + state.now = 160n; // before next() + const p4 = iter.next(); // missed 155 + let done4; + p4.then(res => (done4 = res)); + await waitUntilQuiescent(); + t.deepEqual(done4, { value: 155n, done: false }); + t.is(state.currentWakeup, undefined); + + // very slow turnaround will get the most recent missed event + state.now = 180n; // before next() + const p5 = iter.next(); // missed 165 and 175 + let done5; + p5.then(res => (done5 = res)); + await waitUntilQuiescent(); + t.deepEqual(done5, { value: 175n, done: false }); + t.is(state.currentWakeup, undefined); + + // sample loop, starts when now=180 + async function drain5(results) { + for await (const x of n) { + results.push(x); + if (results.length >= 5) { + break; + } + } + } + + // parallel iterators don't conflict + const results1 = []; + const results2 = []; + + const p6a = drain5(results1); + const p6b = drain5(results2); + t.deepEqual(results1, []); + t.deepEqual(results2, []); + + for (let now = 181n; now <= 300n; now += 1n) { + state.now = now; + if (state.currentWakeup && state.currentHandler) { + state.currentHandler.wake(state.now); + } + // eslint-disable-next-line no-await-in-loop + await waitUntilQuiescent(); + } + await p6a; + await p6b; + t.deepEqual(results1, [175n, 185n, 195n, 205n, 215n]); + t.deepEqual(results2, [175n, 185n, 195n, 205n, 215n]); + t.is(state.now, 300n); +}); + +async function drainForOf(n, results) { + for await (const x of n) { + results.push(x); + } +} + +async function drainManual(n, results) { + const iter = n[Symbol.asyncIterator](); + for (;;) { + // eslint-disable-next-line no-await-in-loop + const res = await iter.next(); + if (res.done) { + results.push({ returnValue: res.value }); + break; + } else { + results.push(res.value); + } + } +} + +test('cancel active iterator', async t => { + // n = ts.makeNotifier(delay, interval, cancelToken); + const { ts, state } = await setup(); + + state.now = 100n; + + // fire at T=125,135,145,.. + const cancel1 = Far('cancel', {}); + const n = ts.makeNotifier(25n, 10n, cancel1); + + // Cancellation halts the iterator, and the "return value" is the + // cancellation time. But note that for..of does not expose the + // return value. + + const resultsForOf = []; + const p1 = drainForOf(n, resultsForOf); // grabs first next() promise + const resultsManual = []; + const p2 = drainManual(n, resultsManual); + + // allow one value to be posted + t.is(state.currentWakeup, 125n); + state.now = 130n; + state.currentHandler.wake(state.now); + await waitUntilQuiescent(); + + state.now = 131n; + ts.cancel(cancel1); // time of cancellation = 131n + + await p1; + await p2; + t.deepEqual(resultsForOf, [125n]); + t.deepEqual(resultsManual, [125n, { returnValue: 131n }]); +}); + +test('cancel idle iterator', async t => { + // n = ts.makeNotifier(delay, interval, cancelToken); + const { ts, state } = await setup(); + + state.now = 100n; + + // fire at T=125,135,145,.. + const cancel1 = Far('cancel', {}); + const n = ts.makeNotifier(25n, 10n, cancel1); + ts.cancel(cancel1); // before first event + + const resultsForOf = []; + const p1 = drainForOf(n, resultsForOf); // grabs first next() promise + const resultsManual = []; + const p2 = drainManual(n, resultsManual); + + await p1; + await p2; + t.deepEqual(resultsForOf, []); + t.deepEqual(resultsManual, [{ returnValue: 100n }]); +}); diff --git a/packages/SwingSet/test/timer/bootstrap-timer.js b/packages/SwingSet/test/timer/bootstrap-timer.js index e6231f00d7e..46d4a433269 100644 --- a/packages/SwingSet/test/timer/bootstrap-timer.js +++ b/packages/SwingSet/test/timer/bootstrap-timer.js @@ -9,13 +9,15 @@ export function buildRootObject() { events.push(time); }, }); + const cancelToken = Far('cancel', {}); + let repeater; return Far('root', { async bootstrap(vats, devices) { ts = await E(vats.timer).createTimerService(devices.timer); }, async installWakeup(baseTime) { - return E(ts).setWakeup(baseTime, handler); + return E(ts).setWakeup(baseTime, handler, cancelToken); }, async getEvents() { // we need 'events' to remain mutable, but return values are @@ -24,8 +26,8 @@ export function buildRootObject() { events.length = 0; return ret; }, - async removeWakeup() { - return E(ts).removeWakeup(handler); + async cancel() { + return E(ts).cancel(cancelToken); }, async banana(baseTime) { @@ -37,5 +39,28 @@ export function buildRootObject() { } throw Error('banana too slippery'); }, + + async goodRepeater(delay, interval) { + repeater = await E(ts).makeRepeater(delay, interval); + await E(repeater).schedule(handler); + }, + + async stopRepeater() { + await E(repeater).disable(); + }, + + async repeaterBadSchedule(delay, interval) { + repeater = await E(ts).makeRepeater(delay, interval); + try { + await E(repeater).schedule('norb'); // missing arguments #4282 + return 'should have failed'; + } catch (e) { + return e.message; + } + }, + + async badCancel() { + await E(ts).cancel('bogus'); + }, }); } diff --git a/packages/SwingSet/test/timer/test-timer.js b/packages/SwingSet/test/timer/test-timer.js index 98bbb56cd49..244314fb0c3 100644 --- a/packages/SwingSet/test/timer/test-timer.js +++ b/packages/SwingSet/test/timer/test-timer.js @@ -28,6 +28,7 @@ test('timer vat', async t => { await c.run(); const run = async (method, args = []) => { + await c.run(); // allow timer device/vat messages to settle assert(Array.isArray(args)); const kpid = c.queueToVatRoot('bootstrap', method, args); await c.run(); @@ -53,16 +54,15 @@ test('timer vat', async t => { await c.run(); const cd4 = await run('getEvents'); - t.deepEqual(parse(cd4.body), [3n]); // scheduled time + t.deepEqual(parse(cd4.body), [4n]); // current time const cd5 = await run('installWakeup', [5n]); t.deepEqual(parse(cd5.body), 5n); const cd6 = await run('installWakeup', [6n]); t.deepEqual(parse(cd6.body), 6n); - // If you added the same handler multiple times, removeWakeup() - // would remove them all. It returns a list of wakeup timestamps. - const cd7 = await run('removeWakeup'); - t.deepEqual(parse(cd7.body), [5n, 6n]); + // you can cancel a wakeup if you provided a cancelToken + const cd7 = await run('cancel'); + t.deepEqual(parse(cd7.body), undefined); timer.poll(7n); await c.run(); @@ -72,4 +72,48 @@ test('timer vat', async t => { const cd9 = await run('banana', [10n]); t.deepEqual(parse(cd9.body), 'bad setWakeup() handler'); + + // start a repeater that should first fire at now+delay+interval, so + // 7+20+10=27,37,47,57,.. TODO: poll at 25,35,40 + await run('goodRepeater', [20n, 10n]); + timer.poll(25n); + const cd10 = await run('getEvents'); + t.deepEqual(parse(cd10.body), []); + timer.poll(35n); // fire 27, reschedules for 37 + const cd11 = await run('getEvents'); + t.deepEqual(parse(cd11.body), [35n]); + timer.poll(40n); // fire 37, reschedules for 47 + const cd12 = await run('getEvents'); + t.deepEqual(parse(cd12.body), [40n]); + + // disabling the repeater at t=40 should unschedule the t=47 event + await run('stopRepeater'); + timer.poll(50n); + const cd13 = await run('getEvents'); + t.deepEqual(parse(cd13.body), []); + + // exercises #4282 + const cd14 = await run('repeaterBadSchedule', [60n, 10n]); + t.deepEqual(parse(cd14.body), 'bad repeater.schedule() handler'); + timer.poll(75n); + await c.run(); + t.pass('survived timer.poll'); + + // using cancel() with a bogus token is ignored + const cd15 = await run('badCancel', []); + t.deepEqual(parse(cd15.body), undefined); }); + +// DONE+TESTED 1: deleting a repeater should cancel all wakeups for it, but the next wakeup happens anyways + +// DONE-BY-DESIGN 2: deleting a repeater should free all memory used by it, but +// there's an array which holds empty entries and never shrinks + +// DONE+TESTED 3: attempting to repeater.schedule an invalid handler should +// throw, but succeeds and provokes a kernel panic later when poll() +// is called (and tries to invoke the handler) + +// DONE(delay) 4: vat-timer.js and timer.md claim `makeRepeater(delay, +// interval)` where the first arg is delay-from-now, but +// device-timer.js provides `makeRepeater(startTime, interval)`, where +// the arg is delay-from-epoch diff --git a/packages/SwingSet/test/vat-timer-upgrade/bootstrap-vat-timer-upgrade.js b/packages/SwingSet/test/vat-timer-upgrade/bootstrap-vat-timer-upgrade.js new file mode 100644 index 00000000000..ab9ce691cfe --- /dev/null +++ b/packages/SwingSet/test/vat-timer-upgrade/bootstrap-vat-timer-upgrade.js @@ -0,0 +1,97 @@ +import { E } from '@endo/eventual-send'; +import { Far } from '@endo/marshal'; + +export function buildRootObject() { + let ts; + const events = []; + function makeHandler(name) { + return Far(`handler-${name}`, { + wake(time) { + events.push(`${name}-${time}`); + }, + }); + } + const cancelToken = Far('cancel', {}); + let clock; + let brand; + const notifiers = {}; + const iterators = {}; + let updateCount; + let repeaterControl; + + return Far('root', { + async bootstrap(vats, devices) { + ts = await E(vats.timer).createTimerService(devices.timer); + // to exercise vat-vattp upgrade, we need the vatAdminService to + // be configured, even though we don't use it ourselves + await E(vats.vatAdmin).createVatAdminService(devices.vatAdmin); + }, + + async installWakeup(baseTime) { + return E(ts).setWakeup(baseTime, makeHandler('wake'), cancelToken); + }, + + async installRepeater(delay, interval) { + repeaterControl = await E(ts).makeRepeater(delay, interval); + return E(repeaterControl).schedule(makeHandler('repeat')); + }, + + async installRepeatAfter(delay, interval) { + const handler = makeHandler('repeatAfter'); + return E(ts).repeatAfter(delay, interval, handler, cancelToken); + }, + + async installNotifier(name, delay, interval) { + notifiers[name] = await E(ts).makeNotifier(delay, interval, cancelToken); + iterators[name] = await E(notifiers[name])[Symbol.asyncIterator](); + }, + + async getClock() { + clock = await E(ts).getClock(); + }, + + async getBrand() { + brand = await E(ts).getTimerBrand(); + }, + + async checkClock() { + const clock2 = await E(ts).getClock(); + return clock2 === clock; + }, + + async checkBrand() { + const brand2 = await E(ts).getTimerBrand(); + return brand2 === brand; + }, + + async readClock() { + return E(clock).getCurrentTimestamp(); + }, + + async readNotifier(name) { + return E(notifiers[name]) + .getUpdateSince(updateCount) + .then(update => { + updateCount = update.updateCount; + return update; + }); + }, + + async readIterator(name) { + return E(iterators[name]).next(); + }, + + async getEvents() { + // we need 'events' to remain mutable, but return values are + // hardened, so clone the array first + const ret = Array.from(events); + events.length = 0; + return ret; + }, + + async cancel() { + await E(repeaterControl).disable(); + await E(ts).cancel(cancelToken); + }, + }); +} diff --git a/packages/SwingSet/test/vat-timer-upgrade/test-vat-timer-upgrade.js b/packages/SwingSet/test/vat-timer-upgrade/test-vat-timer-upgrade.js new file mode 100644 index 00000000000..f2859a8c2b1 --- /dev/null +++ b/packages/SwingSet/test/vat-timer-upgrade/test-vat-timer-upgrade.js @@ -0,0 +1,200 @@ +// eslint-disable-next-line import/order +import { test } from '../../tools/prepare-test-env-ava.js'; + +// eslint-disable-next-line import/order +import bundleSource from '@endo/bundle-source'; +import { parse } from '@endo/marshal'; +import { provideHostStorage } from '../../src/controller/hostStorage.js'; +import { initializeSwingset, makeSwingsetController } from '../../src/index.js'; +import { buildTimer } from '../../src/devices/timer/timer.js'; + +const bfile = name => new URL(name, import.meta.url).pathname; + +async function restartTimer(controller) { + const fn = bfile('../../src/vats/timer/vat-timer.js'); + const bundle = await bundleSource(fn); + const bundleID = await controller.validateAndInstallBundle(bundle); + controller.upgradeStaticVat('timer', false, bundleID, {}); + await controller.run(); +} + +test('vat-timer upgrade', async t => { + const timer = buildTimer(); + const config = { + bootstrap: 'bootstrap', + vats: { + bootstrap: { sourceSpec: bfile('bootstrap-vat-timer-upgrade.js') }, + }, + devices: { timer: { sourceSpec: timer.srcPath } }, + }; + + const hostStorage = provideHostStorage(); + const deviceEndowments = { + timer: { ...timer.endowments }, + }; + await initializeSwingset(config, [], hostStorage); + const c = await makeSwingsetController(hostStorage, deviceEndowments); + t.teardown(c.shutdown); + c.pinVatRoot('bootstrap'); + timer.poll(1n); // initial time + await c.run(); + + const run = async (method, args = []) => { + // await c.run(); // allow timer device/vat messages to settle + assert(Array.isArray(args)); + const kpid = c.queueToVatRoot('bootstrap', method, args); + await c.run(); + const status = c.kpStatus(kpid); + const capdata = c.kpResolution(kpid); + t.is(status, 'fulfilled', JSON.stringify([status, capdata])); + return capdata; + }; + + async function checkEvents(expected) { + const cd = await run('getEvents'); + t.deepEqual(parse(cd.body), expected); + } + + // handler-based APIs can survive upgrade + + await run('installNotifier', ['a', 4n, 10n]); // 5,15,25,.. + await run('installRepeater', [6n, 10n]); // 7,17,27,.. + await run('installRepeatAfter', [8n, 10n]); // 9,19,29,.. + await run('installWakeup', [16n]); + await run('installWakeup', [51n]); + await run('getClock'); + await run('getBrand'); + + // fire the iterator and Notifier once, to exercise their internal state + { + const kpid1 = c.queueToVatRoot('bootstrap', 'readIterator', ['a']); + const kpid2 = c.queueToVatRoot('bootstrap', 'readNotifier', ['a']); + await c.run(); + timer.poll(5n); + await c.run(); + t.is(c.kpStatus(kpid1), 'fulfilled'); + t.deepEqual(parse(c.kpResolution(kpid1).body), { value: 5n, done: false }); + t.is(c.kpStatus(kpid2), 'fulfilled'); + t.is(parse(c.kpResolution(kpid2).body).value, 5n); + // leave them in the inactive state (the iterator's internal + // updateCount is set), so the next firing should be at 15n + } + + // console.log(`-- ready for upgrade`); + // schedule should be: 7,9,16,51 + + // now upgrade vat-timer, and see if the state is retained + await restartTimer(c); + + // check that the Clock and Brand identities are maintained + { + const cd = await run('checkClock'); + t.is(parse(cd.body), true); // identity maintained + } + + { + const cd = await run('checkBrand'); + t.is(parse(cd.body), true); + } + + { + const cd = await run('readClock'); + t.is(parse(cd.body), 5n); // old Clock still functional + } + + // check the iterator+notifier before we allow any more time to + // pass: they should not fire right away, and should wait until 15n + const iterKPID = c.queueToVatRoot('bootstrap', 'readIterator', ['a']); + const notifierKPID = c.queueToVatRoot('bootstrap', 'readNotifier', ['a']); + await c.run(); + t.is(c.kpStatus(iterKPID), 'unresolved'); + t.is(c.kpStatus(notifierKPID), 'unresolved'); + // schedule should be: repeat-7, repeatAfter-9, notifier-15, + // iterator-15, wakeup-16, wakeup-51 + + timer.poll(7n); // fires repeater + await c.run(); + // schedule should be: repeatAfter-9, notifier-15, iterator-15, + // wakeup-16, repeat-17, wakeup-51 + await checkEvents(['repeat-7']); + + timer.poll(9n); // fires repeatAfter + await c.run(); + // schedule should be: notifier-15, iterator-15, wakeup-16, + // repeat-17, repeatAfter-19, wakeup-51 + await checkEvents(['repeatAfter-9']); + + t.is(c.kpStatus(iterKPID), 'unresolved'); + timer.poll(15n); // fires iterator+notifier + await c.run(); + // schedule should be: wakeup-16, repeat-17, repeatAfter-19, + // wakeup-51 (repeaters automatically retrigger, but the iterator + // and notifier do not) + t.is(c.kpStatus(iterKPID), 'fulfilled'); + t.deepEqual(parse(c.kpResolution(iterKPID).body), { + value: 15n, + done: false, + }); + t.is(c.kpStatus(notifierKPID), 'fulfilled'); + t.deepEqual(parse(c.kpResolution(notifierKPID).body).value, 15n); + await checkEvents([]); + + // we advance time to each expected trigger one-at-a-time, rather + // than jumping ahead to 16n, because our handlers are recording the + // time at which they were fired, rather than the time at which they + // were scheduled, and it would be hard to keep them distinct if + // they all reported firing at 16n + timer.poll(16n); + await c.run(); + // schedule should be: repeat-17, repeatAfter-19, wakeup-51 + await checkEvents(['wake-16']); + + // cancelToken should still work + await run('cancel'); // also does repeater.disable() + // schedule now empty + + timer.poll(51n); + // repeater would have fired at 27n, repeatAfter at 29n, wakeup at 51n + await c.run(); + await checkEvents([]); + + // Latest notifier event after the stashed updateCount would have + // been 45n, but it was cancelled, so we get the cancellation time. + { + const kpid = c.queueToVatRoot('bootstrap', 'readNotifier', ['a']); + await c.run(); + t.is(c.kpStatus(kpid), 'fulfilled'); + const finished = parse(c.kpResolution(kpid).body); + t.deepEqual(finished, { value: 16n, updateCount: undefined }); + } + + // same for the iterator + { + const kpid = c.queueToVatRoot('bootstrap', 'readIterator', ['a']); + await c.run(); + t.is(c.kpStatus(kpid), 'fulfilled'); + const finished = parse(c.kpResolution(kpid).body); + t.deepEqual(finished, { value: 16n, done: true }); + } + + // make a second notifier, cancel it before upgrade, then make sure + // the cancellation sticks + await run('installNotifier', ['b', 0n, 10n]); // 51,61,71,.. + await run('cancel'); // time of cancellation = 51 + + await restartTimer(c); + { + const kpid = c.queueToVatRoot('bootstrap', 'readNotifier', ['b']); + await c.run(); + t.is(c.kpStatus(kpid), 'fulfilled'); + const finished = parse(c.kpResolution(kpid).body); + t.deepEqual(finished, { value: 51n, updateCount: undefined }); + } + { + const kpid = c.queueToVatRoot('bootstrap', 'readIterator', ['b']); + await c.run(); + t.is(c.kpStatus(kpid), 'fulfilled'); + const finished = parse(c.kpResolution(kpid).body); + t.deepEqual(finished, { value: 51n, done: true }); + } +}); diff --git a/packages/SwingSet/tools/internal-types.js b/packages/SwingSet/tools/internal-types.js new file mode 100644 index 00000000000..7926471379c --- /dev/null +++ b/packages/SwingSet/tools/internal-types.js @@ -0,0 +1,13 @@ +/** + * @typedef {object} ManualTimerAdmin + * @property { (when: Timestamp) => void } advanceTo + */ + +/** + * @typedef {ManualTimerAdmin & TimerService} ManualTimer + */ + +/** + * @typedef {object} ManualTimerOptions + * @property {Timestamp} [startTime=0n] + */ diff --git a/packages/SwingSet/tools/manual-timer.js b/packages/SwingSet/tools/manual-timer.js new file mode 100644 index 00000000000..d1ad9d1cb80 --- /dev/null +++ b/packages/SwingSet/tools/manual-timer.js @@ -0,0 +1,79 @@ +import { Far } from '@endo/marshal'; +import { makeScalarMapStore } from '@agoric/store'; +import { buildRootObject } from '../src/vats/timer/vat-timer.js'; + +// adapted from 'setup()' in test-vat-timer.js + +function setup() { + const state = { + now: 0n, // current time, updated during test + currentWakeup: undefined, + currentHandler: undefined, + }; + const deviceMarker = harden({}); + const timerDeviceFuncs = harden({ + getLastPolled: () => state.now, + setWakeup: (when, handler) => { + assert.equal(state.currentWakeup, undefined, 'one at a time'); + assert.equal(state.currentHandler, undefined, 'one at a time'); + if (state.currentWakeup !== undefined) { + assert( + state.currentWakeup > state.now, + `too late: ${state.currentWakeup} <= ${state.now}`, + ); + } + state.currentWakeup = when; + state.currentHandler = handler; + return when; + }, + removeWakeup: _handler => { + state.currentWakeup = undefined; + state.currentHandler = undefined; + }, + }); + function D(node) { + assert.equal(node, deviceMarker, 'fake D only supports devices.timer'); + return timerDeviceFuncs; + } + const vatPowers = { D }; + + const vatParameters = {}; + // const baggage = makeScalarBigMapStore(); + const baggage = makeScalarMapStore(); + + const root = buildRootObject(vatPowers, vatParameters, baggage); + const timerService = root.createTimerService(deviceMarker); + + return { timerService, state }; +} + +/** + * A fake TimerService, for unit tests that do not use a real + * kernel. You can make time pass by calling `advanceTo(when)`. + * + * @param {ManualTimerOptions} [options] + * @returns {ManualTimer} + */ +export function buildManualTimer(options = {}) { + const { startTime = 0n, ...other } = options; + const unrec = Object.getOwnPropertyNames(other).join(','); + assert.equal(unrec, '', `buildManualTimer unknown options ${unrec}`); + const { timerService, state } = setup(); + assert.typeof(startTime, 'bigint'); + state.now = startTime; + + function wake() { + if (state.currentHandler) { + state.currentHandler.wake(state.now); + } + } + + function advanceTo(when) { + assert.typeof(when, 'bigint'); + assert(when > state.now, `advanceTo(${when}) < current ${state.now}`); + state.now = when; + wake(); + } + + return Far('ManualTimer', { ...timerService, advanceTo }); +}