fix(daemon): close tab creation race
- commit
- 82284b7e1a346b25215250b9b757ee53ebac7048
- author
- brice · · 2026-07-03 12:20 UTC
- parent
- 7281253
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +63 -2
M internal/usecase/daemon/session.go +13 -2
| @@ -107,17 +107,28 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 107 | 107 | ||
| 108 | 108 | func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |
| 109 | 109 | tbSize := tabSize(sz) | |
| 110 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), sess.cwd, tbSize) | |
| 110 | + | sess.mu.Lock() | |
| 111 | + | name := sess.name | |
| 112 | + | cwd := sess.cwd | |
| 113 | + | sess.mu.Unlock() | |
| 114 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize) | |
| 111 | 115 | if err != nil { | |
| 112 | - | return fmt.Errorf("daemon: spawning tab for session %q: %w", sess.name, err) | |
| 116 | + | return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err) | |
| 113 | 117 | } | |
| 114 | 118 | tb := newTab(pty, tbSize) | |
| 119 | + | d.mu.Lock() | |
| 120 | + | if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil { | |
| 121 | + | d.mu.Unlock() | |
| 122 | + | _ = pty.Close() | |
| 123 | + | return errors.New("daemon: session closed") | |
| 124 | + | } | |
| 115 | 125 | tb.ctx, tb.cancel = context.WithCancel(sess.ctx) | |
| 116 | 126 | sess.mu.Lock() | |
| 117 | 127 | sess.tabs = append(sess.tabs, tb) | |
| 118 | 128 | sess.active = len(sess.tabs) - 1 | |
| 119 | 129 | sess.mu.Unlock() | |
| 120 | 130 | d.startTabGoroutines(sess, tb) | |
| 131 | + | d.mu.Unlock() | |
| 121 | 132 | return nil | |
| 122 | 133 | } | |
| 123 | 134 | ||
| @@ -107,17 +107,28 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||||
| 107 | 107 | ||||
| 108 | func (d *Daemon) createTab(sess *session, sz domain.Size) error { | 108 | func (d *Daemon) createTab(sess *session, sz domain.Size) error { | ||
| 109 | tbSize := tabSize(sz) | 109 | tbSize := tabSize(sz) | ||
| 110 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), sess.cwd, tbSize) | 110 | + | sess.mu.Lock() |
| 111 | + | name := sess.name | |||
| 112 | + | cwd := sess.cwd | |||
| 113 | + | sess.mu.Unlock() | |||
| 114 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize) | |||
| 111 | if err != nil { | 115 | if err != nil { | ||
| 112 | - | return fmt.Errorf("daemon: spawning tab for session %q: %w", sess.name, err) | 116 | + | return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err) |
| 113 | } | 117 | } | ||
| 114 | tb := newTab(pty, tbSize) | 118 | tb := newTab(pty, tbSize) | ||
| 119 | + | d.mu.Lock() | |||
| 120 | + | if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil { | |||
| 121 | + | d.mu.Unlock() | |||
| 122 | + | _ = pty.Close() | |||
| 123 | + | return errors.New("daemon: session closed") | |||
| 124 | + | } | |||
| 115 | tb.ctx, tb.cancel = context.WithCancel(sess.ctx) | 125 | tb.ctx, tb.cancel = context.WithCancel(sess.ctx) | ||
| 116 | sess.mu.Lock() | 126 | sess.mu.Lock() | ||
| 117 | sess.tabs = append(sess.tabs, tb) | 127 | sess.tabs = append(sess.tabs, tb) | ||
| 118 | sess.active = len(sess.tabs) - 1 | 128 | sess.active = len(sess.tabs) - 1 | ||
| 119 | sess.mu.Unlock() | 129 | sess.mu.Unlock() | ||
| 120 | d.startTabGoroutines(sess, tb) | 130 | d.startTabGoroutines(sess, tb) | ||
| 131 | + | d.mu.Unlock() | |||
| 121 | return nil | 132 | return nil | ||
| 122 | } | 133 | } | ||
| 123 | 134 | ||||
M internal/usecase/daemon/session_test.go +50 -0
| @@ -179,6 +179,56 @@ func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) { | |||
| 179 | 179 | } | |
| 180 | 180 | } | |
| 181 | 181 | ||
| 182 | + | func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |
| 183 | + | p1, release1 := newBlockingPTY(t) | |
| 184 | + | defer release1() | |
| 185 | + | p2 := portsmocks.NewMockPTY(t) | |
| 186 | + | opened := make(chan struct{}) | |
| 187 | + | releaseOpen := make(chan struct{}) | |
| 188 | + | closed := make(chan struct{}) | |
| 189 | + | p2.EXPECT().Close().RunAndReturn(func() error { | |
| 190 | + | close(closed) | |
| 191 | + | return nil | |
| 192 | + | }).Once() | |
| 193 | + | p2.EXPECT().Pid().Return(4242).Maybe() | |
| 194 | + | ||
| 195 | + | f := portsmocks.NewMockPTYFactory(t) | |
| 196 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p1, nil).Once() | |
| 197 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 198 | + | func(string, []string, []string, string, domain.Size) (ports.PTY, error) { | |
| 199 | + | close(opened) | |
| 200 | + | <-releaseOpen | |
| 201 | + | return p2, nil | |
| 202 | + | }, | |
| 203 | + | ).Once() | |
| 204 | + | ||
| 205 | + | d := newTestDaemon(t, f, stubClock{}) | |
| 206 | + | tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24})) | |
| 207 | + | var hg sync.WaitGroup | |
| 208 | + | hg.Go(func() { d.handleConn(tr) }) | |
| 209 | + | awaitFrame(t, sends, ports.MsgWelcome) | |
| 210 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 211 | + | sess := firstSession(d) | |
| 212 | + | require.NotNil(t, sess) | |
| 213 | + | ||
| 214 | + | errCh := make(chan error, 1) | |
| 215 | + | go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }() | |
| 216 | + | <-opened | |
| 217 | + | d.killSession(sess, ports.ReasonSessionKilled) | |
| 218 | + | close(releaseOpen) | |
| 219 | + | ||
| 220 | + | require.Error(t, <-errCh) | |
| 221 | + | select { | |
| 222 | + | case <-closed: | |
| 223 | + | case <-time.After(time.Second): | |
| 224 | + | t.Fatal("new PTY was not closed after session died during tab creation") | |
| 225 | + | } | |
| 226 | + | require.Equal(t, 0, sessionCount(d)) | |
| 227 | + | releaseConn() | |
| 228 | + | hg.Wait() | |
| 229 | + | d.sessWg.Wait() | |
| 230 | + | } | |
| 231 | + | ||
| 182 | 232 | // --- ephemeral numbering ---------------------------------------------------- | |
| 183 | 233 | ||
| 184 | 234 | func TestEphemeralNumberingReuse(t *testing.T) { | |
| @@ -179,6 +179,56 @@ func TestKillAllEmptyDaemonSignalsShutdown(t *testing.T) { | |||||
| 179 | } | 179 | } | ||
| 180 | } | 180 | } | ||
| 181 | 181 | ||||
| 182 | + | func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |||
| 183 | + | p1, release1 := newBlockingPTY(t) | |||
| 184 | + | defer release1() | |||
| 185 | + | p2 := portsmocks.NewMockPTY(t) | |||
| 186 | + | opened := make(chan struct{}) | |||
| 187 | + | releaseOpen := make(chan struct{}) | |||
| 188 | + | closed := make(chan struct{}) | |||
| 189 | + | p2.EXPECT().Close().RunAndReturn(func() error { | |||
| 190 | + | close(closed) | |||
| 191 | + | return nil | |||
| 192 | + | }).Once() | |||
| 193 | + | p2.EXPECT().Pid().Return(4242).Maybe() | |||
| 194 | + | ||||
| 195 | + | f := portsmocks.NewMockPTYFactory(t) | |||
| 196 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p1, nil).Once() | |||
| 197 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 198 | + | func(string, []string, []string, string, domain.Size) (ports.PTY, error) { | |||
| 199 | + | close(opened) | |||
| 200 | + | <-releaseOpen | |||
| 201 | + | return p2, nil | |||
| 202 | + | }, | |||
| 203 | + | ).Once() | |||
| 204 | + | ||||
| 205 | + | d := newTestDaemon(t, f, stubClock{}) | |||
| 206 | + | tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24})) | |||
| 207 | + | var hg sync.WaitGroup | |||
| 208 | + | hg.Go(func() { d.handleConn(tr) }) | |||
| 209 | + | awaitFrame(t, sends, ports.MsgWelcome) | |||
| 210 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 211 | + | sess := firstSession(d) | |||
| 212 | + | require.NotNil(t, sess) | |||
| 213 | + | ||||
| 214 | + | errCh := make(chan error, 1) | |||
| 215 | + | go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }() | |||
| 216 | + | <-opened | |||
| 217 | + | d.killSession(sess, ports.ReasonSessionKilled) | |||
| 218 | + | close(releaseOpen) | |||
| 219 | + | ||||
| 220 | + | require.Error(t, <-errCh) | |||
| 221 | + | select { | |||
| 222 | + | case <-closed: | |||
| 223 | + | case <-time.After(time.Second): | |||
| 224 | + | t.Fatal("new PTY was not closed after session died during tab creation") | |||
| 225 | + | } | |||
| 226 | + | require.Equal(t, 0, sessionCount(d)) | |||
| 227 | + | releaseConn() | |||
| 228 | + | hg.Wait() | |||
| 229 | + | d.sessWg.Wait() | |||
| 230 | + | } | |||
| 231 | + | ||||
| 182 | // --- ephemeral numbering ---------------------------------------------------- | 232 | // --- ephemeral numbering ---------------------------------------------------- | ||
| 183 | 233 | ||||
| 184 | func TestEphemeralNumberingReuse(t *testing.T) { | 234 | func TestEphemeralNumberingReuse(t *testing.T) { | ||