From 0220e362f810bb6b449c8a0f9a2c579b13c4825d Mon Sep 17 00:00:00 2001 From: simonfaltum Date: Tue, 9 Jun 2026 22:21:33 +0200 Subject: [PATCH 1/2] Fix deadlock in cmdio tea program queueing acquireTeaProgram blocked on teaDone while holding teaMu, but releaseTeaProgram needs teaMu to close that channel, so queueing a second tea.Program behind an active one deadlocked both goroutines. Wait with the lock released and re-check in a loop before registering. Co-authored-by: Isaac --- libs/cmdio/io.go | 12 ++++++++++-- libs/cmdio/io_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 libs/cmdio/io_test.go diff --git a/libs/cmdio/io.go b/libs/cmdio/io.go index a25062fcd3f..4fe3c38359d 100644 --- a/libs/cmdio/io.go +++ b/libs/cmdio/io.go @@ -156,8 +156,16 @@ func (c *cmdIO) acquireTeaProgram(p *tea.Program) { defer c.teaMu.Unlock() // Wait for existing program to finish - if c.teaDone != nil { - <-c.teaDone + // + // The channel receive must happen with teaMu released: releaseTeaProgram + // locks teaMu to close teaDone, so waiting while holding the lock would + // deadlock both goroutines. Re-check in a loop because another acquirer + // may register a new program before this one reacquires the lock. + for c.teaDone != nil { + done := c.teaDone + c.teaMu.Unlock() + <-done + c.teaMu.Lock() } // Register new program diff --git a/libs/cmdio/io_test.go b/libs/cmdio/io_test.go new file mode 100644 index 00000000000..af29d13909b --- /dev/null +++ b/libs/cmdio/io_test.go @@ -0,0 +1,39 @@ +package cmdio + +import ( + "testing" + "time" +) + +func TestAcquireTeaProgramWaitsForRelease(t *testing.T) { + c := &cmdIO{} + // acquireTeaProgram only stores the pointer, so nil is enough to exercise + // the queueing without running a real tea.Program. + c.acquireTeaProgram(nil) + + acquired := make(chan struct{}) + go func() { + c.acquireTeaProgram(nil) + close(acquired) + }() + + // The second acquire must queue behind the active program. + select { + case <-acquired: + t.Fatal("second acquireTeaProgram returned while the first program was still active") + case <-time.After(50 * time.Millisecond): + } + + // Release on a separate goroutine: the original implementation deadlocked + // here because the waiter held teaMu while blocked on teaDone, which + // releaseTeaProgram needs to close it. + go c.releaseTeaProgram() + + select { + case <-acquired: + case <-time.After(5 * time.Second): + t.Fatal("second acquireTeaProgram did not return after the first program was released") + } + + c.releaseTeaProgram() +} From 4d5c4c47416a09822889f25dae6740aee5eba911 Mon Sep 17 00:00:00 2001 From: simonfaltum Date: Wed, 10 Jun 2026 00:19:31 +0200 Subject: [PATCH 2/2] Remove redundant comments Co-authored-by: Isaac --- libs/cmdio/io.go | 8 +++----- libs/cmdio/io_test.go | 9 +++------ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/libs/cmdio/io.go b/libs/cmdio/io.go index 4fe3c38359d..e759f237035 100644 --- a/libs/cmdio/io.go +++ b/libs/cmdio/io.go @@ -156,11 +156,9 @@ func (c *cmdIO) acquireTeaProgram(p *tea.Program) { defer c.teaMu.Unlock() // Wait for existing program to finish - // - // The channel receive must happen with teaMu released: releaseTeaProgram - // locks teaMu to close teaDone, so waiting while holding the lock would - // deadlock both goroutines. Re-check in a loop because another acquirer - // may register a new program before this one reacquires the lock. + // Receive with teaMu released: releaseTeaProgram locks teaMu to close + // teaDone, so waiting while holding it would deadlock. Loop because another + // acquirer may register a new program before the lock is reacquired. for c.teaDone != nil { done := c.teaDone c.teaMu.Unlock() diff --git a/libs/cmdio/io_test.go b/libs/cmdio/io_test.go index af29d13909b..23872d5f95c 100644 --- a/libs/cmdio/io_test.go +++ b/libs/cmdio/io_test.go @@ -7,8 +7,7 @@ import ( func TestAcquireTeaProgramWaitsForRelease(t *testing.T) { c := &cmdIO{} - // acquireTeaProgram only stores the pointer, so nil is enough to exercise - // the queueing without running a real tea.Program. + // acquireTeaProgram only stores the pointer, so a nil program suffices. c.acquireTeaProgram(nil) acquired := make(chan struct{}) @@ -17,16 +16,14 @@ func TestAcquireTeaProgramWaitsForRelease(t *testing.T) { close(acquired) }() - // The second acquire must queue behind the active program. select { case <-acquired: t.Fatal("second acquireTeaProgram returned while the first program was still active") case <-time.After(50 * time.Millisecond): } - // Release on a separate goroutine: the original implementation deadlocked - // here because the waiter held teaMu while blocked on teaDone, which - // releaseTeaProgram needs to close it. + // Release on a goroutine so a regressed deadlock fails the timeout below + // instead of hanging the test. go c.releaseTeaProgram() select {