TestMaxSessionsRefuses hangs for the full 10-minute test timeout in CI (self-blocking transport + test-ordering race) #409
Labels
No labels
actions
bug
cli
duplicate
enhancement
fork
forum
git storage
help wanted
insights
invalid
issues
moderation
oidc
ownership transfer
packages
pr/merge protection rules
projects
pull requests
question
releases
sponsorships
tags
webhooks
wiki
wontfix
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
crueber/walhub#409
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
TestMaxSessionsRefuses hangs for the full 10-minute test timeout in CI (self-blocking transport + test-ordering race)
What's requested
Fix
TestMaxSessionsRefusesininternal/sshd/coverage_test.goso it cannot deadlock: today the test's own transport can capture the single session slot it meant to prove is refused, blocking the test until Go's 10-minute package timeout panics the CI job.Evidence
GitHub CI run 34714217427, job "test", step "vet + fast Go tests" (
go test -short -count=1 ./...), failed after the full 10-minute per-package timeout. The goroutine dump shows a three-way stalemate:ssh.Session.Runatcoverage_test.go:179—sess2.Run(...), waiting for an exec reply that never terminates.coverage_test.go:48—SSHUploadPackparked on<-c.block.ListenAndServeaccept loop alive and idle (sshd.go:160/:203) — nothing is wedged at the server level; the deadlock is between the test binary and its own fake transport.Mechanism (static reading)
The test assumes an ordering the scheduler does not guarantee (
coverage_test.go:164-183):sess1.Runis launched in a bare goroutine at:170and the test immediately proceeds tosess2.Runat:179. There is no synchronization proving sess1's exec reached the server first —captureTransport.enteredexists for exactly this (closed inSSHUploadPackat:45-46) but this test never sets it.exec(sshd.go:327-334) is a non-blockingselect/default, so it always refuses someone immediately — the refusal logic itself is correct and fires before any transport work. But which session gets refused is a race.coverage_test.go:48, sess2's exec takes thedefaultbranch, writes "too many concurrent sessions", exits 1, andRunreturns — the test passes andclose(tr.block)at:183releases sess1.SSHUploadPackblocks on<-c.block— a channel that is only closed at:183, i.e. aftersess2.Runreturns.sess2.Runreturns only when its transport returns. Self-deadlock: the test goroutine waits onRun(:179),Runwaits on the transport, the transport waits on the test goroutine. sess1's exec is then the one refused, but itsRunruns in a goroutine whose error is discarded (_ =at:170) and its stderr is never captured, so the assertion at:180can never see it either. Nothing breaks the cycle until the 10-minutego testtimeout panics.Secondary fragility: even in the intended ordering, the slot-holder (sess1) sits inside the transport with stdin/stdout wired to the raw SSH channel; the test relies on
close(tr.block)rather than on session teardown/stdio close to end the upload, so any future change to transport or channel close semantics turns a slow close into the same hang.Architecture notes
exec's session limiter is already correct for the property this test pins: theselect { case s.sessions <- ...: default: refuse }atsshd.go:327-334is non-blocking, fires before any transport dispatch, and never burns a slot on refusal. No production-code change is required for the cap itself.captureTransport.entered(coverage_test.go:32, 44-46) and thereleaseQueueper-exec hold pattern (:187-216, used by the siblingTestSessionCapIsPerConnection, which polls with a deadline instead of assuming ordering at:280-292).Prescribed fix
All changes in
internal/sshd/coverage_test.go:entered: make(chan struct{})and wait<-tr.enteredafter launchingsess1.Runand before callingsess2.Run. This guarantees sess1 owns the slot when sess2 is offered, so the refusal at:180is asserted against the intended session — the race at step 1 disappears.tr.block— closetr.blockANDsess1.Close()/cancel the test context soSSHUploadPackis released via channel close and the client-side session is closed, so the upload path sees EOF/channel-close regardless of ordering. (Optionally assert that the goroutine-launchedsess1.Runreturned.)t.Deadline()and skip-or-budget, or runsess2.Runagainst a client config/timer that fails the test with a clear message after a few seconds) so a regression degrades to a fast, named failure instead of consuming the package's 10-minute timeout in CI.TestSessionCapIsPerConnectionalready demonstrates the poll-with-deadline idiom — reuse that shape rather than fixed sleeps.Acceptance criteria
TestMaxSessionsRefusesno longer depends on goroutine scheduling: the slot-holder is synchronized viacaptureTransport.enteredbefore the second exec is offered.select/defaultrefusal before transport dispatch); no server-code changes are required by this fix.go test -short -count=1 ./internal/sshd/andgo test -race -short -count=1 ./internal/sshd/pass repeatedly (e.g.-count=10) without hangs.Fixed by PR #411 (#411): deterministic slot-holder via entered, held-session teardown (block close + sess1.Close), 5s own-deadline budgets with named failures; test-only, limiter unchanged. All acceptance runs pass (short, race, -count=10, no hangs).
Review of PR #411 (
413da51) — verified in scratch worktree, main untouched.ACCEPTANCE CRITERIA (all 5 pass):
STRESS RESULTS (scratch worktree @
413da51):NITS (non-blocking, left unpushed to avoid churn):
MERGE RECOMMENDATION: ready to merge.
Fixed by PR #411 (review clean — deterministic slot-holder proven, teardown + deadlines verified, stress incl. GOMAXPROCS runs green), merged. Closing.