diff --git a/libs/cmdio/io.go b/libs/cmdio/io.go index a25062fcd3f..e759f237035 100644 --- a/libs/cmdio/io.go +++ b/libs/cmdio/io.go @@ -156,8 +156,14 @@ func (c *cmdIO) acquireTeaProgram(p *tea.Program) { defer c.teaMu.Unlock() // Wait for existing program to finish - if c.teaDone != nil { - <-c.teaDone + // 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() + <-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..23872d5f95c --- /dev/null +++ b/libs/cmdio/io_test.go @@ -0,0 +1,36 @@ +package cmdio + +import ( + "testing" + "time" +) + +func TestAcquireTeaProgramWaitsForRelease(t *testing.T) { + c := &cmdIO{} + // acquireTeaProgram only stores the pointer, so a nil program suffices. + c.acquireTeaProgram(nil) + + acquired := make(chan struct{}) + go func() { + c.acquireTeaProgram(nil) + close(acquired) + }() + + select { + case <-acquired: + t.Fatal("second acquireTeaProgram returned while the first program was still active") + case <-time.After(50 * time.Millisecond): + } + + // Release on a goroutine so a regressed deadlock fails the timeout below + // instead of hanging the test. + 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() +}