diff --git a/common/web/gesture-recognizer/src/engine/headless/queuedPromisePrioritizer.ts b/common/web/gesture-recognizer/src/engine/headless/queuedPromisePrioritizer.ts new file mode 100644 index 0000000000..10268e9efc --- /dev/null +++ b/common/web/gesture-recognizer/src/engine/headless/queuedPromisePrioritizer.ts @@ -0,0 +1,152 @@ +interface Entry { + priority: number, + ordinal: number +} + +interface SynchronizationSet { + entries: Entry[], + promise: Promise +} + +/* + * This was "fun". + * + * Collects metadata on all near-synchronous calls to its lone public method, intended to handle + * cases where Promise fulfillment is essentially triggered synchronously. JS has an internal + * "Promise queue" that ensures consistent ordering with Promise resolution and fulfillment, + * assuming all Promises of equal priority. Its func exists to customize their order of + * resolution when fulfilled "synchronously", based on a supplied `priority` value. + * - Common case: a gesture-source path ends (touchend, mouseup) + * - Any promises based on completing the path will need to immediately either reject or fulfill. + * - Depending on implementation, these will be registered on the queue synchronously. + * - This is _absolutely the case_ through our use of the matchers' use of the + * `ManagedPromise` utility class. + * - If certain ones should be given the chance to "resolve" first, regardless of when their + * Promises were initialized, the built-in Promise queue doesn't handle _that_. + * + * By re-deferring on an instantly-resolving Promise, we can note which calls to this method are + * essentially synchronous. During the corresponding await, we can construct additional call- + * specific Promises as handles for our callers and track their priority for sorting. + * + * Once we reach resolution of that instantly-resolving Promise (`syncPromise`), we lock out + * any further calls from the set to be processed; new incoming entries will fall into a later + * set regardless of priority. With the syncSet's recording fully completed... We may then + * sort the known "synchronous" calls to this resolution handler in order of priority, thereby + * enforcing our desired Promise-resolution ordering. + */ + +/** + * The short version: this is a Promise "resynchronizer" that allows us to choose the order in which + * "synchronous" promises resolve. + * + * To clarify: if one external function call triggers direct resolution of three separate Promises + * that instantly queue against this class's `queueWithPriority` method, they are considered + * "synchronous" - as they're all placed on the queue before the queue regains control and before + * the first of the three Promises signals its resolution. + * + * If each of the three then awaits its turn via said queueing, they will each then resolve in order + * of their specified priority. + */ +export class QueuedPromisePrioritizer { + private currentSyncPromise: Promise; + private setQueue: SynchronizationSet[] = []; + + private ordinalSeed: number = 0; + + /** + * This method generates a new Promise on the JS Promise queue that will resolve in custom order + * relative to other calls against this method that occur as of a direct, synchronous result of + * Promises already queued for fulfillment. + * - If there are three such Promises queued that each call this method, the new Promise's + * "instant resolution" is queued (1) after the third call queues itself AND (2) after any + * of the others that queue with a higher priority level have received their turns. + * + * Any future calls caused by Promise fulfillment not yet queued up, as of the "first" (w.l.o.g) + * call against this method, must await their turns until after the lowest priority call from + * Promise fulfillments already queued has received its turn. + * - To continue the same example, if a timeout of 1 ms is set after the third queuing call + * and only then a fourth call for promise-queuing occurs, the fourth will always resolve + * after all three prior calls resolve, no matter what priority value is supplied. + * + * @param priority + */ + async queueWithPriority(priority: number) { + // If we're not in the middle of recognizing a set of simultaneously-queued Promises, + // time to enter that state. + if(!this.currentSyncPromise) { + const syncMetadata: SynchronizationSet = { + entries: [], + promise: this.currentSyncPromise = Promise.resolve() + }; + + /* + * Once we've finished recording the set, we can sort the Promises by priority order - larger + * values first. No need to sort it every time, after all - no new entries will be allowed + * at that point. + */ + this.currentSyncPromise.then(() => { + // Descending priority order for _our_ syncSet. We should only need to do it once, and can do + // it once collection is complete (see `await` below). + syncMetadata.entries.sort((a, b) => { + let primary = b.priority - a.priority; + if(primary) { + return primary; + } + + // If equal priority, the one that registered first wins. It's an easy enough guarantee + // to make, so why not? + return a.ordinal - b.ordinal; + }); + }); + + this.setQueue.push(syncMetadata); + } + + // Capture the current sync-Promise + const syncSet = this.setQueue[this.setQueue.length-1]; + + // Register our tracking metadata for this queue request. + const entry: Entry = { + priority: priority, + ordinal: this.ordinalSeed++ + } + syncSet.entries.push(entry); + + // Now that we've recorded the request's metadata... + await syncSet.promise; // which is Promise.resolve(), and thus already queued for fulfillment. + + /* + * As the line above is the first `await` within this method, only now can other asynchronous + * things have a chance to execute and/or add new Promises to the queue. We've already registered + * our synchronization Promise and reserved our slot in the Promise queue, allowing us to meet + * our guarantees. + * + * As a result, registration is now over and done for Promises allowed to be treated as + * synchronous. Any new calls from new Promises - or even from resolution handlers for existing + * ones - will always reach this point in this function later, effectively after our denoted + * queue-split point, and definitely "asynchronously". + * + * By clearing the current syncSet marker, this function will know to start a new, separate `syncSet`. + * (See: this method's first code block / conditional on this field) + */ + this.currentSyncPromise = null; + + // // #1 - make sure our sync set isn't deferred pending completion of a different syncSet. + // #2 - if we're not the highest priority here... re-defer to let the higher-priority + // entries go first. + while(this.setQueue[0].entries[0] != entry) { + await syncSet.promise; + } + + // It's our turn now! But first, remove our entries in the queue to make sure the next guy + // in the queue gets his turn / can exit the while-loop above. + syncSet.entries.splice(0, 1); + + if(syncSet.entries.length == 0) { + this.setQueue.splice(0, 1); + } + + // And we're in the clear! + return; + } +} \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/test/auto/headless/queuedPromisePrioritizer.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/queuedPromisePrioritizer.spec.ts new file mode 100644 index 0000000000..ee5f2f3c4c --- /dev/null +++ b/common/web/gesture-recognizer/src/test/auto/headless/queuedPromisePrioritizer.spec.ts @@ -0,0 +1,86 @@ +import { assert } from 'chai' +import { timedPromise } from '@keymanapp/web-utils'; + +import { QueuedPromisePrioritizer } from "@keymanapp/gesture-recognizer"; + +describe('QueuedPromisePrioritizer', () => { + it('single call', async () => { + const prioritizer = new QueuedPromisePrioritizer(); + await prioritizer.queueWithPriority(1); + }); + + it('three sync', async () => { + const prioritizer = new QueuedPromisePrioritizer(); + + const simpleLog: number[] = []; + + const waiter1 = prioritizer.queueWithPriority(1).then(() => simpleLog.push(1)); + const waiter2 = prioritizer.queueWithPriority(2).then(() => simpleLog.push(2)); + const waiter3 = prioritizer.queueWithPriority(3).then(() => simpleLog.push(3)); + + await Promise.all([waiter1, waiter2, waiter3]); + + assert.sameOrderedMembers(simpleLog, [3, 2, 1]); + }); + + // Because JS's default Array.sort isn't a stable-sort. + it('three equal-priority sync', async () => { + const prioritizer = new QueuedPromisePrioritizer(); + + const simpleLog: number[] = []; + + const waiter1 = prioritizer.queueWithPriority(1).then(() => simpleLog.push(1)); + const waiter2 = prioritizer.queueWithPriority(1).then(() => simpleLog.push(2)); + const waiter3 = prioritizer.queueWithPriority(1).then(() => simpleLog.push(3)); + + await Promise.all([waiter1, waiter2, waiter3]); + + assert.sameOrderedMembers(simpleLog, [1, 2, 3]); + }); + + it('three sync, time wait, two sync', async () => { + const prioritizer = new QueuedPromisePrioritizer(); + + const simpleLog: number[] = []; + + const waiter1 = prioritizer.queueWithPriority(1).then(() => simpleLog.push(1)); + const waiter2 = prioritizer.queueWithPriority(-2).then(() => simpleLog.push(-2)); + const waiter3 = prioritizer.queueWithPriority(3).then(() => simpleLog.push(3)); + + await timedPromise(10); + + const waiter4 = prioritizer.queueWithPriority(15).then(() => simpleLog.push(15)); + const waiter5 = prioritizer.queueWithPriority(0).then(() => simpleLog.push(0)); + + await Promise.all([waiter1, waiter2, waiter3, waiter4, waiter5]); + + assert.sameOrderedMembers(simpleLog, [3, 1, -2, 15, 0]); + }); + + // The most brutal test / niche edge case. + it('three sync, await-and-call, two sync', async () => { + const prioritizer = new QueuedPromisePrioritizer(); + + const simpleLog: number[] = []; + + const waiter1 = prioritizer.queueWithPriority(1).then(() => simpleLog.push(1)); + const waiter2 = prioritizer.queueWithPriority(-2).then(() => simpleLog.push(-2)); + const waiter3 = prioritizer.queueWithPriority(3).then(() => simpleLog.push(3)); + + // Is within its own set; we do have to await full resolution of the Promise.resolve() call, which takes its + // own slot on the queue. As the queue takes control at this stage, further registrations (from the `then`) + // will be treated as asynchronous with the prior set. + // + // We must _then_ await its complete fulfillment - our await is actually on the `then` clause. + const waiter4 = await Promise.resolve().then(() => prioritizer.queueWithPriority(4).then(() => simpleLog.push(4))); + + // As we `awaited`, we get control back from the queue asynchronously relative to the priority-4 call's + // registration. + const waiter5 = prioritizer.queueWithPriority(15).then(() => simpleLog.push(15)); + const waiter6 = prioritizer.queueWithPriority(0).then(() => simpleLog.push(0)); + + await Promise.all([waiter1, waiter2, waiter3, waiter4, waiter5, waiter6]); + + assert.sameOrderedMembers(simpleLog, [3, 1, -2, 4, 15, 0]); + }); +}); \ No newline at end of file