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.
This commit is contained in:
parent
23de4f77c7
commit
172407787f
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {}
|
||||
|
|
|
|||
|
|
@ -212,7 +212,12 @@ static void verifyPrivate(HANDLE handle, const std::wstring& path, bool root) {
|
|||
}
|
||||
|
||||
std::unique_ptr<Runtime> createRuntime(const std::wstring& parentPath, const std::wstring& prefix) {
|
||||
auto parent = openFile(parentPath, 0, true);
|
||||
std::unique_ptr<File> 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<unsigned char, 16> random;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Record<string, string>> = {
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<string, WindowsPrivateRuntime>();
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue