From 5e4055b39e89843964ad30ba26161a91fedf4955 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Wed, 11 Nov 2026 04:38:46 +0800 Subject: [PATCH 1/4] fix(process): mark Darwin compiler timeouts retryable (#9859) --- src/domain/analysisErrorProjection.ts | 3 +++ .../capture/ProcessCaptureError.test.ts | 27 +++++++++++++++++++ src/process/capture/ProcessCaptureError.ts | 12 +++++++++ 3 files changed, 42 insertions(+) diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index c4c536f254..183a5b96e2 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -524,6 +524,9 @@ const projectError = ( RETRYABLE_CODES.has(code) || (error instanceof AnalysisCapabilityUnavailableError && error.retryable) || + (error._tag === "ProcessCaptureError" && + "retryable" in error && + error.retryable === true) || (error instanceof ArtifactOperationError && error.readerLimit?.can_increase === true && !error.cleanupIncomplete) || diff --git a/src/process/capture/ProcessCaptureError.test.ts b/src/process/capture/ProcessCaptureError.test.ts index 654cbdcfbc..51791c2211 100644 --- a/src/process/capture/ProcessCaptureError.test.ts +++ b/src/process/capture/ProcessCaptureError.test.ts @@ -76,6 +76,33 @@ it("preserves the compiler output and live cleanup owner at capture normalizatio expect(error.capturedOutput?.exit_code).toBe(7); }); +it("marks a timed-out Darwin compiler inspection as retryable", () => { + const compiler = new DarwinProcessOwnershipInspectionError( + "compiler timed out", + { + compilerOutcome: { + outcome: "timed_out", + code: "ETIMEDOUT", + signal: null, + stderr: "compiler deadline exceeded", + stderrTruncated: false, + outputTruncated: false, + killed: true, + timeoutMs: 5_000, + }, + }, + ); + + const normalized = normalizeCaptureFailure(compiler, undefined); + expect(normalized).toMatchObject({ + retryable: true, + capturedOutput: undefined, + }); + if (!(normalized instanceof ProcessCaptureError)) + throw new Error("Expected normalized capture failure"); + expect(projectAnalysisError(normalized)).toMatchObject({ retryable: true }); +}); + it("retains compiler facts and cleanup dependency through capture finalization", () => { const { cleanup, error } = fixture(); let finalized: unknown; diff --git a/src/process/capture/ProcessCaptureError.ts b/src/process/capture/ProcessCaptureError.ts index 68e0d4c172..d424449c4f 100644 --- a/src/process/capture/ProcessCaptureError.ts +++ b/src/process/capture/ProcessCaptureError.ts @@ -15,6 +15,8 @@ interface ProcessCaptureErrorOptions extends AnalysisErrorOptions { readonly executionFailure?: string; readonly partialObservation?: PartialProcessCaptureObservation; readonly cleanupReport?: ProcessCaptureCleanupReport; + /** Whether the producer identified a transient failure that can be retried. */ + readonly retryable?: boolean; } /** Typed application failure produced by controlled process capture. */ @@ -29,6 +31,11 @@ export class ProcessCaptureError extends AnalysisError { | PartialProcessCaptureObservation | undefined; override readonly cleanupReport: ProcessCaptureCleanupReport | undefined; + readonly #retryable: boolean; + + get retryable(): boolean { + return this.#retryable && !this.cleanupIncomplete; + } constructor(message: string, options?: ProcessCaptureErrorOptions) { super(message, options); @@ -41,6 +48,7 @@ export class ProcessCaptureError extends AnalysisError { this.executionFailure = options?.executionFailure ?? message; this.partialObservation = options?.partialObservation; this.cleanupReport = options?.cleanupReport; + this.#retryable = options?.retryable === true; } /** Add final capture observations while preserving primary facts and cleanup ownership. */ @@ -82,6 +90,7 @@ export class ProcessCaptureError extends AnalysisError { ...(executionFailure === undefined ? {} : { executionFailure }), ...(partialObservation === undefined ? {} : { partialObservation }), ...(cleanupReport === undefined ? {} : { cleanupReport }), + ...(this.#retryable ? { retryable: true } : {}), }).bindCleanupDependencies([ this, ...(secondary === undefined ? [] : [secondary]), @@ -135,6 +144,9 @@ export const normalizeCaptureFailure = ( ...(cause.capturedOutput === undefined ? {} : { capturedOutput: cause.capturedOutput }), + ...(cause.compilerOutcome?.outcome === "timed_out" + ? { retryable: true } + : {}), }); return cause.cleanupFailure === undefined ? normalized From b4c3a028fe541a01570e8cebb3b3ac0fef1da68a Mon Sep 17 00:00:00 2001 From: RRFZ <247457381+Plastic-time@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:39:04 -0500 Subject: [PATCH 2/4] fix(windows): leave a shortcut icon comparison unknown across code pages (#9691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(windows): leave a shortcut icon comparison unknown across code pages Windows stores the LinkInfo LocalBasePath only as ANSI when the system code page can represent it, while StringData stays UTF-16. The parser projects the ANSI bytes as Latin1, so on a code page 936 host a path such as F:\百度网盘\BaiduNetdisk\BaiduNetdisk.exe never matched the same Unicode IconLocation, and icon_location_differs_from_target reported true for a shortcut whose icon is its own target. Record whether each target path is an ANSI projection. Compare it with the icon only when both use the same encoding or the ANSI text is ASCII. A full target that cannot be compared makes the result null, like a %VAR% target. * fix(windows): keep each LinkInfo part's encoding when joining paths A joined LinkInfo target was marked ANSI when any part was ANSI. An ASCII ANSI share with a UTF-16 suffix such as \\server\share + 工具\app.exe then could not be compared with a Unicode icon, although no byte needs a code page. Record which encoding each part's non-ASCII text came from, and compare two paths when their non-ASCII text shares one encoding. --- docs/guides/native/windows-shortcut.md | 5 +- .../windows/WindowsShortcutParser.test.ts | 90 ++++++++++++ src/native/windows/WindowsShortcutParser.ts | 138 ++++++++++++------ .../binaryDiagnostics/windowsShortcut.ts | 13 +- 4 files changed, 198 insertions(+), 48 deletions(-) diff --git a/docs/guides/native/windows-shortcut.md b/docs/guides/native/windows-shortcut.md index 548ad6421c..feadfa18a1 100644 --- a/docs/guides/native/windows-shortcut.md +++ b/docs/guides/native/windows-shortcut.md @@ -62,7 +62,10 @@ arguments are present, argument character length, target extension, whether `ShowCommand` is minimized, and whether the icon location differs from the recorded target path. That comparison is `null` unless the icon and a recorded target are both full paths: a stored filename, relative path, or `%VAR%` form -cannot show that two locations differ. This workflow does not emit verdicts +cannot show that two locations differ. Windows stores the `LinkInfo` path only +as ANSI when the system code page can represent it. An ANSI path with non-ASCII +bytes is therefore not compared with a Unicode icon location, or the reverse, +because the code page is unobserved. This workflow does not emit verdicts about intent. A missing optional section is observed absence of that structure. It is not diff --git a/src/native/windows/WindowsShortcutParser.test.ts b/src/native/windows/WindowsShortcutParser.test.ts index bbe1831c9b..20c66ce0e0 100644 --- a/src/native/windows/WindowsShortcutParser.test.ts +++ b/src/native/windows/WindowsShortcutParser.test.ts @@ -644,6 +644,96 @@ it("compares an icon with a full target path, ignoring case and separators", asy expect(iconDifference(different)).toBe(true); }); +it("compares an icon with a target only in a shared or ASCII encoding", async () => { + // Windows stores the LinkInfo path only as ANSI when the system code page can + // represent it. 0xB9 0xA4 0xBE 0xDF is "工具" in code page 936; Latin1 differs. + const gbk = "D:\\\u00b9\u00a4\u00be\u00df\\app.exe"; + const iconFor = async ( + options: Parameters[0], + ): Promise => + iconDifference(await parse(windowsShortcutBytes(options))); + const ansiLinkInfo = (localBasePath: string) => ({ + localBasePath, + unicode: false, + }); + expect( + await iconFor({ + linkInfo: ansiLinkInfo(gbk), + iconLocation: "D:\\工具\\app.exe", + }), + ).toBeNull(); + // A different comparable target does not outweigh the one that cannot be read. + expect( + await iconFor({ + linkInfo: ansiLinkInfo(gbk), + extraData: [{ kind: "environment", unicode: "C:\\Other\\x.exe" }], + iconLocation: "D:\\工具\\app.exe", + }), + ).toBeNull(); + // An ANSI icon cannot be compared with a Unicode target either. + expect( + await iconFor({ + unicode: false, + linkInfo: { localBasePath: "D:\\工具\\app.exe", unicode: true }, + iconLocation: gbk, + }), + ).toBeNull(); + // Both ANSI: the same code page, so the bytes compare. + for (const [iconLocation, differs] of [ + [gbk, false], + ["D:\\\u00b9\u00a4\u00be\u00df\\other.exe", true], + ] as const) + expect( + await iconFor({ + unicode: false, + linkInfo: { localBasePath: gbk }, + iconLocation, + }), + ).toBe(differs); + // ASCII is the same text in every ANSI code page, so it still compares. + for (const [iconLocation, differs] of [ + ["C:\\Tools\\app.exe", false], + ["C:\\Tools\\other.exe", true], + ] as const) + expect( + await iconFor({ + linkInfo: ansiLinkInfo("C:\\Tools\\app.exe"), + iconLocation, + }), + ).toBe(differs); + // A joined path keeps each part's encoding: an ASCII ANSI share with a + // Unicode suffix, or a Unicode share with an ASCII ANSI suffix, compares. + for (const [linkInfo, iconLocation, differs] of [ + [ + { netName: "\\\\server\\share", commonPathSuffix: "工具\\app.exe" }, + "\\\\server\\share\\工具\\app.exe", + false, + ], + [ + { netName: "\\\\server\\share", commonPathSuffix: "工具\\app.exe" }, + "\\\\server\\share\\工具\\other.exe", + true, + ], + ] as const) + expect( + await iconFor({ + linkInfo: { ...linkInfo, unicode: true, networkUnicode: false }, + iconLocation, + }), + ).toBe(differs); + expect( + await iconFor({ + linkInfo: { + netName: "\\\\server\\共享", + commonPathSuffix: "app.exe", + unicode: false, + networkUnicode: true, + }, + iconLocation: "\\\\server\\共享\\app.exe", + }), + ).toBe(false); +}); + it("compares an icon with a target after collapsing dot segments as text", async () => { const iconFor = async (localBasePath: string, iconLocation: string) => iconDifference( diff --git a/src/native/windows/WindowsShortcutParser.ts b/src/native/windows/WindowsShortcutParser.ts index 6b8c88d9c9..93029f9f01 100644 --- a/src/native/windows/WindowsShortcutParser.ts +++ b/src/native/windows/WindowsShortcutParser.ts @@ -295,66 +295,117 @@ const isFullPath = (path: string): boolean => const hasUnexpandedVariable = (path: string): boolean => /%[^%\\/]+%/u.test(path); +/** + * A recorded path and where its non-ASCII text came from. Every ANSI code page + * agrees with ASCII, so only non-ASCII characters depend on the encoding. A + * non-ASCII ANSI byte is a character from the unobserved system code page, + * which its Latin1 projection does not name. + */ +type RecordedPath = { + readonly path: string; + /** Holds non-ASCII text projected from ANSI bytes. */ + readonly ansi: boolean; + /** Holds non-ASCII UTF-16 text. */ + readonly unicode: boolean; +}; + +const isAscii = (text: string): boolean => + [...text].every((char) => char <= "\u007f"); + +const recorded = (path: string, ansi: boolean): RecordedPath => { + const nonAscii = !isAscii(path); + return { path, ansi: ansi && nonAscii, unicode: !ansi && nonAscii }; +}; + +/** Two paths compare as text only when their non-ASCII parts share one encoding. */ +const comparableText = (left: RecordedPath, right: RecordedPath): boolean => + !((left.ansi || right.ansi) && (left.unicode || right.unicode)); + const iconDiffersFromTarget = ( - icon: string | undefined, - targets: readonly string[], + icon: WindowsShortcut["string_data"]["icon_location"], + targets: readonly RecordedPath[], ): boolean | null => { - if (icon === undefined || !isFullPath(icon) || hasUnexpandedVariable(icon)) - return null; - const location = normalizePath(icon); - const fixedTargets = targets.filter( - (target) => isFullPath(target) && !hasUnexpandedVariable(target), + if (icon === null) return null; + const text = icon.value; + if (!isFullPath(text) || hasUnexpandedVariable(text)) return null; + const iconPath = recorded(text, icon.encoding === "ansi"); + const location = normalizePath(text); + const fullTargets = targets.filter((target) => isFullPath(target.path)); + const fixedTargets = fullTargets.filter( + (target) => + !hasUnexpandedVariable(target.path) && comparableText(target, iconPath), ); - if (fixedTargets.some((target) => normalizePath(target) === location)) + if (fixedTargets.some((target) => normalizePath(target.path) === location)) return false; - // A variable-bearing target could name the icon's file, so no difference is claimed. - if (targets.some((t) => isFullPath(t) && hasUnexpandedVariable(t))) - return null; + // A variable-bearing target, or one whose ANSI bytes the icon text cannot be + // compared with, could name the icon's file, so no difference is claimed. + if (fullTargets.length > fixedTargets.length) return null; return fixedTargets.length === 0 ? null : true; }; +/** LinkInfo local and network base paths, each joined with CommonPathSuffix. */ +const linkInfoTargets = ( + info: NonNullable, +): RecordedPath[] => { + const suffixText = info.common_path_suffix_unicode ?? info.common_path_suffix; + const suffix = + suffixText === null || suffixText.length === 0 + ? null + : recorded(suffixText, info.common_path_suffix_unicode === null); + // Each part keeps its own encoding, so an ASCII ANSI part never hides a + // comparable Unicode part. + const withSuffix = (base: RecordedPath): RecordedPath => + suffix === null + ? base + : { + path: `${base.path.replace(/\\$/u, "")}\\${suffix.path.replace(/^\\/u, "")}`, + ansi: base.ansi || suffix.ansi, + unicode: base.unicode || suffix.unicode, + }; + const values: RecordedPath[] = []; + const local = info.local_base_path_unicode ?? info.local_base_path; + if (local !== null && (suffix !== null || local.length > 0)) + values.push( + withSuffix(recorded(local, info.local_base_path_unicode === null)), + ); + const net = info.common_network_relative_link; + if (net !== null) { + const share = net.net_name_unicode ?? net.net_name; + if (share !== null) + values.push(withSuffix(recorded(share, net.net_name_unicode === null))); + } + return values; +}; + const targetPaths = (report: { readonly link_info: WindowsShortcut["link_info"]; readonly string_data: WindowsShortcut["string_data"]; readonly id_list: WindowsShortcut["id_list"]; readonly extra_data: WindowsShortcut["extra_data"]; -}): string[] => { - const values: string[] = []; - const info = report.link_info; - if (info !== null) { - const local = info.local_base_path_unicode ?? info.local_base_path; - const suffix = info.common_path_suffix_unicode ?? info.common_path_suffix; - if (local !== null && suffix !== null && suffix.length > 0) - values.push( - `${local.replace(/\\$/u, "")}\\${suffix.replace(/^\\/u, "")}`, - ); - else if (local !== null && local.length > 0) values.push(local); - const net = info.common_network_relative_link; - if (net !== null) { - const share = net.net_name_unicode ?? net.net_name; - if (share !== null && suffix !== null && suffix.length > 0) - values.push( - `${share.replace(/\\$/u, "")}\\${suffix.replace(/^\\/u, "")}`, - ); - else if (share !== null) values.push(share); - } - } - if (report.string_data.relative_path !== null) - values.push(report.string_data.relative_path.value); +}): RecordedPath[] => { + const values: RecordedPath[] = + report.link_info === null ? [] : linkInfoTargets(report.link_info); + const relative = report.string_data.relative_path; + if (relative !== null) + values.push(recorded(relative.value, relative.encoding === "ansi")); if (report.id_list !== null) { const lastFile = [...report.id_list.items] .reverse() .find((item) => item.kind === "file_entry"); - if (lastFile?.kind === "file_entry") values.push(lastFile.short_name); + // Type bit 0x04 marks a UTF-16LE name; otherwise the name is ANSI. + if (lastFile?.kind === "file_entry") + values.push( + recorded(lastFile.short_name, (lastFile.type_byte & 0x04) === 0), + ); } // A stored literal target, never expanded: `%VAR%` text stays as written. for (const block of report.extra_data.blocks) { if (block.decoded.kind !== "environment_variable") continue; - const stored = - block.decoded.target_unicode.length > 0 - ? block.decoded.target_unicode - : block.decoded.target_ansi; - if (stored.length > 0) values.push(stored); + const unicode = block.decoded.target_unicode.length > 0; + const stored = unicode + ? block.decoded.target_unicode + : block.decoded.target_ansi; + if (stored.length > 0) values.push(recorded(stored, !unicode)); } return values; }; @@ -389,7 +440,7 @@ const observationsOf = ( { kind: "target_extension", evidence_kind: "derived", - extension: extensionOf(targets[0] ?? null), + extension: extensionOf(targets[0]?.path ?? null), }, { kind: "show_command_minimized", @@ -399,10 +450,7 @@ const observationsOf = ( { kind: "icon_location_differs_from_target", evidence_kind: "derived", - value: iconDiffersFromTarget( - report.string_data.icon_location?.value, - targets, - ), + value: iconDiffersFromTarget(report.string_data.icon_location, targets), }, ]; }; diff --git a/tests/fixtures/binaryDiagnostics/windowsShortcut.ts b/tests/fixtures/binaryDiagnostics/windowsShortcut.ts index b8eb930b83..90eddc6792 100644 --- a/tests/fixtures/binaryDiagnostics/windowsShortcut.ts +++ b/tests/fixtures/binaryDiagnostics/windowsShortcut.ts @@ -67,6 +67,13 @@ export type FixtureLinkInfo = { readonly deviceName?: string; readonly networkProviderType?: number; readonly networkFlags?: number; + /** + * Write LinkInfo's Unicode fields: header 0x24, Unicode volume label and + * network names. Defaults to the link's IsUnicode. + */ + readonly unicode?: boolean; + /** Write Unicode network names; defaults to `unicode`. */ + readonly networkUnicode?: boolean; }; export type FixtureExtraBlock = @@ -278,7 +285,9 @@ const linkInfoBytes = (info: FixtureLinkInfo, unicode: boolean): Buffer => { ? ansiZ(info.localBasePath ?? "C:\\target.exe") : Buffer.alloc(0); const network = - flags & 0x2 ? networkLinkBytes(info, unicode) : Buffer.alloc(0); + flags & 0x2 + ? networkLinkBytes(info, info.networkUnicode ?? unicode) + : Buffer.alloc(0); const suffixAnsi = ansiZ(info.commonPathSuffix ?? ""); const localWide = unicode && flags & 0x1 @@ -512,7 +521,7 @@ export const windowsShortcutBytes = ( if (flags & LINK_FLAG.HasLinkTargetIDList && idList !== null) parts.push(idListBytes(idList)); if (flags & LINK_FLAG.HasLinkInfo && linkInfo !== null) - parts.push(linkInfoBytes(linkInfo, unicode)); + parts.push(linkInfoBytes(linkInfo, linkInfo.unicode ?? unicode)); const strings: Buffer[] = []; if (flags & LINK_FLAG.HasName) strings.push(stringDataField(name, unicode)); if (flags & LINK_FLAG.HasRelativePath) From 99e79026c5a6ada0634001beeaa5009b1dbf1016 Mon Sep 17 00:00:00 2001 From: RRFZ <247457381+Plastic-time@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:51:53 -0500 Subject: [PATCH 3/4] fix(elf): report the link-time kernel banner, not the kbuild placeholder (#9690) Since Linux 6.1, kbuild compiles init/version.o with a temporary UTS_VERSION. Its build number and timestamp stay empty unless KBUILD_BUILD_VERSION or KBUILD_BUILD_TIMESTAMP is set. That weak linux_banner stays in the image ahead of the banner from init/version-timestamp.o, and the first-match scan reported it. The WSL 6.18 kernel showed '# SMP PREEMPT_DYNAMIC ' instead of '#1 SMP PREEMPT_DYNAMIC Tue Oct 6 19:13:07 UTC 2026'. Treat a banner with a space right after '#' or at its end as the placeholder. Keep scanning and report a later banner that repeats its text up to '#' and is not a placeholder. Report the placeholder when no such banner follows. --- docs/guides/native/kernel-build-config.md | 11 +- docs/specs/native/kernel-build-config/spec.md | 5 +- src/native/elf/KernelBuildConfig.test.ts | 25 +++++ src/native/elf/KernelBuildConfig.ts | 101 +++++++++++++----- 4 files changed, 109 insertions(+), 33 deletions(-) diff --git a/docs/guides/native/kernel-build-config.md b/docs/guides/native/kernel-build-config.md index bbc7b5052a..971de720a5 100644 --- a/docs/guides/native/kernel-build-config.md +++ b/docs/guides/native/kernel-build-config.md @@ -32,9 +32,14 @@ otherwise. followed by a version token beginning with a digit and ends at a NUL or newline within 512 bytes. `version` is that token. `builder` is the first parenthesized group (`user@host`). `compiler` is the second group up to the -last `) #`, so nested parentheses in compiler strings are kept. The banner is -`absent` when no such string was found in the searched bytes and `unknown` -when the bytes were not searched. +last `) #`, so nested parentheses in compiler strings are kept. Since Linux +6.1, kbuild also leaves the weak banner of `init/version.o`. It is compiled +before the build number and timestamp exist, so unless `KBUILD_BUILD_VERSION` +and `KBUILD_BUILD_TIMESTAMP` are both set, it has a space right after `#` or at +its end. When a later banner repeats that text up to `#` without that gap, the +later banner is reported; otherwise the first one is. The banner is `absent` +when no such string was found in the searched bytes and `unknown` when the +bytes were not searched. `config` is `present` only when all of the following hold: diff --git a/docs/specs/native/kernel-build-config/spec.md b/docs/specs/native/kernel-build-config/spec.md index 49f8d3523b..5043a06933 100644 --- a/docs/specs/native/kernel-build-config/spec.md +++ b/docs/specs/native/kernel-build-config/spec.md @@ -26,7 +26,10 @@ these files. read through the stable artifact read and never executed. 2. The banner is the first printable `Linux version ` string followed by a version token that starts with a digit and ends at a NUL or newline within - 512 bytes. Version, builder and compiler are split from it. The scan is + 512 bytes. Version, builder and compiler are split from it. A banner with a + space right after `#` or at its end is the kbuild placeholder from + `init/version.o`, built without a build number or timestamp; a later banner + that repeats its text up to `#` without that gap replaces it. The scan is linear in the searched bytes. 3. A config is `present` only when `IKCFG_ST` is followed by a gzip member that ends exactly at the next `IKCFG_ED`, inflates within `max_config_bytes`, diff --git a/src/native/elf/KernelBuildConfig.test.ts b/src/native/elf/KernelBuildConfig.test.ts index eb9f93c6ea..66f29834e7 100644 --- a/src/native/elf/KernelBuildConfig.test.ts +++ b/src/native/elf/KernelBuildConfig.test.ts @@ -511,6 +511,31 @@ it("finds a banner after overlapping unterminated repeats in one linear scan", a }); }); +it("reports the link-time banner after the kbuild placeholder banner", async () => { + // Linux 6.1+ keeps init/version.o's weak banner, built before the build + // number and timestamp exist, ahead of the banner the kernel prints. + const head = "Linux version 6.18.0 (u@h) (gcc 13.2.0) #"; + const other = "Linux version 1.0.0 (x@y) (cc) #2"; + const linked = `${head}1 SMP PREEMPT Tue Oct 6 19:13:07 UTC 2026`; + // No build number, then a set KBUILD_BUILD_VERSION with no timestamp. + for (const placeholder of [`${head} SMP PREEMPT `, `${head}1 SMP PREEMPT `]) { + const bytes = Buffer.from( + `${placeholder}\n\0${other}\n\0${placeholder}\n\0${linked}\n`, + ); + expect((await parse(bytes)).banner).toMatchObject({ + text: linked, + version: "6.18.0", + builder: "u@h", + compiler: "gcc 13.2.0", + location: { offset: bytes.length - linked.length - 1 }, + }); + // Without a matching link-time banner the placeholder is still reported. + expect( + (await parse(Buffer.from(`${placeholder}\n\0${other}\n`))).banner, + ).toMatchObject({ text: placeholder, location: { offset: 0 } }); + } +}); + it("honors cancellation during the marker, inflate, and banner scans", async () => { const input = inspectKernelBuildConfigInputSchema.parse({ path: artifactPath, diff --git a/src/native/elf/KernelBuildConfig.ts b/src/native/elf/KernelBuildConfig.ts index 29292a0811..39c81d8f97 100644 --- a/src/native/elf/KernelBuildConfig.ts +++ b/src/native/elf/KernelBuildConfig.ts @@ -556,11 +556,58 @@ const absentBanner = (status: "absent" | "unknown"): Report["banner"] => ({ const isPrintable = (value: number): boolean => value >= 0x20 && value <= 0x7e; +/** + * Text up to and including `#` when the banner's `UTS_VERSION` lacks a build + * number or a timestamp, otherwise null. Since Linux 6.1, kbuild compiles + * `init/version.o` with a temporary `UTS_VERSION` that leaves both empty + * unless `KBUILD_BUILD_VERSION` or `KBUILD_BUILD_TIMESTAMP` is set, so that + * object's weak `linux_banner` has a space right after `#` or at its end. The + * banner the kernel prints comes from `init/version-timestamp.o` in the final + * link: the same text up to `#`, then the build number and timestamp. + */ +const kbuildPlaceholderPrefix = (text: string): string | null => { + const marked = text.lastIndexOf(") #"); + if (marked < 0) return null; + return text.startsWith(" ", marked + 3) || text.endsWith(" ") + ? text.slice(0, marked + 3) + : null; +}; + +/** + * First `Linux version ...` banner in the printable run that ends at + * `end`. Only hits within BANNER_TEXT_BYTES of the terminator fit the window. + */ +const bannerInRun = ( + bytes: Buffer, + at: number, + end: number, +): { + readonly hit: number; + readonly text: string; + readonly parsed: NonNullable>; +} | null => { + const run = bytes.subarray(0, end); + for ( + let hit = run.indexOf(BANNER_PREFIX, Math.max(at, end - BANNER_TEXT_BYTES)); + hit >= 0; + hit = run.indexOf(BANNER_PREFIX, hit + 1) + ) { + const first = bytes[hit + BANNER_PREFIX.length] ?? 0; + if (first < 0x30 || first > 0x39) continue; + const text = bytes.toString("latin1", hit, end); + const parsed = parseBannerText(text); + if (parsed !== null) return { hit, text, parsed }; + } + return null; +}; + /** * First printable, NUL- or newline-terminated `Linux version ...` - * string of at most BANNER_TEXT_BYTES. Every hit inside one printable run - * shares that run's terminator, so each run is scanned once and the search - * resumes after it: the scan is linear in the searched bytes. + * string of at most BANNER_TEXT_BYTES. A kbuild placeholder banner is + * replaced by a later banner that repeats its text up to `#` and is not a + * placeholder itself; without one, the placeholder is reported. Every hit inside one + * printable run shares that run's terminator, so each run is scanned once and + * the search resumes after it: the scan is linear in the searched bytes. */ const findBanner = async ( bytes: Buffer, @@ -569,10 +616,14 @@ const findBanner = async ( ): Promise => { let from = 0; let turnAt = TURN_STRIDE; + let placeholder: { + readonly banner: Report["banner"]; + readonly prefix: string; + } | null = null; for (;;) { assertArtifactReadActive(signal); const at = await findNext(bytes, BANNER_PREFIX, from, signal); - if (at < 0) return absentBanner("absent"); + if (at < 0) return placeholder?.banner ?? absentBanner("absent"); let end = at + BANNER_PREFIX.length; while (end < bytes.length && isPrintable(bytes[end] ?? 0)) { end += 1; @@ -585,31 +636,23 @@ const findBanner = async ( from = end + 1; if (end >= bytes.length || (bytes[end] !== 0 && bytes[end] !== 0x0a)) continue; - // Only hits within BANNER_TEXT_BYTES of the terminator fit the window. - const run = bytes.subarray(0, end); - for ( - let hit = run.indexOf( - BANNER_PREFIX, - Math.max(at, end - BANNER_TEXT_BYTES), - ); - hit >= 0; - hit = run.indexOf(BANNER_PREFIX, hit + 1) - ) { - const first = bytes[hit + BANNER_PREFIX.length] ?? 0; - if (first < 0x30 || first > 0x39) continue; - const text = bytes.toString("latin1", hit, end); - const parsed = parseBannerText(text); - if (parsed === null) continue; - return { - status: "present", - text, - version: parsed.version, - builder: parsed.builder, - compiler: parsed.compiler, - location: located(space, hit, end - hit), - trust: UNTRUSTED, - }; - } + const found = bannerInRun(bytes, at, end); + if (found === null) continue; + const banner: Report["banner"] = { + status: "present", + text: found.text, + version: found.parsed.version, + builder: found.parsed.builder, + compiler: found.parsed.compiler, + location: located(space, found.hit, end - found.hit), + trust: UNTRUSTED, + }; + const prefix = kbuildPlaceholderPrefix(found.text); + if (placeholder === null) { + if (prefix === null) return banner; + placeholder = { banner, prefix }; + } else if (prefix === null && found.text.startsWith(placeholder.prefix)) + return banner; } }; From a4b4957346e3fc454c97491183bb2a55a895766a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:59:29 -0500 Subject: [PATCH 4/4] fix(artifacts): recover Deno inventory after cancelled preparation --- src/artifacts/DenoStandaloneArtifactReader.ts | 41 +++++- .../deno/DenoStandaloneArtifactReader.test.ts | 136 +++++++++++++++++- 2 files changed, 170 insertions(+), 7 deletions(-) diff --git a/src/artifacts/DenoStandaloneArtifactReader.ts b/src/artifacts/DenoStandaloneArtifactReader.ts index 8a94a3c815..8433035153 100644 --- a/src/artifacts/DenoStandaloneArtifactReader.ts +++ b/src/artifacts/DenoStandaloneArtifactReader.ts @@ -12,7 +12,10 @@ import type { } from "../domain/artifactFormatMetadata.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; -import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; +import { + assertArtifactReadActive, + artifactReadCancelledBy, +} from "./ArtifactFileOperation.js"; import { ArtifactFileSource } from "./ArtifactFileSource.js"; import { ArtifactPathRegistry, @@ -67,11 +70,16 @@ interface RemoteDestination { export class DenoStandaloneArtifactReader implements ArtifactReader { readonly format = "deno-standalone" as const; readonly #scope = new ArtifactResourceScope(); - readonly #entries = new Map(); + #entries = new Map(); readonly #streams = new Set(); readonly #source: ArtifactFileSource; #admission: DenoStandaloneAdmission | undefined; - #prepared: Promise | undefined; + #prepared: + | { + readonly signal: AbortSignal | undefined; + readonly promise: Promise; + } + | undefined; #header: DenoStandaloneMetadata | undefined; #closing = false; @@ -110,8 +118,27 @@ export class DenoStandaloneArtifactReader implements ArtifactReader { ): AsyncIterable { await this.#scope.run(async () => { this.#assertOpen(signal); - this.#prepared ??= this.#prepare(signal); - await this.#prepared; + for (;;) { + const pending = (this.#prepared ??= { + signal, + promise: this.#prepare(signal), + }); + try { + await pending.promise; + break; + } catch (cause: unknown) { + if (this.#prepared === pending) this.#prepared = undefined; + if ( + pending.signal !== signal && + signal?.aborted !== true && + artifactReadCancelledBy(cause, pending.signal) + ) { + this.#assertOpen(signal); + continue; + } + throw cause; + } + } await this.#source.readSelectedPath(signal); }); if (this.#header?.vfs_listing === "unsupported") @@ -262,6 +289,7 @@ export class DenoStandaloneArtifactReader implements ArtifactReader { maxParts: MAX_DENO_LOGICAL_PATH_PARTS, maxNodes: MAX_DENO_LOGICAL_PATH_NODES, }); + const entries = new Map(); const admit = (entry: ArtifactEntry): void => { if (entry.declaredSize !== null) { declaredPlaintext += BigInt(entry.declaredSize); @@ -279,7 +307,7 @@ export class DenoStandaloneArtifactReader implements ArtifactReader { ); if (retainedBytes > MAX_DENO_RETAINED_BYTES) throw denoRetainedLimit(retainedBytes); - this.#entries.set(entry.adapterKey, entry); + entries.set(entry.adapterKey, entry); }; if (section.vfs.listing === "listed") { for (const member of section.vfs.members) { @@ -302,6 +330,7 @@ export class DenoStandaloneArtifactReader implements ArtifactReader { } await this.#source.readSelectedPath(signal); this.#assertOpen(signal); + this.#entries = entries; this.#header = header; this.#admission = undefined; } diff --git a/src/artifacts/deno/DenoStandaloneArtifactReader.test.ts b/src/artifacts/deno/DenoStandaloneArtifactReader.test.ts index 252958c7af..570216940c 100644 --- a/src/artifacts/deno/DenoStandaloneArtifactReader.test.ts +++ b/src/artifacts/deno/DenoStandaloneArtifactReader.test.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; +import { buffer } from "node:stream/consumers"; import type { FileHandle } from "node:fs/promises"; -import { writeFile } from "node:fs/promises"; +import { open, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; @@ -1819,3 +1820,136 @@ it("rejects a confirmed Deno section when header and segment widths disagree", a failure: { reason: "format" }, }); }); + +async function collect(values: AsyncIterable): Promise { + const result: T[] = []; + for await (const value of values) result.push(value); + return result; +} + +it.each(["retry", "independent caller", "physical failure"] as const)( + "keeps Deno inventory available after failed preparation: %s", + async (scenario) => { + const path = await writeCarrier( + "retry", + denoElf(sectionFor(Buffer.from("source"))), + ); + const reader = new DenoStandaloneArtifactReader(path); + const controller = new AbortController(); + const reason = { stop: "first Deno inventory" }; + const physicalFailure = Object.assign( + new Error("Deno preparation read failed"), + { code: "EIO" }, + ); + const observed = await open(path, "r"); + const prototype = Object.getPrototypeOf(observed) as FileHandle; + const read = prototype.read; + let enter!: () => void; + let release!: () => void; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + let firstRead = true; + const spy = vi.spyOn(prototype, "read").mockImplementation(async function ( + this: FileHandle, + ...args: Parameters + ) { + const result = await read.apply(this, args); + if (firstRead) { + firstRead = false; + enter(); + await gate; + if (scenario === "physical failure") throw physicalFailure; + } + return result; + }); + const pending: Promise[] = []; + try { + const first = collect(reader.entries(controller.signal)); + const failure = first.then( + () => { + throw new Error("Expected cancelled Deno preparation"); + }, + (cause: unknown) => cause, + ); + pending.push(failure); + await entered; + const concurrent = + scenario === "independent caller" + ? collect(reader.entries()) + : undefined; + if (concurrent !== undefined) { + pending.push(concurrent); + void concurrent.catch(() => undefined); + } + controller.abort(reason); + release(); + const rejected = await failure; + if (scenario === "physical failure") + expect(rejected).toMatchObject({ reason: "io" }); + else { + expect(rejected).toMatchObject({ reason: "cancelled" }); + expect((rejected as Error).cause).toBe(reason); + } + const entries = await (concurrent ?? collect(reader.entries())); + expect(entries.map((entry) => entry.path)).toEqual(["main.ts"]); + expect((await buffer(await reader.open(entries[0]!))).toString()).toBe( + "source", + ); + } finally { + release(); + await Promise.allSettled(pending); + spy.mockRestore(); + await observed.close(); + await reader.close(); + } + }, +); + +it("does not publish Deno entries from cancelled partial preparation", async () => { + const contents = Buffer.from("abc"); + const path = await writeCarrier( + "partial", + denoElf( + sectionFor(contents, [ + { File: { n: "a.ts", o: [0, 1], u: true } }, + { File: { n: "b.ts", o: [1, 1], u: true } }, + { File: { n: "c.ts", o: [2, 1], u: true } }, + ]), + ), + ); + const reader = new DenoStandaloneArtifactReader(path); + const control = new DenoStandaloneArtifactReader(path); + const controller = new AbortController(); + const reason = { stop: "partial Deno inventory" }; + const expected = await collect(control.entries()); + const reserve = ArtifactPathRegistry.prototype.reservePath; + const spy = vi + .spyOn(ArtifactPathRegistry.prototype, "reservePath") + .mockImplementation(function ( + this: ArtifactPathRegistry, + ...args: Parameters + ) { + if (args[0] === "b.ts") controller.abort(reason); + return reserve.apply(this, args); + }); + try { + await expect( + collect(reader.entries(controller.signal)), + ).rejects.toMatchObject({ reason: "cancelled" }); + spy.mockRestore(); + await expect(reader.open(expected[0]!)).rejects.toMatchObject({ + reason: "integrity", + }); + expect( + (await collect(reader.entries())).map((entry) => entry.path), + ).toEqual(["a.ts", "b.ts", "c.ts"]); + } finally { + spy.mockRestore(); + await reader.close(); + await control.close(); + } +});