Skip to content
Merged
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
2 changes: 2 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ Limits must be positive safe integers no greater than `4294967295`.

A path is an array of object keys and array indexes. Use `[]` for the root.
Call `dispose()` when abandoning a scanner before completion.
Successful `finish()` releases the input buffer and unused patch capacity;
final values, state, and field queries remain available.

Chunks contain Unicode text and are encoded as UTF-8. Valid surrogate pairs
can span pushes; raw unpaired UTF-16 code units throw `INVALID_CHUNK` rather
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Release input buffers and unused patch capacity after scanner finalization,
reducing Wasm allocation when completed scanners remain referenced.
- Use OpenAI final arguments when deltas are missing or incomplete and suppress
repeated completion events while preserving explicit call ID reuse.

Expand Down
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ modes. Three alternating trials use fresh Node processes and explicit garbage
collection, with final values checked against the source JSON. Results are
written to `artifacts/final-value-memory.json`.

For scanner finalization changes, run `pnpm bench:finished-memory
/path/to/baseline/src/index.js`. It retains 32 finished scanners with 2,000 nested
records each, comparing 4,096-character chunks and single-chunk input in both
snapshot modes. Three alternating trials use fresh processes without warmups
and verify final values, field queries, and repeated operations. The results in
`artifacts/finished-scanner-memory.json` measure Wasm linear-memory high-water
allocation, not JavaScript heap or process RSS.

The standalone assistant-ui demo has its own locked dependencies. CI runs its
unit tests, production build, and browser tests against both the pinned npm
release and a freshly built tarball from the commit under test. The candidate
Expand Down
88 changes: 88 additions & 0 deletions benchmarks/finished-scanner-memory.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { cpus } from "node:os";
import { resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

const worker = async (entry, snapshots, requestedChunkSize) => {
const entryUrl = pathToFileURL(resolve(entry));
const api = await import(entryUrl);
const { createWasmParser, freeWasmParser } = await import(new URL("internal/wasm-runtime.js", entryUrl));
await api.prepareStreamfold();
const expected = { rows: Array.from({ length: 2000 }, (_, id) => ({
id, label: `item ${id}`, details: { category: "sample", score: 42, enabled: true }, tags: ["a", "b", "c"],
})) };
const text = JSON.stringify(expected);
const chunkSize = Math.min(requestedChunkSize, text.length);
const probe = createWasmParser({ maxBytes: 16 * 1024 * 1024, maxDepth: 128 });
const initialWasmBytes = probe.wasm.memory.buffer.byteLength;
const scanners = [];
try {
for (let id = 0; id < 32; id++) {
const scanner = api.createStructuredStream({ snapshots });
scanners.push(scanner);
for (let offset = 0; offset < text.length; offset += chunkSize)
scanner.push(text.slice(offset, offset + chunkSize));
assert.deepEqual(scanner.finish().partialValue, expected);
assert.equal(scanner.state.complete, true);
assert.equal(scanner.getFieldState(["rows", 100, "details", "score"]), "complete");
}
const wasmBytes = probe.wasm.memory.buffer.byteLength;
for (const scanner of scanners) {
assert.deepEqual(scanner.value, expected);
assert.equal(scanner.finish().partialValue, scanner.value);
assert.equal(scanner.push(" ").partialValue, scanner.value);
assert.equal(scanner.state.bytesSeen, Buffer.byteLength(text) + 1);
assert.equal(scanner.finish().complete, true);
}
return {
snapshots, chunkSize, scanners: scanners.length, rows: expected.rows.length,
payloadBytes: Buffer.byteLength(text), initialWasmBytes, wasmBytes,
};
} finally {
for (const scanner of scanners) scanner.dispose();
freeWasmParser(probe);
}
};

if (process.argv[2] === "--worker") {
console.log(JSON.stringify(await worker(process.argv[3], process.argv[4], Number(process.argv[5]))));
} else {
const baselineEntry = process.argv[2];
if (!baselineEntry) throw new Error("Pass the baseline package's src/index.js path");
const entries = {
baseline: resolve(baselineEntry),
current: fileURLToPath(new URL("../packages/core/src/index.js", import.meta.url)),
};
const measurements = [];
const results = [];
for (const snapshots of ["live", "immutable"]) {
for (const [delivery, chunkSize] of [["streamed", 4096], ["single-chunk", 1024 * 1024]]) {
for (let trial = 0; trial < 3; trial++) {
for (const implementation of trial % 2 ? ["current", "baseline"] : ["baseline", "current"]) {
const measurement = JSON.parse(execFileSync(process.execPath, [
fileURLToPath(import.meta.url), "--worker", entries[implementation], snapshots, String(chunkSize),
], { encoding: "utf8" }));
measurements.push({ implementation, delivery, trial, ...measurement });
}
}
const result = { snapshots, delivery };
for (const implementation of Object.keys(entries)) {
const samples = measurements
.filter((row) => row.snapshots === snapshots && row.delivery === delivery && row.implementation === implementation)
.map((row) => row.wasmBytes)
.sort((a, b) => a - b);
result[implementation] = { medianWasmBytes: samples[1], p95WasmBytes: samples[2] };
}
results.push(result);
console.log(JSON.stringify(result));
}
}
mkdirSync(new URL("../artifacts/", import.meta.url), { recursive: true });
writeFileSync(new URL("../artifacts/finished-scanner-memory.json", import.meta.url), JSON.stringify({
metric: "Wasm linear-memory high-water allocation, not JavaScript heap or process RSS",
environment: { node: process.version, platform: process.platform, arch: process.arch, cpu: cpus()[0].model },
entries, warmups: 0, trials: 3, results, measurements,
}, null, 2) + "\n");
}
1 change: 1 addition & 0 deletions crates/streamfold-core/src/partial.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ impl PartialValueParser {
offset: self.bytes_seen,
});
}
self.output.shrink_to_fit();
Ok(())
}

Expand Down
1 change: 1 addition & 0 deletions crates/streamfold-core/src/wasm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ pub extern "C" fn streamfold_parser_push(handle: usize, length: usize) -> u32 {
#[unsafe(no_mangle)]
pub extern "C" fn streamfold_parser_finish(handle: usize) -> u32 {
let parser = parser_mut(handle);
parser.input = Vec::new();
let result = parser.parser.finish();
parser.encode(result)
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"bench:snapshots": "node benchmarks/snapshots.mjs",
"bench:completion-memory": "node benchmarks/completion-memory.mjs",
"bench:final-value-memory": "node benchmarks/final-value-memory.mjs",
"bench:finished-memory": "node benchmarks/finished-scanner-memory.mjs",
"bench:sdk": "node benchmarks/sdk-adapters.mjs",
"bench:published": "node scripts/benchmark-published.mjs",
"dashboard": "node dashboard/server.mjs",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/internal/wasm-binary.js

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions packages/core/src/scanner-lifecycle.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import test from "node:test";
import { createStructuredStream } from "./index.js";

test("finished scanners release scratch space while their values stay reachable", () => {
for (const snapshots of ["live", "immutable"]) {
const output = execFileSync(process.execPath, ["--input-type=module", "-e", `
import assert from "node:assert/strict";
import { createStructuredStream } from ${JSON.stringify(new URL("./index.js", import.meta.url).href)};
import { createWasmParser, freeWasmParser } from ${JSON.stringify(new URL("./internal/wasm-runtime.js", import.meta.url).href)};
const expected = { rows: Array.from({ length: 2000 }, (_, id) => ({
id, label: "item " + id, details: { category: "sample", score: 42, enabled: true }, tags: ["a", "b", "c"],
})) };
const text = JSON.stringify(expected);
const probe = createWasmParser({ maxBytes: 16 * 1024 * 1024, maxDepth: 128 });
const scanners = [];
let firstPeak;
try {
for (let i = 0; i < 16; i++) {
const scanner = createStructuredStream({ snapshots: ${JSON.stringify(snapshots)} });
scanners.push(scanner);
scanner.push(text);
assert.deepEqual(scanner.finish().partialValue, expected);
firstPeak ??= probe.wasm.memory.buffer.byteLength;
}
const finalPeak = probe.wasm.memory.buffer.byteLength;
for (const scanner of scanners) {
assert.deepEqual(scanner.value, expected);
assert.equal(scanner.state.complete, true);
assert.equal(scanner.getFieldState(["rows", 100, "details", "score"]), "complete");
}
console.log(JSON.stringify({ firstPeak, finalPeak }));
} finally {
for (const scanner of scanners) scanner.dispose();
freeWasmParser(probe);
}
`], { encoding: "utf8", timeout: 30_000 });
const { firstPeak, finalPeak } = JSON.parse(output);
assert.ok(finalPeak <= firstPeak * 2 + 1024 * 1024,
`${snapshots}: Wasm allocation grew from ${firstPeak} to ${finalPeak} bytes for completed scanners`);
}
});

test("finalized scanners preserve patches, values, state, and repeated operations", () => {
for (const snapshots of ["live", "immutable"]) {
for (const text of ["42", "-0", "1e400", "null", '"é🚀"', '[1,{"ok":true}]']) {
const scanner = createStructuredStream({ snapshots });
try {
const split = Math.floor(text.length / 2);
scanner.push(text.slice(0, split));
scanner.push(text.slice(split));
const final = scanner.finish();
assert.deepEqual(final.partialValue, JSON.parse(text));
assert.equal(final.complete, true);
assert.equal(scanner.value, final.partialValue);
assert.equal(scanner.state.bytesSeen, Buffer.byteLength(text));
assert.equal(scanner.getFieldState([]), "complete");
if (typeof final.partialValue === "number")
assert.deepEqual(final.changes, [{ op: "complete", path: [] }]);
const repeated = scanner.finish();
assert.equal(repeated.partialValue, final.partialValue);
assert.deepEqual(repeated.changes, []);
assert.equal(scanner.push(" \n").partialValue, final.partialValue);
assert.equal(scanner.state.bytesSeen, Buffer.byteLength(text) + 2);
assert.equal(scanner.finish().complete, true);
assert.throws(() => scanner.push("x"), { code: "TRAILING_DATA" });
} finally {
scanner.dispose();
scanner.dispose();
}
}
}
});
Loading