From 0d6e6d7e00acccad07bd2d0e81a45d11835a2c6a Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Thu, 10 Sep 2026 00:47:48 -0500 Subject: [PATCH 1/5] fix(installer): retry transient Windows file locks --- CHANGELOG.md | 2 + src/mendcode/install.ps1 | 22 +++++- .../script/windows-installer-smoke.ts | 74 ++++++++++++++++--- 3 files changed, 85 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87120cca..2b683db5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ reflow moves an already-bottom scroll position. - Add native PTY coverage for queued notifications, transcript paging and double-`Esc` cancellation. +- Retry bounded Windows executable activation when a transient security scan + briefly holds the freshly verified candidate, while preserving atomic rollback. ## 0.1.44-beta.5 - 2026-09-09 diff --git a/src/mendcode/install.ps1 b/src/mendcode/install.ps1 index 0f9f9601..9309a975 100644 --- a/src/mendcode/install.ps1 +++ b/src/mendcode/install.ps1 @@ -450,9 +450,25 @@ function Activate-Candidate { } $script:Phase = "activating" Write-UpdateStatus - # File.Replace is one same-volume operation and preserves the original as its backup. - if ([IO.File]::Exists($destination)) { [IO.File]::Replace($candidate, $destination, $previous) } - else { [IO.File]::Move($candidate, $destination) } + if ([IO.File]::Exists($destination)) { + # Windows security scanners can briefly hold a freshly executed binary. + # Keep the replace atomic while allowing that bounded transient lock to clear. + $maxAttempts = 8 + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + try { + # File.Replace is one same-volume operation and preserves the original as its backup. + [IO.File]::Replace($candidate, $destination, $previous) + break + } catch { + $exception = $_.Exception + $isIoFailure = $exception -is [IO.IOException] -or $exception.InnerException -is [IO.IOException] + if (-not $isIoFailure -or $attempt -eq $maxAttempts) { throw } + $delay = 250 * $attempt + Write-Warning "Executable activation is temporarily locked; retrying in ${delay}ms (attempt $attempt/$maxAttempts)." + Start-Sleep -Milliseconds $delay + } + } + } else { [IO.File]::Move($candidate, $destination) } $script:Phase = "activated" Write-UpdateStatus Write-Ok "Installed $destination" diff --git a/src/mendcode/script/windows-installer-smoke.ts b/src/mendcode/script/windows-installer-smoke.ts index 5c000f6c..b1f2177d 100644 --- a/src/mendcode/script/windows-installer-smoke.ts +++ b/src/mendcode/script/windows-installer-smoke.ts @@ -11,16 +11,24 @@ if (!binary || !version) throw new Error("Provide MENDCODE_INSTALLER_TEST_BINARY const root = await fs.mkdtemp(path.join(os.tmpdir(), "mendcode-windows-installer-")) const digest = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex") const quote = (value: string) => `'${value.replaceAll("'", "''")}'` -async function powershell(source: string, env: Record = {}) { - const child = Bun.spawn(["powershell.exe", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(source, "utf16le").toString("base64")], { +const encodePowerShell = (source: string) => Buffer.from(source, "utf16le").toString("base64") +const spawnPowerShell = (source: string, env: Record = {}) => + Bun.spawn(["powershell.exe", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodePowerShell(source)], { env: { ...process.env, ...env }, stdout: "pipe", stderr: "pipe", }) +async function powershell(source: string, env: Record = {}) { + const child = spawnPowerShell(source, env) const timer = setTimeout(() => child.kill(), 90_000) try { const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]) return { code, output: stdout + stderr } } finally { clearTimeout(timer) } } +async function waitForFile(file: string) { + const deadline = Date.now() + 10_000 + while (!(await Bun.file(file).exists()) && Date.now() < deadline) await Bun.sleep(25) + assert.equal(await Bun.file(file).exists(), true, `Timed out waiting for ${file}`) +} const archive = path.join(root, "candidate.zip") const installer = path.resolve(import.meta.dir, "../install.ps1") const parsed = await powershell(`$tokens=$null; $errors=$null; [void][System.Management.Automation.Language.Parser]::ParseFile(${quote(installer)}, [ref]$tokens, [ref]$errors); if ($errors.Count) { $errors | Out-String | Write-Error; exit 1 }`) @@ -41,25 +49,71 @@ const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { return new Response("Not found", { status: 404 }) } }) try { - for (scenario of ["success", "checksum-failure", "http-failure", "truncated"]) { + for (scenario of ["success", "transient-lock", "checksum-failure", "http-failure", "truncated"]) { + const expectSuccess = scenario === "success" || scenario === "transient-lock" const home = path.join(root, scenario) const installed = path.join(home, ".mendcode", "bin", "mendcode.exe") await fs.mkdir(path.dirname(installed), { recursive: true }) // An unusable existing executable must not be run to perform recovery. await fs.writeFile(installed, "previous damaged executable") const previousDigest = digest(await Bun.file(installed).bytes()) - const result = await powershell(`& ${quote(installer)} -Version ${quote(version)} -SkipSetup -NoModifyPath; exit $LASTEXITCODE`, { - OPENCODE_TEST_HOME: home, MENDCODE_GITHUB_BASE_URL: server.url.toString().replace(/\/$/, ""), - MENDCODE_UPDATE_PARENT_PID: undefined, MENDCODE_VERIFIED_SUMS_FILE: undefined, - MENDCODE_DB: path.join(home, "data", "test.db"), - }) + const lockReady = path.join(home, "lock-ready") + const lockProcess = + scenario === "transient-lock" + ? spawnPowerShell(` +$directory = ${quote(path.dirname(installed))} +$ready = ${quote(lockReady)} +$deadline = [DateTime]::UtcNow.AddSeconds(30) +$stream = $null +try { + while ([DateTime]::UtcNow -lt $deadline -and $null -eq $stream) { + $operation = Get-ChildItem -LiteralPath $directory -Directory -Filter ".update.*" -ErrorAction SilentlyContinue | + Where-Object { [IO.File]::Exists((Join-Path $_.FullName "candidate.exe")) } | + Select-Object -First 1 + if ($operation) { + $status = Join-Path $operation.FullName "status" + if ([IO.File]::Exists($status) -and [IO.File]::ReadAllText($status) -match "binary_sha256=[a-f0-9]{64}") { + try { + $stream = [IO.File]::Open((Join-Path $operation.FullName "candidate.exe"), [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None) + } catch { + $exception = $_.Exception + if ($exception -isnot [IO.IOException] -and $exception.InnerException -isnot [IO.IOException]) { throw } + } + } + } + if ($null -eq $stream) { Start-Sleep -Milliseconds 25 } + } + if ($null -eq $stream) { throw "Did not acquire the transient installer lock." } + [IO.File]::WriteAllText($ready, "ready") + Start-Sleep -Milliseconds 1500 +} finally { + if ($null -ne $stream) { $stream.Dispose() } +} +`) + : undefined + const result = await (async () => { + try { + if (lockProcess) await waitForFile(lockReady) + return await powershell(`& ${quote(installer)} -Version ${quote(version)} -SkipSetup -NoModifyPath; exit $LASTEXITCODE`, { + OPENCODE_TEST_HOME: home, MENDCODE_GITHUB_BASE_URL: server.url.toString().replace(/\/$/, ""), + MENDCODE_UPDATE_PARENT_PID: undefined, MENDCODE_VERIFIED_SUMS_FILE: undefined, + MENDCODE_DB: path.join(home, "data", "test.db"), + }) + } finally { + if (lockProcess) { + lockProcess.kill() + await lockProcess.exited + } + } + })() await fs.writeFile(path.join(home, "installer.log"), result.output) - if (scenario === "success") assert.equal(result.code, 0, result.output) + if (expectSuccess) assert.equal(result.code, 0, result.output) + if (scenario === "transient-lock") assert.match(result.output, /temporarily locked; retrying/i) const operations = (await fs.readdir(path.dirname(installed))).filter((name) => name.startsWith(".update.")) assert.equal(operations.length, 1, `${scenario}: ${result.output}`) const operation = path.join(path.dirname(installed), operations[0]) const status = await fs.readFile(path.join(operation, "status"), "utf8") - if (scenario === "success") { + if (expectSuccess) { assert.equal(result.code, 0, result.output) assert.equal(digest(await Bun.file(installed).bytes()), binaryDigest) assert.equal(digest(await Bun.file(path.join(operation, "previous")).bytes()), previousDigest) From 389e9a095ad34c119b28fc8c36bfe7d16310dcb3 Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Thu, 10 Sep 2026 00:51:11 -0500 Subject: [PATCH 2/5] test(installer): exercise transient Windows lock --- src/mendcode/script/windows-installer-smoke.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/mendcode/script/windows-installer-smoke.ts b/src/mendcode/script/windows-installer-smoke.ts index b1f2177d..5c5052c4 100644 --- a/src/mendcode/script/windows-installer-smoke.ts +++ b/src/mendcode/script/windows-installer-smoke.ts @@ -62,19 +62,20 @@ try { scenario === "transient-lock" ? spawnPowerShell(` $directory = ${quote(path.dirname(installed))} +$installed = ${quote(installed)} $ready = ${quote(lockReady)} $deadline = [DateTime]::UtcNow.AddSeconds(30) $stream = $null try { while ([DateTime]::UtcNow -lt $deadline -and $null -eq $stream) { - $operation = Get-ChildItem -LiteralPath $directory -Directory -Filter ".update.*" -ErrorAction SilentlyContinue | - Where-Object { [IO.File]::Exists((Join-Path $_.FullName "candidate.exe")) } | + $operation = Get-ChildItem -LiteralPath $directory -Force -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like ".update.*" } | Select-Object -First 1 if ($operation) { $status = Join-Path $operation.FullName "status" if ([IO.File]::Exists($status) -and [IO.File]::ReadAllText($status) -match "binary_sha256=[a-f0-9]{64}") { try { - $stream = [IO.File]::Open((Join-Path $operation.FullName "candidate.exe"), [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None) + $stream = [IO.File]::Open($installed, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) } catch { $exception = $_.Exception if ($exception -isnot [IO.IOException] -and $exception.InnerException -isnot [IO.IOException]) { throw } From a5558a520a027825488e6b731f4af50300b546fd Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Thu, 10 Sep 2026 00:53:51 -0500 Subject: [PATCH 3/5] test(installer): expose lock fixture diagnostics --- src/mendcode/script/windows-installer-smoke.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/mendcode/script/windows-installer-smoke.ts b/src/mendcode/script/windows-installer-smoke.ts index 5c5052c4..0486eb19 100644 --- a/src/mendcode/script/windows-installer-smoke.ts +++ b/src/mendcode/script/windows-installer-smoke.ts @@ -24,9 +24,10 @@ async function powershell(source: string, env: Record { try { - if (lockProcess) await waitForFile(lockReady) + if (lockProcess) await waitForFile(lockReady, lockDiagnostic) return await powershell(`& ${quote(installer)} -Version ${quote(version)} -SkipSetup -NoModifyPath; exit $LASTEXITCODE`, { OPENCODE_TEST_HOME: home, MENDCODE_GITHUB_BASE_URL: server.url.toString().replace(/\/$/, ""), MENDCODE_UPDATE_PARENT_PID: undefined, MENDCODE_VERIFIED_SUMS_FILE: undefined, From f7e6f9104dcb89729d7ebee949a1a2e3cf5b9eec Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Thu, 10 Sep 2026 00:56:33 -0500 Subject: [PATCH 4/5] test(installer): wait for slow Windows fixtures --- src/mendcode/script/windows-installer-smoke.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/mendcode/script/windows-installer-smoke.ts b/src/mendcode/script/windows-installer-smoke.ts index 0486eb19..58534b49 100644 --- a/src/mendcode/script/windows-installer-smoke.ts +++ b/src/mendcode/script/windows-installer-smoke.ts @@ -24,8 +24,8 @@ async function powershell(source: string, env: Record { try { - if (lockProcess) await waitForFile(lockReady, lockDiagnostic) + if (lockProcess) await waitForFile(lockReady, lockDiagnostic, 90_000) return await powershell(`& ${quote(installer)} -Version ${quote(version)} -SkipSetup -NoModifyPath; exit $LASTEXITCODE`, { OPENCODE_TEST_HOME: home, MENDCODE_GITHUB_BASE_URL: server.url.toString().replace(/\/$/, ""), MENDCODE_UPDATE_PARENT_PID: undefined, MENDCODE_VERIFIED_SUMS_FILE: undefined, From 19637b18a8da3007016288b4a6826397fb326e59 Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Thu, 10 Sep 2026 01:07:54 -0500 Subject: [PATCH 5/5] fix(installer): recognize transient Windows sharing violations --- src/mendcode/install.ps1 | 7 ++- .../script/windows-installer-smoke.ts | 58 ++++++++++++------- 2 files changed, 41 insertions(+), 24 deletions(-) diff --git a/src/mendcode/install.ps1 b/src/mendcode/install.ps1 index 9309a975..24d6e8e4 100644 --- a/src/mendcode/install.ps1 +++ b/src/mendcode/install.ps1 @@ -461,8 +461,11 @@ function Activate-Candidate { break } catch { $exception = $_.Exception - $isIoFailure = $exception -is [IO.IOException] -or $exception.InnerException -is [IO.IOException] - if (-not $isIoFailure -or $attempt -eq $maxAttempts) { throw } + $inner = $exception.InnerException + $isIoFailure = $exception -is [IO.IOException] -or $inner -is [IO.IOException] + $isAccessFailure = $exception -is [UnauthorizedAccessException] -or $inner -is [UnauthorizedAccessException] + $isSharingViolation = $exception.HResult -in @(-2147024864, -2147024863) -or $inner.HResult -in @(-2147024864, -2147024863) + if (-not ($isIoFailure -or $isAccessFailure -or $isSharingViolation) -or $attempt -eq $maxAttempts) { throw } $delay = 250 * $attempt Write-Warning "Executable activation is temporarily locked; retrying in ${delay}ms (attempt $attempt/$maxAttempts)." Start-Sleep -Milliseconds $delay diff --git a/src/mendcode/script/windows-installer-smoke.ts b/src/mendcode/script/windows-installer-smoke.ts index 58534b49..cc0a50f4 100644 --- a/src/mendcode/script/windows-installer-smoke.ts +++ b/src/mendcode/script/windows-installer-smoke.ts @@ -30,6 +30,23 @@ async function waitForFile(file: string, diagnostic?: string, timeout = 10_000) if (diagnostic && (await Bun.file(diagnostic).exists())) throw new Error(await fs.readFile(diagnostic, "utf8")) assert.equal(await Bun.file(file).exists(), true, `Timed out waiting for ${file}`) } +async function waitForActivation(directory: string, release: string) { + const deadline = Date.now() + 90_000 + while (!(await Bun.file(release).exists()) && Date.now() < deadline) { + const entries = await fs.readdir(directory, { withFileTypes: true }).catch(() => []) + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith(".update.")) continue + const status = await fs.readFile(path.join(directory, entry.name, "status"), "utf8").catch(() => "") + if (!status.includes("phase=activating")) continue + await Bun.sleep(500) + await fs.writeFile(release, "release") + return + } + await Bun.sleep(25) + } + if (await Bun.file(release).exists()) return + throw new Error(`Timed out waiting for installer activation in ${directory}`) +} const archive = path.join(root, "candidate.zip") const installer = path.resolve(import.meta.dir, "../install.ps1") const parsed = await powershell(`$tokens=$null; $errors=$null; [void][System.Management.Automation.Language.Parser]::ParseFile(${quote(installer)}, [ref]$tokens, [ref]$errors); if ($errors.Count) { $errors | Out-String | Write-Error; exit 1 }`) @@ -63,33 +80,17 @@ try { const lockProcess = scenario === "transient-lock" ? spawnPowerShell(` -$directory = ${quote(path.dirname(installed))} $installed = ${quote(installed)} $ready = ${quote(lockReady)} +$release = ${quote(path.join(home, "lock-release"))} $diagnostic = ${quote(lockDiagnostic)} -$deadline = [DateTime]::UtcNow.AddSeconds(90) $stream = $null try { - while ([DateTime]::UtcNow -lt $deadline -and $null -eq $stream) { - $operation = Get-ChildItem -LiteralPath $directory -Force -Directory -ErrorAction SilentlyContinue | - Where-Object { $_.Name -like ".update.*" } | - Select-Object -First 1 - if ($operation) { - $status = Join-Path $operation.FullName "status" - if ([IO.File]::Exists($status) -and [IO.File]::ReadAllText($status) -match "binary_sha256=[a-f0-9]{64}") { - try { - $stream = [IO.File]::Open($installed, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) - } catch { - $exception = $_.Exception - if ($exception -isnot [IO.IOException] -and $exception.InnerException -isnot [IO.IOException]) { throw } - } - } - } - if ($null -eq $stream) { Start-Sleep -Milliseconds 25 } - } - if ($null -eq $stream) { throw "Did not acquire the transient installer lock." } + $stream = [IO.File]::Open($installed, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) [IO.File]::WriteAllText($ready, "ready") - Start-Sleep -Milliseconds 1500 + $deadline = [DateTime]::UtcNow.AddSeconds(90) + while (-not [IO.File]::Exists($release) -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 25 } + if (-not [IO.File]::Exists($release)) { throw "Did not receive the transient installer lock release." } } catch { [IO.File]::WriteAllText($diagnostic, ($_ | Out-String)) exit 1 @@ -100,7 +101,20 @@ try { : undefined const result = await (async () => { try { - if (lockProcess) await waitForFile(lockReady, lockDiagnostic, 90_000) + if (lockProcess) { + await waitForFile(lockReady, lockDiagnostic, 90_000) + const activation = waitForActivation(path.dirname(installed), path.join(home, "lock-release")) + try { + return await powershell(`& ${quote(installer)} -Version ${quote(version)} -SkipSetup -NoModifyPath; exit $LASTEXITCODE`, { + OPENCODE_TEST_HOME: home, MENDCODE_GITHUB_BASE_URL: server.url.toString().replace(/\/$/, ""), + MENDCODE_UPDATE_PARENT_PID: undefined, MENDCODE_VERIFIED_SUMS_FILE: undefined, + MENDCODE_DB: path.join(home, "data", "test.db"), + }) + } finally { + await fs.writeFile(path.join(home, "lock-release"), "release") + await activation + } + } return await powershell(`& ${quote(installer)} -Version ${quote(version)} -SkipSetup -NoModifyPath; exit $LASTEXITCODE`, { OPENCODE_TEST_HOME: home, MENDCODE_GITHUB_BASE_URL: server.url.toString().replace(/\/$/, ""), MENDCODE_UPDATE_PARENT_PID: undefined, MENDCODE_VERIFIED_SUMS_FILE: undefined,