Skip to content

Commit 8de5449

Browse files
committed
feat: type directly to the DUT serial console
The client now switches the terminal to raw mode during a run and forwards every keystroke - including Ctrl-C, Ctrl-D and Ctrl-Z - straight to the DUT. Press Ctrl-A then x to quit the session. Raw mode is build-tagged for Linux and macOS, with a no-op fallback on other platforms so the client still builds everywhere. (#121) Signed-off-by: llogen <christoph.lange@blindspot.software>
1 parent 2c0ed04 commit 8de5449

7 files changed

Lines changed: 346 additions & 20 deletions

File tree

cmds/dutctl/rawmode_darwin.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright 2025 Blindspot Software
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
//go:build darwin
6+
7+
package main
8+
9+
import "golang.org/x/sys/unix"
10+
11+
// Terminal get/set ioctl request numbers for macOS (BSD-derived).
12+
const (
13+
tcGetReq = unix.TIOCGETA
14+
tcSetReq = unix.TIOCSETA
15+
)

cmds/dutctl/rawmode_linux.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright 2025 Blindspot Software
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
//go:build linux
6+
7+
package main
8+
9+
import "golang.org/x/sys/unix"
10+
11+
// Terminal get/set ioctl request numbers for Linux.
12+
const (
13+
tcGetReq = unix.TCGETS
14+
tcSetReq = unix.TCSETS
15+
)

cmds/dutctl/rawmode_other.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright 2025 Blindspot Software
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
//go:build !linux && !darwin
6+
7+
package main
8+
9+
// setRawInput is a no-op on platforms without termios support (e.g. Windows).
10+
// Input stays line-buffered; the interactive serial experience is degraded but
11+
// the client still builds and runs.
12+
func setRawInput(_ int) func() {
13+
return nil
14+
}

cmds/dutctl/rawmode_unix.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright 2025 Blindspot Software
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
//go:build linux || darwin
6+
7+
package main
8+
9+
import "golang.org/x/sys/unix"
10+
11+
// setRawInput puts the terminal into raw input mode so the interactive serial
12+
// session behaves like a direct console:
13+
//
14+
// - ECHO/ICANON off: keystrokes are delivered immediately, character at a
15+
// time, and not echoed locally (the DUT echoes them back).
16+
// - ISIG off: control characters such as Ctrl-C, Ctrl-Z and Ctrl-\ are NOT
17+
// turned into local signals; they are forwarded to the DUT as raw bytes.
18+
// - IXON off: Ctrl-S/Ctrl-Q flow control is forwarded to the DUT instead of
19+
// being swallowed by the local terminal.
20+
// - IEXTEN off: Ctrl-V and friends are forwarded literally.
21+
// - ICRNL off: a typed CR is sent as CR, not translated to NL.
22+
//
23+
// dutctl is exited with the client-side escape sequence (Ctrl-A x), not with a
24+
// terminal signal — see filterEscape in rpc.go.
25+
//
26+
// It returns a restore function, or nil if the fd is not a terminal (in which
27+
// case input stays line-buffered, which is the correct fallback for pipes).
28+
//
29+
// The ioctl request numbers differ per OS (tcGetReq/tcSetReq are defined in the
30+
// platform-specific files); the termios flags themselves are shared across
31+
// unix platforms.
32+
func setRawInput(fileDescriptor int) func() {
33+
termios, err := unix.IoctlGetTermios(fileDescriptor, tcGetReq)
34+
if err != nil {
35+
return nil
36+
}
37+
38+
old := *termios
39+
40+
termios.Iflag &^= unix.ICRNL | unix.IXON
41+
termios.Lflag &^= unix.ECHO | unix.ICANON | unix.ISIG | unix.IEXTEN
42+
termios.Cc[unix.VMIN] = 1
43+
termios.Cc[unix.VTIME] = 0
44+
45+
if err := unix.IoctlSetTermios(fileDescriptor, tcSetReq, termios); err != nil {
46+
return nil
47+
}
48+
49+
return func() {
50+
_ = unix.IoctlSetTermios(fileDescriptor, tcSetReq, &old)
51+
}
52+
}

cmds/dutctl/rpc.go

Lines changed: 104 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
package main
66

77
import (
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
192237
func (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

Comments
 (0)