Skip to content

Compile process.send presence, process.exitCode reads, wider stream hooks, void race entries and promisify of callbacks - #1429

Merged
ctate merged 10 commits into
mainfrom
opt/bc-node-surface
Oct 20, 2026
Merged

ctate merged 10 commits into
mainfrom
opt/bc-node-surface

Conversation

@cramforce

@cramforce cramforce commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

A set of small Node surface gaps that commonly sit on a service's startup path. Each one was a compile refusal; all now compile, and the forms that still cannot work keep a named diagnostic.

process.send presence

const sink = process.send ? "ipc" : "stdout";
if (process.send) process.send({ hello: 1 });

Node defines process.send and process.disconnect only when the process has an IPC channel. A read whose value only feeds a truthiness test (a condition, !, Boolean(...), an operand of &&/||) and typeof process.send now answer that presence: a channel this fork child opened, or a non-empty NODE_CHANNEL_FD from a Node.js parent, the variable Node itself tests at startup. The test is built from the existing environment read (plus scr_process_ipc_open in programs with fork targets), so it links no new runtime code. Other value uses of process.send keep the SC2020 fence.

In a program without fork targets this binary never opens a channel, so:

  • process.send?.(...) skips its arguments and answers undefined without a channel, like Node;
  • process.send(...) / process.send!(...) evaluate their arguments and throw Node's TypeError: process.send is not a function;
  • with a Node.js parent's channel, which has no lowering, a call throws an SC2020 runtime fence instead of silently dropping the message. The message type is not checked there, since it is never sent.

Programs with fork targets keep their existing send lowering.

process.exitCode reads

process.exitCode now reads undefined until something assigns it, then the assigned code. process.exit(code) assigns it before the 'exit' listeners run, and process.exit() without an argument keeps it. To tell an unset code from 0, assignments must be recorded, so only programs that read the property use the recording writer (process.setExitCodeTracked); all other programs keep the untracked write and compile to identical code.

Stream hooks that declare a supertype

class LineSink extends Writable {
  _write(chunk: Buffer | string, encoding: BufferEncoding, cb: (err?: Error | null) => void) { ... }
}

A stream subclass hook may now declare a supertype of what Node passes (a union holding Node's argument type). The wrapper takes Node's Buffer and widens it into the declared parameter. This covers _write, _transform, _final, _destroy and _read. _writev has no lowering at all and stays fenced, and the option-callback form (new Writable({ write(chunk: Buffer | string) {} })) keeps the exact-type rule.

Stream classes declared by @types/node

class X extends Writable (and Readable, Duplex, Transform) from node:stream compiled only against the bundled declarations. With @types/node in the project, which nests the classes in the module's internal namespace, the base was refused as "extending classes not declared in the program". The recognition now accepts either declaration of the stream module's classes. A new node-types fixture runs a Writable with a widened chunk, a Transform and an erroring Writable under the pinned @types/node.

Promise.race with a Promise entry

Promise.race([poll(), exited()]) over Promise<T | undefined> and Promise<void> was refused because the checker folds void into the result's undefined arm. A Promise<void> entry now fulfills the race with that arm (a new branch in the race adapter). Promise.all, Promise.any and Promise.allSettled have a different, stricter restriction (one shared promise type; a heterogeneous array literal is the tuple overload) and are unchanged.

http.RequestOptions as a declared type

http.RequestOptions and https.RequestOptions as declared types ride checked-dynamic storage, like SpawnOptions. http.request(options) with a non-literal options value still fences (the options must be an object literal), as before.

util.promisify over typed callback functions

const closeAsync = promisify(client.close.bind(client));
await closeAsync();

util.promisify(fn) over a typed callback-last function value (a bound method, a function declaration, an arrow) now builds a native promisified function: a newPromise whose executor calls fn with the arguments and a callback. A truthy error rejects, the value fulfills, and a synchronous throw rejects, as in Node. An optional callback parameter (close(callback?: (error?: Error) => void)) works too: the call passes the callback into the union. The callback's error parameter must be a union of an Error class and null/undefined; other callback shapes keep the "util.promisify of this target" refusal, whose hint now names this form.

The bundled Node declarations had no generic promisify overloads, so a callback-only function matched crypto.randomFill's overload and reported "Expected 1-3 arguments, but got 0". They now carry @types/node's two callback-only overloads, placed before the targets that would capture them. The overloads with leading arguments cannot be added: TypeScript's subtype pass then prefers them over the builtin targets (promisify(execFile) would lose its result type), so those forms type-check only with @types/node. The node-types fixture covers them.

__dirname and __filename in TypeScript ES modules

A .ts/.tsx file with import syntax is an ES module under Node's strip-types rule, which has no __dirname/__filename; scriptc refused them there. TypeScript projects are commonly built as CommonJS bundles (tsc, esbuild, tsx) where both exist, and @types/node declares them for every file. They now compile in such files to the module's source path, the same compile-time constants as in CommonJS and the same strings as import.meta.dirname/import.meta.filename. This is on by default and needs no flag: the only programs that observe a difference are ones that rely on the ReferenceError or on typeof __dirname === "undefined", which TypeScript projects targeting a CommonJS build do not. JavaScript ES modules keep the fence. The limitations page documents the difference.

Evidence

Corpus programs (Node and native, both lanes):

  • 6420-process-send-presence.ts: every truthiness position, typeof, ?. without a channel, the direct call's TypeError.
  • 6421-process-send-presence-fork/: presence in a fork child and its parent, the guarded send.
  • 6422-process-exitcode-read.ts: unset, 0, assignment, ?? default, invalid code (RangeError keeps the old code), process.exit(7) seen by the exit listener (exit 7).
  • 6423-process-exitcode-default.ts: the exit status follows exitCode on a natural exit (exit 2).
  • 6424-process-exit-keeps-exitcode.ts: process.exit() keeps exitCode (exit 5).
  • 6425-stream-hook-wider-chunk.ts: Writable/Transform hooks declaring Buffer | string, _final, an erroring _write.
  • 6426-promise-race-void-entry.ts: either entry first, an already settled void entry, number | void, a rejection.
  • 6427-http-request-options-type.ts: declared options with spread and a typed parameter.
  • 6428-promisify-callback-functions.ts: bound methods (one with an optional callback), a declaration, an arrow, rejection, a synchronous throw, asynchronous settlement order.
  • 6429-esm-js-dirname.mjs: a JavaScript ES module still has no __dirname.

Harness:

  • tests/harness/esm-path-globals.test.ts: __dirname/__filename in a two-module TypeScript ESM program; the oracle runs a twin with import.meta.dirname/import.meta.filename at the same paths.
  • tests/harness/project-config.test.ts: promisify with leading arguments and stream subclasses under @types/node.
  • Diagnostics: boot-path-node-surface-fences.ts (a stored process.send, a non-supertype chunk, a promisify target with a non-Error callback).

Gates:

  • Zero-cost gate (scripts/zero-cost-gate.mjs --corpus=all): PASS on the final tree against main 4bbf31b: 3249 programs identical in IR, LLVM and release executables (bench workloads, the self-hosted compiler, every TypeScript and JavaScript corpus program); compile time bench geomean +0.14% [−1.03%, +1.17%], self-hosted +0.45% (one pair), both within tolerance.
  • Lint, format, test:ts7 --baselines-only (new baselines only; existing ones unchanged), pnpm manifest and pnpm node-compat (evidence rows only), node-compat:check.
  • Self-hosting IR build of packages/compiler/src/native/cli.ts: no diagnostics.

While building the self-hosting check, the first version of the promisify helper built its parameter lists with IrType[].map((type, i) => …). That made the self-hosted compiler type IrParam elements as possibly undefined program-wide, and an unrelated { ...param, owned: true } spread then failed. The helper now builds the lists with push. The root cause in the self-hosted flow analysis is worth a separate look.

🤖 Generated with Claude Code

cramforce and others added 3 commits October 19, 2026 16:46
…itCode reads, wider stream hooks, void race entries, RequestOptions and promisify of callbacks

- `if (process.send)`, `Boolean(process.send)`, `!process.send` and `typeof process.send` answer whether the process has an IPC channel (a fork channel this binary opened, or NODE_CHANNEL_FD from a Node.js parent). The same holds for process.disconnect.
- In a program without fork targets, `process.send?.(...)` answers undefined, and a direct call throws Node's TypeError when there is no channel. A Node.js parent's channel has no lowering, so a call fences when it runs. `process.send!(...)` lowers like the direct call.
- process.exitCode reads answer the assigned code, or undefined. Only programs that read it record assignments, including process.exit(code) before the 'exit' listeners run. Other programs keep the untracked write.
- A stream subclass hook may declare a supertype of Node's argument (`_write(chunk: Buffer | string, ...)`): the wrapper takes Node's Buffer and widens it.
- Promise.race accepts a Promise<void> entry when the result holds undefined.
- http.RequestOptions and https.RequestOptions as declared types ride checked-dynamic storage, like SpawnOptions.
- util.promisify over a typed callback-last function value (a bound method, a declaration, an arrow) builds a native promisified function. A truthy error rejects, the value fulfills, and a synchronous throw rejects.
- The bundled Node declarations gain the callback-only generic promisify overloads. Before this, `promisify(obj.close.bind(obj))` matched crypto.randomFill's overload and reported "Expected 1-3 arguments".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ules

A .ts or .tsx file with import syntax is an ES module under Node's strip-types rule, which has no __dirname or __filename. TypeScript projects are commonly shipped as CommonJS bundles where both exist, and @types/node declares them for every file. scriptc now compiles them in such files to the module's source path, the same compile-time constants as in CommonJS and the same strings as import.meta.dirname and import.meta.filename. JavaScript ES modules keep the fence. The limitations page documents the difference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A class that extends Writable, Readable, Duplex or Transform from node:stream compiled only against the bundled Node declarations. With @types/node in the project, which nests the classes in the module's `internal` namespace, the base was refused as "not declared in the program". The recognition now accepts either declaration of the stream module's classes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 20, 2026 7:10am UTC

Comment thread packages/compiler/src/frontend/lowering/builtins/promisify-callback.ts Outdated
Resolve the conflicts with main's process.features value, the WebCrypto dynamic types, the NODE_COMPAT_VERSION import and the symbol.declarations API. Renumber this branch's corpus programs to 6420-6429, write the new limitations partial one sentence per line, and regenerate the compatibility outputs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cramforce and others added 2 commits October 19, 2026 19:42
Main split lower-exprs.ts into expression families; the typeof and __dirname changes move to expressions/typeof.ts and expressions/identifiers.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`util.promisify(client.close.bind(client))` over `close(callback?: (error?: Error) => void)` was refused because the callback parameter is `callback | undefined`. The promisified function now takes the union's function arm and passes the callback into the union.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The read used to defer a refusal, which kept the corpus program from
compiling statically. It now throws Node's catchable ReferenceError
("__dirname is not defined"), and typeof answers "undefined" as in
Node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The promisified executor typed its call of the function as void, so a
function with a non-void return type failed to compile with SC9001. The
call now has the function's return type, and its value is discarded, as
in Node.
@ctate
ctate merged commit d5d9a11 into main Oct 20, 2026
61 of 64 checks passed
@ctate
ctate deleted the opt/bc-node-surface branch October 20, 2026 07:18

This branch was successfully deployed

1 active deployment
Preview — b6ef49d3 Deployed Oct 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants