Uh oh!
There was an error while loading. Please reload this page.
fix(installer): close stdin in finally, and drain before the write (backend#2246) - #816
Merged
Merged
Conversation
…ackend#2246)
Bugbot flagged `try { Write($Stdin); Close() } catch { }` in Invoke-BoundedProcess:
a throw from Write() skips Close(), so a child waiting for EOF hangs to the bound
and returns 124 instead of its real exit.
The mechanism as stated does not fire, and the measurement says why: the only
exception seen here is a broken pipe, and a broken pipe means the child has
already STOPPED reading -- so it is not waiting for EOF. Constructed it:
/usr/bin/true with a 200 KB payload returns the child's Code=0 in 0.2s with the
unfixed code, skipped Close() and all.
But the four flagged lines are genuinely defective, for a worse reason. The
stdout/stderr readers started AFTER the stdin write, so a child that both reads
stdin and writes output deadlocks once the payload passes the ~64 KiB pipe
buffer: it fills its stdout pipe, stops reading stdin, our Write() blocks, and
WaitForExit() is never reached. Not 124 -- no return at all, the hard timeout
this function exists to provide silently gone. Measured: /bin/cat with a 200 KB
payload and -TimeoutSec 20 outlived a 60s outer watchdog. With the readers
started first it returns Code=0 in 0.2s with all 200000 bytes back.
Two changes, both inside those lines:
* the ReadToEndAsync() drains move ahead of the stdin write
* Close() moves into a `finally`, so it runs even when Write() throws
Neither is reachable from today's two call sites -- `docker login
--password-stdin` and `docker exec -i <node> sh` -- whose payloads are a
credential and a ~304-byte prep script, both far under the pipe buffer. This
hardens a latent contract violation in a general-purpose bounded-exec helper; it
is not a live incident fix.
Also replaces the old source guard, which pinned `Write(...); Close()` as one
literal blob and so spoke for two independent properties at once, with three
narrower checks: the write guard, the finally placement, and a drain-before-write
ordering check derived by index position from the real function body (failing
closed when either anchor is missing, so "cannot tell" is a finding). Plus the
behavioural deadlock case, which is the one that actually reddens.
scripts/manifest.sha256 regenerated -- install-k8s.ps1 is in the supply-chain
integrity manifest, and `make drift` caught the stale digest.
Verification on macOS 26.5.2, pwsh 7.5.2 / Pester 6.0.1:
install-k8s.Tests.ps1 749 total, 736 passed, 0 failed, 13 skipped
(origin/develop baseline 746/733/0/13 -- exactly +3)
installer-parity 3/3 install.Tests.ps1 41/41
telemetry.Tests.ps1 114/114
make lint green (54 parsed, 61 shellchecked)
make drift all 18 guards green
Parser::ParseFile clean on both changed files
Mutation-proved, anchor counts asserted == 1 before each replacement:
M1 readers moved back after the write -> ordering guard RED (drain 3976 >
write 3851) and deadlock case RED ("outlived a 60s watchdog despite
-TimeoutSec 20"); restored byte-identical (cmp).
M2 Close() chained back into the write's try -> finally guard RED and write
guard RED; restored byte-identical (cmp).
The deadlock case is gated on `Test-Path /bin/cat` -- the real precondition -- not
on $IsWindows, which does not exist under the Windows PowerShell 5.1 that
install.ps1 pins and would have turned a missing binary into a false regression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>saqlainsyed007
approved these changes
Aug 24, 2026
saqlainsyed007
left a comment
Contributor
There was a problem hiding this comment.
Verified at head: Invoke-BoundedProcess now starts the ReadToEndAsync() output/error readers before the StandardInput.Write($Stdin), and closes stdin in a finally. That makes the structure deadlock-safe — a child that both reads stdin and writes >64 KiB no longer stalls the write (readers keep the pipe drained), and a broken-pipe throw from Write() still EOFs the child so WaitForExit/the hard timeout can fire. CI green, Bugbot pass, no open threads. LGTM.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes tracebloc/backend#2246 — Bugbot finding discussion_r3821144936, deferred at the
staging → mainprod hop.The claim, and what actually fires
Bugbot flagged four lines in
Invoke-BoundedProcess:That causal chain does not fire, and the measurement says why. The only exception actually raised here is a broken pipe, and a broken pipe means the child has stopped reading — so it is not sitting waiting for EOF. The two states are near mutually exclusive. Constructed it against the unfixed code:
/usr/bin/truewith a 200 KB payload returns the child's realCode=0in 0.2s, skippedClose()and all. No 124, no hang.But the flagged lines are genuinely defective, for a worse reason. The
ReadToEndAsync()drains started after the stdin write. So a child that both reads stdin and writes output deadlocks once the payload passes the ~64 KiB pipe buffer: it fills its stdout pipe, blocks because nobody is draining it, therefore stops reading stdin, therefore ourWrite()blocks — andWaitForExit($TimeoutSec * 1000)is never reached. Not a 124: no return at all. The hard timeout that is this function's entire reason to exist silently disappears.Constructed inputs, before → after
-TimeoutSec 20throughout, each case run in a job under a 60s outer watchdog (the failure is an unbounded hang, so a direct call would hang the whole run rather than fail one test):sh -c 'cat >/dev/null; exit 7'Code=7, 0.1sCode=7, 0.1s/usr/bin/true(already exited)Code=0, 0.3sCode=0, 0.2s/bin/cat(reads stdin and echoes)Code=0, 0.2s, all 200000 B returnedsh -c 'cat >/dev/null; exit 7'(quiet)Code=7, 0.1sCode=7, 0.1sC is the defect; A, B and D are unchanged, which is the point — B is the broken-pipe case the existing test already covered, and it still returns the child's verdict rather than our plumbing.
Note why B passing does not cover C:
/usr/bin/trueproduces no output, so there is no stdout backpressure. Both are 200 KB; only one deadlocks.Reachability — stated plainly
Neither mechanism is reachable from today's call sites. There are exactly two:
docker login … --password-stdin— a credentialdocker exec -i <node> sh— a generated prep script, measured at ~304 bytesBoth are orders of magnitude under the 65536-byte pipe buffer, so neither can deadlock, and neither hits the broken-pipe path in normal operation. This PR hardens a latent contract violation in a general-purpose bounded-exec helper. It is not a live-incident fix, and I am not claiming it is one.
I still think it is worth landing: the function's advertised contract is a hard timeout, and there is a constructible input for which that contract fails completely rather than degrading. The change is four lines, strictly safer, and provably inert on every reachable path.
The change
ReadToEndAsync()drains move ahead of the stdin writeClose()moves into afinally, so it runs even whenWrite()throws (and keeps its own inner guard, sinceClose()flushes and can raise the same broken pipe)The swallow itself stays. A pipe error is about our plumbing, not the command, and reporting it would replace the child's real verdict — the existing comment block explains this and it still holds.
Tests
The old source guard pinned
Write(...); Close()as one literal blob, so it spoke for two independent properties at once and had to be rewritten to change either. Replaced with narrower checks plus the behavioural case that actually reddens:try { Write($Stdin) } catch)finallyplacement, andShould -Not -Matchthe chained shape that regressed-BeGreaterThan -1first, so two missing anchors cannot compare equal and pass as agreementCode -ne 124, so a hang is never reported as a timeout, and the full 200000 bytes must round-trip (proof the child was drained, not merely that something returned fast)The deadlock case is gated on
Test-Path /bin/cat— the real precondition — not on$IsWindows, which does not exist under the Windows PowerShell 5.1 thatinstall.ps1pins and would have turned a missing binary into a false regression.Mutation table
Anchor occurrences asserted
== 1before each replacement, so an inert mutation cannot masquerade as coverage; both restores verified byte-identical withcmp.readers=1,waitforexit=1drain 3976 > write 3851); deadlock case RED (outlived a 60s watchdog despite -TimeoutSec 20). 3 passed / 2 failedClose()chained back into the write'stryfinally-close=1Also in this PR
scripts/manifest.sha256regenerated.install-k8s.ps1is in the supply-chain integrity manifest thatinstall.shverifies, andmake driftcaught the stale digest — the guard did its job. Digests only; the cosign signature is the release workflow's.Verification — macOS 26.5.2, pwsh 7.5.2, Pester 6.0.1
bash -n/shellcheckare N/A to the diff — no shell file changed; the PowerShell equivalent (Parser::ParseFile) is above, andmake lintwas run regardless.Gaps
Pester (windows-latest)from macOS; it has since passed on this PR, along withPester (ubuntu-latest),Lintand Cursor Bugbot. The three source guards are platform-independent and run there; the behavioural deadlock case skips on Windows by design (no/bin/cat). I did not find a Windows child I could confirm streams rather than buffers stdin, so I did not guess at one — the Windows-native reproduction is left uncovered rather than faked, and the ordering guard is what protects that platform from a reorder.