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// EndBatch flushes only ordinary undecided bytes after a validated automated
162// batch. An open paste is never terminated here; admission and input validation
163// must prevent automation from entering or inheriting one.
164func (c *pasteCoalescer) EndBatch() bool {
165 c.mu.Lock()
166 defer c.mu.Unlock()
167 if c.closed || c.buffering {
168 return false
169 }
170 c.stopFlushTimer()
171 pending := c.pending
172 c.pending = nil
173 if len(pending) != 0 {
174 c.emit(pending)
175 }
176 return true
177}
178
179// Close stops any pending timers and their goroutines. Callers which hand input
180// to a replacement must first use CloseAndTakeHeld so undecided bytes survive.
181func (c *pasteCoalescer) Close() {
182 _ = c.CloseAndTakeHeld()
183}
184
185// CloseAndTakeHeld stops the coalescer and returns ordinary bytes withheld while
186// deciding whether an opening marker or complete bracketed paste would arrive.
187// The returned bytes retain their original order and are removed from c.
188func (c *pasteCoalescer) CloseAndTakeHeld() []byte {
189 c.mu.Lock()
190 defer c.mu.Unlock()
191 c.closed = true
192 c.stopFlushTimer()
193 c.stopSafetyTimer()
194 held := append([]byte(nil), c.pending...)
195 held = append(held, c.buf...)
196 c.pending = nil
197 c.buf = nil
198 c.buffering = false
199 return held
200}
201
202func (c *pasteCoalescer) startFlushTimer() {
203 c.stopFlushTimer()
204 if c.closed {
205 return
206 }
207 timer := c.clock.NewTimer(pasteFlushDelay)
208 done := make(chan struct{})
209 c.flushTimer = timer
210 c.flushDone = done
211 go func() {
212 select {
213 case <-timer.C():
214 case <-done:
215 return
216 }
217 c.mu.Lock()
218 defer c.mu.Unlock()
219 if c.flushTimer != timer || len(c.pending) == 0 {
220 return
221 }
222 c.flushTimer = nil
223 c.flushDone = nil
224 held := c.pending
225 c.pending = nil
226 c.emit(held)
227 }()
228}
229
230func (c *pasteCoalescer) stopFlushTimer() {
231 if c.flushTimer != nil {
232 c.flushTimer.Stop()
233 c.flushTimer = nil
234 }
235 if c.flushDone != nil {
236 close(c.flushDone)
237 c.flushDone = nil
238 }
239}
240
241func (c *pasteCoalescer) startSafetyTimer() {
242 c.stopSafetyTimer()
243 if c.closed {
244 return
245 }
246 timer := c.clock.NewTimer(pasteSafetyDelay)
247 done := make(chan struct{})
248 c.safetyTimer = timer
249 c.safetyDone = done
250 go func() {
251 select {
252 case <-timer.C():
253 case <-done:
254 return
255 }
256 c.mu.Lock()
257 defer c.mu.Unlock()
258 if c.safetyTimer != timer || !c.buffering {
259 return
260 }
261 buffered := c.buf
262 c.buffering = false
263 c.buf = nil
264 c.safetyTimer = nil
265 c.safetyDone = nil
266 if len(buffered) > 0 {
267 c.emit(buffered)
268 }
269 }()
270}
271
272func (c *pasteCoalescer) stopSafetyTimer() {
273 if c.safetyTimer != nil {
274 c.safetyTimer.Stop()
275 c.safetyTimer = nil
276 }
277 if c.safetyDone != nil {
278 close(c.safetyDone)
279 c.safetyDone = nil
280 }
281}
282
283// trailingOpenPrefixLen returns the length of the longest suffix of data that
284// is a non-empty strict prefix of the opening marker (e.g. a trailing "\x1b" or
285// "\x1b[20"). A full marker match is handled by the caller before this runs.
286func trailingOpenPrefixLen(data []byte) int {
287 matchLimit := len(ports.BracketedPasteOpenMarker) - 1
288 if matchLimit > len(data) {
289 matchLimit = len(data)
290 }
291 for n := matchLimit; n >= 1; n-- {
292 if bytes.Equal(data[len(data)-n:], ports.BracketedPasteOpenMarker[:n]) {
293 return n
294 }
295 }
296 return 0
297}