Skip to content

fix: command manager connect go routine leak - #869

Merged
hpidcock merged 4 commits into
canonical:masterfrom
hpidcock:connect-leak
Jun 9, 2026
Merged

hpidcock merged 4 commits into
canonical:masterfrom
hpidcock:connect-leak

Conversation

@hpidcock

@hpidcock hpidcock commented May 26, 2026

Copy link
Copy Markdown
Member

This is a port of a downstream project's fix to the command manager
connection handling logic. Previous to this fix, the connect method could
leak a go routine if the execution was found, but the connect request
was cancelled. The fix is to make the channel, which is sent on without a
select, a buffered channel of one.

This change also adds a regression test for the go routine leak.

Here is the test failure demonstrating the go routine leak.

$ GOEXPERIMENT=goroutineleakprofile go test -c ./internals/overlord/cmdstate
$ stress -fast -p 256 -count 1024 ./cmdstate.test

/tmp/nix-shell.L1vRQa/go-stress-20260529T151421-3792207636
OK: 1 passed
--- FAIL: Test (0.01s)
    goroutine 12 [chan send (leaked)]:
    github.com/canonical/pebble/internals/overlord/cmdstate.(*CommandManager).Connect.func2()
    	/home/hpidcock/work/pebble/internals/overlord/cmdstate/manager.go:77 +0x49
    created by github.com/canonical/pebble/internals/overlord/cmdstate.(*CommandManager).Connect in goroutine 11
    	/home/hpidcock/work/pebble/internals/overlord/cmdstate/manager.go:72 +0x148
FAIL

Comment on lines +110 to +138
// TestConnectContextCancelledGoroutineLeak demonstrates the goroutine leak in
// Connect. executionCh is unbuffered: if Connect exits via r.Context().Done()
// after waitExecution has already found a non-nil execution, the goroutine
// blocks forever trying to send on executionCh with no receiver.
//
// The test is probabilistic.
func (s *managerSuite) TestConnectContextCancelledGoroutineLeak(c *C) {
st := state.New(nil)
runner := state.NewTaskRunner(st)
mgr := cmdstate.NewManager(runner)

st.Lock()
task := st.NewTask("exec", "test cmd")
chg := st.NewChange("exec", "test change")
chg.AddTask(task)
st.Unlock()

// Pre-register the execution so waitExecution returns immediately
// with a non-nil value, before stopWait can be closed.
mgr.AddTestExecution(task.ID())

// Pre-cancel the context so Connect can exit via r.Context().Done()
// while the goroutine is still trying to send on executionCh.
ctx, cancel := context.WithCancel(context.Background())
cancel()

r := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
_ = mgr.Connect(r, httptest.NewRecorder(), task, "stdio")
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regression test for the go routine leak.

Comment on lines -71 to 79
executionCh := make(chan *execution)
executionCh := make(chan *execution, 1)
go func() {
e := m.waitExecution(task.ID(), stopWait)
if e != nil {
// executionCh is buffered to avoid a goroutine leak when this is
// blocked indefinitely.
executionCh <- e
}
}()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix for the go routine leak.

@hpidcock
hpidcock requested a review from benhoyt May 28, 2026 02:02

@benhoyt benhoyt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. Per voice discussion, let's make a couple of changes here:

  • Let's split the PR into just the gouroutine leak fix (and associated test), and all the other tests. Then we can evaluate the latter tests independently.
  • I'd still like to avoid the additional goleak third party dependency if at all possible. I found https://go.dev/doc/go1.26#goroutineleak-profiles, which is introduced in Go 1.26 as an experimental feature. Can we try that?

@benhoyt

benhoyt commented May 28, 2026

Copy link
Copy Markdown
Contributor

Looks like you might be able to do this with the recent stdlib testing/synctest package, too. See https://antonz.org/detecting-goroutine-leaks/#detecting-the-leak-synctest (blog post also mentions the new goroutineleak pprof).

@hpidcock
hpidcock requested a review from benhoyt May 29, 2026 05:16
@benhoyt

benhoyt commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Thanks for this, much better, and neat to be able to use synctest. Can we make this specific test a top-level test (rather than a check/suite one) so we only use synctest.Run in the one test, not around the whole package? (For when we add more tests to this package later.)

Additionally, I've found I can only repro this one in every 5000 times (go test -count=5000 ./internals/overlord/cmdstate/). I know it's a probabilistic test, but is there a way we can get that down to one in 5 or so, so it has a decent chance of happening during normal test runs?

@hpidcock

hpidcock commented Jun 2, 2026

Copy link
Copy Markdown
Member Author

Thanks for this, much better, and neat to be able to use synctest. Can we make this specific test a top-level test (rather than a check/suite one) so we only use synctest.Run in the one test, not around the whole package? (For when we add more tests to this package later.)

Not without moving to canonical/tc. Whole package is the best way to give the clearest results anyway IMO. See #877.

Additionally, I've found I can only repro this one in every 5000 times (go test -count=5000 ./internals/overlord/cmdstate/). I know it's a probabilistic test, but is there a way we can get that down to one in 5 or so, so it has a decent chance of happening during normal test runs?

See #878 on how I think we can deal with this. This is how I weeded out a large portion
of flaky tests out of Juju.

@benhoyt benhoyt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, sounds good. Let's push ahead with this and try the stress test thing -- that's a good idea in any case.

@hpidcock
hpidcock merged commit b43995f into canonical:master Jun 9, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants