From e3b7a96041bc3159c01e2729314059f09b830e73 Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Wed, 16 Sep 2026 14:05:07 +0200 Subject: [PATCH 01/11] feat(wasm): expose section predicates and external_debug_info decode - Add isCodeSection and isExternalDebugInfoSection predicates - Add decodeExternalDebugInfo on top of the private byte-vector decoder - Name the code section id instead of repeating 10 in SECTION_ORDER - Add debugIdFromBuildId, rendering a hex build id as a dashed debug id - Make readVarUint32, writeVarUint32, and VarUint32 module-private - Drop the LEB128 test block that only covered those exports Co-authored-by: Cursor --- packages/cli/src/lib/wasm/binary.ts | 43 ++++++++++++++++++++--- packages/cli/src/lib/wasm/build-id.ts | 27 ++++++++++++++ packages/cli/test/lib/wasm/binary.test.ts | 30 ---------------- 3 files changed, 66 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/lib/wasm/binary.ts b/packages/cli/src/lib/wasm/binary.ts index 4af1f3ecd..17bd4f7f5 100644 --- a/packages/cli/src/lib/wasm/binary.ts +++ b/packages/cli/src/lib/wasm/binary.ts @@ -35,6 +35,9 @@ const log = logger.withTag("wasm.binary"); /** Section id of a custom section. */ const CUSTOM_SECTION_ID = 0; +/** Section id of the code section. DWARF offsets are relative to it. */ +const CODE_SECTION_ID = 10; + /** Name of the custom section holding function names. */ const NAME_SECTION = "name"; @@ -68,7 +71,7 @@ const SECTION_ORDER = [ [8, "start"], [9, "element"], [12, "data count"], - [10, "code"], + [CODE_SECTION_ID, "code"], [11, "data"], ] as const; @@ -141,7 +144,7 @@ export type WasmSection = { }; /** A varuint32 read off a byte stream. */ -export type VarUint32 = { +type VarUint32 = { /** The decoded value. */ value: number; /** Bytes the encoding occupied. */ @@ -158,7 +161,7 @@ export type VarUint32 = { * @throws {WasmParseError} when the encoding runs past the buffer, spans more * groups than a 32-bit value can need, or decodes above 2^32 - 1 */ -export function readVarUint32(bytes: Uint8Array, offset: number): VarUint32 { +function readVarUint32(bytes: Uint8Array, offset: number): VarUint32 { let value = 0; let scale = 1; for (let size = 0; size < MAX_VARUINT32_BYTES; size++) { @@ -192,7 +195,7 @@ export function readVarUint32(bytes: Uint8Array, offset: number): VarUint32 { * @param value - A non-negative integer below 2^32 * @returns The encoded bytes, one to five of them */ -export function writeVarUint32(value: number): Uint8Array { +function writeVarUint32(value: number): Uint8Array { const bytes: number[] = []; let remaining = value; do { @@ -321,6 +324,38 @@ export function decodeBuildId(contents: Uint8Array): Uint8Array | null { return decodeByteVector(contents, BUILD_ID_SECTION); } +/** + * Read the URL out of an `external_debug_info` section body. + * + * @param contents - The section body, after its name + * @returns The URL, or `null` when the body is malformed or not UTF-8 + */ +export function decodeExternalDebugInfo(contents: Uint8Array): string | null { + const bytes = decodeByteVector(contents, EXTERNAL_DEBUG_INFO_SECTION); + if (bytes === null) { + return null; + } + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error) { + log.debug(`${EXTERNAL_DEBUG_INFO_SECTION} body is not valid UTF-8`, error); + return null; + } +} + +/** Whether a section is the code section. */ +export function isCodeSection(section: WasmSection): boolean { + return section.id === CODE_SECTION_ID; +} + +/** Whether a section points at a debug companion. */ +export function isExternalDebugInfoSection(section: WasmSection): boolean { + return ( + section.id === CUSTOM_SECTION_ID && + section.name === EXTERNAL_DEBUG_INFO_SECTION + ); +} + /** Whether a section is one of the custom sections carrying DWARF. */ export function isDebugSection(section: WasmSection): boolean { return ( diff --git a/packages/cli/src/lib/wasm/build-id.ts b/packages/cli/src/lib/wasm/build-id.ts index 3effc5f54..23f6d9095 100644 --- a/packages/cli/src/lib/wasm/build-id.ts +++ b/packages/cli/src/lib/wasm/build-id.ts @@ -41,6 +41,33 @@ export function uuidToBytes(uuid: string): Uint8Array | null { } } +/** Hex digits in a UUID, excluding hyphens. */ +const UUID_HEX_LENGTH = 32; + +/** Hex-digit counts of a canonical UUID's five hyphen-separated groups. */ +const UUID_GROUP_SIZES = [8, 4, 4, 4, 12]; + +/** + * Render a hex build id as the dashed debug id Sentry indexes it under. + * + * Only the first 16 bytes are significant, mirroring how `symbolic` derives a + * WASM object's debug id from its `build_id`. A build id too short to form a + * UUID yields nothing, so callers omit the advisory id rather than send a + * malformed one. + */ +export function debugIdFromBuildId(buildId: string): string | undefined { + if (buildId.length < UUID_HEX_LENGTH) { + return; + } + const groups: string[] = []; + let offset = 0; + for (const size of UUID_GROUP_SIZES) { + groups.push(buildId.slice(offset, offset + size)); + offset += size; + } + return groups.join("-"); +} + /** * Generate a random v4 build id. * diff --git a/packages/cli/test/lib/wasm/binary.test.ts b/packages/cli/test/lib/wasm/binary.test.ts index ce9dc6718..74ac5b644 100644 --- a/packages/cli/test/lib/wasm/binary.test.ts +++ b/packages/cli/test/lib/wasm/binary.test.ts @@ -16,9 +16,7 @@ import { makeBuildIdSection, makeExternalDebugInfoSection, parseSections, - readVarUint32, WasmParseError, - writeVarUint32, } from "../../../src/lib/wasm/binary.js"; import { byteVector, @@ -33,34 +31,6 @@ import { wasmModule, } from "./helpers.js"; -describe("readVarUint32 / writeVarUint32", () => { - test.each([ - 0, 1, 127, 128, 624_485, 0xff_ff_ff_ff, - ])("round-trips %i", (value) => { - const encoded = writeVarUint32(value); - expect(readVarUint32(encoded, 0)).toEqual({ - value, - size: encoded.length, - }); - }); - - test("reads a padded, non-canonical encoding", () => { - // 0x01 spread over four groups. Legal, and some toolchains emit it. - expect(readVarUint32(fromHex("81808000"), 0)).toEqual({ - value: 1, - size: 4, - }); - }); - - test.each([ - ["truncated", "80"], - ["longer than five groups", "8080808080"], - ["above 32 bits", "8080808010"], - ])("rejects an encoding that is %s", (_label, hex) => { - expect(() => readVarUint32(fromHex(hex), 0)).toThrow(WasmParseError); - }); -}); - describe("parseSections", () => { test.each([ ["a buffer that is not a wasm module", fromHex("6e6f742d7761736d")], From f4bb9ee68abf9692411c45d7d4ce6e0346270820 Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Wed, 16 Sep 2026 14:05:24 +0200 Subject: [PATCH 02/11] ref(wasm): reuse splitWasm for debug-files prepare - Add prepareWasmFile, classifying a module and splitting it when it has DWARF - Call the shared splitWasm instead of a second copy of the split logic - Read sections through binary predicates, not raw section constants - Own the build-id file I/O here, keeping build-id.ts free of disk access - Repair a pair split by another tool by reconciling one shared build id - Pin byte-for-byte parity with Symbolicator wasm-split via golden vectors Co-authored-by: Cursor --- packages/cli/src/lib/wasm/prepare.ts | 631 +++++++++++++++++++++ packages/cli/test/lib/wasm/prepare.test.ts | 426 ++++++++++++++ 2 files changed, 1057 insertions(+) create mode 100644 packages/cli/src/lib/wasm/prepare.ts create mode 100644 packages/cli/test/lib/wasm/prepare.test.ts diff --git a/packages/cli/src/lib/wasm/prepare.ts b/packages/cli/src/lib/wasm/prepare.ts new file mode 100644 index 000000000..af695427b --- /dev/null +++ b/packages/cli/src/lib/wasm/prepare.ts @@ -0,0 +1,631 @@ +/** + * Prepare WebAssembly modules for Sentry symbolication. + * + * A module compiled with DWARF carries its debug info inline, which is far too + * large to ship to users and useless to Sentry unless uploaded separately. + * Preparing a module splits it in two: a `*.debug.wasm` companion retaining + * every section, and a deployable module with the `.debug_*` sections removed. + * Both carry the same `build_id`, which is how Sentry matches a stack frame to + * its debug file. + * + * The companion must keep the Code section: DWARF addresses are relative to it, + * so a companion without it cannot be symbolicated. This mirrors the split + * performed by Symbolicator's `wasm-split`, so companions produced by either + * tool are interchangeable in Sentry. + */ + +import { readFile, writeFile } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join } from "node:path"; +import { logger } from "../logger.js"; +import { + BUILD_ID_SECTION, + decodeBuildId, + decodeExternalDebugInfo, + encodeModule, + isCodeSection, + isDebugSection, + isExternalDebugInfoSection, + isNameSection, + makeBuildIdSection, + parseSections, + type WasmSection, +} from "./binary.js"; +import { + buildIdFromSections, + formatBuildId, + randomBuildId, +} from "./build-id.js"; +import { splitWasm } from "./split.js"; + +const log = logger.withTag("wasm.prepare"); + +/** Suffix identifying a debug companion produced by this command. */ +const COMPANION_SUFFIX = ".debug.wasm"; + +/** Trailing `.wasm` extension, in any case. */ +const WASM_EXTENSION = /\.wasm$/i; + +/** How much debug information a module actually carries. */ +export type DebugQuality = + /** Inline DWARF: can be split into a companion. */ + | "dwarf" + /** Points at an external companion that was not found locally. */ + | "external-debug-info" + /** Function names only: no line-level symbolication. */ + | "symtab" + /** No debug information at all. */ + | "none"; + +/** What preparation did, or would do, to a module. */ +export type PrepareAction = + /** Module was split; a companion was written. */ + | "split" + /** Module would be split, but `--dry-run` suppressed all writes. */ + | "would-split" + /** A companion with a matching build id already exists. */ + | "already-prepared" + /** Module is not splittable; a warning explains why. */ + | "skipped"; + +/** What a module contains, as far as debug information is concerned. */ +export type WasmInspection = { + /** Classification driving the prepare decision. */ + quality: DebugQuality; + /** Existing build id, or `null` when the module has none. */ + buildId: Uint8Array | null; + /** Whether a Code section is present. */ + hasCode: boolean; + /** URL from `external_debug_info`, if the module carries one. */ + externalDebugInfo: string | null; +}; + +/** Outcome of preparing a single module. */ +export type PrepareResult = { + /** Path of the module that was inspected. */ + path: string; + /** What preparation did. */ + action: PrepareAction; + /** Debug classification of the module as found on disk. */ + quality: DebugQuality; + /** Effective build id in hex, absent only when a dry run skipped stamping. */ + buildId?: string; + /** Path of the companion, when one was written or already existed. */ + companion?: string; + /** Why the module was skipped, when it was. */ + warning?: string; + /** What to try next, when the skip looks like a build configuration issue. */ + recommendation?: string; +}; + +/** Outcome of a whole `debug-files prepare` run. */ +export type PrepareCommandResult = { + /** Organization slug. Omitted when nothing was uploaded. */ + org?: string; + /** Project slug. Omitted when nothing was uploaded. */ + project?: string; + /** Whether companions were actually uploaded. */ + uploaded: boolean; + /** Per-module outcome. */ + modules: PrepareResult[]; + /** Number of companions uploaded. */ + filesUploaded: number; +}; + +/** Options controlling preparation of one file. */ +export type PrepareOptions = { + /** Inspect and classify without writing anything. */ + dryRun?: boolean; + /** Directory for companions. Defaults to alongside the input. */ + outDir?: string; + /** Build id to inject when a module has none. Defaults to a random UUID. */ + buildId?: Uint8Array; + /** Also drop the `name` section from split deployable modules. */ + stripNames?: boolean; +}; + +/** + * Whether a quality means the module was built with DWARF. + * + * Mirrors `DebugQuality::has_dwarf`: a module pointing at an external companion + * counts, because the debug info exists somewhere even when it was not found + * locally. This is a statement about the build, not about whether Sentry can + * reach the debug info — callers that need the latter must also check that the + * companion resolved. + */ +export function hasDwarfQuality(quality: DebugQuality): boolean { + return quality === "dwarf" || quality === "external-debug-info"; +} + +/** Whether a path names a debug companion produced by this command. */ +export function isDebugCompanionPath(path: string): boolean { + return basename(path).toLowerCase().endsWith(COMPANION_SUFFIX); +} + +/** Whether a path names a WebAssembly module. */ +export function isWasmPath(path: string): boolean { + return basename(path).toLowerCase().endsWith(".wasm"); +} + +/** + * Companion path for a module: `app.wasm` becomes `app.debug.wasm`. + * + * @param wasmPath - Path of the input module. + * @param outDir - Directory to place the companion in, or omitted for + * alongside the input. + */ +export function companionPath(wasmPath: string, outDir?: string): string { + const name = basename(wasmPath).replace(WASM_EXTENSION, ""); + const fileName = `${name}${COMPANION_SUFFIX}`; + return join(outDir ?? dirname(wasmPath), fileName); +} + +/** Which debug-relevant sections a module turned out to contain. */ +type SectionSurvey = { + hasDwarf: boolean; + hasNameSection: boolean; + hasCode: boolean; + buildId: Uint8Array | null; + externalDebugInfo: string | null; +}; + +/** Record what one section contributes to the survey. */ +function surveySection(found: SectionSurvey, section: WasmSection): void { + if (isCodeSection(section)) { + found.hasCode = true; + } + if (!section.contents) { + return; + } + if (section.name === BUILD_ID_SECTION) { + found.buildId = decodeBuildId(section.contents); + } else if (isNameSection(section)) { + found.hasNameSection = true; + } else if (isExternalDebugInfoSection(section)) { + found.externalDebugInfo = decodeExternalDebugInfo(section.contents); + } else if (isDebugSection(section)) { + found.hasDwarf = true; + } +} + +/** + * Reduce a survey to a single quality, best first. + * + * Inline DWARF outranks an external pointer, which outranks names alone. + */ +function classifyDebugQuality(found: SectionSurvey): DebugQuality { + if (found.hasDwarf) { + return "dwarf"; + } + if (found.externalDebugInfo) { + return "external-debug-info"; + } + return found.hasNameSection ? "symtab" : "none"; +} + +/** + * Classify a module's debug information. + * + * @param bytes - Complete module bytes. + * @returns What the module contains. + * @throws {WasmParseError} If the buffer is not a well-formed module. + */ +export function inspectWasm(bytes: Uint8Array): WasmInspection { + const found: SectionSurvey = { + hasDwarf: false, + hasNameSection: false, + hasCode: false, + buildId: null, + externalDebugInfo: null, + }; + + for (const section of parseSections(bytes)) { + surveySection(found, section); + } + + return { + quality: classifyDebugQuality(found), + buildId: found.buildId, + hasCode: found.hasCode, + externalDebugInfo: found.externalDebugInfo, + }; +} + +/** + * Read a module's build id off disk. + * + * @returns The id, or `null` when the file is missing, unparseable, or carries + * no readable `build_id`. Callers treat all three the same way: there is no + * id here to match against. + */ +async function readCompanionBuildId(path: string): Promise { + try { + return buildIdFromSections(parseSections(await readFile(path))); + } catch (error) { + log.debug(`No readable build id at ${path}`, error); + return null; + } +} + +/** + * Give a module a build id, writing it back in place when it has none. + * + * `wasm-split` stamps every module it processes regardless of debug quality. + * Sentry matches a stack frame to its debug file by build id, so an unstamped + * module can never be symbolicated — not even from a debug file uploaded + * later. Stamping now keeps that option open. + * + * @param path - Module to stamp. + * @param sections - Sections already parsed from that module. + * @param existing - Id the module already carries, if any. + * @returns The effective build id, or `null` when a dry run left the module + * untouched. + */ +async function ensureBuildIdOnDisk( + path: string, + sections: WasmSection[], + existing: Uint8Array | null, + options: PrepareOptions +): Promise { + if (existing) { + return existing; + } + if (options.dryRun) { + return null; + } + const buildId = options.buildId ?? randomBuildId(); + await writeFile( + path, + encodeModule([...sections, makeBuildIdSection(buildId)]) + ); + return buildId; +} + +/** Whether two build ids are byte-identical. */ +function buildIdsMatch(a: Uint8Array | null, b: Uint8Array | null): boolean { + if (!(a && b) || a.length !== b.length) { + return false; + } + return a.every((byte, index) => byte === b[index]); +} + +/** Explain why a module of the given quality cannot be split. */ +function skipWarning(quality: DebugQuality): string | null { + switch (quality) { + case "dwarf": + return null; + case "external-debug-info": + return "has external_debug_info but no local companion with matching build_id"; + case "symtab": + return "no line-level symbolication (name/symtab only)"; + default: + return "no debug information; rebuild with DWARF (Emscripten -g, wasm-pack dwarf-debug-info)"; + } +} + +/** + * Suggest a next step for a skip the build can actually fix. + * + * Only missing or insufficient debug info points at the build: a module the + * compiler never emitted DWARF for, or one carrying names alone. An already + * stripped module and a dangling companion pointer are pipeline problems, so + * they get no suggestion here. + */ +function skipRecommendation(quality: DebugQuality): string | null { + if (quality === "symtab" || quality === "none") { + return "verify build flags emit DWARF"; + } + return null; +} + +/** + * Resolve an `external_debug_info` URL to a local path. + * + * Remote URLs have no local companion to check, so they resolve to nothing. + * Relative paths are taken against the module's own directory, which is how the + * companion filename written during a split is meant to be read. + */ +function resolveExternalDebugPath( + wasmPath: string, + url: string +): string | null { + if (url.startsWith("http://") || url.startsWith("https://")) { + return null; + } + return isAbsolute(url) ? url : join(dirname(wasmPath), url); +} + +/** An existing companion, and the quality to report for the pair. */ +type ExistingCompanion = { companion: string; quality: DebugQuality }; + +/** + * Detect a module that has already been prepared. + * + * A module counts as prepared when a companion carrying the same build id + * exists. Re-splitting such a module would replace a good companion with one + * holding no DWARF, so this check runs before the quality classification. + * + * The module's own `external_debug_info` pointer is followed first, so a + * companion named anything other than `.debug.wasm` (as `wasm-split` may + * produce) is still recognized. The conventional path is the fallback. + */ +async function findExistingCompanion( + wasmPath: string, + inspection: WasmInspection, + expectedCompanion: string +): Promise { + if (!inspection.buildId) { + return null; + } + + if (inspection.externalDebugInfo) { + const referenced = resolveExternalDebugPath( + wasmPath, + inspection.externalDebugInfo + ); + if ( + referenced && + buildIdsMatch(await readCompanionBuildId(referenced), inspection.buildId) + ) { + return { companion: referenced, quality: "external-debug-info" }; + } + } + + if ( + buildIdsMatch( + await readCompanionBuildId(expectedCompanion), + inspection.buildId + ) + ) { + return { companion: expectedCompanion, quality: inspection.quality }; + } + return null; +} + +/** + * Stamp a module that cannot be split, and report why it was left alone. + * + * @returns The skip, carrying the build id the module ends up with unless a + * dry run left it unstamped, or `null` when the module is splittable. + */ +async function reportSkip( + path: string, + sections: WasmSection[], + inspection: WasmInspection, + options: PrepareOptions +): Promise { + const warning = skipWarning(inspection.quality); + if (!warning) { + return null; + } + const buildId = await ensureBuildIdOnDisk( + path, + sections, + inspection.buildId, + options + ); + const recommendation = skipRecommendation(inspection.quality); + return { + path, + action: "skipped", + quality: inspection.quality, + ...(buildId ? { buildId: formatBuildId(buildId) } : {}), + warning, + ...(recommendation ? { recommendation } : {}), + }; +} + +/** + * Give a module and the companion it names a shared build id. + * + * A module carrying `external_debug_info` but no build id was split by a tool + * that never stamped it. The pair is intact apart from the id Sentry matches + * on, so it is repairable — and stamping the module alone would make it + * unrepairable, since a random id can never be reconciled with the companion. + * + * The companion is written first. A crash between the two writes then leaves + * the module unstamped, which the next run repairs; the reverse order would + * leave a stamped module pointing at an unstamped companion, which reads as a + * dangling pointer forever after. + * + * @returns The repaired pair, or `null` when there is nothing to repair and + * the caller should fall through to the normal skip path. + */ +async function repairUnpairedCompanion( + wasmPath: string, + sections: WasmSection[], + inspection: WasmInspection, + options: PrepareOptions +): Promise { + if (!inspection.externalDebugInfo) { + return null; + } + const companion = resolveExternalDebugPath( + wasmPath, + inspection.externalDebugInfo + ); + // A remote URL names no file this run can stamp. + if (!companion) { + return null; + } + + let companionSections: WasmSection[]; + let companionInspection: WasmInspection; + try { + const companionBytes = await readFile(companion); + companionSections = parseSections(companionBytes); + companionInspection = inspectWasm(companionBytes); + } catch (error) { + log.debug(`No usable companion at ${companion}`, error); + return null; + } + + // Without DWARF the companion is not a debug file, so pairing with it would + // upload nothing useful under an id the module now claims. + if (companionInspection.quality !== "dwarf") { + return null; + } + + const adopted = companionInspection.buildId; + const buildId = adopted ?? options.buildId ?? randomBuildId(); + + if (options.dryRun) { + return { + path: wasmPath, + action: "already-prepared", + quality: "external-debug-info", + // A generated id does not exist yet; only report one already on disk. + ...(adopted ? { buildId: formatBuildId(adopted) } : {}), + companion, + warning: "would reconcile build_id with companion", + }; + } + + if (!adopted) { + await writeFile( + companion, + encodeModule([...companionSections, makeBuildIdSection(buildId)]) + ); + } + await writeFile( + wasmPath, + encodeModule([...sections, makeBuildIdSection(buildId)]) + ); + + return { + path: wasmPath, + action: "already-prepared", + quality: "external-debug-info", + buildId: formatBuildId(buildId), + companion, + warning: adopted + ? "reconciled build_id with companion" + : "reconciled build_id with companion (stamped both files)", + }; +} + +/** + * Classify and, where possible, split a single `.wasm` file. + * + * Modules that cannot be split are stamped with a build id and reported with a + * warning rather than failing the run, so a scan over a build directory does + * not stop at the first module lacking DWARF. + * + * @param path - Path of the module to prepare. + * @param options - Dry-run, output directory, and stripping behaviour. + * @returns What was done, including the companion path when one was produced. + */ +export async function prepareWasmFile( + path: string, + options: PrepareOptions = {} +): Promise { + if (isDebugCompanionPath(path)) { + return { + path, + action: "skipped", + quality: "dwarf", + warning: "already a debug companion (*.debug.wasm); skipping", + }; + } + + const bytes = await readFile(path); + let sections: WasmSection[]; + let inspection: WasmInspection; + try { + sections = parseSections(bytes); + inspection = inspectWasm(bytes); + } catch (error) { + return { + path, + action: "skipped", + quality: "none", + warning: `not a valid WASM module: ${(error as Error).message}`, + }; + } + + const expectedCompanion = companionPath(path, options.outDir); + + if (inspection.quality !== "dwarf") { + const existing = await findExistingCompanion( + path, + inspection, + expectedCompanion + ); + if (existing) { + return { + path, + action: "already-prepared", + quality: existing.quality, + buildId: formatBuildId(inspection.buildId as Uint8Array), + companion: existing.companion, + }; + } + } + + // Repair before skipping: an unstamped module with a companion is a pair a + // previous tool left half-finished, not a module that cannot be prepared. + if (!inspection.buildId && inspection.quality === "external-debug-info") { + const repaired = await repairUnpairedCompanion( + path, + sections, + inspection, + options + ); + if (repaired) { + return repaired; + } + } + + const skipped = await reportSkip(path, sections, inspection, options); + if (skipped) { + return skipped; + } + + if (options.dryRun) { + return { + path, + action: "would-split", + quality: inspection.quality, + ...(inspection.buildId + ? { buildId: formatBuildId(inspection.buildId) } + : {}), + companion: expectedCompanion, + }; + } + + const split = splitWasm(bytes, { + companion: true, + strip: true, + ...(options.buildId ? { buildId: options.buildId } : {}), + stripNames: options.stripNames ?? false, + externalDebugInfo: basename(expectedCompanion), + }); + + // Write the companion first: if the process dies between the two writes, an + // orphan companion is recoverable, whereas a stripped module whose DWARF was + // never saved anywhere is not. + await writeFile(expectedCompanion, split.companion as Uint8Array); + await writeFile(path, split.module); + + return { + path, + action: "split", + quality: inspection.quality, + buildId: formatBuildId(split.buildId), + companion: expectedCompanion, + }; +} + +/** + * Path to upload to Sentry for a result, if it produced one. + * + * Only split modules yield a debug file. A name/symtab-only module keeps its + * `name` section in the deployable copy and runtimes resolve function names + * from it directly, so uploading it would add nothing the stack trace does not + * already carry. + */ +export function uploadPath(result: PrepareResult): string | null { + if (result.action === "split" || result.action === "already-prepared") { + return result.companion ?? null; + } + return null; +} diff --git a/packages/cli/test/lib/wasm/prepare.test.ts b/packages/cli/test/lib/wasm/prepare.test.ts new file mode 100644 index 000000000..19ec77dce --- /dev/null +++ b/packages/cli/test/lib/wasm/prepare.test.ts @@ -0,0 +1,426 @@ +/** + * Behavioural tests for WASM debug-file preparation. + * + * These cover the prepare decisions — what gets split, stamped, skipped, or + * reconciled — and the on-disk effect of each. The split primitive itself is + * covered by split.test.ts, and the envelope parser by binary.test.ts. + */ + +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { parseSections } from "../../../src/lib/wasm/binary.js"; +import { formatBuildId, uuidToBytes } from "../../../src/lib/wasm/build-id.js"; +import { + companionPath, + hasDwarfQuality, + inspectWasm, + prepareWasmFile, + uploadPath, +} from "../../../src/lib/wasm/prepare.js"; +import { splitWasm } from "../../../src/lib/wasm/split.js"; +import { + byteVector, + CODE_SECTION_ID, + customSection, + fromHex, + section, + toHex, + wasmModule, +} from "./helpers.js"; + +/** Deterministic build id used where the exact value matters. */ +const FIXED_UUID = "00000000-0000-4000-8000-000000000000"; + +/** A stand-in code section. Its contents are opaque to the parser. */ +function code(): Uint8Array { + return section(CODE_SECTION_ID, fromHex("00")); +} + +/** A DWARF debug section. */ +function dwarf(): Uint8Array { + return customSection(".debug_info", fromHex("0102")); +} + +/** A `build_id` section holding the given bytes. */ +function buildIdSection(buildId: Uint8Array): Uint8Array { + return customSection("build_id", byteVector(buildId)); +} + +/** An `external_debug_info` section pointing at `url`. */ +function externalDebugInfoSection(url: string): Uint8Array { + return customSection( + "external_debug_info", + byteVector(new TextEncoder().encode(url)) + ); +} + +/** Module with inline DWARF: the splittable case. */ +function dwarfModule(): Uint8Array { + return wasmModule([code(), dwarf()]); +} + +/** Module with function names only: no line-level debug info. */ +function nameOnlyModule(): Uint8Array { + return wasmModule([code(), customSection("name", fromHex("00"))]); +} + +/** Module with no debug information at all. */ +function emptyModule(): Uint8Array { + return wasmModule([code()]); +} + +/** Module already stripped: a build id but no debug sections. */ +function strippedModule(): Uint8Array { + return wasmModule([code(), buildIdSection(new Uint8Array(16).fill(0x07))]); +} + +/** Whether a module carries a custom section with the given name. */ +function hasSection(bytes: Uint8Array, name: string): boolean { + return parseSections(bytes).some((entry) => entry.name === name); +} + +/** Split a module the way `prepareWasmFile` does, for fixture setup. */ +function split(bytes: Uint8Array, companionName: string) { + return splitWasm(bytes, { + companion: true, + strip: true, + buildId: uuidToBytes(FIXED_UUID) as Uint8Array, + externalDebugInfo: companionName, + }); +} + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "wasm-prepare-")); +}); + +afterEach(() => { + dir = ""; +}); + +/** Write a module into the temp dir and return its path. */ +async function writeModule(name: string, bytes: Uint8Array): Promise { + const path = join(dir, name); + await writeFile(path, bytes); + return path; +} + +describe("hasDwarfQuality", () => { + test("counts an external companion pointer as having DWARF", () => { + // Mirrors DebugQuality::has_dwarf in the Rust CLI: this describes how the + // module was built, not whether its debug info can be reached. The + // --require-dwarf gate applies the reachability check on top. + expect(hasDwarfQuality("dwarf")).toBe(true); + expect(hasDwarfQuality("external-debug-info")).toBe(true); + expect(hasDwarfQuality("symtab")).toBe(false); + expect(hasDwarfQuality("none")).toBe(false); + }); +}); + +/** + * Golden vectors captured from Symbolicator's `wasm-split` (via `wasmbin`), + * splitting a module holding a single `.debug_info` section with the build id + * pinned to {@link FIXED_UUID}. + * + * Sentry treats companions from either tool identically, so these bytes are a + * compatibility contract, not an implementation detail. Decoded, the outputs + * confirm the two custom-section encodings this module depends on: `build_id` + * is a length-prefixed byte vector, `external_debug_info` a length-prefixed + * UTF-8 string. + */ +const WASM_SPLIT_INPUT = "0061736d0100000000100b2e64656275675f696e666f00010203"; +const WASM_SPLIT_STRIPPED = + "0061736d01000000001a086275696c645f6964100000000000004000800000000000000000231365787465726e616c5f64656275675f696e666f0e6170702e64656275672e7761736d"; +const WASM_SPLIT_COMPANION = + "0061736d0100000000100b2e64656275675f696e666f00010203001a086275696c645f69641000000000000040008000000000000000"; + +describe("wasm-split compatibility", () => { + test("produces byte-identical output to wasm-split", () => { + const result = split(fromHex(WASM_SPLIT_INPUT), "app.debug.wasm"); + + expect(toHex(result.module)).toBe(WASM_SPLIT_STRIPPED); + expect(toHex(result.companion as Uint8Array)).toBe(WASM_SPLIT_COMPANION); + }); + + test("reads back a module wasm-split wrote", () => { + const inspection = inspectWasm(fromHex(WASM_SPLIT_STRIPPED)); + + expect(inspection.quality).toBe("external-debug-info"); + expect(inspection.externalDebugInfo).toBe("app.debug.wasm"); + expect(toHex(inspection.buildId as Uint8Array)).toBe( + "00000000000040008000000000000000" + ); + }); +}); + +describe("prepareWasmFile", () => { + test("splits a module with DWARF", async () => { + const path = await writeModule("app.wasm", dwarfModule()); + const result = await prepareWasmFile(path); + + expect(result.action).toBe("split"); + expect(result.quality).toBe("dwarf"); + expect(existsSync(companionPath(path))).toBe(true); + expect(uploadPath(result)).toBe(companionPath(path)); + // The deployable keeps its original path so the build artifact does not move. + expect(hasSection(await readFile(path), ".debug_info")).toBe(false); + }); + + test("stamps a symtab-only module but uploads nothing", async () => { + const path = await writeModule("unity.wasm", nameOnlyModule()); + const result = await prepareWasmFile(path); + + expect(result.action).toBe("skipped"); + expect(result.quality).toBe("symtab"); + expect(result.warning).toContain("no line-level"); + expect(existsSync(companionPath(path))).toBe(false); + // Stamped so a later DWARF build can be matched, but the name section stays + // readable in the deployable, so there is nothing worth uploading. + expect(result.buildId).toBeDefined(); + expect(uploadPath(result)).toBeNull(); + expect(inspectWasm(await readFile(path)).buildId).not.toBeNull(); + }); + + test("stamps a module with no debug info", async () => { + const path = await writeModule("app.wasm", emptyModule()); + const result = await prepareWasmFile(path); + + expect(result.action).toBe("skipped"); + expect(result.quality).toBe("none"); + expect(result.warning).toContain("no debug information"); + expect(result.buildId).toBeDefined(); + }); + + test("does not re-split a module that only has a build id", async () => { + const path = await writeModule("app.wasm", strippedModule()); + const result = await prepareWasmFile(path); + + expect(result.action).toBe("skipped"); + expect(result.quality).toBe("none"); + expect(result.warning).toContain("no debug information"); + expect(existsSync(companionPath(path))).toBe(false); + }); + + test("recommends checking build flags when debug info is missing", async () => { + const missing = await prepareWasmFile( + await writeModule("empty.wasm", emptyModule()) + ); + const names = await prepareWasmFile( + await writeModule("names.wasm", nameOnlyModule()) + ); + + expect(missing.recommendation).toBe("verify build flags emit DWARF"); + expect(names.recommendation).toBe("verify build flags emit DWARF"); + }); + + test("detects an already-prepared pair on a second run", async () => { + const path = await writeModule("app.wasm", dwarfModule()); + const first = await prepareWasmFile(path); + const second = await prepareWasmFile(path); + + expect(first.action).toBe("split"); + expect(second.action).toBe("already-prepared"); + expect(second.buildId).toBe(first.buildId); + }); + + test("dry run writes nothing and stamps nothing", async () => { + const path = await writeModule("app.wasm", dwarfModule()); + const before = await readFile(path); + const result = await prepareWasmFile(path, { dryRun: true }); + + expect(result.action).toBe("would-split"); + expect(existsSync(companionPath(path))).toBe(false); + expect(await readFile(path)).toEqual(before); + }); + + test("dry run does not stamp a skipped module", async () => { + const path = await writeModule("unity.wasm", nameOnlyModule()); + const result = await prepareWasmFile(path, { dryRun: true }); + + expect(result.action).toBe("skipped"); + expect(result.buildId).toBeUndefined(); + expect(inspectWasm(await readFile(path)).buildId).toBeNull(); + }); + + test("uses an explicit build id", async () => { + const path = await writeModule("app.wasm", dwarfModule()); + const result = await prepareWasmFile(path, { + buildId: uuidToBytes(FIXED_UUID) as Uint8Array, + }); + + expect(result.buildId).toBe("00000000000040008000000000000000"); + }); + + test("out-dir redirects the companion but strips in place", async () => { + const outDir = await mkdtemp(join(tmpdir(), "wasm-out-")); + const path = await writeModule("app.wasm", dwarfModule()); + const result = await prepareWasmFile(path, { outDir }); + + expect(result.companion).toBe(join(outDir, "app.debug.wasm")); + expect(existsSync(join(outDir, "app.debug.wasm"))).toBe(true); + expect(existsSync(companionPath(path))).toBe(false); + // The deployed path is the one that ends up stripped and stamped. + expect(hasSection(await readFile(path), ".debug_info")).toBe(false); + expect(inspectWasm(await readFile(path)).buildId).not.toBeNull(); + }); + + test("skips a companion given as input", async () => { + const path = await writeModule("app.debug.wasm", dwarfModule()); + const result = await prepareWasmFile(path); + + expect(result.action).toBe("skipped"); + expect(result.warning).toContain("already a debug companion"); + }); + + test("recognizes a companion named by external_debug_info", async () => { + // A companion whose name does not follow the .debug.wasm convention + // is only findable by following the module's own pointer. + const result = split(dwarfModule(), "custom-name.wasm"); + const path = await writeModule("app.wasm", result.module); + await writeModule("custom-name.wasm", result.companion as Uint8Array); + + const prepared = await prepareWasmFile(path); + + expect(prepared.action).toBe("already-prepared"); + expect(prepared.quality).toBe("external-debug-info"); + expect(prepared.companion).toBe(join(dir, "custom-name.wasm")); + }); + + test("ignores a remote external_debug_info URL", async () => { + const result = split(dwarfModule(), "https://example.com/app.debug.wasm"); + const path = await writeModule("app.wasm", result.module); + + const prepared = await prepareWasmFile(path); + + // Nothing local to verify, so it is reported rather than claimed prepared. + expect(prepared.action).toBe("skipped"); + expect(prepared.quality).toBe("external-debug-info"); + }); + + test("skips a file that is not a WASM module", async () => { + const path = join(dir, "not-wasm.wasm"); + await writeFile(path, "definitely not wasm"); + const result = await prepareWasmFile(path); + + expect(result.action).toBe("skipped"); + expect(result.warning).toContain("not a valid WASM module"); + }); +}); + +describe("prepareWasmFile: pairs split by another tool", () => { + /** Deployable left by a tool that stripped DWARF without stamping a build id. */ + function unstampedModule(companionName: string): Uint8Array { + return wasmModule([code(), externalDebugInfoSection(companionName)]); + } + + /** Companion carrying DWARF, optionally already stamped. */ + function companionModule(buildId?: Uint8Array): Uint8Array { + const sections = [code(), dwarf()]; + if (buildId) { + sections.push(buildIdSection(buildId)); + } + return wasmModule(sections); + } + + /** Write an unstamped module and its companion; return both paths. */ + async function writePair( + companionBuildId?: Uint8Array + ): Promise<{ module: string; companion: string }> { + return { + module: await writeModule("app.wasm", unstampedModule("app.dbg.wasm")), + companion: await writeModule( + "app.dbg.wasm", + companionModule(companionBuildId) + ), + }; + } + + test("adopts the build id the companion already carries", async () => { + const expected = uuidToBytes(FIXED_UUID) as Uint8Array; + const { module, companion } = await writePair(expected); + const before = await readFile(companion); + + const result = await prepareWasmFile(module); + + expect(result.action).toBe("already-prepared"); + expect(result.buildId).toBe(formatBuildId(expected)); + expect(result.companion).toBe(companion); + expect(result.warning).toBe("reconciled build_id with companion"); + // The module gains the companion's id; the companion is left untouched. + const stamped = inspectWasm(await readFile(module)).buildId as Uint8Array; + expect(Uint8Array.from(stamped)).toEqual(expected); + expect(await readFile(companion)).toEqual(before); + }); + + test("stamps one shared id when neither file has one", async () => { + const { module, companion } = await writePair(); + + const result = await prepareWasmFile(module); + + expect(result.action).toBe("already-prepared"); + expect(result.warning).toContain("stamped both files"); + + const moduleId = inspectWasm(await readFile(module)).buildId; + const companionId = inspectWasm(await readFile(companion)).buildId; + expect(moduleId).not.toBeNull(); + expect(companionId).toEqual(moduleId); + expect(result.buildId).toBe(formatBuildId(moduleId as Uint8Array)); + }); + + test("uploads the repaired companion", async () => { + const { module, companion } = await writePair(); + + expect(uploadPath(await prepareWasmFile(module))).toBe(companion); + }); + + test("writes nothing on a dry run", async () => { + const { module, companion } = await writePair(); + const moduleBefore = await readFile(module); + const companionBefore = await readFile(companion); + + const result = await prepareWasmFile(module, { dryRun: true }); + + expect(result.warning).toBe("would reconcile build_id with companion"); + expect(await readFile(module)).toEqual(moduleBefore); + expect(await readFile(companion)).toEqual(companionBefore); + }); + + test("splits rather than reconciles when DWARF is still inline", async () => { + // A pointer left by a tool that never stripped the module: the DWARF is + // right here, so producing a real companion beats adopting a foreign id. + const module = await writeModule( + "app.wasm", + wasmModule([code(), dwarf(), externalDebugInfoSection("app.dbg.wasm")]) + ); + await writeModule("app.dbg.wasm", companionModule()); + + const result = await prepareWasmFile(module); + + expect(result.action).toBe("split"); + expect(hasSection(await readFile(module), ".debug_info")).toBe(false); + }); + + test("skips when the companion is missing", async () => { + const module = await writeModule("app.wasm", unstampedModule("gone.wasm")); + + const result = await prepareWasmFile(module); + + expect(result.action).toBe("skipped"); + expect(result.quality).toBe("external-debug-info"); + }); + + test("skips when the companion carries no DWARF", async () => { + const module = await writeModule("app.wasm", unstampedModule("bare.wasm")); + await writeModule("bare.wasm", emptyModule()); + + const result = await prepareWasmFile(module); + + // Pairing with it would claim an id for a file holding no debug info. + expect(result.action).toBe("skipped"); + expect(result.quality).toBe("external-debug-info"); + }); +}); From ada71a1776b7d5132dc4d14e35244c14336799ed Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Wed, 16 Sep 2026 14:26:20 +0200 Subject: [PATCH 03/11] ref(debug-files): share stamping, wait, and source-file helpers - Stamp build ids through splitWasm instead of rebuilding the section list - Drop a redundant parseSections per scanned module and per repaired companion - Move readSourceFile into read-file.ts, shared with debug-files upload - Add wait.ts, replacing the resolveWaitMode copy each upload path carried - Type the wait flags structurally so neither command imports the other's flags Co-authored-by: Cursor --- .../cli/src/commands/debug-files/read-file.ts | 23 ++++++++ .../cli/src/commands/debug-files/upload.ts | 48 ++--------------- packages/cli/src/commands/debug-files/wait.ts | 49 +++++++++++++++++ packages/cli/src/lib/wasm/prepare.ts | 54 +++++++++---------- 4 files changed, 102 insertions(+), 72 deletions(-) create mode 100644 packages/cli/src/commands/debug-files/wait.ts diff --git a/packages/cli/src/commands/debug-files/read-file.ts b/packages/cli/src/commands/debug-files/read-file.ts index ac54407b6..971062771 100644 --- a/packages/cli/src/commands/debug-files/read-file.ts +++ b/packages/cli/src/commands/debug-files/read-file.ts @@ -2,8 +2,12 @@ * Shared file reading for `debug-files` commands. */ +import { readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { ValidationError } from "../../lib/errors.js"; +import { logger } from "../../lib/logger.js"; + +const log = logger.withTag("debug-files.read-file"); /** * Read a debug information file from disk with descriptive error handling. @@ -30,3 +34,22 @@ export async function readDebugFile(path: string): Promise { throw new ValidationError(`Cannot read file '${path}': ${msg}`, "path"); } } + +/** + * Read a source file for source-bundle resolution. + * + * A debug file names the sources it was compiled from, but those sources are + * often absent on the machine running the upload. A missing one is expected, so + * it is skipped rather than failing the bundle. + * + * @param sourcePath - Path the debug file references. + * @returns The contents, or `null` when the file is not available locally. + */ +export function readSourceFile(sourcePath: string): Uint8Array | null { + try { + return readFileSync(sourcePath); + } catch (err) { + log.debug(`Source file not available, skipping: ${sourcePath}`, err); + return null; + } +} diff --git a/packages/cli/src/commands/debug-files/upload.ts b/packages/cli/src/commands/debug-files/upload.ts index afc8b6dcf..c5d1c3921 100644 --- a/packages/cli/src/commands/debug-files/upload.ts +++ b/packages/cli/src/commands/debug-files/upload.ts @@ -21,7 +21,7 @@ */ import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { basename, join } from "node:path"; import type { SentryContext } from "../../context.js"; @@ -31,7 +31,6 @@ import { getChunkUploadOptions, } from "../../lib/api/chunk-upload.js"; import { - DEBUG_FILES_MAX_WAIT_MS, type DebugFileUpload, type DebugFileUploadResult, uploadDebugFiles, @@ -48,7 +47,7 @@ import { prepareDifs, scanPaths, } from "../../lib/dif/scan.js"; -import { ContextError, ValidationError } from "../../lib/errors.js"; +import { ContextError } from "../../lib/errors.js"; import { colorTag, mdKvTable, @@ -57,6 +56,8 @@ import { import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { readSourceFile } from "./read-file.js"; +import { resolveWaitMode } from "./wait.js"; const log = logger.withTag("debug-files.upload"); @@ -177,19 +178,6 @@ function difKey(dif: DebugFileUpload): string { return `${dif.debugId ?? ""}:${hash}`; } -/** - * Read a source file from disk for source-bundle/IL2CPP resolution, returning - * `null` (and logging at debug level) when it is not available locally. - */ -function readSourceFile(sourcePath: string): Uint8Array | null { - try { - return readFileSync(sourcePath); - } catch (err) { - log.debug(`Source file not available, skipping: ${sourcePath}`, err); - return null; - } -} - /** * Compute a Unity IL2CPP line mapping for a prepared file and, when non-empty, * append it as a separate `il2cpp` DIF carrying the file's debug id. @@ -318,34 +306,6 @@ function missingRequestedIds( ); } -/** - * Resolve the wait mode and deadline from `--wait` / `--wait-for`. - * - * @throws {ValidationError} If both flags are set, or `--wait-for` is invalid. - */ -function resolveWaitMode(flags: UploadFlags): { - wait: boolean; - maxWaitMs: number; -} { - const waitFor = flags["wait-for"]; - if (flags.wait && waitFor !== undefined) { - throw new ValidationError( - "--wait and --wait-for cannot be combined", - "wait" - ); - } - if (waitFor !== undefined) { - if (!Number.isFinite(waitFor) || waitFor <= 0) { - throw new ValidationError( - "--wait-for must be a positive number of seconds", - "wait-for" - ); - } - return { wait: true, maxWaitMs: Math.round(waitFor * 1000) }; - } - return { wait: Boolean(flags.wait), maxWaitMs: DEBUG_FILES_MAX_WAIT_MS }; -} - // ── Command ───────────────────────────────────────────────────────── /** diff --git a/packages/cli/src/commands/debug-files/wait.ts b/packages/cli/src/commands/debug-files/wait.ts new file mode 100644 index 000000000..1eebb4622 --- /dev/null +++ b/packages/cli/src/commands/debug-files/wait.ts @@ -0,0 +1,49 @@ +/** + * Shared `--wait` / `--wait-for` handling for `debug-files` commands. + * + * Uploading is only half the job: the server still has to assemble and index + * what it received. Commands that upload therefore offer the same pair of + * flags, and they have to agree on what the pair means. + */ + +import { DEBUG_FILES_MAX_WAIT_MS } from "../../lib/api/debug-files.js"; +import { ValidationError } from "../../lib/errors.js"; + +/** The wait flags, as a command declares them. */ +export type WaitFlags = { + /** Wait for server-side processing, using the default deadline. */ + wait?: boolean; + /** Wait for server-side processing, in seconds. */ + "wait-for"?: number; +}; + +/** Whether to wait, and for how long. */ +export type WaitMode = { + wait: boolean; + maxWaitMs: number; +}; + +/** + * Resolve the wait mode and deadline from the flags. + * + * @throws {ValidationError} If both flags are set, or `--wait-for` is invalid. + */ +export function resolveWaitMode(flags: WaitFlags): WaitMode { + const waitFor = flags["wait-for"]; + if (flags.wait && waitFor !== undefined) { + throw new ValidationError( + "--wait and --wait-for cannot be combined", + "wait" + ); + } + if (waitFor !== undefined) { + if (!Number.isFinite(waitFor) || waitFor <= 0) { + throw new ValidationError( + "--wait-for must be a positive number of seconds", + "wait-for" + ); + } + return { wait: true, maxWaitMs: Math.round(waitFor * 1000) }; + } + return { wait: Boolean(flags.wait), maxWaitMs: DEBUG_FILES_MAX_WAIT_MS }; +} diff --git a/packages/cli/src/lib/wasm/prepare.ts b/packages/cli/src/lib/wasm/prepare.ts index af695427b..8fcddf7e3 100644 --- a/packages/cli/src/lib/wasm/prepare.ts +++ b/packages/cli/src/lib/wasm/prepare.ts @@ -21,12 +21,10 @@ import { BUILD_ID_SECTION, decodeBuildId, decodeExternalDebugInfo, - encodeModule, isCodeSection, isDebugSection, isExternalDebugInfoSection, isNameSection, - makeBuildIdSection, parseSections, type WasmSection, } from "./binary.js"; @@ -255,29 +253,38 @@ async function readCompanionBuildId(path: string): Promise { * later. Stamping now keeps that option open. * * @param path - Module to stamp. - * @param sections - Sections already parsed from that module. + * @param bytes - The module as read from disk. * @param existing - Id the module already carries, if any. * @returns The effective build id, or `null` when a dry run left the module * untouched. */ async function ensureBuildIdOnDisk( path: string, - sections: WasmSection[], + bytes: Uint8Array, existing: Uint8Array | null, options: PrepareOptions ): Promise { + // Checked before splitting so an already-stamped module is never re-encoded. if (existing) { return existing; } if (options.dryRun) { return null; } - const buildId = options.buildId ?? randomBuildId(); - await writeFile( - path, - encodeModule([...sections, makeBuildIdSection(buildId)]) - ); - return buildId; + const stamped = stampBuildId(bytes, options.buildId); + await writeFile(path, stamped.module); + return stamped.buildId; +} + +/** + * Stamp a module with a build id, changing nothing else. + * + * A split with neither `strip` nor `companion` is exactly that, so stamping + * shares one implementation with the real split rather than reassembling the + * section list by hand. + */ +function stampBuildId(bytes: Uint8Array, buildId?: Uint8Array) { + return splitWasm(bytes, { ...(buildId ? { buildId } : {}) }); } /** Whether two build ids are byte-identical. */ @@ -389,7 +396,7 @@ async function findExistingCompanion( */ async function reportSkip( path: string, - sections: WasmSection[], + bytes: Uint8Array, inspection: WasmInspection, options: PrepareOptions ): Promise { @@ -399,7 +406,7 @@ async function reportSkip( } const buildId = await ensureBuildIdOnDisk( path, - sections, + bytes, inspection.buildId, options ); @@ -432,7 +439,7 @@ async function reportSkip( */ async function repairUnpairedCompanion( wasmPath: string, - sections: WasmSection[], + bytes: Uint8Array, inspection: WasmInspection, options: PrepareOptions ): Promise { @@ -448,11 +455,10 @@ async function repairUnpairedCompanion( return null; } - let companionSections: WasmSection[]; + let companionBytes: Uint8Array; let companionInspection: WasmInspection; try { - const companionBytes = await readFile(companion); - companionSections = parseSections(companionBytes); + companionBytes = await readFile(companion); companionInspection = inspectWasm(companionBytes); } catch (error) { log.debug(`No usable companion at ${companion}`, error); @@ -481,15 +487,9 @@ async function repairUnpairedCompanion( } if (!adopted) { - await writeFile( - companion, - encodeModule([...companionSections, makeBuildIdSection(buildId)]) - ); + await writeFile(companion, stampBuildId(companionBytes, buildId).module); } - await writeFile( - wasmPath, - encodeModule([...sections, makeBuildIdSection(buildId)]) - ); + await writeFile(wasmPath, stampBuildId(bytes, buildId).module); return { path: wasmPath, @@ -528,10 +528,8 @@ export async function prepareWasmFile( } const bytes = await readFile(path); - let sections: WasmSection[]; let inspection: WasmInspection; try { - sections = parseSections(bytes); inspection = inspectWasm(bytes); } catch (error) { return { @@ -566,7 +564,7 @@ export async function prepareWasmFile( if (!inspection.buildId && inspection.quality === "external-debug-info") { const repaired = await repairUnpairedCompanion( path, - sections, + bytes, inspection, options ); @@ -575,7 +573,7 @@ export async function prepareWasmFile( } } - const skipped = await reportSkip(path, sections, inspection, options); + const skipped = await reportSkip(path, bytes, inspection, options); if (skipped) { return skipped; } From d765ed7fca565ec71a2084255dcd9859e9ab30be Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Wed, 16 Sep 2026 15:10:51 +0200 Subject: [PATCH 04/11] feat(debug-files): add prepare command with markdown output - Add debug-files prepare, registering it in the debug-files route map - Render one key-value table per module through the markdown pipeline - Keep each warning and recommendation in its own module's table - Colour the skip label and warnings with colorTag, never raw chalk - Print debug quality as the JSON payload spells it, dropping the remap - Gate uploads on --require-dwarf before any file is sent - Refresh the generated skill reference and command docs Co-authored-by: Cursor --- .../cli-docs/src/content/docs/contributing.md | 2 +- .../src/fragments/commands/debug-files.md | 39 ++ .../sentry-cli/skills/sentry-cli/SKILL.md | 1 + .../sentry-cli/references/debug-files.md | 32 ++ .../cli/src/commands/debug-files/index.ts | 2 + .../cli/src/commands/debug-files/prepare.ts | 540 ++++++++++++++++++ .../cli/src/lib/formatters/wasm-prepare.ts | 94 +++ .../test/commands/debug-files/prepare.test.ts | 191 +++++++ 8 files changed, 900 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/debug-files/prepare.ts create mode 100644 packages/cli/src/lib/formatters/wasm-prepare.ts create mode 100644 packages/cli/test/commands/debug-files/prepare.test.ts diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 3e932f7cc..773db426f 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -60,7 +60,7 @@ cli/ │ │ ├── code-mappings/# upload │ │ ├── dart-symbol-map/# upload │ │ ├── dashboard/ # add, create, delete, edit, list, restore, revisions, view -│ │ ├── debug-files/ # bundle-jvm, bundle-sources, check, find, print-sources, upload +│ │ ├── debug-files/ # bundle-jvm, bundle-sources, check, find, prepare, print-sources, upload │ │ ├── docs/ # list, query │ │ ├── event/ # list, send, view │ │ ├── feedback/ # list, view diff --git a/apps/cli-docs/src/fragments/commands/debug-files.md b/apps/cli-docs/src/fragments/commands/debug-files.md index 979839c15..0bbc42bdf 100644 --- a/apps/cli-docs/src/fragments/commands/debug-files.md +++ b/apps/cli-docs/src/fragments/commands/debug-files.md @@ -49,8 +49,47 @@ sentry debug-files upload ./build --il2cpp-mapping --include-sources # Preview what would be uploaded without uploading (no credentials needed) sentry debug-files upload ./build --no-upload + +# Split WebAssembly debug info and upload it (scans directories recursively) +sentry debug-files prepare ./dist + +# Preview the split without writing or uploading anything +sentry debug-files prepare ./dist --dry-run + +# Split only, keeping the companions local +sentry debug-files prepare ./dist --no-upload + +# Write companions elsewhere; modules are still stripped in place +sentry debug-files prepare ./dist --out-dir ./symbols + +# Fail the build if any module was compiled without DWARF +sentry debug-files prepare ./dist --require-dwarf ``` +## Notes on `prepare` + +- `debug-files prepare` replaces the two-step `wasm-split` + `debug-files + upload` workflow for WebAssembly. Its output is byte-identical to + `wasm-split`, so companions from either tool behave the same in Sentry. +- For each module carrying inline DWARF it injects a `build_id` (if absent), + writes a `*.debug.wasm` companion retaining the Code section and DWARF, + strips the `.debug_*` sections from the deployable module **in place**, and + points it at the companion via `external_debug_info`. Your build artifact + keeps its path; only the companion is new. +- The companion must keep the Code section — DWARF addresses are relative to it, + so a companion without it cannot be symbolicated. +- Modules without DWARF are still stamped with a `build_id` and reported with a + warning rather than failing the run. Sentry matches a frame to its debug file + by `build_id`, so stamping now keeps symbolication possible later. +- Name/symtab-only modules are not uploaded: the `name` section stays in the + deployable module and runtimes read function names from it directly, so a + debug file built from one adds nothing to the stack trace. +- Running the command twice is safe. A module whose companion already exists + with a matching `build_id` is reported as already prepared and left alone. +- `--require-dwarf` exits non-zero when any scanned module lacks DWARF, which is + the flag to use in CI. A module whose `external_debug_info` names a companion + that cannot be found fails the gate too, since its debug info is unreachable. + ## Notes on `find` - `debug-files find` locates debug files **locally** by debug identifier — it diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 9fb20ac1f..c502351df 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -479,6 +479,7 @@ Work with debug information files - `sentry debug-files check ` — Inspect a debug information file - `sentry debug-files find ` — Locate debug files for given debug identifiers +- `sentry debug-files prepare ` — Split WebAssembly debug info and upload it to Sentry - `sentry debug-files upload ` — Upload debug information files to Sentry - `sentry debug-files print-sources ` — List the source files a debug file references - `sentry debug-files bundle-sources ` — Bundle a debug file's source files for source context diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md index 4daef1ece..482b78fab 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md @@ -25,6 +25,23 @@ Locate debug files for given debug identifiers - `--no-cwd - Do not look for debug files in the current directory` - `-p, --path ... - Add a directory to search recursively (repeatable)` +### `sentry debug-files prepare ` + +Split WebAssembly debug info and upload it to Sentry + +**Flags:** +- `--dry-run - Classify modules without writing or uploading anything` +- `--no-upload - Split modules but do not upload the companions` +- `--require-dwarf - Fail if any scanned module lacks DWARF debug info` +- `--out-dir - Directory for *.debug.wasm companions (modules are stripped in place)` +- `--strip-names - Also drop the name section from split modules (companion keeps it)` +- `--build-id - Use this UUID as the build id instead of a random one` +- `--include-sources - Also upload a source bundle for each companion` +- `--ignore ... - Skip files and folders matching this glob (repeatable)` +- `--ignore-file - Skip files and folders listed in this ignore file` +- `--wait - Wait for server-side processing and report any errors` +- `--wait-for - Wait up to this many seconds for server-side processing` + ### `sentry debug-files upload ` Upload debug information files to Sentry @@ -112,6 +129,21 @@ sentry debug-files upload ./build --il2cpp-mapping --include-sources # Preview what would be uploaded without uploading (no credentials needed) sentry debug-files upload ./build --no-upload + +# Split WebAssembly debug info and upload it (scans directories recursively) +sentry debug-files prepare ./dist + +# Preview the split without writing or uploading anything +sentry debug-files prepare ./dist --dry-run + +# Split only, keeping the companions local +sentry debug-files prepare ./dist --no-upload + +# Write companions elsewhere; modules are still stripped in place +sentry debug-files prepare ./dist --out-dir ./symbols + +# Fail the build if any module was compiled without DWARF +sentry debug-files prepare ./dist --require-dwarf ``` All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/src/commands/debug-files/index.ts b/packages/cli/src/commands/debug-files/index.ts index 6584544a5..54f168f78 100644 --- a/packages/cli/src/commands/debug-files/index.ts +++ b/packages/cli/src/commands/debug-files/index.ts @@ -9,6 +9,7 @@ import { bundleJvmCommand } from "./bundle-jvm.js"; import { bundleSourcesCommand } from "./bundle-sources.js"; import { checkCommand } from "./check.js"; import { findCommand } from "./find.js"; +import { prepareCommand } from "./prepare.js"; import { printSourcesCommand } from "./print-sources.js"; import { uploadCommand } from "./upload.js"; @@ -16,6 +17,7 @@ export const debugFilesRoute = buildRouteMap({ routes: { check: checkCommand, find: findCommand, + prepare: prepareCommand, upload: uploadCommand, "print-sources": printSourcesCommand, "bundle-sources": bundleSourcesCommand, diff --git a/packages/cli/src/commands/debug-files/prepare.ts b/packages/cli/src/commands/debug-files/prepare.ts new file mode 100644 index 000000000..fc755fd0c --- /dev/null +++ b/packages/cli/src/commands/debug-files/prepare.ts @@ -0,0 +1,540 @@ +/** + * sentry debug-files prepare ... + * + * Scan for WebAssembly modules, split the ones carrying inline DWARF into a + * deployable module plus a `*.debug.wasm` companion, and upload the companions + * to Sentry. + * + * This replaces the two-step `wasm-split` + `debug-files upload` workflow. The + * split matches what Symbolicator's `wasm-split` produces, so companions from + * either tool are interchangeable in Sentry. + * + * Modules that cannot be split are stamped with a `build_id` and reported with + * a warning rather than failing the run, so scanning a build directory does not + * stop at the first module without DWARF. Use `--require-dwarf` in CI to turn + * that into an error. + */ + +import { readFile, stat } from "node:fs/promises"; +import { basename, relative, resolve, sep } from "node:path"; +import type { SentryContext } from "../../context.js"; +import { + type ChunkServerOptions, + getChunkUploadOptions, +} from "../../lib/api/chunk-upload.js"; +import { + type DebugFileUpload, + uploadDebugFiles, +} from "../../lib/api/debug-files.js"; +import { buildCommand } from "../../lib/command.js"; +import { createSourceBundle } from "../../lib/dif/index.js"; +import { scanPaths } from "../../lib/dif/scan.js"; +import { ContextError, ValidationError } from "../../lib/errors.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { formatPrepareResult } from "../../lib/formatters/wasm-prepare.js"; +import { logger } from "../../lib/logger.js"; +import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { + type CompiledMatcher, + compileMatchers, + matchesAny, +} from "../../lib/scan/path-utils.js"; +import { debugIdFromBuildId, uuidToBytes } from "../../lib/wasm/build-id.js"; +import { + hasDwarfQuality, + isDebugCompanionPath, + isWasmPath, + type PrepareCommandResult, + type PrepareResult, + prepareWasmFile, + uploadPath, +} from "../../lib/wasm/prepare.js"; +import { readSourceFile } from "./read-file.js"; +import { resolveWaitMode } from "./wait.js"; + +const log = logger.withTag("debug-files.prepare"); + +const USAGE_HINT = "sentry debug-files prepare ..."; + +/** Flags accepted by the prepare command. */ +type PrepareFlags = { + "dry-run"?: boolean; + "no-upload"?: boolean; + "require-dwarf"?: boolean; + "out-dir"?: string; + "strip-names"?: boolean; + "build-id"?: string; + "include-sources"?: boolean; + ignore?: string[]; + "ignore-file"?: string; + wait?: boolean; + "wait-for"?: number; +}; + +/** + * Resolve the wait mode and deadline from `--wait` / `--wait-for`. +/** + * Parse the explicit `--build-id` value into raw bytes. + * + * @throws {ValidationError} If the value is not a UUID. + */ +function resolveBuildId(value: string | undefined): Uint8Array | undefined { + if (value === undefined) { + return; + } + const bytes = uuidToBytes(value); + if (!bytes) { + throw new ValidationError( + `--build-id must be a UUID, got '${value}'`, + "build-id" + ); + } + return bytes; +} + +/** + * Whether a module fails the `--require-dwarf` gate. + * + * Only skipped modules can fail: a split or already-prepared module reached the + * end state the flag is checking for. + * + * A skipped `external_debug_info` module is a dangling pointer by + * construction — had its companion been found with a matching build id, it + * would have been reported as already-prepared. The DWARF exists somewhere, + * but nothing reachable was uploaded, so the build symbolicates no better than + * one compiled without debug info and the gate has to catch it. + */ +export function lacksDwarf(result: PrepareResult): boolean { + if (result.action !== "skipped") { + return false; + } + return ( + result.quality === "external-debug-info" || !hasDwarfQuality(result.quality) + ); +} + +/** Yield a result that uploaded nothing, then close with the given hint. */ +function* reportWithoutUpload(params: { + results: PrepareResult[]; + hint: string; +}) { + yield new CommandOutput({ + uploaded: false, + modules: params.results, + filesUploaded: 0, + }); + return { hint: params.hint }; +} + +/** Upload the companions, yield the combined result, and pick a hint. */ +async function* reportUpload( + setExitCode: (code: number) => void, + params: { + org: string; + project: string; + difs: DebugFileUpload[]; + results: PrepareResult[]; + wait: boolean; + maxWaitMs: number; + serverOptions: ChunkServerOptions; + } +) { + const uploads = await uploadDebugFiles(params); + const failures = uploads.filter( + (r) => r.state === "error" || r.state === "not_found" + ); + + yield new CommandOutput({ + org: params.org, + project: params.project, + uploaded: true, + modules: params.results, + filesUploaded: uploads.length - failures.length, + }); + + if (failures.length > 0) { + setExitCode(1); + const details = failures + .map((r) => `${r.debugId ?? r.name}: ${r.state}`) + .join("; "); + return { hint: `${failures.length} companion(s) failed: ${details}` }; + } + + return { + hint: `Uploaded ${uploads.length} debug companion(s) to ${params.org}/${params.project}`, + }; +} + +/** + * Whether a module has a companion, or would get one on a real run. + * + * Unlike {@link uploadPath} this counts `would-split`, so a dry run reports the + * work it would do rather than the (always empty) set it would upload. + */ +function producesCompanion(result: PrepareResult): boolean { + return ( + result.action === "split" || + result.action === "would-split" || + result.action === "already-prepared" + ); +} + +/** + * Append a source bundle for a companion, when it references sources available + * on this machine. + * + * The companion is where the DWARF lives, so it is the only artifact that can + * name the source files to collect. Failures are logged and swallowed so a + * missing source tree never aborts the upload. + */ +function appendSourceBundle( + difs: DebugFileUpload[], + companion: Uint8Array, + name: string, + debugId: string | undefined +): void { + let result: ReturnType; + try { + result = createSourceBundle(companion, name, readSourceFile); + } catch (err) { + log.debug(`Could not build source bundle for ${name}`, err); + return; + } + if (!(result.bundle && result.fileCount > 0)) { + return; + } + const bundleDebugId = debugId ?? result.debugId ?? undefined; + difs.push({ + name: `${bundleDebugId ?? name}.src.zip`, + debugId: bundleDebugId, + content: Buffer.from(result.bundle), + }); +} + +/** Read each companion from disk and turn it into an upload entry. */ +async function collectCompanionDifs( + results: PrepareResult[], + includeSources: boolean +): Promise { + const difs: DebugFileUpload[] = []; + for (const result of results) { + const path = uploadPath(result); + if (!path) { + continue; + } + const content = await readFile(path); + // The server re-parses the bytes and indexes every slice itself, so the id + // is advisory; send it in the canonical dashed form or not at all. + const debugId = result.buildId + ? debugIdFromBuildId(result.buildId) + : undefined; + difs.push({ name: basename(path), debugId, content }); + + if (includeSources) { + appendSourceBundle( + difs, + new Uint8Array(content), + basename(path), + debugId + ); + } + } + return difs; +} + +/** + * Compile `--ignore` globs and `--ignore-file` entries into path matchers. + * + * The ignore file is read as gitignore-style lines: blanks and `#` comments are + * dropped and every other line is treated as a glob. Patterns are not split on + * commas, because a glob's brace group (`{a,b}`) legitimately contains them. + */ +async function buildIgnoreMatchers( + ignores: string[] | undefined, + ignoreFile: string | undefined +): Promise { + const patterns = [...(ignores ?? [])]; + if (ignoreFile) { + const contents = await readFile(ignoreFile, "utf-8"); + for (const line of contents.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length > 0 && !trimmed.startsWith("#")) { + patterns.push(trimmed); + } + } + } + return compileMatchers(patterns); +} + +/** + * Path of a scanned file relative to the scan root it came from. + * + * Ignore globs are written against the tree the user pointed at — `--ignore + * 'vendor/**'` for `prepare ./dist` means `./dist/vendor` — so they must be + * tested against a root-relative path rather than one relative to the process + * working directory. Separators are normalized to `/` because glob patterns + * always use them. + */ +function pathRelativeToRoot(path: string, roots: string[]): string { + const resolved = resolve(path); + let deepestRoot = ""; + for (const root of roots) { + const rootPath = resolve(root); + const contains = + resolved === rootPath || resolved.startsWith(`${rootPath}${sep}`); + if (contains && rootPath.length > deepestRoot.length) { + deepestRoot = rootPath; + } + } + // A root naming the file itself leaves nothing relative to match on, so fall + // back to the basename. + const rel = deepestRoot ? relative(deepestRoot, resolved) : ""; + return (rel === "" ? basename(resolved) : rel).split(sep).join("/"); +} + +/** Whether a scanned path is excluded by the ignore matchers. */ +function isIgnored( + path: string, + matchers: CompiledMatcher[], + roots: string[] +): boolean { + if (matchers.length === 0) { + return false; + } + return matchesAny(matchers, pathRelativeToRoot(path, roots), basename(path)); +} + +/** + * Reject an explicitly named file that is not a WebAssembly module. + * + * A directory is scanned recursively and simply yields nothing, but naming a + * non-`.wasm` file directly is a mistake worth reporting rather than silently + * treating as an empty scan. + * + * @throws {ValidationError} If an explicit path is a file without a `.wasm` + * extension. + */ +async function assertWasmPaths(paths: string[]): Promise { + for (const path of paths) { + const info = await stat(path).catch(() => null); + if (info?.isFile() && !isWasmPath(path)) { + throw new ValidationError( + `Expected a .wasm file or a directory, but got ${path}`, + "path" + ); + } + } +} + +export const prepareCommand = buildCommand({ + // Auth is only needed on the upload path; --dry-run and --no-upload skip it. + auth: false, + docs: { + brief: "Split WebAssembly debug info and upload it to Sentry", + fullDescription: + "Scan files and directories for WebAssembly modules, split the ones " + + "carrying inline DWARF, and upload the debug companions to Sentry.\n\n" + + "For each module with DWARF this injects a build_id (if absent), writes " + + "a *.debug.wasm companion retaining the Code section and DWARF, strips " + + "the .debug_* sections from the deployable module in place, and points " + + "it at the companion via external_debug_info. The deployable module " + + "keeps its original path, so your build artifact does not move.\n\n" + + "Modules without DWARF are stamped with a build_id and reported with a " + + "warning instead of failing the run. Running the command twice is safe: " + + "an already-prepared module is detected and left alone.\n\n" + + "Org/project are auto-detected from DSN, env vars, or config defaults.\n\n" + + "--require-dwarf is checked before anything is uploaded, so a build " + + "missing debug info fails without pushing files first. A module whose " + + "external_debug_info points at a companion that cannot be found fails " + + "too: its debug info is unreachable.\n\n" + + "Usage:\n" + + " sentry debug-files prepare ./dist\n" + + " sentry debug-files prepare ./app.wasm --no-upload\n" + + " sentry debug-files prepare ./dist --dry-run\n" + + " sentry debug-files prepare ./dist --require-dwarf\n" + + " sentry debug-files prepare ./dist --out-dir ./symbols\n" + + " sentry debug-files prepare ./dist --include-sources\n" + + " sentry debug-files prepare ./dist --ignore 'vendor/**'", + }, + output: { + human: formatPrepareResult, + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "WebAssembly files or directories to scan", + parse: String, + placeholder: "path", + }, + }, + flags: { + "dry-run": { + kind: "boolean", + brief: "Classify modules without writing or uploading anything", + optional: true, + default: false, + }, + "no-upload": { + kind: "boolean", + brief: "Split modules but do not upload the companions", + optional: true, + default: false, + }, + "require-dwarf": { + kind: "boolean", + brief: "Fail if any scanned module lacks DWARF debug info", + optional: true, + default: false, + }, + "out-dir": { + kind: "parsed", + parse: String, + brief: + "Directory for *.debug.wasm companions (modules are stripped in place)", + optional: true, + }, + "strip-names": { + kind: "boolean", + brief: + "Also drop the name section from split modules (companion keeps it)", + optional: true, + default: false, + }, + "build-id": { + kind: "parsed", + parse: String, + brief: "Use this UUID as the build id instead of a random one", + optional: true, + }, + "include-sources": { + kind: "boolean", + brief: "Also upload a source bundle for each companion", + optional: true, + default: false, + }, + ignore: { + kind: "parsed", + parse: String, + brief: "Skip files and folders matching this glob (repeatable)", + optional: true, + variadic: true, + }, + "ignore-file": { + kind: "parsed", + parse: String, + brief: "Skip files and folders listed in this ignore file", + optional: true, + }, + wait: { + kind: "boolean", + brief: "Wait for server-side processing and report any errors", + optional: true, + default: false, + }, + "wait-for": { + kind: "parsed", + parse: Number, + brief: "Wait up to this many seconds for server-side processing", + optional: true, + }, + }, + }, + async *func(this: SentryContext, flags: PrepareFlags, ...paths: string[]) { + if (paths.length === 0) { + throw new ContextError("WebAssembly path(s)", USAGE_HINT, []); + } + const { wait, maxWaitMs } = resolveWaitMode(flags); + const buildId = resolveBuildId(flags["build-id"]); + const dryRun = Boolean(flags["dry-run"]); + const setExitCode = (code: number) => { + this.process.exitCode = code; + }; + + await assertWasmPaths(paths); + const ignoreMatchers = await buildIgnoreMatchers( + flags.ignore, + flags["ignore-file"] + ); + + // Companions are inputs to a previous run, never candidates themselves. + const candidates = (await scanPaths(paths)).filter( + (path) => + isWasmPath(path) && + !isDebugCompanionPath(path) && + !isIgnored(path, ignoreMatchers, paths) + ); + + if (candidates.length === 0) { + log.warn("No WebAssembly modules found."); + return yield* reportWithoutUpload({ + results: [], + hint: `No .wasm modules found. Try: ${USAGE_HINT}`, + }); + } + + const results: PrepareResult[] = []; + for (const path of candidates) { + results.push( + await prepareWasmFile(path, { + dryRun, + outDir: flags["out-dir"], + buildId, + stripNames: flags["strip-names"], + }) + ); + } + + // The DWARF gate runs before anything is uploaded: a build missing debug + // info should fail cleanly rather than push files and then go red. + const missingDwarf = results.filter(lacksDwarf); + if (flags["require-dwarf"] && missingDwarf.length > 0) { + setExitCode(1); + return yield* reportWithoutUpload({ + results, + hint: `${missingDwarf.length} module(s) have no reachable DWARF debug info (--require-dwarf).`, + }); + } + + const prepared = results.filter(producesCompanion).length; + + // A dry run reports what it found and stops before any upload. + if (dryRun || flags["no-upload"]) { + return yield* reportWithoutUpload({ + results, + hint: dryRun + ? `Would prepare ${prepared} module(s). Remove --dry-run to write them.` + : `Prepared ${prepared} module(s). Remove --no-upload to upload the companions.`, + }); + } + + const difs = await collectCompanionDifs( + results, + Boolean(flags["include-sources"]) + ); + if (difs.length === 0) { + return yield* reportWithoutUpload({ + results, + hint: "No debug companions to upload.", + }); + } + + const resolved = await resolveOrgAndProject({ + cwd: this.cwd, + usageHint: USAGE_HINT, + }); + if (!resolved) { + throw new ContextError("Organization and project", USAGE_HINT); + } + + return yield* reportUpload(setExitCode, { + org: resolved.org, + project: resolved.project, + difs, + results, + wait, + maxWaitMs, + serverOptions: await getChunkUploadOptions(resolved.org), + }); + }, +}); diff --git a/packages/cli/src/lib/formatters/wasm-prepare.ts b/packages/cli/src/lib/formatters/wasm-prepare.ts new file mode 100644 index 000000000..474d04f27 --- /dev/null +++ b/packages/cli/src/lib/formatters/wasm-prepare.ts @@ -0,0 +1,94 @@ +/** + * Human-readable report for `sentry debug-files prepare`. + * + * One table per module, so a warning stays next to the module it belongs to. + * A build directory can hold dozens of modules, and a flat list of lines makes + * it easy to read a warning against the wrong path. + */ + +import type { + PrepareAction, + PrepareCommandResult, + PrepareResult, +} from "../wasm/prepare.js"; +import { + colorTag, + escapeMarkdownCell, + escapeMarkdownInline, + mdKvTable, + renderMarkdown, + safeCodeSpan, +} from "./markdown.js"; + +/** What preparation did to a module, in the past tense. */ +const ACTION_LABELS: Record = { + split: "Split", + "would-split": "Would split", + "already-prepared": "Already prepared", + skipped: colorTag("red", "Skipped"), +}; + +/** + * One module as a heading plus a key-value table. + * + * The path is the heading, in full rather than as a basename: a build tree + * routinely holds several same-named modules. + */ +function moduleSection(module: PrepareResult): string { + const rows: [string, string][] = [ + ["Action", ACTION_LABELS[module.action]], + ["Debug quality", module.quality], + ]; + if (module.buildId) { + rows.push(["Build ID", safeCodeSpan(module.buildId)]); + } + if (module.companion) { + rows.push(["Companion", safeCodeSpan(module.companion)]); + } + if (module.warning) { + rows.push([ + colorTag("yellow", "Warning"), + escapeMarkdownCell(module.warning), + ]); + } + if (module.recommendation) { + rows.push([ + colorTag("cyan", "Recommendation"), + escapeMarkdownCell(module.recommendation), + ]); + } + return mdKvTable(rows, escapeMarkdownInline(module.path)); +} + +/** + * Counts opening the report. + * + * Deliberately short: per-module facts belong in the tables below, so this only + * states how much was scanned and, when relevant, how much was uploaded. + */ +function summaryLines(data: PrepareCommandResult): string[] { + const scanned = data.modules.length; + const lines = [ + `## Found ${scanned} ${scanned === 1 ? "wasm file" : "wasm files"}`, + ]; + if (data.uploaded) { + const uploaded = data.filesUploaded; + const target = + data.org && data.project ? ` to ${data.org}/${data.project}` : ""; + lines.push( + "", + `Uploaded ${uploaded} ${uploaded === 1 ? "companion" : "companions"}${target}.` + ); + } + return lines; +} + +/** Format human-readable output for the prepare result. */ +export function formatPrepareResult(data: PrepareCommandResult): string { + const sections = data.modules.map(moduleSection); + return renderMarkdown( + [...summaryLines(data), ...sections.flatMap((section) => ["", section])] + .join("\n") + .trim() + ); +} diff --git a/packages/cli/test/commands/debug-files/prepare.test.ts b/packages/cli/test/commands/debug-files/prepare.test.ts new file mode 100644 index 000000000..f90b7bb35 --- /dev/null +++ b/packages/cli/test/commands/debug-files/prepare.test.ts @@ -0,0 +1,191 @@ +/** + * Tests for `debug-files prepare` human output and the `--require-dwarf` gate. + * + * Output assertions cover the rendered string only; split and stamp behaviour is + * covered by test/lib/wasm/prepare.test.ts. + * + * Vitest runs without a TTY, so `isPlainOutput()` is true and color tags are + * stripped. That makes the expected strings stable and also proves plain mode + * emits no stray tag markup. + */ + +import chalk from "chalk"; +import { describe, expect, test } from "vitest"; +import { lacksDwarf } from "../../../src/commands/debug-files/prepare.js"; +import { COLORS } from "../../../src/lib/formatters/colors.js"; +import { formatPrepareResult } from "../../../src/lib/formatters/wasm-prepare.js"; +import type { PrepareResult } from "../../../src/lib/wasm/prepare.js"; + +/** A split module with every optional field populated. */ +const splitModule: PrepareResult = { + path: "web/assets/maze.wasm", + action: "split", + quality: "dwarf", + buildId: "d5bcf04a000040008000000000000000", + companion: "web/assets/maze.debug.wasm", +}; + +/** A skipped module carrying a warning. */ +const skippedModule: PrepareResult = { + path: "web/assets/maze.nosym.wasm", + action: "skipped", + quality: "none", + buildId: "00000000000040008000000000000000", + warning: "already stripped (build_id present, no debug sections)", +}; + +/** Render a report that uploaded nothing, for the given modules. */ +function report(...modules: PrepareResult[]): string { + return formatPrepareResult({ + uploaded: false, + modules, + filesUploaded: 0, + }); +} + +describe("formatPrepareResult", () => { + test("gives each module its own table, keyed by full path", () => { + const output = report({ + ...splitModule, + path: "/build/out/nested/app.wasm", + companion: "/symbols/app.debug.wasm", + }); + + expect(output).toContain("/build/out/nested/app.wasm"); + expect(output).toContain("Action"); + expect(output).toContain("Split"); + expect(output).toContain("Debug quality"); + expect(output).toContain("dwarf"); + expect(output).toContain("d5bcf04a000040008000000000000000"); + expect(output).toContain("/symbols/app.debug.wasm"); + }); + + test("attaches a warning and a recommendation to their own module", () => { + const output = report({ + path: "app.wasm", + action: "skipped", + quality: "symtab", + warning: "no line-level symbolication (name/symtab only)", + recommendation: "verify build flags emit DWARF", + }); + + expect(output).toContain("Skipped"); + expect(output).toContain("Warning"); + expect(output).toContain("no line-level symbolication (name/symtab only)"); + expect(output).toContain("Recommendation"); + expect(output).toContain("verify build flags emit DWARF"); + }); + + test("labels each action", () => { + const label = (action: PrepareResult["action"]) => + report({ ...splitModule, action }); + + expect(label("split")).toContain("Split"); + expect(label("would-split")).toContain("Would split"); + expect(label("already-prepared")).toContain("Already prepared"); + expect(label("skipped")).toContain("Skipped"); + }); + + test("prints quality as the JSON payload spells it", () => { + const output = report({ ...splitModule, quality: "external-debug-info" }); + + expect(output).toContain("external-debug-info"); + }); + + test("omits Build ID and Companion when absent", () => { + const output = report({ + path: "app.wasm", + action: "skipped", + quality: "symtab", + }); + + expect(output).not.toContain("Build ID"); + expect(output).not.toContain("Companion"); + }); + + test("does not let markdown in a path alter the output", () => { + // Underscores are emphasis markers in markdown; a real filename keeps them. + const output = report({ ...splitModule, path: "build/_my_module_.wasm" }); + + expect(output).toContain("build/_my_module_.wasm"); + }); + + test("opens with the scanned count and pluralizes it", () => { + expect(report(splitModule).split("\n")[0]).toBe("Found 1 wasm file"); + expect(report(splitModule, skippedModule).split("\n")[0]).toBe( + "Found 2 wasm files" + ); + }); + + test("reports the upload target only after an upload", () => { + const uploaded = formatPrepareResult({ + org: "my-org", + project: "my-project", + uploaded: true, + modules: [splitModule], + filesUploaded: 1, + }); + + expect(uploaded).toContain("Uploaded 1 companion to my-org/my-project"); + expect(report(splitModule)).not.toContain("Uploaded"); + }); + + test("renders a summary alone when no modules were found", () => { + expect(report()).toContain("Found 0 wasm files"); + }); + + test("colors the skip label red in terminal output", () => { + process.env.SENTRY_PLAIN_OUTPUT = "0"; + const level = chalk.level; + chalk.level = 3; + try { + expect(report(skippedModule)).toContain(chalk.hex(COLORS.red)("Skipped")); + } finally { + chalk.level = level; + delete process.env.SENTRY_PLAIN_OUTPUT; + } + }); + + test("leaves no color tag markup in plain mode", () => { + const output = formatPrepareResult({ + org: "my-org", + project: "my-project", + uploaded: true, + modules: [splitModule, skippedModule], + filesUploaded: 1, + }); + + expect(output).not.toMatch(/<\/?(muted|yellow|red|cyan)>/); + }); +}); + +describe("lacksDwarf", () => { + const skipped = (quality: PrepareResult["quality"]): PrepareResult => ({ + path: "app.wasm", + action: "skipped", + quality, + }); + + test("fails a dangling external_debug_info pointer", () => { + // The companion could not be resolved — a resolved one would have been + // reported as already-prepared — so nothing reachable was uploaded. + expect(lacksDwarf(skipped("external-debug-info"))).toBe(true); + }); + + test("fails a module with no usable debug info", () => { + expect(lacksDwarf(skipped("symtab"))).toBe(true); + expect(lacksDwarf(skipped("none"))).toBe(true); + }); + + test("passes a companion named directly on the command line", () => { + expect(lacksDwarf(skipped("dwarf"))).toBe(false); + }); + + test("passes every module that produced a debug file", () => { + expect(lacksDwarf(splitModule)).toBe(false); + expect(lacksDwarf({ ...splitModule, action: "already-prepared" })).toBe( + false + ); + expect(lacksDwarf({ ...splitModule, action: "would-split" })).toBe(false); + }); +}); From ee292fa1f03fc8e8461173229c10ccad5c9dd280 Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Wed, 16 Sep 2026 16:44:15 +0200 Subject: [PATCH 05/11] fix(debug-files): take the first readable build_id when inspecting - Guard the inspectWasm build_id survey with ??= so a later malformed build_id section cannot erase an id already found, matching buildIdFromSections and the Rust tool's find_map - Intersect PrepareFlags and UploadFlags with WaitFlags instead of restating --wait / --wait-for in each command - Drop the orphaned resolveWaitMode JSDoc left behind in prepare.ts, which was nesting into the resolveBuildId comment block --- packages/cli/src/commands/debug-files/prepare.ts | 8 ++------ packages/cli/src/commands/debug-files/upload.ts | 6 ++---- packages/cli/src/lib/wasm/prepare.ts | 4 +++- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/debug-files/prepare.ts b/packages/cli/src/commands/debug-files/prepare.ts index fc755fd0c..228512137 100644 --- a/packages/cli/src/commands/debug-files/prepare.ts +++ b/packages/cli/src/commands/debug-files/prepare.ts @@ -50,14 +50,14 @@ import { uploadPath, } from "../../lib/wasm/prepare.js"; import { readSourceFile } from "./read-file.js"; -import { resolveWaitMode } from "./wait.js"; +import { resolveWaitMode, type WaitFlags } from "./wait.js"; const log = logger.withTag("debug-files.prepare"); const USAGE_HINT = "sentry debug-files prepare ..."; /** Flags accepted by the prepare command. */ -type PrepareFlags = { +type PrepareFlags = WaitFlags & { "dry-run"?: boolean; "no-upload"?: boolean; "require-dwarf"?: boolean; @@ -67,12 +67,8 @@ type PrepareFlags = { "include-sources"?: boolean; ignore?: string[]; "ignore-file"?: string; - wait?: boolean; - "wait-for"?: number; }; -/** - * Resolve the wait mode and deadline from `--wait` / `--wait-for`. /** * Parse the explicit `--build-id` value into raw bytes. * diff --git a/packages/cli/src/commands/debug-files/upload.ts b/packages/cli/src/commands/debug-files/upload.ts index c5d1c3921..1c2160cb3 100644 --- a/packages/cli/src/commands/debug-files/upload.ts +++ b/packages/cli/src/commands/debug-files/upload.ts @@ -57,7 +57,7 @@ import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; import { readSourceFile } from "./read-file.js"; -import { resolveWaitMode } from "./wait.js"; +import { resolveWaitMode, type WaitFlags } from "./wait.js"; const log = logger.withTag("debug-files.upload"); @@ -126,7 +126,7 @@ type DebugFilesUploadResult = { }; /** Flags accepted by the upload command. */ -type UploadFlags = { +type UploadFlags = WaitFlags & { type?: string[]; id?: string[]; "require-all"?: boolean; @@ -138,8 +138,6 @@ type UploadFlags = { "derived-data"?: boolean; "no-zips"?: boolean; "no-upload"?: boolean; - wait?: boolean; - "wait-for"?: number; }; // ── Formatter ─────────────────────────────────────────────────────── diff --git a/packages/cli/src/lib/wasm/prepare.ts b/packages/cli/src/lib/wasm/prepare.ts index 8fcddf7e3..0d250696a 100644 --- a/packages/cli/src/lib/wasm/prepare.ts +++ b/packages/cli/src/lib/wasm/prepare.ts @@ -175,7 +175,9 @@ function surveySection(found: SectionSurvey, section: WasmSection): void { return; } if (section.name === BUILD_ID_SECTION) { - found.buildId = decodeBuildId(section.contents); + // First readable id wins, as in `buildIdFromSections` and the Rust tool; + // a later malformed section must not erase an id already found. + found.buildId ??= decodeBuildId(section.contents); } else if (isNameSection(section)) { found.hasNameSection = true; } else if (isExternalDebugInfoSection(section)) { From 9025888a94f6b1b5779b2b1b66f965f0b458d430 Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Wed, 16 Sep 2026 16:48:49 +0200 Subject: [PATCH 06/11] ref(wasm): read the build id through the shared helper --- packages/cli/src/lib/wasm/prepare.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/lib/wasm/prepare.ts b/packages/cli/src/lib/wasm/prepare.ts index 0d250696a..063a820b9 100644 --- a/packages/cli/src/lib/wasm/prepare.ts +++ b/packages/cli/src/lib/wasm/prepare.ts @@ -18,8 +18,6 @@ import { readFile, writeFile } from "node:fs/promises"; import { basename, dirname, isAbsolute, join } from "node:path"; import { logger } from "../logger.js"; import { - BUILD_ID_SECTION, - decodeBuildId, decodeExternalDebugInfo, isCodeSection, isDebugSection, @@ -162,7 +160,6 @@ type SectionSurvey = { hasDwarf: boolean; hasNameSection: boolean; hasCode: boolean; - buildId: Uint8Array | null; externalDebugInfo: string | null; }; @@ -174,11 +171,7 @@ function surveySection(found: SectionSurvey, section: WasmSection): void { if (!section.contents) { return; } - if (section.name === BUILD_ID_SECTION) { - // First readable id wins, as in `buildIdFromSections` and the Rust tool; - // a later malformed section must not erase an id already found. - found.buildId ??= decodeBuildId(section.contents); - } else if (isNameSection(section)) { + if (isNameSection(section)) { found.hasNameSection = true; } else if (isExternalDebugInfoSection(section)) { found.externalDebugInfo = decodeExternalDebugInfo(section.contents); @@ -214,17 +207,17 @@ export function inspectWasm(bytes: Uint8Array): WasmInspection { hasDwarf: false, hasNameSection: false, hasCode: false, - buildId: null, externalDebugInfo: null, }; - for (const section of parseSections(bytes)) { + const sections = parseSections(bytes); + for (const section of sections) { surveySection(found, section); } return { quality: classifyDebugQuality(found), - buildId: found.buildId, + buildId: buildIdFromSections(sections), hasCode: found.hasCode, externalDebugInfo: found.externalDebugInfo, }; From e6e4bc3b44f400732a3a4073c626dc7fa313512e Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Thu, 17 Sep 2026 10:48:46 +0200 Subject: [PATCH 07/11] fix(debug-files): reject --build-id for directories and multiple paths --- .../sentry-cli/references/debug-files.md | 2 +- .../cli/src/commands/debug-files/prepare.ts | 37 ++++++++++- .../test/commands/debug-files/prepare.test.ts | 65 ++++++++++++++++++- 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md index 482b78fab..88ebaebd7 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md @@ -35,7 +35,7 @@ Split WebAssembly debug info and upload it to Sentry - `--require-dwarf - Fail if any scanned module lacks DWARF debug info` - `--out-dir - Directory for *.debug.wasm companions (modules are stripped in place)` - `--strip-names - Also drop the name section from split modules (companion keeps it)` -- `--build-id - Use this UUID as the build id instead of a random one` +- `--build-id - Use this UUID as the build id instead of a random one (one .wasm file only)` - `--include-sources - Also upload a source bundle for each companion` - `--ignore ... - Skip files and folders matching this glob (repeatable)` - `--ignore-file - Skip files and folders listed in this ignore file` diff --git a/packages/cli/src/commands/debug-files/prepare.ts b/packages/cli/src/commands/debug-files/prepare.ts index 228512137..3520f3a57 100644 --- a/packages/cli/src/commands/debug-files/prepare.ts +++ b/packages/cli/src/commands/debug-files/prepare.ts @@ -322,6 +322,32 @@ async function assertWasmPaths(paths: string[]): Promise { } } +/** + * Reject `--build-id` for anything but one explicitly named module. + * + * The flag supplies a single id, and every module that needs stamping would + * receive it. Sentry matches a stack frame to its debug file by build id, so + * modules sharing one are indistinguishable and neither symbolicates + * reliably. A directory can always hold more than one module, which is why it + * is refused even when it currently holds exactly one. + * + * @throws {ValidationError} If several paths were given, or the single path is + * not a file. + */ +async function assertSingleModuleForBuildId(paths: string[]): Promise { + const [path, ...rest] = paths; + // A path that does not exist is left to the scan, which names it in its own + // error rather than blaming the flag. + const info = path ? await stat(path).catch(() => null) : null; + if (rest.length > 0 || info?.isDirectory()) { + throw new ValidationError( + "--build-id applies to one module: pass a single .wasm file, not a " + + "directory or several paths", + "build-id" + ); + } +} + export const prepareCommand = buildCommand({ // Auth is only needed on the upload path; --dry-run and --no-upload skip it. auth: false, @@ -343,9 +369,14 @@ export const prepareCommand = buildCommand({ "missing debug info fails without pushing files first. A module whose " + "external_debug_info points at a companion that cannot be found fails " + "too: its debug info is unreachable.\n\n" + + "--build-id names one module and is rejected for a directory or for " + + "several paths. Every module that needs stamping would take the id, and " + + "modules sharing one cannot be told apart when Sentry looks for their " + + "debug files.\n\n" + "Usage:\n" + " sentry debug-files prepare ./dist\n" + " sentry debug-files prepare ./app.wasm --no-upload\n" + + " sentry debug-files prepare ./app.wasm --build-id \n" + " sentry debug-files prepare ./dist --dry-run\n" + " sentry debug-files prepare ./dist --require-dwarf\n" + " sentry debug-files prepare ./dist --out-dir ./symbols\n" + @@ -400,7 +431,8 @@ export const prepareCommand = buildCommand({ "build-id": { kind: "parsed", parse: String, - brief: "Use this UUID as the build id instead of a random one", + brief: + "Use this UUID as the build id instead of a random one (one .wasm file only)", optional: true, }, "include-sources": { @@ -448,6 +480,9 @@ export const prepareCommand = buildCommand({ }; await assertWasmPaths(paths); + if (buildId) { + await assertSingleModuleForBuildId(paths); + } const ignoreMatchers = await buildIgnoreMatchers( flags.ignore, flags["ignore-file"] diff --git a/packages/cli/test/commands/debug-files/prepare.test.ts b/packages/cli/test/commands/debug-files/prepare.test.ts index f90b7bb35..c436b2c52 100644 --- a/packages/cli/test/commands/debug-files/prepare.test.ts +++ b/packages/cli/test/commands/debug-files/prepare.test.ts @@ -9,9 +9,16 @@ * emits no stray tag markup. */ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import chalk from "chalk"; -import { describe, expect, test } from "vitest"; -import { lacksDwarf } from "../../../src/commands/debug-files/prepare.js"; +import { describe, expect, test, vi } from "vitest"; +import { + lacksDwarf, + prepareCommand, +} from "../../../src/commands/debug-files/prepare.js"; +import { ValidationError } from "../../../src/lib/errors.js"; import { COLORS } from "../../../src/lib/formatters/colors.js"; import { formatPrepareResult } from "../../../src/lib/formatters/wasm-prepare.js"; import type { PrepareResult } from "../../../src/lib/wasm/prepare.js"; @@ -189,3 +196,57 @@ describe("lacksDwarf", () => { expect(lacksDwarf({ ...splitModule, action: "would-split" })).toBe(false); }); }); + +describe("--build-id", () => { + const BUILD_ID = "a1b2c3d4-e5f6-4788-99aa-bbccddeeff00"; + + /** Run the command with `--build-id` over the given paths. */ + async function runWithBuildId(...paths: string[]): Promise { + const func = (await prepareCommand.loader()) as unknown as ( + this: unknown, + flags: Record, + ...rest: string[] + ) => Promise; + const context = { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + env: {} as NodeJS.ProcessEnv, + process: { ...process, exitCode: undefined } as typeof process, + }; + await func.call( + context, + { "build-id": BUILD_ID, "dry-run": true, "no-upload": true }, + ...paths + ); + } + + /** A directory holding one module, so only the path kind is under test. */ + async function moduleDir(): Promise<{ dir: string; module: string }> { + const dir = await mkdtemp(join(tmpdir(), "prepare-build-id-")); + const module = join(dir, "app.wasm"); + await writeFile(module, Uint8Array.from([0x00, 0x61, 0x73, 0x6d])); + return { dir, module }; + } + + test("is refused for a directory, which may hold several modules", async () => { + const { dir } = await moduleDir(); + + await expect(runWithBuildId(dir)).rejects.toThrow(ValidationError); + }); + + test("is refused for several paths", async () => { + const { module } = await moduleDir(); + const second = await moduleDir(); + + await expect(runWithBuildId(module, second.module)).rejects.toThrow( + /single .wasm file/ + ); + }); + + test("is accepted for one named module", async () => { + const { module } = await moduleDir(); + + await expect(runWithBuildId(module)).resolves.toBeUndefined(); + }); +}); From 625815a369e606595c11ccac5275fc177860b70a Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Thu, 17 Sep 2026 13:11:15 +0200 Subject: [PATCH 08/11] address review feedback - reuse shared readSourceFile in bundle-sources instead of an inline readFileSync try/catch - build the prepare report by pushing module sections into the summary array instead of spread/flatMap intermediates - name all thirteen non-custom wasm section ids as constants in SECTION_ORDER - trim the prepare docs fragment: drop the wasm-split parity note and shorten the idempotency line --- .../src/fragments/commands/debug-files.md | 6 +- .../commands/debug-files/bundle-sources.ts | 15 +---- .../cli/src/lib/formatters/wasm-prepare.ts | 11 ++-- packages/cli/src/lib/wasm/binary.ts | 60 +++++++++++++++---- 4 files changed, 56 insertions(+), 36 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/debug-files.md b/apps/cli-docs/src/fragments/commands/debug-files.md index 0bbc42bdf..105103054 100644 --- a/apps/cli-docs/src/fragments/commands/debug-files.md +++ b/apps/cli-docs/src/fragments/commands/debug-files.md @@ -68,9 +68,6 @@ sentry debug-files prepare ./dist --require-dwarf ## Notes on `prepare` -- `debug-files prepare` replaces the two-step `wasm-split` + `debug-files - upload` workflow for WebAssembly. Its output is byte-identical to - `wasm-split`, so companions from either tool behave the same in Sentry. - For each module carrying inline DWARF it injects a `build_id` (if absent), writes a `*.debug.wasm` companion retaining the Code section and DWARF, strips the `.debug_*` sections from the deployable module **in place**, and @@ -84,8 +81,7 @@ sentry debug-files prepare ./dist --require-dwarf - Name/symtab-only modules are not uploaded: the `name` section stays in the deployable module and runtimes read function names from it directly, so a debug file built from one adds nothing to the stack trace. -- Running the command twice is safe. A module whose companion already exists - with a matching `build_id` is reported as already prepared and left alone. +- The command is idempotent. - `--require-dwarf` exits non-zero when any scanned module lacks DWARF, which is the flag to use in CI. A module whose `external_debug_info` names a companion that cannot be found fails the gate too, since its debug info is unreachable. diff --git a/packages/cli/src/commands/debug-files/bundle-sources.ts b/packages/cli/src/commands/debug-files/bundle-sources.ts index 232567746..892f097db 100644 --- a/packages/cli/src/commands/debug-files/bundle-sources.ts +++ b/packages/cli/src/commands/debug-files/bundle-sources.ts @@ -14,7 +14,6 @@ * bundled `symbolic` WASM module (see `src/lib/dif/`). */ -import { readFileSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import { basename, dirname, resolve } from "node:path"; import type { SentryContext } from "../../context.js"; @@ -28,7 +27,7 @@ import { } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; -import { readDebugFile } from "./read-file.js"; +import { readDebugFile, readSourceFile } from "./read-file.js"; const log = logger.withTag("debug-files.bundle-sources"); @@ -119,17 +118,7 @@ export const bundleSourcesCommand = buildCommand({ result = createSourceBundle( new Uint8Array(content), basename(path), - (sourcePath) => { - try { - return readFileSync(sourcePath); - } catch (err) { - log.debug( - `Source file not available, skipping: ${sourcePath}`, - err - ); - return null; - } - } + readSourceFile ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/packages/cli/src/lib/formatters/wasm-prepare.ts b/packages/cli/src/lib/formatters/wasm-prepare.ts index 474d04f27..287e1d91e 100644 --- a/packages/cli/src/lib/formatters/wasm-prepare.ts +++ b/packages/cli/src/lib/formatters/wasm-prepare.ts @@ -85,10 +85,9 @@ function summaryLines(data: PrepareCommandResult): string[] { /** Format human-readable output for the prepare result. */ export function formatPrepareResult(data: PrepareCommandResult): string { - const sections = data.modules.map(moduleSection); - return renderMarkdown( - [...summaryLines(data), ...sections.flatMap((section) => ["", section])] - .join("\n") - .trim() - ); + const lines = summaryLines(data); + for (const module of data.modules) { + lines.push("", moduleSection(module)); + } + return renderMarkdown(lines.join("\n").trim()); } diff --git a/packages/cli/src/lib/wasm/binary.ts b/packages/cli/src/lib/wasm/binary.ts index 17bd4f7f5..10599bfd3 100644 --- a/packages/cli/src/lib/wasm/binary.ts +++ b/packages/cli/src/lib/wasm/binary.ts @@ -35,9 +35,45 @@ const log = logger.withTag("wasm.binary"); /** Section id of a custom section. */ const CUSTOM_SECTION_ID = 0; +/** Section id of the type section. */ +const TYPE_SECTION_ID = 1; + +/** Section id of the import section. */ +const IMPORT_SECTION_ID = 2; + +/** Section id of the function section. */ +const FUNCTION_SECTION_ID = 3; + +/** Section id of the table section. */ +const TABLE_SECTION_ID = 4; + +/** Section id of the memory section. */ +const MEMORY_SECTION_ID = 5; + +/** Section id of the global section. */ +const GLOBAL_SECTION_ID = 6; + +/** Section id of the export section. */ +const EXPORT_SECTION_ID = 7; + +/** Section id of the start section. */ +const START_SECTION_ID = 8; + +/** Section id of the element section. */ +const ELEMENT_SECTION_ID = 9; + /** Section id of the code section. DWARF offsets are relative to it. */ const CODE_SECTION_ID = 10; +/** Section id of the data section. */ +const DATA_SECTION_ID = 11; + +/** Section id of the data count section. */ +const DATA_COUNT_SECTION_ID = 12; + +/** Section id of the exception tag section, from the exception-handling proposal. */ +const EXCEPTION_TAG_SECTION_ID = 13; + /** Name of the custom section holding function names. */ const NAME_SECTION = "name"; @@ -60,19 +96,19 @@ const DEBUG_SECTION_PREFIX = ".debug_"; * with the `exception-handling` feature on, which is what makes 13 legal here. */ const SECTION_ORDER = [ - [1, "type"], - [2, "import"], - [3, "function"], - [4, "table"], - [5, "memory"], - [13, "exception tag"], - [6, "global"], - [7, "export"], - [8, "start"], - [9, "element"], - [12, "data count"], + [TYPE_SECTION_ID, "type"], + [IMPORT_SECTION_ID, "import"], + [FUNCTION_SECTION_ID, "function"], + [TABLE_SECTION_ID, "table"], + [MEMORY_SECTION_ID, "memory"], + [EXCEPTION_TAG_SECTION_ID, "exception tag"], + [GLOBAL_SECTION_ID, "global"], + [EXPORT_SECTION_ID, "export"], + [START_SECTION_ID, "start"], + [ELEMENT_SECTION_ID, "element"], + [DATA_COUNT_SECTION_ID, "data count"], [CODE_SECTION_ID, "code"], - [11, "data"], + [DATA_SECTION_ID, "data"], ] as const; /** What a non-custom section id means, and where it sorts. */ From 83c787ac2e090741b931f5e3a4ed9dfdec634d6d Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Thu, 17 Sep 2026 13:37:54 +0200 Subject: [PATCH 09/11] refactor(scan): share ignore matcher - Move buildIgnoreMatcher into lib/scan/ignore.ts - Reuse it in debug-files prepare and sourcemap commands --- .../cli/src/commands/debug-files/prepare.ts | 67 +++++-------------- packages/cli/src/commands/sourcemap/inject.ts | 2 +- .../cli/src/commands/sourcemap/resolve.ts | 2 +- packages/cli/src/commands/sourcemap/upload.ts | 2 +- packages/cli/src/lib/scan/ignore.ts | 42 ++++++++++++ packages/cli/src/lib/scan/index.ts | 2 +- packages/cli/src/lib/sourcemap/inject.ts | 38 +---------- 7 files changed, 63 insertions(+), 92 deletions(-) diff --git a/packages/cli/src/commands/debug-files/prepare.ts b/packages/cli/src/commands/debug-files/prepare.ts index 3520f3a57..dcffd1719 100644 --- a/packages/cli/src/commands/debug-files/prepare.ts +++ b/packages/cli/src/commands/debug-files/prepare.ts @@ -34,16 +34,13 @@ import { CommandOutput } from "../../lib/formatters/output.js"; import { formatPrepareResult } from "../../lib/formatters/wasm-prepare.js"; import { logger } from "../../lib/logger.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; -import { - type CompiledMatcher, - compileMatchers, - matchesAny, -} from "../../lib/scan/path-utils.js"; +import { buildIgnoreMatcher, normalizePath } from "../../lib/scan/index.js"; import { debugIdFromBuildId, uuidToBytes } from "../../lib/wasm/build-id.js"; import { hasDwarfQuality, isDebugCompanionPath, isWasmPath, + type PrepareAction, type PrepareCommandResult, type PrepareResult, prepareWasmFile, @@ -161,6 +158,12 @@ async function* reportUpload( }; } +const COMPANION_PRODUCING_ACTIONS = new Set([ + "split", + "would-split", + "already-prepared", +]); + /** * Whether a module has a companion, or would get one on a real run. * @@ -168,11 +171,7 @@ async function* reportUpload( * work it would do rather than the (always empty) set it would upload. */ function producesCompanion(result: PrepareResult): boolean { - return ( - result.action === "split" || - result.action === "would-split" || - result.action === "already-prepared" - ); + return COMPANION_PRODUCING_ACTIONS.has(result.action); } /** @@ -238,38 +237,14 @@ async function collectCompanionDifs( return difs; } -/** - * Compile `--ignore` globs and `--ignore-file` entries into path matchers. - * - * The ignore file is read as gitignore-style lines: blanks and `#` comments are - * dropped and every other line is treated as a glob. Patterns are not split on - * commas, because a glob's brace group (`{a,b}`) legitimately contains them. - */ -async function buildIgnoreMatchers( - ignores: string[] | undefined, - ignoreFile: string | undefined -): Promise { - const patterns = [...(ignores ?? [])]; - if (ignoreFile) { - const contents = await readFile(ignoreFile, "utf-8"); - for (const line of contents.split("\n")) { - const trimmed = line.trim(); - if (trimmed.length > 0 && !trimmed.startsWith("#")) { - patterns.push(trimmed); - } - } - } - return compileMatchers(patterns); -} - /** * Path of a scanned file relative to the scan root it came from. * - * Ignore globs are written against the tree the user pointed at — `--ignore + * Ignore patterns are written against the tree the user pointed at — `--ignore * 'vendor/**'` for `prepare ./dist` means `./dist/vendor` — so they must be * tested against a root-relative path rather than one relative to the process - * working directory. Separators are normalized to `/` because glob patterns - * always use them. + * working directory. Separators are normalized to `/` because the `ignore` + * package only understands POSIX paths. */ function pathRelativeToRoot(path: string, roots: string[]): string { const resolved = resolve(path); @@ -285,19 +260,7 @@ function pathRelativeToRoot(path: string, roots: string[]): string { // A root naming the file itself leaves nothing relative to match on, so fall // back to the basename. const rel = deepestRoot ? relative(deepestRoot, resolved) : ""; - return (rel === "" ? basename(resolved) : rel).split(sep).join("/"); -} - -/** Whether a scanned path is excluded by the ignore matchers. */ -function isIgnored( - path: string, - matchers: CompiledMatcher[], - roots: string[] -): boolean { - if (matchers.length === 0) { - return false; - } - return matchesAny(matchers, pathRelativeToRoot(path, roots), basename(path)); + return normalizePath(rel === "" ? basename(resolved) : rel); } /** @@ -483,7 +446,7 @@ export const prepareCommand = buildCommand({ if (buildId) { await assertSingleModuleForBuildId(paths); } - const ignoreMatchers = await buildIgnoreMatchers( + const ignoreMatcher = await buildIgnoreMatcher( flags.ignore, flags["ignore-file"] ); @@ -493,7 +456,7 @@ export const prepareCommand = buildCommand({ (path) => isWasmPath(path) && !isDebugCompanionPath(path) && - !isIgnored(path, ignoreMatchers, paths) + !ignoreMatcher?.ignores(pathRelativeToRoot(path, paths)) ); if (candidates.length === 0) { diff --git a/packages/cli/src/commands/sourcemap/inject.ts b/packages/cli/src/commands/sourcemap/inject.ts index aca157ed5..d4e88e5b9 100644 --- a/packages/cli/src/commands/sourcemap/inject.ts +++ b/packages/cli/src/commands/sourcemap/inject.ts @@ -13,10 +13,10 @@ import { renderMarkdown, } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; +import { buildIgnoreMatcher } from "../../lib/scan/index.js"; import { assertDirectoryReadable, buildEmptyDiscoveryError, - buildIgnoreMatcher, diagnoseEmptyDiscovery, discoverFilePairs, type InjectResult, diff --git a/packages/cli/src/commands/sourcemap/resolve.ts b/packages/cli/src/commands/sourcemap/resolve.ts index c9761a5f6..9f78a2162 100644 --- a/packages/cli/src/commands/sourcemap/resolve.ts +++ b/packages/cli/src/commands/sourcemap/resolve.ts @@ -17,9 +17,9 @@ import { renderMarkdown, } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; +import { buildIgnoreMatcher } from "../../lib/scan/index.js"; import { assertDirectoryReadable, - buildIgnoreMatcher, resolveDirectorySourcemaps, } from "../../lib/sourcemap/inject.js"; diff --git a/packages/cli/src/commands/sourcemap/upload.ts b/packages/cli/src/commands/sourcemap/upload.ts index e36d0f557..268a42575 100644 --- a/packages/cli/src/commands/sourcemap/upload.ts +++ b/packages/cli/src/commands/sourcemap/upload.ts @@ -21,10 +21,10 @@ import { ContextError, ValidationError } from "../../lib/errors.js"; import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { buildIgnoreMatcher } from "../../lib/scan/index.js"; import { assertDirectoryReadable, buildEmptyDiscoveryError, - buildIgnoreMatcher, diagnoseEmptyDiscovery, discoverFilePairs, type InjectResult, diff --git a/packages/cli/src/lib/scan/ignore.ts b/packages/cli/src/lib/scan/ignore.ts index 420b75b6b..581595460 100644 --- a/packages/cli/src/lib/scan/ignore.ts +++ b/packages/cli/src/lib/scan/ignore.ts @@ -45,8 +45,50 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import ignore, { type Ignore } from "ignore"; import { handleFileError } from "../dsn/fs-utils.js"; +import { ValidationError } from "../errors.js"; import type { IgnoreMatcher } from "./types.js"; +/** + * Build an `ignore` matcher from user-supplied `--ignore` patterns and/or an + * `--ignore-file` path. Returns `undefined` when neither is set, so callers can + * skip the per-file check entirely. + * + * Patterns follow gitignore semantics (negations, anchoring, directory-only + * `dir/` entries) rather than plain glob matching, and the ignore file is + * parsed by the `ignore` package — blank lines and `#` comments are dropped. + * + * @throws {ValidationError} If `ignoreFilePath` is set but does not exist. + */ +export async function buildIgnoreMatcher( + patterns?: readonly string[], + ignoreFilePath?: string +): Promise { + const hasPatterns = patterns !== undefined && patterns.length > 0; + if (!(hasPatterns || ignoreFilePath)) { + return; + } + const ig = ignore(); + if (hasPatterns) { + ig.add([...patterns]); + } + if (ignoreFilePath) { + try { + const content = await readFile(ignoreFilePath, "utf-8"); + ig.add(content); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new ValidationError( + `Ignore file '${ignoreFilePath}' does not exist.`, + "ignore-file" + ); + } + throw err; + } + } + return ig; +} + /** Options for constructing an `IgnoreStack`. */ export type IgnoreStackOptions = { /** Walker `cwd`. Absolute path. */ diff --git a/packages/cli/src/lib/scan/index.ts b/packages/cli/src/lib/scan/index.ts index 9015dcea6..5e48e543d 100644 --- a/packages/cli/src/lib/scan/index.ts +++ b/packages/cli/src/lib/scan/index.ts @@ -52,7 +52,7 @@ export { export { collectGlob, globFiles } from "./glob.js"; export { collectGrep, grepFiles } from "./grep.js"; export type { IgnoreStackOptions } from "./ignore.js"; -export { IgnoreStack } from "./ignore.js"; +export { buildIgnoreMatcher, IgnoreStack } from "./ignore.js"; export type { CompilePatternOptions } from "./regex.js"; export { compilePattern, diff --git a/packages/cli/src/lib/sourcemap/inject.ts b/packages/cli/src/lib/sourcemap/inject.ts index 0c51b3859..68d990594 100644 --- a/packages/cli/src/lib/sourcemap/inject.ts +++ b/packages/cli/src/lib/sourcemap/inject.ts @@ -7,11 +7,11 @@ import { open, readFile, stat } from "node:fs/promises"; import { dirname, relative, resolve as resolvePath, sep } from "node:path"; -import ignore from "ignore"; +import type ignore from "ignore"; import { NODE_MODULES_DIRNAME } from "../constants.js"; import { ValidationError } from "../errors.js"; import { logger } from "../logger.js"; -import { walkFiles } from "../scan/index.js"; +import { buildIgnoreMatcher, walkFiles } from "../scan/index.js"; import { EXISTING_DEBUGID_RE, injectDebugId, @@ -521,40 +521,6 @@ async function findCompanionMap( */ const SOURCEMAP_SKIP_DIRS: readonly string[] = [NODE_MODULES_DIRNAME]; -/** - * Build an `ignore` matcher from user-provided patterns and/or an - * ignore-file path. Returns `undefined` when no patterns are active. - */ -export async function buildIgnoreMatcher( - patterns?: string[], - ignoreFilePath?: string -): Promise | undefined> { - const hasPatterns = patterns && patterns.length > 0; - if (!(hasPatterns || ignoreFilePath)) { - return; - } - const ig = ignore(); - if (hasPatterns) { - ig.add(patterns); - } - if (ignoreFilePath) { - try { - const content = await readFile(ignoreFilePath, "utf-8"); - ig.add(content); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - throw new ValidationError( - `Ignore file '${ignoreFilePath}' does not exist.`, - "ignore-file" - ); - } - throw err; - } - } - return ig; -} - /** * Read-only discovery pass — returns the list of JS + sourcemap pairs * without injecting debug IDs. Used as a pre-check by the upload From d48170bcb7cee20e1293d31ddc0763cbf05cba3a Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Fri, 18 Sep 2026 14:12:01 +0200 Subject: [PATCH 10/11] fix: companion collisions under --out-dir - name companions ..debug.wasm instead of .debug.wasm - resolve build id before naming so unstamped modules get unique filenames - write external_debug_info as a path relative to the module, not basename only - dry-run reports exact path when id is known, placeholder otherwise - update prepare help text and docs to match the new companion naming --- .../src/fragments/commands/debug-files.md | 8 +- .../cli/src/commands/debug-files/prepare.ts | 9 +- packages/cli/src/lib/wasm/prepare.ts | 92 ++++++++++---- .../test/commands/debug-files/prepare.test.ts | 12 ++ packages/cli/test/lib/wasm/prepare.test.ts | 114 ++++++++++++++++-- 5 files changed, 193 insertions(+), 42 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/debug-files.md b/apps/cli-docs/src/fragments/commands/debug-files.md index 105103054..aebde38e5 100644 --- a/apps/cli-docs/src/fragments/commands/debug-files.md +++ b/apps/cli-docs/src/fragments/commands/debug-files.md @@ -69,10 +69,10 @@ sentry debug-files prepare ./dist --require-dwarf ## Notes on `prepare` - For each module carrying inline DWARF it injects a `build_id` (if absent), - writes a `*.debug.wasm` companion retaining the Code section and DWARF, - strips the `.debug_*` sections from the deployable module **in place**, and - points it at the companion via `external_debug_info`. Your build artifact - keeps its path; only the companion is new. + writes a `..debug.wasm` companion retaining the Code section + and DWARF, strips the `.debug_*` sections from the deployable module **in + place**, and points it at the companion via `external_debug_info`. Your build + artifact keeps its path; only the companion is new. - The companion must keep the Code section — DWARF addresses are relative to it, so a companion without it cannot be symbolicated. - Modules without DWARF are still stamped with a `build_id` and reported with a diff --git a/packages/cli/src/commands/debug-files/prepare.ts b/packages/cli/src/commands/debug-files/prepare.ts index dcffd1719..4156edb2a 100644 --- a/packages/cli/src/commands/debug-files/prepare.ts +++ b/packages/cli/src/commands/debug-files/prepare.ts @@ -320,10 +320,11 @@ export const prepareCommand = buildCommand({ "Scan files and directories for WebAssembly modules, split the ones " + "carrying inline DWARF, and upload the debug companions to Sentry.\n\n" + "For each module with DWARF this injects a build_id (if absent), writes " + - "a *.debug.wasm companion retaining the Code section and DWARF, strips " + - "the .debug_* sections from the deployable module in place, and points " + - "it at the companion via external_debug_info. The deployable module " + - "keeps its original path, so your build artifact does not move.\n\n" + + "a ..debug.wasm companion retaining the Code section " + + "and DWARF, strips the .debug_* sections from the deployable module in " + + "place, and points it at the companion via external_debug_info. The " + + "deployable module keeps its original path, so your build artifact does " + + "not move.\n\n" + "Modules without DWARF are stamped with a build_id and reported with a " + "warning instead of failing the run. Running the command twice is safe: " + "an already-prepared module is detected and left alone.\n\n" + diff --git a/packages/cli/src/lib/wasm/prepare.ts b/packages/cli/src/lib/wasm/prepare.ts index 063a820b9..eb5d0e68b 100644 --- a/packages/cli/src/lib/wasm/prepare.ts +++ b/packages/cli/src/lib/wasm/prepare.ts @@ -15,7 +15,7 @@ */ import { readFile, writeFile } from "node:fs/promises"; -import { basename, dirname, isAbsolute, join } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; import { logger } from "../logger.js"; import { decodeExternalDebugInfo, @@ -38,6 +38,15 @@ const log = logger.withTag("wasm.prepare"); /** Suffix identifying a debug companion produced by this command. */ const COMPANION_SUFFIX = ".debug.wasm"; +/** + * Stands in for the build id in a dry-run companion name. + * + * A module carrying no id yet, and given none explicitly, gets a random one on + * the real run. The name is therefore unknowable, and the shape of it is the + * useful answer. + */ +const BUILD_ID_PLACEHOLDER = ""; + /** Trailing `.wasm` extension, in any case. */ const WASM_EXTENSION = /\.wasm$/i; @@ -143,18 +152,28 @@ export function isWasmPath(path: string): boolean { } /** - * Companion path for a module: `app.wasm` becomes `app.debug.wasm`. + * Companion path for a module: `app.wasm` becomes `app..debug.wasm`. * * @param wasmPath - Path of the input module. + * @param buildId - Effective build id in hex, as both files will carry it. * @param outDir - Directory to place the companion in, or omitted for * alongside the input. */ -export function companionPath(wasmPath: string, outDir?: string): string { - const name = basename(wasmPath).replace(WASM_EXTENSION, ""); - const fileName = `${name}${COMPANION_SUFFIX}`; +export function companionPath( + wasmPath: string, + buildId: string, + outDir?: string +): string { + const stem = basename(wasmPath).replace(WASM_EXTENSION, ""); + const fileName = `${stem}.${buildId}${COMPANION_SUFFIX}`; return join(outDir ?? dirname(wasmPath), fileName); } +/** Path relative to the module, for its `external_debug_info` section. */ +function externalDebugInfoPath(wasmPath: string, companion: string): string { + return relative(dirname(wasmPath), companion).split(sep).join("/"); +} + /** Which debug-relevant sections a module turned out to contain. */ type SectionSurvey = { hasDwarf: boolean; @@ -347,8 +366,8 @@ type ExistingCompanion = { companion: string; quality: DebugQuality }; * holding no DWARF, so this check runs before the quality classification. * * The module's own `external_debug_info` pointer is followed first, so a - * companion named anything other than `.debug.wasm` (as `wasm-split` may - * produce) is still recognized. The conventional path is the fallback. + * companion named by another tool (as `wasm-split` may produce) is still + * recognized. The conventional path is the fallback. */ async function findExistingCompanion( wasmPath: string, @@ -498,6 +517,31 @@ async function repairUnpairedCompanion( }; } +/** + * Report the split a dry run stopped short of performing. + * + * The companion is named after the build id, so a module carrying none — and + * given none explicitly — has no name to report: the real run will mint a + * random id. Reporting a name built from an id generated here would name a file + * that never appears, so the placeholder stands in and the preview shows the + * shape of the name instead. + */ +function reportWouldSplit( + path: string, + inspection: WasmInspection, + options: PrepareOptions +): PrepareResult { + const settled = inspection.buildId ?? options.buildId; + const name = settled ? formatBuildId(settled) : BUILD_ID_PLACEHOLDER; + return { + path, + action: "would-split", + quality: inspection.quality, + ...(settled ? { buildId: formatBuildId(settled) } : {}), + companion: companionPath(path, name, options.outDir), + }; +} + /** * Classify and, where possible, split a single `.wasm` file. * @@ -535,13 +579,17 @@ export async function prepareWasmFile( }; } - const expectedCompanion = companionPath(path, options.outDir); + // Naming a companion takes the id it will carry, so the conventional path + // only exists once the module has one. + const knownCompanion = inspection.buildId + ? companionPath(path, formatBuildId(inspection.buildId), options.outDir) + : null; - if (inspection.quality !== "dwarf") { + if (inspection.quality !== "dwarf" && knownCompanion) { const existing = await findExistingCompanion( path, inspection, - expectedCompanion + knownCompanion ); if (existing) { return { @@ -574,29 +622,27 @@ export async function prepareWasmFile( } if (options.dryRun) { - return { - path, - action: "would-split", - quality: inspection.quality, - ...(inspection.buildId - ? { buildId: formatBuildId(inspection.buildId) } - : {}), - companion: expectedCompanion, - }; + return reportWouldSplit(path, inspection, options); } + // Resolved here rather than inside the split, because the companion is named + // after the id and the split needs that name as its pointer. An id already on + // the module still wins: `splitWasm` ignores `buildId` when it finds one. + const buildId = inspection.buildId ?? options.buildId ?? randomBuildId(); + const companion = companionPath(path, formatBuildId(buildId), options.outDir); + const split = splitWasm(bytes, { companion: true, strip: true, - ...(options.buildId ? { buildId: options.buildId } : {}), + buildId, stripNames: options.stripNames ?? false, - externalDebugInfo: basename(expectedCompanion), + externalDebugInfo: externalDebugInfoPath(path, companion), }); // Write the companion first: if the process dies between the two writes, an // orphan companion is recoverable, whereas a stripped module whose DWARF was // never saved anywhere is not. - await writeFile(expectedCompanion, split.companion as Uint8Array); + await writeFile(companion, split.companion as Uint8Array); await writeFile(path, split.module); return { @@ -604,7 +650,7 @@ export async function prepareWasmFile( action: "split", quality: inspection.quality, buildId: formatBuildId(split.buildId), - companion: expectedCompanion, + companion, }; } diff --git a/packages/cli/test/commands/debug-files/prepare.test.ts b/packages/cli/test/commands/debug-files/prepare.test.ts index c436b2c52..31307ae0a 100644 --- a/packages/cli/test/commands/debug-files/prepare.test.ts +++ b/packages/cli/test/commands/debug-files/prepare.test.ts @@ -117,6 +117,18 @@ describe("formatPrepareResult", () => { expect(output).toContain("build/_my_module_.wasm"); }); + test("keeps a dry-run companion placeholder intact", () => { + // A dry run cannot know the random build id, so it reports the name as a + // template. The angle brackets must survive rendering. + const output = report({ + ...splitModule, + action: "would-split", + companion: "symbols/app..debug.wasm", + }); + + expect(output).toContain("symbols/app..debug.wasm"); + }); + test("opens with the scanned count and pluralizes it", () => { expect(report(splitModule).split("\n")[0]).toBe("Found 1 wasm file"); expect(report(splitModule, skippedModule).split("\n")[0]).toBe( diff --git a/packages/cli/test/lib/wasm/prepare.test.ts b/packages/cli/test/lib/wasm/prepare.test.ts index 19ec77dce..55dcac236 100644 --- a/packages/cli/test/lib/wasm/prepare.test.ts +++ b/packages/cli/test/lib/wasm/prepare.test.ts @@ -7,7 +7,7 @@ */ import { existsSync } from "node:fs"; -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; @@ -17,6 +17,7 @@ import { companionPath, hasDwarfQuality, inspectWasm, + isDebugCompanionPath, prepareWasmFile, uploadPath, } from "../../../src/lib/wasm/prepare.js"; @@ -109,6 +110,17 @@ async function writeModule(name: string, bytes: Uint8Array): Promise { return path; } +/** + * Companions written into a directory. + * + * Companion names carry a build id, which is random unless the test pinned one, + * so tests assert on what landed in the directory rather than on a name they + * would have to reconstruct. + */ +async function companionsIn(target = dir): Promise { + return (await readdir(target)).filter(isDebugCompanionPath); +} + describe("hasDwarfQuality", () => { test("counts an external companion pointer as having DWARF", () => { // Mirrors DebugQuality::has_dwarf in the Rust CLI: this describes how the @@ -164,12 +176,53 @@ describe("prepareWasmFile", () => { expect(result.action).toBe("split"); expect(result.quality).toBe("dwarf"); - expect(existsSync(companionPath(path))).toBe(true); - expect(uploadPath(result)).toBe(companionPath(path)); + expect(result.companion).toBe( + companionPath(path, result.buildId as string) + ); + expect(existsSync(result.companion as string)).toBe(true); + expect(uploadPath(result)).toBe(result.companion); // The deployable keeps its original path so the build artifact does not move. expect(hasSection(await readFile(path), ".debug_info")).toBe(false); }); + test("names the companion after the build id and points the module at it", async () => { + const path = await writeModule("app.wasm", dwarfModule()); + const result = await prepareWasmFile(path); + + expect(result.companion).toBe( + join(dir, `app.${result.buildId}.debug.wasm`) + ); + // The pointer is resolved relative to the module, so it must name the + // companion as written, not as a guess at the old convention. + expect(inspectWasm(await readFile(path)).externalDebugInfo).toBe( + `app.${result.buildId}.debug.wasm` + ); + }); + + test("keeps companions apart for same-named modules sharing an out-dir", async () => { + // The collision --out-dir used to cause: both modules named their companion + // app.debug.wasm, so the second write destroyed the first module's DWARF + // after it had already been stripped in place. + const outDir = await mkdtemp(join(tmpdir(), "wasm-out-")); + await mkdir(join(dir, "a")); + await mkdir(join(dir, "b")); + const first = await writeModule(join("a", "app.wasm"), dwarfModule()); + const second = await writeModule(join("b", "app.wasm"), dwarfModule()); + + const a = await prepareWasmFile(first, { outDir }); + const b = await prepareWasmFile(second, { outDir }); + + expect(a.companion).not.toBe(b.companion); + expect(await companionsIn(outDir)).toHaveLength(2); + // Both companions still hold the DWARF their module gave up. + expect( + hasSection(await readFile(a.companion as string), ".debug_info") + ).toBe(true); + expect( + hasSection(await readFile(b.companion as string), ".debug_info") + ).toBe(true); + }); + test("stamps a symtab-only module but uploads nothing", async () => { const path = await writeModule("unity.wasm", nameOnlyModule()); const result = await prepareWasmFile(path); @@ -177,7 +230,7 @@ describe("prepareWasmFile", () => { expect(result.action).toBe("skipped"); expect(result.quality).toBe("symtab"); expect(result.warning).toContain("no line-level"); - expect(existsSync(companionPath(path))).toBe(false); + expect(await companionsIn()).toEqual([]); // Stamped so a later DWARF build can be matched, but the name section stays // readable in the deployable, so there is nothing worth uploading. expect(result.buildId).toBeDefined(); @@ -202,7 +255,7 @@ describe("prepareWasmFile", () => { expect(result.action).toBe("skipped"); expect(result.quality).toBe("none"); expect(result.warning).toContain("no debug information"); - expect(existsSync(companionPath(path))).toBe(false); + expect(await companionsIn()).toEqual([]); }); test("recommends checking build flags when debug info is missing", async () => { @@ -233,10 +286,32 @@ describe("prepareWasmFile", () => { const result = await prepareWasmFile(path, { dryRun: true }); expect(result.action).toBe("would-split"); - expect(existsSync(companionPath(path))).toBe(false); + expect(await companionsIn()).toEqual([]); expect(await readFile(path)).toEqual(before); }); + test("dry run names the companion when the id is already settled", async () => { + const path = await writeModule("app.wasm", dwarfModule()); + const result = await prepareWasmFile(path, { + dryRun: true, + buildId: uuidToBytes(FIXED_UUID) as Uint8Array, + }); + + // --build-id makes the real run's name knowable, so report it exactly. + expect(result.companion).toBe( + join(dir, "app.00000000000040008000000000000000.debug.wasm") + ); + }); + + test("dry run reports a placeholder name for an unsettled id", async () => { + const path = await writeModule("app.wasm", dwarfModule()); + const result = await prepareWasmFile(path, { dryRun: true }); + + // The id will be random, so the shape of the name is the honest answer. + expect(result.buildId).toBeUndefined(); + expect(result.companion).toBe(join(dir, "app..debug.wasm")); + }); + test("dry run does not stamp a skipped module", async () => { const path = await writeModule("unity.wasm", nameOnlyModule()); const result = await prepareWasmFile(path, { dryRun: true }); @@ -260,14 +335,31 @@ describe("prepareWasmFile", () => { const path = await writeModule("app.wasm", dwarfModule()); const result = await prepareWasmFile(path, { outDir }); - expect(result.companion).toBe(join(outDir, "app.debug.wasm")); - expect(existsSync(join(outDir, "app.debug.wasm"))).toBe(true); - expect(existsSync(companionPath(path))).toBe(false); + expect(result.companion).toBe( + join(outDir, `app.${result.buildId}.debug.wasm`) + ); + expect(existsSync(result.companion as string)).toBe(true); + expect(await companionsIn()).toEqual([]); // The deployed path is the one that ends up stripped and stamped. expect(hasSection(await readFile(path), ".debug_info")).toBe(false); expect(inspectWasm(await readFile(path)).buildId).not.toBeNull(); }); + test("out-dir leaves a pointer the module can resolve", async () => { + const outDir = await mkdtemp(join(tmpdir(), "wasm-out-")); + const path = await writeModule("app.wasm", dwarfModule()); + const result = await prepareWasmFile(path, { outDir }); + + // A bare basename here would be a dangling pointer: the companion is not + // beside the module. A second run has to be able to follow it. + const pointer = inspectWasm(await readFile(path)).externalDebugInfo; + expect(existsSync(join(dir, pointer as string))).toBe(true); + expect(await prepareWasmFile(path, { outDir })).toMatchObject({ + action: "already-prepared", + companion: result.companion, + }); + }); + test("skips a companion given as input", async () => { const path = await writeModule("app.debug.wasm", dwarfModule()); const result = await prepareWasmFile(path); @@ -277,8 +369,8 @@ describe("prepareWasmFile", () => { }); test("recognizes a companion named by external_debug_info", async () => { - // A companion whose name does not follow the .debug.wasm convention - // is only findable by following the module's own pointer. + // A companion named by another tool is only findable by following the + // module's own pointer. const result = split(dwarfModule(), "custom-name.wasm"); const path = await writeModule("app.wasm", result.module); await writeModule("custom-name.wasm", result.companion as Uint8Array); From 42d877dc4ce8699aa2dc50c399d9c2ad95636d0d Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Fri, 18 Sep 2026 14:44:48 +0200 Subject: [PATCH 11/11] fix(prepare): say debug files in upload output, not companions --- packages/cli/src/commands/debug-files/prepare.ts | 4 ++-- packages/cli/src/lib/formatters/wasm-prepare.ts | 2 +- packages/cli/src/lib/wasm/prepare.ts | 2 +- packages/cli/test/commands/debug-files/prepare.test.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/debug-files/prepare.ts b/packages/cli/src/commands/debug-files/prepare.ts index 4156edb2a..611d4fb62 100644 --- a/packages/cli/src/commands/debug-files/prepare.ts +++ b/packages/cli/src/commands/debug-files/prepare.ts @@ -150,11 +150,11 @@ async function* reportUpload( const details = failures .map((r) => `${r.debugId ?? r.name}: ${r.state}`) .join("; "); - return { hint: `${failures.length} companion(s) failed: ${details}` }; + return { hint: `${failures.length} debug file(s) failed: ${details}` }; } return { - hint: `Uploaded ${uploads.length} debug companion(s) to ${params.org}/${params.project}`, + hint: `Uploaded ${uploads.length} debug file(s) to ${params.org}/${params.project}`, }; } diff --git a/packages/cli/src/lib/formatters/wasm-prepare.ts b/packages/cli/src/lib/formatters/wasm-prepare.ts index 287e1d91e..cadf7565a 100644 --- a/packages/cli/src/lib/formatters/wasm-prepare.ts +++ b/packages/cli/src/lib/formatters/wasm-prepare.ts @@ -77,7 +77,7 @@ function summaryLines(data: PrepareCommandResult): string[] { data.org && data.project ? ` to ${data.org}/${data.project}` : ""; lines.push( "", - `Uploaded ${uploaded} ${uploaded === 1 ? "companion" : "companions"}${target}.` + `Uploaded ${uploaded} ${uploaded === 1 ? "debug file" : "debug files"}${target}.` ); } return lines; diff --git a/packages/cli/src/lib/wasm/prepare.ts b/packages/cli/src/lib/wasm/prepare.ts index eb5d0e68b..f7acbb813 100644 --- a/packages/cli/src/lib/wasm/prepare.ts +++ b/packages/cli/src/lib/wasm/prepare.ts @@ -112,7 +112,7 @@ export type PrepareCommandResult = { uploaded: boolean; /** Per-module outcome. */ modules: PrepareResult[]; - /** Number of companions uploaded. */ + /** Number of debug files uploaded: companions, plus any source bundles. */ filesUploaded: number; }; diff --git a/packages/cli/test/commands/debug-files/prepare.test.ts b/packages/cli/test/commands/debug-files/prepare.test.ts index 31307ae0a..405350314 100644 --- a/packages/cli/test/commands/debug-files/prepare.test.ts +++ b/packages/cli/test/commands/debug-files/prepare.test.ts @@ -145,7 +145,7 @@ describe("formatPrepareResult", () => { filesUploaded: 1, }); - expect(uploaded).toContain("Uploaded 1 companion to my-org/my-project"); + expect(uploaded).toContain("Uploaded 1 debug file to my-org/my-project"); expect(report(splitModule)).not.toContain("Uploaded"); });