Skip to content

Commit e738596

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 04810b8 commit e738596

8 files changed

Lines changed: 491 additions & 21 deletions

File tree

cmds/dutctl/rawconsole_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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+
package main
6+
7+
import (
8+
"bytes"
9+
"testing"
10+
)
11+
12+
// TestRawConsoleNeverArmsForNonFileStdin verifies that a non-*os.File stdin
13+
// (e.g. a piped/scripted run) never switches to raw mode, regardless of the
14+
// interactive hint, and that arm/disarm are safe no-ops in that case.
15+
func TestRawConsoleNeverArmsForNonFileStdin(t *testing.T) {
16+
console := newRawConsole(&bytes.Buffer{}, true)
17+
18+
console.arm()
19+
20+
if console.isActive() {
21+
t.Error("isActive() = true for non-file stdin, want false")
22+
}
23+
24+
// disarm must not panic even though arm never engaged.
25+
console.disarm()
26+
}
27+
28+
// TestRawConsoleNeverArmsForScriptedInvocation verifies that when the command
29+
// was invoked with arguments (interactive=false), raw mode is never armed even
30+
// though the agent streams console output (modelled here by calling arm).
31+
func TestRawConsoleNeverArmsForScriptedInvocation(t *testing.T) {
32+
// interactive=false models a serial expect/send sequence run.
33+
console := newRawConsole(&bytes.Buffer{}, false)
34+
35+
console.arm()
36+
37+
if console.isActive() {
38+
t.Error("isActive() = true for a scripted (argument-bearing) invocation, want false")
39+
}
40+
41+
console.disarm()
42+
}
43+
44+
// TestRawConsoleArmIsIdempotent verifies that repeated arm calls (one per
45+
// console message) do not panic and leave a consistent state. With a non-file
46+
// stdin it stays inactive; the point is that calling arm many times is safe.
47+
func TestRawConsoleArmIsIdempotent(t *testing.T) {
48+
console := newRawConsole(&bytes.Buffer{}, true)
49+
50+
for range 5 {
51+
console.arm()
52+
}
53+
54+
if console.isActive() {
55+
t.Error("isActive() = true for non-file stdin, want false")
56+
}
57+
58+
console.disarm()
59+
console.disarm() // double disarm must be safe
60+
}

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: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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+
err = unix.IoctlSetTermios(fileDescriptor, tcSetReq, termios)
46+
if err != nil {
47+
return nil
48+
}
49+
50+
return func() {
51+
_ = unix.IoctlSetTermios(fileDescriptor, tcSetReq, &old)
52+
}
53+
}

0 commit comments

Comments
 (0)