fix(compiler): support source breakpoints in dev builds (#481)

- Emit source locations for native TypeScript and JavaScript debugging across C and LLVM backends.
- Preserve macOS debug symbols through linking and cache reuse, with regression coverage and Xcode guidance.

Fixes #461

Co-authored-by: timokosonen <325538238+timokosonen@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-09-26 22:21:11 -05:00
committed by GitHub
co-authored by timokosonen
parent 8272aa83c2
commit 0e6a13d041
24 changed files with 571 additions and 20 deletions
+5
View File
@@ -119,6 +119,9 @@ Prebuilds release runtime objects and native TLS/dynamic-engine archives for tar
<dt><code>--strip</code></dt>
<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>
<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>
@@ -179,6 +182,8 @@ fib-linux: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), dynamically
The default backend emits textual LLVM IR. On supported targets it is lowered to an object by the bundled helper and linked with the matching precompiled runtime pack; <code>SCRIPTC_LINKER</code> controls the platform linker driver, separately from C compilation. A program outside the LLVM tier is never miscompiled—the native build falls back to the C backend transparently and says so in one stderr line. The production <code>wasm32-wasi</code> target uses LLVM's 32-bit ABI path and never falls back; an LLVM coverage gap is a build diagnostic. Dynamic npm embedding is LLVM surface on every target.
For source breakpoints in Xcode or LLDB, build with <code>--optimization=dev</code>. In an Xcode custom build rule, add this option to the <code>scriptc build</code> invocation and list both the executable and its <code>.dSYM</code> bundle as outputs. Keep the bundle next to the executable and retain the original source files at their build-time paths. Both the LLVM and C backends preserve source locations across imported modules.
The C backend is a debugging aid: deliberately readable, source-line-annotated output with differential tests against LLVM wherever the two overlap. Pin it when you want to inspect what your program became:
```console
+4
View File
@@ -2,6 +2,10 @@
Honesty is the product. The static surface is large but not total, and a few behaviors diverge from Node **by design**. This page is the plain-language version; `scriptc coverage` on your program is the real answer for your code, and every blocker it reports is specific and coded. Nothing on this page is silent: everything here is either a compile error or a documented, numbered divergence.
## 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`.
## What doesn't compile (yet)
These are rejected at compile time with an `SC` code, a code frame, and usually a rewrite hint. A non-exhaustive tour of the ones you're most likely to meet:
+2 -2
View File
@@ -33,8 +33,8 @@ Options:
its C inspection lane accepts async-free programs only
--optimization <release|dev>
native optimization posture (default: release/-O2). dev
uses -O0 and stable cached LLVM object shards for faster
edits of large programs
uses -O0, source breakpoints, and cached LLVM object shards;
macOS executable builds also produce an adjacent .dSYM
--strip remove symbol/debug payload from the linked executable
for smaller builds (opt in; --emit=exe only)
--windows-subsystem <console|gui>
@@ -1,4 +1,5 @@
import { InternalCompilerError } from "../../errors.js";
import { SourceLocations } from "../source-locations.js";
/* IR → C. Three-address style: every IR expression lands in a fresh C temp.
* Verbose (clang -O2 erases it) but buys three things: short-circuit
* emission is trivially correct, reference counting has one mechanical
@@ -70,6 +71,8 @@ import { emitExpr, liveDynRefAdapter as buildLiveDynRefAdapter, type StreamTyped
import { emitLibraryIdentityLines } from "../library-identity-markers.js";
export interface CEmitOptions {
/** Exact frontend sources for dev-build #line directives. */
debugSources?: ReadonlyMap<string, string>;
/** Library archive assembly may move the volatile identity getters into a
* separate translation unit. Public/direct emission keeps them by default. */
emitLibraryIdentity?: boolean;
@@ -156,6 +159,8 @@ function ffiCallbackDummyC(callback: IrFfiCallbackParam["callback"]): string {
}
export class CEmitter {
private readonly debug: SourceLocations | null;
sourceLoc: SrcLoc | null = null;
readonly lines: string[] = [];
indent = 0;
tempCounter = 0;
@@ -440,6 +445,7 @@ export class CEmitter {
sourceText?: string,
private readonly options: CEmitOptions = {},
) {
this.debug = options.debugSources === undefined ? null : new SourceLocations(options.debugSources);
this.constantNumericTables = findConstantNumericTables(mod);
this.ffiCallbackAdapters = allocateFfiCallbackAdapters(mod.ffiImports ?? []);
this.ffiHasRetainedCallback = hasRetainedFfiCallback(mod.ffiImports ?? []);
@@ -1580,9 +1586,20 @@ export class CEmitter {
}
line(text: string): void {
const pos = this.sourceLoc === null ? null : this.debug?.position(this.sourceLoc);
if (pos !== null && pos !== undefined && text.trim() !== "") {
// Repeat the directive for every generated line: one TS statement can
// expand into many native instructions, all belonging to that line.
this.lines.push(`#line ${pos.line} ${JSON.stringify(pos.file)}`);
}
this.lines.push(" ".repeat(this.indent) + text);
}
endSourceFunction(): void {
this.sourceLoc = null;
if (this.debug !== null) this.lines.push('#line 1 "<scriptc>"');
}
srcComment(loc: SrcLoc): string {
if (!this.lineStarts) return "";
let lo = 0, hi = this.lineStarts.length - 1;
+15 -1
View File
@@ -20,6 +20,7 @@ import { endsWithJump, matchStringSelfConcat } from "../../ir/analysis.js";
export function emitFunction(emitter: CEmitter, fn: IrFunction): void {
emitter.sourceLoc = fn.loc;
emitter.tempCounter = 0;
emitter.frames = [];
emitter.scopes = [];
@@ -93,6 +94,8 @@ export function emitFunction(emitter: CEmitter, fn: IrFunction): void {
emitter.indent--;
emitter.line(`}`);
// Keep generated runtime scaffolding out of the user's source file.
emitter.endSourceFunction();
emitter.line(``);
}
@@ -166,6 +169,16 @@ export function emitStmts(emitter: CEmitter, stmts: IrStmt[]): void {
}
export function emitStmt(emitter: CEmitter, s: IrStmt): void {
const previous = emitter.sourceLoc;
emitter.sourceLoc = s.loc;
try {
emitStmtBody(emitter, s);
} finally {
emitter.sourceLoc = previous;
}
}
function emitStmtBody(emitter: CEmitter, s: IrStmt): void {
emitter.frames.push([]);
switch (s.kind) {
case "varDecl": {
@@ -1034,8 +1047,9 @@ export function emitStmt(emitter: CEmitter, s: IrStmt): void {
/** Emits `if (cond) ` followed by a block on the same line for readability. */
export function mergeBrace(emitter: CEmitter, emitBlockFn: () => void): void {
const head = emitter.lines.pop()!;
const before = emitter.lines.length;
let before = emitter.lines.length;
emitBlockFn();
while (emitter.lines[before]?.startsWith("#line ")) before++;
emitter.lines[before] = head + emitter.lines[before]!.trimStart();
}
@@ -0,0 +1,52 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { promisify } from "node:util";
const exec = promisify(execFile);
const HEADER = Buffer.from("SCDSYM01");
export function needsDarwinDebugSymbols(platform: string, optimization?: string, strip = false): boolean {
return platform === "darwin" && optimization === "dev" && !strip;
}
/** Run before deleting link inputs: Mach-O executables retain a debug map,
* while the actual line tables live in those temporary object files. */
export async function createDarwinDebugSymbols(binary: string): Promise<void> {
await exec("dsymutil", [binary, "-o", `${binary}.dSYM`]);
}
/** Keep the two dSYM payloads together as one integrity-checked cache file.
* Names are fixed by the output, never accepted from serialized cache data. */
export async function readDarwinDebugSymbols(binary: string): Promise<Buffer> {
const contents = join(`${binary}.dSYM`, "Contents");
const [plist, dwarf] = await Promise.all([
readFile(join(contents, "Info.plist")),
readFile(join(contents, "Resources", "DWARF", basename(binary))),
]);
const sizes = Buffer.alloc(4);
sizes.writeUInt32LE(plist.length);
return Buffer.concat([HEADER, sizes, plist, dwarf]);
}
export async function installDarwinDebugSymbols(bytes: Buffer, binary: string): Promise<void> {
if (bytes.length < 12 || !bytes.subarray(0, 8).equals(HEADER)) throw new Error("invalid cached dSYM");
const plistEnd = 12 + bytes.readUInt32LE(8);
if (plistEnd <= 12 || plistEnd >= bytes.length) throw new Error("invalid cached dSYM sizes");
const current = await readDarwinDebugSymbols(binary).catch(() => null);
if (current?.equals(bytes)) return;
const stage = await mkdtemp(join(dirname(binary), ".scriptc-dsym-"));
try {
const contents = join(stage, "Contents");
const dwarfDir = join(contents, "Resources", "DWARF");
await mkdir(dwarfDir, { recursive: true });
await Promise.all([
writeFile(join(contents, "Info.plist"), bytes.subarray(12, plistEnd)),
writeFile(join(dwarfDir, basename(binary)), bytes.subarray(plistEnd)),
]);
await rm(`${binary}.dSYM`, { recursive: true, force: true });
await rename(stage, `${binary}.dSYM`);
} finally {
await rm(stage, { recursive: true, force: true });
}
}
@@ -9,6 +9,7 @@
import type { FfiProfile } from "../ffi/ffi-manifest.js";
import type { NativeLinkFeatures } from "./native-link-info.js";
import type { NativeArtifactDependency } from "./native-toolchain.js";
import { needsDarwinDebugSymbols } from "./debug-symbols.js";
import { loadRuntimePack, type RuntimePackSelection } from "./runtime-pack.js";
import {
executableOptimizationLinkerArgs,
@@ -30,6 +31,7 @@ export interface NativeLinkPlan {
/** Inputs already snapshotted by the program-object emitter. */
programObjectDependencies: NativeArtifactDependency[];
runtimePack: RuntimePackSelection;
darwinDebugSymbols?: boolean;
}
export async function createNativeLinkPlan(options: {
@@ -48,6 +50,7 @@ export async function createNativeLinkPlan(options: {
const runtimePack = await loadRuntimePack(options);
return {
target: options.target,
darwinDebugSymbols: needsDarwinDebugSymbols(options.target.platform, options.optimization, options.strip),
outputPath: options.outPath,
inputs: [
options.programObject,
+8
View File
@@ -22,6 +22,7 @@ import {
} from "./native-toolchain.js";
import { RuntimePackError, stageRuntimePackArtifacts } from "./runtime-pack.js";
import type { NativeLinkPlan } from "./link-plan.js";
import { createDarwinDebugSymbols, installDarwinDebugSymbols, readDarwinDebugSymbols } from "./debug-symbols.js";
const execFileAsync = promisify(execFile);
@@ -254,10 +255,17 @@ export async function linkNativeExecutable(
await execFileAsync(linker, args);
const output = await stat(privateOut);
if (!output.isFile() || output.size === 0) throw new Error("linker produced no executable");
if (plan.darwinDebugSymbols) {
await createDarwinDebugSymbols(privateOut);
await installDarwinDebugSymbols(await readDarwinDebugSymbols(privateOut), plan.outputPath);
}
await rename(privateOut, plan.outputPath).catch(async () => {
await rm(plan.outputPath, { force: true });
await rename(privateOut, plan.outputPath);
});
if (plan.target.platform === "darwin" && !plan.darwinDebugSymbols) {
await rm(`${plan.outputPath}.dSYM`, { recursive: true, force: true });
}
if (
options.onArtifactReady !== undefined && preLinkDependencies !== null &&
await nativeArtifactDependenciesStillMatch(preLinkDependencies).catch(() => false)
+17 -2
View File
@@ -19,6 +19,7 @@ export class BlockBuilder {
readonly entryAllocas: string[] = [];
private tempCounter = 0;
private labelCounter = 0;
debugLocation: string | null = null;
constructor() {
this.cur = { label: "entry", lines: [], term: null };
@@ -35,7 +36,21 @@ export class BlockBuilder {
}
line(s: string): void {
if (this.cur.term === null) this.cur.lines.push(` ${s}`);
if (this.cur.term === null) this.cur.lines.push(` ${this.withDebugLocation(s)}`);
}
private withDebugLocation(s: string): string {
if (this.debugLocation === null || s.trimStart().startsWith(";")) return s;
// Instruction comments must follow the attachment. Quoted LLVM operands
// can themselves contain semicolons; those are not comment delimiters.
let quoted = false;
for (let i = 0; i < s.length; i++) {
if (s[i] === '"') quoted = !quoted;
if (s[i] === ";" && !quoted) {
return `${s.slice(0, i).trimEnd()}, !dbg ${this.debugLocation} ${s.slice(i)}`;
}
}
return `${s}, !dbg ${this.debugLocation}`;
}
tmp(): string {
@@ -51,7 +66,7 @@ export class BlockBuilder {
}
terminate(s: string): void {
if (this.cur.term === null) this.cur.term = ` ${s}`;
if (this.cur.term === null) this.cur.term = ` ${this.withDebugLocation(s)}`;
}
br(label: string): void {
@@ -0,0 +1,81 @@
import { basename, dirname } from "node:path";
import type { IrFunction, SrcLoc } from "../../ir/ir.js";
import { SourceLocations } from "../source-locations.js";
import { mangleFunction } from "../mangle.js";
/** LLVM metadata strings use UTF-8 byte escapes, including for paths. */
function quoted(text: string): string {
return '"' + [...Buffer.from(text)].map((byte) =>
byte >= 0x20 && byte < 0x7f && byte !== 0x22 && byte !== 0x5c
? String.fromCharCode(byte)
: `\\${byte.toString(16).padStart(2, "0").toUpperCase()}`,
).join("") + '"';
}
/** Source breakpoints and stack frames; no claim about TypeScript locals. */
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 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>) {
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.version = this.add('!{i32 2, !"Debug Info Version", i32 3}');
this.dwarf = this.add('!{i32 2, !"Dwarf Version", i32 4}');
}
private add(node: string): string {
const id = `!${this.nodes.length}`;
this.nodes.push(`${id} = ${node}`);
return id;
}
private file(path: string): string {
let file = this.files.get(path);
if (file === undefined) {
file = this.add(`!DIFile(filename: ${quoted(basename(path))}, directory: ${quoted(dirname(path))})`);
this.files.set(path, file);
}
return file;
}
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})`);
}
location(loc: SrcLoc, fn: string | null): string | null {
if (fn === null) return null;
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}`;
let scope = this.scopes.get(scopeKey);
if (scope === undefined) {
scope = this.add(`!DILexicalBlockFile(scope: ${fn}, file: ${this.file(pos.file)}, discriminator: 0)`);
this.scopes.set(scopeKey, scope);
}
const key = `${scope}:${pos.line}:${pos.column}`;
let id = this.locations.get(key);
if (id === undefined) {
id = this.add(`!DILocation(line: ${pos.line}, column: ${Math.min(pos.column, 65535)}, scope: ${scope})`);
this.locations.set(key, id);
}
return id;
}
render(): string {
return [`!llvm.dbg.cu = !{${this.unit}}`, `!llvm.module.flags = !{${this.version}, ${this.dwarf}}`, ...this.nodes].join("\n");
}
}
+22 -1
View File
@@ -89,6 +89,7 @@ import { RUNTIME_ABI_MARKER } from "../runtime-abi.js";
import { computeMayThrow } from "../c/may-throw.js";
import { mangleArgPack, mangleAsyncSpawn, mangleClassObj, mangleFnClosure, mangleFunction, mangleGenDrop, mangleGenSpawn, mangleGlobal, mangleLocal, mangleRecordStruct, mangleTrampoline, mangleWrapper } from "../mangle.js";
import { BlockBuilder } from "./blocks.js";
import { LlvmDebugInfo } from "./debug-info.js";
import { f64Lit, ffiNativeTypeLl, llvmCommentText } from "./common.js";
import { emitLiteralExpr, emitOperatorExpr, emitStringExpr, emitContainerExpr, emitRecordExpr } from "./expr-primitives.js";
import { emitControlExpr } from "./expr-control.js";
@@ -170,6 +171,8 @@ interface LlScopeEntry {
}
export interface LlvmTargetOptions {
/** Exact frontend sources for dev-build line tables and stack frames. */
debugSources?: ReadonlyMap<string, string>;
/** Pointer width of the target C ABI. Native targets are 64-bit today. */
pointerBits?: 32 | 64;
/** Select the WASI libc entry-point convention. */
@@ -204,6 +207,8 @@ function llStrBytes(text: string): string {
}
class LlEmitter {
private readonly debug: LlvmDebugInfo | null;
private debugScope: string | null = null;
readonly sizeType: "i32" | "i64";
readonly cycleColorOffset: number;
private readonly wasi: boolean;
@@ -377,6 +382,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.constantNumericTables = findConstantNumericTables(mod);
this.sizeType = options.pointerBits === 32 ? "i32" : "i64";
this.wasi = options.wasi === true;
@@ -1321,6 +1327,7 @@ class LlEmitter {
if (this.wasi) out.push(`attributes #1 = { sanitize_address presplitcoroutine }`);
if (hasNoInlineRecordClone) out.push(`attributes #2 = { noinline sanitize_address }`);
out.push(``);
if (this.debug !== null) out.push(this.debug.render());
return out.join("\n");
}
out.push(
@@ -1449,6 +1456,7 @@ class LlEmitter {
...(hasNoInlineRecordClone ? [`attributes #2 = { noinline sanitize_address }`] : []),
``,
);
if (this.debug !== null) out.push(this.debug.render());
return out.join("\n");
}
@@ -2968,6 +2976,8 @@ class LlEmitter {
private emitFunction(fn: IrFunction): string {
const B = new BlockBuilder();
this.B = B;
this.debugScope = this.debug?.function(fn) ?? null;
B.debugLocation = this.debug?.location(fn.loc, this.debugScope) ?? null;
this.frames = [];
this.scopes = [];
this.jumpTargets = [];
@@ -3122,7 +3132,8 @@ class LlEmitter {
if (fn.captures !== undefined) params.unshift("ptr %sc_env");
const ret = this.llType(fn.returnType);
const attrs = coro !== null ? "#1" : FN_ATTRS;
return `define internal ${ret} @${mangleFunction(fn.name)}(${params.join(", ")}) ${attrs} { ; ${fn.name}\n${B.render()}\n}`;
const debug = this.debugScope === null ? "" : ` !dbg ${this.debugScope}`;
return `define internal ${ret} @${mangleFunction(fn.name)}(${params.join(", ")}) ${attrs}${debug} { ; ${fn.name}\n${B.render()}\n}`;
}
// ── statements ──────────────────────────────────────────────────────────
@@ -3153,6 +3164,16 @@ class LlEmitter {
}
private emitStmt(s: IrStmt): void {
const previous = this.B.debugLocation;
this.B.debugLocation = this.debug?.location(s.loc, this.debugScope) ?? null;
try {
this.emitStmtBody(s);
} finally {
this.B.debugLocation = previous;
}
}
private emitStmtBody(s: IrStmt): void {
const B = this.B;
this.frames.push([]);
switch (s.kind) {
@@ -1,9 +1,11 @@
import { execFileSync } from "node:child_process";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, expect, test } from "vitest";
import { splitLlvmLibraryProgram, splitLlvmProgram } from "./split.js";
import { LlvmDebugInfo } from "./debug-info.js";
import { VOID } from "../../ir/ir.js";
const scratch: string[] = [];
afterAll(async () => {
@@ -77,6 +79,32 @@ test("thread-local program state conservatively keeps the single-TU path", () =>
expect(splitLlvmProgram(tls, { minimumBytes: 0, targetBytes: 64 * 1024 })).toBeNull();
});
test("debug metadata stays on definitions when splitting LLVM modules", async () => {
const debug = new LlvmDebugInfo("/source/main.ts", new Map([["/source/main.ts", "console.log(1);\n"]]));
let source = SAMPLE;
for (const name of ["left", "right", "public_entry"]) {
const loc = { file: "/source/main.ts", start: 0, end: 1 };
const scope = debug.function({ name, loc, params: [], locals: [], body: [], returnType: VOID });
const location = debug.location(loc, scope);
source = source.replace(new RegExp(`(@${name}\\(\\) #0) \\{([\\s\\S]*?)\\n\\}`), (_, header: string, body: string) =>
`${header} !dbg ${scope} {${body.split("\n").map((line) => line.startsWith(" ") ? `${line}, !dbg ${location}` : line).join("\n")}\n}`,
);
}
source += debug.render();
const split = splitLlvmProgram(source, { minimumBytes: 0 });
expect(split).not.toBeNull();
const dir = await mkdtemp(join(tmpdir(), "scriptc-debug-split-"));
scratch.push(dir);
for (const shard of split!.shards) {
expect(shard.source).not.toMatch(/^declare.*!dbg/m);
const path = join(dir, shard.name);
await writeFile(path, shard.source);
const result = spawnSync("clang", ["-Wno-override-module", "-c", path, "-o", `${path}.o`], { encoding: "utf8" });
expect(result.status, result.stderr).toBe(0);
expect(result.stderr).toBe("");
}
});
test("dev libraries split at the measured 2MB crossover while executables retain 4MB", () => {
const body = Array.from({ length: 700 }, (_, i) =>
`define internal i64 @library_pad_${i}() #0 {\nentry:\n ; ${"x".repeat(3072)}\n ret i64 ${i}\n}\n`,
+4 -1
View File
@@ -87,7 +87,10 @@ function functionDefAt(lines: readonly string[], start: number): { def: Function
const source = [definitionHeader, ...lines.slice(start + 1, end + 1)].join("\n");
// Parameter names are legal on declarations. Preserve the emitted text so
// attributes, zeroext, and varargs stay byte-exact.
const declaration = `declare ${linkage}${match[2]} ${match[3]}(${match[4]})${match[5]}`.trimEnd();
// A definition's DISubprogram cannot be attached to a declaration in a
// different shard. Its owning definition retains the original metadata.
const attrs = match[5]!.replace(/\s+!dbg !\d+/g, "");
const declaration = `declare ${linkage}${match[2]} ${match[3]}(${match[4]})${attrs}`.trimEnd();
return { def: { source, declaration, symbol, promoted }, end };
}
@@ -3845,6 +3845,16 @@ entry:
// The completed executable hit returns before shard lookup/merge.
expect((await stat(join(cacheRoot, "program-shard", name))).mtimeMs).toBe(old.getTime());
}
if (process.platform === "darwin") {
// A missing bundle is restored with its executable without revisiting
// any program shards, including on case-insensitive macOS volumes.
await rm(`${outPath}.dSYM`, { recursive: true });
await build(9);
expect((await stat(join(`${outPath}.dSYM`, "Contents/Resources/DWARF/program"))).size).toBeGreaterThan(0);
for (const name of after) {
expect((await stat(join(cacheRoot, "program-shard", name))).mtimeMs).toBe(old.getTime());
}
}
} finally {
if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"];
else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir;
@@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { localizeElfObject, mergeAndLocalizeCoffObjects } from "./object-localize.js";
import { executableOptimizationLinkerArgs, executableStripLinkerArgs, windowsSubsystemLinkerArgs, type WindowsSubsystem } from "./targets.js";
import { createDarwinDebugSymbols, installDarwinDebugSymbols, needsDarwinDebugSymbols, readDarwinDebugSymbols } from "./debug-symbols.js";
import {
createVendorArchives,
MBEDTLS_VERSION,
@@ -3407,6 +3408,7 @@ interface LocalArtifactStamp {
version: 2;
key: string;
digest: string;
debugSymbolsDigest?: string;
dependencies: NativeArtifactDependency[];
integrity: string;
}
@@ -3756,7 +3758,7 @@ function localArtifactStampPath(root: string, outPath: string): string {
}
function localArtifactStampIntegrity(
stamp: Pick<LocalArtifactStamp, "version" | "key" | "digest" | "dependencies">,
stamp: Pick<LocalArtifactStamp, "version" | "key" | "digest" | "dependencies" | "debugSymbolsDigest">,
): string {
return createHash("sha256")
.update("local-artifact-stamp-v2\0")
@@ -3768,6 +3770,7 @@ async function localArtifactHit(
stampPath: string,
outPath: string,
key: string,
darwinDebugSymbols = false,
): Promise<LocalArtifactStamp | null> {
try {
const stamp = JSON.parse(await readFile(stampPath, "utf8")) as Partial<LocalArtifactStamp>;
@@ -3777,6 +3780,8 @@ async function localArtifactHit(
stamp.version !== 2 ||
stamp.key !== key ||
!/^[0-9a-f]{64}$/.test(stamp.digest ?? "") ||
(darwinDebugSymbols !== (stamp.debugSymbolsDigest !== undefined)) ||
(stamp.debugSymbolsDigest !== undefined && !/^[0-9a-f]{64}$/.test(stamp.debugSymbolsDigest)) ||
!Array.isArray(stamp.dependencies) ||
!/^[0-9a-f]{64}$/.test(stamp.integrity ?? "") ||
localArtifactStampIntegrity({
@@ -3784,6 +3789,7 @@ async function localArtifactHit(
key: stamp.key,
digest: stamp.digest!,
dependencies: stamp.dependencies,
...(stamp.debugSymbolsDigest === undefined ? {} : { debugSymbolsDigest: stamp.debugSymbolsDigest }),
}) !== stamp.integrity ||
!output.isFile() ||
(output.mode & 0o777) !== expectedMode ||
@@ -3811,7 +3817,8 @@ async function localArtifactHit(
)
) ||
!(await nativeArtifactDependenciesStillMatch(stamp.dependencies)) ||
await fileDigest(outPath) !== stamp.digest
await fileDigest(outPath) !== stamp.digest ||
darwinDebugSymbols && createHash("sha256").update(await readDarwinDebugSymbols(outPath)).digest("hex") !== stamp.debugSymbolsDigest
) {
return null;
}
@@ -3830,6 +3837,7 @@ async function publishLocalArtifactStamp(
dependencyPaths: readonly string[],
recursiveDirectories: readonly string[] = [],
recursiveExclusions: readonly string[] = [],
darwinDebugSymbols = false,
): Promise<LocalArtifactStamp> {
await mkdir(dirname(stampPath), { recursive: true });
const tmp = `${stampPath}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
@@ -3844,6 +3852,9 @@ async function publishLocalArtifactStamp(
key,
digest: await fileDigest(outPath),
dependencies,
...(darwinDebugSymbols
? { debugSymbolsDigest: createHash("sha256").update(await readDarwinDebugSymbols(outPath)).digest("hex") }
: {}),
} as const;
const stamp: LocalArtifactStamp = {
...unsigned,
@@ -4023,6 +4034,10 @@ async function compileCInternal(
const tls = (opts.tls ?? false) || nativeFetch || netIsland;
const tlsCa = (opts.tlsCa ?? false) || tls;
const driver = resolveCc();
const darwinDebugSymbols = needsDarwinDebugSymbols(targetPlatform(driver), optimization, opts.strip);
const debugFlags = optimization === "dev" && !opts.strip
? ["-gline-tables-only", ...(opts.cPath.endsWith(".ll") ? [] : ["-gno-column-info"])]
: [];
const shardNames = new Set<string>();
const programShardsValid = opts.programShards?.every((shard) => {
if (
@@ -4192,7 +4207,7 @@ async function compileCInternal(
programBytes,
compilerPath: effectiveCompiler.canonicalPath,
};
const hit = await localArtifactHit(stampPath, opts.outPath, key);
const hit = await localArtifactHit(stampPath, opts.outPath, key, darwinDebugSymbols);
if (hit !== null) {
await opts.onArtifactReady?.({ dependencies: hit.dependencies }).catch(() => undefined);
return;
@@ -4424,10 +4439,11 @@ async function compileCInternal(
} = {},
): string[] => [
"-std=c11",
...debugFlags,
...driver.targetArgs,
...threadArgs,
...(sanitize
? ["-O1", "-fsanitize=address", "-DSCR_RC_AUDIT"]
? [optimization === "dev" ? "-O0" : "-O1", "-fsanitize=address", "-DSCR_RC_AUDIT"]
: [optimization === "dev" ? "-O0" : "-O2"]),
...executableSectionFlags.compile,
...(opts.textDecoderLegacy ? ["-DSCR_TEXT_DECODER_LEGACY"] : []),
@@ -4617,10 +4633,11 @@ async function compileCInternal(
// historical single clang invocation.
const cflags = [
"-std=c11",
...debugFlags,
...driver.targetArgs,
...threadArgs,
...(sanitize
? ["-O1", "-fsanitize=address", "-DSCR_RC_AUDIT"]
? [optimization === "dev" ? "-O0" : "-O1", "-fsanitize=address", "-DSCR_RC_AUDIT"]
: [optimization === "dev" ? "-O0" : "-O2"]),
...executableSectionFlags.compile,
...(opts.textDecoderLegacy ? ["-DSCR_TEXT_DECODER_LEGACY"] : []),
@@ -5026,6 +5043,20 @@ async function compileCInternal(
if (!(await copyValidCachedFile(cachedBin, tmpOut))) {
throw new Error("invalid cached executable");
}
if (darwinDebugSymbols) {
const cachedSymbols = `${cachedBin}.dsym`;
const symbolsCopy = privateSiblingPath(opts.outPath, "dsym-hit");
try {
if (!(await copyValidCachedFile(cachedSymbols, symbolsCopy))) throw new Error("invalid cached dSYM");
const bytes = await readFile(symbolsCopy);
// A concurrent publisher may replace either payload. The sidecar
// also names its binary digest so a mixed pair always misses.
if (bytes.subarray(0, 32).toString("hex") !== await fileDigest(tmpOut)) throw new Error("mismatched cached dSYM");
await installDarwinDebugSymbols(bytes.subarray(32), opts.outPath);
} finally {
await rm(symbolsCopy, { force: true });
}
}
// Match a fresh linker output under the caller's current umask. Reusing a
// cache entry populated by a less restrictive shell must not widen access.
await chmod(tmpOut, 0o777 & ~process.umask());
@@ -5038,6 +5069,7 @@ async function compileCInternal(
localArtifactDependencyPaths,
[dirname(resolve(opts.cPath))],
[persistentCache.root],
darwinDebugSymbols,
).catch(() => null);
if (stamp !== null) {
await opts.onArtifactReady?.({ dependencies: stamp.dependencies }).catch(() => undefined);
@@ -5258,6 +5290,13 @@ async function compileCInternal(
: { programPath: shardedProgramObject, outPath: privateOut },
),
);
if (darwinDebugSymbols) {
// clang invokes dsymutil when compiling a source TU while linking.
// A sharded program is already an object, so run it explicitly while
// the merged object and staged runtime inputs still exist.
if (shardedProgramObject !== null) await createDarwinDebugSymbols(privateOut);
await installDarwinDebugSymbols(await readDarwinDebugSymbols(privateOut), opts.outPath);
}
await installArtifact(privateOut, opts.outPath);
if (cachedBin !== null && keyHex !== null) {
@@ -5317,6 +5356,14 @@ async function compileCInternal(
// generated code and embedded literals private; the hit path reapplies
// the caller's current executable mode to its destination copy.
await publishCachedFile(privateOut, cachedBin);
if (darwinDebugSymbols) {
const symbols = privateSiblingPath(privateOut, "debug-symbols");
await writeFile(symbols, Buffer.concat([
Buffer.from(await fileDigest(privateOut), "hex"),
await readDarwinDebugSymbols(privateOut),
]));
await publishCachedFile(symbols, `${cachedBin}.dsym`);
}
} catch {
/* publishing is best-effort */
}
@@ -5334,6 +5381,7 @@ async function compileCInternal(
localArtifactDependencyPaths,
[dirname(resolve(opts.cPath))],
[persistentCache.root],
darwinDebugSymbols,
).catch(() => null);
if (stamp !== null) {
await opts.onArtifactReady?.({ dependencies: stamp.dependencies }).catch(() => undefined);
@@ -5350,6 +5398,9 @@ async function compileCInternal(
export async function compileC(opts: CcOptions): Promise<void> {
clearCcCaches();
await compileCInternal(opts, false);
if (targetPlatform(resolveCc()) === "darwin" && !needsDarwinDebugSymbols("darwin", opts.optimization, opts.strip)) {
await rm(`${opts.outPath}.dSYM`, { recursive: true, force: true });
}
}
export type NativeCacheWarmProfile = "runtime" | "tls" | "dynamic";
@@ -0,0 +1,16 @@
import { expect, test } from "vitest";
import { SourceLocations } from "./source-locations.js";
test("source offsets use their own file, UTF-16 columns, and JS line separators", () => {
const text = "one\r\ntwo\rthree\nfour\u2028five\u2029😀six";
const sources = new SourceLocations(new Map([["main.ts", "short"], ["other.ts", text]]));
for (const [index, word] of ["one", "two", "three", "four", "five", "😀six"].entries()) {
const offset = text.indexOf(word);
expect(sources.position({ file: "other.ts", start: offset, end: offset + word.length })).toEqual({
file: "other.ts", line: index + 1, column: 1,
});
}
expect(sources.position({ file: "other.ts", start: text.indexOf("six"), end: text.length })?.column).toBe(3);
expect(sources.position({ file: "missing.ts", start: 0, end: 0 })).toBeNull();
expect(sources.position({ file: "main.ts", start: -1, end: -1 })).toBeNull();
});
@@ -0,0 +1,36 @@
import type { SrcLoc } from "../ir/ir.js";
export interface SourcePosition {
file: string;
line: number;
column: number;
}
/** Resolve IR's UTF-16 offsets against the exact sources used by the frontend. */
export class SourceLocations {
private readonly starts = new Map<string, number[]>();
constructor(sources: ReadonlyMap<string, string>) {
for (const [file, text] of sources) {
const starts = [0];
for (let i = 0; i < text.length; i++) {
const ch = text.charCodeAt(i);
if (ch === 13 && text.charCodeAt(i + 1) === 10) i++;
if (ch === 10 || ch === 13 || ch === 0x2028 || ch === 0x2029) starts.push(i + 1);
}
this.starts.set(file, starts);
}
}
position(loc: SrcLoc): SourcePosition | null {
const starts = this.starts.get(loc.file);
if (starts === undefined || loc.start < 0) return null;
let lo = 0, hi = starts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >>> 1;
if (starts[mid]! <= loc.start) lo = mid;
else hi = mid - 1;
}
return { file: loc.file, line: lo + 1, column: loc.start - starts[lo]! + 1 };
}
}
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { FrontendInputTracker, trackedFileExists, trackedReadFile } from "../frontend/input-tracker.js";
import { nativeArtifactDependenciesStillMatch } from "../backend/native-toolchain.js";
import { installDarwinDebugSymbols, readDarwinDebugSymbols } from "../backend/debug-symbols.js";
import {
publishEarlyExecutableCache,
readEarlyExecutableCache,
@@ -122,6 +123,29 @@ test("early executable cache restores the emitted TU, IR, and native feature gat
expect(await readFile(alternateC, "utf8")).toBe("/* saved C backend */\n");
});
test("early dev cache restores the matching dSYM and rejects a missing payload", async () => {
const f = await fixture();
f.options.optimization = "dev";
f.options.target = "native:darwin:arm64:runtime-pack";
const tracker = new FrontendInputTracker();
tracker.run(() => trackedReadFile(f.source));
await writeFile(f.options.outPath, "native binary");
const symbols = Buffer.concat([Buffer.from("SCDSYM01"), Buffer.from([5, 0, 0, 0]), Buffer.from("plistDWARF")]);
await installDarwinDebugSymbols(symbols, f.options.outPath);
await publishEarlyExecutableCache(f.root, f.options, {
cPath: f.cPath, irPath: f.irPath, native: { ...native, optimization: "dev" },
executableRestored: true, nativeDependencies: [], frontend: tracker.snapshot(),
});
await rm(`${f.options.outPath}.dSYM`, { recursive: true });
expect((await readEarlyExecutableCache(f.root, f.options))?.executableRestored).toBe(true);
expect(await readDarwinDebugSymbols(f.options.outPath)).toEqual(symbols);
const files = await readdir(f.root, { recursive: true });
const cachedSymbols = files.find((file) => file.endsWith("program.dsym"));
expect(cachedSymbols).toBeDefined();
await rm(join(f.root, cachedSymbols!));
expect(await readEarlyExecutableCache(f.root, f.options)).toBeNull();
});
test("early executable cache misses on source and resolution changes", async () => {
const f = await fixture();
const tracker = new FrontendInputTracker();
@@ -6,6 +6,7 @@ import { frontendInputsStillMatch, validFrontendInputSnapshot } from "../fronten
import { compilerReleaseVersion } from "../library/sidecar.js";
import { nativeArtifactDependenciesStillMatch, type NativeArtifactDependency } from "../backend/native-toolchain.js";
import type { CompilerImplementationDependency } from "../library/compiler-self-identity.js";
import { installDarwinDebugSymbols, needsDarwinDebugSymbols, readDarwinDebugSymbols } from "../backend/debug-symbols.js";
import { compilerImplementationDependenciesStillMatch, compilerImplementationRoot } from "../library/compiler-self-identity.js";
import {
cacheKey as sharedCacheKey,
@@ -69,6 +70,7 @@ interface EarlyExecutableCacheStamp {
translationUnit: CachedExecutableFile;
ir: CachedExecutableFile | null;
executable: CachedExecutableFile | null;
debugSymbols?: CachedExecutableFile;
};
nativeDependencies: NativeArtifactDependency[] | null;
native: EarlyExecutableNativeFeatures;
@@ -291,7 +293,7 @@ function executableFrontendOutputExclusions(
options: EarlyExecutableCacheOptions,
backend: "c" | "llvm",
): ReturnType<typeof frontendOutputExclusions> {
return frontendOutputExclusions(options, backend, "", [options.outPath]);
return frontendOutputExclusions(options, backend, "", [options.outPath, `${options.outPath}.dSYM`]);
}
async function fileMatches(
@@ -347,6 +349,13 @@ export async function readEarlyExecutableCache(
stamp.files.executable?.name !== "program.bin" ||
!/^[0-9a-f]{64}$/.test(stamp.files.executable.digest)
)) ||
(stamp.files.debugSymbols !== undefined && (
stamp.files.executable === null || stamp.files.debugSymbols.name !== "program.dsym" ||
!/^[0-9a-f]{64}$/.test(stamp.files.debugSymbols.digest)
)) ||
(stamp.files.executable !== null &&
needsDarwinDebugSymbols(options.target.split(":")[1] ?? "", options.optimization, options.strip) !==
(stamp.files.debugSymbols !== undefined)) ||
(stamp.files.executable === null) !== (stamp.nativeDependencies === null) ||
(stamp.nativeDependencies !== null && !Array.isArray(stamp.nativeDependencies)) ||
(stamp.files.ir !== null) !== options.emitIr ||
@@ -358,7 +367,7 @@ export async function readEarlyExecutableCache(
) return null;
const directory = dirname(path);
const [translationUnit, ir, executable] = await Promise.all([
const [translationUnit, ir, executable, debugSymbols] = await Promise.all([
readCachedFile(
join(directory, stamp.files.translationUnit.name),
stamp.files.translationUnit.digest,
@@ -372,11 +381,15 @@ export async function readEarlyExecutableCache(
join(directory, stamp.files.executable.name),
stamp.files.executable.digest,
),
stamp.files.debugSymbols === undefined
? Promise.resolve(null)
: readCachedFile(join(directory, stamp.files.debugSymbols.name), stamp.files.debugSymbols.digest),
]);
if (
translationUnit === null ||
stamp.files.ir !== null && ir === null ||
stamp.files.executable !== null && executable === null
stamp.files.executable !== null && executable === null ||
stamp.files.debugSymbols !== undefined && debugSymbols === null
) return null;
// Validate the native proof before restoring frontend artifacts: replacing
@@ -398,10 +411,14 @@ export async function readEarlyExecutableCache(
) await installBytes(ir, paths.irPath);
if (executableRestored) {
try {
if (debugSymbols !== null) await installDarwinDebugSymbols(debugSymbols, options.outPath);
const expectedMode = 0o777 & ~process.umask();
if (!(await fileMatches(options.outPath, stamp.files.executable!.digest, expectedMode))) {
await installExecutable(executable!, options.outPath);
}
if (debugSymbols === null && options.target.split(":")[1] === "darwin") {
await rm(`${options.outPath}.dSYM`, { recursive: true, force: true });
}
} catch {
executableRestored = false;
}
@@ -412,6 +429,7 @@ export async function readEarlyExecutableCache(
join(directory, stamp.files.translationUnit.name),
...(stamp.files.ir === null ? [] : [join(directory, stamp.files.ir.name)]),
...(stamp.files.executable === null ? [] : [join(directory, stamp.files.executable.name)]),
...(stamp.files.debugSymbols === undefined ? [] : [join(directory, stamp.files.debugSymbols.name)]),
].map((cachePath) => utimes(cachePath, now, now).catch(() => undefined)));
return {
cPath: paths.cPath,
@@ -553,6 +571,12 @@ export async function publishEarlyExecutableCache(
: publishFile(result.irPath, "program.ir.json"),
publishExecutable,
]);
let debugSymbols: CachedExecutableFile | undefined;
if (executable !== null && needsDarwinDebugSymbols(options.target.split(":")[1] ?? "", options.optimization, options.strip)) {
const bytes = await readDarwinDebugSymbols(options.outPath);
await writeFile(join(stage, "program.dsym"), bytes, { mode: 0o600 });
debugSymbols = { name: "program.dsym", digest: digest(bytes) };
}
if (!frontendInputsStillMatch(
result.frontend,
executableFrontendOutputExclusions(options, result.native.backend),
@@ -561,7 +585,7 @@ export async function publishEarlyExecutableCache(
version: 1,
key: cacheKey(options),
frontend: result.frontend,
files: { translationUnit, ir, executable },
files: { translationUnit, ir, executable, ...(debugSymbols === undefined ? {} : { debugSymbols }) },
nativeDependencies: executable === null ? null : result.nativeDependencies!,
native: result.native,
};
@@ -579,6 +603,7 @@ export async function publishEarlyExecutableCache(
await install(translationUnit.name);
if (ir !== null) await install(ir.name);
if (executable !== null) await install(executable.name);
if (debugSymbols !== undefined) await install(debugSymbols.name);
await install("stamp.json");
await publishEarlyExecutableRoute(root, options);
} finally {
+9 -3
View File
@@ -221,7 +221,8 @@ export interface CompileBaseOptions {
* C fallback; a missing LLVM lowering there is SC3001. */
backend?: "c" | "llvm";
/** Native optimization posture. Release is the shipped -O2 default; dev
* uses -O0 and stable multi-TU object caching for large LLVM programs. */
* uses -O0, source line tables, and stable multi-TU object caching for
* large LLVM programs. Darwin executables include an adjacent .dSYM. */
optimization?: "release" | "dev";
/** Remove symbol/debug payload from an executable at link time. */
strip?: boolean;
@@ -1624,6 +1625,9 @@ async function compileTracked(
c: join(opts.outDir, `${stem}.c`),
llvm: join(opts.outDir, `${stem}.ll`),
} as const;
const debugOptions = opts.optimization === "dev" && !opts.strip
? { debugSources: sourceTexts }
: {};
if (outputKind === "ir") {
await mkdir(dirname(opts.outPath), { recursive: true });
@@ -1633,7 +1637,7 @@ async function compileTracked(
if (outputKind === "c") {
await mkdir(dirname(opts.outPath), { recursive: true });
await writeFile(opts.outPath, emitCModule(lowered.module, entryText));
await writeFile(opts.outPath, emitCModule(lowered.module, entryText, debugOptions));
return { ok: true, artifact: { kind: "c", path: opts.outPath } };
}
@@ -1641,6 +1645,7 @@ async function compileTracked(
let llvm: string;
try {
llvm = emitLlvmModule(lowered.module, {
...debugOptions,
pointerBits: buildPlatform === "wasi" ? 32 : 64,
wasi: buildPlatform === "wasi",
runtimeAbiMarker: outputKind === "obj",
@@ -1712,6 +1717,7 @@ async function compileTracked(
if (opts.backend !== "c") {
try {
const ll = emitLlvmModule(lowered.module!, {
...debugOptions,
pointerBits: buildPlatform === "wasi" ? 32 : 64,
wasi: buildPlatform === "wasi",
runtimeAbiMarker:
@@ -1733,7 +1739,7 @@ async function compileTracked(
}
}
if (backend === "c") {
await writeFile(cPath, emitCModule(lowered.module!, entryText));
await writeFile(cPath, emitCModule(lowered.module!, entryText, debugOptions));
}
let irPath: string | undefined;
if (opts.emitIr) {
@@ -0,0 +1,110 @@
import { execFile } from "node:child_process";
import { cp, mkdtemp, readFile, rm, stat } 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/3066-source-debugging");
const sanitize = process.env["SCRIPTC_SAN"] === "1";
// Xcode ships this debugger. Resolve real breakpoints after temporary native
// objects have been deleted, including after the executable cache restores.
test.runIf(process.platform === "darwin").each(["llvm", "c"] as const)(
"dev %s builds preserve source breakpoints across cache hits",
async (backend) => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-debug-"));
try {
const source = join(dir, 'source space é "quoted"');
await cp(corpus, source, { recursive: true });
const entry = join(source, "main.ts");
const outPath = join(dir, "program");
const options = { outDir: dir, outPath, backend, optimization: "dev" as const, sanitize };
const oracle = await exec(process.execPath, [entry]);
for (let build = 0; build < 2; build++) {
const result = await compile(entry, options);
expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics) : "").toBe(true);
if (!result.ok) return;
expect((await stat(`${outPath}.dSYM/Contents/Resources/DWARF/program`)).size).toBeGreaterThan(0);
const native = await exec(outPath);
expect(native.stdout).toBe(oracle.stdout);
expect(native.stderr).toBe(oracle.stderr);
const debug = await exec("lldb", ["--batch", outPath,
"-o", "breakpoint set --file main.ts --line 4 --move-to-nearest-code false",
"-o", "breakpoint set --file helper.ts --line 4 --move-to-nearest-code false",
"-o", "breakpoint list",
]);
expect(debug.stdout).not.toContain("pending");
expect(debug.stdout).toContain("main.ts:4");
expect(debug.stdout).toContain("helper.ts:4");
await rm(outPath);
await rm(`${outPath}.dSYM`, { recursive: true });
}
for (const mode of ["release", "dev", "strip"] as const) {
const result = await compile(entry, {
...options,
optimization: mode === "release" ? "release" : "dev",
strip: mode === "strip",
});
expect(result.ok).toBe(true);
if (mode === "dev") {
expect((await stat(`${outPath}.dSYM`)).isDirectory()).toBe(true);
} else {
await expect(stat(`${outPath}.dSYM`)).rejects.toMatchObject({ code: "ENOENT" });
}
}
} finally {
await rm(dir, { recursive: true, force: true });
}
},
);
test.runIf(process.platform === "darwin")("dev LLVM object and assembly outputs carry source locations", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-debug-object-"));
try {
for (const outputKind of ["obj", "asm"] as const) {
const outPath = join(dir, `program.${outputKind}`);
const result = await compile(join(corpus, "main.ts"), { outDir: dir, outPath, outputKind, optimization: "dev" });
expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics) : "").toBe(true);
if (!result.ok) continue;
if (outputKind === "obj") {
const { stdout } = await exec("dwarfdump", ["--debug-line", outPath]);
expect(stdout).toContain("main.ts");
expect(stdout).toContain("helper.ts");
} else {
const assembly = await readFile(outPath, "utf8");
expect(assembly).toContain(".loc");
expect(assembly).toContain("helper.ts");
}
}
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test.each([
"800-switch-basics.ts",
"1021-async-ordering.ts",
"1452-return-through-finally.ts",
"2013-generators-sent-values.ts",
"603-closures-rc-stress.ts",
])("dev code generation preserves control flow: %s", async (fixture) => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-debug-flow-"));
try {
const entry = join(corpus, "..", fixture);
const node = await exec(process.execPath, [entry]);
for (const backend of ["c", "llvm"] as const) {
const outPath = join(dir, process.platform === "win32" ? `${backend}.exe` : backend);
const built = await compile(entry, { outDir: dir, outPath, backend, optimization: "dev", sanitize });
expect(built.ok, !built.ok ? JSON.stringify(built.diagnostics) : "").toBe(true);
if (!built.ok) continue;
const native = await exec(outPath);
expect(native.stdout).toBe(node.stdout);
expect(native.stderr).toBe(node.stderr);
}
} finally {
await rm(dir, { recursive: true, force: true });
}
});
@@ -7230,6 +7230,13 @@
],
"diags": []
},
"<repo>/tests/corpus/3066-source-debugging/main.ts": {
"order": [
"<repo>/tests/corpus/3066-source-debugging/helper.ts",
"<repo>/tests/corpus/3066-source-debugging/main.ts"
],
"diags": []
},
"<repo>/tests/corpus/400-fib.ts": {
"order": [
"<repo>/tests/corpus/400-fib.ts"
@@ -0,0 +1,7 @@
export function calculate(count: number): number {
let value = 0;
for (let i = 0; i < count; i++) {
value += i;
}
return value;
}
@@ -0,0 +1,8 @@
import { calculate } from "./helper.ts";
function report(): void {
const value = calculate(3);
console.log("result", value);
}
report();