From 263b434064d293fdd3bbad73872805e81b96605b Mon Sep 17 00:00:00 2001 From: Brian Warner Date: Thu, 11 Aug 2022 00:49:51 -0700 Subject: [PATCH] fix!(swingset): overhaul vat-timer, durability, API, and tests vat-timer is now fully virtualized, durablized, and upgradeable. RAM usage should be O(N) in the number of: * pending Promise wakeups (`wakeAt`, `delay`) * active Notifier promises (`makeNotifier`) * active Iterator promises (`makeNotifier()[Symbol.asyncIterator]`) Pending promises will be disconnected (rejected) during upgrade, as usual. All handlers and Promises will fire with the most recent timestamp available, which (under load) may be somewhat later than the scheduled wakeup time. Until cancellation, Notifiers will always report a scheduled time (i.e. `start` plus some multiple of the interval). The opaque `updateCount` used in Notifier updates is a counter starting from 1n. When a Notifier is cancelled, the final/"finish" value is the timestamp of cancellation, which may or may not be a multiple of the interval (and might be a duplicate of the last non-final value). Once in the cancelled state, `getUpdateSince(anything)` yields `{ value: cancellationTimestamp, updateCount: undefined }`, and the corresponding `iterator.next()` resolves to `{ value: cancellationTimestamp, done: true }`. Neither will ever reject their Promises (except due to upgrade). Asking for a wakeup in the past or present will fire immediately. Most API calls will accept an arbitrary Far object as a CancelToken, which can be used to cancel the wakeup/repeater. `makeRepeater` is the exception. This does not change the device-timer API or implementation, however vat-timer now only uses a single device-side wakeup, and only exposes a single handler object, to minimize the memory usage and object retention by the device (since devices do not participate in GC). This introduces a `Clock` which can return time values without also providing scheduling authority, and a `TimerBrand` which can validate time values without providing clock or scheduling authority. Timestamps are not yet Branded, but the scaffolding is in place. `packages/SwingSet/tools/manual-timer.js` offers a manually-driven timer service, which can help with unit tests. closes #4282 refs #4286 closes #4296 closes #5616 closes #5668 closes #5709 refs #5798 --- packages/SwingSet/docs/timer.md | 92 +- packages/SwingSet/src/vats/timer/types.d.ts | 77 +- packages/SwingSet/src/vats/timer/vat-timer.js | 1006 +++++++++++++- packages/SwingSet/test/test-manual-timer.js | 12 + packages/SwingSet/test/test-vat-timer.js | 1199 +++++++++++++++++ .../SwingSet/test/timer/bootstrap-timer.js | 31 +- packages/SwingSet/test/timer/test-timer.js | 54 +- .../bootstrap-vat-timer-upgrade.js | 97 ++ .../test-vat-timer-upgrade.js | 200 +++ packages/SwingSet/tools/internal-types.js | 13 + packages/SwingSet/tools/manual-timer.js | 79 ++ 11 files changed, 2741 insertions(+), 119 deletions(-) create mode 100644 packages/SwingSet/test/test-manual-timer.js create mode 100644 packages/SwingSet/test/test-vat-timer.js create mode 100644 packages/SwingSet/test/vat-timer-upgrade/bootstrap-vat-timer-upgrade.js create mode 100644 packages/SwingSet/test/vat-timer-upgrade/test-vat-timer-upgrade.js create mode 100644 packages/SwingSet/tools/internal-types.js create mode 100644 packages/SwingSet/tools/manual-timer.js diff --git a/packages/SwingSet/docs/timer.md b/packages/SwingSet/docs/timer.md index ac362fd39e96..1ee5521045a8 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 7b0a868a51ab..a3e43dec684d 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 84a7787136b9..cd565a98c833 100644 --- a/packages/SwingSet/src/vats/timer/vat-timer.js +++ b/packages/SwingSet/src/vats/timer/vat-timer.js @@ -1,98 +1,956 @@ +/* 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: (now: TimestampValue) => 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 + */ +const 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 + */ +const 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 {Cancellable} event + */ +const 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 {Cancellable} event + */ +const 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} + */ +const 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 +// const 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[] } + */ +const 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 } } + */ +const 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 const 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; + const 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} + */ + const toTimestamp = when => { + return TimeMath.toAbs(when); // TODO (when, brand) + }; + + /** + * convert external (branded) Timestamp to internal bigint + * + * @param {Timestamp} when + * @returns {TimestampValue} + */ + const fromTimestamp = when => { + return TimeMath.absValue(when); // TODO: brand + }; + + /** + * convert external (branded) RelativeTime to internal bigint + * + * @param {RelativeTime} delta + * @returns {RelativeTimeValue} + */ + const fromRelativeTime = delta => TimeMath.relValue(delta); + + const 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} + */ + const getNow = () => { + insistDevice(); + return Nat(D(timerDevice).getLastPolled()); + }; + + // this gets called when the device's wakeup message reaches us + const processAndReschedule = () => { + // first, service everything that is ready + const now = getNow(); + removeEventsUpTo(schedule, now).forEach(event => event.fired(now)); + // 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) + + // In general these events are in one of three states, with three + // transitions: + // + // idle --start--> scheduled : event is on 'schedule' and maybe 'cancels' + // idle --start--> fired : not on either + // scheduled --fired--> fired : already removed from 'schedule', + // must remove from 'cancels' + // scheduled --cancel--> cancelled : already removed from 'cancels', + // must remove from schedule + + // -- Event (one-shot) + + /** + * @param {TimestampValue} when + * @param {Handler} handler + * @param {CancelToken} [cancelToken] + */ + const initOneShotEvent = (when, handler, cancelToken) => { + return { when, handler, cancelToken }; + }; + + const oneShotEventBehavior = { + scheduleYourself({ self, state }) { + addEvent(schedule, state.when, self); + if (state.cancelToken) { + addCancel(cancels, state.cancelToken, self); + } + reschedule(); + }, + + fired({ self, state }, now) { + if (state.cancelToken) { + removeCancel(cancels, state.cancelToken, self); // stop tracking + } + // we tell the client the most recent time available + const p = E(state.handler).wake(now); + // 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); + }, + + cancel({ self, state }) { + removeEvent(schedule, state.when, self); + reschedule(); + }, + }; + + const makeOneShotEvent = vivifyKind( + baggage, + 'oneShotEvent', + initOneShotEvent, + oneShotEventBehavior, + ); + + // -- Event (promise) + + const initPromiseEvent = (when, cancelToken) => { + return { when, cancelToken }; + }; + + const promiseEventBehavior = { + scheduleYourself({ self, state }) { + addEvent(schedule, state.when, self); + if (state.cancelToken) { + addCancel(cancels, state.cancelToken, self); + } + reschedule(); + }, + + fired({ self, state }, now) { + if (state.cancelToken) { + removeCancel(cancels, state.cancelToken, self); // stop tracking + } + if (wakeupPromises.has(self)) { + wakeupPromises.get(self).resolve(now); + // else we were upgraded and promise was rejected/disconnected + } + }, + + cancel({ self, state }) { + removeEvent(schedule, state.when, self); + 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, + ); + + /** + * @param {TimestampValue} when + * @param {TimestampValue} now + * @param {CancelToken} cancelToken + * @returns {Promise} + */ + const wakeAtInternal = (when, now, cancelToken) => { + if (when <= now) { + return Promise.resolve(toTimestamp(now)); + } + 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 + }; + + // -- Event (repeaters) + + // state machine: + // idle --start--> scheduled : on 'schedule' and maybe 'cancels' + // idle --start--> firing : maybe on 'cancels' + // scheduled --fired--> firing : already removed from 'schedule' + // scheduled --cancel--> cancelled: already removed from 'cancels' + // firing --resp.resolve--> scheduled: (reschedule) add to 'schedule' + // firing --resp.reject--> cancelled: maybe remove from 'cancels' + // firing --cancel--> cancelled: already removed from 'cancels' + // cancelled --resp.resolve--> cancelled + // cancelled --resp.reject--> cancelled + + const initRepeaterEvent = (start, interval, handler, cancelToken) => { + const scheduled = undefined; // wakeup time if scheduled, clear if firing + const cancelled = false; // set to 'true' once cancelled + return { start, interval, handler, scheduled, cancelled, cancelToken }; + }; + + const repeaterEventBehavior = { + scheduleYourself({ self, state }) { + if (state.cancelToken) { + addCancel(cancels, state.cancelToken, self); + } + const now = getNow(); + if (state.start === now) { + state.scheduled = now; + self.fired(now); + } else { + const { next } = measureInterval(state.start, state.interval, now); + state.scheduled = next.time; // cleared if fired/cancelled + addEvent(schedule, next.time, self); + reschedule(); + } + }, + + fired({ self, state }, now) { + state.scheduled = undefined; + // repeaters stay in "firing" until their promise settles + E(state.handler) + .wake(now) + .then( + _res => self.wakerDone(), // reschedule unless cancelled + _err => self.wakerFailed(), // do not reschedule + ) + .catch(err => console.log(`timer repeater error`, err)); + }, + + wakerDone({ self, state }) { + if (!state.cancelled) { + const now = getNow(); + const { next } = measureInterval(state.start, state.interval, now); + state.scheduled = next.time; // cleared if fired/cancelled + addEvent(schedule, next.time, self); + reschedule(); + } + }, + + wakerFailed({ self, state }) { + if (state.cancelToken) { + removeCancel(cancels, state.cancelToken, self); // stop tracking + } + }, + + cancel({ self, state }) { + // TODO: consider handler.onError + if (state.scheduled !== undefined) { + removeEvent(schedule, state.scheduled, self); + reschedule(); + state.scheduled = undefined; // not strictly necessary, event is dropped + } + state.cancelled = true; // for wakerDone to check + state.cancelToken = undefined; // for wakerFailed to check + }, + }; + + const makeRepeaterEvent = vivifyKind( + baggage, + 'repeaterEvent', + initRepeaterEvent, + repeaterEventBehavior, + ); + + // -- now we can define the public API functions + + /** + * @returns {Timestamp} + */ + const getCurrentTimestamp = () => toTimestamp(getNow()); + + /** + * @param {Timestamp} when + * @param {Handler} handler + * @param {CancelToken} [cancelToken] + * @returns {Timestamp} + */ + const 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 now = getNow(); + if (when <= now) { + const p = E(handler).wake(now); // would prefer sendOnly() + p.catch(_err => undefined); + } + + 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 } + */ + const wakeAt = (when, cancelToken = undefined) => { + when = fromTimestamp(when); + const now = getNow(); + return wakeAtInternal(when, now, cancelToken); + }; + + /** + * addDelay(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 } + */ + const addDelay = (delay, cancelToken = undefined) => { + delay = fromRelativeTime(delay); + assert(delay >= 0n, 'delay must not be negative'); + const now = getNow(); + const when = now + delay; + return wakeAtInternal(when, now, 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 + */ + const cancel = cancelToken => { + // silently ignore multiple cancels and bogus token + if (cancels.has(cancelToken)) { + const cancelled = cancels.get(cancelToken); + cancels.delete(cancelToken); + cancelled.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] + */ + const 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. + + const 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, + ); + const makeRepeater = (delay, interval) => + createRepeater(delay, interval).repeater; + + /** + * @param {RelativeTime} delay + * @param {RelativeTime} interval + * @param {Handler} handler + * @param {CancelToken} [cancelToken] + */ + const 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); + }; + + // -- notifiers - const index = D(timerNode).makeRepeater(delay, interval); + // First we define the Iterator, since Notifiers are Iterable. - const vatRepeater = Far('vatRepeater', { - schedule(h) { - return D(timerNode).schedule(index, h); - }, - disable() { - repeaters.delete(index); - return D(timerNode).deleteRepeater(index); - }, + const initIterator = notifier => ({ + notifier, + updateCount: undefined, + active: false, + }); + const iteratorBehavior = { + next({ state }) { + assert(!state.active, 'timer iterator dislikes overlapping next()'); + state.active = true; + return state.notifier + .getUpdateSince(state.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] + */ + const 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, now, 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() }, - }); + }, + }; + + const 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 } + */ + const makeNotifier = (delay, interval, cancelToken) => + 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 + * + * @param {unknown} timerNode + * @returns {Promise} + */ + const 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 000000000000..179dc79b15ee --- /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 000000000000..2c322294923a --- /dev/null +++ b/packages/SwingSet/test/test-vat-timer.js @@ -0,0 +1,1199 @@ +// 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(); + + const addEvent = (when, event) => debugTools.addEvent(schedule, when, event); + const removeEvent = (when, event) => + debugTools.removeEvent(schedule, when, event); + const firstWakeup = () => debugTools.firstWakeup(schedule); + const removeEventsUpTo = upto => 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(); + const addCancel = (cancelToken, event) => + debugTools.addCancel(cancels, cancelToken, event); + const removeCancel = (cancelToken, event) => + 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; + const 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]); +}); + +const setup = async () => { + 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; + }, + }); + const 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 = {}; + const makeHandler = which => + Far('handler', { + wake(time) { + // handlers/promises get the most recent timestamp + fired[which] = time; + }, + }); + + const thenFire = (p, which) => { + p.then( + value => (fired[which] = ['fulfill', value]), + err => (fired[which] = ['reject', err]), + ); + }; + + const toTS = value => { + return TimeMath.toAbs(value); // TODO (when, brand) + }; + const 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(), clock.getTimerBrand() + 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 + const cancel1 = Far('cancel1', {}); + ts.repeatAfter(0n, 10n, makeHandler(3), cancel1); + t.is(state.currentWakeup, undefined); + await waitUntilQuiescent(); + t.is(fired[3], 3n); + t.is(state.currentWakeup, 13n); + // delay=0 doesn't break cancellation + ts.cancel(cancel1); + await waitUntilQuiescent(); + t.is(state.currentWakeup, undefined); // unscheduled + + // delay=0 can be cancelled during slow handler + const cancel2 = Far('cancel2', {}); + const pk2 = makePromiseKit(); + let slowState2 = 'uncalled'; + const slowHandler2 = Far('slow', { + wake(time) { + slowState2 = time; + return pk2.promise; + }, + }); + ts.repeatAfter(0n, 10n, slowHandler2, cancel2); // 3,13,.. + await waitUntilQuiescent(); + t.is(slowState2, 3n); + t.is(state.currentWakeup, undefined); + ts.cancel(cancel2); + t.is(state.currentWakeup, undefined); + pk2.resolve('done'); + t.is(state.currentWakeup, undefined); // not rescheduled + + // cancellation during slow handler which rejects (thus cancels again) + const cancel3 = Far('cancel3', {}); + const pk3 = makePromiseKit(); + let slowState3 = 'uncalled'; + const slowHandler3 = Far('slow', { + wake(time) { + slowState3 = time; + return pk3.promise; + }, + }); + ts.repeatAfter(0n, 10n, slowHandler3, cancel3); // 3,13,.. + await waitUntilQuiescent(); + t.is(slowState3, 3n); + t.is(state.currentWakeup, undefined); + ts.cancel(cancel3); + t.is(state.currentWakeup, undefined); + pk3.reject('oops'); + t.is(state.currentWakeup, undefined); +}); + +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 + const drain5 = async 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); +}); + +const drainForOf = async (n, results) => { + for await (const x of n) { + results.push(x); + } +}; + +const drainManual = async (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 e6231f00d7e0..46d4a4332690 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 98bbb56cd49b..0e5c327f4c4c 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,.. + 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 000000000000..ab9ce691cfec --- /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 000000000000..f2859a8c2b19 --- /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 000000000000..7926471379ca --- /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 000000000000..9518124e3109 --- /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 + +const 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; + }, + }); + const 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 const 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; + + const wake = () => { + if (state.currentHandler) { + state.currentHandler.wake(state.now); + } + }; + + const advanceTo = when => { + assert.typeof(when, 'bigint'); + assert(when > state.now, `advanceTo(${when}) < current ${state.now}`); + state.now = when; + wake(); + }; + + return Far('ManualTimer', { ...timerService, advanceTo }); +};