fix(compiler): expose source variables in LLVM dev builds

- Preserve source bindings and lexical scopes for native debugger inspection.
- Verify variable values, module isolation, and cache restores in LLDB, and document native representations.

Fixes #483

Co-authored-by: timokosonen <325538238+timokosonen@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-09-27 08:19:49 -05:00
co-authored by timokosonen
parent 41020ddb39
commit e30d11219a
13 changed files with 340 additions and 27 deletions
+1 -1
View File
@@ -120,7 +120,7 @@ Prebuilds release runtime objects and native TLS/dynamic-engine archives for tar
<dd>Remove symbol and debug payload when linking an executable, including cross-compiled targets. This opt-in flag works with either backend and <code>--from-c</code> without changing code optimization.</dd>
<dt><code>--optimization &lt;release|dev&gt;</code></dt>
<dd>Select native optimization for executable, assembly, or object output. The default is <code>release</code> (<code>-O2</code>). <code>dev</code> uses <code>-O0</code> and emits source locations for TypeScript and JavaScript breakpoints and stack frames. macOS executable builds also produce an adjacent <code>.dSYM</code> bundle.</dd>
<dd>Select native optimization for executable, assembly, or object output. The default is <code>release</code> (<code>-O2</code>). <code>dev</code> uses <code>-O0</code> and emits source locations for TypeScript and JavaScript breakpoints and stack frames. LLVM builds include source variable names and native storage descriptions; see <a href="/limitations#native-debugging">Native debugging</a> for the supported representations. macOS executable builds also produce an adjacent <code>.dSYM</code> bundle.</dd>
<dt><code>--windows-subsystem &lt;console|gui&gt;</code></dt>
<dd>Select the subsystem of a Windows executable. <code>console</code> is the default; <code>gui</code> prevents Windows from opening a console window for the app. Valid only for executable builds targeting Windows, including <code>--from-c</code>.</dd>
+1 -1
View File
@@ -4,7 +4,7 @@ Honesty is the product. The static surface is large but not total, and a few beh
## Native debugging
Executable builds with `--optimization=dev` support source breakpoints and native stack frames for statically compiled TypeScript and JavaScript. Debuggers see native representations; TypeScript variable inspection, expression evaluation, and stepping through code executed by the dynamic engine are not implemented. Release builds and builds with `--strip` omit these source mappings. macOS uses an adjacent `.dSYM` bundle generated with the Command Line Tools' `dsymutil`.
Executable builds with `--optimization=dev` support source breakpoints and native stack frames for statically compiled TypeScript and JavaScript. LLVM builds also describe source locals, parameters, captured bindings, and module globals using their source names and lexical scopes. Numbers and booleans display directly; strings expose their native `ScrStr` layout, including UTF-8 `data` and byte `len`, and tagged unions expose their `tag` and `slot` arms. Other heap values and forward-captured scalar bindings can appear as opaque native pointers. The C backend exposes generated C names and representations. TypeScript expression evaluation, JavaScript object formatting, and stepping through code executed by the dynamic engine are not implemented. Release builds and builds with `--strip` omit these source mappings. macOS uses an adjacent `.dSYM` bundle generated with the Command Line Tools' `dsymutil`.
## What doesn't compile (yet)
+131 -12
View File
@@ -1,5 +1,5 @@
import { basename, dirname } from "node:path";
import type { IrFunction, SrcLoc } from "../../ir/ir.js";
import { isUnitType, typeKey, type IrFunction, type IrGlobal, type IrLocal, type IrType, type IrUnionDef, type SrcLoc } from "../../ir/ir.js";
import { SourceLocations } from "../source-locations.js";
import { mangleFunction } from "../mangle.js";
@@ -12,23 +12,22 @@ function quoted(text: string): string {
).join("") + '"';
}
/** Source breakpoints and stack frames; no claim about TypeScript locals. */
/** DWARF describes source bindings at their actual native storage locations. */
export class LlvmDebugInfo {
private readonly nodes: string[] = [];
private readonly files = new Map<string, string>();
private readonly locations = new Map<string, string>();
private readonly scopes = new Map<string, string>();
private readonly lexicalScopes = new Map<string, { loc: SrcLoc; id: string }[]>();
private readonly types = new Map<string, string>();
private readonly units = new Map<string, { id: string; globalList: string; globals: string[] }>();
private readonly source: SourceLocations;
private readonly unit: string;
private readonly signature: string;
private readonly version: string;
private readonly dwarf: string;
constructor(sourceFile: string, sources: ReadonlyMap<string, string>) {
constructor(sourceFile: string, sources: ReadonlyMap<string, string>, private readonly pointerBits = 64, private readonly unions: readonly IrUnionDef[] = []) {
this.source = new SourceLocations(sources);
const file = this.file(sourceFile);
this.unit = this.add(`distinct !DICompileUnit(language: DW_LANG_C11, file: ${file}, producer: "scriptc", isOptimized: false, runtimeVersion: 0, emissionKind: LineTablesOnly)`);
this.signature = this.add(`!DISubroutineType(types: ${this.add("!{}")})`);
this.unit(sourceFile);
this.version = this.add('!{i32 2, !"Debug Info Version", i32 3}');
this.dwarf = this.add('!{i32 2, !"Dwarf Version", i32 4}');
}
@@ -48,11 +47,127 @@ export class LlvmDebugInfo {
return file;
}
private unit(path: string): { id: string; globalList: string; globals: string[] } {
// Distinct source modules need distinct CUs even though code generation
// shares one LLVM module; otherwise debuggers conflate file-local names.
let unit = this.units.get(path);
if (unit === undefined) {
const file = this.file(path);
const globalList = this.add("!{}");
const id = this.add(`distinct !DICompileUnit(language: DW_LANG_C11, file: ${file}, producer: "scriptc", isOptimized: false, runtimeVersion: 0, emissionKind: FullDebug, globals: ${globalList})`);
unit = { id, globalList, globals: [] };
this.units.set(path, unit);
}
return unit;
}
function(fn: IrFunction): string | null {
const pos = this.source.position(fn.loc);
if (pos === null) return null;
const file = this.file(pos.file);
return this.add(`distinct !DISubprogram(name: ${quoted(fn.name)}, linkageName: ${quoted(mangleFunction(fn.name))}, scope: ${file}, file: ${file}, line: ${pos.line}, type: ${this.signature}, scopeLine: ${pos.line}, spFlags: DISPFlagLocalToUnit | DISPFlagDefinition, unit: ${this.unit})`);
const signature = this.add(`!DISubroutineType(types: ${this.add(`!{${[fn.returnType, ...fn.params.map((p) => p.type)].map((t) => this.type(t)).join(", ")}}`)})`);
const id = this.add(`distinct !DISubprogram(name: ${quoted(fn.name)}, linkageName: ${quoted(mangleFunction(fn.name))}, scope: ${file}, file: ${file}, line: ${pos.line}, type: ${signature}, scopeLine: ${pos.line}, spFlags: DISPFlagLocalToUnit | DISPFlagDefinition, unit: ${this.unit(pos.file).id})`);
const scopes: { loc: SrcLoc; id: string }[] = [];
this.lexicalScopes.set(id, scopes);
const captures = new Set(fn.captures?.map((c) => c.localId));
const spans = fn.locals.filter((local) => !captures.has(local.id)).flatMap((local) => local.source ? [local.source.scope] : []);
// Outer scopes first; declarations in flattened IR still retain their
// source block, loop, catch, or function scope (including hoisted var).
spans.sort((a, b) => (b.end - b.start) - (a.end - a.start));
for (const loc of spans) {
if (loc.file !== fn.loc.file || (fn.loc.end > fn.loc.start &&
(loc.start <= fn.loc.start || loc.end > fn.loc.end))) continue;
if (scopes.some((scope) => scope.loc.start === loc.start && scope.loc.end === loc.end)) continue;
const start = this.source.position(loc);
if (start === null) continue;
const parent = this.scopeAt(loc, id);
scopes.push({ loc, id: this.add(`distinct !DILexicalBlock(scope: ${parent}, file: ${file}, line: ${start.line}, column: ${Math.min(start.column, 65535)})`) });
}
return id;
}
private scopeAt(loc: SrcLoc, fn: string): string {
const scopes = this.lexicalScopes.get(fn) ?? [];
return scopes.findLast((scope) => scope.loc.file === loc.file && scope.loc.start <= loc.start && loc.end <= scope.loc.end)?.id ?? fn;
}
private pointer(base: string): string {
return this.add(`!DIDerivedType(tag: DW_TAG_pointer_type, baseType: ${base}, size: ${this.pointerBits})`);
}
private type(type: IrType): string {
if (type.kind === "void") return "null";
const key = typeKey(type);
const known = this.types.get(key);
if (known !== undefined) return known;
let id: string;
switch (type.kind) {
case "f64": case "date": case "procStream":
id = this.add(`!DIBasicType(name: ${quoted(type.kind === "f64" ? "number" : type.kind)}, size: 64, encoding: DW_ATE_float)`);
break;
case "bool":
id = this.add('!DIBasicType(name: "boolean", size: 8, encoding: DW_ATE_boolean)');
break;
case "string": {
const size = this.add(`!DIBasicType(name: "size_t", size: ${this.pointerBits}, encoding: DW_ATE_unsigned)`);
const char = this.add('!DIBasicType(name: "char", size: 8, encoding: DW_ATE_signed_char)');
const data = this.add(`!DICompositeType(tag: DW_TAG_array_type, baseType: ${char}, elements: ${this.add(`!{${this.add("!DISubrange(count: -1)")}}`)})`);
const fields = ["rc", "len", "cap", "data"].map((name, i) => this.add(`!DIDerivedType(tag: DW_TAG_member, name: ${quoted(name)}, baseType: ${i === 3 ? data : size}, size: ${i === 3 ? 0 : this.pointerBits}, offset: ${i * this.pointerBits})`));
id = this.pointer(this.add(`!DICompositeType(tag: DW_TAG_structure_type, name: "ScrStr", size: ${3 * this.pointerBits}, elements: ${this.add(`!{${fields.join(", ")}}`)})`));
break;
}
case "union": {
const arms = this.unions.find((union) => union.id === type.unionId)?.arms ?? [];
const tags = arms.map((arm, i) => this.add(`!DIEnumerator(name: ${quoted(typeKey(arm))}, value: ${i}, isUnsigned: true)`));
const uint = this.add('!DIBasicType(name: "uint32_t", size: 32, encoding: DW_ATE_unsigned)');
const tag = this.add(`!DICompositeType(tag: DW_TAG_enumeration_type, name: ${quoted(`${type.unionId}_tag`)}, baseType: ${uint}, size: 32, elements: ${this.add(`!{${tags.join(", ")}}`)})`);
const members = arms.flatMap((arm, i) => isUnitType(arm) ? [] : [this.add(`!DIDerivedType(tag: DW_TAG_member, name: "arm${i}", baseType: ${this.type(arm)}, size: ${arm.kind === "bool" ? 8 : arm.kind === "f64" || arm.kind === "date" ? 64 : this.pointerBits})`)]);
const slot = this.add(`!DICompositeType(tag: DW_TAG_union_type, name: ${quoted(`${type.unionId}_value`)}, size: 64, elements: ${this.add(`!{${members.join(", ")}}`)})`);
const offset = this.pointerBits === 32 ? 24 : 40;
const fields = [
this.add(`!DIDerivedType(tag: DW_TAG_member, name: "tag", baseType: ${tag}, size: 32, offset: ${this.pointerBits})`),
this.add(`!DIDerivedType(tag: DW_TAG_member, name: "slot", baseType: ${slot}, size: 64, offset: ${offset * 8})`),
];
id = this.pointer(this.add(`!DICompositeType(tag: DW_TAG_structure_type, name: ${quoted(`ScrUnion_${type.unionId}`)}, size: ${(offset + 8) * 8}, elements: ${this.add(`!{${fields.join(", ")}}`)})`));
break;
}
default:
// Do not invent object layouts: unsupported visualizations remain
// typed opaque pointers, never doubles read out of pointer slots.
id = this.pointer(this.add(`!DICompositeType(tag: DW_TAG_structure_type, name: ${quoted(key)}, flags: DIFlagFwdDecl)`));
}
this.types.set(key, id);
return id;
}
local(local: IrLocal, fn: string | null, arg = 0, captured = false): { variable: string; expression: string; location: string } | null {
if (fn === null || local.source === undefined) return null;
const pos = this.source.position(local.source.loc);
if (pos === null) return null;
const scope = captured ? fn : this.scopeAt(local.source.scope, fn);
// Scalar forward captures use an array-backed TDZ box. Expose that
// exceptional representation opaquely rather than reporting a bad value.
const opaqueBox = local.boxed && local.tdz && (local.type.kind === "f64" || local.type.kind === "bool" || local.type.kind === "date");
const type = opaqueBox ? this.pointer(this.add('!DICompositeType(tag: DW_TAG_structure_type, name: "ScrBox", flags: DIFlagFwdDecl)')) : this.type(local.type);
const variable = this.add(`!DILocalVariable(name: ${quoted(local.name)}, ${arg === 0 ? "" : `arg: ${arg}, `}scope: ${scope}, file: ${this.file(pos.file)}, line: ${pos.line}, type: ${type})`);
// ScrBox.slot follows rc, kind, and three function pointers, aligned to
// uint64_t on both the native and wasm32 runtime ABIs.
const boxOffset = this.pointerBits === 32 ? 24 : 40;
const expression = local.boxed && !opaqueBox ? `!DIExpression(DW_OP_deref, DW_OP_plus_uconst, ${boxOffset})` : "!DIExpression()";
const location = this.add(`!DILocation(line: ${pos.line}, column: ${Math.min(pos.column, 65535)}, scope: ${scope})`);
return { variable, expression, location };
}
global(global: IrGlobal): string | null {
if (global.source === undefined) return null;
const pos = this.source.position(global.source.loc);
if (pos === null) return null;
const file = this.file(pos.file);
const unit = this.unit(pos.file);
const variable = this.add(`distinct !DIGlobalVariable(name: ${quoted(global.name)}, scope: ${unit.id}, file: ${file}, line: ${pos.line}, type: ${this.type(global.type)}, isLocal: true, isDefinition: true)`);
const expression = this.add(`!DIGlobalVariableExpression(var: ${variable}, expr: !DIExpression())`);
unit.globals.push(expression);
return expression;
}
location(loc: SrcLoc, fn: string | null): string | null {
@@ -60,10 +175,11 @@ export class LlvmDebugInfo {
const pos = this.source.position(loc);
if (pos === null) return null;
// Initializers and compiler-generated entry wrappers can span files.
const scopeKey = `${fn}:${pos.file}`;
const lexical = this.scopeAt(loc, fn);
const scopeKey = `${lexical}:${pos.file}`;
let scope = this.scopes.get(scopeKey);
if (scope === undefined) {
scope = this.add(`!DILexicalBlockFile(scope: ${fn}, file: ${this.file(pos.file)}, discriminator: 0)`);
scope = this.add(`!DILexicalBlockFile(scope: ${lexical}, file: ${this.file(pos.file)}, discriminator: 0)`);
this.scopes.set(scopeKey, scope);
}
const key = `${scope}:${pos.line}:${pos.column}`;
@@ -76,6 +192,9 @@ export class LlvmDebugInfo {
}
render(): string {
return [`!llvm.dbg.cu = !{${this.unit}}`, `!llvm.module.flags = !{${this.version}, ${this.dwarf}}`, ...this.nodes].join("\n");
for (const unit of this.units.values()) {
this.nodes[Number(unit.globalList.slice(1))] = `${unit.globalList} = !{${unit.globals.join(", ")}}`;
}
return [`!llvm.dbg.cu = !{${[...this.units.values()].map((unit) => unit.id).join(", ")}}`, `!llvm.module.flags = !{${this.version}, ${this.dwarf}}`, ...this.nodes].join("\n");
}
}
+15 -5
View File
@@ -171,7 +171,7 @@ interface LlScopeEntry {
}
export interface LlvmTargetOptions {
/** Exact frontend sources for dev-build line tables and stack frames. */
/** Exact frontend sources for dev-build locations and variable metadata. */
debugSources?: ReadonlyMap<string, string>;
/** Pointer width of the target C ABI. Native targets are 64-bit today. */
pointerBits?: 32 | 64;
@@ -186,7 +186,8 @@ export interface LlvmTargetOptions {
}
export function emitLlvmModule(mod: IrModule, options: LlvmTargetOptions = {}): string {
return new LlEmitter(scalarizeNumericRecords(mod), options).emit();
// Keep source storage intact for debugger inspection in dev builds.
return new LlEmitter(options.debugSources === undefined ? scalarizeNumericRecords(mod) : mod, options).emit();
}
/** LLVM c"..." payload for a UTF-8 literal, NUL-terminated like the C
@@ -382,7 +383,7 @@ class LlEmitter {
private logArgSlots = 0;
constructor(private readonly mod: IrModule, options: LlvmTargetOptions) {
this.debug = options.debugSources === undefined ? null : new LlvmDebugInfo(mod.sourceFile, options.debugSources);
this.debug = options.debugSources === undefined ? null : new LlvmDebugInfo(mod.sourceFile, options.debugSources, options.pointerBits, mod.unions);
this.constantNumericTables = findConstantNumericTables(mod);
this.sizeType = options.pointerBits === 32 ? "i32" : "i64";
this.wasi = options.wasi === true;
@@ -1254,7 +1255,8 @@ class LlEmitter {
for (const g of globals) {
const ty = this.llType(g.type);
const zero = ty === "double" ? f64Lit(0) : ty === "ptr" ? "null" : "false";
out.push(`@${mangleGlobal(g.id)} = internal ${tl}global ${ty} ${zero} ; ${g.name}`);
const debug = this.debug?.global(g);
out.push(`@${mangleGlobal(g.id)} = internal ${tl}global ${ty} ${zero}${debug ? `, !dbg ${debug}` : ""} ; ${g.name}`);
}
if (globals.length > 0) out.push(``);
out.push(...helpers);
@@ -2772,6 +2774,13 @@ class LlEmitter {
return { kind: "global", slot: `@${mangleGlobal(id)}`, type: g };
}
private emitDebugLocal(local: IrLocal, slot: string, arg = 0, captured = false): void {
const debug = this.debug?.local(local, this.debugScope, arg, captured);
if (!debug) return;
this.declare("declare void @llvm.dbg.declare(metadata, metadata, metadata)");
this.B.entryAllocas.push(`call void @llvm.dbg.declare(metadata ptr ${slot}, metadata ${debug.variable}, metadata ${debug.expression}), !dbg ${debug.location}`);
}
/** Loads a boxed binding's box pointer out of its slot. */
private loadBox(slot: string): string {
const b = this.B.tmp();
@@ -3038,6 +3047,7 @@ class LlEmitter {
? "ptr"
: this.llType(local.type);
B.entryAllocas.push(`%${mangleLocal(local.id)} = alloca ${slotTy} ; ${local.name}`);
this.emitDebugLocal(local, `%${mangleLocal(local.id)}`, fn.params.findIndex((p) => p.localId === local.id) + 1, this.captureIds.has(local.id));
// Refcounted/boxed locals start NULL (the C prologue's `= NULL`):
// scope-exit releases run whether or not an assign ever did.
if (paramIds.has(local.id) || this.captureIds.has(local.id)) continue;
@@ -3555,7 +3565,7 @@ class LlEmitter {
case "for": {
// The init's scope wraps the whole loop (break/continue must NOT
// release it — scopeDepth captured after the push, C parity).
const integerLoop = matchIntegerBytesForLoop(s, this.currentLocals);
const integerLoop = this.debug === null ? matchIntegerBytesForLoop(s, this.currentLocals) : null;
this.scopes.push([]);
let integerSlot: string | null = null;
if (integerLoop) {
@@ -0,0 +1,19 @@
import type { IrBindingSource } from "../ir/ir.js";
import * as ts from "./ts7/adapter.js";
import { locOf } from "./program.js";
/** Preserve source scopes before lowering flattens bindings and control flow. */
export function bindingSource(name: ts.Node): IrBindingSource {
let functionScoped = false;
for (let node = name.parent; node; node = node.parent) {
if (ts.isVariableDeclarationList(node)) {
functionScoped = (node.flags & ts.NodeFlags.BlockScoped) === 0;
}
if (ts.isFunctionLike(node) || ts.isSourceFile(node) ||
(!functionScoped && (ts.isBlock(node) || ts.isCaseBlock(node) ||
ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node) || ts.isCatchClause(node)))) {
return { loc: locOf(name), scope: locOf(node) };
}
}
return { loc: locOf(name), scope: locOf(name) };
}
@@ -3,6 +3,7 @@
* seeds), npm/JSON import collection, per-file %init functions, %main, and
* the module artifacts (globals, embedded npm tables) the IR module carries. */
import * as ts from "../ts7/adapter.js";
import { bindingSource } from "../binding-source.js";
import type { Lowerer } from "./lowerer.js";
import { dirname as dirnamePath, isAbsolute, resolve as resolvePath } from "node:path";
import { NpmGraphBuilder, packageNameOfPath, probeNodeImportRefusal, probeNodeRequireRefusal } from "../npm.js";
@@ -1412,7 +1413,7 @@ export function collectGlobals(lowerer: Lowerer, sf: ts.SourceFile, topStmts: ts
) {
const symbol = lowerer.checker.getSymbolAtLocation(nameNode);
if (symbol) {
const g: IrGlobal = { id: `%g.${tag}${nameNode.text}`, name: nameNode.text, type: DYN, mutable: isLet };
const g: IrGlobal = { id: `%g.${tag}${nameNode.text}`, name: nameNode.text, type: DYN, mutable: isLet, source: bindingSource(nameNode) };
lowerer.globalsBySymbol.set(symbol, g);
lowerer.globalsList.push(g);
}
@@ -1445,7 +1446,7 @@ export function collectGlobals(lowerer: Lowerer, sf: ts.SourceFile, topStmts: ts
) {
const symbol = lowerer.checker.getSymbolAtLocation(nameNode);
if (symbol && !lowerer.globalsBySymbol.has(symbol)) {
const g: IrGlobal = { id: `%g.${tag}${nameNode.text}`, name: nameNode.text, type: DYN, mutable: isLet };
const g: IrGlobal = { id: `%g.${tag}${nameNode.text}`, name: nameNode.text, type: DYN, mutable: isLet, source: bindingSource(nameNode) };
lowerer.globalsBySymbol.set(symbol, g);
lowerer.globalsList.push(g);
}
@@ -1472,7 +1473,7 @@ export function collectGlobals(lowerer: Lowerer, sf: ts.SourceFile, topStmts: ts
) {
const symbol = lowerer.checker.getSymbolAtLocation(nameNode);
if (symbol && !lowerer.globalsBySymbol.has(symbol)) {
const g: IrGlobal = { id: `%g.${tag}${nsPrefix}${nameNode.text}`, name: nameNode.text, type: DYN, mutable: isLet };
const g: IrGlobal = { id: `%g.${tag}${nsPrefix}${nameNode.text}`, name: nameNode.text, type: DYN, mutable: isLet, source: bindingSource(nameNode) };
lowerer.globalsBySymbol.set(symbol, g);
lowerer.globalsList.push(g);
// Mutable dyn globals hold the dyn undefined from module
@@ -1498,7 +1499,7 @@ export function collectGlobals(lowerer: Lowerer, sf: ts.SourceFile, topStmts: ts
) {
const symbol = lowerer.checker.getSymbolAtLocation(nameNode);
if (symbol && !lowerer.globalsBySymbol.has(symbol)) {
const g: IrGlobal = { id: `%g.${tag}${nsPrefix}${nameNode.text}`, name: nameNode.text, type: JSVAL, mutable: isLet };
const g: IrGlobal = { id: `%g.${tag}${nsPrefix}${nameNode.text}`, name: nameNode.text, type: JSVAL, mutable: isLet, source: bindingSource(nameNode) };
lowerer.globalsBySymbol.set(symbol, g);
lowerer.globalsList.push(g);
}
@@ -1616,6 +1617,7 @@ export function collectGlobals(lowerer: Lowerer, sf: ts.SourceFile, topStmts: ts
name: nameNode.text,
type,
mutable: isLet,
source: bindingSource(nameNode),
};
lowerer.globalsBySymbol.set(symbol, g);
lowerer.globalsList.push(g);
@@ -1738,7 +1740,7 @@ export function collectGlobals(lowerer: Lowerer, sf: ts.SourceFile, topStmts: ts
}
const symbol = lowerer.checker.getSymbolAtLocation(nameNode);
if (!symbol || lowerer.globalsBySymbol.has(symbol)) continue;
const g: IrGlobal = { id: `%g.${tag}${nameNode.text}`, name: nameNode.text, type, mutable: true };
const g: IrGlobal = { id: `%g.${tag}${nameNode.text}`, name: nameNode.text, type, mutable: true, source: bindingSource(nameNode) };
lowerer.globalsBySymbol.set(symbol, g);
lowerer.globalsList.push(g);
noteVarGlobalEntryInit(lowerer, sf, g);
@@ -4,6 +4,7 @@ import { InternalCompilerError } from "../../errors.js";
* flow (if/while/for/for-of/do, switch, try/catch, abrupt completions),
* explicit resource management, and blocked-binding poisoning. */
import * as ts from "../ts7/adapter.js";
import { bindingSource } from "../binding-source.js";
import type { Lowerer } from "./lowerer.js";
import { arrayValueRead, arrayValueStore, arrayValueType } from "./array-values.js";
import { lowerForAwaitGenerator, lowerForOfGenerator, lowerYieldStarStatement, type GenType } from "./lower-generators.js";
@@ -730,7 +731,7 @@ export function provenanceElidedConstDecl(lowerer: Lowerer, decl: ts.VariableDec
const name = nameNode.text;
const count = lowerer.ctx.localCounters.get(name) ?? 0;
lowerer.ctx.localCounters.set(name, count + 1);
const local: IrLocal = { id: `${name}.${count}`, name, type, mutable: true };
const local: IrLocal = { id: `${name}.${count}`, name, type, mutable: true, source: bindingSource(nameNode) };
lowerer.ctx.locals.push(local);
lowerer.ctx.scopes[0]!.set(symbol, local);
// A checked-dynamic slot holds the dyn undefined (a NULL dyn is a
@@ -789,7 +790,7 @@ export function provenanceElidedConstDecl(lowerer: Lowerer, decl: ts.VariableDec
const name = nameNode.text;
const count = ctx.localCounters.get(name) ?? 0;
ctx.localCounters.set(name, count + 1);
const local: IrLocal = { id: `${name}.${count}`, name, type, mutable: true };
const local: IrLocal = { id: `${name}.${count}`, name, type, mutable: true, source: bindingSource(decl.name) };
ctx.locals.push(local);
ctx.scopes[0]!.set(symbol, local);
root.out.push({ kind: "varDecl", localId: local.id, init: wrapped, loc: locOf(decl) });
@@ -14,6 +14,7 @@ import { InternalCompilerError } from "../../errors.js";
* ("x.0", "x.1" for shadowing); the IR is scope-flat.
*/
import { resolve } from "node:path";
import { bindingSource } from "../binding-source.js";
import { tsgoPath } from "../dts-paths.js";
import * as ts from "../ts7/adapter.js";
import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js";
@@ -8569,7 +8570,7 @@ export class Lowerer {
const ctx = this.ctx;
const count = ctx.localCounters.get(name) ?? 0;
ctx.localCounters.set(name, count + 1);
const local: IrLocal = { id: `${name}.${count}`, name, type, mutable };
const local: IrLocal = { id: `${name}.${count}`, name, type, mutable, source: bindingSource(nameNode) };
ctx.locals.push(local);
const symbol = this.checker.getSymbolAtLocation(nameNode);
if (symbol) ctx.scopes[ctx.scopes.length - 1]!.set(symbol, local);
@@ -8942,6 +8943,7 @@ export class Lowerer {
type: origin.type,
mutable: origin.mutable,
boxed: true,
...(origin.source ? { source: origin.source } : {}),
// TDZ travels with the binding: reads through ANY capture of a
// forward-captured const must trap while the box is empty.
...(origin.tdz ? { tdz: true as const } : {}),
+9
View File
@@ -1361,6 +1361,13 @@ export interface IrGlobal {
name: string;
type: IrType;
mutable: boolean;
/** Original declaration and lexical scope, when this is a source binding. */
source?: IrBindingSource;
}
export interface IrBindingSource {
loc: SrcLoc;
scope: SrcLoc;
}
export interface IrLocal {
@@ -1368,6 +1375,8 @@ export interface IrLocal {
name: string;
type: IrType;
mutable: boolean;
/** Absent for compiler temporaries and hidden ABI parameters. */
source?: IrBindingSource;
/** Captured by a nested function: the variable lives in a refcounted box
* (a shared binding — mutations are visible through every capture). All
* access, including in the declaring function, goes through the box. */
@@ -0,0 +1,105 @@
import { execFile } from "node:child_process";
import { cp, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect, test } from "vitest";
import { compile } from "../src/index.js";
const exec = promisify(execFile);
const corpus = join(import.meta.dirname, "../../../tests/corpus/3068-debug-variables");
const sanitize = process.env["SCRIPTC_SAN"] === "1";
test.runIf(process.platform === "darwin")("LLVM dev builds expose source variables and lexical scopes in LLDB", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-debug-variables-"));
try {
const source = join(dir, "source space é");
await cp(corpus, source, { recursive: true });
const entry = join(source, "main.ts");
const outPath = join(dir, "program");
const text = await readFile(entry, "utf8");
const line = (marker: string): number => text.split("\n").findIndex((l) => l.endsWith(`// debug: ${marker}`)) + 1;
const oracle = await exec(process.execPath, [entry]);
for (let build = 0; build < 2; build++) {
const result = await compile(entry, { outDir: dir, outPath, backend: "llvm", optimization: "dev", sanitize });
expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics) : "").toBe(true);
if (!result.ok) return;
const native = await exec(outPath);
expect(native.stdout).toBe(oracle.stdout);
expect(native.stderr).toBe(oracle.stderr);
const commands = [
...["globals", "locals", "shadow", "capture", "restored", "loop", "item", "hoisted"].map((marker) =>
`breakpoint set --file main.ts --line ${line(marker)} --move-to-nearest-code false`),
"breakpoint set --file other.ts --line 4 --move-to-nearest-code false",
"run",
"frame variable moduleValue moduleText",
"continue",
"frame variable input enabled fallback value text",
"frame variable text->len",
"frame variable text->data",
"continue",
"frame variable value",
"continue",
"frame variable value",
"continue",
"frame variable value",
"continue",
"frame variable i",
"continue",
"frame variable i",
"continue",
"frame variable item->tag item->slot.arm0",
"continue",
"frame variable item->tag item->slot.arm0",
"continue",
"frame variable hoisted",
"continue",
"frame variable moduleValue",
"continue",
];
const debug = await exec("lldb", ["--batch", outPath, ...commands.flatMap((c) => ["-o", c])]).catch((error: { stdout: string; stderr: string }) => {
throw new Error(`${error.stdout}\n${error.stderr}`);
});
expect(debug.stderr).toBe("");
expect(debug.stdout).not.toContain("pending");
for (const value of ["moduleValue = 42", "moduleValue = 100", "input = 3", "enabled = true", "fallback = 9", "value = 4", "value = 99", "value = 6", "i = 0", "i = 1", "item->tag = f64", "item->slot.arm0 = 6", "item->slot.arm0 = 7", "hoisted = 13"]) {
expect(debug.stdout).toContain(value);
}
expect(debug.stdout).toContain("text->len = 8");
expect(debug.stdout).toContain('text->data = "hello é"');
const shadowValues = [...debug.stdout.matchAll(/\(lldb\) frame variable value\r?\n\(double\) value = (\d+)/g)].map((match) => Number(match[1]));
expect(shadowValues).toEqual([99, 6, 6]);
expect(debug.stdout).not.toContain("sc_l_");
// Exercise the executable cache's restored dSYM, with native objects
// and the first linked binary already gone.
await rm(outPath);
await rm(`${outPath}.dSYM`, { recursive: true });
}
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("release LLVM output omits dev variable metadata", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-debug-modes-"));
try {
for (const mode of ["dev", "release"] as const) {
const outPath = join(dir, `${mode}.ll`);
const result = await compile(join(corpus, "main.ts"), {
outDir: dir, outPath, outputKind: "llvm",
optimization: mode,
});
expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics) : "").toBe(true);
const llvm = await readFile(outPath, "utf8");
if (mode === "dev") {
expect(llvm).toContain("emissionKind: FullDebug");
expect(llvm).toContain('!DILocalVariable(name: "input", arg: 1');
expect(llvm).not.toContain("LineTablesOnly");
} else {
expect(llvm).not.toMatch(/!DI|llvm\.dbg\./);
}
}
} finally {
await rm(dir, { recursive: true, force: true });
}
});
@@ -73,6 +73,9 @@ test.runIf(process.platform === "darwin")("dev LLVM object and assembly outputs
const { stdout } = await exec("dwarfdump", ["--debug-line", outPath]);
expect(stdout).toContain("main.ts");
expect(stdout).toContain("helper.ts");
const info = await exec("dwarfdump", ["--debug-info", outPath]);
expect(info.stdout).toContain("DW_TAG_variable");
expect(info.stdout).toContain("DW_TAG_formal_parameter");
} else {
const assembly = await readFile(outPath, "utf8");
expect(assembly).toContain(".loc");
+38
View File
@@ -0,0 +1,38 @@
import { reportOther } from "./other.ts";
const moduleValue = 42;
const moduleText = "module";
function inspect(input: number, enabled: boolean, fallback = 9): void {
let value = input + 1;
const text = "hello é";
const update = (): void => {
value += 2;
console.log("capture", value); // debug: capture
};
console.log("locals", value, enabled, text, fallback); // debug: locals
{
const value = 99;
console.log("inner", value); // debug: shadow
}
update();
console.log("outer", value); // debug: restored
const bytes = new Uint8Array([4, 5]);
for (let i = 0; i < bytes.length; i++) {
console.log("loop", i, bytes[i]); // debug: loop
}
for (const item of [6, 7]) {
console.log("item"); // debug: item
console.log(item);
}
if (enabled) {
var hoisted = input + 10;
} else {
hoisted = 0;
}
console.log("hoisted", hoisted); // debug: hoisted
}
console.log("globals", moduleValue, moduleText); // debug: globals
inspect(3, true);
reportOther();
@@ -0,0 +1,5 @@
const moduleValue = 100;
export function reportOther(): void {
console.log("other", moduleValue); // debug: other
}