Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changelog.d/202.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Queue client-mode scene updates and skip the rebuild for data-only updates
123 changes: 123 additions & 0 deletions src/ansys/visor/visor-client/src/jest-tests/SerialUpdateQueue.test.ts
Original file line number Diff line number Diff line change
@@ -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:<tag>` 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<void>((resolve, reject) => held.push({ resolve, reject }));
},
() => {
record.push('rebuild');
}
);
return { record, held, queue };
}

const flushAsync = () => new Promise<void>((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']);
});
});
Original file line number Diff line number Diff line change
@@ -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);
});
});
29 changes: 26 additions & 3 deletions src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<void>}
*/
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<void>}
*/
#applyUpdateAsync = async (bindCanvas) => {
let t0 = 0;
let t1 = 0;

Expand Down Expand Up @@ -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
Expand Down
106 changes: 106 additions & 0 deletions src/ansys/visor/visor-client/src/wasm/SerialUpdateQueue.ts
Original file line number Diff line number Diff line change
@@ -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<void>;

/** 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<void>((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>): 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<void>;
readonly #rebuild: () => void;
#running: Promise<void> | null = null;
#pending: PendingUpdate | null = null;

/** applyAsync applies one wasm update; rebuild fires the server-updated listeners once. */
constructor(applyAsync: (bindCanvas: unknown) => Promise<void>, 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<void> {
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<void> {
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>): 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);
}
}
21 changes: 21 additions & 0 deletions src/ansys/visor/visor-client/src/wasm/WasmUpdateOptions.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)[WasmUpdateOptions.DATA_ONLY_KEY] === true;
return new WasmUpdateOptions(!dataOnly);
}
}
Loading