diff --git a/docs/js.md b/docs/js.md index d43df97..bab52b7 100644 --- a/docs/js.md +++ b/docs/js.md @@ -543,16 +543,17 @@ The flag only affects tracing in debug-enabled builds. ## Simple API -A preconfigured module instance exported as `simple`. +A preconfigured module instance exported as `simple({ maxSpans = 65536 } = {})`. -It is equivalent to calling `setup(...)` with all callbacks wired to collect parser spans automatically. +It is equivalent to calling `setup(...)` with all callbacks wired to collect parser spans automatically. `maxSpans` is a non-negative safe integer and limits retained spans per parser. Differences from a plain `setup()` instance: - `create()` enables all callbacks automatically (`CALLBACK_ACTIVE_ALL`). -- `destroy(parser)` also clears collected spans for that parser. +- `destroy(parser)` also clears collected spans and overflow state for that parser. - A `spans` object is exposed on the module, keyed by parser id. -- Every callback appends `[callbackType, offset, length]` to `spans[parser]`. +- Callbacks append `[callbackType, offset, length]` until `maxSpans` is reached. Further spans are dropped and `spanOverflow[parser]` becomes `true`. +- `drain(parser)` atomically returns `{ spans, truncated }` and resets that parser's collection. Drain long-lived parsers regularly; `truncated` reports whether spans were dropped since the previous drain. This API is useful when you want a minimal integration that records parser events without manually providing callback functions. @@ -570,7 +571,10 @@ const buffer = Buffer.from(milo.memory.buffer, ptr, message.length) buffer.set(message, 0) milo.parse(parser, ptr, message.length) -for (const [type, at, len] of milo.spans[parser]) { +const batch = milo.drain(parser) +if (batch.truncated) throw new Error('span limit reached') + +for (const [type, at, len] of batch.spans) { console.log(`[${at.toString().padStart(3, ' ')}, ${len.toString().padStart(3, ' ')}] -> ${milo.Callbacks[type]}`) } diff --git a/parser/wasm/src/template.js b/parser/wasm/src/template.js index 8c3d066..ce34de9 100644 --- a/parser/wasm/src/template.js +++ b/parser/wasm/src/template.js @@ -50,18 +50,42 @@ function hasDebug () { /* REPLACE: setters */ -function simpleCreate (spans, create) { +// The postbuild step injects generated callback references. +// eslint-disable-next-line no-unused-vars +function collectSpan (spans, spanOverflow, maxSpans, parser, span) { + if (spans[parser].length < maxSpans) { + spans[parser].push(span) + } else { + spanOverflow[parser] = true + } +} + +function simpleCreate (spans, spanOverflow, create) { const parser = create() spans[parser] = [] + spanOverflow[parser] = false this.setActiveCallbacks(parser, this.CALLBACK_ACTIVE_ALL) return parser } -function simpleDestroy (spans, destroy, parser) { - spans[parser] = undefined +function simpleDestroy (spans, spanOverflow, destroy, parser) { + delete spans[parser] + delete spanOverflow[parser] destroy(parser) } +function simpleDrain (spans, spanOverflow, parser) { + const drained = spans[parser] ?? [] + const truncated = spanOverflow[parser] ?? false + + if (spans[parser] !== undefined) { + spans[parser] = [] + spanOverflow[parser] = false + } + + return { spans: drained, truncated } +} + export function noop () {} export function setup (env = {}) { @@ -116,14 +140,25 @@ export function setup (env = {}) { return milo } -export function simple () { - const spans = {} +export function simple ({ maxSpans = 65536 } = {}) { + if (!Number.isSafeInteger(maxSpans) || maxSpans < 0) { + throw new RangeError('maxSpans must be a non-negative safe integer') + } - const milo = setup({/* REPLACE: callbacks:simple */}) + const spans = Object.create(null) + const spanOverflow = Object.create(null) + + /* prettier-ignore */ + const milo = setup({ + /* REPLACE: callbacks:simple */ + }) milo.spans = spans - milo.create = simpleCreate.bind(milo, spans, milo.create) - milo.destroy = simpleDestroy.bind(milo, spans, milo.destroy) + milo.spanOverflow = spanOverflow + milo.maxSpans = maxSpans + milo.create = simpleCreate.bind(milo, spans, spanOverflow, milo.create) + milo.destroy = simpleDestroy.bind(milo, spans, spanOverflow, milo.destroy) + milo.drain = simpleDrain.bind(undefined, spans, spanOverflow) return milo } diff --git a/parser/wasm/test/helpers.js b/parser/wasm/test/helpers.js index 97a8877..f792b78 100644 --- a/parser/wasm/test/helpers.js +++ b/parser/wasm/test/helpers.js @@ -1,6 +1,8 @@ // Exercise the generated packages, including their JavaScript bindings. process.env.MILO_VARIANT ??= 'simd' -export const { setup } = await import(`../../../dist/wasm/release/package/src/${process.env.MILO_VARIANT}/index.js`) +export const { setup, simple } = await import( + `../../../dist/wasm/release/package/src/${process.env.MILO_VARIANT}/index.js` +) export function createParser (t, setup) { const headers = [] diff --git a/parser/wasm/test/simple.test.js b/parser/wasm/test/simple.test.js new file mode 100644 index 0000000..d131869 --- /dev/null +++ b/parser/wasm/test/simple.test.js @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import { it } from 'node:test' +import { simple } from './helpers.js' + +function parse (milo, parser, input) { + const bytes = Buffer.from(input) + const ptr = milo.alloc(bytes.length) + try { + new Uint8Array(milo.memory.buffer, ptr, bytes.length).set(bytes) + return milo.parse(parser, ptr, bytes.length) + } finally { + milo.dealloc(ptr, bytes.length) + } +} + +it('simple_span_collection_is_bounded_and_drainable', () => { + assert.throws(() => simple({ maxSpans: -1 }), RangeError) + assert.throws(() => simple({ maxSpans: 1.5 }), RangeError) + + const milo = simple({ maxSpans: 2 }) + const parser = milo.create() + + assert.equal(parse(milo, parser, 'GET / HTTP/1.1\r\n\r\n'), 18) + assert.equal(milo.spans[parser].length, 2) + assert.equal(milo.spanOverflow[parser], true) + + const first = milo.drain(parser) + assert.equal(first.spans.length, 2) + assert.equal(first.truncated, true) + assert.deepEqual(milo.spans[parser], []) + assert.equal(milo.spanOverflow[parser], false) + + assert.equal(parse(milo, parser, 'GET / HTTP/1.1\r\n\r\n'), 18) + const second = milo.drain(parser) + assert.equal(second.spans.length, 2) + assert.equal(second.truncated, true) + + milo.destroy(parser) + assert.equal(parser in milo.spans, false) + assert.equal(parser in milo.spanOverflow, false) +}) diff --git a/scripts/src/bin/postbuild-wasm.rs b/scripts/src/bin/postbuild-wasm.rs index 33a2482..adc33f5 100644 --- a/scripts/src/bin/postbuild-wasm.rs +++ b/scripts/src/bin/postbuild-wasm.rs @@ -209,7 +209,9 @@ fn generate_simple_callbacks(info: &BuildInfo) -> String { .iter() .map(|(key, value)| { let name = key.replacen("CALLBACK_", "", 1).to_lowercase(); - format!("{name} (parser, at, len) {{\n spans[parser].push([{value}, at, len])\n}}") + format!( + "{name} (parser, at, len) {{\n collectSpan(spans, spanOverflow, maxSpans, parser, [{value}, at, len])\n}}" + ) }) .collect::>() .join(",\n")