From bd121462341159a7b060c534f43ae40d4e13ee4f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 28 Sep 2026 08:25:40 -0500 Subject: [PATCH] Recognize Buffer chunks in untyped native callbacks - Compile Buffer.isBuffer checks on untyped values using the native Buffer flag. - Distinguish stream Buffers from plain byte arrays and document existing brand loss at typed boundaries. --- docs/src/app/limitations/page.mdx | 2 +- packages/compiler/src/backend/c/exprs.ts | 4 ++++ .../compiler/src/backend/llvm/expr-dynamic.ts | 10 ++++++++ .../src/frontend/lowering/containers/bytes.ts | 6 +++++ packages/compiler/src/ir/ir.ts | 2 +- tests/corpus/1699-stream-callback-shapes.ts | 23 +++++++++++++++++++ 6 files changed, 45 insertions(+), 2 deletions(-) diff --git a/docs/src/app/limitations/page.mdx b/docs/src/app/limitations/page.mdx index c97754c2..c2b63413 100644 --- a/docs/src/app/limitations/page.mdx +++ b/docs/src/app/limitations/page.mdx @@ -72,7 +72,7 @@ A static-tier program otherwise produces byte-identical stdout and the same exit **Runtime traps are not catchable.** User `throw` is fully catchable, and runtime failures Node models as exceptions (JSON parse errors, checked-cast failures, fs errors, regex errors) throw real error objects. Remaining hard traps, including typed-array bounds violations, abort the process. -**JSON callbacks have a native subset.** Function replacers and two-argument revivers run statically, including nested replacements, object-property deletion, and thrown exceptions. A replacer that omits the root returns `undefined`. Replacer property lists, reviver source contexts, and computed indentation remain unsupported; a reviver that deletes an array element throws because checked-dynamic arrays cannot represent holes. Callback values use the checked-dynamic boundary: typed records and arrays become snapshots, so callback mutations do not update the original typed containers, record fields retain declaration order, and typed Buffer values lose their Buffer brand when converted to bytes. JavaScript callbacks receive the holder as `this`; TypeScript callbacks that access a dynamic `this` remain unsupported. +**JSON callbacks have a native subset.** Function replacers and two-argument revivers run statically, including nested replacements, object-property deletion, and thrown exceptions. A replacer that omits the root returns `undefined`. Replacer property lists, reviver source contexts, and computed indentation remain unsupported; a reviver that deletes an array element throws because checked-dynamic arrays cannot represent holes. Callback values use the checked-dynamic boundary: typed records and arrays become snapshots, so callback mutations do not update the original typed containers, record fields retain declaration order, and typed Buffer values lose their Buffer brand when converted to bytes. `Buffer.isBuffer` observes that brand loss when typed Buffers cross into `unknown`; untyped stream chunks retain their Buffer brand. JavaScript callbacks receive the holder as `this`; TypeScript callbacks that access a dynamic `this` remain unsupported. **A lying cast on dynamic data throws instead of corrupting memory** — the headline divergence, and the point. `JSON.parse(s) as Config` with mismatched data throws a catchable error naming the offending path (`expected number at $.port, got string`) where JS would silently hand you garbage. diff --git a/packages/compiler/src/backend/c/exprs.ts b/packages/compiler/src/backend/c/exprs.ts index 4020fb75..b617655b 100644 --- a/packages/compiler/src/backend/c/exprs.ts +++ b/packages/compiler/src/backend/c/exprs.ts @@ -3231,6 +3231,10 @@ function emitDynamicExpr( // kind, so the calls stay unconditional); narrowing never changes // representation (SEMANTICS.md). const d = emitter.emitExpr(e.value); + if (e.test === "buffer") { + const test = `(${d.name}->kind == SCR_DYN_BYTES && ${d.name}->buffer)`; + return emitter.newTemp(e.type, e.negated ? `!${test}` : test); + } const test = e.test === "nullish" ? `(${d.name}->kind == SCR_DYN_UNDEF || ${d.name}->kind == SCR_DYN_NULL)` diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 354165fe..a00c7160 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -598,6 +598,16 @@ export function emitDynamicExpr(host: LlvmEmitterContext, e: ExprOf<"dynFrom" | }; if (e.test === "nullish") { test = oneOf([DYN_KIND.UNDEF, DYN_KIND.NULL]); + } else if (e.test === "buffer") { + const bytes = oneOf([DYN_KIND.BYTES]); + const flagPtr = B.tmp(); + const flag = B.tmp(); + const buffer = B.tmp(); + B.line(`${flagPtr} = getelementptr inbounds i8, ptr ${d.name}, i64 ${host.abiOffset(12, 8)} ; ->buffer`); + B.line(`${flag} = load i8, ptr ${flagPtr}`); + B.line(`${buffer} = icmp ne i8 ${flag}, 0`); + test = B.tmp(); + B.line(`${test} = and i1 ${bytes}, ${buffer}`); } else if (e.test === "object") { // `typeof v === "object"`: objects, arrays, bytes, native // handles, promises, AND null — engine-held objects by the diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index 000b6d80..8affc4ad 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -1147,6 +1147,12 @@ export function lowerBufferStaticCall(lowerer: Lowerer, call: ts.CallExpression, loc, }; } + // Untyped stream chunks retain the native checked value's Buffer + // flavor. Plain bytes are Uint8Arrays at this boundary; inspecting + // only the bytes tag would incorrectly identify them as Buffers. + if (v.type.kind === "dyn") { + return { kind: "dynTest", test: "buffer", value: v, type: BOOL, loc }; + } if (v.type.kind === "union") { const def = lowerer.unions.get(v.type.unionId); const tag = def ? def.arms.findIndex((a) => a.kind === "bytes" && a.elem === "u8") : -1; diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 916b902d..8ede11fc 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -5313,7 +5313,7 @@ export type IrExpr = * "function"` — true exactly for the checked-dynamic tree's function kind (boxed * closures); function values are truthy and answer FALSE to the * `"object"` test, JS-exact. */ - | { kind: "dynTest"; test: "string" | "number" | "boolean" | "undefined" | "null" | "nullish" | "bytes" | "object" | "array" | "truthy" | "error" | "function"; negated?: true; value: IrExpr; type: IrType; loc: SrcLoc } + | { kind: "dynTest"; test: "string" | "number" | "boolean" | "undefined" | "null" | "nullish" | "bytes" | "buffer" | "object" | "array" | "truthy" | "error" | "function"; negated?: true; value: IrExpr; type: IrType; loc: SrcLoc } /** Keyed read on a dyn value — `pkg.name` / `pkg["k"]` / the * `pkg?.scripts` chain step on a JSON.parse result. `key` is * string-typed (a strLit for the dot form); `type` is always dyn. An diff --git a/tests/corpus/1699-stream-callback-shapes.ts b/tests/corpus/1699-stream-callback-shapes.ts index 0333679e..0508cfcc 100644 --- a/tests/corpus/1699-stream-callback-shapes.ts +++ b/tests/corpus/1699-stream-callback-shapes.ts @@ -24,3 +24,26 @@ const tail: string | null = "tail-str"; w.write(tail !== null ? tail : Buffer.from("never")); w.end(() => console.log("finished, count:", count)); console.log("sync"); + +// Native untyped stream chunks retain their Buffer brand, while ordinary +// Uint8Arrays and structured clones have the same byte storage without it. +function describeBuffer(value: unknown): void { + console.log("buffer?", Buffer.isBuffer(value)); + if (Buffer.isBuffer(value)) console.log("buffer text", value.toString("utf8")); +} +describeBuffer(new Uint8Array([97, 98])); +describeBuffer("text"); +describeBuffer(null); +describeBuffer(undefined); +describeBuffer(5); +describeBuffer(false); +describeBuffer([1, 2]); +describeBuffer({ type: "Buffer", data: [1, 2] }); +const raw = new Readable({ read: () => { raw.push("bytes"); raw.push(null); } }); +raw.on("data", (chunk: unknown) => { + describeBuffer(chunk); + describeBuffer(structuredClone(chunk)); +}); +const encoded = new Readable({ read: () => { encoded.push("decoded"); encoded.push(null); } }); +encoded.setEncoding("utf8"); +encoded.on("data", (chunk: unknown) => describeBuffer(chunk));