From 91043ea366204ce1eaac9fc405020e74f96ac8f6 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:58:28 +0900 Subject: [PATCH 1/4] fix(config): re-arm atomic writer ACL timeout memos Reimplement the intent of #7843 on the current dev atomic writer. Opt normal sync, streamed and async writes into the existing destination-keyed recovery policy while keeping both residual cleanup paths from consuming recovery. Required hardening, identity checks and publication ordering remain intact. Cover all five harden calls, cooldown boundaries, continued stalls, permission denial and cleanup with deterministic runner/clock regressions. Document the recovery behavior and retain the current config-root ACL settlement boundary. Fixes #7834 Co-authored-by: JohnnyXu --- .../troubleshooting/windows-acl-timeouts.md | 19 ++ scripts/test-layout/layout.json | 1 + src/config/atomic-write.ts | 15 +- structure/config.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + .../windows-atomic-write-acl-rearm.test.ts | 211 ++++++++++++++++++ 6 files changed, 243 insertions(+), 6 deletions(-) create mode 100644 docs-site/src/content/docs/troubleshooting/windows-acl-timeouts.md create mode 100644 tests/windows/windows-atomic-write-acl-rearm.test.ts diff --git a/docs-site/src/content/docs/troubleshooting/windows-acl-timeouts.md b/docs-site/src/content/docs/troubleshooting/windows-acl-timeouts.md new file mode 100644 index 00000000000..580cbfead1f --- /dev/null +++ b/docs-site/src/content/docs/troubleshooting/windows-acl-timeouts.md @@ -0,0 +1,19 @@ +--- +title: Windows File Permission Timeouts +description: How atomic file writes recover after a temporary icacls stall. +--- + +On Windows, opencodex uses `icacls` to protect temporary files before writing +configuration, credentials, or cached state. If permission hardening fails, the +write fails and the previous destination stays in place. + +A temporary `icacls` timeout does not require a proxy restart to recover. A later +atomic write to the same destination may make one recovery attempt. If that +attempt also times out, further writes to that destination are refused until +five minutes after the recovery attempt started. The next write can then try +hardening again; recovery happens when a write is requested, without a timer. + +Every recovery attempt must complete permission hardening before publishing the +file. Permission-denied errors remain failures and do not trigger an automatic +retry. Cleanup of a failed write does not consume the next write's recovery +attempt. Other file destinations have independent timeout tracking. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a61f3c0d32b..f69fb961e18 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1927,6 +1927,7 @@ "win-exec.test.ts": "windows", "win-paths.test.ts": "windows", "windows-acl-start-cost.test.ts": "windows", + "windows-atomic-write-acl-rearm.test.ts": "windows", "windows-atomic-replace.test.ts": "windows", "windows-deploy-close-regressions.test.ts": "windows", "windows-elevation-spawn.test.ts": "windows", diff --git a/src/config/atomic-write.ts b/src/config/atomic-write.ts index 756c8cb13d7..426f87eb9ea 100644 --- a/src/config/atomic-write.ts +++ b/src/config/atomic-write.ts @@ -207,7 +207,7 @@ function writePrivateTempFile( try { onCreated(descriptor); if (windowsHardeningApplies()) { - hardenSecretPath(path, { required: true, timeoutMemoKey }); + hardenSecretPath(path, { required: true, timeoutMemoKey, retryTimedOutOnce: true }); } // Keyed on the REAL platform, not the override: on a POSIX host the mode is // the boundary and `assertPrivateTempDescriptor` demands 0o600, which an @@ -248,7 +248,7 @@ function writePrivateTempFileWith( try { onCreated(descriptor); if (windowsHardeningApplies()) { - hardenSecretPath(path, { required: true, timeoutMemoKey }); + hardenSecretPath(path, { required: true, timeoutMemoKey, retryTimedOutOnce: true }); } if (process.platform !== "win32") fchmodSync(descriptor, 0o600); assertPrivateTempDescriptor(path, descriptor, requireStableIdentity); @@ -271,7 +271,7 @@ async function writePrivateTempFileAsync( try { onCreated(descriptor); if (windowsHardeningApplies()) { - await hardenSecretPathAsync(path, { required: true, timeoutMemoKey }); + await hardenSecretPathAsync(path, { required: true, timeoutMemoKey, retryTimedOutOnce: true }); } if (process.platform !== "win32") { fchmodSync(descriptor, 0o600); @@ -299,6 +299,8 @@ function atomicWriteFileToTarget( const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; let hardened = false; let ownsTemp = false; + // Cleanup must not consume the destination's recovery attempt for a later write. + let cleanupHarden = false; let publicationAttempted = false; let identity: BigIntStats | undefined; const created = (descriptor: number): void => { @@ -321,7 +323,7 @@ function atomicWriteFileToTarget( // its ChangeTime bump is what used to invalidate the harden memo one line // later. On POSIX the mode IS the boundary, so it stays. if (windowsHardeningApplies()) { - hardenSecretPath(tempPath, { required: true, timeoutMemoKey: path }); + hardenSecretPath(tempPath, { required: true, timeoutMemoKey: path, retryTimedOutOnce: !cleanupHarden }); return; } try { chmodSync(tempPath, 0o600); } catch { /* platform may ignore chmod */ } @@ -365,6 +367,7 @@ function atomicWriteFileToTarget( forgetEphemeralSecretPath(tmp); } catch (cause) { if (!ownsTemp) throw cause; + cleanupHarden = true; let sharedTemp = false; if (exclusive) { try { sharedTemp = assertIdentity(tmp).nlink > 1n; } catch (error) { @@ -490,12 +493,13 @@ export async function atomicWriteFileAsync( testSeam?: AtomicWriteAsyncTestSeam, ): Promise { let ownsTemp = false; + let cleanupHarden = false; const effective: AtomicWriteAsyncIO = io ?? { write: (tempPath, value) => writePrivateTempFileAsync(tempPath, value, path, () => { ownsTemp = true; }), harden: async tempPath => { // Same reasoning as the synchronous writer above. if (windowsHardeningApplies()) { - await hardenSecretPathAsync(tempPath, { required: true, timeoutMemoKey: path }); + await hardenSecretPathAsync(tempPath, { required: true, timeoutMemoKey: path, retryTimedOutOnce: !cleanupHarden }); return; } try { chmodSync(tempPath, 0o600); } catch { /* platform may ignore chmod */ } @@ -520,6 +524,7 @@ export async function atomicWriteFileAsync( forgetEphemeralSecretPath(tmp); } catch (cause) { if (!ownsTemp) throw cause; + cleanupHarden = true; // The async ACL belt bounds the writer, but it is not evidence that icacls released this // path. Leave the temp in the existing residual state instead of racing an unlink against a // live Windows handle. The default Windows writer hardens before writing secret bytes; a diff --git a/structure/config.md b/structure/config.md index 5b3301556a4..52802ef9f1d 100644 --- a/structure/config.md +++ b/structure/config.md @@ -70,7 +70,7 @@ identity, and snapshot-guarded removal. `RuntimePortState.attestationSecret` and owner-only state and are validated before a record is returned. `src/config.ts` re-exports the same symbols for compatibility, but new lifecycle-only callers import the process-state leaf directly. -Replacing config and process-state writes use `src/config/atomic-write.ts`. The leaf preserves the shared +Replacing config and process-state writes use `src/config/atomic-write.ts`. Normal Windows writes opt into the destination's single ACL timeout recovery attempt; a consumed attempt re-arms after `TIMEOUT_MEMO_REARM_MS` in `src/lib/windows-secret-acl.ts`. Both cleanup paths leave that recovery unconsumed. Every admitted attempt still requires a successful harden before secret bytes or publication; non-timeout failures retain their existing fail-closed behavior. `tests/windows/windows-atomic-write-acl-rearm.test.ts` covers all five harden calls, the re-arm boundary and cleanup. The leaf preserves the shared process-wide temp sequence, optional synchronous caller destination validation before every rename attempt, symlink target resolution, no-follow directory-entry replacement for externally writable integration directories, real-home test guard, owner manifest, Windows ACL hardening, scrub-before-unlink failure path, and explicit residual-temp errors. No-follow writes also accept raw bytes and exclusive publication: the same protected descriptor writer checks bigint file identity before and after content, then an atomic hard link refuses any occupied destination and verifies the published identity. Exclusive cleanup never truncates a shared inode or any inode after a link attempt, even if the syscall reports failure; a substituted temporary entry is neither scrubbed nor removed. A caller must not replace it with a local temp-and-rename shortcut. Publication failures in diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0b5ed542af8..ab80f37758e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,6 @@ { "update-reclaim-authorizer.test.ts": "update", "update-capability-policy.test.ts": "update", "update-service-home.test.ts": "update", "update-service-home-policy.test.ts": "update", "update-home-recovery.test.ts": "update", "update-recovery-authority.test.ts": "update", "update-recovery-boundaries.test.ts": "update", "update-home-launcher.test.ts": "update", "update-home-worker.test.ts": "update", "update-mutation-boundaries.test.ts": "update", "update-mutation-guard.test.ts": "update", "update-guarded-ops.test.ts": "update", "update-effect-authority.test.ts": "update", "update-windows-wrapper-authority.test.ts": "update", "update-mutation-scanner.test.ts": "update", "refreshed-upstream-deadlines.test.ts": "responses", "local-upstream-deadlines.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "provider-input-modalities.test.ts": "providers", "turn-membership.test.ts": "responses", "turn-membership-ingress.test.ts": "responses", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "windows-config-root-async-writers.test.ts": "windows", + "windows-atomic-write-acl-rearm.test.ts": "windows", "t3code-client.test.ts": "clients", "t3code-client-lifecycle.test.ts": "clients", "fix-jsonc-cr-comments.test.ts": "lib", diff --git a/tests/windows/windows-atomic-write-acl-rearm.test.ts b/tests/windows/windows-atomic-write-acl-rearm.test.ts new file mode 100644 index 00000000000..1b18c74a71c --- /dev/null +++ b/tests/windows/windows-atomic-write-acl-rearm.test.ts @@ -0,0 +1,211 @@ +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + forgetHardenedSecretPath, + hardenSecretPath, + resetHardenedStateForTests, + setAsyncIcaclsRunnerForTests, + setIcaclsRunnerForTests, + setNowForTests, + setPlatformForTests, + TIMEOUT_MEMO_REARM_MS, + timedOutSecretPathCountForTests, + type IcaclsResult, +} from "../../src/lib/windows-secret-acl"; +import { + resetWindowsPrincipalForTests, + setAsyncWindowsPrincipalRunnerForTests, + setWindowsPrincipalRunnerForTests, +} from "../../src/lib/windows-user-principal"; + +const native = { ...fs }; +let root = ""; +let denyUnlink = false; +// Exercise the default cleanup hardener, rather than replacing io.harden with a fake. +mock.module("node:fs", () => ({ + ...native, + unlinkSync(path: fs.PathLike) { + if (denyUnlink && String(path).startsWith(join(root, "secret.json.ocx."))) { + throw Object.assign(new Error("fixture holds temporary file"), { code: "EACCES" }); + } + native.unlinkSync(path); + }, +})); +const { + atomicWriteFile, + atomicWriteFileAsync, + atomicWriteFileStreamed, + AtomicWriteResidualTempError, + setWindowsHardeningForTests, +} = await import("../../src/config/atomic-write"); + +const OK: IcaclsResult = { success: true, exitCode: 0, timedOut: false, stdout: "" }; +const PRINCIPAL = { ...OK, stdout: "S-1-5-21-9-9-9-1001\r\nTESTHOST\\tester" }; +let now = 0; +let state: "healthy" | "timeout" | "denied" = "healthy"; +let grants: string[] = []; +let previousTimeout: string | undefined; +let previousVerify: string | undefined; + +function runAcl(args: string[]): IcaclsResult { + if (args.includes("/grant:r")) grants.push(native.readFileSync(args[0]!, "utf8")); + if (state === "timeout") { + now += 1_000; // Exhaust the injected 1s hardening budget without a real wait. + return { success: false, exitCode: null, timedOut: true, stdout: "" }; + } + return state === "healthy" ? OK : { ...OK, success: false, exitCode: 5 }; +} + +beforeEach(() => { + root = native.mkdtempSync(join(tmpdir(), "ocx-atomic-acl-rearm-")); + native.writeFileSync(join(root, "secret.json"), "previous", "utf8"); + previousTimeout = process.env.OPENCODEX_ACL_TIMEOUT_MS; + previousVerify = process.env.OPENCODEX_ACL_VERIFY_EXISTING; + process.env.OPENCODEX_ACL_TIMEOUT_MS = "1000"; + delete process.env.OPENCODEX_ACL_VERIFY_EXISTING; + resetHardenedStateForTests(); + now = 0; state = "healthy"; grants = []; denyUnlink = false; + setPlatformForTests("win32"); + setWindowsHardeningForTests(true); + setWindowsPrincipalRunnerForTests(() => PRINCIPAL); + setAsyncWindowsPrincipalRunnerForTests(async () => PRINCIPAL); + setIcaclsRunnerForTests(runAcl); + setAsyncIcaclsRunnerForTests(async args => runAcl(args)); + setNowForTests(() => now); +}); + +afterEach(() => { + denyUnlink = false; + setWindowsHardeningForTests(null); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + setNowForTests(null); + setPlatformForTests(null); + resetWindowsPrincipalForTests(); + resetHardenedStateForTests(); + if (previousTimeout === undefined) delete process.env.OPENCODEX_ACL_TIMEOUT_MS; + else process.env.OPENCODEX_ACL_TIMEOUT_MS = previousTimeout; + if (previousVerify === undefined) delete process.env.OPENCODEX_ACL_VERIFY_EXISTING; + else process.env.OPENCODEX_ACL_VERIFY_EXISTING = previousVerify; + native.rmSync(root, { recursive: true, force: true }); +}); +afterAll(() => { mock.restore(); }); + +const writers = { + sync: async (path: string, content: string) => { atomicWriteFile(path, content); }, + streamed: async (path: string, content: string) => { + atomicWriteFileStreamed(path, descriptor => native.writeFileSync(descriptor, content)); + }, + async: (path: string, content: string) => atomicWriteFileAsync(path, content), +}; + +for (const [name, write] of Object.entries(writers)) { + describe(`${name} atomic writer timeout recovery (#7834)`, () => { + test("a later write recovers the same destination, preserving bytes on failure", async () => { + const destination = join(root, "secret.json"); + state = "timeout"; + await expect(write(destination, "unpublished")).rejects.toMatchObject({ code: "ETIMEDOUT" }); + expect(native.readFileSync(destination, "utf8")).toBe("previous"); + expect(grants).toEqual([""]); // Required ACL admission precedes secret bytes. + state = "healthy"; + await write(destination, "recovered"); + expect(native.readFileSync(destination, "utf8")).toBe("recovered"); + expect(grants).toEqual(["", ""]); + expect(timedOutSecretPathCountForTests()).toBe(0); + }); + + test("a stalled recovery admits only one attempt per re-arm window", async () => { + const destination = join(root, "secret.json"); + state = "timeout"; + await expect(write(destination, "first")).rejects.toMatchObject({ code: "ETIMEDOUT" }); + const consumedAt = now; + await expect(write(destination, "recovery")).rejects.toMatchObject({ code: "ETIMEDOUT" }); + expect(grants).toHaveLength(2); + now = consumedAt + TIMEOUT_MEMO_REARM_MS - 1; + state = "healthy"; + await expect(write(destination, "too early")).rejects.toMatchObject({ code: "EACLRETRYEXHAUSTED" }); + await expect(write(destination, "still early")).rejects.toMatchObject({ code: "EACLRETRYEXHAUSTED" }); + expect(grants).toHaveLength(2); + now++; + state = "timeout"; + const rearmedAt = now; + await expect(write(destination, "still stalled")).rejects.toMatchObject({ code: "ETIMEDOUT" }); + expect(grants).toHaveLength(3); + expect(native.readFileSync(destination, "utf8")).toBe("previous"); + state = "healthy"; + await expect(write(destination, "blocked again")).rejects.toMatchObject({ code: "EACLRETRYEXHAUSTED" }); + expect(grants).toHaveLength(3); + now = rearmedAt + TIMEOUT_MEMO_REARM_MS; + await write(destination, "recovered"); + expect(grants).toEqual(["", "", "", ""]); + expect(native.readFileSync(destination, "utf8")).toBe("recovered"); + }); + + test("non-timeout ACL errors fail closed without a retry or timeout memo", async () => { + const destination = join(root, "secret.json"); + state = "denied"; + for (let attempt = 1; attempt <= 2; attempt++) { + await expect(write(destination, "unpublished")).rejects.toThrow(); + expect(grants).toHaveLength(attempt); + expect(timedOutSecretPathCountForTests()).toBe(0); + expect(native.readFileSync(destination, "utf8")).toBe("previous"); + } + state = "healthy"; + await write(destination, "recovered"); + expect(native.readFileSync(destination, "utf8")).toBe("recovered"); + }); + + test("cleanup leaves an empty residual without spending the recovery attempt", async () => { + const destination = join(root, "secret.json"); + state = "timeout"; denyUnlink = true; + await expect(write(destination, "unpublished")).rejects.toBeInstanceOf(AtomicWriteResidualTempError); + expect(grants).toEqual([""]); + const residuals = native.readdirSync(root).filter(path => path.endsWith(".tmp")); + expect(residuals).toHaveLength(1); + expect(native.readFileSync(join(root, residuals[0]!), "utf8")).toBe(""); + expect(native.readFileSync(destination, "utf8")).toBe("previous"); + denyUnlink = false; state = "healthy"; + await write(destination, "recovered"); + expect(grants).toEqual(["", ""]); + expect(native.readFileSync(destination, "utf8")).toBe("recovered"); + }); + }); +} + +for (const kind of ["sync", "async"] as const) { + test(`${kind} pre-rename hardener admits a re-armed memo only after a full harden`, async () => { + const destination = join(root, "secret.json"); + const afterTempWrite = (temp: string) => { + // Independently poison/consume the destination memo AFTER the initial harden, + // then invalidate the temp's success memo to exercise the fifth/fourth call site. + forgetHardenedSecretPath(temp); + state = "timeout"; + expect(() => hardenSecretPath(temp, { required: true, timeoutMemoKey: destination })).toThrow(); + const consumedAt = now; + expect(() => hardenSecretPath(temp, { + required: true, timeoutMemoKey: destination, retryTimedOutOnce: true, + })).toThrow(); + now = consumedAt + TIMEOUT_MEMO_REARM_MS; + state = "healthy"; + }; + if (kind === "sync") atomicWriteFile(destination, "replacement", undefined, { afterTempWrite }); + else await atomicWriteFileAsync(destination, "replacement", undefined, { afterTempWrite }); + expect(grants).toEqual(["", "replacement", "replacement", "replacement"]); + expect(native.readFileSync(destination, "utf8")).toBe("replacement"); + expect(timedOutSecretPathCountForTests()).toBe(0); + }); + + test(`${kind} pre-rename ACL denial still prevents publication`, async () => { + const destination = join(root, "secret.json"); + const afterTempWrite = (temp: string) => { + forgetHardenedSecretPath(temp); + state = "denied"; + }; + if (kind === "sync") expect(() => atomicWriteFile(destination, "replacement", undefined, { afterTempWrite })).toThrow(); + else await expect(atomicWriteFileAsync(destination, "replacement", undefined, { afterTempWrite })).rejects.toThrow(); + expect(grants).toEqual(["", "replacement"]); + expect(native.readFileSync(destination, "utf8")).toBe("previous"); + }); +} From adee1554f8cd06bbc5887c101fad690bfafb5d1a Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:10:37 +0900 Subject: [PATCH 2/4] fix(config): retain destination ACL admission until child reap Reserve default atomic-writer destinations across active writes and retained Windows ACL children. Refuse overlapping sync, streamed and async writers without spending timeout recovery or relinquishing removal ownership. Cover repeated re-arm windows, mixed writers and recovery after actual reap. Restore both principal runners before resetting regression identity state so subsequent native ACL tests use the real account. --- .../troubleshooting/windows-acl-timeouts.md | 4 + src/config/atomic-write.ts | 24 ++++- structure/config.md | 2 +- .../windows-atomic-write-acl-rearm.test.ts | 87 +++++++++++++++++++ 4 files changed, 114 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/troubleshooting/windows-acl-timeouts.md b/docs-site/src/content/docs/troubleshooting/windows-acl-timeouts.md index 580cbfead1f..b6c22c9ad30 100644 --- a/docs-site/src/content/docs/troubleshooting/windows-acl-timeouts.md +++ b/docs-site/src/content/docs/troubleshooting/windows-acl-timeouts.md @@ -13,6 +13,10 @@ attempt also times out, further writes to that destination are refused until five minutes after the recovery attempt started. The next write can then try hardening again; recovery happens when a write is requested, without a timer. +If an earlier permission-hardening process is still running, writes to the same +destination are refused until it exits, including after the five-minute window. +These refusals return promptly and preserve the next recovery attempt. + Every recovery attempt must complete permission hardening before publishing the file. Permission-denied errors remain failures and do not trigger an automatic retry. Cleanup of a failed write does not consume the next write's recovery diff --git a/src/config/atomic-write.ts b/src/config/atomic-write.ts index 426f87eb9ea..45e8113a585 100644 --- a/src/config/atomic-write.ts +++ b/src/config/atomic-write.ts @@ -18,10 +18,12 @@ import { recordOwnedConfigPath } from "../lib/config-ownership"; import { assertNotRealCodexHomeUnderTest, assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { forgetEphemeralSecretPath, + flushWindowsSecretAclReapsBeforeRemoval, hardenSecretPath, hardenSecretPathAsync, reattributeHardenedSecretPath, windowsSecretAclReapPendingForPath, + WindowsSecretAclSettleTimeoutError, } from "../lib/windows-secret-acl"; import { renameAtomicFile, @@ -30,6 +32,22 @@ import { import { getConfigDir } from "./paths"; let atomicSequence = 0; +const aclDestinations = new Set(); + +/** Own a destination through the write and any child retained after its timeout belt. */ +function admitAtomicAclWork(target: string, temp: string, enabled: boolean): (() => void) | undefined { + if (!enabled || !windowsHardeningApplies()) return undefined; + const destination = join(realpathSync(dirname(target)), basename(target)); + const key = process.platform === "win32" ? destination.toLowerCase() : destination; + if (aclDestinations.has(key)) throw new WindowsSecretAclSettleTimeoutError(); + aclDestinations.add(key); + return () => { + if (windowsSecretAclReapPendingForPath(temp)) { + // Caller settlement is bounded; admission resumes only after actual handle release. + void flushWindowsSecretAclReapsBeforeRemoval(temp).then(() => { aclDestinations.delete(key); }); + } else aclDestinations.delete(key); + }; +} /** * Whether this writer's Windows ACL branch applies. @@ -297,6 +315,7 @@ function atomicWriteFileToTarget( if (recordOwnership) recordOwnedConfigPath(getConfigDir(), path); assertResolvedTargetAllowed(path, target); const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; + const releaseAcl = admitAtomicAclWork(target, tmp, io === undefined); let hardened = false; let ownsTemp = false; // Cleanup must not consume the destination's recovery attempt for a later write. @@ -406,7 +425,7 @@ function atomicWriteFileToTarget( if (removed) forgetEphemeralSecretPath(tmp); if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause }); throw cause; - } + } finally { releaseAcl?.(); } } export function atomicWriteFile( @@ -511,6 +530,7 @@ export async function atomicWriteFileAsync( const target = resolveWriteTarget(path); assertResolvedTargetAllowed(path, target); const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; + const releaseAcl = admitAtomicAclWork(target, tmp, io === undefined); let hardened = false; let tempWasHardenedBeforeContent = false; try { @@ -560,5 +580,5 @@ export async function atomicWriteFileAsync( if (removed) forgetEphemeralSecretPath(tmp); if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause }); throw cause; - } + } finally { releaseAcl?.(); } } diff --git a/structure/config.md b/structure/config.md index 52802ef9f1d..2d030c1b54a 100644 --- a/structure/config.md +++ b/structure/config.md @@ -70,7 +70,7 @@ identity, and snapshot-guarded removal. `RuntimePortState.attestationSecret` and owner-only state and are validated before a record is returned. `src/config.ts` re-exports the same symbols for compatibility, but new lifecycle-only callers import the process-state leaf directly. -Replacing config and process-state writes use `src/config/atomic-write.ts`. Normal Windows writes opt into the destination's single ACL timeout recovery attempt; a consumed attempt re-arms after `TIMEOUT_MEMO_REARM_MS` in `src/lib/windows-secret-acl.ts`. Both cleanup paths leave that recovery unconsumed. Every admitted attempt still requires a successful harden before secret bytes or publication; non-timeout failures retain their existing fail-closed behavior. `tests/windows/windows-atomic-write-acl-rearm.test.ts` covers all five harden calls, the re-arm boundary and cleanup. The leaf preserves the shared +Replacing config and process-state writes use `src/config/atomic-write.ts`. Normal Windows writes opt into the destination's single ACL timeout recovery attempt; a consumed attempt re-arms after `TIMEOUT_MEMO_REARM_MS` in `src/lib/windows-secret-acl.ts`. Default writers reserve the resolved destination across synchronous, streamed and asynchronous writes, including any child retained after the caller's timeout belt. Overlapping writes refuse immediately with sanitized `ETIMEDOUT` until actual reap; this refusal consumes no recovery attempt and leaves removal ownership intact. Both cleanup paths leave that recovery unconsumed. Every admitted attempt still requires a successful harden before secret bytes or publication; non-timeout failures retain their existing fail-closed behavior. `tests/windows/windows-atomic-write-acl-rearm.test.ts` covers all five harden calls, the re-arm boundary, active/retained child admission and cleanup. The leaf preserves the shared process-wide temp sequence, optional synchronous caller destination validation before every rename attempt, symlink target resolution, no-follow directory-entry replacement for externally writable integration directories, real-home test guard, owner manifest, Windows ACL hardening, scrub-before-unlink failure path, and explicit residual-temp errors. No-follow writes also accept raw bytes and exclusive publication: the same protected descriptor writer checks bigint file identity before and after content, then an atomic hard link refuses any occupied destination and verifies the published identity. Exclusive cleanup never truncates a shared inode or any inode after a link attempt, even if the syscall reports failure; a substituted temporary entry is neither scrubbed nor removed. A caller must not replace it with a local temp-and-rename shortcut. Publication failures in diff --git a/tests/windows/windows-atomic-write-acl-rearm.test.ts b/tests/windows/windows-atomic-write-acl-rearm.test.ts index 1b18c74a71c..4d6bf51dd5a 100644 --- a/tests/windows/windows-atomic-write-acl-rearm.test.ts +++ b/tests/windows/windows-atomic-write-acl-rearm.test.ts @@ -4,14 +4,17 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { forgetHardenedSecretPath, + flushWindowsSecretAclReapsBeforeRemoval, hardenSecretPath, resetHardenedStateForTests, setAsyncIcaclsRunnerForTests, + setAsyncIcaclsBeltSchedulerForTests, setIcaclsRunnerForTests, setNowForTests, setPlatformForTests, TIMEOUT_MEMO_REARM_MS, timedOutSecretPathCountForTests, + windowsSecretAclReapPendingForPath, type IcaclsResult, } from "../../src/lib/windows-secret-acl"; import { @@ -83,6 +86,8 @@ afterEach(() => { setAsyncIcaclsRunnerForTests(null); setNowForTests(null); setPlatformForTests(null); + setWindowsPrincipalRunnerForTests(null); + setAsyncWindowsPrincipalRunnerForTests(null); resetWindowsPrincipalForTests(); resetHardenedStateForTests(); if (previousTimeout === undefined) delete process.env.OPENCODEX_ACL_TIMEOUT_MS; @@ -209,3 +214,85 @@ for (const kind of ["sync", "async"] as const) { expect(native.readFileSync(destination, "utf8")).toBe("previous"); }); } + +test("one destination retains at most one ACL child across repeated re-arm windows", async () => { + const destination = join(root, "secret.json"); + const held: { path: string; release: (result: IcaclsResult) => void }[] = []; + setAsyncIcaclsRunnerForTests(args => { + if (!args[0]!.startsWith(`${destination}.ocx.`)) return Promise.resolve(runAcl(args)); + return new Promise(resolve => { held.push({ path: args[0]!, release: resolve }); }); + }); + setAsyncIcaclsBeltSchedulerForTests(callback => { + let cancelled = false; + queueMicrotask(() => { if (!cancelled) { now += 1_001; callback(); } }); + return () => { cancelled = true; }; + }); + try { + const retained: number[] = []; + for (let attempt = 0; attempt < 3; attempt++) { + if (attempt === 2) now += TIMEOUT_MEMO_REARM_MS; + await expect(atomicWriteFileAsync(destination, "unpublished")).rejects.toThrow(); + retained.push(held.filter(entry => windowsSecretAclReapPendingForPath(entry.path)).length); + } + expect(retained).toEqual([1, 1, 1]); + expect(held).toHaveLength(1); + for (const write of [writers.sync, writers.streamed, writers.async]) { + now += TIMEOUT_MEMO_REARM_MS; + await expect(write(destination, "unpublished")).rejects.toMatchObject({ code: "ETIMEDOUT" }); + expect(held).toHaveLength(1); + expect(grants).toEqual([]); + expect(native.readFileSync(destination, "utf8")).toBe("previous"); + expect(native.readFileSync(held[0]!.path, "utf8")).toBe(""); + } + // A different destination is independent, even while this child is retained. + setAsyncIcaclsBeltSchedulerForTests(null); + await atomicWriteFileAsync(join(root, "other.json"), "other"); + expect(native.readFileSync(join(root, "other.json"), "utf8")).toBe("other"); + held[0]!.release({ ...OK, success: false, timedOut: true, exitCode: null }); + await flushWindowsSecretAclReapsBeforeRemoval(root); + setAsyncIcaclsRunnerForTests(async args => runAcl(args)); + // Refusals while held must not consume recovery; actual reap admits it immediately. + await atomicWriteFileAsync(destination, "recovered"); + expect(native.readFileSync(destination, "utf8")).toBe("recovered"); + expect(timedOutSecretPathCountForTests()).toBe(0); + } finally { + for (const entry of held) entry.release({ ...OK, success: false, timedOut: true, exitCode: null }); + await flushWindowsSecretAclReapsBeforeRemoval(root); + setAsyncIcaclsBeltSchedulerForTests(null); + } +}); + +test("active ACL work excludes mixed writers before its timeout belt fires", async () => { + const destination = join(root, "secret.json"); + const held: ((result: IcaclsResult) => void)[] = []; + const belts: (() => void)[] = []; + let entered!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + setAsyncIcaclsRunnerForTests(() => new Promise(resolve => { held.push(resolve); entered(); })); + setAsyncIcaclsBeltSchedulerForTests(callback => { belts.push(callback); return () => {}; }); + const result = (work: Promise) => work.then(() => "published", error => error.code ?? error.name); + const first = result(atomicWriteFileAsync(destination, "first")); + try { + await started; + const sync = await result(writers.sync(destination, "sync")); + const streamed = await result(writers.streamed(destination, "streamed")); + const second = result(atomicWriteFileAsync(destination, "second")); + await new Promise(resolve => setImmediate(resolve)); + now += 1_001; + for (const belt of belts) belt(); + expect(await first).toBe("AtomicWriteResidualTempError"); + expect(await second).toBe("ETIMEDOUT"); + expect(sync).toBe("ETIMEDOUT"); + expect(streamed).toBe("ETIMEDOUT"); + expect(held).toHaveLength(1); + expect(grants).toEqual([]); + expect(native.readFileSync(destination, "utf8")).toBe("previous"); + } finally { + now += 1_001; + for (const belt of belts) belt(); + for (const release of held) release({ ...OK, success: false, timedOut: true, exitCode: null }); + await first; + await flushWindowsSecretAclReapsBeforeRemoval(root); + setAsyncIcaclsBeltSchedulerForTests(null); + } +}); From 92f1b53e3f602b7746059a64f65d19526bf1cc9a Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:42:55 +0900 Subject: [PATCH 3/4] test(config): require atomic ACL timeout recovery admission Keep the exact required/destination assertion and require the explicit retryTimedOutOnce opt-in added for #7834. The old exact options object rejects the intended recovery contract during test:changed. --- tests/server/config.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index 16bdc0f5d55..f9a4c4915fa 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -3440,7 +3440,7 @@ describe("config.ts – sync writer timeout keying (#840 refinement)", () => { atomicWriteFile(destination, "secret"); expect(hardenSpy).toHaveBeenCalledWith( expect.stringContaining(".tmp"), - { required: true, timeoutMemoKey: destination }, + { required: true, timeoutMemoKey: destination, retryTimedOutOnce: true }, ); } finally { hardenSpy.mockRestore(); From 858e59b7c60270f0b5a219b412c87aa91a69839c Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:44:19 +0900 Subject: [PATCH 4/4] fix(config): allow healthy overlapping Windows atomic writes --- .../troubleshooting/windows-acl-timeouts.md | 8 +- src/config/atomic-write.ts | 18 +- structure/config.md | 2 +- .../windows-atomic-write-acl-rearm.test.ts | 170 +++++++++++++++--- 4 files changed, 164 insertions(+), 34 deletions(-) diff --git a/docs-site/src/content/docs/troubleshooting/windows-acl-timeouts.md b/docs-site/src/content/docs/troubleshooting/windows-acl-timeouts.md index b6c22c9ad30..e8d34ca0d17 100644 --- a/docs-site/src/content/docs/troubleshooting/windows-acl-timeouts.md +++ b/docs-site/src/content/docs/troubleshooting/windows-acl-timeouts.md @@ -13,9 +13,11 @@ attempt also times out, further writes to that destination are refused until five minutes after the recovery attempt started. The next write can then try hardening again; recovery happens when a write is requested, without a timer. -If an earlier permission-hardening process is still running, writes to the same -destination are refused until it exits, including after the five-minute window. -These refusals return promptly and preserve the next recovery attempt. +If an earlier `icacls` process has outlived its caller's timeout belt and has not +yet been reaped, writes to the same destination are refused, including after +the five-minute window. These refusals return promptly and preserve the next +recovery attempt. Healthy overlapping writes can proceed, including while an +earlier write is hardening its temporary file or waiting to rename it. Every recovery attempt must complete permission hardening before publishing the file. Permission-denied errors remain failures and do not trigger an automatic diff --git a/src/config/atomic-write.ts b/src/config/atomic-write.ts index 45e8113a585..e177229340b 100644 --- a/src/config/atomic-write.ts +++ b/src/config/atomic-write.ts @@ -32,20 +32,26 @@ import { import { getConfigDir } from "./paths"; let atomicSequence = 0; -const aclDestinations = new Set(); +const aclDestinations = new Map>(); -/** Own a destination through the write and any child retained after its timeout belt. */ +/** Track overlapping writes, refusing only children retained past their timeout belt. */ function admitAtomicAclWork(target: string, temp: string, enabled: boolean): (() => void) | undefined { if (!enabled || !windowsHardeningApplies()) return undefined; const destination = join(realpathSync(dirname(target)), basename(target)); const key = process.platform === "win32" ? destination.toLowerCase() : destination; - if (aclDestinations.has(key)) throw new WindowsSecretAclSettleTimeoutError(); - aclDestinations.add(key); + const temps = aclDestinations.get(key) ?? new Set(); + if ([...temps].some(windowsSecretAclReapPendingForPath)) throw new WindowsSecretAclSettleTimeoutError(); + temps.add(temp); + aclDestinations.set(key, temps); + const release = (): void => { + temps.delete(temp); + if (temps.size === 0) aclDestinations.delete(key); + }; return () => { if (windowsSecretAclReapPendingForPath(temp)) { // Caller settlement is bounded; admission resumes only after actual handle release. - void flushWindowsSecretAclReapsBeforeRemoval(temp).then(() => { aclDestinations.delete(key); }); - } else aclDestinations.delete(key); + void flushWindowsSecretAclReapsBeforeRemoval(temp).then(release); + } else release(); }; } diff --git a/structure/config.md b/structure/config.md index 2d030c1b54a..ce3166854ee 100644 --- a/structure/config.md +++ b/structure/config.md @@ -70,7 +70,7 @@ identity, and snapshot-guarded removal. `RuntimePortState.attestationSecret` and owner-only state and are validated before a record is returned. `src/config.ts` re-exports the same symbols for compatibility, but new lifecycle-only callers import the process-state leaf directly. -Replacing config and process-state writes use `src/config/atomic-write.ts`. Normal Windows writes opt into the destination's single ACL timeout recovery attempt; a consumed attempt re-arms after `TIMEOUT_MEMO_REARM_MS` in `src/lib/windows-secret-acl.ts`. Default writers reserve the resolved destination across synchronous, streamed and asynchronous writes, including any child retained after the caller's timeout belt. Overlapping writes refuse immediately with sanitized `ETIMEDOUT` until actual reap; this refusal consumes no recovery attempt and leaves removal ownership intact. Both cleanup paths leave that recovery unconsumed. Every admitted attempt still requires a successful harden before secret bytes or publication; non-timeout failures retain their existing fail-closed behavior. `tests/windows/windows-atomic-write-acl-rearm.test.ts` covers all five harden calls, the re-arm boundary, active/retained child admission and cleanup. The leaf preserves the shared +Replacing config and process-state writes use `src/config/atomic-write.ts`. Normal Windows writes opt into the destination's single ACL timeout recovery attempt; a consumed attempt re-arms after `TIMEOUT_MEMO_REARM_MS` in `src/lib/windows-secret-acl.ts`. Default writers track temporary paths per resolved destination across synchronous, streamed and asynchronous writes. A new write refuses immediately with sanitized `ETIMEDOUT` only while one of that destination's tracked paths has an icacls child retained past its caller's timeout belt and not yet reaped, including across re-arm windows. Healthy overlapping writes proceed, including during ACL hardening or a pause before rename. Refusal consumes no recovery attempt and leaves removal ownership intact; completing one write does not drop another write's retained-child tracking. Both cleanup paths leave that recovery unconsumed. Every admitted attempt still requires a successful harden before secret bytes or publication; non-timeout failures retain their existing fail-closed behavior. `tests/windows/windows-atomic-write-acl-rearm.test.ts` covers all five harden calls, the re-arm boundary, healthy overlap, retained child admission and cleanup. The leaf preserves the shared process-wide temp sequence, optional synchronous caller destination validation before every rename attempt, symlink target resolution, no-follow directory-entry replacement for externally writable integration directories, real-home test guard, owner manifest, Windows ACL hardening, scrub-before-unlink failure path, and explicit residual-temp errors. No-follow writes also accept raw bytes and exclusive publication: the same protected descriptor writer checks bigint file identity before and after content, then an atomic hard link refuses any occupied destination and verifies the published identity. Exclusive cleanup never truncates a shared inode or any inode after a link attempt, even if the syscall reports failure; a substituted temporary entry is neither scrubbed nor removed. A caller must not replace it with a local temp-and-rename shortcut. Publication failures in diff --git a/tests/windows/windows-atomic-write-acl-rearm.test.ts b/tests/windows/windows-atomic-write-acl-rearm.test.ts index 4d6bf51dd5a..4775e99a43b 100644 --- a/tests/windows/windows-atomic-write-acl-rearm.test.ts +++ b/tests/windows/windows-atomic-write-acl-rearm.test.ts @@ -215,26 +215,37 @@ for (const kind of ["sync", "async"] as const) { }); } -test("one destination retains at most one ACL child across repeated re-arm windows", async () => { +test.each(["timeout", "success", "rejection"] as const)("sequential retries retain one ACL child across re-arm windows (%s)", async reapOutcome => { const destination = join(root, "secret.json"); - const held: { path: string; release: (result: IcaclsResult) => void }[] = []; + const held: { path: string; release: (result: IcaclsResult) => void; reject: () => void }[] = []; + const beltRefusals: string[] = []; setAsyncIcaclsRunnerForTests(args => { if (!args[0]!.startsWith(`${destination}.ocx.`)) return Promise.resolve(runAcl(args)); - return new Promise(resolve => { held.push({ path: args[0]!, release: resolve }); }); + return new Promise((resolve, reject) => { + held.push({ path: args[0]!, release: resolve, reject: () => reject(new Error("fixture runner failure")) }); + }); }); setAsyncIcaclsBeltSchedulerForTests(callback => { let cancelled = false; - queueMicrotask(() => { if (!cancelled) { now += 1_001; callback(); } }); + queueMicrotask(() => { + if (cancelled) return; + now += 1_001; + callback(); + // Retention must block admission before the timed-out writer reaches finally. + try { atomicWriteFile(destination, "blocked during belt"); beltRefusals.push("published"); } + catch (error) { beltRefusals.push((error as NodeJS.ErrnoException).code ?? "unknown"); } + }); return () => { cancelled = true; }; }); try { const retained: number[] = []; for (let attempt = 0; attempt < 3; attempt++) { - if (attempt === 2) now += TIMEOUT_MEMO_REARM_MS; + if (attempt > 0) now += TIMEOUT_MEMO_REARM_MS; await expect(atomicWriteFileAsync(destination, "unpublished")).rejects.toThrow(); retained.push(held.filter(entry => windowsSecretAclReapPendingForPath(entry.path)).length); } expect(retained).toEqual([1, 1, 1]); + expect(beltRefusals).toEqual(["ETIMEDOUT"]); expect(held).toHaveLength(1); for (const write of [writers.sync, writers.streamed, writers.async]) { now += TIMEOUT_MEMO_REARM_MS; @@ -248,7 +259,8 @@ test("one destination retains at most one ACL child across repeated re-arm windo setAsyncIcaclsBeltSchedulerForTests(null); await atomicWriteFileAsync(join(root, "other.json"), "other"); expect(native.readFileSync(join(root, "other.json"), "utf8")).toBe("other"); - held[0]!.release({ ...OK, success: false, timedOut: true, exitCode: null }); + if (reapOutcome === "rejection") held[0]!.reject(); + else held[0]!.release(reapOutcome === "success" ? OK : { ...OK, success: false, timedOut: true, exitCode: null }); await flushWindowsSecretAclReapsBeforeRemoval(root); setAsyncIcaclsRunnerForTests(async args => runAcl(args)); // Refusals while held must not consume recovery; actual reap admits it immediately. @@ -262,36 +274,146 @@ test("one destination retains at most one ACL child across repeated re-arm windo } }); -test("active ACL work excludes mixed writers before its timeout belt fires", async () => { +test("healthy ACL work allows overlapping async, sync and streamed writers", async () => { + const destination = join(root, "secret.json"); + let release!: (result: IcaclsResult) => void; + let entered!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + let firstCall = true; + setAsyncIcaclsRunnerForTests(args => { + if (!firstCall) return Promise.resolve(runAcl(args)); + firstCall = false; + expect(native.readFileSync(args[0]!, "utf8")).toBe(""); + runAcl(args); + return new Promise(resolve => { release = resolve; entered(); }); + }); + const first = atomicWriteFileAsync(destination, "first"); + try { + await started; + for (const name of ["async", "sync", "streamed"] as const) { + await writers[name](destination, name); + expect(native.readFileSync(destination, "utf8")).toBe(name); + } + release(OK); + await first; + expect(native.readFileSync(destination, "utf8")).toBe("first"); + expect(grants).toEqual(["", "", "", ""]); + expect(timedOutSecretPathCountForTests()).toBe(0); + } finally { + release(OK); + await first; + await flushWindowsSecretAclReapsBeforeRemoval(root); + } +}); + +test("healthy writes succeed while an earlier write is paused before rename", async () => { + const destination = join(root, "secret.json"); + let resume!: () => void; + let entered!: () => void; + const paused = new Promise(resolve => { entered = resolve; }); + const gate = new Promise(resolve => { resume = resolve; }); + const first = atomicWriteFileAsync(destination, "first", undefined, { + afterTempWrite: async temp => { + expect(native.readFileSync(temp, "utf8")).toBe("first"); + entered(); + await gate; + }, + }); + try { + await paused; + for (const name of ["async", "sync", "streamed"] as const) { + await writers[name](destination, name); + expect(native.readFileSync(destination, "utf8")).toBe(name); + } + resume(); + await first; + expect(native.readFileSync(destination, "utf8")).toBe("first"); + expect(grants).toEqual(["", "", "", ""]); + } finally { + resume(); + await first; + } +}); + +test("completing a healthy peer preserves another write's retained-child guard", async () => { const destination = join(root, "secret.json"); - const held: ((result: IcaclsResult) => void)[] = []; + let resume!: () => void; + let entered!: () => void; + let reap!: (result: IcaclsResult) => void; + const paused = new Promise(resolve => { entered = resolve; }); + const gate = new Promise(resolve => { resume = resolve; }); + const first = atomicWriteFileAsync(destination, "first", undefined, { + afterTempWrite: async () => { entered(); await gate; }, + }); + try { + await paused; + setAsyncIcaclsRunnerForTests(() => new Promise(resolve => { reap = resolve; })); + setAsyncIcaclsBeltSchedulerForTests(callback => { + let cancelled = false; + queueMicrotask(() => { if (!cancelled) { now += 1_001; callback(); } }); + return () => { cancelled = true; }; + }); + await expect(atomicWriteFileAsync(destination, "unpublished")).rejects.toBeInstanceOf(AtomicWriteResidualTempError); + resume(); + await first; + expect(native.readFileSync(destination, "utf8")).toBe("first"); + setAsyncIcaclsBeltSchedulerForTests(null); + setAsyncIcaclsRunnerForTests(async args => runAcl(args)); + for (const write of Object.values(writers)) { + await expect(write(destination, "blocked")).rejects.toMatchObject({ code: "ETIMEDOUT" }); + } + expect(grants).toEqual([""]); + reap({ ...OK, success: false, timedOut: true, exitCode: null }); + await flushWindowsSecretAclReapsBeforeRemoval(root); + await atomicWriteFileAsync(destination, "recovered"); + expect(native.readFileSync(destination, "utf8")).toBe("recovered"); + } finally { + resume(); + reap?.({ ...OK, success: false, timedOut: true, exitCode: null }); + await first; + await flushWindowsSecretAclReapsBeforeRemoval(root); + setAsyncIcaclsBeltSchedulerForTests(null); + } +}); + +test("overlaps admitted before timeout stay guarded until the last child reaps", async () => { + const destination = join(root, "secret.json"); + const held: { path: string; release: (result: IcaclsResult) => void }[] = []; const belts: (() => void)[] = []; let entered!: () => void; const started = new Promise(resolve => { entered = resolve; }); - setAsyncIcaclsRunnerForTests(() => new Promise(resolve => { held.push(resolve); entered(); })); + setAsyncIcaclsRunnerForTests(args => new Promise(resolve => { + held.push({ path: args[0]!, release: resolve }); + if (held.length === 2) entered(); + })); setAsyncIcaclsBeltSchedulerForTests(callback => { belts.push(callback); return () => {}; }); - const result = (work: Promise) => work.then(() => "published", error => error.code ?? error.name); - const first = result(atomicWriteFileAsync(destination, "first")); + const results = ["first", "second"].map(content => atomicWriteFileAsync(destination, content).then( + () => "published", error => error.name, + )); try { - await started; - const sync = await result(writers.sync(destination, "sync")); - const streamed = await result(writers.streamed(destination, "streamed")); - const second = result(atomicWriteFileAsync(destination, "second")); - await new Promise(resolve => setImmediate(resolve)); + expect(await Promise.race([started.then(() => true), results[1]!.then(() => false)])).toBe(true); now += 1_001; for (const belt of belts) belt(); - expect(await first).toBe("AtomicWriteResidualTempError"); - expect(await second).toBe("ETIMEDOUT"); - expect(sync).toBe("ETIMEDOUT"); - expect(streamed).toBe("ETIMEDOUT"); - expect(held).toHaveLength(1); - expect(grants).toEqual([]); + expect(await Promise.all(results)).toEqual(["AtomicWriteResidualTempError", "AtomicWriteResidualTempError"]); + expect(held.every(entry => windowsSecretAclReapPendingForPath(entry.path))).toBe(true); + setAsyncIcaclsBeltSchedulerForTests(null); + setAsyncIcaclsRunnerForTests(async args => runAcl(args)); + await expect(atomicWriteFileAsync(destination, "blocked")).rejects.toMatchObject({ code: "ETIMEDOUT" }); + held[0]!.release({ ...OK, success: false, timedOut: true, exitCode: null }); + await flushWindowsSecretAclReapsBeforeRemoval(held[0]!.path); + now += TIMEOUT_MEMO_REARM_MS; + await expect(atomicWriteFileAsync(destination, "still blocked")).rejects.toMatchObject({ code: "ETIMEDOUT" }); expect(native.readFileSync(destination, "utf8")).toBe("previous"); + expect(grants).toEqual([]); + held[1]!.release(OK); + await flushWindowsSecretAclReapsBeforeRemoval(root); + await atomicWriteFileAsync(destination, "recovered"); + expect(native.readFileSync(destination, "utf8")).toBe("recovered"); } finally { now += 1_001; for (const belt of belts) belt(); - for (const release of held) release({ ...OK, success: false, timedOut: true, exitCode: null }); - await first; + for (const entry of held) entry.release({ ...OK, success: false, timedOut: true, exitCode: null }); + await Promise.all(results); await flushWindowsSecretAclReapsBeforeRemoval(root); setAsyncIcaclsBeltSchedulerForTests(null); }