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.
This commit is contained in:
Chris Tate
2026-09-28 08:25:40 -05:00
committed by GitHub
parent 7cf3bc715a
commit bd12146234
6 changed files with 45 additions and 2 deletions
+1 -1
View File
@@ -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.
+4
View File
@@ -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)`
@@ -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
@@ -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;
+1 -1
View File
@@ -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
@@ -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));