From 7e8c46ae7c5bcd42e7bae38ae9f73799dad10acb Mon Sep 17 00:00:00 2001 From: shujaat hasan Date: Wed, 29 Jul 2026 15:34:17 +0200 Subject: [PATCH 1/7] chore(#422): honest step labels + per-tool heartbeat/progress in the PS installer Step 1/5 "Checking system requirements" actually installed ~700 MB of tools, nearly all console-silent (downloads with the progress overlay off since #471, plus silent winget/Add-AppxPackage/installer invocations), which reads as a hang. The k3d start path also streamed raw INFO[...] lines past the style system. - Split Step 1 into "Checking system requirements" (preflight/GPU/virtualisation) and a dedicated "Installing system tools" step; renumber to /6. - Invoke-WithHeartbeat: run a blocking op in a background job with a live spinner (built on the existing Wait-JobWithProgress) so no op sits silent >10s. Wired into every tool download (kubectl/k3d/helm/winget/Docker Desktop), the winget installs, Add-AppxPackage, and the Docker Desktop installer. - Get-ToolSummaryLine: one honest line per tool (name, version, size, elapsed), printed as each tool becomes ready. - Route `k3d cluster start` through Invoke-WithHeartbeat: capture its raw output to the log + show a styled heartbeat instead of streaming INFO[...] lines (and fail loudly if start fails, instead of always reporting "started"). - Tests: Pester for Get-ToolSummaryLine, Invoke-WithHeartbeat, and source guards for the 6-step split + no-raw-k3d-output. The copy catalog is bash-driven and the bash installer already splits check (step a) from install (step b) with real progress, so its golden is unaffected. Closes #422 Co-Authored-By: Claude Opus 4.8 --- scripts/install-k8s.ps1 | 160 ++++++++++++++++++++++------ scripts/manifest.sha256 | 2 +- scripts/tests/install-k8s.Tests.ps1 | 49 +++++++++ 3 files changed, 178 insertions(+), 33 deletions(-) diff --git a/scripts/install-k8s.ps1 b/scripts/install-k8s.ps1 index e808b901..080a17f4 100644 --- a/scripts/install-k8s.ps1 +++ b/scripts/install-k8s.ps1 @@ -136,6 +136,45 @@ function Wait-JobWithProgress { # is always local; the guard makes it a no-op on non-Windows Pester runs.) $script:JobInit = { if ($env:SystemRoot) { Set-Location $env:SystemRoot } } +# One honest line per system tool once it's ready (#422): name, version, and +# whatever of {size, elapsed} is known — so "Installing system tools" shows +# concrete per-tool progress instead of a silent ~700 MB. Pure/formatting-only +# so it is unit-testable. e.g. "kubectl v1.31.0 (~60 MB, 12s)". +function Get-ToolSummaryLine { + param([string]$Name, [string]$Version = "", [string]$Size = "", [int]$ElapsedSec = -1) + $head = if ($Version) { "$Name $Version" } else { "$Name" } + $meta = @() + if ($Size) { $meta += $Size } + if ($ElapsedSec -ge 0) { $meta += ("{0}s" -f $ElapsedSec) } + if ($meta.Count) { return "$head (" + ($meta -join ", ") + ")" } + return $head +} + +# Run a blocking operation with a live spinner heartbeat so Steps 1-2 never sit +# console-silent for more than a couple of seconds (#422): downloads (progress +# overlay is off for speed, #471), winget installs, and the Docker Desktop +# installer are otherwise dead air. The scriptblock runs in a background job +# (jobs don't inherit functions/vars — pass inputs via -ArgumentList) driven by +# Wait-JobWithProgress. Returns the job's output; throws on timeout or job +# failure so callers keep their existing Invoke-WithRetry / try-catch flow. +function Invoke-WithHeartbeat { + param( + [Parameter(Mandatory)][scriptblock]$Script, + [object[]]$ArgumentList = @(), + [string]$Message = "Working", + [int]$TimeoutSec = 1800, + [int]$PollSeconds = 2 + ) + $job = Start-Job -ScriptBlock $Script -ArgumentList $ArgumentList -InitializationScript $script:JobInit + $finished = Wait-JobWithProgress -Job $job -TimeoutSec $TimeoutSec -Message $Message -PollSeconds $PollSeconds + $out = @(Receive-Job $job -ErrorAction SilentlyContinue) + $state = $job.State + Remove-Job $job -Force -ErrorAction SilentlyContinue + if (-not $finished) { throw "Timed out after ${TimeoutSec}s while: $Message" } + if ($state -eq 'Failed') { throw "Failed while: $Message" } + return $out +} + function Get-WindowsArch { switch ($env:PROCESSOR_ARCHITECTURE) { "AMD64" { return "amd64" } @@ -604,9 +643,16 @@ function Install-Winget { $url = "https://github.com/microsoft/winget-cli/releases/latest/download/Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle" $dest = "$env:TEMP\winget-installer.msixbundle" Invoke-WithRetry -Label "winget download" -ScriptBlock { - Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing + Invoke-WithHeartbeat -Message "Downloading winget (~200 MB)" ` + -ArgumentList @($url, $dest) -Script { + param($u, $d); $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $u -OutFile $d -UseBasicParsing + } } - Add-AppxPackage -Path $dest + # Add-AppxPackage on a ~200 MB bundle is console-silent for a while (#422). + Invoke-WithHeartbeat -Message "Installing winget" -ArgumentList @($dest) -Script { + param($d); Add-AppxPackage -Path $d + } | Out-Null Remove-Item $dest -Force -ErrorAction SilentlyContinue RefreshPath Log "winget installed." @@ -621,19 +667,32 @@ function Install-DockerDesktop { if (-not (Test-Path $dockerExe)) { if (Has "winget") { - winget install -e --id Docker.DockerDesktop ` - --accept-package-agreements --accept-source-agreements --silent + # winget install is console-silent for minutes on a 600 MB package (#422). + Info "Installing Docker Desktop (~600 MB via winget) -- several minutes is normal." + try { + Invoke-WithHeartbeat -Message "Installing Docker Desktop" -TimeoutSec 2400 -Script { + winget install -e --id Docker.DockerDesktop --accept-package-agreements --accept-source-agreements --silent + } | Out-Null + } catch { Log "Docker Desktop winget install: $_" } } else { $ddArch = Get-WindowsArch # Honest progress (#468): the single biggest download of the install. # Size measured 2026-07-29 (613 MB). Info "Downloading Docker Desktop (~600 MB) -- the biggest download of this install; several minutes is normal." $installer = "$env:TEMP\DockerDesktopInstaller.exe" + $ddUrl = "https://desktop.docker.com/win/main/$ddArch/Docker%20Desktop%20Installer.exe" Invoke-WithRetry -Label "Docker download" -ScriptBlock { - Invoke-WebRequest -Uri "https://desktop.docker.com/win/main/$ddArch/Docker%20Desktop%20Installer.exe" ` - -OutFile $installer -UseBasicParsing + Invoke-WithHeartbeat -Message "Downloading Docker Desktop (~600 MB)" -TimeoutSec 2400 ` + -ArgumentList @($ddUrl, $installer) -Script { + param($u, $d); $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $u -OutFile $d -UseBasicParsing + } } - Start-Process -FilePath $installer -ArgumentList "install --quiet --accept-license" -Wait + # The installer itself runs silent with --quiet; heartbeat it too (#422). + Invoke-WithHeartbeat -Message "Installing Docker Desktop" -TimeoutSec 2400 ` + -ArgumentList @($installer) -Script { + param($d); Start-Process -FilePath $d -ArgumentList "install --quiet --accept-license" -Wait + } | Out-Null Remove-Item $installer -Force -ErrorAction SilentlyContinue } RefreshPath @@ -872,11 +931,16 @@ function Install-Kubectl { (Invoke-WebRequest "https://dl.k8s.io/release/stable.txt" -UseBasicParsing).Content.Trim() } Log "Downloading kubectl $kVer ($arch)..." - Info "Downloading kubectl $kVer (~60 MB)..." $kubectlDest = "$TOOL_DIR\kubectl.exe" + $kUrl = "https://dl.k8s.io/release/$kVer/bin/windows/$arch/kubectl.exe" + $t0 = Get-Date + # Heartbeat during the otherwise-silent transfer (#422); retry wraps it. Invoke-WithRetry -Label "download" -ScriptBlock { - Invoke-WebRequest "https://dl.k8s.io/release/$kVer/bin/windows/$arch/kubectl.exe" ` - -OutFile $kubectlDest -UseBasicParsing + Invoke-WithHeartbeat -Message "Downloading kubectl $kVer (~60 MB)" ` + -ArgumentList @($kUrl, $kubectlDest) -Script { + param($u, $d); $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest $u -OutFile $d -UseBasicParsing + } } $expectedHash = Invoke-WithRetry -Label "checksum" -ScriptBlock { (Invoke-WebRequest "https://dl.k8s.io/release/$kVer/bin/windows/$arch/kubectl.exe.sha256" ` @@ -890,6 +954,7 @@ function Install-Kubectl { RefreshPath Log "kubectl $kVer installed." Assert-ToolRuns -Name "kubectl" -VersionArgs @("version","--client") -BinPath $kubectlDest + Ok (Get-ToolSummaryLine -Name "kubectl" -Version $kVer -Size "~60 MB" -ElapsedSec ([int]((Get-Date) - $t0).TotalSeconds)) } # ── Pinned tool versions (#382 / #410) ────────────────────────────────────── @@ -964,13 +1029,18 @@ function Install-K3dAndHelm { if (-not (Has "k3d")) { if (Has "winget") { Log "Installing k3d via winget..." - $null = (winget install -e --id Rancher.k3d ` - --accept-package-agreements --accept-source-agreements --silent 2>&1) + # winget install is console-silent; heartbeat so it doesn't read as a hang (#422). + try { + $null = Invoke-WithHeartbeat -Message "Installing k3d via winget" -TimeoutSec 600 -Script { + winget install -e --id Rancher.k3d --accept-package-agreements --accept-source-agreements --silent 2>&1 + } + } catch { Log "k3d winget install: $_" } } RefreshPath if (-not (Has "k3d")) { $arch = Get-WindowsArch + $t0k3d = Get-Date Log "Downloading k3d binary directly ($arch)..." # Pinned by default (#382 / #410) — no api.github.com on the default path. $k3dVer = Resolve-ToolVersion -Name "k3d" -Value $K3dVersion ` @@ -979,11 +1049,14 @@ function Install-K3dAndHelm { if (-not $tag) { throw "no Location header on the /releases/latest redirect" } $tag } - Info "Downloading k3d $k3dVer (~25 MB)..." $k3dDest = "$TOOL_DIR\k3d.exe" + $k3dUrl = "https://github.com/k3d-io/k3d/releases/download/$k3dVer/k3d-windows-$arch.exe" Invoke-WithRetry -Label "k3d download" -ScriptBlock { - Invoke-WebRequest "https://github.com/k3d-io/k3d/releases/download/$k3dVer/k3d-windows-$arch.exe" ` - -OutFile $k3dDest -UseBasicParsing + Invoke-WithHeartbeat -Message "Downloading k3d $k3dVer (~25 MB)" ` + -ArgumentList @($k3dUrl, $k3dDest) -Script { + param($u, $d); $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest $u -OutFile $d -UseBasicParsing + } } # Fail-closed verification, matching the Linux path and the kubectl # precedent: an unfetchable checksums.txt, a missing asset line, or a @@ -1015,6 +1088,7 @@ function Install-K3dAndHelm { } Log "k3d checksum verified." RefreshPath + Ok (Get-ToolSummaryLine -Name "k3d" -Version $k3dVer -Size "~25 MB" -ElapsedSec ([int]((Get-Date) - $t0k3d).TotalSeconds)) } } Assert-ToolRuns -Name "k3d" -VersionArgs @("version") -BinPath "$TOOL_DIR\k3d.exe" @@ -1023,8 +1097,12 @@ function Install-K3dAndHelm { if (-not (Has "helm")) { if (Has "winget") { Log "Installing Helm via winget..." - $null = (winget install -e --id Helm.Helm ` - --accept-package-agreements --accept-source-agreements --silent 2>&1) + # winget install is console-silent; heartbeat so it doesn't read as a hang (#422). + try { + $null = Invoke-WithHeartbeat -Message "Installing Helm via winget" -TimeoutSec 600 -Script { + winget install -e --id Helm.Helm --accept-package-agreements --accept-source-agreements --silent 2>&1 + } + } catch { Log "helm winget install: $_" } RefreshPath } @@ -1038,11 +1116,15 @@ function Install-K3dAndHelm { if (-not $c) { throw "empty helm-latest-version response" } $c } - Info "Downloading Helm $helmVer (~20 MB)..." + $t0helm = Get-Date $helmZip = "$env:TEMP\helm-$helmVer-windows-$arch.zip" + $helmUrl = "https://get.helm.sh/helm-$helmVer-windows-$arch.zip" Invoke-WithRetry -Label "helm download" -ScriptBlock { - Invoke-WebRequest "https://get.helm.sh/helm-$helmVer-windows-$arch.zip" ` - -OutFile $helmZip -UseBasicParsing + Invoke-WithHeartbeat -Message "Downloading Helm $helmVer (~20 MB)" ` + -ArgumentList @($helmUrl, $helmZip) -Script { + param($u, $d); $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest $u -OutFile $d -UseBasicParsing + } } $helmExtract = "$env:TEMP\helm-extract" if (Test-Path $helmExtract) { Remove-Item $helmExtract -Recurse -Force } @@ -1051,6 +1133,7 @@ function Install-K3dAndHelm { Remove-Item $helmZip -Force -ErrorAction SilentlyContinue Remove-Item $helmExtract -Recurse -Force -ErrorAction SilentlyContinue RefreshPath + Ok (Get-ToolSummaryLine -Name "helm" -Version $helmVer -Size "~20 MB" -ElapsedSec ([int]((Get-Date) - $t0helm).TotalSeconds)) } if (-not (Has "helm")) { Err "Helm could not be installed. Install manually from https://helm.sh/docs/intro/install/ and re-run." } @@ -1409,7 +1492,16 @@ function New-K3dCluster { Ok "Compute environment already running." } else { Log "Cluster '$CLUSTER_NAME' exists but stopped -- starting..." - k3d cluster start $CLUSTER_NAME + # Route k3d's raw INFO[...] through the style system (#422): run with a + # heartbeat and capture the output to the log instead of streaming raw lines. + try { + $startOut = Invoke-WithHeartbeat -Message "Starting your secure environment" -TimeoutSec 300 ` + -ArgumentList @($CLUSTER_NAME) -Script { param($n) k3d cluster start $n 2>&1 } + if ($startOut) { Log "k3d cluster start: $($startOut -join "`n")" } + } catch { + Log "k3d cluster start failed: $_" + Err "Couldn't start the existing '$CLUSTER_NAME' environment. Check Docker is running, then re-run." + } Ok "Compute environment started." } @@ -1992,7 +2084,7 @@ function Get-InstalledClientInfo { # adopted - cluster already registered: TB_PROV_ID/TB_PROV_NS, no password # fallback - CLI missing/too old -> the legacy manual prompts in the Helm step function Invoke-ProvisionClient { - Step 4 5 "Registering this machine" + Step 5 6 "Registering this machine" $script:TB_PROV_MODE = "fallback" if (Get-ProvisioningPreset) { @@ -2140,7 +2232,7 @@ function Invoke-ProvisionClient { function Install-ClientHelm { # -- Step 5/5: Install tracebloc client -- - Step 5 5 "Installing tracebloc client" + Step 6 6 "Installing tracebloc client" if (-not (Test-Path $HOST_DATA_DIR)) { New-Item -ItemType Directory -Path $HOST_DATA_DIR -Force | Out-Null @@ -3056,7 +3148,7 @@ function Install-TraceblocCli { # credential in Step 4 (browser sign-in + `client create`). A failed CLI # install is still non-fatal: Step 4 falls back to the legacy manual- # credential flow, so the machine can always be connected. - Step 3 5 "Install the tracebloc CLI" + Step 4 6 "Install the tracebloc CLI" Info "Installing the tracebloc CLI..." @@ -3114,33 +3206,37 @@ Start-InstallLog Print-Banner Print-Roadmap -# -- Step 1/5: Check system requirements -- -Step 1 5 "Checking system requirements" +# -- Step 1/6: Check system requirements (honest split from tool install, #422) -- +Step 1 6 "Checking system requirements" Test-Preflight Find-Gpu Enable-VirtualisationFeatures + +# -- Step 2/6: Install system tools (~700 MB — Docker Desktop, kubectl, k3d, helm; +# each names its wait + shows a heartbeat + prints a summary line, #422) -- +Step 2 6 "Installing system tools" Install-Winget Install-DockerDesktop Install-NvidiaContainerToolkit Install-Kubectl Install-K3dAndHelm -# -- Step 2/5: Set up secure compute environment -- -Step 2 5 "Setting up secure compute environment" +# -- Step 3/6: Set up secure compute environment -- +Step 3 6 "Setting up secure compute environment" New-K3dCluster Install-GpuDevicePlugin Confirm-GpuNode -# -- Step 3/5: install the tracebloc CLI FIRST (#388) — it mints the machine -# credential in Step 4; a CLI-install hiccup degrades Step 4 to the legacy +# -- Step 4/6: install the tracebloc CLI FIRST (#388) — it mints the machine +# credential in Step 5; a CLI-install hiccup degrades Step 5 to the legacy # manual-credential fallback instead of aborting. Install-TraceblocCli -# -- Step 4/5: register this machine (browser sign-in + `client create`; +# -- Step 5/6: register this machine (browser sign-in + `client create`; # env-var credentials skip it; missing/old CLI falls back to manual prompts) -- Invoke-ProvisionClient -# -- Step 5/5 handled inside Install-ClientHelm -- +# -- Step 6/6 handled inside Install-ClientHelm -- Install-ClientHelm # Verify the client actually came up before reporting anything diff --git a/scripts/manifest.sha256 b/scripts/manifest.sha256 index fc2b3957..f87180c5 100644 --- a/scripts/manifest.sha256 +++ b/scripts/manifest.sha256 @@ -15,4 +15,4 @@ e2ea63d844e6649f1d3aaae9fd4733845a1a39df37d68abbaeda00330f9e1c7e scripts/lib/as b6a7c592c2d2a71506f8d7ee4a09f048634f6f6958096f1f455e02e2353f9db3 scripts/lib/probe.sh c47c86d5f844154bad82485baf1f003be88ace3b8b9f09f59f078c2b9bc8874f scripts/lib/summary.sh 77e03332ebfab1ef759c6148a57afcf479c02c5dc6cc7b0e0e680f58e20cd364 scripts/lib/diagnose.sh -7a88933bfc7561e68f11c4b5b64864552cfced57eb17cd7700b2e1ef5e4d848a scripts/install-k8s.ps1 +38ef8989ef10753a43d630f262105cc1429ba4facf425034fee031c738c506cc scripts/install-k8s.ps1 diff --git a/scripts/tests/install-k8s.Tests.ps1 b/scripts/tests/install-k8s.Tests.ps1 index f614fbf9..14f03754 100644 --- a/scripts/tests/install-k8s.Tests.ps1 +++ b/scripts/tests/install-k8s.Tests.ps1 @@ -28,6 +28,55 @@ Describe "Get-BackendUrl" { It "unknown -> prod" { $env:CLIENT_ENV = "whatever"; Get-BackendUrl | Should -Be "https://api.tracebloc.io/" } } +Describe "Get-ToolSummaryLine (#422 honest per-tool progress)" { + It "name + version + size + elapsed" { + Get-ToolSummaryLine -Name "kubectl" -Version "v1.31.0" -Size "~60 MB" -ElapsedSec 12 | + Should -Be "kubectl v1.31.0 (~60 MB, 12s)" + } + It "name + version only (no meta parens)" { + Get-ToolSummaryLine -Name "helm" -Version "v4.2.3" | Should -Be "helm v4.2.3" + } + It "name only" { Get-ToolSummaryLine -Name "k3d" | Should -Be "k3d" } + It "size without elapsed" { + Get-ToolSummaryLine -Name "k3d" -Version "v5.9.0" -Size "~25 MB" | + Should -Be "k3d v5.9.0 (~25 MB)" + } + It "elapsed 0 is shown (not treated as absent)" { + Get-ToolSummaryLine -Name "helm" -Version "v4.2.3" -ElapsedSec 0 | + Should -Be "helm v4.2.3 (0s)" + } +} + +Describe "Invoke-WithHeartbeat (#422 no silent window)" { + It "returns the operation output" { + (Invoke-WithHeartbeat -Message "adding" -PollSeconds 1 -Script { 40 + 2 }) | Should -Be 42 + } + It "throws when the operation fails (so callers keep retry/abort flow)" { + { Invoke-WithHeartbeat -Message "boom" -PollSeconds 1 -Script { throw "kaboom" } } | Should -Throw + } + It "passes ArgumentList into the job scriptblock" { + (Invoke-WithHeartbeat -Message "args" -PollSeconds 1 -ArgumentList @("a","b") -Script { param($x,$y) "$x$y" }) | + Should -Be "ab" + } +} + +Describe "Step honesty (#422 split check vs install)" { + BeforeAll { $script:SRC = Get-Content "$PSScriptRoot/../install-k8s.ps1" -Raw } + It "runs six steps, not five" { + $script:SRC | Should -Match 'Step 6 6 "' + $script:SRC | Should -Not -Match 'Step [0-9] 5 "' + } + It "has a dedicated 'Installing system tools' step" { + $script:SRC | Should -Match 'Step 2 6 "Installing system tools"' + } + It "the k3d start path does not stream raw output (routed via heartbeat)" { + # The old bare form streamed k3d's INFO[...] to the console; it must be gone, + # replaced by the captured job form (k3d cluster start `$n) inside the heartbeat. + $script:SRC | Should -Not -Match '(?m)^\s*k3d cluster start \$CLUSTER_NAME\s*$' + $script:SRC | Should -Match 'k3d cluster start \$n' + } +} + Describe "Test-Credentials" { It "HTTP 200 -> valid" { Mock Invoke-WebRequest { [pscustomobject]@{ StatusCode = 200 } } From 9736515d3ec5bfb08850b98ae8e7ec0011c8a6f1 Mon Sep 17 00:00:00 2001 From: shujaat hasan Date: Wed, 29 Jul 2026 17:02:00 +0200 Subject: [PATCH 2/7] fix(#422): job-runspace TLS 1.2 floor + k3d start exit-code check (Bugbot) Two High-severity findings from moving work into Start-Job via Invoke-WithHeartbeat: - TLS 1.2 doesn't carry into job runspaces (PS 5.1 defaults to TLS 1.0/1.1), so in-job HTTPS downloads (kubectl/k3d/helm/winget/Docker Desktop) could fail SSL/TLS on hosts that need the explicit floor. Re-apply Tls12 in $script:JobInit (OR-in, don't clobber), which every job runs before its scriptblock. - A native `k3d cluster start` non-zero exit leaves the job state 'Completed', so Invoke-WithHeartbeat never threw and the installer reported "Compute environment started." on a stopped cluster. The start scriptblock now checks $LASTEXITCODE and throws its captured output, so the existing catch surfaces a real Err. Adds a functional in-job-TLS test and a source guard for the exit-code throw. Co-Authored-By: Claude Opus 4.8 --- scripts/install-k8s.ps1 | 23 +++++++++++++++++++++-- scripts/manifest.sha256 | 2 +- scripts/tests/install-k8s.Tests.ps1 | 13 +++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/scripts/install-k8s.ps1 b/scripts/install-k8s.ps1 index 62cee7de..c1fe6e10 100644 --- a/scripts/install-k8s.ps1 +++ b/scripts/install-k8s.ps1 @@ -171,7 +171,18 @@ function Wait-JobWithProgress { # install (#409). Every Start-Job below passes this as -InitializationScript to # pin the job to a local working directory before it runs anything. (SystemRoot # is always local; the guard makes it a no-op on non-Windows Pester runs.) -$script:JobInit = { if ($env:SystemRoot) { Set-Location $env:SystemRoot } } +$script:JobInit = { + if ($env:SystemRoot) { Set-Location $env:SystemRoot } + # Job runspaces don't inherit the parent's TLS floor (set once at script top). + # Windows PowerShell 5.1 still defaults to TLS 1.0/1.1, which many corporate + # proxies and CDNs reject — so in-job HTTPS downloads (kubectl/k3d/helm/winget/ + # Docker Desktop via Invoke-WithHeartbeat) would fail SSL/TLS without this + # (#422 Bugbot). Re-apply TLS 1.2 (OR-in, don't clobber a higher floor). + try { + [Net.ServicePointManager]::SecurityProtocol = + [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + } catch {} +} # One honest line per system tool once it's ready (#422): name, version, and # whatever of {size, elapsed} is known — so "Installing system tools" shows @@ -1538,7 +1549,15 @@ function New-K3dCluster { # heartbeat and capture the output to the log instead of streaming raw lines. try { $startOut = Invoke-WithHeartbeat -Message "Starting your secure environment" -TimeoutSec 300 ` - -ArgumentList @($CLUSTER_NAME) -Script { param($n) k3d cluster start $n 2>&1 } + -ArgumentList @($CLUSTER_NAME) -Script { + param($n) + $o = k3d cluster start $n 2>&1 + # A native non-zero exit leaves the job state 'Completed', so it must + # throw to surface as a failure (else the installer reports "started" + # on a stopped cluster, #422 Bugbot). Throw carries the output. + if ($LASTEXITCODE -ne 0) { throw ($o | Out-String) } + $o + } if ($startOut) { Log "k3d cluster start: $($startOut -join "`n")" } } catch { Log "k3d cluster start failed: $_" diff --git a/scripts/manifest.sha256 b/scripts/manifest.sha256 index 17cb6e19..ed105590 100644 --- a/scripts/manifest.sha256 +++ b/scripts/manifest.sha256 @@ -15,4 +15,4 @@ e2ea63d844e6649f1d3aaae9fd4733845a1a39df37d68abbaeda00330f9e1c7e scripts/lib/as b6a7c592c2d2a71506f8d7ee4a09f048634f6f6958096f1f455e02e2353f9db3 scripts/lib/probe.sh c47c86d5f844154bad82485baf1f003be88ace3b8b9f09f59f078c2b9bc8874f scripts/lib/summary.sh 77e03332ebfab1ef759c6148a57afcf479c02c5dc6cc7b0e0e680f58e20cd364 scripts/lib/diagnose.sh -2c80761ed0ac025609e7d4a18967053ac1852ea0a6ea4e3ca798e0c79eba5d9c scripts/install-k8s.ps1 +696b0a365a1fa674822de7eb85eaf9a4b273d58d5b64a058de8863a9d97d20f3 scripts/install-k8s.ps1 diff --git a/scripts/tests/install-k8s.Tests.ps1 b/scripts/tests/install-k8s.Tests.ps1 index 2602a90a..68323d0a 100644 --- a/scripts/tests/install-k8s.Tests.ps1 +++ b/scripts/tests/install-k8s.Tests.ps1 @@ -58,6 +58,12 @@ Describe "Invoke-WithHeartbeat (#422 no silent window)" { (Invoke-WithHeartbeat -Message "args" -PollSeconds 1 -ArgumentList @("a","b") -Script { param($x,$y) "$x$y" }) | Should -Be "ab" } + It "job runspaces get the TLS 1.2 floor (Bugbot #422)" { + # Jobs don't inherit the parent's SecurityProtocol; JobInit must re-apply it, + # else in-job HTTPS downloads fail on TLS-1.2-only hosts. + (Invoke-WithHeartbeat -Message "tls" -PollSeconds 1 -Script { [Net.ServicePointManager]::SecurityProtocol.ToString() }) | + Should -Match 'Tls12' + } } Describe "Step honesty (#422 split check vs install)" { @@ -75,6 +81,13 @@ Describe "Step honesty (#422 split check vs install)" { $script:SRC | Should -Not -Match '(?m)^\s*k3d cluster start \$CLUSTER_NAME\s*$' $script:SRC | Should -Match 'k3d cluster start \$n' } + It "k3d start throws on a non-zero exit so a stopped cluster isn't reported started (Bugbot #422)" { + # Invoke-WithHeartbeat only throws on job Failed/timeout; a native non-zero + # exit leaves the job Completed, so the start scriptblock must check + # $LASTEXITCODE and throw its captured output ($o) itself. + $script:SRC | Should -Match 'k3d cluster start \$n' + $script:SRC | Should -Match 'if \(\$LASTEXITCODE -ne 0\) \{ throw \(\$o' + } } Describe "Get-ErrDetailLines (#423 honest failure output)" { From e41c5ae59d9283da1cb457554f7c00790cefc9a5 Mon Sep 17 00:00:00 2001 From: shujaat hasan Date: Wed, 29 Jul 2026 17:08:27 +0200 Subject: [PATCH 3/7] fix(#422): surface heartbeat failure detail + fail loudly on Docker install (Bugbot) Two follow-on findings from the Start-Job/heartbeat design: - Invoke-WithHeartbeat threw a generic 'Failed while: ...' and swallowed the job's real error (Receive-Job -ErrorAction SilentlyContinue), so the k3d-start detail never reached the log/Err. Now capture output+error (2>&1) and the job's terminating reason, and include it in the throw; the k3d-start catch passes it as Err detail too. - The Docker Desktop installer Start-Process had no -ErrorAction Stop and no exit check, so a spawn/install failure completed the job as success and Step 2 continued. Now -ErrorAction Stop + PassThru + exit-code throw, wrapped so it Errs cleanly with the real detail. Adds a heartbeat failure-detail test + a Docker-installer source guard. Co-Authored-By: Claude Opus 4.8 --- scripts/install-k8s.ps1 | 39 ++++++++++++++++++++++------- scripts/manifest.sha256 | 2 +- scripts/tests/install-k8s.Tests.ps1 | 11 ++++++++ 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/scripts/install-k8s.ps1 b/scripts/install-k8s.ps1 index c1fe6e10..e01c010c 100644 --- a/scripts/install-k8s.ps1 +++ b/scripts/install-k8s.ps1 @@ -215,11 +215,20 @@ function Invoke-WithHeartbeat { ) $job = Start-Job -ScriptBlock $Script -ArgumentList $ArgumentList -InitializationScript $script:JobInit $finished = Wait-JobWithProgress -Job $job -TimeoutSec $TimeoutSec -Message $Message -PollSeconds $PollSeconds - $out = @(Receive-Job $job -ErrorAction SilentlyContinue) - $state = $job.State + # Capture BOTH output and error records (2>&1) so a failure's real detail + # (e.g. the k3d/installer error the scriptblock threw) can be surfaced, not + # swallowed (#422 Bugbot). The job's terminating exception is the most reliable + # source of the reason. + $out = @(Receive-Job $job -ErrorAction SilentlyContinue 2>&1) + $state = $job.State + $reason = $null + try { $reason = $job.ChildJobs[0].JobStateInfo.Reason.Message } catch {} Remove-Job $job -Force -ErrorAction SilentlyContinue - if (-not $finished) { throw "Timed out after ${TimeoutSec}s while: $Message" } - if ($state -eq 'Failed') { throw "Failed while: $Message" } + if (-not $finished) { throw "Timed out after ${TimeoutSec}s while: ${Message}" } + if ($state -eq 'Failed') { + $detail = if ($reason) { "$reason" } else { ("$($out -join "`n")").Trim() } + throw ("Failed while: ${Message}" + $(if ($detail) { " -- $detail" } else { "" })) + } return $out } @@ -742,10 +751,20 @@ function Install-DockerDesktop { } } # The installer itself runs silent with --quiet; heartbeat it too (#422). - Invoke-WithHeartbeat -Message "Installing Docker Desktop" -TimeoutSec 2400 ` - -ArgumentList @($installer) -Script { - param($d); Start-Process -FilePath $d -ArgumentList "install --quiet --accept-license" -Wait - } | Out-Null + # -ErrorAction Stop + a non-zero exit-code check so a spawn failure or a + # failed install throws (job -> Failed) instead of completing as success and + # letting Step 2 continue as if Docker was installed (#422 Bugbot). + try { + Invoke-WithHeartbeat -Message "Installing Docker Desktop" -TimeoutSec 2400 ` + -ArgumentList @($installer) -Script { + param($d) + $p = Start-Process -FilePath $d -ArgumentList "install --quiet --accept-license" ` + -Wait -PassThru -ErrorAction Stop + if ($p.ExitCode -ne 0) { throw "Docker Desktop installer exited $($p.ExitCode)" } + } | Out-Null + } catch { + Err "Docker Desktop installation failed. Install it manually from https://www.docker.com/products/docker-desktop/ and re-run." "$_" + } Remove-Item $installer -Force -ErrorAction SilentlyContinue } RefreshPath @@ -1560,8 +1579,10 @@ function New-K3dCluster { } if ($startOut) { Log "k3d cluster start: $($startOut -join "`n")" } } catch { + # $_ now carries the real k3d output (Invoke-WithHeartbeat surfaces the + # job's failure reason), so pass it as Err detail, not just to the log (#422 Bugbot). Log "k3d cluster start failed: $_" - Err "Couldn't start the existing '$CLUSTER_NAME' environment. Check Docker is running, then re-run." + Err "Couldn't start the existing '$CLUSTER_NAME' environment. Check Docker is running, then re-run." "$_" } Ok "Compute environment started." } diff --git a/scripts/manifest.sha256 b/scripts/manifest.sha256 index ed105590..6a53495d 100644 --- a/scripts/manifest.sha256 +++ b/scripts/manifest.sha256 @@ -15,4 +15,4 @@ e2ea63d844e6649f1d3aaae9fd4733845a1a39df37d68abbaeda00330f9e1c7e scripts/lib/as b6a7c592c2d2a71506f8d7ee4a09f048634f6f6958096f1f455e02e2353f9db3 scripts/lib/probe.sh c47c86d5f844154bad82485baf1f003be88ace3b8b9f09f59f078c2b9bc8874f scripts/lib/summary.sh 77e03332ebfab1ef759c6148a57afcf479c02c5dc6cc7b0e0e680f58e20cd364 scripts/lib/diagnose.sh -696b0a365a1fa674822de7eb85eaf9a4b273d58d5b64a058de8863a9d97d20f3 scripts/install-k8s.ps1 +948b2a968e6aad9b133554934c39d96ac7e21245c6451ed5525878645b8a4540 scripts/install-k8s.ps1 diff --git a/scripts/tests/install-k8s.Tests.ps1 b/scripts/tests/install-k8s.Tests.ps1 index 68323d0a..6cbf91b6 100644 --- a/scripts/tests/install-k8s.Tests.ps1 +++ b/scripts/tests/install-k8s.Tests.ps1 @@ -64,6 +64,11 @@ Describe "Invoke-WithHeartbeat (#422 no silent window)" { (Invoke-WithHeartbeat -Message "tls" -PollSeconds 1 -Script { [Net.ServicePointManager]::SecurityProtocol.ToString() }) | Should -Match 'Tls12' } + It "surfaces the real failure detail, not just a generic message (Bugbot #422)" { + # A failed job's real error must reach the caller (log + Err), not be swallowed. + { Invoke-WithHeartbeat -Message "op" -PollSeconds 1 -Script { throw "REAL_REASON_XYZ" } } | + Should -Throw -ExpectedMessage "*REAL_REASON_XYZ*" + } } Describe "Step honesty (#422 split check vs install)" { @@ -88,6 +93,12 @@ Describe "Step honesty (#422 split check vs install)" { $script:SRC | Should -Match 'k3d cluster start \$n' $script:SRC | Should -Match 'if \(\$LASTEXITCODE -ne 0\) \{ throw \(\$o' } + It "the Docker Desktop installer fails loudly, not silently (Bugbot #422)" { + # -ErrorAction Stop on the spawn + a non-zero exit-code throw, so a failed + # install doesn't complete the job as success and let Step 2 continue. + $script:SRC | Should -Match 'install --quiet --accept-license[\s\S]{0,120}-ErrorAction Stop' + $script:SRC | Should -Match '\$p\.ExitCode -ne 0[\s\S]{0,40}throw' + } } Describe "Get-ErrDetailLines (#423 honest failure output)" { From 675d6210d3b4ff0b272d7a569dea039211f49747 Mon Sep 17 00:00:00 2001 From: shujaat hasan Date: Wed, 29 Jul 2026 17:13:23 +0200 Subject: [PATCH 4/7] fix(#422): print k3d/helm summary only after the execute-gate (Bugbot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit k3d and helm printed their green Get-ToolSummaryLine 'ready' line inside the download branch, before Assert-ToolRuns — so a corrupt/wrong-arch binary showed as ready and then failed the gate (kubectl already gates first). Compute the summary at download time (correct elapsed) but defer the Ok until after the execute-gate passes. Adds a source guard. Co-Authored-By: Claude Opus 4.8 --- scripts/install-k8s.ps1 | 10 ++++++++-- scripts/manifest.sha256 | 2 +- scripts/tests/install-k8s.Tests.ps1 | 6 ++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/scripts/install-k8s.ps1 b/scripts/install-k8s.ps1 index e01c010c..55f13f16 100644 --- a/scripts/install-k8s.ps1 +++ b/scripts/install-k8s.ps1 @@ -1160,10 +1160,14 @@ function Install-K3dAndHelm { } Log "k3d checksum verified." RefreshPath - Ok (Get-ToolSummaryLine -Name "k3d" -Version $k3dVer -Size "~25 MB" -ElapsedSec ([int]((Get-Date) - $t0k3d).TotalSeconds)) + # Compute the summary now (correct elapsed) but print it only AFTER the + # execute-gate passes — a corrupt/wrong-arch binary must not show a green + # "ready" line before Assert-ToolRuns (#422 Bugbot; kubectl gates first too). + $k3dSummary = Get-ToolSummaryLine -Name "k3d" -Version $k3dVer -Size "~25 MB" -ElapsedSec ([int]((Get-Date) - $t0k3d).TotalSeconds) } } Assert-ToolRuns -Name "k3d" -VersionArgs @("version") -BinPath "$TOOL_DIR\k3d.exe" + if ($k3dSummary) { Ok $k3dSummary } # -- Helm -- if (-not (Has "helm")) { @@ -1205,12 +1209,14 @@ function Install-K3dAndHelm { Remove-Item $helmZip -Force -ErrorAction SilentlyContinue Remove-Item $helmExtract -Recurse -Force -ErrorAction SilentlyContinue RefreshPath - Ok (Get-ToolSummaryLine -Name "helm" -Version $helmVer -Size "~20 MB" -ElapsedSec ([int]((Get-Date) - $t0helm).TotalSeconds)) + # Summary printed only after the execute-gate below (#422 Bugbot). + $helmSummary = Get-ToolSummaryLine -Name "helm" -Version $helmVer -Size "~20 MB" -ElapsedSec ([int]((Get-Date) - $t0helm).TotalSeconds) } if (-not (Has "helm")) { Err "Helm could not be installed. Install manually from https://helm.sh/docs/intro/install/ and re-run." } } Assert-ToolRuns -Name "helm" -VersionArgs @("version") -BinPath "$TOOL_DIR\helm.exe" + if ($helmSummary) { Ok $helmSummary } Ok "System tools" } diff --git a/scripts/manifest.sha256 b/scripts/manifest.sha256 index 6a53495d..20f36d03 100644 --- a/scripts/manifest.sha256 +++ b/scripts/manifest.sha256 @@ -15,4 +15,4 @@ e2ea63d844e6649f1d3aaae9fd4733845a1a39df37d68abbaeda00330f9e1c7e scripts/lib/as b6a7c592c2d2a71506f8d7ee4a09f048634f6f6958096f1f455e02e2353f9db3 scripts/lib/probe.sh c47c86d5f844154bad82485baf1f003be88ace3b8b9f09f59f078c2b9bc8874f scripts/lib/summary.sh 77e03332ebfab1ef759c6148a57afcf479c02c5dc6cc7b0e0e680f58e20cd364 scripts/lib/diagnose.sh -948b2a968e6aad9b133554934c39d96ac7e21245c6451ed5525878645b8a4540 scripts/install-k8s.ps1 +e362d4ed58588c8614d8c7da42643ce4b726ac3c13a6266452fef240c0c5cae2 scripts/install-k8s.ps1 diff --git a/scripts/tests/install-k8s.Tests.ps1 b/scripts/tests/install-k8s.Tests.ps1 index 6cbf91b6..8289501d 100644 --- a/scripts/tests/install-k8s.Tests.ps1 +++ b/scripts/tests/install-k8s.Tests.ps1 @@ -99,6 +99,12 @@ Describe "Step honesty (#422 split check vs install)" { $script:SRC | Should -Match 'install --quiet --accept-license[\s\S]{0,120}-ErrorAction Stop' $script:SRC | Should -Match '\$p\.ExitCode -ne 0[\s\S]{0,40}throw' } + It "k3d/helm print their green summary only after the execute-gate (Bugbot #422)" { + # A corrupt/wrong-arch binary must fail Assert-ToolRuns before any green Ok; + # the summary is deferred to after the gate (kubectl already does this). + $script:SRC | Should -Match 'Assert-ToolRuns -Name "k3d"[\s\S]{0,80}if \(\$k3dSummary\) \{ Ok' + $script:SRC | Should -Match 'Assert-ToolRuns -Name "helm"[\s\S]{0,80}if \(\$helmSummary\) \{ Ok' + } } Describe "Get-ErrDetailLines (#423 honest failure output)" { From a699d07bd73c8b70a4a8f0f8ce560f9bddbdd16f Mon Sep 17 00:00:00 2001 From: shujaat hasan Date: Wed, 29 Jul 2026 17:20:33 +0200 Subject: [PATCH 5/7] fix(#422): winget Docker install falls back + fails loudly (Bugbot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The winget Docker path soft-logged failures, never checked $LASTEXITCODE, and had no direct-download fallback when winget was present — so a failed winget install let Step 2 continue and only surfaced as the 10-minute Docker-wait timeout later. Now: the winget scriptblock throws on a non-zero exit; if winget is absent OR didn't land the exe, fall through to the direct download (parity with k3d/helm); and a final Test-Path guard Errs immediately if neither path installed Docker. Adds a source guard. Co-Authored-By: Claude Opus 4.8 --- scripts/install-k8s.ps1 | 25 ++++++++++++++++++++----- scripts/manifest.sha256 | 2 +- scripts/tests/install-k8s.Tests.ps1 | 7 +++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/scripts/install-k8s.ps1 b/scripts/install-k8s.ps1 index 55f13f16..e8a35473 100644 --- a/scripts/install-k8s.ps1 +++ b/scripts/install-k8s.ps1 @@ -728,15 +728,25 @@ function Install-DockerDesktop { $dockerExe = "$env:ProgramFiles\Docker\Docker\Docker Desktop.exe" if (-not (Test-Path $dockerExe)) { + # Try winget first (if present), then fall back to the direct download when + # winget is absent OR its install didn't land the exe — parity with k3d/helm, + # so a swallowed winget failure doesn't leave Step 2 to die in the long + # Docker-wait later (#422 Bugbot). if (Has "winget") { # winget install is console-silent for minutes on a 600 MB package (#422). Info "Installing Docker Desktop (~600 MB via winget) -- several minutes is normal." try { Invoke-WithHeartbeat -Message "Installing Docker Desktop" -TimeoutSec 2400 -Script { winget install -e --id Docker.DockerDesktop --accept-package-agreements --accept-source-agreements --silent + # winget is native: a non-zero exit leaves the job Completed, so throw + # to mark it failed and trigger the direct-download fallback below. + if ($LASTEXITCODE -ne 0) { throw "winget exited $LASTEXITCODE" } } | Out-Null - } catch { Log "Docker Desktop winget install: $_" } - } else { + } catch { Log "Docker Desktop winget install failed (will try direct download): $_" } + RefreshPath + } + + if (-not (Test-Path $dockerExe)) { $ddArch = Get-WindowsArch # Honest progress (#468): the single biggest download of the install. # Size measured 2026-07-29 (613 MB). @@ -752,8 +762,7 @@ function Install-DockerDesktop { } # The installer itself runs silent with --quiet; heartbeat it too (#422). # -ErrorAction Stop + a non-zero exit-code check so a spawn failure or a - # failed install throws (job -> Failed) instead of completing as success and - # letting Step 2 continue as if Docker was installed (#422 Bugbot). + # failed install throws (job -> Failed) instead of completing as success (#422 Bugbot). try { Invoke-WithHeartbeat -Message "Installing Docker Desktop" -TimeoutSec 2400 ` -ArgumentList @($installer) -Script { @@ -766,8 +775,14 @@ function Install-DockerDesktop { Err "Docker Desktop installation failed. Install it manually from https://www.docker.com/products/docker-desktop/ and re-run." "$_" } Remove-Item $installer -Force -ErrorAction SilentlyContinue + RefreshPath + } + + # Neither winget nor the direct installer produced the exe — fail loudly now + # rather than in the 10-minute Docker-wait below (#422 Bugbot). + if (-not (Test-Path $dockerExe)) { + Err "Docker Desktop installation didn't complete. Install it manually from https://www.docker.com/products/docker-desktop/ and re-run." } - RefreshPath } $dockerRunning = $false diff --git a/scripts/manifest.sha256 b/scripts/manifest.sha256 index 20f36d03..c9f71736 100644 --- a/scripts/manifest.sha256 +++ b/scripts/manifest.sha256 @@ -15,4 +15,4 @@ e2ea63d844e6649f1d3aaae9fd4733845a1a39df37d68abbaeda00330f9e1c7e scripts/lib/as b6a7c592c2d2a71506f8d7ee4a09f048634f6f6958096f1f455e02e2353f9db3 scripts/lib/probe.sh c47c86d5f844154bad82485baf1f003be88ace3b8b9f09f59f078c2b9bc8874f scripts/lib/summary.sh 77e03332ebfab1ef759c6148a57afcf479c02c5dc6cc7b0e0e680f58e20cd364 scripts/lib/diagnose.sh -e362d4ed58588c8614d8c7da42643ce4b726ac3c13a6266452fef240c0c5cae2 scripts/install-k8s.ps1 +5ab958ff6278e85a4edcdeed16107316fc375d20a1b2fb06ceb2243edf11747e scripts/install-k8s.ps1 diff --git a/scripts/tests/install-k8s.Tests.ps1 b/scripts/tests/install-k8s.Tests.ps1 index 8289501d..cd298c3f 100644 --- a/scripts/tests/install-k8s.Tests.ps1 +++ b/scripts/tests/install-k8s.Tests.ps1 @@ -105,6 +105,13 @@ Describe "Step honesty (#422 split check vs install)" { $script:SRC | Should -Match 'Assert-ToolRuns -Name "k3d"[\s\S]{0,80}if \(\$k3dSummary\) \{ Ok' $script:SRC | Should -Match 'Assert-ToolRuns -Name "helm"[\s\S]{0,80}if \(\$helmSummary\) \{ Ok' } + It "the winget Docker path checks exit, falls back, and fails loudly (Bugbot #422)" { + # winget Docker install must check $LASTEXITCODE (throw -> fallback), then a + # final Test-Path guard Errs if neither winget nor the direct install landed. + $script:SRC | Should -Match 'Docker\.DockerDesktop' + $script:SRC | Should -Match 'if \(\$LASTEXITCODE -ne 0\) \{ throw "winget exited' + $script:SRC | Should -Match "Docker Desktop installation didn't complete" + } } Describe "Get-ErrDetailLines (#423 honest failure output)" { From e6be743f2c2d635be8ad97f6748cfc1359e02d05 Mon Sep 17 00:00:00 2001 From: shujaat hasan Date: Wed, 29 Jul 2026 17:34:31 +0200 Subject: [PATCH 6/7] fix(#422): run installers as killable processes, not orphan-prone jobs (Bugbot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Start-Process -Wait / winget install inside Invoke-WithHeartbeat (a background job) leaks the child process on timeout: Stop-Job ends the job runspace but the installer keeps running, and the winget path could time out then fall through to a second concurrent install. Switch the Docker Desktop installer + all winget installs (Docker, k3d, helm) to Start-Process -PassThru + Wait-ProcessWithDeadline, which shows the spinner AND kills the actual process on timeout, then checks the exit code. Downloads (Invoke-WebRequest) stay on Invoke-WithHeartbeat — no child process to orphan. Updates the Docker source guards accordingly. Co-Authored-By: Claude Opus 4.8 --- scripts/install-k8s.ps1 | 64 ++++++++++++++++++----------- scripts/manifest.sha256 | 2 +- scripts/tests/install-k8s.Tests.ps1 | 22 +++++----- 3 files changed, 54 insertions(+), 34 deletions(-) diff --git a/scripts/install-k8s.ps1 b/scripts/install-k8s.ps1 index e8a35473..3d952596 100644 --- a/scripts/install-k8s.ps1 +++ b/scripts/install-k8s.ps1 @@ -734,14 +734,19 @@ function Install-DockerDesktop { # Docker-wait later (#422 Bugbot). if (Has "winget") { # winget install is console-silent for minutes on a 600 MB package (#422). + # Run it as a tracked PROCESS (not a background job): Wait-ProcessWithDeadline + # shows a spinner AND kills the process on timeout, so a stuck install can't + # orphan past the step and fall through to a second concurrent install — + # Stop-Job would leave the job's child process running (#422 Bugbot). Info "Installing Docker Desktop (~600 MB via winget) -- several minutes is normal." try { - Invoke-WithHeartbeat -Message "Installing Docker Desktop" -TimeoutSec 2400 -Script { - winget install -e --id Docker.DockerDesktop --accept-package-agreements --accept-source-agreements --silent - # winget is native: a non-zero exit leaves the job Completed, so throw - # to mark it failed and trigger the direct-download fallback below. - if ($LASTEXITCODE -ne 0) { throw "winget exited $LASTEXITCODE" } - } | Out-Null + $wp = Start-Process -FilePath "winget" -PassThru -ErrorAction Stop -ArgumentList @( + "install","-e","--id","Docker.DockerDesktop", + "--accept-package-agreements","--accept-source-agreements","--silent") + if (-not (Wait-ProcessWithDeadline -Process $wp -Deadline (Get-Date).AddMinutes(40) -Message "Installing Docker Desktop (winget)")) { + throw "winget Docker install timed out (process killed)" + } + if ($wp.ExitCode -ne 0) { throw "winget exited $($wp.ExitCode)" } } catch { Log "Docker Desktop winget install failed (will try direct download): $_" } RefreshPath } @@ -760,19 +765,24 @@ function Install-DockerDesktop { Invoke-WebRequest -Uri $u -OutFile $d -UseBasicParsing } } - # The installer itself runs silent with --quiet; heartbeat it too (#422). - # -ErrorAction Stop + a non-zero exit-code check so a spawn failure or a - # failed install throws (job -> Failed) instead of completing as success (#422 Bugbot). + # Run the installer as a tracked PROCESS with a deadline that KILLS it on + # timeout (a background job would orphan the installer, #422 Bugbot). + # -ErrorAction Stop catches a spawn failure; the exit code catches a failed + # install — either way fail loudly, never continue as if Docker installed. try { - Invoke-WithHeartbeat -Message "Installing Docker Desktop" -TimeoutSec 2400 ` - -ArgumentList @($installer) -Script { - param($d) - $p = Start-Process -FilePath $d -ArgumentList "install --quiet --accept-license" ` - -Wait -PassThru -ErrorAction Stop - if ($p.ExitCode -ne 0) { throw "Docker Desktop installer exited $($p.ExitCode)" } - } | Out-Null + $ip = Start-Process -FilePath $installer -ArgumentList "install --quiet --accept-license" ` + -PassThru -ErrorAction Stop } catch { - Err "Docker Desktop installation failed. Install it manually from https://www.docker.com/products/docker-desktop/ and re-run." "$_" + Remove-Item $installer -Force -ErrorAction SilentlyContinue + Err "Docker Desktop installer wouldn't start. Install it manually from https://www.docker.com/products/docker-desktop/ and re-run." "$_" + } + if (-not (Wait-ProcessWithDeadline -Process $ip -Deadline (Get-Date).AddMinutes(40) -Message "Installing Docker Desktop")) { + Remove-Item $installer -Force -ErrorAction SilentlyContinue + Err "Docker Desktop installation timed out (installer stopped). Install it manually from https://www.docker.com/products/docker-desktop/ and re-run." + } + if ($ip.ExitCode -ne 0) { + Remove-Item $installer -Force -ErrorAction SilentlyContinue + Err "Docker Desktop installation failed (installer exited $($ip.ExitCode)). Install it manually from https://www.docker.com/products/docker-desktop/ and re-run." } Remove-Item $installer -Force -ErrorAction SilentlyContinue RefreshPath @@ -1116,10 +1126,14 @@ function Install-K3dAndHelm { if (-not (Has "k3d")) { if (Has "winget") { Log "Installing k3d via winget..." - # winget install is console-silent; heartbeat so it doesn't read as a hang (#422). + # winget install is console-silent; run it as a killable tracked process + # (not a job — Stop-Job would orphan the child on timeout) with a spinner + + # deadline. Best-effort: on failure the direct download below takes over (#422). try { - $null = Invoke-WithHeartbeat -Message "Installing k3d via winget" -TimeoutSec 600 -Script { - winget install -e --id Rancher.k3d --accept-package-agreements --accept-source-agreements --silent 2>&1 + $kp = Start-Process -FilePath "winget" -PassThru -ErrorAction Stop -ArgumentList @( + "install","-e","--id","Rancher.k3d","--accept-package-agreements","--accept-source-agreements","--silent") + if (-not (Wait-ProcessWithDeadline -Process $kp -Deadline (Get-Date).AddMinutes(10) -Message "Installing k3d (winget)")) { + throw "k3d winget install timed out (process killed)" } } catch { Log "k3d winget install: $_" } } @@ -1188,10 +1202,14 @@ function Install-K3dAndHelm { if (-not (Has "helm")) { if (Has "winget") { Log "Installing Helm via winget..." - # winget install is console-silent; heartbeat so it doesn't read as a hang (#422). + # winget install is console-silent; killable tracked process + spinner/deadline + # (a job would orphan the child on timeout). Best-effort: the direct download + # below takes over on failure (#422). try { - $null = Invoke-WithHeartbeat -Message "Installing Helm via winget" -TimeoutSec 600 -Script { - winget install -e --id Helm.Helm --accept-package-agreements --accept-source-agreements --silent 2>&1 + $hp = Start-Process -FilePath "winget" -PassThru -ErrorAction Stop -ArgumentList @( + "install","-e","--id","Helm.Helm","--accept-package-agreements","--accept-source-agreements","--silent") + if (-not (Wait-ProcessWithDeadline -Process $hp -Deadline (Get-Date).AddMinutes(10) -Message "Installing Helm (winget)")) { + throw "helm winget install timed out (process killed)" } } catch { Log "helm winget install: $_" } RefreshPath diff --git a/scripts/manifest.sha256 b/scripts/manifest.sha256 index c9f71736..3040b158 100644 --- a/scripts/manifest.sha256 +++ b/scripts/manifest.sha256 @@ -15,4 +15,4 @@ e2ea63d844e6649f1d3aaae9fd4733845a1a39df37d68abbaeda00330f9e1c7e scripts/lib/as b6a7c592c2d2a71506f8d7ee4a09f048634f6f6958096f1f455e02e2353f9db3 scripts/lib/probe.sh c47c86d5f844154bad82485baf1f003be88ace3b8b9f09f59f078c2b9bc8874f scripts/lib/summary.sh 77e03332ebfab1ef759c6148a57afcf479c02c5dc6cc7b0e0e680f58e20cd364 scripts/lib/diagnose.sh -5ab958ff6278e85a4edcdeed16107316fc375d20a1b2fb06ceb2243edf11747e scripts/install-k8s.ps1 +833e8e97236bb1eacfd5a564a7b2d4a3f3bb5978f4200decb91cc0b85f064a52 scripts/install-k8s.ps1 diff --git a/scripts/tests/install-k8s.Tests.ps1 b/scripts/tests/install-k8s.Tests.ps1 index cd298c3f..65a7ec91 100644 --- a/scripts/tests/install-k8s.Tests.ps1 +++ b/scripts/tests/install-k8s.Tests.ps1 @@ -93,11 +93,12 @@ Describe "Step honesty (#422 split check vs install)" { $script:SRC | Should -Match 'k3d cluster start \$n' $script:SRC | Should -Match 'if \(\$LASTEXITCODE -ne 0\) \{ throw \(\$o' } - It "the Docker Desktop installer fails loudly, not silently (Bugbot #422)" { - # -ErrorAction Stop on the spawn + a non-zero exit-code throw, so a failed - # install doesn't complete the job as success and let Step 2 continue. - $script:SRC | Should -Match 'install --quiet --accept-license[\s\S]{0,120}-ErrorAction Stop' - $script:SRC | Should -Match '\$p\.ExitCode -ne 0[\s\S]{0,40}throw' + It "the Docker installer runs as a killable process, not an orphan-prone job (Bugbot #422)" { + # Start-Process -PassThru + Wait-ProcessWithDeadline (kills on timeout) + an + # exit-code check — a background job would orphan the installer on timeout. + $script:SRC | Should -Match 'Start-Process -FilePath \$installer[\s\S]{0,80}-PassThru -ErrorAction Stop' + $script:SRC | Should -Match 'Wait-ProcessWithDeadline -Process \$ip' + $script:SRC | Should -Match '\$ip\.ExitCode -ne 0' } It "k3d/helm print their green summary only after the execute-gate (Bugbot #422)" { # A corrupt/wrong-arch binary must fail Assert-ToolRuns before any green Ok; @@ -105,11 +106,12 @@ Describe "Step honesty (#422 split check vs install)" { $script:SRC | Should -Match 'Assert-ToolRuns -Name "k3d"[\s\S]{0,80}if \(\$k3dSummary\) \{ Ok' $script:SRC | Should -Match 'Assert-ToolRuns -Name "helm"[\s\S]{0,80}if \(\$helmSummary\) \{ Ok' } - It "the winget Docker path checks exit, falls back, and fails loudly (Bugbot #422)" { - # winget Docker install must check $LASTEXITCODE (throw -> fallback), then a - # final Test-Path guard Errs if neither winget nor the direct install landed. - $script:SRC | Should -Match 'Docker\.DockerDesktop' - $script:SRC | Should -Match 'if \(\$LASTEXITCODE -ne 0\) \{ throw "winget exited' + It "the winget Docker path is killable, checks exit, falls back, and fails loudly (Bugbot #422)" { + # winget runs as a tracked process (killable on timeout), its exit is checked + # (throw -> fallback), and a final Test-Path guard Errs if nothing landed. + $script:SRC | Should -Match 'Start-Process -FilePath "winget"[\s\S]{0,240}Docker\.DockerDesktop' + $script:SRC | Should -Match 'Wait-ProcessWithDeadline -Process \$wp' + $script:SRC | Should -Match '\$wp\.ExitCode -ne 0' $script:SRC | Should -Match "Docker Desktop installation didn't complete" } } From ef4b7c5a16e52fdb5f815389cb6175d75420e6e3 Mon Sep 17 00:00:00 2001 From: shujaat hasan Date: Wed, 29 Jul 2026 17:39:40 +0200 Subject: [PATCH 7/7] fix(#422): run k3d cluster start as a killable process too (Bugbot) Same orphan hazard as the installers: k3d cluster start ran inside Invoke-WithHeartbeat (a job), so Stop-Job on timeout left the native k3d child running. Switch it to Start-Process -PassThru + Wait-ProcessWithDeadline (kills on timeout), redirecting its raw INFO[...] to temp files for the log; check both the deadline and the exit code so a failed/stuck start Errs with the real reason instead of a false 'started'. Now every process-spawning op is killable; only in-runspace downloads + Add-AppxPackage remain on the job-based heartbeat. Co-Authored-By: Claude Opus 4.8 --- scripts/install-k8s.ps1 | 40 +++++++++++++++++------------ scripts/manifest.sha256 | 2 +- scripts/tests/install-k8s.Tests.ps1 | 22 ++++++++-------- 3 files changed, 35 insertions(+), 29 deletions(-) diff --git a/scripts/install-k8s.ps1 b/scripts/install-k8s.ps1 index 3d952596..bd0a9002 100644 --- a/scripts/install-k8s.ps1 +++ b/scripts/install-k8s.ps1 @@ -1603,25 +1603,31 @@ function New-K3dCluster { Ok "Compute environment already running." } else { Log "Cluster '$CLUSTER_NAME' exists but stopped -- starting..." - # Route k3d's raw INFO[...] through the style system (#422): run with a - # heartbeat and capture the output to the log instead of streaming raw lines. + # Run k3d start as a killable tracked PROCESS with a deadline (a background + # job would orphan the native k3d child on timeout, #422 Bugbot), capturing + # its raw INFO[...] to temp files so it goes to the log, not streamed to the + # console. Exit code + timeout are both checked so a failed start Errs with + # the real reason instead of falsely reporting "started". + $startOutFile = Join-Path $env:TEMP "k3d-start-$(Get-Random).log" + $startErrFile = Join-Path $env:TEMP "k3d-start-err-$(Get-Random).log" + $sp = $null try { - $startOut = Invoke-WithHeartbeat -Message "Starting your secure environment" -TimeoutSec 300 ` - -ArgumentList @($CLUSTER_NAME) -Script { - param($n) - $o = k3d cluster start $n 2>&1 - # A native non-zero exit leaves the job state 'Completed', so it must - # throw to surface as a failure (else the installer reports "started" - # on a stopped cluster, #422 Bugbot). Throw carries the output. - if ($LASTEXITCODE -ne 0) { throw ($o | Out-String) } - $o - } - if ($startOut) { Log "k3d cluster start: $($startOut -join "`n")" } + $sp = Start-Process -FilePath "k3d" -ArgumentList @("cluster","start",$CLUSTER_NAME) ` + -NoNewWindow -PassThru -ErrorAction Stop ` + -RedirectStandardOutput $startOutFile -RedirectStandardError $startErrFile } catch { - # $_ now carries the real k3d output (Invoke-WithHeartbeat surfaces the - # job's failure reason), so pass it as Err detail, not just to the log (#422 Bugbot). - Log "k3d cluster start failed: $_" - Err "Couldn't start the existing '$CLUSTER_NAME' environment. Check Docker is running, then re-run." "$_" + Remove-Item $startOutFile, $startErrFile -Force -ErrorAction SilentlyContinue + Err "Couldn't start the existing '$CLUSTER_NAME' environment (k3d wouldn't start). Check Docker is running, then re-run." "$_" + } + $startTimedOut = -not (Wait-ProcessWithDeadline -Process $sp -Deadline (Get-Date).AddMinutes(5) -Message "Starting your secure environment") + $startLog = (("$(Get-Content $startErrFile -Raw -ErrorAction SilentlyContinue)`n$(Get-Content $startOutFile -Raw -ErrorAction SilentlyContinue)")).Trim() + Remove-Item $startOutFile, $startErrFile -Force -ErrorAction SilentlyContinue + if ($startLog) { Log "k3d cluster start: $startLog" } + if ($startTimedOut) { + Err "Starting the existing '$CLUSTER_NAME' environment timed out (k3d stopped). Check Docker is running, then re-run." $startLog + } + if ($sp.ExitCode -ne 0) { + Err "Couldn't start the existing '$CLUSTER_NAME' environment. Check Docker is running, then re-run." $startLog } Ok "Compute environment started." } diff --git a/scripts/manifest.sha256 b/scripts/manifest.sha256 index 3040b158..e14bbcdd 100644 --- a/scripts/manifest.sha256 +++ b/scripts/manifest.sha256 @@ -15,4 +15,4 @@ e2ea63d844e6649f1d3aaae9fd4733845a1a39df37d68abbaeda00330f9e1c7e scripts/lib/as b6a7c592c2d2a71506f8d7ee4a09f048634f6f6958096f1f455e02e2353f9db3 scripts/lib/probe.sh c47c86d5f844154bad82485baf1f003be88ace3b8b9f09f59f078c2b9bc8874f scripts/lib/summary.sh 77e03332ebfab1ef759c6148a57afcf479c02c5dc6cc7b0e0e680f58e20cd364 scripts/lib/diagnose.sh -833e8e97236bb1eacfd5a564a7b2d4a3f3bb5978f4200decb91cc0b85f064a52 scripts/install-k8s.ps1 +867236f9a76b93ec9fd48d6f5d649389c6f159ee8d314f70938cb566089c0d1c scripts/install-k8s.ps1 diff --git a/scripts/tests/install-k8s.Tests.ps1 b/scripts/tests/install-k8s.Tests.ps1 index 65a7ec91..39f7c492 100644 --- a/scripts/tests/install-k8s.Tests.ps1 +++ b/scripts/tests/install-k8s.Tests.ps1 @@ -80,18 +80,18 @@ Describe "Step honesty (#422 split check vs install)" { It "has a dedicated 'Installing system tools' step" { $script:SRC | Should -Match 'Step 2 6 "Installing system tools"' } - It "the k3d start path does not stream raw output (routed via heartbeat)" { - # The old bare form streamed k3d's INFO[...] to the console; it must be gone, - # replaced by the captured job form (k3d cluster start `$n) inside the heartbeat. + It "the k3d start path runs as a killable process with output to the log, not streamed (Bugbot #422)" { + # No bare streaming form; k3d start is a tracked process with its raw INFO[...] + # redirected to temp files (logged), so nothing streams to the console. $script:SRC | Should -Not -Match '(?m)^\s*k3d cluster start \$CLUSTER_NAME\s*$' - $script:SRC | Should -Match 'k3d cluster start \$n' - } - It "k3d start throws on a non-zero exit so a stopped cluster isn't reported started (Bugbot #422)" { - # Invoke-WithHeartbeat only throws on job Failed/timeout; a native non-zero - # exit leaves the job Completed, so the start scriptblock must check - # $LASTEXITCODE and throw its captured output ($o) itself. - $script:SRC | Should -Match 'k3d cluster start \$n' - $script:SRC | Should -Match 'if \(\$LASTEXITCODE -ne 0\) \{ throw \(\$o' + $script:SRC | Should -Match 'Start-Process -FilePath "k3d" -ArgumentList @\("cluster","start"' + $script:SRC | Should -Match 'RedirectStandardError \$startErrFile' + } + It "k3d start Errs on timeout or non-zero exit, never a false 'started' (Bugbot #422)" { + # A deadline that KILLS the process (no orphan) plus an exit-code check both + # gate the "started" line. + $script:SRC | Should -Match 'Wait-ProcessWithDeadline -Process \$sp' + $script:SRC | Should -Match '\$sp\.ExitCode -ne 0' } It "the Docker installer runs as a killable process, not an orphan-prone job (Bugbot #422)" { # Start-Process -PassThru + Wait-ProcessWithDeadline (kills on timeout) + an