diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index 2c07c8aa0e350d..b86948f2c4d64b 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -161,8 +161,26 @@ declare function $toPropertyKey(x: any): PropertyKey; * `$toObject(this, "Class.prototype.method requires that |this| not be null or undefined");` */ declare function $toObject(object: any, errorMessage?: string): object; +/** + * ## References + * - [WebKit - `emit_intrinsic_newArrayWithSize`](https://github.com/oven-sh/WebKit/blob/e1a802a2287edfe7f4046a9dd8307c8b59f5d816/Source/JavaScriptCore/bytecompiler/NodesCodegen.cpp#L2317) + */ declare function $newArrayWithSize(size: number): T[]; -declare function $newArrayWithSpecies(): TODO; +/** + * Optimized path for creating a new array storing objects with the same homogenous Structure + * as {@link array}. + * + * @param size the initial size of the new array + * @param array the array whose shape we want to copy + * + * @returns a new array + * + * ## References + * - [WebKit - `emit_intrinsic_newArrayWithSpecies`](https://github.com/oven-sh/WebKit/blob/e1a802a2287edfe7f4046a9dd8307c8b59f5d816/Source/JavaScriptCore/bytecompiler/NodesCodegen.cpp#L2328) + * - [WebKit - #4909](https://github.com/WebKit/WebKit/pull/4909) + * - [WebKit Bugzilla - Related Issue/Ticket](https://bugs.webkit.org/show_bug.cgi?id=245797) + */ +declare function $newArrayWithSpecies(size: number, array: T[]): T[]; declare function $newPromise(): TODO; declare function $createPromise(): TODO; declare const $iterationKindKey: TODO; diff --git a/src/js/builtins/StreamInternals.ts b/src/js/builtins/StreamInternals.ts index a81ff3ca6f7d47..73b85878b84718 100644 --- a/src/js/builtins/StreamInternals.ts +++ b/src/js/builtins/StreamInternals.ts @@ -89,8 +89,8 @@ export function validateAndNormalizeQueuingStrategy(size, highWaterMark) { $linkTimeConstant; export function createFIFO() { - const Denqueue = require("internal/fifo"); - return new Denqueue(); + const Dequeue = require("internal/fifo"); + return new Dequeue(); } export function newQueue() { diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index 893ff59006ff8f..fb9d0391e1680c 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -151,3 +151,4 @@ export const bindgen = $zig("bindgen_test.zig", "getBindgenTestFunctions") as { }; export const noOpForTesting = $cpp("NoOpForTesting.cpp", "createNoOpForTesting"); +export const Dequeue = require("internal/fifo"); diff --git a/src/js/internal/fifo.ts b/src/js/internal/fifo.ts index a7438aa3bdc698..c9ead667060e13 100644 --- a/src/js/internal/fifo.ts +++ b/src/js/internal/fifo.ts @@ -1,5 +1,10 @@ var slice = Array.prototype.slice; -class Denqueue { +class Dequeue { + _head: number; + _tail: number; + _capacityMask: number; + _list: (T | undefined)[]; + constructor() { this._head = 0; this._tail = 0; @@ -8,26 +13,21 @@ class Denqueue { this._list = $newArrayWithSize(4); } - _head; - _tail; - _capacityMask; - _list; - - size() { + size(): number { if (this._head === this._tail) return 0; if (this._head < this._tail) return this._tail - this._head; else return this._capacityMask + 1 - (this._head - this._tail); } - isEmpty() { + isEmpty(): boolean { return this.size() == 0; } - isNotEmpty() { + isNotEmpty(): boolean { return this.size() > 0; } - shift() { + shift(): T | undefined { var { _head: head, _tail, _list, _capacityMask } = this; if (head === _tail) return undefined; var item = _list[head]; @@ -37,24 +37,21 @@ class Denqueue { return item; } - peek() { + peek(): T | undefined { if (this._head === this._tail) return undefined; return this._list[this._head]; } - push(item) { + push(item: T): void { var tail = this._tail; $putByValDirect(this._list, tail, item); this._tail = (tail + 1) & this._capacityMask; if (this._tail === this._head) { this._growArray(); } - // if (this._capacity && this.size() > this._capacity) { - // this.shift(); - // } } - toArray(fullCopy) { + toArray(fullCopy: boolean): T[] { var list = this._list; var len = $toLength(list.length); @@ -66,19 +63,19 @@ class Denqueue { var j = 0; for (var i = _head; i < len; i++) $putByValDirect(array, j++, list[i]); for (var i = 0; i < _tail; i++) $putByValDirect(array, j++, list[i]); - return array; + return array as T[]; } else { return slice.$call(list, this._head, this._tail); } } - clear() { + clear(): void { this._head = 0; this._tail = 0; this._list.fill(undefined); } - _growArray() { + private _growArray(): void { if (this._head) { // copy existing data, head to end, then beginning to tail. this._list = this.toArray(true); @@ -92,10 +89,10 @@ class Denqueue { this._capacityMask = (this._capacityMask << 1) | 1; } - _shrinkArray() { + private _shrinkArray(): void { this._list.length >>>= 1; this._capacityMask >>>= 1; } } -export default Denqueue; +export default Dequeue; diff --git a/test/internal/fifo.test.ts b/test/internal/fifo.test.ts new file mode 100644 index 00000000000000..69ff6867ff9c33 --- /dev/null +++ b/test/internal/fifo.test.ts @@ -0,0 +1,235 @@ +import { Dequeue } from "bun:internal-for-testing"; +import { describe, expect, test, it, beforeAll, beforeEach } from "bun:test"; + +/** + * Implements the same API as {@link Dequeue} but uses a simple list as the + * backing store. + * + * Used to check expected behavior. + */ +class DequeueList { + private _list: T[]; + + constructor() { + this._list = []; + } + + size(): number { + return this._list.length; + } + + isEmpty(): boolean { + return this.size() == 0; + } + + isNotEmpty(): boolean { + return this.size() > 0; + } + + shift(): T | undefined { + return this._list.shift(); + } + + peek(): T | undefined { + return this._list[0]; + } + + push(item: T): void { + this._list.push(item); + } + + toArray(fullCopy: boolean): T[] { + return fullCopy ? this._list.slice() : this._list; + } + + clear(): void { + this._list = []; + } +} + +describe("Given an empty queue", () => { + let queue: Dequeue; + + beforeEach(() => { + queue = new Dequeue(); + }); + + it("has a size of 0", () => { + expect(queue.size()).toBe(0); + }); + + it("is empty", () => { + expect(queue.isEmpty()).toBe(true); + expect(queue.isNotEmpty()).toBe(false); + }); + + it("shift() returns undefined", () => { + expect(queue.shift()).toBe(undefined); + expect(queue.size()).toBe(0); + }); + + it("has an initial capacity of 4", () => { + expect(queue._list.length).toBe(4); + expect(queue._capacityMask).toBe(3); + }); + + it("toArray() returns an empty array", () => { + expect(queue.toArray()).toEqual([]); + }); + + describe("When an element is pushed", () => { + beforeEach(() => { + queue.push(42); + }); + + it("has a size of 1", () => { + expect(queue.size()).toBe(1); + }); + + it("can be peeked without removing it", () => { + expect(queue.peek()).toBe(42); + expect(queue.size()).toBe(1); + }); + + it("is not empty", () => { + expect(queue.isEmpty()).toBe(false); + expect(queue.isNotEmpty()).toBe(true); + }); + + it("can be shifted out", () => { + const el = queue.shift(); + expect(el).toBe(42); + expect(queue.size()).toBe(0); + expect(queue.isEmpty()).toBe(true); + }); + }); // +}); // + +describe("grow boundary conditions", () => { + describe.each([3, 4, 16])("when %d items are pushed", n => { + let queue: Dequeue; + + beforeEach(() => { + queue = new Dequeue(); + for (let i = 0; i < n; i++) { + queue.push(i); + } + }); + + it(`has a size of ${n}`, () => { + expect(queue.size()).toBe(n); + }); + + it("is not empty", () => { + expect(queue.isEmpty()).toBe(false); + expect(queue.isNotEmpty()).toBe(true); + }); + + it(`can shift() ${n} times`, () => { + for (let i = 0; i < n; i++) { + expect(queue.peek()).toBe(i); + expect(queue.shift()).toBe(i); + } + expect(queue.size()).toBe(0); + expect(queue.shift()).toBe(undefined); + }); + + it("toArray() returns [0..n-1]", () => { + // same as repeated push() but only allocates once + var expected = new Array(n); + for (let i = 0; i < n; i++) { + expected[i] = i; + } + expect(queue.toArray()).toEqual(expected); + }); + }); +}); // + +describe("adding and removing items", () => { + let queue: Dequeue; + let expected: DequeueList; + + describe("when 10k items are pushed", () => { + beforeEach(() => { + queue = new Dequeue(); + expected = new DequeueList(); + + for (let i = 0; i < 10_000; i++) { + queue.push(i); + expected.push(i); + } + }); + + it("has a size of 10000", () => { + expect(queue.size()).toBe(10_000); + expect(expected.size()).toBe(10_000); + }); + + describe("when 10 items are shifted", () => { + beforeEach(() => { + for (let i = 0; i < 10; i++) { + expect(queue.shift()).toBe(expected.shift()); + } + }); + + it("has a size of 9990", () => { + expect(queue.size()).toBe(9990); + expect(expected.size()).toBe(9990); + }); + }); + + describe("when 1k items are pushed, then removed", () => { + beforeEach(() => { + for (let i = 0; i < 1_000; i++) { + queue.push(i); + expected.push(i); + } + expect(queue.size()).toBe(1_000); + + while (queue.isNotEmpty()) { + expect(queue.shift()).toBe(expected.shift()); + } + it("when new items are added, the backing list is resized", () => { + for (let i = 0; i < 10_000; i++) { + queue.push(i); + expected.push(i); + expect(queue.size()).toBe(expected.size()); + expect(queue.peek()).toBe(expected.peek()); + expect(queue.isEmpty()).toBeFalse(); + expect(queue.isNotEmpty()).toBeTrue(); + } + }); + }); + }); + }); // + + it("pushing and shifting a lot of items affects the size and backing list correctly", () => { + queue = new Dequeue(); + expected = new DequeueList(); + + for (let i = 0; i < 15_000; i++) { + queue.push(i); + expected.push(i); + expect(queue.size()).toBe(expected.size()); + expect(queue.peek()).toBe(expected.peek()); + expect(queue.isEmpty()).toBeFalse(); + expect(queue.isNotEmpty()).toBeTrue(); + } + + // shift() shrinks the backing array when tail > 10,000 and the list is + // shrunk too far (tail <= list.length >>> 2) + for (let i = 0; i < 10_000; i++) { + expect(queue.shift()).toBe(expected.shift()); + expect(queue.size()).toBe(expected.size()); + } + + for (let i = 0; i < 5_000; i++) { + queue.push(i); + expected.push(i); + expect(queue.size()).toBe(expected.size()); + expect(queue.peek()).toBe(expected.peek()); + expect(queue.isEmpty()).toBeFalse(); + expect(queue.isNotEmpty()).toBeTrue(); + } + }); // +});