An unchanged rebuild that kept its output still selected the runtime pack's
artifacts first, reading the stored selection, and computed the identity of
every selected artifact to open the executable cache.
- The executable cache key names the pack by its package.json text, the
manifest's identity (its text on darwin), and the link features and
flavor. Together they fix which artifacts the selection names.
- The cache selects the artifacts only when it needs their identities, in
restore() and trace(). keep() checks the recorded entry, which lists them.
- A build that does not keep or restore its executable selects the pack
before code generation, as before.
Linux executables linked through the clang driver with its default linker,
GNU ld. lld links the same objects in about half the time.
- Native and Node-hosted Linux exe links pass -fuse-ld=lld when ld.lld is
beside the driver's executable (where clang looks first) or on PATH, and
the default driver is in use (no SCRIPTC_LINKER, no sanitizer link).
- A link with lld that fails runs again with the driver's default linker,
whose error is the one reported when that fails too.
A native build reuses its stale frontend cache entry when every changed
source is token-equivalent to the text the entry was built from, but dev
builds never did: their debug information records source lines and
columns, which a token-equivalent edit can move.
Builds with debug information (dev builds without --strip) now reuse the
entry when every changed source is token-equivalent and every token keeps
its line and column (tokenPositionEquivalentSource): edits to a comment's
text within its line, comments and whitespace after the last token, and
trailing whitespace. Edits that move a token still rebuild.
The token scanner also reads a `/` after a non-null assertion as division
(`data[i]! / 7`). It used to read it as the start of a regular expression
and give up on the file, so comment edits in files with that pattern
always rebuilt. A `!` after a line break, or after an operator or a
keyword such as `return`, still starts a negated regular expression, and
a `!` after `)`, `}`, `++` or `--` gives up.
The native compiler forked four worker processes to emit the function
bodies of a large module. It now forks one per available core, up to
eight. SCRIPTC_EMIT_WORKERS still sets the count. The output is the same
text at any count.
Emitted functions report an exception by returning with the thread's
pending flag set, and every caller tests the flag after each call that
may throw. On Linux executables (glibc and musl), functions reached only
by direct calls now propagate exceptions by unwinding the native stack
instead:
- unwindAbiFunctions selects the may-throw functions that are not async
or generators, constructors, virtually called or overridden methods,
closure targets, the entry, library exports, module initializers or
callees of coroutine bodies.
- A native unwind costs about 2 µs a raise and 0.1 µs a frame, so functions that
exceptions are likely to cross keep the flag: those with a `throw` of
their own, those up to three calls above one, and recursive functions,
counting calls through methods, closures and callbacks.
- Their propagating exits call scr_unwind_raise (scr_unwind.c), a forced
unwind through _Unwind_ForcedUnwind with the exception still in the
active cell.
- Direct calls to them are `invoke`s without a pending check. The
landing pad runs the frame's usual unwind path: it releases the frame's
values and then enters the enclosing try handler or resumes the unwind.
- Remaining plain calls to them (wrappers, adapters, runtime-facing entry
points) become invokes whose landing pad continues on the pending flag,
or resumes when the wrapper is itself invoked. Every other call is
marked `nounwind`, so inlining a raising function at an invoke keeps its
calls plain.
- When emitted text breaks these rules (a raising function's address is
taken, or an invoked function returns with the flag pending), the
module is emitted again with those functions on the pending flag.
Emitted code does not declare scr_unwind_raise `noreturn`, so sanitized
builds do not run the sanitizer's no-return hook, which needs stack
bounds that fiber stacks do not report. Instead, scr_unwind_raise clears
the shadow of the frames it unwinds.
Executables link a static unwinder: libgcc_eh with the host toolchain and
the glibc runtime packs, and Zig's libunwind with Zig and the musl packs.
macOS, Windows, Android, WASI, libraries and debug builds keep the
pending flag everywhere and emit the same code as before.
The throw-chains runtime workload measures exceptions caught 2, 5 and 20
recursive calls above the throw, through direct calls and a callback,
and a parser that backtracks by throwing.
On Linux the executable cache stored an executable only from a key's second
link on, so the first unchanged rebuild after a build linked again, and only
the build after that restored the executable.
- A key's first link on Linux is traced, in the same verbose link. Instead
of storing the executable, the cache records the output's file identity
together with the link's inputs once the output is in place.
- keep() answers an unchanged rebuild whose output still has that identity
and whose inputs are unchanged. The build then neither links nor copies:
the output stays as it is.
- A rebuild whose output changed or is missing links again; from that second
link on, the executable is stored as before and restored where needed.
- The cache records the output after every Linux build that places one, so
the next unchanged rebuild keeps it.
darwin keeps its existing behavior.
Every native exe build that the executable cache does not restore hashed
each selected runtime artifact before staging it, and every exe build
parsed and validated the whole runtime-pack.json manifest to select its
artifacts.
- Staging still copies each artifact. It skips the SHA-256 of an artifact
when the file's identity (device, inode, size, mtime, ctime) is the one
recorded the last time that path matched the same size and digest, and
the identity does not change while the file is read. Other artifacts are
hashed as before, and their identities are recorded after they match.
- A pack's selection for a target, compiler version, feature set, flavor
and mode is stored, keyed by the file identities of package.json and
runtime-pack.json. A stored selection skips parsing and validating the
manifest; the manifest is parsed only when a caller needs it, through
runtimePackManifest.
- The native compiler keeps both in its cache, in the runtime-pack family,
which prune covers.
The native compiler's executable cache restored a linked executable only
on darwin. On Linux every exe build linked again, even when the frontend
and object caches answered the rest of the build.
- The cache now opens on Linux when the linker is a clang driver. One link
with `-v -Wl,-t` lists the driver's linker job and every file the linker
loads; their identities are checked again before an entry is published
and when it is restored.
- On Linux a key's first link only records that it was linked. A later
link of the same key traces and publishes, so a program built once pays
no tracing.
- The key names the runtime pack manifest by its file identity on Linux
instead of by its text.
- The executable and object caches share one digest of the LLVM text.
- Content-addressed entries are hashed once, and identities are compared
by field.
darwin keeps its own driver check, dry run and key.
A native frontend cache entry stores the input's LLVM text after its
metadata, with an empty placeholder in the input itself. The placeholder
was an empty string where NativeExecutableInput declares an array of
parts. Under Node the reader's cast of the parsed entry is not checked,
but the native compiler checks it, so every read threw "expected array
at $.input.llvm, got string". The reader treated that as a miss: no
native build ever reused its frontend output, and an unchanged rebuild
ran the frontend, lowering and LLVM emission again.
The placeholder is now an empty array, and the cache test checks the
stored member types.
Release builds compile for the x86-64 baseline, so LLVM vectorizes with
SSE2 only. `--target-cpu=<baseline|x86-64-v2|x86-64-v3|x86-64-v4|native>`
compiles the program's code for a higher level: every emitted function
carries the level as its `target-cpu`, so LLVM can use AVX2, BMI2, FMA or
AVX-512. The executable then runs only on CPUs of that level.
- `native` resolves to the highest level the build machine supports, read
from /proc/cpuinfo on Linux and `sysctl machdep.cpu` on macOS.
- The level is part of the executable cache key; `baseline` and builds
without the option produce the same output and keys as before.
- The runtime pack keeps its baseline code.
- Targets other than x86-64 refuse a level (SC3002). The CLI's cached
bootstrap route leaves builds with the option to the full compiler.
- `bench:runtime` takes `--baseline-target-cpu` and `--candidate-target-cpu`.
A codec stored in untyped storage (hono's StreamingApi keeps
`this.encoder = new TextEncoder()` in a JavaScript class field) had no
dynamic representation and threw SC1101 at run time. The codec records
now cross 'unknown' slots as handles of two new tags that hold the record
itself, so identity and a decoder's streaming state survive the round
trip, and checked casts unbox the same record.
The handles answer encode, encodeInto and decode (with options objects),
the encoding, fatal and ignoreBOM properties, method values with Node's
ERR_INVALID_THIS, instanceof, String() tags and JSON's {}. The unit
scr_text_codec_dyn.c links only into programs that convert a codec to or
from an untyped value (the textCodecDyn runtime feature, found with the
dgram carrier walk, now shared).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tsc-ts 0.0.8's API server serves on the named pipe the Node host
connects over on Windows (tsc-ts --api --pipe), and the native compiler
already starts it over its standard streams there, so tsc-ts is the
default checker on every platform. SCRIPTC_CHECKER=tsgo still selects
tsgo.
The compiler depends on tsc-ts 0.0.8, which the release-age exclusions
admit with its platform packages and @scriptc/threads 0.4.2.
The Windows CLI smoke test runs with both checkers, and
verify-native-cli.mjs --run checks that a distribution's tsc-ts runs on
its platform (tsc-ts.exe on Windows).
Programs that compile against a package shim (fs-extra, chalk, undici,
hot-shots) produced IR and LLVM that depended on where the compiler was
installed: lifted helper and closure names hashed the shim file's path
relative to the entry, the shared-helper location pass ordered candidate
sites by absolute path, and IR artifacts spelled every shim location as
an absolute path. Two checkouts of the same sources therefore emitted
different artifacts, and the zero-cost gate always reported
fs-extra-package as differing.
Files scriptc ships are now named by their place inside scriptc
(scriptc:shims/...) in structural positions, in the shared-helper
ordering, and in serialized IR artifacts. In-memory locations keep the
real path, so diagnostics and source lookups are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The LLVM emitter builds a module as parts and joined them into one
string: on a large program such as tsc-ts the parts and the 150 MB joined
text were live together at the end of emission. The native compiler only
writes, escapes and hashes the text, so it now takes the emitter's parts
(emitLlvmModuleParts) and never joins them. Consecutive small parts are
joined into batches of about 1 MiB as they are written, escaped or hashed
(joinedParts), so the many small parts cost few calls. A frontend cache
hit reads the text back as one part. The three emission entry points
share one module pipeline (moduleEmitter).
The native compiler wrote the module's LLVM text, 150 MB on a large
program such as tsc-ts, by encoding the whole string at once; its frontend
cache entry by first escaping the whole text into a second string and then
encoding that; and it read the written file back whole to key the object
cache. Those copies were the build's peak.
Text parts are now encoded and written in slices of at most 1 MiB UTF-16
units that never split a surrogate pair. The frontend cache entry is
generated while it is written: the LLVM text is escaped slice by slice,
which is the same JSON as escaping it whole, the entry is hashed in the
same pass, and an entry past one native string abandons its write as it
was skipped before. The object cache key hashes the file in 1 MiB blocks.
The files and cache keys are unchanged.