fix(installer): keep -Wait, and use the stage-0 handoff even when .env exists
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 6s

Two defects from a Server 2019 run that got further than any before it - the
payload verified against the lock on a real server for the first time.

EMPTY EXIT CODE. "Python install failed (exit )" on an install that had actually
worked. Dropping -Wait to make -TimeoutSec enforceable left $p.ExitCode
unreadable: PowerShell only reliably populates it on a waited process. -Wait is
restored and the trade is now explicit - exit codes are load-bearing here, 1639
vs 1603 vs 3010 is the entire diagnosis, and a bounded wait is not worth losing
them for. -TimeoutSec is advisory: logged as an expected duration so a hang is
identifiable, not enforced. The code is also read defensively now, and an
unreadable one fails loudly rather than being taken for success.

That timeout has never worked - -Wait made the block dead code from the start -
so nothing is lost that was ever there. Trying to fix it broke something that
was working, which was the wrong trade to make silently.

STALE .env PREFERRED OVER A GOOD HANDOFF. The .dbpass fallback sat in the else
of "if .env exists", so it was consulted only when .env was absent. A
part-finished install HAS an .env, holding whatever password stage 2 last wrote;
if stage 0 has since regenerated the credential, .env is stale and .dbpass is
correct - and the installer preferred the stale one, giving "Access denied" with
the right password sitting unread on disk. The handoff is now applied before the
branch, so it covers both, and only when .env points at the local server so it
can never redirect a site whose database lives elsewhere.
This commit is contained in:
cproudlock
2026-08-04 13:57:13 -04:00
parent 21110b86eb
commit f6b621d126

View File

@@ -232,38 +232,44 @@ function Invoke-Native {
# ERROR 1049 Unknown database '<path>'.
$common['RedirectStandardInput'] = $StdinFile
}
if ($TimeoutSec -le 0) { $common['Wait'] = $true }
# -Wait ALWAYS. Dropping it to make -TimeoutSec enforceable left
# $p.ExitCode unreadable: a Python install that had actually worked
# reported "Python install failed (exit )" with an empty code, because
# PowerShell only reliably populates ExitCode on a waited process.
#
# So the trade is deliberate. Exit codes are load-bearing here - 1639 vs
# 1603 vs 3010 is the whole diagnosis - and a bounded wait is not worth
# losing them for. -TimeoutSec is therefore ADVISORY: it is recorded in
# the log so a hang is identifiable, but the wait is unbounded.
$common['Wait'] = $true
if ($TimeoutSec -gt 0) { Write-Log (" (expected to finish within {0}s)" -f $TimeoutSec) }
$p = Start-Process @common
# A hung child must not hang the install. msiexec in particular deadlocks
# when a second Windows Installer transaction holds the global mutex: two
# msiexec processes sat there for 40 minutes with no output and no error.
if ($TimeoutSec -gt 0) {
if (-not $p.WaitForExit($TimeoutSec * 1000)) {
try { $p.Kill() } catch { }
Fail "$What timed out after $TimeoutSec seconds" @'
The command was killed. For an MSI this usually means another Windows Installer
transaction held the global mutex: check for stray msiexec processes
(Get-Process msiexec), end them, then re-run this stage.
'@
}
# The parameterless overload waits for the redirected streams to be
# flushed and closed. Without it the log files can still be being
# written when they are read below, losing the tail of a failure.
$p.WaitForExit()
}
# NOTE: msiexec can deadlock when another Windows Installer transaction
# holds the global mutex - two msiexec processes once sat for 40 minutes
# with no output and no error. That is why the expected duration is
# logged above: an operator seeing no progress well past it should check
# for stray msiexec processes (Get-Process msiexec) and end them.
$out = @()
if (Test-Path $so) { $out += Get-Content $so -ErrorAction SilentlyContinue }
if (Test-Path $se) { $out += Get-Content $se -ErrorAction SilentlyContinue }
if ($OkExit -notcontains $p.ExitCode) {
$code = $null
try { $code = $p.ExitCode } catch { }
if ($null -eq $code) {
# Never silently treat "cannot tell" as success.
$out | Select-Object -Last 8 | ForEach-Object { Write-Log " $_" 'FAIL' }
Fail "$What failed (exit $($p.ExitCode))"
Fail "$What finished but its exit code could not be read" `
'Check the log above and the tool''s own log before re-running.'
}
if ($p.ExitCode -ne 0) {
if ($OkExit -notcontains $code) {
$out | Select-Object -Last 8 | ForEach-Object { Write-Log " $_" 'FAIL' }
Fail "$What failed (exit $code)"
}
if ($code -ne 0) {
# Accepted, but say so: 3010/1641 mean the server needs a reboot to
# finish, and an operator who never sees that wonders later why
# something only half works.
Write-Log ("$What returned {0} - completed, but this server needs a reboot to finish" -f $p.ExitCode) 'WARN'
Write-Log ("$What returned {0} - completed, but this server needs a reboot to finish" -f $code) 'WARN'
$script:RebootPending = $true
}
return $out
@@ -1192,6 +1198,34 @@ a page that cannot load its own assets. Rebuild with scripts/build-site.sh
}
# --- .env ---------------------------------------------------------------
# The stage-0 handoff is checked BEFORE the .env branch, not inside its else.
# It used to sit in the else, so it was consulted only when .env was absent -
# and on a part-finished install .env DOES exist, holding whatever password
# stage 2 last wrote. If stage 0 has since regenerated the credential, .env
# is stale, .dbpass is correct, and the installer preferred the stale one:
# "Access denied", with the right password sitting unread on disk.
#
# .dbpass only exists between stage 0 and stage 5 - stage 5 shreds it once
# the install is proven - so its presence means "this install is unfinished
# and THIS is the current password". Applied only when .env points at the
# local server, because the handoff describes the bundled database and must
# never redirect a site whose database lives elsewhere.
$handoff = Join-Path $AppRoot '.dbpass'
if (-not $DbPasswordFile -and (Test-Path $handoff)) {
$envIsLocal = $true
if (Test-Path $EnvFile) {
$envIsLocal = $false
$urlLine = Get-Content $EnvFile -ErrorAction SilentlyContinue |
Where-Object { $_ -like 'DATABASE_URL=*' } | Select-Object -First 1
if ($urlLine -and ($urlLine -match '@(127\.0\.0\.1|localhost|\[::1\]):')) { $envIsLocal = $true }
}
if ($envIsLocal) {
$DbPasswordFile = $handoff
if (-not $DbHost) { $DbHost = '127.0.0.1' }
Write-Log 'using the stage-0 password handoff (an unfinished install; .env may be stale)'
}
}
if (Test-Path $EnvFile) {
# Preserving .env is right for a plain re-run: regenerating JWT_SECRET_KEY
# would invalidate every issued session.
@@ -1284,17 +1318,7 @@ a page that cannot load its own assets. Rebuild with scripts/build-site.sh
} else {
if (-not $SiteHost) { $SiteHost = $env:COMPUTERNAME }
# Stage 0 leaves an ACL'd handoff file when it created the database itself.
# Prefer it: a greenfield install then needs no password from anyone, and
# the generated password never passes through a human or a command line.
# This MUST come before the -DbHost check below - stage 0 always installs
# to the local box, so the handoff implies the host as well as the password.
$handoff = Join-Path $AppRoot '.dbpass'
if (-not $DbPasswordFile -and (Test-Path $handoff)) {
$DbPasswordFile = $handoff
if (-not $DbHost) { $DbHost = '127.0.0.1' }
Write-Log 'using the bundled-MySQL password handoff from stage 0 (host 127.0.0.1)'
}
# The stage-0 handoff was already applied above, for both branches.
if (-not $DbHost) {
Fail 'no database host supplied and no existing .env' @'