blob: ee9e1653d2f2a1a3e985cb3cf8e09f3d2fd88b45 [file]
// Checks rive_shared_lane.mjs on a heap laid out by hand from
// rive_shared_layout.mjs, so it runs without librive: lookups against stale
// and retired ids, blob slices, snapshot readers racing publishers on other
// threads, sequences carrying into their high word, a lane past the table's
// lane words, and a page freeing what a lane it ended still held.
// node shared_lane_test.mjs
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';
import { fileURLToPath } from 'url';
import {
kBlobData,
kChunkSlots,
kHeaderCapacity,
kHeaderKind,
kHeaderLock,
kHoldLock,
kHoldToken,
kHoldWords,
kIdBit,
kIdSlotShift,
kKindBlob,
kKindRegion,
kKindRing,
kKindSignal,
kKindSnapshot,
kLaneCount,
kLaneFirstToken,
kLaneIdle,
kRecordHolds,
kRegionData,
kRingData,
kRingUsed,
kSignalSeq,
kSignalSeqHigh,
kRingWriteFull,
kRingWriteNeverFits,
kRingWriteWritten,
kSignalWatcherCount,
kSignalWatchers,
kSlotBlock,
kSlotLive,
kSlotOwner,
kSlotOwnerHigh,
kSlotSize,
kSlotState,
kSnapshotData,
kSnapshotCopies,
kSnapshotLatest,
kSnapshotSizes,
kSnapshotSeq,
kSnapshotSeqHigh,
kTableChunks,
kTableLanes,
kTableTokens,
} from '../../src/wasm/web/rive_shared_layout.mjs';
import { kProcessorHolds } from '../../src/wasm/web/rive_lane_imports.mjs';
import { createSharedLaneImports, releaseSharedLane } from '../../src/wasm/web/rive_shared_lane.mjs';
const kTable = 1024;
const kChunk = 8192;
const kBlocks = kChunk + kChunkSlots * kSlotSize;
// Past 32 bits, so both owner words count.
const kOwner = 2 ** 32 + 5;
// A lane over heap, with a module memory of moduleBytes for its arguments.
function laneOver(heap, moduleBytes, holds = null, fail = undefined, table = kTable) {
const memory = { buffer: new ArrayBuffer(moduleBytes) };
const imports = createSharedLaneImports(() => heap, table, kOwner, () => memory, holds, fail);
return { imports, bytes: new Uint8Array(memory.buffer) };
}
// A lane claims its word once per table, so a check of a fresh claim takes
// a table of its own.
function makeHeap(bytes, table = kTable) {
const heap = new SharedArrayBuffer(bytes);
new Uint32Array(heap)[(table + kTableChunks) >>> 2] = kChunk;
return { heap, u32: new Uint32Array(heap), next: kBlocks, slots: 0 };
}
// Adds a block as SharedRegistry::add would and answers its id.
function add(h, kind, blockSize, capacity, dataSize, owner = kOwner) {
const block = h.next;
h.next += (blockSize + dataSize + 7) & ~7;
h.u32[(block + kHeaderKind) >>> 2] = kind;
h.u32[(block + kHeaderCapacity) >>> 2] = capacity;
const index = h.slots++;
const slot = kChunk + index * kSlotSize;
h.u32[(slot + kSlotBlock) >>> 2] = block;
h.u32[(slot + kSlotOwner) >>> 2] = owner % 2 ** 32;
h.u32[(slot + kSlotOwnerHigh) >>> 2] = Math.floor(owner / 2 ** 32);
Atomics.store(h.u32, (slot + kSlotState) >>> 2, kSlotLive);
return { id: (kIdBit | (index << kIdSlotShift)) >>> 0, block, slot };
}
let failures = 0;
function check(ok, what) {
if (!ok) {
failures++;
console.log('FAIL ' + what);
}
}
async function main() {
const self = fileURLToPath(import.meta.url);
{
const h = makeHeap(1 << 20);
const holds = new Int32Array(new SharedArrayBuffer(kHoldWords * 4));
const { imports, bytes } = laneOver(h.heap, 4096, holds);
check(holds[kHoldToken] === kLaneFirstToken, 'the first lane claims the first token');
check(h.u32[(kTable + kTableLanes) >>> 2] === kLaneIdle, 'a claimed lane word is idle');
const ring = add(h, kKindRing, kRingData, 64, 64);
bytes.set([5, 6, 7], 16);
check(imports['ring_write'](ring.id, 16, 3, 0, 0) === kRingWriteWritten, 'a ring takes a record');
check(imports['ring_used'](ring.id) === 7, 'a record counts its length');
check(imports['ring_read'](ring.id, 64, 8) === 4, 'a read answers the size plus one');
check(bytes[66] === 7, 'a read copies the record out');
check(h.u32[(kTable + kTableLanes) >>> 2] === kLaneIdle, 'a call leaves nothing pinned');
check(h.u32[(ring.block + kHeaderLock) >>> 2] === 0, 'a call leaves nothing locked');
check(imports['ring_write'](ring.id, 16, 61, 0, 0) === kRingWriteNeverFits,
'a record larger than the ring never fits');
for (let i = 0; i < 6; i++) {
imports['ring_write'](ring.id, 16, 6, 0, 0);
}
check(imports['ring_write'](ring.id, 16, 6, 0, 0) === kRingWriteFull, 'a full ring refuses for now');
check(imports['ring_drop'](ring.id, 6) === 0, 'a drop leaves a record that fits');
check(imports['ring_drop'](ring.id, 4) === 7, 'a drop pops a record too large');
check(imports['ring_used'](ring.id) === 50, 'a dropped record frees its bytes');
imports['ring_drop'](ring.id, 0);
check(imports['ring_read'](ring.id, 64, 8) === 7, 'the record after a drop still reads');
while (imports['ring_drop'](ring.id, 0) !== 0) {}
check(imports['ring_used'](ring.id) === 0, 'drops empty the ring');
check(h.u32[(ring.block + kHeaderLock) >>> 2] === 0, 'a drop leaves nothing locked');
// A stale generation, a retired slot and another kind find nothing.
check(imports['ring_used']((ring.id + 1) >>> 0) === 0, 'a stale id finds nothing');
check(imports['snapshot_seq'](ring.id) === 0, 'an id of another kind finds nothing');
Atomics.and(h.u32, (ring.slot + kSlotState) >>> 2, ~kSlotLive);
check(imports['ring_used'](ring.id) === 0, 'a retired slot finds nothing');
const foreign = add(h, kKindRing, kRingData, 64, 64, kOwner + 2 ** 32);
check(imports['ring_write'](foreign.id, 16, 3, 0, 0) === kRingWriteFull, "another VM's ring takes nothing");
check(imports['ring_used'](foreign.id) === 0, "another VM's ring finds nothing");
check(h.u32[(kTable + kTableLanes) >>> 2] === kLaneIdle, 'a refused lookup leaves nothing pinned');
}
{
// Atomics answer -1 outside the region and once it is gone.
const h = makeHeap(1 << 20);
const { imports } = laneOver(h.heap, 64);
const region = add(h, kKindRegion, kRegionData, 8, 8);
check(imports['atomic_store'](region.id, 4, 7) === 0, 'a store in the region answers 0');
check(imports['atomic_add'](region.id, 4, 1) === 7, 'an add answers the value before it');
check(imports['atomic_cas'](region.id, 4, 8, 0xffffffff) === 8, 'a compare exchange answers the old value');
check(imports['atomic_load'](region.id, 4) === 0xffffffff, 'a load answers the word unsigned');
check(imports['atomic_load'](region.id, 8) === -1, 'a word past the region answers -1');
check(imports['atomic_store'](region.id, 2, 1) === -1, 'an unaligned word answers -1');
Atomics.and(h.u32, (region.slot + kSlotState) >>> 2, ~kSlotLive);
check(imports['atomic_cas'](region.id, 0, 0, 1) === -1, 'a gone region answers -1');
}
{
// As a processor's natives, the lane leaves records and values that
// name shared objects in place and fails, and refuses to send any.
const h = makeHeap(1 << 20);
const failures = [];
const { imports, bytes } = laneOver(h.heap, 4096, null, (text) => failures.push(text));
const refused = (what) => {
check(failures.length === 1 && failures[0] === kProcessorHolds, what + ' fails the lane');
failures.length = 0;
};
const ring = add(h, kKindRing, kRingData, 64, 64);
const record = new DataView(h.heap, ring.block + kRingData, 20);
record.setUint32(0, (3 + 8) | kRecordHolds, true);
new Uint8Array(h.heap).set([5, 6, 7], ring.block + kRingData + 4);
record.setUint32(7, 0x80001000, true);
record.setUint32(11, 1, true);
h.u32[(ring.block + kRingUsed) >>> 2] = 15;
check(imports['ring_read'](ring.id, 64, 8) === 0, 'a read of a held record answers nothing');
refused('a read of a held record');
check(imports['ring_read'](ring.id, 0, 0) === 0, 'a size query of a held record answers nothing');
refused('a size query of a held record');
check(imports['ring_used'](ring.id) === 15, 'a held record stays in the ring');
check(h.u32[(ring.block + kHeaderLock) >>> 2] === 0, 'a refused read leaves nothing locked');
h.u32[(ring.block + kRingUsed) >>> 2] = 0;
bytes.set([1, 2], 64);
check(imports['ring_write'](ring.id, 64, 2, 128, 1) === 0, 'a record naming shared objects is not sent');
refused('a send naming shared objects');
check(imports['ring_used'](ring.id) === 0, 'a refused send writes nothing');
check(imports['ring_write'](ring.id, 64, 2, 0, 0) === 1 && failures.length === 0, 'a plain record still sends');
const snapshot = add(h, kKindSnapshot, kSnapshotData, 32, 32 * kSnapshotCopies);
const value = new DataView(h.heap, snapshot.block + kSnapshotData, 32);
value.setUint32(0, 42, true);
value.setUint32(4, 0x80001000, true);
value.setUint32(8, 1, true);
h.u32[(snapshot.block + kSnapshotSizes) >>> 2] = (4 + 8) | kRecordHolds;
h.u32[(snapshot.block + kSnapshotLatest) >>> 2] = 1;
new DataView(bytes.buffer).setUint32(64, 7, true);
check(imports['snapshot_read'](snapshot.id, 64, 8) === 0, 'a read of a held value answers nothing');
refused('a read of a held value');
check(new DataView(bytes.buffer).getUint32(64, true) === 7, 'a refused read copies nothing');
check(imports['snapshot_publish'](snapshot.id, 64, 4, 0, 0) === 0, 'a publish over a held value is refused');
refused('a publish over a held value');
check(h.u32[(snapshot.block + kSnapshotLatest) >>> 2] === 1, 'a refused publish keeps the held value');
check(h.u32[(snapshot.block + kHeaderLock) >>> 2] === 0, 'a refused publish leaves nothing locked');
h.u32[(snapshot.block + kSnapshotSizes) >>> 2] = 4;
check(imports['snapshot_publish'](snapshot.id, 64, 4, 128, 1) === 0, 'a value naming shared objects is not published');
refused('a publish naming shared objects');
check(imports['snapshot_publish'](snapshot.id, 64, 40, 0, 0) === 0 && failures.length === 0,
'a value too large is refused without failing');
check(imports['snapshot_publish'](snapshot.id, 64, 4, 0, 0) === 1, 'a publish over a plain value lands');
check(imports['snapshot_read'](snapshot.id, 68, 8) === 5 && new DataView(bytes.buffer).getUint32(68, true) === 7,
'a reader sees the new value');
check(failures.length === 0, 'plain values never fail the lane');
}
{
const h = makeHeap(1 << 20);
const { imports, bytes } = laneOver(h.heap, 64);
const blob = add(h, kKindBlob, kBlobData, 10, 10);
new Uint8Array(h.heap).set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], blob.block + kBlobData);
check(imports['blob_size'](blob.id) === 10, 'a blob answers its size');
check(imports['blob_read'](blob.id, 2, 16, 4) === 4, 'a read fills its buffer');
check(bytes[16] === 2 && bytes[19] === 5, 'a read copies from its offset');
check(imports['blob_read'](blob.id, 8, 16, 4) === 2, 'a read near the end copies what is left');
check(bytes[17] === 9, 'the last bytes land first in the buffer');
check(imports['blob_read'](blob.id, 10, 16, 4) === 0, 'a read past the end copies nothing');
check(imports['blob_read'](blob.id, 0, 62, 4) === 0, 'a buffer outside the lane copies nothing');
check(imports['blob_size'](imports['blob_create'](16, 4)) === 0, 'a lane cannot create a blob');
check(imports['ring_used'](blob.id) === 0, 'a blob is no ring');
check(h.u32[(kTable + kTableLanes) >>> 2] === kLaneIdle, 'a blob read leaves nothing pinned');
}
{
// Readers on their own threads never see a mix of two publishes.
const h = makeHeap(1 << 20);
const words = 64;
const snapshot = add(
h,
kKindSnapshot,
kSnapshotData,
words * 4,
words * 4 * kSnapshotCopies
);
// Words: lanes ready, publishers done.
const control = new Int32Array(new SharedArrayBuffer(8));
const roles = ['publish', 'publish', 'read', 'read'];
const lanes = roles.length;
const publishers = roles.filter((role) => role === 'publish').length;
const spawn = (role, index) =>
new Promise((done) => {
const worker = new Worker(self, {
workerData: { role, index, heap: h.heap, id: snapshot.id, words, control, lanes, publishers },
});
worker.on('message', done);
});
const results = await Promise.all(roles.map((role, i) => spawn(role, i % 2)));
const reads = results.filter((r) => r.role === 'read');
check(reads.every((r) => r.torn === 0), 'no reader saw a torn value');
check(reads.every((r) => r.reads > 0), 'every reader saw values');
const published = results.reduce((sum, r) => sum + (r.published ?? 0), 0);
const low = h.u32[(snapshot.block + kSnapshotSeq) >>> 2];
const high = h.u32[(snapshot.block + kSnapshotSeqHigh) >>> 2];
check(high === 0 && low === published, 'every publish counts');
}
{
// A sequence carries into its high word, and reads as one number.
const h = makeHeap(1 << 20);
const { imports } = laneOver(h.heap, 64);
const signal = add(h, kKindSignal, 64, 0, 0);
h.u32[(signal.block + kSignalSeq) >>> 2] = 0xffffffff;
h.u32[(signal.block + kSignalSeqHigh) >>> 2] = 2;
check(imports['signal_seq'](signal.id) === 3 * 2 ** 32 - 1, 'a sequence reads both words');
imports['signal_notify'](signal.id);
check(h.u32[(signal.block + kSignalSeq) >>> 2] === 0, 'a notify wraps the low word');
check(imports['signal_seq'](signal.id) === 3 * 2 ** 32, 'and carries into the high word');
}
{
// Past the lane words a lane still takes a token of its own, whose lock
// the page frees, but no lane word.
const table = 4096;
const h = makeHeap(1 << 20, table);
const lanes = (table + kTableLanes) >>> 2;
h.u32.fill(kLaneIdle, lanes, lanes + kLaneCount);
const holds = new Int32Array(new SharedArrayBuffer(kHoldWords * 4));
laneOver(h.heap, 64, holds, undefined, table);
const token = Atomics.load(holds, kHoldToken);
check(token === kLaneFirstToken + kLaneCount, 'a lane past the lane words takes the next token');
check(h.u32[(table + kTableTokens) >>> 2] === 1, 'the table counts that token');
const ring = add(h, kKindRing, kRingData, 64, 64);
const lockAt = (ring.block + kHeaderLock) >>> 2;
Atomics.store(h.u32, lockAt, token);
Atomics.store(holds, kHoldLock, ring.block + kHeaderLock);
releaseSharedLane(() => h.heap, table, holds);
check(h.u32[lockAt] === 0, 'the page frees the lock of a lane past the lane words');
check(h.u32.subarray(lanes, lanes + kLaneCount).every((word) => word === kLaneIdle),
'and leaves every lane word alone');
}
{
// A lane ended while it notifies a signal leaves the lock and the slot
// pin behind, which the page frees and nothing else.
const watchers = 8 << 20;
const h = makeHeap(watchers * 4 + (1 << 20));
const signal = add(h, kKindSignal, 64, 0, 8 + watchers * 4);
const wake = signal.block + 64;
const list = wake + 8;
h.u32[(signal.block + kSignalWatcherCount) >>> 2] = watchers;
h.u32[(signal.block + kSignalWatchers) >>> 2] = list;
h.u32.fill(wake, list >>> 2, (list >>> 2) + watchers);
const holds = new Int32Array(new SharedArrayBuffer(kHoldWords * 4));
const worker = new Worker(self, {
workerData: { role: 'stall', heap: h.heap, id: signal.id, holds },
});
const lockAt = (signal.block + kHeaderLock) >>> 2;
const i32 = new Int32Array(h.heap);
while (Atomics.load(i32, lockAt) === 0) {}
await worker.terminate();
const token = Atomics.load(holds, kHoldToken);
check(token >= kLaneFirstToken, 'the lane records its token');
check(Atomics.load(i32, lockAt) === token, 'the ended lane still holds the lock');
const laneAt = ((kTable + kTableLanes) >>> 2) + token - kLaneFirstToken;
check(Atomics.load(i32, laneAt) === signal.slot, 'the ended lane still pins the slot');
check(Atomics.load(holds, kHoldLock) === signal.block + kHeaderLock, 'the lane records its lock');
releaseSharedLane(() => h.heap, kTable, holds);
check(Atomics.load(i32, lockAt) === 0, 'the page frees the lock');
check(Atomics.load(i32, laneAt) === 0, 'the page frees the lane word');
// A lock someone else took since is left alone.
Atomics.store(i32, lockAt, 1);
releaseSharedLane(() => h.heap, kTable, holds);
check(Atomics.load(i32, lockAt) === 1, 'the page keeps a lock it did not take');
}
console.log(failures === 0 ? 'shared lane ok' : failures + ' shared lane checks failed');
process.exit(failures === 0 ? 0 : 1);
}
function worker() {
const { role, heap } = workerData;
if (role === 'stall') {
laneOver(heap, 64, workerData.holds).imports['signal_notify'](workerData.id);
return;
}
const { words, control, lanes, publishers } = workerData;
// Address 0 means no buffer to the natives.
const at = 64;
const { imports, bytes } = laneOver(heap, at + words * 4);
const values = new Uint32Array(bytes.buffer, at, words);
// Every lane starts together, so reads race publishes however slowly
// the threads come up.
Atomics.add(control, 0, 1);
while (Atomics.load(control, 0) < lanes) {}
if (role === 'publish') {
let published = 0;
while (published < 20000) {
published++;
const word = published * 2 + workerData.index;
const count = 1 + (word % words);
values.fill(word, 0, count);
imports['snapshot_publish'](workerData.id, at, count * 4, 0, 0);
}
Atomics.add(control, 1, 1);
parentPort.postMessage({ role, published });
return;
}
let torn = 0;
let reads = 0;
// Past the last publish a read always finds a value.
while (Atomics.load(control, 1) < publishers || reads === 0) {
const size = imports['snapshot_read'](workerData.id, at, words * 4);
if (size === 0) {
continue;
}
reads++;
const count = (size - 1) / 4;
let whole = count === 1 + (values[0] % words);
for (let k = 0; k < count; k++) {
whole = whole && values[k] === values[0];
}
torn += whole ? 0 : 1;
}
parentPort.postMessage({ role, torn, reads });
}
if (isMainThread) {
await main();
} else {
worker();
}