Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 22 additions & 3 deletions src/mendcode/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
95 changes: 85 additions & 10 deletions src/mendcode/script/windows-installer-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> = {}) {
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<string, string | undefined> = {}) =>
Bun.spawn(["powershell.exe", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodePowerShell(source)], {
env: { ...process.env, ...env }, stdout: "pipe", stderr: "pipe",
})
async function powershell(source: string, env: Record<string, string | undefined> = {}) {
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 }`)
Expand All @@ -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)
Expand Down
Loading