paste_coalescer.go RawHistoryFolder
1package client
2
3import (
4 "bytes"
5 "sync"
6 "time"
7
8 "github.com/bnema/vev/internal/ports"
9)
10
11const (
12 // pasteFlushDelay bounds how long a trailing strict prefix of the opening
13 // marker is held before it is emitted as ordinary bytes. It only has to
14 // bridge a local kernel read split: the rest of the marker is already in
15 // the tty buffer and arrives on the next Read microseconds later. It is NOT
16 // meant to bridge a network-scale gap — once the opening marker completes,
17 // buffering mode holds the whole paste (up to the 2s safety timer), so a
18 // close-marker split at any realistic latency is covered by whole-paste
19 // framing rather than this timer. Kept well under the daemon router's
20 // ESCDelay so a real lone Esc keypress still reaches the pane promptly.
21 pasteFlushDelay = 20 * time.Millisecond
22 // pasteSafetyDelay caps how long the coalescer waits for a closing marker
23 // before flushing a partial paste, so a paste with a lost closing marker
24 // degrades to today's per-frame behavior instead of stalling input.
25 pasteSafetyDelay = 2 * time.Second
26 // maxPasteBuffer caps buffered paste bytes. Beyond it the buffer is flushed
27 // verbatim and normal scanning resumes; input is never dropped.
28 maxPasteBuffer = 2 << 20 // 2 MiB
29)
30
31// pasteCoalescer reframes a bracketed paste that arrives split across input
32// reads into a single emit call. Over a remote attach, stdin is read in fixed
33// chunks and forwarded frame by frame; an input-frame boundary can land inside
34// a paste marker (e.g. between the marker's ESC and its '['). When the daemon's
35// keys router then sees a lone trailing ESC it retains it for ESCDelay and, if
36// the next frame is late, forwards it as a standalone Esc keypress — breaking
37// the opening marker so the pane's TUI never enters paste mode. Coalescing the
38// whole paste into one emit keeps both markers contiguous on the wire.
39//
40// It is safe for concurrent use: Scan runs on the stdin pump goroutine while a
41// flush/safety timer may fire on its own goroutine; both take the mutex, so
42// emit calls are serialised.
43type pasteCoalescer struct {
44 clock ports.Clock
45 emit func([]byte)
46
47 mu sync.Mutex
48 buffering bool // inside a paste: accumulating until the closing marker
49 buf []byte // paste bytes accumulated in buffering mode
50 pending []byte // held trailing strict prefix of the opening marker
51
52 flushTimer ports.Timer
53 flushDone chan struct{}
54 safetyTimer ports.Timer
55 safetyDone chan struct{}
56 closed bool
57}
58
59func newPasteCoalescer(clock ports.Clock, emit func([]byte)) *pasteCoalescer {
60 return &pasteCoalescer{clock: clock, emit: emit}
61}
62
63// Scan consumes one run of ordinary input bytes, emitting either pass-through
64// bytes immediately or a whole bracketed paste as a single emit call once its
65// closing marker is seen.
66func (c *pasteCoalescer) Scan(data []byte) {
67 if len(data) == 0 {
68 return
69 }
70 c.mu.Lock()
71 defer c.mu.Unlock()
72 if len(c.pending) > 0 {
73 // New bytes arrived before the flush timer fired: the held prefix was
74 // the start of this data, not a lone Esc. Recombine and rescan.
75 c.stopFlushTimer()
76 data = append(c.pending, data...)
77 c.pending = nil
78 }
79 c.process(data)
80}
81
82// process runs the scan/buffer state machine with the mutex held.
83func (c *pasteCoalescer) process(data []byte) {
84 for {
85 if c.buffering {
86 searchFrom := len(c.buf)
87 if overlap := len(ports.BracketedPasteCloseMarker) - 1; searchFrom > overlap {
88 searchFrom -= overlap
89 } else {
90 searchFrom = 0
91 }
92 c.buf = append(c.buf, data...)
93 idx := bytes.Index(c.buf[searchFrom:], ports.BracketedPasteCloseMarker)
94 if idx >= 0 {
95 idx += searchFrom
96 }
97 if idx < 0 {
98 if len(c.buf) > maxPasteBuffer {
99 c.emit(c.buf)
100 c.resetBuffering()
101 }
102 return
103 }
104 end := idx + len(ports.BracketedPasteCloseMarker)
105 c.emit(c.buf[:end])
106 rest := append([]byte(nil), c.buf[end:]...)
107 c.resetBuffering()
108 data = rest
109 // Fall through to scan any bytes trailing the paste.
110 }
111
112 if len(data) == 0 {
113 return
114 }
115
116 if idx := bytes.Index(data, ports.BracketedPasteOpenMarker); idx >= 0 {
117 if idx > 0 {
118 c.emit(data[:idx])
119 }
120 c.buffering = true
121 c.startSafetyTimer()
122 data = data[idx:] // marker onward; re-enter the buffering branch
123 continue
124 }
125
126 if p := trailingOpenPrefixLen(data); p > 0 {
127 if len(data) > p {
128 c.emit(data[:len(data)-p])
129 }
130 c.pending = append([]byte(nil), data[len(data)-p:]...)
131 c.startFlushTimer()
132 return
133 }
134
135 c.emit(data)
136 return
137 }
138}
139
140// resetBuffering leaves paste mode and stops the safety timer.
141func (c *pasteCoalescer) resetBuffering() {
142 c.buffering = false
143 c.buf = nil
144 c.stopSafetyTimer()
145}
146
147// Buffering reports whether it is currently unsafe to reinterpret incoming
148// bytes as ordinary keystrokes: either a bracketed paste is being
149// accumulated, or a trailing strict prefix of the opening marker is being
150// held across a read boundary (pending, awaiting either completion into a
151// real paste or the flush timer proving it was not one). The clipboard
152// Ctrl+V interceptor uses this to avoid treating bytes that are, or might
153// turn out to be, part of an in-flight paste as a standalone keystroke —
154// pasted text may legitimately contain 0x16.
155func (c *pasteCoalescer) Buffering() bool {
156 c.mu.Lock()
157 defer c.mu.Unlock()
158 return c.buffering || len(c.pending) > 0
159}
160
161// Close stops any pending timers and their goroutines. Callers which hand input
162// to a replacement must first use CloseAndTakeHeld so undecided bytes survive.
163func (c *pasteCoalescer) Close() {
164 _ = c.CloseAndTakeHeld()
165}
166
167// CloseAndTakeHeld stops the coalescer and returns ordinary bytes withheld while
168// deciding whether an opening marker or complete bracketed paste would arrive.
169// The returned bytes retain their original order and are removed from c.
170func (c *pasteCoalescer) CloseAndTakeHeld() []byte {
171 c.mu.Lock()
172 defer c.mu.Unlock()
173 c.closed = true
174 c.stopFlushTimer()
175 c.stopSafetyTimer()
176 held := append([]byte(nil), c.pending...)
177 held = append(held, c.buf...)
178 c.pending = nil
179 c.buf = nil
180 c.buffering = false
181 return held
182}
183
184func (c *pasteCoalescer) startFlushTimer() {
185 c.stopFlushTimer()
186 if c.closed {
187 return
188 }
189 timer := c.clock.NewTimer(pasteFlushDelay)
190 done := make(chan struct{})
191 c.flushTimer = timer
192 c.flushDone = done
193 go func() {
194 select {
195 case <-timer.C():
196 case <-done:
197 return
198 }
199 c.mu.Lock()
200 defer c.mu.Unlock()
201 if c.flushTimer != timer || len(c.pending) == 0 {
202 return
203 }
204 c.flushTimer = nil
205 c.flushDone = nil
206 held := c.pending
207 c.pending = nil
208 c.emit(held)
209 }()
210}
211
212func (c *pasteCoalescer) stopFlushTimer() {
213 if c.flushTimer != nil {
214 c.flushTimer.Stop()
215 c.flushTimer = nil
216 }
217 if c.flushDone != nil {
218 close(c.flushDone)
219 c.flushDone = nil
220 }
221}
222
223func (c *pasteCoalescer) startSafetyTimer() {
224 c.stopSafetyTimer()
225 if c.closed {
226 return
227 }
228 timer := c.clock.NewTimer(pasteSafetyDelay)
229 done := make(chan struct{})
230 c.safetyTimer = timer
231 c.safetyDone = done
232 go func() {
233 select {
234 case <-timer.C():
235 case <-done:
236 return
237 }
238 c.mu.Lock()
239 defer c.mu.Unlock()
240 if c.safetyTimer != timer || !c.buffering {
241 return
242 }
243 buffered := c.buf
244 c.buffering = false
245 c.buf = nil
246 c.safetyTimer = nil
247 c.safetyDone = nil
248 if len(buffered) > 0 {
249 c.emit(buffered)
250 }
251 }()
252}
253
254func (c *pasteCoalescer) stopSafetyTimer() {
255 if c.safetyTimer != nil {
256 c.safetyTimer.Stop()
257 c.safetyTimer = nil
258 }
259 if c.safetyDone != nil {
260 close(c.safetyDone)
261 c.safetyDone = nil
262 }
263}
264
265// trailingOpenPrefixLen returns the length of the longest suffix of data that
266// is a non-empty strict prefix of the opening marker (e.g. a trailing "\x1b" or
267// "\x1b[20"). A full marker match is handled by the caller before this runs.
268func trailingOpenPrefixLen(data []byte) int {
269 matchLimit := len(ports.BracketedPasteOpenMarker) - 1
270 if matchLimit > len(data) {
271 matchLimit = len(data)
272 }
273 for n := matchLimit; n >= 1; n-- {
274 if bytes.Equal(data[len(data)-n:], ports.BracketedPasteOpenMarker[:n]) {
275 return n
276 }
277 }
278 return 0
279}