From 172407787f1842dedbcd27b5385177595f786ce4 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:42:54 +0800 Subject: [PATCH] fix(windows): preserve transient runtime admission failures (#2467) Fixes #2257. Thanks to @chqcjq for the native self-test retry diagnosis in #2266. Preserve retry disposition through analysis workflows. Native warm admission requires exact sharing/lock codes and affirmative pre-allocation evidence; cleanup ownership takes precedence. No automatic retry or new analysis cache. Verified with real Windows 11 CLI, same-connection SDK MCP normal/conflict/recovery, the Windows native verifier, 99 focused tests and static/docs gates. Official npm 11.16.0 launcher sources were the application input. No IDA or Ghidra engine was used for this change. Win32 5 was not reproduced; no resolution claim for #1701/#2196. Performance measurements and limits are recorded in experiments/analysis-efficiency/windows-runtime-retry.md. --- .../windows-runtime-retry.md | 107 ++++++++++++++++++ native/windows/README.md | 11 ++ native/windows/src/addon.cc | 2 + native/windows/src/authority.hpp | 2 + native/windows/src/filesystem.cc | 7 +- scripts/verify-windows-native.mjs | 33 ++++++ src/domain/analysisErrorCore.ts | 8 +- src/domain/analysisErrorPresentation.ts | 7 ++ src/domain/analysisErrorProjection.test.ts | 85 ++++++++++++++ src/domain/analysisErrorProjection.ts | 2 + .../analysis/JavaScriptAnalysisWorker.ts | 8 +- src/javascript/sourceMaps/SourceMapCommand.ts | 8 +- src/process/PrivateRuntimeRoot.ts | 15 +-- .../PrivateRuntimeRootUnavailableError.ts | 14 +++ src/process/WindowsAuthority.ts | 2 + src/wasm/WabtArtifactProvider.test.ts | 78 +++++++------ src/wasm/WabtFailures.ts | 4 +- src/windows/WindowsPrivateRuntime.ts | 26 ++++- .../sourceMapRuntimeAvailability.test.ts | 35 ++++++ .../windowsRuntimeAdmission.test.ts | 99 ++++++++++++++++ 20 files changed, 491 insertions(+), 62 deletions(-) create mode 100644 experiments/analysis-efficiency/windows-runtime-retry.md create mode 100644 src/process/PrivateRuntimeRootUnavailableError.ts create mode 100644 tests/boundary/javascript/sourceMapRuntimeAvailability.test.ts create mode 100644 tests/process-global/windowsRuntimeAdmission.test.ts diff --git a/experiments/analysis-efficiency/windows-runtime-retry.md b/experiments/analysis-efficiency/windows-runtime-retry.md new file mode 100644 index 000000000..2286727e8 --- /dev/null +++ b/experiments/analysis-efficiency/windows-runtime-retry.md @@ -0,0 +1,107 @@ +# Windows runtime admission retry verification + +Scope: [#2257](https://github.com/morluto/rea/issues/2257). Related initial +diagnosis: [#2266](https://github.com/morluto/rea/pull/2266). + +## Revisions and real input + +The baseline is `4f4f6cd7b5d7a400b90772a138d7b9f942c2a556`. +The candidate was developed from +`4773c152bc5f299fa682575bb8d1efea7587c7bc`; relevant runtime, native and +dependency-lock sources are identical between those two revisions. +These measurements precede integration of subsequent main changes. + +The host ran Windows 11 10.0.22631.0, official Windows x64 Node 24.18.0, +and locked production dependencies. The real application input was the official +npm 11.16.0 launcher distribution's `bin` directory. REA inspected that source; +it did not execute the application as an analysis target. Runtime copies, +raw replies and test executables remain outside the checkout. No IDA or Ghidra +engine was involved in this native-runtime verification. + +## Correctness and recovery + +A test-owned directory handle requesting `FILE_ADD_FILE` produced an actual +kernel sharing violation (Win32 32) during native runtime-parent admission. +Releasing that handle restored admission. Before the fix, public CLI analysis +reported a permanent `unsupported_provider` failure with `retryable: false`. +The corrected CLI preserves `capability_unavailable` and the reason, with +`category: unavailable` and `retryable: true`. + +Normal CLI analysis and post-conflict recovery produced the same canonical +normalized result before and after the correction: 535,733 compact JSON bytes, +SHA-256 `a24d3c2d9897cccc5ba70e9ee4dce03da88f033a939fe87457136fc5b3a3e2db`. +The complete successful CLI reply remained 537,124 bytes. + +A single real SDK 2.3.1 stdio MCP connection first completed analysis, then +encountered the same kernel conflict after native authority caching, then +recovered successfully when the handle was released. Its final receipt passed +and found no residual runtime directories. The native producer supplies +`runtimeAllocation: "not-started"` only when parent admission failed before +directory creation. The adapter requires that fact and exact numeric codes +32/33; permission failures, opaque error text and unknown allocation state +remain nonretryable. Incomplete cleanup also prevents retry. + +The native verifier additionally reproduces a real sharing conflict using an +owned runtime's DELETE lease, verifies the original error and allocation fact, +and confirms no child directory was allocated. This case runs unconditionally +in the Windows native verifier, without the optional process-boundary fixture. +The full verifier passed with 20 positive controls. Its optional independently +built process-boundary fixture was not supplied in this run. + +The related focused suite passed 99 tests across seven files, including worker +ownership, cancellation, cleanup, error projection, loader behavior and adapter +boundaries. Source-map and WABT acquisition-failure tests use injected failures; +they establish disposition propagation, not real engine execution. + +Thirty baseline native self-tests passed with no residual directories. +Win32 5 from #1701/#2196 was not reproduced on this host; these results do not +establish its cause or resolution. + +## Delivery and resource measurements + +One final same-connection MCP trial used one CPU per Node process, BelowNormal +priority, a 512 MiB client heap setting and a 768 MiB server heap setting. +Heap settings do not bound total process or family memory. + +| Stage | Observed wall time | Serialized reply bytes | +| ---------------------------------- | -----------------: | ---------------------: | +| Connect | 11,351 ms | Not recorded | +| Complete tool discovery, 187 tools | 744 ms | 2,498,686 | +| Normal analysis | 12,887 ms | 1,126,797 | +| Sharing failure | 265 ms | 878 | +| Recovery on the same connection | 5,578 ms | 1,126,797 | + +The earlier warm failure reply was 1,218 bytes. Successful reply sizes and the +catalogue digest were unchanged within the tested source comparison: +`7bdadddc909f4b2b093f0b3da9f79fafdac879c9d3c147938b8fbce1b52cd726`. +These are delivery bytes, not model tokens. Model-visible discovery handling, +actual model token usage and total model task cost were not measured. + +A targeted process-generation sampler collected 343 observations over +35,630 ms. Its aggregate observed CPU time was a **lower bound** of 26,766 ms: +process exits can hide the final CPU tail. Its peak sampled working-set sum was +727,244,800 bytes (about 694 MiB). This is a sampled sum, can double-count shared +pages and is not an exact physical-memory peak. Only the test-owned Node and +directory-holder processes were included; the observer itself was excluded. + +This is one controlled functional trial, with no paired CPU/memory baseline. +Cold/warm filesystem caches and host noise were not normalized. No speedup, +CPU reduction, memory reduction or model-token savings are claimed. The change +adds no analysis retry loop or cache and performs no additional engine calls. +Existing successful native authority caching remains intact; failed readiness +is retried by a later caller attempt. Retry becomes useful advice only after +the transient condition clears and cleanup permits another attempt. + +## Integration replay + +After rebasing onto main `a2d8b94dd`, runtime candidate +`2c7a09eaf3ffe26667b40081f1cf0fd2ce44a146` again passed the 99 related tests, +static and documentation gates, real Windows CLI and same-connection MCP +normal/conflict/recovery sequence. Both workflows found no residual directories; +the CLI normalized-result digest above still matched. The MCP success and error +reply sizes remained 1,126,797 and 878 bytes respectively. Upstream Android/SAZ +contract changes increased complete discovery to 2,517,904 bytes, digest +`d14588eab7404ad129907eb34a45a0db0d43233c4de829f76babd59723bed199`. +This later catalogue is not evidence of a delivery-size change caused by the +runtime-admission fix. Resource sampling above belongs to the earlier pinned +candidate; aggregate resources were not resampled for this integration replay. diff --git a/native/windows/README.md b/native/windows/README.md index 82dd5526b..c63540140 100644 --- a/native/windows/README.md +++ b/native/windows/README.md @@ -85,6 +85,17 @@ Windows failures retain their constraint, requested coordinate, Win32 code, and system message. Unsupported filesystems and reparse paths are distinct from OS access denial and missing packaged controls. +A failed runtime-parent admission additionally reports +`runtimeAllocation: "not-started"`: no runtime directory was created. The +Windows adapter uses that fact with numeric sharing/lock codes 32/33 to retain +a retryable private-runtime failure, including after successful native +authority caching. Missing allocation evidence, other codes, and opaque error +text do not establish a safe retry. Analysis errors propagate the producer's +retry disposition through JavaScript, source-map and WABT workflows; transient +failures report `category: unavailable` and retain the original reason. +Incomplete cleanup blocks an operation retry until its owned resources settle. +REA adds no automatic retry loop or new analysis cache for this behavior. + Ordinary drive-absolute paths accept backslashes, forward slashes, or mixed separators. File identity retains the original requested spelling. Separator translation does not resolve dot components, repeated separators, or extend diff --git a/native/windows/src/addon.cc b/native/windows/src/addon.cc index 0e39b84c0..bffd4cbd4 100644 --- a/native/windows/src/addon.cc +++ b/native/windows/src/addon.cc @@ -87,6 +87,8 @@ napi_value failureValue(napi_env env, const Failure& failure) { set(env, error, "requestedPath", string(env, failure.path)); set(env, error, "win32Code", number(env, failure.win32)); set(env, error, "win32Message", string(env, detail)); + if (failure.runtimeDirectoryUnallocated) + set(env, error, "runtimeAllocation", string(env, std::string("not-started"))); return error; } diff --git a/native/windows/src/authority.hpp b/native/windows/src/authority.hpp index cc713ce0b..13d7e4d60 100644 --- a/native/windows/src/authority.hpp +++ b/native/windows/src/authority.hpp @@ -33,6 +33,8 @@ struct Failure : std::runtime_error { DWORD win32; std::wstring path; std::string constraint; + // Affirmative proof for a failed runtime-parent admission; omission is unknown. + bool runtimeDirectoryUnallocated = false; Failure(std::string constraint, std::wstring path = L"", DWORD code = GetLastError()) : std::runtime_error(constraint), win32(code), path(std::move(path)), constraint(std::move(constraint)) {} diff --git a/native/windows/src/filesystem.cc b/native/windows/src/filesystem.cc index 6cfbc8e97..c8f66e5c1 100644 --- a/native/windows/src/filesystem.cc +++ b/native/windows/src/filesystem.cc @@ -212,7 +212,12 @@ static void verifyPrivate(HANDLE handle, const std::wstring& path, bool root) { } std::unique_ptr createRuntime(const std::wstring& parentPath, const std::wstring& prefix) { - auto parent = openFile(parentPath, 0, true); + std::unique_ptr parent; + try { parent = openFile(parentPath, 0, true); } + catch (Failure& failure) { + failure.runtimeDirectoryUnallocated = true; + throw; + } require(!prefix.empty() && prefix.find_first_of(L"\\/:\"<>|?*") == std::wstring::npos, "Runtime prefix must be one filename component", parentPath, ERROR_INVALID_PARAMETER); std::array random; diff --git a/scripts/verify-windows-native.mjs b/scripts/verify-windows-native.mjs index c63b6909f..3db2613ad 100644 --- a/scripts/verify-windows-native.mjs +++ b/scripts/verify-windows-native.mjs @@ -11,6 +11,7 @@ import { mkdtemp, readFile, rename, + readdir, rm, symlink, writeFile, @@ -37,6 +38,14 @@ const { WindowsPrivateRuntime, windowsPrivateRuntime } = await import( const { ghidraSessionRoot } = await import( pathToFileURL(join(packageRoot, "dist/ghidra/GhidraSessionRoot.js")) ); +const { PrivateRuntimeRoot } = await import( + pathToFileURL(join(packageRoot, "dist/process/PrivateRuntimeRoot.js")) +); +const { PrivateRuntimeRootUnavailableError } = await import( + pathToFileURL( + join(packageRoot, "dist/process/PrivateRuntimeRootUnavailableError.js"), + ) +); const workspace = await mkdtemp(join(tmpdir(), "rea-native-conformance-")); const environment = Object.entries(process.env) .filter(([, value]) => value !== undefined) @@ -140,6 +149,30 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe ]) assert.throws(() => native.call("open", [rejected])); report.controls.ordinaryDriveSeparatorsAndRequestedIdentity = true; + // An owned runtime's DELETE lease conflicts with a fresh parent admission. + // This produces a genuine sharing failure after successful authority caching, + // without another executable, and proves that no child runtime was created. + const sharingParent = WindowsPrivateRuntime.create( + workspace, + "rea-sharing-parent-", + ); + try { + await assert.rejects( + PrivateRuntimeRoot.create({ parent: sharingParent.observation.path }), + (failure) => { + assert.ok(failure instanceof PrivateRuntimeRootUnavailableError); + assert.equal(failure.retryable, true); + assert.ok(failure.cause instanceof Error); + assert.equal(failure.cause.win32Code, 32); + assert.equal(failure.cause.runtimeAllocation, "not-started"); + return true; + }, + ); + assert.deepEqual(await readdir(sharingParent.observation.path), []); + report.controls.transientRuntimeAdmissionBeforeAllocation = true; + } finally { + await sharingParent.close(); + } const driveRoot = parse(workspace).root; const runtimeParent = ghidraSessionRoot({ base: join(driveRoot, ".Tmp", "scratch"), diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts index 9e08f29a3..50db91ccd 100644 --- a/src/domain/analysisErrorCore.ts +++ b/src/domain/analysisErrorCore.ts @@ -100,18 +100,24 @@ export class AnalysisOutputError extends AnalysisError { export class AnalysisCapabilityUnavailableError extends AnalysisError { readonly _tag = "AnalysisCapabilityUnavailableError"; override readonly userMessage: string | undefined; + /** Incomplete cleanup must settle before the operation can be retried. */ + readonly retryable: boolean; constructor( readonly providerId: string, readonly operation: string, readonly reason: string, - options?: AnalysisErrorOptions & { readonly userMessage?: string }, + options?: AnalysisErrorOptions & { + readonly userMessage?: string; + readonly retryable?: boolean; + }, ) { super( `Provider ${providerId} cannot execute ${operation}: ${reason}`, options, ); this.userMessage = options?.userMessage; + this.retryable = options?.retryable === true && !this.cleanupIncomplete; } } diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 215ed40fc..19c3a9002 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -1,6 +1,7 @@ import { AnalysisAccessDeniedError, AnalysisArtifactChangedError, + AnalysisCapabilityUnavailableError, AnalysisInputError, AnalysisUnsupportedTargetError, AnalysisResourceConstraintError, @@ -33,6 +34,8 @@ import type { AnalysisErrorProjection } from "./analysisErrorProjection.js"; export const analysisErrorRemediationAction = ( error: AnalysisError, ): string => { + if (error instanceof AnalysisCapabilityUnavailableError && error.retryable) + return "Review details.reason, then retry the same analysis after the condition clears."; if (error instanceof EvidenceFileError && error.published !== undefined) return "Use the published file at details.published_path and resolve the reported staging cleanup failure."; if (error instanceof AnalysisSnapshotMismatchError) @@ -112,6 +115,8 @@ export const analysisErrorRemediationAction = ( export const analysisErrorCategory = ( error: AnalysisError, ): AnalysisErrorProjection["category"] => { + if (error instanceof AnalysisCapabilityUnavailableError && error.retryable) + return "unavailable"; if ( error instanceof BinaryTargetError && error.constraint === "load_selection_rejected" @@ -220,6 +225,8 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => { const hopperMessage = hopperErrorUserMessage(error); if (hopperMessage !== undefined) return hopperMessage; if (error.userMessage !== undefined) return error.userMessage; + if (error instanceof AnalysisCapabilityUnavailableError && error.retryable) + return "Required runtime controls are unavailable for this attempt."; const standardMessage = standardErrorMessage(error._tag); if (standardMessage !== undefined) return standardMessage; if (error instanceof ArtifactOperationError) return artifactMessage(error); diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts index 467704b98..0766ca6e0 100644 --- a/src/domain/analysisErrorProjection.test.ts +++ b/src/domain/analysisErrorProjection.test.ts @@ -25,6 +25,91 @@ import { ProviderSelectionError } from "./providerSelectionError.js"; import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js"; import { nativeCallPartialObservationSchema } from "./native/nativeCallPartialObservation.js"; +describe("capability failure retry disposition", () => { + it.each([ + { options: {}, retryable: false, category: "unsupported_provider" }, + { + options: { retryable: false }, + retryable: false, + category: "unsupported_provider", + }, + { options: { retryable: true }, retryable: true, category: "unavailable" }, + ] as const)( + "preserves the producer's $retryable disposition", + ({ options, retryable, category }) => { + const reason = "Native self-test failed with a sharing violation"; + const projected = projectAnalysisError( + new AnalysisCapabilityUnavailableError( + "fixture-provider", + "inspect", + reason, + options, + ), + ); + expect(projected).toMatchObject({ + code: "capability_unavailable", + category, + retryable, + details: { reason }, + }); + if (retryable) { + expect(projected.message).toBe( + "Required runtime controls are unavailable for this attempt.", + ); + expect(projected.remediation.action).toContain( + "retry the same analysis", + ); + } + expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe( + true, + ); + }, + ); + + it("preserves a caller-selected message for a retryable failure", () => { + expect( + projectAnalysisError( + new AnalysisCapabilityUnavailableError( + "fixture-provider", + "inspect", + "temporary failure", + { + retryable: true, + userMessage: "Selected runtime diagnostic", + }, + ), + ).message, + ).toBe("Selected runtime diagnostic"); + }); + + it("retains cleanup ownership before allowing a transient operation to retry", () => { + expect( + projectAnalysisError( + new AnalysisCapabilityUnavailableError( + "fixture-provider", + "inspect", + "temporary failure", + { + retryable: true, + cleanup: { + reason: "owned handle is still live", + resources: ["native-owner"], + }, + }, + ), + ), + ).toMatchObject({ + code: "cleanup_incomplete", + retryable: false, + details: { + cleanup: "incomplete", + resources: ["native-owner"], + execution_failure: "capability_unavailable", + }, + }); + }); +}); + it("projects a published file receipt alongside retained cleanup failure", () => { const projected = projectAnalysisError( new EvidenceFileError("write", "io", { diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 723200e18..bfdf7eb42 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -83,6 +83,8 @@ export const projectAnalysisError = ( message: analysisErrorUserMessage(error), retryable: RETRYABLE_CODES.has(code) || + (error instanceof AnalysisCapabilityUnavailableError && + error.retryable) || (code === "resource_constraint" && error instanceof AnalysisResourceConstraintError && error.resource === "capacity"), diff --git a/src/javascript/analysis/JavaScriptAnalysisWorker.ts b/src/javascript/analysis/JavaScriptAnalysisWorker.ts index c02ddaa3e..6a587915b 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorker.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorker.ts @@ -27,10 +27,8 @@ import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import { ok, err, type Result } from "../../domain/result.js"; import { javaScriptRuntime } from "../../domain/runtimeVersion.js"; -import { - PrivateRuntimeRoot, - PrivateRuntimeRootUnavailableError, -} from "../../process/PrivateRuntimeRoot.js"; +import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js"; +import { PrivateRuntimeRootUnavailableError } from "../../process/PrivateRuntimeRootUnavailableError.js"; import { javaScriptWorkerMemoryLimitBytes } from "../../process/JavaScriptWorkerMemoryLimit.js"; import { ProviderProcessSupervisor, @@ -735,7 +733,7 @@ export class JavaScriptAnalysisWorker { PROVIDER, OPERATION, cause.message, - { cause }, + { cause, retryable: cause.retryable }, ) : new ProviderAdapterError(PROVIDER, OPERATION, { cause, diff --git a/src/javascript/sourceMaps/SourceMapCommand.ts b/src/javascript/sourceMaps/SourceMapCommand.ts index e86954106..9e8837952 100644 --- a/src/javascript/sourceMaps/SourceMapCommand.ts +++ b/src/javascript/sourceMaps/SourceMapCommand.ts @@ -16,10 +16,8 @@ import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import { err, ok, type Result } from "../../domain/result.js"; import { WEB_SOURCE_MAP_LIMITS } from "../../domain/webSourceLocation.js"; -import { - PrivateRuntimeRoot, - PrivateRuntimeRootUnavailableError, -} from "../../process/PrivateRuntimeRoot.js"; +import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js"; +import { PrivateRuntimeRootUnavailableError } from "../../process/PrivateRuntimeRootUnavailableError.js"; import { ProviderStartupDeadline } from "../../process/ProviderDeadline.js"; import { javaScriptWorkerMemoryLimitBytes } from "../../process/JavaScriptWorkerMemoryLimit.js"; import { cleanupOwnedProcessGroup } from "../../process/ProcessOwnership.js"; @@ -261,7 +259,7 @@ class OwnedCodec implements SourceMapCleanupOwner { PROVIDER, OPERATION, cause.message, - { cause }, + { cause, retryable: cause.retryable }, ) : new ProviderAdapterError(PROVIDER, OPERATION, { cause, diff --git a/src/process/PrivateRuntimeRoot.ts b/src/process/PrivateRuntimeRoot.ts index 7002e2f65..c319df0de 100644 --- a/src/process/PrivateRuntimeRoot.ts +++ b/src/process/PrivateRuntimeRoot.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { WindowsPrivateRuntime } from "../windows/WindowsPrivateRuntime.js"; +import { PrivateRuntimeRootUnavailableError } from "./PrivateRuntimeRootUnavailableError.js"; import { windowsNativeCapabilities, @@ -34,16 +35,6 @@ export const privateRuntimeRootCapability = ( ? windowsNativeCapabilities(platform).private_runtime_dacl : { available: true, reason: null, proof: "posix-mkdtemp-owner-only" }; -/** Expected failure when a runtime root's privacy boundary is unavailable. */ -export class PrivateRuntimeRootUnavailableError extends Error { - readonly code = "private-runtime-root-authority-unavailable"; - - constructor(message: string) { - super(message); - this.name = "PrivateRuntimeRootUnavailableError"; - } -} - /** * Owns a private temporary runtime root and removes it idempotently. * @@ -66,7 +57,9 @@ export class PrivateRuntimeRoot { const platform = options.platform ?? process.platform; const capability = privateRuntimeRootCapability(platform); if (!capability.available) - throw new PrivateRuntimeRootUnavailableError(capability.reason); + throw new PrivateRuntimeRootUnavailableError(capability.reason, { + retryable: capability.retryable === true, + }); if (platform === "win32") { const runtime = WindowsPrivateRuntime.create( options.parent ?? tmpdir(), diff --git a/src/process/PrivateRuntimeRootUnavailableError.ts b/src/process/PrivateRuntimeRootUnavailableError.ts new file mode 100644 index 000000000..27e7ae49d --- /dev/null +++ b/src/process/PrivateRuntimeRootUnavailableError.ts @@ -0,0 +1,14 @@ +/** Expected failure when a runtime root's privacy boundary is unavailable. */ +export class PrivateRuntimeRootUnavailableError extends Error { + readonly code = "private-runtime-root-authority-unavailable"; + readonly retryable: boolean; + + constructor( + message: string, + options?: ErrorOptions & { readonly retryable?: boolean }, + ) { + super(message, options); + this.name = "PrivateRuntimeRootUnavailableError"; + this.retryable = options?.retryable ?? false; + } +} diff --git a/src/process/WindowsAuthority.ts b/src/process/WindowsAuthority.ts index ccd041312..b25a22863 100644 --- a/src/process/WindowsAuthority.ts +++ b/src/process/WindowsAuthority.ts @@ -11,6 +11,7 @@ export type WindowsNativeCapability = readonly available: false; readonly reason: string; readonly proof: "not-proven" | "not-applicable"; + readonly retryable?: true; }; /** Security controls required before REA may claim Windows isolation. */ @@ -52,6 +53,7 @@ export const windowsNativeCapabilities = ( available: false, reason: loaded.reason, proof: "not-proven", + ...(loaded.retryable === true ? { retryable: true as const } : {}), } as const); return { job_object_process_ownership: capability, diff --git a/src/wasm/WabtArtifactProvider.test.ts b/src/wasm/WabtArtifactProvider.test.ts index 74d481de3..94c4e0c95 100644 --- a/src/wasm/WabtArtifactProvider.test.ts +++ b/src/wasm/WabtArtifactProvider.test.ts @@ -3,12 +3,11 @@ import { join } from "node:path"; import { expect, it } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { WabtArtifactProvider } from "./WabtArtifactProvider.js"; -import { - PrivateRuntimeRoot, - PrivateRuntimeRootUnavailableError, -} from "../process/PrivateRuntimeRoot.js"; +import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; +import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js"; import { spawnOwnedProviderProcess } from "../process/ProviderProcess.js"; import { OwnedCommandFailure } from "../process/OwnedCommand.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; const failureTags: Readonly> = { invalid: "AnalysisInputError", @@ -40,40 +39,51 @@ it("returns actionable unavailable without creating a workspace when tools are a }); }); -it("reports an unavailable private runtime root without a misleading WABT remediation", async () => { - const toolRoot = await createTestTempDirectory("rea-wabt-tool-root-"); - for (const tool of ["wasm-validate", "wasm-objdump", "wasm2wat"]) - await writeFile( - join(toolRoot, tool + (process.platform === "win32" ? ".exe" : "")), - "source-owned executable seam", - { mode: 0o700 }, - ); - const provider = new WabtArtifactProvider( - { REA_WABT_BIN_DIRECTORY: toolRoot }, - undefined, - () => { - throw new PrivateRuntimeRootUnavailableError( - "REA Windows native controls are unavailable: probe failure", +it.each([false, true])( + "reports an unavailable private runtime root with retryable=%s without a misleading WABT remediation", + async (retryable) => { + const toolRoot = await createTestTempDirectory("rea-wabt-tool-root-"); + for (const tool of ["wasm-validate", "wasm-objdump", "wasm2wat"]) + await writeFile( + join(toolRoot, tool + (process.platform === "win32" ? ".exe" : "")), + "source-owned executable seam", + { mode: 0o700 }, ); - }, - ); - expect( - await provider.inspect({ + const provider = new WabtArtifactProvider( + { REA_WABT_BIN_DIRECTORY: toolRoot }, + undefined, + () => { + throw new PrivateRuntimeRootUnavailableError( + "REA Windows native controls are unavailable: probe failure", + { retryable }, + ); + }, + ); + const result = await provider.inspect({ path: "/module.wasm", glue_paths: [], candidate_paths: [], - }), - ).toMatchObject({ - ok: false, - error: { - _tag: "AnalysisCapabilityUnavailableError", - userMessage: undefined, - message: expect.stringContaining( - "REA Windows native controls are unavailable", - ), - }, - }); -}); + }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + retryable, + userMessage: undefined, + message: expect.stringContaining( + "REA Windows native controls are unavailable", + ), + }, + }); + if (result.ok) + throw new Error("Unavailable runtime must refuse inspection"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "capability_unavailable", + retryable, + category: retryable ? "unavailable" : "unsupported_provider", + }); + }, +); it.each([ "success", "invalid", diff --git a/src/wasm/WabtFailures.ts b/src/wasm/WabtFailures.ts index ef5692f1b..4f386998f 100644 --- a/src/wasm/WabtFailures.ts +++ b/src/wasm/WabtFailures.ts @@ -11,7 +11,7 @@ import { } from "../domain/analysisErrorCore.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { OwnedCommandFailure } from "../process/OwnedCommand.js"; -import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRoot.js"; +import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js"; import { WABT_LIMITS, WABT_PROVIDER_IDENTITY } from "./WabtRelease.js"; /** Preserve lifecycle failures and diagnostic bytes instead of returning partial success. */ export const wabtFailure = ( @@ -76,7 +76,7 @@ export const wabtFailure = ( WABT_PROVIDER_IDENTITY.id, "inspect_wasm_artifact", reason, - { cause }, + { cause, retryable: cause.retryable }, ); if (phase === "configuration") return new AnalysisCapabilityUnavailableError( diff --git a/src/windows/WindowsPrivateRuntime.ts b/src/windows/WindowsPrivateRuntime.ts index e17544a49..2c361a6d0 100644 --- a/src/windows/WindowsPrivateRuntime.ts +++ b/src/windows/WindowsPrivateRuntime.ts @@ -1,6 +1,7 @@ import { win32 } from "node:path"; import { z } from "zod"; +import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js"; import { requireWindowsNativeAuthority, @@ -23,6 +24,13 @@ const fileSchema = windowsFileIdentitySchema.extend({ handle: windowsHandleSchema, }); const runtimes = new Map(); +// Retry only a proven pre-allocation sharing/lock conflict, never opaque text +// or an operation that may already own an unpublished runtime directory. +const transientRuntimeAdmissionSchema = z.object({ + code: z.literal("ERR_REA_WINDOWS_NATIVE"), + win32Code: z.union([z.literal(32), z.literal(33)]), + runtimeAllocation: z.literal("not-started"), +}); /** Retain private DACL and path handles until native recursive cleanup settles. */ export class WindowsPrivateRuntime { @@ -40,9 +48,21 @@ export class WindowsPrivateRuntime { /** Create a new directory with its private DACL installed at creation. */ static create(parent: string, prefix: string): WindowsPrivateRuntime { const authority = requireWindowsNativeAuthority(); - const observation = runtimeSchema.parse( - authority.call("runtime_create", [parent, prefix]), - ); + let observed: unknown; + try { + observed = authority.call("runtime_create", [parent, prefix]); + } catch (cause: unknown) { + if ( + cause instanceof Error && + transientRuntimeAdmissionSchema.safeParse(cause).success + ) + throw new PrivateRuntimeRootUnavailableError(cause.message, { + cause, + retryable: true, + }); + throw cause; + } + const observation = runtimeSchema.parse(observed); const runtime = new WindowsPrivateRuntime(observation, authority); runtimes.set(windowsRuntimeIdentityKey(observation), runtime); return runtime; diff --git a/tests/boundary/javascript/sourceMapRuntimeAvailability.test.ts b/tests/boundary/javascript/sourceMapRuntimeAvailability.test.ts new file mode 100644 index 000000000..753a3bd47 --- /dev/null +++ b/tests/boundary/javascript/sourceMapRuntimeAvailability.test.ts @@ -0,0 +1,35 @@ +import { expect, it } from "vitest"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { runSourceMapCommand } from "../../../src/javascript/sourceMaps/SourceMapCommand.js"; +import { PrivateRuntimeRootUnavailableError } from "../../../src/process/PrivateRuntimeRootUnavailableError.js"; + +it.each([false, true])( + "preserves private-runtime retryable=%s through the source-map operation", + async (retryable) => { + const reason = "Native runtime self-test failed with a sharing violation"; + let launches = 0; + const result = await runSourceMapCommand("{}", "/selected.map", undefined, { + prepareOwnership: async () => {}, + createRuntime: async () => { + throw new PrivateRuntimeRootUnavailableError(reason, { retryable }); + }, + launcher: async () => { + launches += 1; + throw new Error("A refused runtime must not launch a codec"); + }, + }); + expect(launches).toBe(0); + if (result.ok) throw new Error("Unavailable runtime must refuse decoding"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "capability_unavailable", + category: retryable ? "unavailable" : "unsupported_provider", + retryable, + details: { + provider_id: "source-map-decoder", + operation: "trace_web_source_location", + reason, + }, + }); + expect(result.error.cleanupIncomplete).toBe(false); + }, +); diff --git a/tests/process-global/windowsRuntimeAdmission.test.ts b/tests/process-global/windowsRuntimeAdmission.test.ts new file mode 100644 index 000000000..a9f328a18 --- /dev/null +++ b/tests/process-global/windowsRuntimeAdmission.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { WindowsPrivateRuntime } from "../../src/windows/WindowsPrivateRuntime.js"; +import { PrivateRuntimeRootUnavailableError } from "../../src/process/PrivateRuntimeRootUnavailableError.js"; + +const { nativeCall } = vi.hoisted(() => ({ nativeCall: vi.fn() })); +vi.mock("../../src/windows/WindowsNativeLoader.js", async (original) => ({ + ...(await original< + typeof import("../../src/windows/WindowsNativeLoader.js") + >()), + requireWindowsNativeAuthority: () => ({ call: nativeCall }), +})); +beforeEach(() => { + nativeCall.mockReset(); +}); + +it.each([ + { + label: "sharing conflict before allocation", + win32Code: 32, + runtimeAllocation: "not-started", + retryable: true, + }, + { + label: "lock conflict before allocation", + win32Code: 33, + runtimeAllocation: "not-started", + retryable: true, + }, + { label: "unknown allocation", win32Code: 32, retryable: false }, + { + label: "possible allocation", + win32Code: 32, + runtimeAllocation: "started", + retryable: false, + }, + { + label: "permission failure", + win32Code: 5, + runtimeAllocation: "not-started", + retryable: false, + }, + { + label: "string code", + win32Code: "32", + runtimeAllocation: "not-started", + retryable: false, + }, + { + label: "inconsistent error code", + win32Code: 32, + code: "EACCES", + runtimeAllocation: "not-started", + retryable: false, + }, +])( + "retains $label without guessing runtime ownership", + ({ retryable, label: _label, ...facts }) => { + const cause = Object.assign(new Error("Selected native diagnostic"), { + code: "ERR_REA_WINDOWS_NATIVE", + ...facts, + }); + nativeCall.mockImplementation(() => { + throw cause; + }); + let observed: unknown; + try { + WindowsPrivateRuntime.create("D:/selected parent", "selected-prefix-"); + } catch (failure: unknown) { + observed = failure; + } + expect(nativeCall).toHaveBeenCalledTimes(1); + expect(nativeCall).toHaveBeenCalledWith("runtime_create", [ + "D:/selected parent", + "selected-prefix-", + ]); + if (retryable) { + expect(observed).toBeInstanceOf(PrivateRuntimeRootUnavailableError); + expect(observed).toMatchObject({ + message: cause.message, + cause, + retryable: true, + }); + } else expect(observed).toBe(cause); + }, +); + +it("does not infer a transient status from a human-readable diagnostic", () => { + const cause = new Error("Open Windows path component failed (Win32 32)"); + nativeCall.mockImplementation(() => { + throw cause; + }); + let observed: unknown; + try { + WindowsPrivateRuntime.create("D:/selected parent", "selected-prefix-"); + } catch (failure: unknown) { + observed = failure; + } + expect(observed).toBe(cause); +});