fix(daemon): surface dropped input
writeToPane logged pty write failures but the user's keystrokes just vanished. Notify on failure so a wedged pty or dead child surfaces as a NoticeInputDropped toast; coalescing collapses repeated failures on the same code into one toast with a rising count instead of spamming.
- commit
- a2b1d8632d117998a03e341b11f1ae2148cffb0b
- author
- brice · · 2026-07-21 04:56 UTC
- parent
- 0f2b5db
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +49 -0
M internal/usecase/daemon/daemon_test.go +20 -0
| @@ -105,6 +105,26 @@ func newBlockingPTYWithWrites(t *testing.T, writes chan<- []byte) (*portsmocks.M | |||
| 105 | 105 | return p, unblock | |
| 106 | 106 | } | |
| 107 | 107 | ||
| 108 | + | // newBlockingPTYWithFailingWrite returns a MockPTY whose Write always fails | |
| 109 | + | // with writeErr, simulating a wedged pty (or a dead child) that cannot | |
| 110 | + | // accept forwarded keystrokes. | |
| 111 | + | func newBlockingPTYWithFailingWrite(t *testing.T, writeErr error) (*portsmocks.MockPTY, func()) { | |
| 112 | + | t.Helper() | |
| 113 | + | p := portsmocks.NewMockPTY(t) | |
| 114 | + | release := make(chan struct{}) | |
| 115 | + | var once sync.Once | |
| 116 | + | unblock := func() { once.Do(func() { close(release) }) } | |
| 117 | + | p.EXPECT().Read(mock.Anything).RunAndReturn(func(b []byte) (int, error) { | |
| 118 | + | <-release | |
| 119 | + | return 0, io.EOF | |
| 120 | + | }).Maybe() | |
| 121 | + | p.EXPECT().Write(mock.Anything).Return(0, writeErr).Maybe() | |
| 122 | + | p.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |
| 123 | + | p.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Maybe() | |
| 124 | + | p.EXPECT().Pid().Return(4242).Maybe() | |
| 125 | + | return p, unblock | |
| 126 | + | } | |
| 127 | + | ||
| 108 | 128 | // quietPTY is an independently closable PTY for background floating prewarms. | |
| 109 | 129 | // It intentionally has no testify expectations: tests using the factories below | |
| 110 | 130 | // are asserting their regular panes, not incidental asynchronous prewarms. | |
| @@ -105,6 +105,26 @@ func newBlockingPTYWithWrites(t *testing.T, writes chan<- []byte) (*portsmocks.M | |||||
| 105 | return p, unblock | 105 | return p, unblock | ||
| 106 | } | 106 | } | ||
| 107 | 107 | ||||
| 108 | + | // newBlockingPTYWithFailingWrite returns a MockPTY whose Write always fails | |||
| 109 | + | // with writeErr, simulating a wedged pty (or a dead child) that cannot | |||
| 110 | + | // accept forwarded keystrokes. | |||
| 111 | + | func newBlockingPTYWithFailingWrite(t *testing.T, writeErr error) (*portsmocks.MockPTY, func()) { | |||
| 112 | + | t.Helper() | |||
| 113 | + | p := portsmocks.NewMockPTY(t) | |||
| 114 | + | release := make(chan struct{}) | |||
| 115 | + | var once sync.Once | |||
| 116 | + | unblock := func() { once.Do(func() { close(release) }) } | |||
| 117 | + | p.EXPECT().Read(mock.Anything).RunAndReturn(func(b []byte) (int, error) { | |||
| 118 | + | <-release | |||
| 119 | + | return 0, io.EOF | |||
| 120 | + | }).Maybe() | |||
| 121 | + | p.EXPECT().Write(mock.Anything).Return(0, writeErr).Maybe() | |||
| 122 | + | p.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |||
| 123 | + | p.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Maybe() | |||
| 124 | + | p.EXPECT().Pid().Return(4242).Maybe() | |||
| 125 | + | return p, unblock | |||
| 126 | + | } | |||
| 127 | + | ||||
| 108 | // quietPTY is an independently closable PTY for background floating prewarms. | 128 | // quietPTY is an independently closable PTY for background floating prewarms. | ||
| 109 | // It intentionally has no testify expectations: tests using the factories below | 129 | // It intentionally has no testify expectations: tests using the factories below | ||
| 110 | // are asserting their regular panes, not incidental asynchronous prewarms. | 130 | // are asserting their regular panes, not incidental asynchronous prewarms. | ||
M internal/usecase/daemon/input.go +3 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "bytes" | |
| 6 | 6 | "strconv" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/usecase/keys" | |
| 9 | 10 | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/mouse" | |
| @@ -212,6 +213,8 @@ func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) { | |||
| 212 | 213 | sess.mu.Unlock() | |
| 213 | 214 | } | |
| 214 | 215 | d.log.Error("pty write failed", "err", err, "session", name) | |
| 216 | + | d.notify(sess, domain.NoticeError, domain.NoticeInputDropped, | |
| 217 | + | "input not delivered to pane", err) | |
| 215 | 218 | } | |
| 216 | 219 | } | |
| 217 | 220 | ||
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "bytes" | 5 | "bytes" | ||
| 6 | "strconv" | 6 | "strconv" | ||
| 7 | 7 | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | "github.com/bnema/vev/internal/usecase/keys" | 9 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 9 | "github.com/bnema/vev/internal/usecase/layout" | 10 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 10 | "github.com/bnema/vev/internal/usecase/mouse" | 11 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| @@ -212,6 +213,8 @@ func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) { | |||||
| 212 | sess.mu.Unlock() | 213 | sess.mu.Unlock() | ||
| 213 | } | 214 | } | ||
| 214 | d.log.Error("pty write failed", "err", err, "session", name) | 215 | d.log.Error("pty write failed", "err", err, "session", name) | ||
| 216 | + | d.notify(sess, domain.NoticeError, domain.NoticeInputDropped, | |||
| 217 | + | "input not delivered to pane", err) | |||
| 215 | } | 218 | } | ||
| 216 | } | 219 | } | ||
| 217 | 220 | ||||
M internal/usecase/daemon/input_test.go +26 -0
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/base64" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "sync" | |
| @@ -172,6 +173,31 @@ func TestAltCForwardsToPTY(t *testing.T) { | |||
| 172 | 173 | releasePTY() | |
| 173 | 174 | } | |
| 174 | 175 | ||
| 176 | + | // TestWriteToPaneFailureNotifiesDroppedInput drives forwarded keystrokes into | |
| 177 | + | // a pane whose pty.Write always fails (a wedged pty, or a dead child). The | |
| 178 | + | // user's typing must not silently vanish: each failed write records a | |
| 179 | + | // NoticeInputDropped notice, and repeated failures on the same code coalesce | |
| 180 | + | // into a single toast with a rising count rather than spamming one per key. | |
| 181 | + | func TestWriteToPaneFailureNotifiesDroppedInput(t *testing.T) { | |
| 182 | + | writeErr := errors.New("write /dev/ptmx: input/output error") | |
| 183 | + | p, releasePTY := newBlockingPTYWithFailingWrite(t, writeErr) | |
| 184 | + | defer releasePTY() | |
| 185 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 186 | + | ||
| 187 | + | for range 5 { | |
| 188 | + | d.handleInput(sess, ac, []byte("x")) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | toasts := awaitToastCount(t, ac, 1) | |
| 192 | + | require.Equal(t, domain.NoticeInputDropped, toasts[0].Code) | |
| 193 | + | require.Equal(t, domain.NoticeError, toasts[0].Severity) | |
| 194 | + | require.Equal(t, "input not delivered to pane", toasts[0].Message) | |
| 195 | + | require.Equal(t, 5, toasts[0].Count, "5 failed writes must coalesce into one toast, not one per keystroke") | |
| 196 | + | ||
| 197 | + | hist := d.notices.history() | |
| 198 | + | require.Len(t, hist, 5, "coalescing is display-only; history keeps every occurrence") | |
| 199 | + | } | |
| 200 | + | ||
| 175 | 201 | func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) { | |
| 176 | 202 | normal, releaseNormal := newBlockingPTY(t) | |
| 177 | 203 | floatingPTY, releaseFloating := newBlockingPTY(t) | |
| @@ -3,6 +3,7 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/base64" | 5 | "encoding/base64" | ||
| 6 | + | "errors" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "strings" | 8 | "strings" | ||
| 8 | "sync" | 9 | "sync" | ||
| @@ -172,6 +173,31 @@ func TestAltCForwardsToPTY(t *testing.T) { | |||||
| 172 | releasePTY() | 173 | releasePTY() | ||
| 173 | } | 174 | } | ||
| 174 | 175 | ||||
| 176 | + | // TestWriteToPaneFailureNotifiesDroppedInput drives forwarded keystrokes into | |||
| 177 | + | // a pane whose pty.Write always fails (a wedged pty, or a dead child). The | |||
| 178 | + | // user's typing must not silently vanish: each failed write records a | |||
| 179 | + | // NoticeInputDropped notice, and repeated failures on the same code coalesce | |||
| 180 | + | // into a single toast with a rising count rather than spamming one per key. | |||
| 181 | + | func TestWriteToPaneFailureNotifiesDroppedInput(t *testing.T) { | |||
| 182 | + | writeErr := errors.New("write /dev/ptmx: input/output error") | |||
| 183 | + | p, releasePTY := newBlockingPTYWithFailingWrite(t, writeErr) | |||
| 184 | + | defer releasePTY() | |||
| 185 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 186 | + | ||||
| 187 | + | for range 5 { | |||
| 188 | + | d.handleInput(sess, ac, []byte("x")) | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | toasts := awaitToastCount(t, ac, 1) | |||
| 192 | + | require.Equal(t, domain.NoticeInputDropped, toasts[0].Code) | |||
| 193 | + | require.Equal(t, domain.NoticeError, toasts[0].Severity) | |||
| 194 | + | require.Equal(t, "input not delivered to pane", toasts[0].Message) | |||
| 195 | + | require.Equal(t, 5, toasts[0].Count, "5 failed writes must coalesce into one toast, not one per keystroke") | |||
| 196 | + | ||||
| 197 | + | hist := d.notices.history() | |||
| 198 | + | require.Len(t, hist, 5, "coalescing is display-only; history keeps every occurrence") | |||
| 199 | + | } | |||
| 200 | + | ||||
| 175 | func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) { | 201 | func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) { | ||
| 176 | normal, releaseNormal := newBlockingPTY(t) | 202 | normal, releaseNormal := newBlockingPTY(t) | ||
| 177 | floatingPTY, releaseFloating := newBlockingPTY(t) | 203 | floatingPTY, releaseFloating := newBlockingPTY(t) | ||