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..24d6e8e4 100644 --- a/src/mendcode/install.ps1 +++ b/src/mendcode/install.ps1 @@ -450,9 +450,28 @@ 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 + $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 + } + } + } 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..cc0a50f4 100644 --- a/src/mendcode/script/windows-installer-smoke.ts +++ b/src/mendcode/script/windows-installer-smoke.ts @@ -11,16 +11,42 @@ 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, diagnostic?: string, timeout = 10_000) { + const deadline = Date.now() + timeout + while (!(await Bun.file(file).exists()) && Date.now() < deadline) await Bun.sleep(25) + 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 }`) @@ -41,25 +67,74 @@ 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 lockDiagnostic = path.join(home, "lock-diagnostic") + const lockProcess = + scenario === "transient-lock" + ? spawnPowerShell(` +$installed = ${quote(installed)} +$ready = ${quote(lockReady)} +$release = ${quote(path.join(home, "lock-release"))} +$diagnostic = ${quote(lockDiagnostic)} +$stream = $null +try { + $stream = [IO.File]::Open($installed, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + [IO.File]::WriteAllText($ready, "ready") + $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 +} finally { + if ($null -ne $stream) { $stream.Dispose() } +} +`) + : undefined + const result = await (async () => { + try { + 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, + 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)