55package main
66
77import (
8- "bufio"
98 "context"
109 "errors"
1110 "fmt"
@@ -188,10 +187,74 @@ func (app *application) detailsRPC(device, command, keyword string) error {
188187 return nil
189188}
190189
190+ // Interactive escape sequence. Ctrl-A is the prefix; the following key decides:
191+ // - 'x'/'X'/Ctrl-X: quit dutctl.
192+ // - Ctrl-A: send a single literal Ctrl-A to the DUT.
193+ // - anything else: forward both the prefix and the key unchanged.
194+ //
195+ // Every other byte (Ctrl-C, Ctrl-D, Ctrl-Z, ...) is forwarded to the DUT.
196+ const (
197+ escapePrefix = 0x01 // Ctrl-A
198+ escapeQuitCtrl = 0x18 // Ctrl-X
199+ )
200+
201+ // filterEscape applies the interactive escape state machine to in. It returns
202+ // the bytes to forward to the DUT and whether the quit sequence was seen.
203+ // escapePending carries the "prefix seen" state across reads, so the prefix and
204+ // its following key may arrive in separate stdin reads.
205+ func filterEscape (in []byte , escapePending * bool ) (out []byte , quit bool ) {
206+ out = make ([]byte , 0 , len (in ))
207+
208+ for _ , b := range in {
209+ if * escapePending {
210+ * escapePending = false
211+
212+ switch b {
213+ case 'x' , 'X' , escapeQuitCtrl :
214+ return out , true
215+ case escapePrefix : // Ctrl-A Ctrl-A -> one literal Ctrl-A
216+ out = append (out , escapePrefix )
217+ default : // not an escape; forward the prefix and this byte
218+ out = append (out , escapePrefix , b )
219+ }
220+
221+ continue
222+ }
223+
224+ if b == escapePrefix {
225+ * escapePending = true
226+
227+ continue
228+ }
229+
230+ out = append (out , b )
231+ }
232+
233+ return out , false
234+ }
235+
191236//nolint:funlen,cyclop,gocognit
192237func (app * application ) runRPC (device , command string , cmdArgs []string ) error {
193238 const numWorkers = 2 // The send and receive worker goroutines
194239
240+ // Set raw input mode: disable local echo, canonical mode and signal
241+ // generation so each keystroke — including control characters like Ctrl-C —
242+ // is forwarded to the DUT immediately. Interactive modules like serial rely
243+ // on the remote side to echo input. Only a real terminal is switched to raw
244+ // mode; piped/scripted stdin stays untouched so it is forwarded verbatim.
245+ interactive := false
246+
247+ if f , ok := app .stdin .(* os.File ); ok {
248+ if restore := setRawInput (int (f .Fd ())); restore != nil {
249+ interactive = true
250+
251+ defer restore ()
252+
253+ // The escape sequence is the only way to quit while raw mode is on.
254+ fmt .Fprint (os .Stderr , "\r \n [dutctl] interactive session — press Ctrl-A then x to quit\r \n " )
255+ }
256+ }
257+
195258 runCtx , cancel := context .WithCancel (context .Background ())
196259 defer cancel ()
197260
@@ -324,18 +387,22 @@ func (app *application) runRPC(device, command string, cmdArgs []string) error {
324387 }
325388 }()
326389
327- // Send routine — reads lines from stdin and forwards them to the server.
390+ // Send routine — reads raw bytes from stdin and forwards them to the server.
328391 //
329392 // Unlike the receive routine this goroutine intentionally does NOT defer
330- // cancel(). When stdin reaches EOF (e.g. /dev/null in non-interactive
331- // runs) this goroutine returns immediately. If it cancelled the context
332- // on exit, the receive routine would be torn down before it could read
333- // and print the server's response.
393+ // cancel() for the EOF case . When stdin reaches EOF (e.g. /dev/null in
394+ // non-interactive runs) this goroutine returns immediately; if it cancelled
395+ // the context on exit, the receive routine would be torn down before it
396+ // could read and print the server's response.
334397 //
335- // Only the receive routine drives context cancellation so that all
336- // server output is processed before the RPC terminates .
398+ // It DOES cancel when the user types the interactive escape sequence
399+ // (Ctrl-A x): that is an explicit request to end the session .
337400 go func () {
338- reader := bufio .NewReader (app .stdin )
401+ const stdinBufSize = 256
402+
403+ buf := make ([]byte , stdinBufSize )
404+
405+ var escapePending bool
339406
340407 for {
341408 select {
@@ -346,7 +413,7 @@ func (app *application) runRPC(device, command string, cmdArgs []string) error {
346413 default :
347414 }
348415
349- text , err := reader . ReadString ( '\n' )
416+ nRead , err := app . stdin . Read ( buf )
350417 if err != nil {
351418 if ! errors .Is (err , io .EOF ) {
352419 errChan <- fmt .Errorf ("reading stdin: %w" , err )
@@ -355,17 +422,35 @@ func (app *application) runRPC(device, command string, cmdArgs []string) error {
355422 return
356423 }
357424
358- err = stream .Send (& pb.RunRequest {
359- Msg : & pb.RunRequest_Console {
360- Console : & pb.Console {
361- Data : & pb.Console_Stdin {
362- Stdin : []byte (text ),
425+ payload := buf [:nRead ]
426+
427+ quit := false
428+ if interactive {
429+ // Intercept the escape sequence; forward everything else
430+ // (including Ctrl-C) untouched.
431+ payload , quit = filterEscape (payload , & escapePending )
432+ }
433+
434+ if len (payload ) > 0 {
435+ sendErr := stream .Send (& pb.RunRequest {
436+ Msg : & pb.RunRequest_Console {
437+ Console : & pb.Console {
438+ Data : & pb.Console_Stdin {
439+ Stdin : payload ,
440+ },
363441 },
364442 },
365- },
366- })
367- if err != nil {
368- errChan <- fmt .Errorf ("sending RPC message: %w" , err )
443+ })
444+ if sendErr != nil {
445+ errChan <- fmt .Errorf ("sending RPC message: %w" , sendErr )
446+
447+ return
448+ }
449+ }
450+
451+ if quit {
452+ log .Println ("Send routine terminating: escape sequence" )
453+ cancel ()
369454
370455 return
371456 }
0 commit comments