@@ -37,8 +37,9 @@ type Serial struct {
3737 Port string // Port is the path to the serial device on the dutagent.
3838 Baud int // Baud is the baud rate of the serial device. Is unset, DefaultBaudRate is used.
3939
40- expect * regexp.Regexp // expect is a pattern to match against the serial output.
41- timeout time.Duration // timeout is the maximum time to wait for the expect pattern to match.
40+ expect * regexp.Regexp // expect is a pattern to match against the serial output.
41+ timeout time.Duration // timeout is the maximum time to wait for the expect pattern to match.
42+ csiRemainder []byte // csiRemainder holds an incomplete CSI sequence carried over across buffer reads.
4243}
4344
4445// Ensure implementing the Module interface.
@@ -138,54 +139,73 @@ func (s *Serial) Run(ctx context.Context, session module.Session, args ...string
138139 matchCh := make (chan struct {}, 1 )
139140
140141 // Forward client stdin to serial port.
141- // Strip ANSI CSI sequences from stdin, since the remote system may send terminal
142- // queries (e.g. DSR) that cause the local terminal to inject responses (e.g. CPR)
143- // into stdin, which would corrupt the serial input.
142+ // DSR suppression (preventing CPR injection) is handled on the output side by
143+ // filterOutputCSI, which strips non-SGR CSI sequences — including DSR (ESC[6n) —
144+ // from serial output before it reaches the client terminal. The terminal never sees
145+ // a DSR query and therefore never injects CPR responses into stdin.
144146 const stdinBufSize = 256
145147
146- go func () {
147- buf := make ([]byte , stdinBufSize )
148+ type readResult struct {
149+ data []byte
150+ err error
151+ }
148152
149- for {
150- nRead , err := stdin .Read (buf )
151- if err != nil {
152- select {
153- case <- done :
154- return // Run() exited — suppress spurious error log.
155- default :
156- }
153+ readResultCh := make (chan readResult , 1 )
157154
158- if ctx .Err () != nil {
159- return
160- }
155+ go func () { // inner goroutine — may outlive Run(); exits when stdinCh is eventually closed.
156+ buf := make ([]byte , stdinBufSize )
161157
162- log .Printf ("serial module: error reading from stdin: %v" , err )
158+ for {
159+ n , err := stdin .Read (buf )
160+ cp := make ([]byte , n )
161+ copy (cp , buf [:n ])
162+ readResultCh <- readResult {data : cp , err : err }
163163
164+ if err != nil {
164165 return
165166 }
167+ }
168+ }()
166169
167- data := stripCSI (buf [:nRead ])
168-
169- if len (data ) == 0 {
170- continue
171- }
172-
170+ go func () { // outer goroutine — exits promptly when done is closed.
171+ for {
173172 select {
174173 case <- done :
175174 return
176- default :
177- }
175+ case res := <- readResultCh :
176+ if res .err != nil {
177+ select {
178+ case <- done :
179+ return // Run() exited — suppress spurious error log.
180+ default :
181+ }
182+
183+ if ctx .Err () != nil {
184+ return
185+ }
186+
187+ log .Printf ("serial module: error reading from stdin: %v" , res .err )
188+
189+ return
190+ }
178191
179- _ , writeErr := port .Write (data )
180- if writeErr != nil {
181192 select {
182193 case <- done :
183- return // port closed on Run() exit — not an error.
194+ return
184195 default :
185- log .Printf ("serial module: error writing to serial port: %v" , writeErr )
186196 }
187197
188- return
198+ _ , writeErr := port .Write (res .data )
199+ if writeErr != nil {
200+ select {
201+ case <- done :
202+ return // port closed on Run() exit — not an error.
203+ default :
204+ log .Printf ("serial module: error writing to serial port: %v" , writeErr )
205+ }
206+
207+ return
208+ }
189209 }
190210 }
191211 }()
@@ -219,12 +239,14 @@ func (s *Serial) Run(ctx context.Context, session module.Session, args ...string
219239 lineBuffer .Reset ()
220240 }
221241
222- var flushTimer * time. Timer
242+ var flushGen uint64
223243
224244 defer func () {
225- if flushTimer != nil {
226- flushTimer .Stop ()
227- }
245+ mutex .Lock ()
246+
247+ flushGen ++ // Invalidate any pending timer callbacks.
248+
249+ mutex .Unlock ()
228250 }()
229251
230252 const flushTimeout = 100 * time .Millisecond
@@ -236,13 +258,6 @@ func (s *Serial) Run(ctx context.Context, session module.Session, args ...string
236258
237259 return nil
238260 case <- ctx .Done ():
239- mutex .Lock ()
240-
241- // Flush any remaining data before closing.
242- flushBuffer ()
243-
244- mutex .Unlock ()
245-
246261 if errors .Is (ctx .Err (), context .DeadlineExceeded ) {
247262 fmt .Fprintln (stdout , "\n --- Timeout reached, no match found ---" )
248263
@@ -269,19 +284,21 @@ func (s *Serial) Run(ctx context.Context, session module.Session, args ...string
269284
270285 // Filter CSI sequences from serial output that could cause
271286 // cursor positioning artifacts. Color/style sequences (SGR)
272- // are preserved.
273- outData := filterOutputCSI (readBuffer [:sbytes ])
287+ // are preserved. Prepend any partial CSI sequence left over
288+ // from the previous read to handle sequences split across buffers.
289+ chunk := readBuffer [:sbytes ]
290+ if len (s .csiRemainder ) > 0 {
291+ chunk = append (s .csiRemainder , chunk ... )
292+ s .csiRemainder = nil
293+ }
294+
295+ outData := filterOutputCSI (chunk , & s .csiRemainder )
274296 if len (outData ) == 0 {
275297 continue
276298 }
277299
278300 mutex .Lock ()
279301
280- // Stop pending flush timer since we have new data.
281- if flushTimer != nil {
282- flushTimer .Stop ()
283- }
284-
285302 // Process the data read character by character
286303 for _ , b := range outData {
287304 lineBuffer .WriteByte (b )
@@ -312,10 +329,18 @@ func (s *Serial) Run(ctx context.Context, session module.Session, args ...string
312329
313330 // If there's data remaining in the line buffer (no newline yet),
314331 // schedule a flush so prompts like "login: " appear promptly.
332+ // Each timer captures its generation; stale callbacks are no-ops.
315333 if lineBuffer .Len () > 0 {
316- flushTimer = time .AfterFunc (flushTimeout , func () {
334+ flushGen ++
335+ thisGen := flushGen
336+
337+ time .AfterFunc (flushTimeout , func () {
317338 mutex .Lock ()
318- flushBuffer ()
339+
340+ if flushGen == thisGen {
341+ flushBuffer ()
342+ }
343+
319344 mutex .Unlock ()
320345 })
321346 }
@@ -371,6 +396,9 @@ func (s *Serial) openPort() (*serial.Port, error) {
371396 return port , nil
372397}
373398
399+ // escByte is the ASCII escape character that starts ANSI/VT escape sequences.
400+ const escByte = 0x1b
401+
374402// csiPrefixLen is the length of the CSI prefix "ESC[".
375403const csiPrefixLen = 2
376404
@@ -379,71 +407,62 @@ const csiPrefixLen = 2
379407// This prevents cursor positioning, screen clearing, and terminal query sequences
380408// from affecting the client terminal, while preserving colored output.
381409//
410+ // Incomplete CSI sequences at the end of data are stored in remainder so they
411+ // can be prepended to the next buffer read and reconstituted correctly.
412+ //
382413//nolint:cyclop,varnamelen
383- func filterOutputCSI (data []byte ) []byte {
414+ func filterOutputCSI (data []byte , remainder * [] byte ) []byte {
384415 result := make ([]byte , 0 , len (data ))
416+ * remainder = nil
385417
386418 for i := 0 ; i < len (data ); i ++ {
387- if data [i ] == 0x1b && i + 1 < len (data ) && data [i + 1 ] == '[' {
388- // Find the extent of this CSI sequence.
389- j := i + csiPrefixLen
390-
391- for j < len (data ) && data [j ] >= 0x30 && data [j ] <= 0x3f {
392- j ++ // parameter bytes
393- }
419+ if data [i ] != escByte {
420+ result = append (result , data [i ])
394421
395- for j < len (data ) && data [j ] >= 0x20 && data [j ] <= 0x2f {
396- j ++ // intermediate bytes
397- }
422+ continue
423+ }
398424
399- if j >= len (data ) {
400- // Incomplete sequence at end of buffer — drop it.
401- break
402- }
425+ // ESC at end of buffer: might be the start of a CSI sequence split across reads.
426+ if i + 1 >= len (data ) {
427+ * remainder = []byte {escByte }
403428
404- if data [j ] == 'm' {
405- // SGR (colors/styles) — keep it.
406- result = append (result , data [i :j + 1 ]... )
407- }
429+ break
430+ }
408431
409- // All other CSI sequences are dropped.
410- i = j
432+ if data [i + 1 ] != '[' {
433+ // ESC not followed by '[' — not a CSI sequence, emit as-is.
434+ result = append (result , data [i ])
411435
412436 continue
413437 }
414438
415- result = append (result , data [i ])
416- }
439+ // CSI sequence: ESC [
440+ // Find the extent of this CSI sequence.
441+ j := i + csiPrefixLen
417442
418- return result
419- }
443+ for j < len (data ) && data [j ] >= 0x30 && data [j ] <= 0x3f {
444+ j ++ // parameter bytes
445+ }
420446
421- // stripCSI removes ANSI CSI (Control Sequence Introducer) sequences from data.
422- // CSI sequences start with ESC[ (0x1b 0x5b), followed by parameter bytes (0x30-0x3f),
423- // intermediate bytes (0x20-0x2f), and a final byte (0x40-0x7e).
424- // This filters out terminal responses (like cursor position reports) that the local
425- // terminal injects into stdin when the remote system sends queries.
426- //
427- //nolint:varnamelen
428- func stripCSI (data []byte ) []byte {
429- result := make ([]byte , 0 , len (data ))
447+ for j < len (data ) && data [j ] >= 0x20 && data [j ] <= 0x2f {
448+ j ++ // intermediate bytes
449+ }
430450
431- for i := 0 ; i < len (data ); i ++ {
432- if data [ i ] == 0x1b && i + 1 < len ( data ) && data [ i + 1 ] == '[' {
433- // Skip ESC[
434- i += 2
451+ if j >= len (data ) {
452+ // Incomplete sequence at end of buffer — carry it over to the next read.
453+ * remainder = make ([] byte , len ( data ) - i )
454+ copy ( * remainder , data [ i :])
435455
436- // Skip parameter bytes (0x30-0x3f) and intermediate bytes (0x20-0x2f).
437- for i < len (data ) && data [i ] >= 0x20 && data [i ] < 0x40 {
438- i ++
439- }
456+ break
457+ }
440458
441- // i now points at the final byte (0x40-0x7e); the outer loop's
442- // i++ will advance past it on the next iteration .
443- continue
459+ if data [ j ] == 'm' {
460+ // SGR (colors/styles) — keep it.
461+ result = append ( result , data [ i : j + 1 ] ... )
444462 }
445463
446- result = append (result , data [i ])
464+ // All other CSI sequences are dropped.
465+ i = j
447466 }
448467
449468 return result
0 commit comments