Skip to content

Commit 193226b

Browse files
committed
fix(cli): eliminate update-check "send on closed channel" race
startupHooks runs once per cobra Execute; its background goroutine used the package-level updateCheckResult directly for both close() and send. A process running multiple commands (the test binary) reassigns that pointer each Execute, so one goroutine could close another's channel -> panic: send on closed channel, plus an unguarded pointer data race. Each invocation now owns its own channel (startUpdateAdvisory): the goroutine sends/closes only its local channel, never the shared pointer, and the pointer handoff is mutex-guarded. Extracted consumeUpdateAdvisory + checkForUpdateMessage so PersistentPostRun and vdb.go share one safe consumer. Adds concurrency, delivery and nil-safety regression tests (race-clean under -race).
1 parent 5bab816 commit 193226b

3 files changed

Lines changed: 154 additions & 42 deletions

File tree

cmd/root.go

Lines changed: 78 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"path/filepath"
88
"strings"
9+
"sync"
910
"time"
1011

1112
"github.com/spf13/cobra"
@@ -34,8 +35,80 @@ var (
3435
buildDate = "unknown" // -X github.com/vulnetix/cli/v3/cmd.buildDate=
3536
)
3637

37-
// updateCheckResult receives the update advisory message from the background goroutine.
38-
var updateCheckResult chan string
38+
// updateCheckResult receives the update advisory message from the background
39+
// goroutine started by the most recent startupHooks invocation. It is guarded
40+
// by updateCheckMu because cobra.OnInitialize fires startupHooks once per
41+
// Execute — a process that runs multiple commands (notably the test binary)
42+
// reassigns this pointer while prior background goroutines and the PostRun
43+
// consumer may still touch it.
44+
var (
45+
updateCheckMu sync.Mutex
46+
updateCheckResult chan string
47+
)
48+
49+
// startUpdateAdvisory installs a fresh advisory channel for the current command
50+
// and runs check in the background, delivering a non-empty advisory message (if
51+
// any) exactly once. Each call owns its own channel: the goroutine sends to and
52+
// closes only that local channel, never the shared pointer, so overlapping
53+
// invocations can never send on or close another call's channel (the historical
54+
// "send on closed channel" panic). The channel is buffered (cap 1) so the send
55+
// never blocks even when no consumer is waiting.
56+
func startUpdateAdvisory(check func() (msg string, ok bool)) {
57+
ch := make(chan string, 1)
58+
updateCheckMu.Lock()
59+
updateCheckResult = ch
60+
updateCheckMu.Unlock()
61+
go func() {
62+
defer close(ch)
63+
if msg, ok := check(); ok && msg != "" {
64+
ch <- msg
65+
}
66+
}()
67+
}
68+
69+
// consumeUpdateAdvisory non-blockingly reads the pending advisory message for
70+
// the current command, returning "" when none is ready. Safe to call from any
71+
// goroutine.
72+
func consumeUpdateAdvisory() string {
73+
updateCheckMu.Lock()
74+
ch := updateCheckResult
75+
updateCheckMu.Unlock()
76+
if ch == nil {
77+
return ""
78+
}
79+
select {
80+
case msg := <-ch:
81+
return msg
82+
default:
83+
return ""
84+
}
85+
}
86+
87+
// checkForUpdateMessage performs the network update check and returns an
88+
// advisory string when a newer release is available. It records that a check
89+
// happened so ShouldCheckForUpdate throttles subsequent runs.
90+
func checkForUpdateMessage() (string, bool) {
91+
release, err := update.CheckLatest()
92+
if err != nil {
93+
return "", false
94+
}
95+
update.RecordUpdateCheck()
96+
latest, err := update.ParseVersion(release.TagName)
97+
if err != nil {
98+
return "", false
99+
}
100+
current, err := update.ParseVersion(version)
101+
if err != nil {
102+
return "", false
103+
}
104+
if !latest.IsNewerThan(current) {
105+
return "", false
106+
}
107+
return fmt.Sprintf(
108+
"\nUpdate available: v%s → v%s\nRun 'vulnetix update' to update.\n",
109+
current, latest,
110+
), true
111+
}
39112

40113
// rootCmd represents the base command when called without any subcommands
41114
var rootCmd = &cobra.Command{
@@ -53,15 +126,8 @@ vulnerabilities efficiently.`,
53126
})
54127
},
55128
PersistentPostRun: func(cmd *cobra.Command, args []string) {
56-
if updateCheckResult == nil {
57-
return
58-
}
59-
select {
60-
case msg := <-updateCheckResult:
61-
if msg != "" {
62-
fmt.Fprint(os.Stderr, msg)
63-
}
64-
default:
129+
if msg := consumeUpdateAdvisory(); msg != "" {
130+
fmt.Fprint(os.Stderr, msg)
65131
}
66132
},
67133
RunE: func(cmd *cobra.Command, args []string) error {
@@ -327,29 +393,7 @@ func startupHooks() {
327393
return
328394
}
329395

330-
updateCheckResult = make(chan string, 1)
331-
go func() {
332-
defer close(updateCheckResult)
333-
release, err := update.CheckLatest()
334-
if err != nil {
335-
return
336-
}
337-
update.RecordUpdateCheck()
338-
latest, err := update.ParseVersion(release.TagName)
339-
if err != nil {
340-
return
341-
}
342-
current, err := update.ParseVersion(version)
343-
if err != nil {
344-
return
345-
}
346-
if latest.IsNewerThan(current) {
347-
updateCheckResult <- fmt.Sprintf(
348-
"\nUpdate available: v%s → v%s\nRun 'vulnetix update' to update.\n",
349-
current, latest,
350-
)
351-
}
352-
}()
396+
startUpdateAdvisory(checkForUpdateMessage)
353397
}
354398

355399
func init() {

cmd/update_advisory_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package cmd
2+
3+
import (
4+
"sync"
5+
"testing"
6+
"time"
7+
)
8+
9+
// TestUpdateAdvisoryConcurrentInvocations reproduces the historical
10+
// "send on closed channel" panic: cobra.OnInitialize fires startupHooks once
11+
// per Execute, so a process running many commands (the test binary itself)
12+
// repeatedly reassigns updateCheckResult while prior background goroutines and
13+
// the PostRun consumer still touch it. The pre-fix code had each goroutine
14+
// close/send the shared package var, so one goroutine could close another's
15+
// channel. Each invocation now owns its own channel, so this must run cleanly
16+
// (and race-clean under `go test -race`).
17+
func TestUpdateAdvisoryConcurrentInvocations(t *testing.T) {
18+
const workers = 300
19+
var wg sync.WaitGroup
20+
wg.Add(workers)
21+
for i := 0; i < workers; i++ {
22+
go func(i int) {
23+
defer wg.Done()
24+
// Half the invocations produce an advisory, half don't — exercising
25+
// both the send and the close-only paths.
26+
startUpdateAdvisory(func() (string, bool) {
27+
return "update available", i%2 == 0
28+
})
29+
// Concurrently consume, as PersistentPostRun / vdb.go do.
30+
_ = consumeUpdateAdvisory()
31+
}(i)
32+
}
33+
wg.Wait()
34+
// Drain whatever the last invocation left, exercising the consumer once more.
35+
_ = consumeUpdateAdvisory()
36+
}
37+
38+
// TestUpdateAdvisoryDelivery verifies a produced message is delivered exactly
39+
// once and that a subsequent read returns empty (channel drained/closed).
40+
func TestUpdateAdvisoryDelivery(t *testing.T) {
41+
startUpdateAdvisory(func() (string, bool) {
42+
return "hello", true
43+
})
44+
// The background goroutine may not have sent yet; poll with a yield until
45+
// the message arrives or a deadline elapses. (consumeUpdateAdvisory is
46+
// non-blocking by contract, so the test does the waiting.)
47+
var got string
48+
deadline := time.Now().Add(2 * time.Second)
49+
for time.Now().Before(deadline) {
50+
if msg := consumeUpdateAdvisory(); msg != "" {
51+
got = msg
52+
break
53+
}
54+
time.Sleep(time.Millisecond)
55+
}
56+
if got != "hello" {
57+
t.Fatalf("expected advisory %q to be delivered, got %q", "hello", got)
58+
}
59+
// After consumption the buffered value is gone; a further read is empty.
60+
if msg := consumeUpdateAdvisory(); msg != "" {
61+
t.Errorf("expected no further advisory, got %q", msg)
62+
}
63+
}
64+
65+
// TestConsumeUpdateAdvisoryNilChannel confirms the consumer is safe before any
66+
// advisory channel has been installed.
67+
func TestConsumeUpdateAdvisoryNilChannel(t *testing.T) {
68+
updateCheckMu.Lock()
69+
updateCheckResult = nil
70+
updateCheckMu.Unlock()
71+
if msg := consumeUpdateAdvisory(); msg != "" {
72+
t.Errorf("expected empty advisory for nil channel, got %q", msg)
73+
}
74+
}

cmd/vdb.go

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -170,14 +170,8 @@ Examples:
170170
}
171171

172172
// Replicate rootCmd's PersistentPostRun (update check notification)
173-
if updateCheckResult != nil {
174-
select {
175-
case msg := <-updateCheckResult:
176-
if msg != "" {
177-
fmt.Fprint(os.Stderr, msg)
178-
}
179-
default:
180-
}
173+
if msg := consumeUpdateAdvisory(); msg != "" {
174+
fmt.Fprint(os.Stderr, msg)
181175
}
182176

183177
return nil

0 commit comments

Comments
 (0)