diff --git a/doc/changelog.d/202.added.md b/doc/changelog.d/202.added.md new file mode 100644 index 00000000..a0550567 --- /dev/null +++ b/doc/changelog.d/202.added.md @@ -0,0 +1 @@ +Queue client-mode scene updates and skip the rebuild for data-only updates diff --git a/src/ansys/visor/visor-client/src/jest-tests/SerialUpdateQueue.test.ts b/src/ansys/visor/visor-client/src/jest-tests/SerialUpdateQueue.test.ts new file mode 100644 index 00000000..37c7029d --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/SerialUpdateQueue.test.ts @@ -0,0 +1,123 @@ +import { SerialUpdateQueue } from '../wasm/SerialUpdateQueue'; + +/** + * Server updates run one at a time. Updates arriving while one runs merge + * into one pending update, which keeps the latest options and rebuilds if any + * arrival was structural. A failed update never rebuilds and never blocks + * the next one. + * + * The fake apply records `start:` and is held on a promise the test + * settles; the fake rebuild records `rebuild`. Expected records are literals. + */ + +type Update = Readonly<{ tag: string; dataOnly?: boolean }>; +type Held = { resolve: () => void; reject: (reason: unknown) => void }; + +function makeQueue() { + const record: string[] = []; + const held: Held[] = []; + const queue = new SerialUpdateQueue( + (bindCanvas: unknown) => { + record.push(`start:${(bindCanvas as Update).tag}`); + return new Promise((resolve, reject) => held.push({ resolve, reject })); + }, + () => { + record.push('rebuild'); + } + ); + return { record, held, queue }; +} + +const flushAsync = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('SerialUpdateQueue', () => { + test('second update starts only after the first fulfils', async () => { + const { record, held, queue } = makeQueue(); + + queue.run({ tag: 'A' }); + queue.run({ tag: 'B' }); + await flushAsync(); + expect(record).toEqual(['start:A']); + + held[0].resolve(); + await flushAsync(); + expect(record).toEqual(['start:A', 'rebuild', 'start:B']); + }); + + test('second update starts only after the first rejects', async () => { + const { record, held, queue } = makeQueue(); + + const first = queue.run({ tag: 'A' }); + const firstRejects = expect(first).rejects.toThrow('A failed'); + queue.run({ tag: 'B' }); + await flushAsync(); + expect(record).toEqual(['start:A']); + + held[0].reject(new Error('A failed')); + await flushAsync(); + expect(record).toEqual(['start:A', 'start:B']); + await firstRejects; + }); + + test('updates arriving during a held update merge into one', async () => { + const { record, held, queue } = makeQueue(); + + queue.run({ tag: 'A' }); + const b = queue.run({ tag: 'B' }); + const c = queue.run({ tag: 'C' }); + const d = queue.run({ tag: 'D' }); + expect(c).toBe(b); + expect(d).toBe(b); + + held[0].resolve(); + await flushAsync(); + expect(record).toEqual(['start:A', 'rebuild', 'start:D']); + }); + + test('merged update does not rebuild when every arrival was data-only', async () => { + const { record, held, queue } = makeQueue(); + + queue.run({ tag: 'A', dataOnly: true }); + queue.run({ tag: 'B', dataOnly: true }); + queue.run({ tag: 'C', dataOnly: true }); + queue.run({ tag: 'D', dataOnly: true }); + held[0].resolve(); + await flushAsync(); + held[1].resolve(); + await flushAsync(); + + expect(record).toEqual(['start:A', 'start:D']); + }); + + test('merged update rebuilds once when any arrival was structural', async () => { + const { record, held, queue } = makeQueue(); + + queue.run({ tag: 'A', dataOnly: true }); + queue.run({ tag: 'B', dataOnly: true }); + queue.run({ tag: 'C' }); + queue.run({ tag: 'D', dataOnly: true }); + held[0].resolve(); + await flushAsync(); + expect(record).toEqual(['start:A', 'start:D']); + + held[1].resolve(); + await flushAsync(); + expect(record).toEqual(['start:A', 'start:D', 'rebuild']); + }); + + test('an arrival after the pending update started waits for it', async () => { + const { record, held, queue } = makeQueue(); + + queue.run({ tag: 'A', dataOnly: true }); + queue.run({ tag: 'B', dataOnly: true }); + held[0].resolve(); + await flushAsync(); + queue.run({ tag: 'E', dataOnly: true }); + await flushAsync(); + expect(record).toEqual(['start:A', 'start:B']); + + held[1].resolve(); + await flushAsync(); + expect(record).toEqual(['start:A', 'start:B', 'start:E']); + }); +}); diff --git a/src/ansys/visor/visor-client/src/jest-tests/WasmUpdateOptions.test.ts b/src/ansys/visor/visor-client/src/jest-tests/WasmUpdateOptions.test.ts new file mode 100644 index 00000000..afe99fae --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/WasmUpdateOptions.test.ts @@ -0,0 +1,21 @@ +import { WasmUpdateOptions } from '../wasm/WasmUpdateOptions'; + +/** + * The server's update options decide whether an update rebuilds the frontend. + * + * The key is written here as a literal, never read from the class under test. + */ + +describe('WasmUpdateOptions', () => { + test('an object whose dataOnly is true does not rebuild', () => { + expect(WasmUpdateOptions.parse({ dataOnly: true }).rebuildsFrontend).toBe(false); + }); + + test('an empty object rebuilds', () => { + expect(WasmUpdateOptions.parse({}).rebuildsFrontend).toBe(true); + }); + + test('the load-time true rebuilds', () => { + expect(WasmUpdateOptions.parse(true).rebuildsFrontend).toBe(true); + }); +}); diff --git a/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js b/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js index 6d548b6b..5b1048ca 100644 --- a/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js +++ b/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js @@ -1,4 +1,5 @@ import { VtkWASMHandler } from '@kitware/vtk-wasm/remote'; +import { SerialUpdateQueue } from './SerialUpdateQueue'; import TrameInterface from './TrameInterface.js'; import VtkScene from './VtkScene.js'; import { VtkWasmPerfCounter } from './VtkWasmPerfCounters.js'; @@ -334,14 +335,23 @@ export default class RemoteVtkScene { return object; }; /** - * @param {boolean} [bindCanvas=false] + * Apply a server update through the serial queue; does nothing until the wasm module is loaded. + * @param {unknown} bindCanvas - the server's update options, or `true` at load; passed to + * `VtkWASMHandler.update` unchanged * @return {Promise} */ updateAsync = async (bindCanvas) => { if (!this.#wasmHandler.loaded) { return; } - + await this.#updates.run(bindCanvas); + }; + /** + * Pull and apply one server update, free cached memory, resize, and report perf. + * @param {unknown} bindCanvas + * @return {Promise} + */ + #applyUpdateAsync = async (bindCanvas) => { let t0 = 0; let t1 = 0; @@ -370,11 +380,24 @@ export default class RemoteVtkScene { ); this.trameTriggerAsync('perf_report_wasm', report).catch(() => {}); } - + }; + /** + * Call every server-updated listener once. + * @return {void} + */ + #fireServerUpdated = () => { for (const callback of this.#serverUpdatedListeners.values()) { callback(); } }; + /** + * Serial queue every server update runs through. + * @type{SerialUpdateQueue} + */ + #updates = new SerialUpdateQueue( + (bindCanvas) => this.#applyUpdateAsync(bindCanvas), + () => this.#fireServerUpdated() + ); /** * @param {string} name * @param {function(...any):any} f diff --git a/src/ansys/visor/visor-client/src/wasm/SerialUpdateQueue.ts b/src/ansys/visor/visor-client/src/wasm/SerialUpdateQueue.ts new file mode 100644 index 00000000..f74c875f --- /dev/null +++ b/src/ansys/visor/visor-client/src/wasm/SerialUpdateQueue.ts @@ -0,0 +1,106 @@ +import { WasmUpdateOptions } from './WasmUpdateOptions'; + +/** One update to apply: the latest arrival's options, the OR of every arrival's rebuild flag, one promise for all callers. */ +class PendingUpdate { + #bindCanvas: unknown; + #rebuildsFrontend: boolean; + readonly #resolve: () => void; + readonly #reject: (reason: unknown) => void; + + /** The promise every caller merged into this update receives. */ + readonly promise: Promise; + + /** A pending update holding its first arrival. */ + constructor(bindCanvas: unknown) { + this.#bindCanvas = bindCanvas; + this.#rebuildsFrontend = WasmUpdateOptions.parse(bindCanvas).rebuildsFrontend; + let resolve!: () => void; + let reject!: (reason: unknown) => void; + this.promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + this.#resolve = resolve; + this.#reject = reject; + } + + /** The latest arrival's options. */ + get bindCanvas(): unknown { + return this.#bindCanvas; + } + + /** True when any merged arrival was structural. */ + get rebuildsFrontend(): boolean { + return this.#rebuildsFrontend; + } + + /** Keep this arrival's options and OR in its rebuild flag. */ + merge(bindCanvas: unknown): void { + this.#bindCanvas = bindCanvas; + this.#rebuildsFrontend ||= WasmUpdateOptions.parse(bindCanvas).rebuildsFrontend; + } + + /** Settle every merged caller's promise as applied settles. */ + settleWith(applied: Promise): void { + applied.then(this.#resolve, this.#reject); + } +} + +/** Applies server updates one at a time; updates arriving while one runs merge into a single pending update. */ +export class SerialUpdateQueue { + readonly #applyWasmAsync: (bindCanvas: unknown) => Promise; + readonly #rebuild: () => void; + #running: Promise | null = null; + #pending: PendingUpdate | null = null; + + /** applyAsync applies one wasm update; rebuild fires the server-updated listeners once. */ + constructor(applyAsync: (bindCanvas: unknown) => Promise, rebuild: () => void) { + this.#applyWasmAsync = applyAsync; + this.#rebuild = rebuild; + } + + /** Starts at once on an idle queue; otherwise merges into the pending update; returns that update's promise. */ + run(bindCanvas: unknown): Promise { + if (this.#running === null) { + const update = new PendingUpdate(bindCanvas); + this.#start(update); + return update.promise; + } + if (this.#pending === null) { + this.#pending = new PendingUpdate(bindCanvas); + } else { + this.#pending.merge(bindCanvas); + } + return this.#pending.promise; + } + + /** Apply one update; rebuild once if its flag is set and the apply fulfilled. */ + async #applyAsync(update: PendingUpdate): Promise { + await this.#applyWasmAsync(update.bindCanvas); + if (update.rebuildsFrontend) { + this.#rebuild(); + } + } + + /** Begin applying an update and hold it as the running one. */ + #start(update: PendingUpdate): void { + const applied = this.#applyAsync(update); + update.settleWith(applied); + this.#track(applied); + } + + /** When the running update settles, fulfilled or rejected, start the pending one or go idle. */ + #track(running: Promise): void { + this.#running = running; + const next = () => { + const pending = this.#pending; + this.#pending = null; + if (pending === null) { + this.#running = null; + } else { + this.#start(pending); + } + }; + running.then(next, next); + } +} diff --git a/src/ansys/visor/visor-client/src/wasm/WasmUpdateOptions.ts b/src/ansys/visor/visor-client/src/wasm/WasmUpdateOptions.ts new file mode 100644 index 00000000..052598e7 --- /dev/null +++ b/src/ansys/visor/visor-client/src/wasm/WasmUpdateOptions.ts @@ -0,0 +1,21 @@ +/** How the client applies one server update: with or without rebuilding the frontend. */ +export class WasmUpdateOptions { + /** The update-option key that marks an update as data-only. */ + static readonly DATA_ONLY_KEY = 'dataOnly'; + + /** Whether the server-updated listeners fire once this update is applied. */ + readonly rebuildsFrontend: boolean; + + private constructor(rebuildsFrontend: boolean) { + this.rebuildsFrontend = rebuildsFrontend; + } + + /** The options an update's argument carries; only an object whose dataOnly is true skips the rebuild. */ + static parse(value: unknown): WasmUpdateOptions { + const dataOnly = + value != null && + typeof value === 'object' && + (value as Record)[WasmUpdateOptions.DATA_ONLY_KEY] === true; + return new WasmUpdateOptions(!dataOnly); + } +}