Skip to content

Commit fa2e69b

Browse files
authored
[fix] Close goroutine leak in update hint + add exec cobra-level test seam (#301)
* [fix] Close goroutine leak in update hint and add exec cobra-level test seam (#287) Finding 1: checkUpdateHint previously called u.Check(context.Background()), so the inner HTTP goroutine kept running for up to the updater's 30s client timeout after the outer 2s window elapsed. Fix: accept ctx context.Context as the first param, derive context.WithTimeout(ctx, timeout) for the request, and pass tctx to u.Check so the HTTP round-trip is bounded by both the caller's context and the timeout. Also reset rootCmd.ctx in TestExecute's cleanup to prevent the post-Execute cancelled signal-context from leaking into subsequent tests. Finding 2: execCmd.RunE had no injectable constructor, so a refactor breaking the flag-parse→filter→executor wiring could only be caught by the e2e suite. Extract runExec(cmd, args, r git.Runner, execFn execRunner) and add buildExecCmd mirroring the buildTrustCmd pattern (trust.go:49). Both production RunE and the new constructor delegate to runExec, so tests exercise the real wiring. Add four cobra-level tests: missing-target-flag, executor-invoked, JSON-output, no-matching-worktrees. * [fix] Address review feedback: cancel relay goroutine, unified exec flag registration, t.Fatal - Add defer cancel() to relay goroutine in checkUpdateHint so the context timer is released promptly on all exit paths, not just the happy path - Extract addExecFlags() helper shared by init() and buildExecCmd() so both flag-registration sites stay in sync and can't silently diverge - Change t.Error to t.Fatal in cancelled-context HTTP handler assertion * [fix] Change t.Error to t.Fatal in TestCheckUpdateHintDevVersion handler
1 parent 25d2c42 commit fa2e69b

6 files changed

Lines changed: 270 additions & 52 deletions

File tree

cmd/exec.go

Lines changed: 68 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ const (
3333
hintConcurrency = "Limit the number of parallel executions"
3434
)
3535

36+
// execRunner is the injectable executor function type, matching executor.Run.
37+
type execRunner func(context.Context, executor.Config) []executor.Result
38+
3639
var execCmd = &cobra.Command{
3740
Use: "exec <command>",
3841
Short: "Run a shell command across worktrees",
@@ -41,48 +44,78 @@ var execCmd = &cobra.Command{
4144
rimba exec --type bugfix "npm test"`,
4245
Args: cobra.ExactArgs(1),
4346
RunE: func(cmd *cobra.Command, args []string) error {
44-
opts := execReadFlags(cmd)
45-
if err := execValidateFlags(opts); err != nil {
46-
return err
47-
}
47+
return runExec(cmd, args, newRunner(), executor.Run)
48+
},
49+
}
4850

49-
execShowHints(cmd)
51+
// runExec is the shared implementation for the exec command, accepting
52+
// injected runner and executor so both production and tests use the same path.
53+
func runExec(cmd *cobra.Command, args []string, r git.Runner, execFn execRunner) error {
54+
opts := execReadFlags(cmd)
55+
if err := execValidateFlags(opts); err != nil {
56+
return err
57+
}
5058

51-
r := newRunner()
52-
s := spinner.New(spinnerOpts(cmd))
53-
defer s.Stop()
54-
s.Start("Collecting worktrees...")
59+
execShowHints(cmd)
5560

56-
prefixes := resolver.AllPrefixes()
57-
filtered, err := execSelectWorktrees(cmd, r, s, opts, prefixes)
58-
if err != nil {
59-
return err
60-
}
61+
s := spinner.New(spinnerOpts(cmd))
62+
defer s.Stop()
63+
s.Start("Collecting worktrees...")
6164

62-
if len(filtered) == 0 {
63-
s.Stop()
64-
fmt.Fprintln(cmd.OutOrStdout(), "No worktrees match the given filters.")
65-
return nil
66-
}
65+
prefixes := resolver.AllPrefixes()
66+
filtered, err := execSelectWorktrees(cmd, r, s, opts, prefixes)
67+
if err != nil {
68+
return err
69+
}
70+
71+
if len(filtered) == 0 {
72+
s.Stop()
73+
fmt.Fprintln(cmd.OutOrStdout(), "No worktrees match the given filters.")
74+
return nil
75+
}
6776

68-
targets := execBuildTargets(filtered, prefixes)
69-
s.Update(fmt.Sprintf("Running in %d worktree(s)...", len(targets)))
77+
targets := execBuildTargets(filtered, prefixes)
78+
s.Update(fmt.Sprintf("Running in %d worktree(s)...", len(targets)))
7079

71-
results := executor.Run(cmd.Context(), executor.Config{
72-
Targets: targets,
73-
Command: args[0],
74-
Concurrency: opts.concurrency,
75-
FailFast: opts.failFast,
76-
Runner: executor.ShellRunner(),
77-
})
80+
results := execFn(cmd.Context(), executor.Config{
81+
Targets: targets,
82+
Command: args[0],
83+
Concurrency: opts.concurrency,
84+
FailFast: opts.failFast,
85+
Runner: executor.ShellRunner(),
86+
})
7887

79-
s.Stop()
88+
s.Stop()
8089

81-
if isJSON(cmd) {
82-
return execRenderJSON(cmd, args[0], results)
83-
}
84-
return execRenderText(cmd, results, prefixes)
85-
},
90+
if isJSON(cmd) {
91+
return execRenderJSON(cmd, args[0], results)
92+
}
93+
return execRenderText(cmd, results, prefixes)
94+
}
95+
96+
// addExecFlags registers exec-specific flags on c, shared by execCmd and buildExecCmd.
97+
func addExecFlags(c *cobra.Command) {
98+
c.Flags().Bool(flagAll, false, "run in all eligible worktrees")
99+
c.Flags().String(flagType, "", "filter by prefix type (e.g. feature, bugfix)")
100+
c.Flags().Bool(flagDirty, false, "run only in dirty worktrees")
101+
c.Flags().Bool(flagFailFast, false, "stop after the first failure")
102+
c.Flags().Int(flagConcurrency, 0, "max parallel executions (0 = unlimited)")
103+
}
104+
105+
// buildExecCmd constructs a testable exec command with injected deps.
106+
// Used by exec_test.go to exercise the full flag-parse → filter → executor pipeline.
107+
func buildExecCmd(r git.Runner, execFn execRunner) *cobra.Command {
108+
c := &cobra.Command{
109+
Use: "exec <command>",
110+
Args: cobra.ExactArgs(1),
111+
RunE: func(cmd *cobra.Command, args []string) error {
112+
return runExec(cmd, args, r, execFn)
113+
},
114+
}
115+
addExecFlags(c)
116+
c.Flags().Bool(flagJSON, false, "")
117+
c.Flags().Bool(flagNoColor, false, "")
118+
return c
86119
}
87120

88121
// execOpts holds parsed exec flags.
@@ -221,14 +254,8 @@ func execRenderText(cmd *cobra.Command, results []executor.Result, prefixes []st
221254
}
222255

223256
func init() {
224-
execCmd.Flags().Bool(flagAll, false, "run in all eligible worktrees")
225-
execCmd.Flags().String(flagType, "", "filter by prefix type (e.g. feature, bugfix)")
226-
execCmd.Flags().Bool(flagDirty, false, "run only in dirty worktrees")
227-
execCmd.Flags().Bool(flagFailFast, false, "stop after the first failure")
228-
execCmd.Flags().Int(flagConcurrency, 0, "max parallel executions (0 = unlimited)")
229-
257+
addExecFlags(execCmd)
230258
_ = execCmd.RegisterFlagCompletionFunc(flagType, typeFilterCompletion())
231-
232259
rootCmd.AddCommand(execCmd)
233260
}
234261

cmd/exec_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/lugassawan/rimba/internal/config"
1212
"github.com/lugassawan/rimba/internal/executor"
13+
"github.com/lugassawan/rimba/internal/git"
1314
"github.com/lugassawan/rimba/internal/output"
1415
"github.com/lugassawan/rimba/internal/resolver"
1516
"github.com/lugassawan/rimba/internal/spinner"
@@ -550,3 +551,150 @@ func TestExecShowHintsJSONEarlyReturn(t *testing.T) {
550551
t.Errorf("expected no output in JSON mode, got %q", buf.String())
551552
}
552553
}
554+
555+
// newExecCmd builds a testable exec command with injected runner and executor.
556+
func newExecCmd(r git.Runner, execFn execRunner) (*cobra.Command, *bytes.Buffer) {
557+
buf := &bytes.Buffer{}
558+
cfg := &config.Config{DefaultSource: "main"}
559+
c := buildExecCmd(r, execFn)
560+
c.SetOut(buf)
561+
c.SetErr(buf)
562+
c.SetContext(config.WithConfig(context.Background(), cfg))
563+
return c, buf
564+
}
565+
566+
func TestExecCmdMissingTargetFlag(t *testing.T) {
567+
r := &mockRunner{
568+
run: func(_ ...string) (string, error) { return "", nil },
569+
runInDir: noopRunInDir,
570+
}
571+
cmd, _ := newExecCmd(r, func(_ context.Context, _ executor.Config) []executor.Result { return nil })
572+
cmd.SetArgs([]string{"echo hi"})
573+
err := cmd.Execute()
574+
if err == nil {
575+
t.Fatal("expected error when neither --all nor --type is set")
576+
}
577+
if !strings.Contains(err.Error(), "provide --all or --type") {
578+
t.Errorf("error = %q, want 'provide --all or --type'", err.Error())
579+
}
580+
}
581+
582+
func TestExecCmdInvokesExecutor(t *testing.T) {
583+
porcelain := strings.Join([]string{
584+
"worktree /repo",
585+
"HEAD abc",
586+
"branch refs/heads/main",
587+
"",
588+
"worktree /wt/foo",
589+
"HEAD def",
590+
"branch refs/heads/feature/foo",
591+
"",
592+
}, "\n")
593+
r := &mockRunner{
594+
run: func(_ ...string) (string, error) { return porcelain, nil },
595+
runInDir: noopRunInDir,
596+
}
597+
598+
var capturedCfg executor.Config
599+
fakeExec := func(_ context.Context, cfg executor.Config) []executor.Result {
600+
capturedCfg = cfg
601+
return []executor.Result{
602+
{Target: executor.Target{Branch: "feature/foo", Task: "foo", Path: "/wt/foo"}, ExitCode: 0, Stdout: []byte("ok\n")},
603+
}
604+
}
605+
606+
cmd, buf := newExecCmd(r, fakeExec)
607+
cmd.SetArgs([]string{"--all", "--no-color", "echo ok"})
608+
if err := cmd.Execute(); err != nil {
609+
t.Fatalf("unexpected error: %v", err)
610+
}
611+
612+
if capturedCfg.Command != "echo ok" {
613+
t.Errorf("command = %q, want %q", capturedCfg.Command, "echo ok")
614+
}
615+
if len(capturedCfg.Targets) == 0 {
616+
t.Error("expected at least one target")
617+
}
618+
if !strings.Contains(buf.String(), "foo") {
619+
t.Errorf("output missing task name: %q", buf.String())
620+
}
621+
}
622+
623+
func TestExecCmdJSONOutput(t *testing.T) {
624+
porcelain := strings.Join([]string{
625+
"worktree /repo",
626+
"HEAD abc",
627+
"branch refs/heads/main",
628+
"",
629+
"worktree /wt/foo",
630+
"HEAD def",
631+
"branch refs/heads/feature/foo",
632+
"",
633+
}, "\n")
634+
r := &mockRunner{
635+
run: func(_ ...string) (string, error) { return porcelain, nil },
636+
runInDir: noopRunInDir,
637+
}
638+
fakeExec := func(_ context.Context, _ executor.Config) []executor.Result {
639+
return []executor.Result{
640+
{Target: executor.Target{Branch: "feature/foo", Task: "foo", Path: "/wt/foo"}, ExitCode: 0},
641+
}
642+
}
643+
644+
cmd, buf := newExecCmd(r, fakeExec)
645+
cmd.SetArgs([]string{"--all", "--json", "echo hi"})
646+
if err := cmd.Execute(); err != nil {
647+
t.Fatalf("unexpected error: %v", err)
648+
}
649+
650+
var env output.Envelope
651+
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
652+
t.Fatalf("invalid JSON: %v\noutput: %s", err, buf.String())
653+
}
654+
if env.Command != "exec" {
655+
t.Errorf("envelope command = %q, want %q", env.Command, "exec")
656+
}
657+
dataMap, ok := env.Data.(map[string]any)
658+
if !ok {
659+
t.Fatalf("data type = %T, want map[string]any", env.Data)
660+
}
661+
resultsArr, ok := dataMap["results"].([]any)
662+
if !ok {
663+
t.Fatalf("results type = %T, want []any", dataMap["results"])
664+
}
665+
if len(resultsArr) != 1 {
666+
t.Errorf("results length = %d, want 1", len(resultsArr))
667+
}
668+
}
669+
670+
func TestExecCmdNoMatchingWorktrees(t *testing.T) {
671+
// Only main worktree — no non-main worktrees to match.
672+
porcelain := strings.Join([]string{
673+
"worktree /repo",
674+
"HEAD abc",
675+
"branch refs/heads/main",
676+
"",
677+
}, "\n")
678+
r := &mockRunner{
679+
run: func(_ ...string) (string, error) { return porcelain, nil },
680+
runInDir: noopRunInDir,
681+
}
682+
called := false
683+
fakeExec := func(_ context.Context, _ executor.Config) []executor.Result {
684+
called = true
685+
return nil
686+
}
687+
688+
cmd, buf := newExecCmd(r, fakeExec)
689+
cmd.SetArgs([]string{"--all", "echo hi"})
690+
if err := cmd.Execute(); err != nil {
691+
t.Fatalf("unexpected error: %v", err)
692+
}
693+
694+
if called {
695+
t.Error("executor should not be called when no worktrees match")
696+
}
697+
if !strings.Contains(buf.String(), "No worktrees match") {
698+
t.Errorf("expected 'No worktrees match' in output, got: %q", buf.String())
699+
}
700+
}

cmd/root.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,13 @@ func init() {
110110
rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
111111
var hint <-chan *updater.CheckResult
112112
if cmd == rootCmd {
113-
hint = checkUpdateHint(version, 2*time.Second)
113+
ctx := cmd.Context()
114+
if ctx == nil {
115+
// cmd.Context() is nil when HelpFunc is called outside of
116+
// ExecuteContext (e.g., directly in tests).
117+
ctx = context.Background()
118+
}
119+
hint = checkUpdateHint(ctx, version, 2*time.Second)
114120
printBanner(cmd)
115121
}
116122
originalHelp(cmd, args)

cmd/root_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,10 @@ func TestExecute(t *testing.T) {
218218
t.Cleanup(func() {
219219
rootCmd.SetOut(nil)
220220
rootCmd.SetErr(nil)
221+
// Execute() sets rootCmd.ctx to a signal-notified context that is
222+
// cancelled when Execute returns (via defer stop()). Reset it so
223+
// subsequent tests that call cmd.Context() get a live context.
224+
rootCmd.SetContext(context.Background())
221225
})
222226

223227
if err := Execute(); err != nil {

cmd/update_hint.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,40 +14,47 @@ import (
1414
// Tests can override this to inject a mock server.
1515
var newUpdater func(string) *updater.Updater = updater.New
1616

17-
// checkUpdateHint runs a background version check with a timeout.
17+
// checkUpdateHint runs a background version check bounded by timeout.
1818
// Returns nil if the version is dev, the check fails, times out, or is up to date.
19-
func checkUpdateHint(version string, timeout time.Duration) <-chan *updater.CheckResult {
19+
// The check is cancelled when ctx is done or timeout elapses — whichever comes first.
20+
func checkUpdateHint(ctx context.Context, version string, timeout time.Duration) <-chan *updater.CheckResult {
2021
ch := make(chan *updater.CheckResult, 1)
2122

2223
if updater.IsDevVersion(version) {
2324
close(ch)
2425
return ch
2526
}
2627

28+
// Derive a timeout-scoped child so the HTTP request is actually cancelled
29+
// when timeout elapses, not just when the caller stops waiting.
30+
tctx, cancel := context.WithTimeout(ctx, timeout)
31+
2732
// Read newUpdater before spawning the goroutine: goroutine launch
2833
// establishes a happens-before edge, preventing a data race with
2934
// test overrides that restore the variable via t.Cleanup.
3035
u := newUpdater(version)
3136
go func() {
32-
result, err := u.Check(context.Background())
37+
defer cancel()
38+
result, err := u.Check(tctx)
3339
if err != nil || result.UpToDate {
3440
close(ch)
3541
return
3642
}
3743
ch <- result
3844
}()
3945

40-
// Return a channel that resolves with the result or nil after timeout
46+
// Return a channel that resolves with the result or nil when tctx expires.
4147
out := make(chan *updater.CheckResult, 1)
4248
go func() {
49+
defer cancel()
4350
select {
4451
case r, ok := <-ch:
4552
if ok {
4653
out <- r
4754
} else {
4855
close(out)
4956
}
50-
case <-time.After(timeout):
57+
case <-tctx.Done():
5158
close(out)
5259
}
5360
}()

0 commit comments

Comments
 (0)