Skip to content

Borrow facts: stack promotion across statically linked modules - #609

Merged
ASDAlexander77 merged 15 commits into
mainfrom
borrow-facts-across-modules
Oct 18, 2026
Merged

ASDAlexander77 merged 15 commits into
mainfrom
borrow-facts-across-modules

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

Stack promotion so far treated every call into another module as an escape. With this change, a library records which parameters of its functions are only read, and a program linked statically against it reads those facts. An object passed to such a parameter can then go on the stack. For example, Math.max(a, b, c) now builds its argument array on the stack, under both --emit=exe and --emit=jit.

Spec: tslang/docs/superpowers/specs/2026-10-18-borrow-facts-across-modules-design.md. Plan: tslang/docs/superpowers/plans/2026-10-18-borrow-facts-across-modules.md.

Producing the facts

  • The symbol: every module compiled ahead of time exports __tsborrow_<file>_<hash>. It is a char array with one line per function: <symbol> <borrowed parameter indexes>.
  • Analysis: the facts come from the stack promotion escape analysis, run trusting no vtable, since an importer may subclass any class.
  • Always written: the symbol is emitted whatever --export and --no-stack-promotion say.
  • Renamed functions: a function renamed by @linkname or @dllname publishes no facts.

Reading the facts

Facts are read only where the file read is the file that runs:

  • --emit=exe: the default library's static library (now linked by its path rather than searched for), and every --lib that resolves to a static library or object file (also linked by that path).
  • --emit=jit: the default library's DLL, read from the file before anything is loaded. A JIT cache entry lists that DLL as a dependency.
  • Never: DLL links, -shared imports, --emit=obj. Missing or old facts mean every call escapes, as before.

Matching

  • Direct calls only: only a direct call to a declared function uses a fact. Virtual calls on library classes still escape.
  • Ambiguous names: a fact is dropped when another link input defines the same symbol: another --lib, an --obj, a -shared import, or a module the JIT cache compiled separately. If any --lib is searched for by the linker, no fact is trusted at all.
  • Declarations bound elsewhere: a declaration renamed by @linkname or @dllname takes no fact.

Also

  • Print flag: tslang --print-borrow-facts=<file> prints what an object file, static library or DLL publishes.
  • Docs: docs/memory-models.md has a new "Calls into other modules" section.

Testing

  • Producer: test-stack-facts-lib-* covers gc, rc, own and none.
  • Reader and consumer: test-borrow-facts-* builds a static library and a program with --lib, checks every decision, and compares the output with an unpromoted build. Negative cases:
    • --emit=obj
    • a library built without facts
    • @linkname
    • a second library or --obj defining the same symbol
    • a searched --lib
    • a @static -shared import
    • --lib naming a DLL
    • an import library
  • Default library: test-borrow-facts-defaultlib covers the real default library. It is skipped unless that library was built with this compiler. It passed against a locally rebuilt rc default library, in both exe and JIT modes, and also checks that a stray TypeScriptDefaultLib.lib in the working folder is not linked.
  • Teeth checks:
    • With a producer that lists every parameter, the program printed 0 instead of 2, and the tests fail.
    • Without the -shared filter, the -shared test fails.
    • Without the DLL reader, the DLL test fails.
  • Full suite: 4672/4672 with promotion on, and 4672/4672 with -DTSLANG_TEST_NO_STACK_PROMOTION=ON.

Not covered

  • Linux: the archive and .so paths are written but untested; this was built and tested on Windows only.
  • Default library: the default library has to be rebuilt with this compiler before it publishes facts. Until then nothing changes for programs using it.
  • Implicit libraries: the libraries the compiler links implicitly (CRT, gc, runtime) are trusted not to define the default library's symbols.

🤖 Generated with Claude Code

ASDAlexander77 and others added 15 commits October 18, 2026 21:17
Libraries publish which parameters of their exported functions are only
borrowed, generated by the stack promotion escape analysis and stored in
the binary; programs read them to promote objects passed to those calls.
Three questions are still open (default-library gain, DLL links, Linux
archives).

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every module compiled ahead of time gets an exported char array,
__tsborrow_<file>_<hash>, listing for each non-private function the
parameters its body only borrows. The facts come from the stack
promotion escape analysis, run trusting no vtable, since an importer may
subclass any class. They are written whatever --export and
--no-stack-promotion say. The pass records the text on the module; the
LLVM lowering makes it the symbol after conversion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dump::readCStringSymbols reads every symbol with a prefix, and the char
array it names, from a COFF or ELF object file or from the object
members of a static library; import members and unreadable files give
nothing. The hidden --print-borrow-facts=<file> prints them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An --emit=exe compile reads the __tsborrow_ facts of the default
library's static library and of every --lib it can find (a file, or the
target's <name>.lib / lib<name>.a in the current directory), and links
such a library by that path, so the file read is the file linked. Stack
promotion then treats an argument a declared function's library lists
as borrowed. DLL links, -shared imports and --emit=obj read nothing;
missing facts mean every call escapes, as before.

The default library's file for a link or the JIT now comes from one
function, getDefaultLibBinary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under --emit=jit the compiler reads the __tsborrow_ facts of the default
library DLL it loads, from the file (PE exports, or an ELF shared
object's dynamic symbols) before anything is loaded. A JIT cache entry
lists that file as a dependency, so a rebuilt library misses the cache.

test-borrow-facts-defaultlib checks Math.max's rest array goes on the
stack under --emit=exe and --emit=jit for every model whose default
library publishes facts, and skips otherwise.

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

It defines, or binds, another symbol than its name, so a fact keyed by
the name could describe a different function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A fact is matched by name, and the linker may take another definition
of that name. A fact is now dropped when another library or --obj of
the link defines its symbol, when a -shared import exports it, or when
the JIT cache compiles the module apart from modules it imports. A
--lib the linker searches for itself may define anything, so then no
fact is trusted. Dump::definedSymbols lists what an input defines.

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

--emit=exe linked the default library with -l and -L, so the linker took
the first library of that name it found - a stray copy in the current
folder before the configured one - while the facts were read from the
configured file. The static library is now linked by its path; the
DLL's import library is still searched for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only a static library or an object file is linked into the program, so
only its facts describe the code that runs. Any other --lib counts as
one the linker resolves itself, and then no fact is trusted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 merged commit ccd7c50 into main Oct 18, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the borrow-facts-across-modules branch October 18, 2026 22:21
ASDAlexander77 added a commit that referenced this pull request Oct 20, 2026
#609 links the static default library by its full path and dropped its -L.
On Linux libTypeScriptDefaultLib.a is a GNU ld script,
INPUT(-lTypeScriptDefaultLibCore -lcurl), whose archive the linker looks up
through the -L paths: every --emit=exe with the default library failed with
"cannot find -lTypeScriptDefaultLibCore" (v0.0-pre-alpha105 release, Linux).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 20, 2026
)

#609 links the static default library by its full path and dropped its -L.
On Linux libTypeScriptDefaultLib.a is a GNU ld script,
INPUT(-lTypeScriptDefaultLibCore -lcurl), whose archive the linker looks up
through the -L paths: every --emit=exe with the default library failed with
"cannot find -lTypeScriptDefaultLibCore" (v0.0-pre-alpha105 release, Linux).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant