refactor(term,pty): drop golang.org/x deps for stdlib syscalls
Add pkg/linuxterm, a small Linux-only wrapper over the frozen stdlib syscall package covering what vev used from golang.org/x/term and golang.org/x/sys/unix: raw mode (identical MakeRaw flag semantics), winsize get/set, pty slave number and unlock ioctls, and the foreground process group query. Migrate the terminal adapter, the pty adapter, and the perf harness; call-site behavior, open flags, and error wrapping are unchanged. The ioctl helper takes an unsafe.Pointer and converts to uintptr inside the Syscall argument list, keeping the referent live across stack moves. The vev binary now depends on the standard library alone; testify remains test-only.
- commit
- 875a6f7c71ca6a234c655d255f2794d39aa63768
- author
- brice · · 2026-07-14 14:19 UTC
- parent
- abde31c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +304 -47
M cmd/vev-perf-harness/launcher.go +6 -6
| @@ -15,8 +15,8 @@ import ( | |||
| 15 | 15 | "syscall" | |
| 16 | 16 | "time" | |
| 17 | 17 | ||
| 18 | + | "github.com/bnema/vev/pkg/linuxterm" | |
| 18 | 19 | "github.com/bnema/vev/pkg/safedir" | |
| 19 | - | "golang.org/x/sys/unix" | |
| 20 | 20 | ) | |
| 21 | 21 | ||
| 22 | 22 | type launchedProcess interface { | |
| @@ -726,19 +726,19 @@ func (p *cliProcess) forceProcessGroupKill() { | |||
| 726 | 726 | // openPTY is the small Linux PTY boundary needed to drive the public terminal | |
| 727 | 727 | // client. No daemon or adapter implementation is imported by this command. | |
| 728 | 728 | func openPTY() (*os.File, *os.File, error) { | |
| 729 | - | masterFD, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0) | |
| 729 | + | masterFD, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY, 0) | |
| 730 | 730 | if err != nil { | |
| 731 | 731 | return nil, nil, err | |
| 732 | 732 | } | |
| 733 | - | closeMaster := func(err error) (*os.File, *os.File, error) { _ = unix.Close(masterFD); return nil, nil, err } | |
| 734 | - | number, err := unix.IoctlGetInt(masterFD, unix.TIOCGPTN) | |
| 733 | + | closeMaster := func(err error) (*os.File, *os.File, error) { _ = syscall.Close(masterFD); return nil, nil, err } | |
| 734 | + | number, err := linuxterm.PtsNumber(masterFD) | |
| 735 | 735 | if err != nil { | |
| 736 | 736 | return closeMaster(err) | |
| 737 | 737 | } | |
| 738 | - | if err := unix.IoctlSetPointerInt(masterFD, unix.TIOCSPTLCK, 0); err != nil { | |
| 738 | + | if err := linuxterm.UnlockPt(masterFD); err != nil { | |
| 739 | 739 | return closeMaster(err) | |
| 740 | 740 | } | |
| 741 | - | slaveFD, err := unix.Open(fmt.Sprintf("/dev/pts/%d", number), unix.O_RDWR|unix.O_NOCTTY, 0) | |
| 741 | + | slaveFD, err := syscall.Open(fmt.Sprintf("/dev/pts/%d", number), syscall.O_RDWR|syscall.O_NOCTTY, 0) | |
| 742 | 742 | if err != nil { | |
| 743 | 743 | return closeMaster(err) | |
| 744 | 744 | } | |
| @@ -15,8 +15,8 @@ import ( | |||||
| 15 | "syscall" | 15 | "syscall" | ||
| 16 | "time" | 16 | "time" | ||
| 17 | 17 | ||||
| 18 | + | "github.com/bnema/vev/pkg/linuxterm" | |||
| 18 | "github.com/bnema/vev/pkg/safedir" | 19 | "github.com/bnema/vev/pkg/safedir" | ||
| 19 | - | "golang.org/x/sys/unix" | |||
| 20 | ) | 20 | ) | ||
| 21 | 21 | ||||
| 22 | type launchedProcess interface { | 22 | type launchedProcess interface { | ||
| @@ -726,19 +726,19 @@ func (p *cliProcess) forceProcessGroupKill() { | |||||
| 726 | // openPTY is the small Linux PTY boundary needed to drive the public terminal | 726 | // openPTY is the small Linux PTY boundary needed to drive the public terminal | ||
| 727 | // client. No daemon or adapter implementation is imported by this command. | 727 | // client. No daemon or adapter implementation is imported by this command. | ||
| 728 | func openPTY() (*os.File, *os.File, error) { | 728 | func openPTY() (*os.File, *os.File, error) { | ||
| 729 | - | masterFD, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0) | 729 | + | masterFD, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY, 0) |
| 730 | if err != nil { | 730 | if err != nil { | ||
| 731 | return nil, nil, err | 731 | return nil, nil, err | ||
| 732 | } | 732 | } | ||
| 733 | - | closeMaster := func(err error) (*os.File, *os.File, error) { _ = unix.Close(masterFD); return nil, nil, err } | 733 | + | closeMaster := func(err error) (*os.File, *os.File, error) { _ = syscall.Close(masterFD); return nil, nil, err } |
| 734 | - | number, err := unix.IoctlGetInt(masterFD, unix.TIOCGPTN) | 734 | + | number, err := linuxterm.PtsNumber(masterFD) |
| 735 | if err != nil { | 735 | if err != nil { | ||
| 736 | return closeMaster(err) | 736 | return closeMaster(err) | ||
| 737 | } | 737 | } | ||
| 738 | - | if err := unix.IoctlSetPointerInt(masterFD, unix.TIOCSPTLCK, 0); err != nil { | 738 | + | if err := linuxterm.UnlockPt(masterFD); err != nil { |
| 739 | return closeMaster(err) | 739 | return closeMaster(err) | ||
| 740 | } | 740 | } | ||
| 741 | - | slaveFD, err := unix.Open(fmt.Sprintf("/dev/pts/%d", number), unix.O_RDWR|unix.O_NOCTTY, 0) | 741 | + | slaveFD, err := syscall.Open(fmt.Sprintf("/dev/pts/%d", number), syscall.O_RDWR|syscall.O_NOCTTY, 0) |
| 742 | if err != nil { | 742 | if err != nil { | ||
| 743 | return closeMaster(err) | 743 | return closeMaster(err) | ||
| 744 | } | 744 | } | ||
M go.mod +1 -5
| @@ -2,11 +2,7 @@ module github.com/bnema/vev | |||
| 2 | 2 | ||
| 3 | 3 | go 1.26 | |
| 4 | 4 | ||
| 5 | - | require ( | |
| 6 | - | github.com/stretchr/testify v1.11.1 | |
| 7 | - | golang.org/x/sys v0.46.0 | |
| 8 | - | golang.org/x/term v0.44.0 | |
| 9 | - | ) | |
| 5 | + | require github.com/stretchr/testify v1.11.1 | |
| 10 | 6 | ||
| 11 | 7 | require ( | |
| 12 | 8 | github.com/davecgh/go-spew v1.1.1 // indirect | |
| @@ -2,11 +2,7 @@ module github.com/bnema/vev | |||||
| 2 | 2 | ||||
| 3 | go 1.26 | 3 | go 1.26 | ||
| 4 | 4 | ||||
| 5 | - | require ( | 5 | + | require github.com/stretchr/testify v1.11.1 |
| 6 | - | github.com/stretchr/testify v1.11.1 | |||
| 7 | - | golang.org/x/sys v0.46.0 | |||
| 8 | - | golang.org/x/term v0.44.0 | |||
| 9 | - | ) | |||
| 10 | 6 | ||||
| 11 | require ( | 7 | require ( | ||
| 12 | github.com/davecgh/go-spew v1.1.1 // indirect | 8 | github.com/davecgh/go-spew v1.1.1 // indirect | ||
M go.sum +0 -4 generated file: collapsed
M internal/adapters/pty/pty_linux.go +11 -14
| @@ -5,7 +5,7 @@ package pty | |||
| 5 | 5 | // The Linux adapter drives /dev/ptmx directly (open master, TIOCGPTN to learn | |
| 6 | 6 | // the slave index, TIOCSPTLCK to unlock, then open /dev/pts/<N>) rather than | |
| 7 | 7 | // depending on a third-party pty package, keeping the dependency surface at | |
| 8 | - | // x/sys/unix plus the standard library. | |
| 8 | + | // the standard library alone. | |
| 9 | 9 | ||
| 10 | 10 | import ( | |
| 11 | 11 | "context" | |
| @@ -21,7 +21,7 @@ import ( | |||
| 21 | 21 | "github.com/bnema/vev/internal/domain" | |
| 22 | 22 | "github.com/bnema/vev/internal/platform" | |
| 23 | 23 | "github.com/bnema/vev/internal/ports" | |
| 24 | - | "golang.org/x/sys/unix" | |
| 24 | + | "github.com/bnema/vev/pkg/linuxterm" | |
| 25 | 25 | ) | |
| 26 | 26 | ||
| 27 | 27 | // killGracePeriod is how long Close waits for a signalled child to exit before | |
| @@ -43,7 +43,7 @@ func (Factory) Open(ctx context.Context, command string, args []string, env []st | |||
| 43 | 43 | if err := ctx.Err(); err != nil { | |
| 44 | 44 | return nil, err | |
| 45 | 45 | } | |
| 46 | - | masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0) | |
| 46 | + | masterFd, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0) | |
| 47 | 47 | if err != nil { | |
| 48 | 48 | return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err) | |
| 49 | 49 | } | |
| @@ -52,22 +52,22 @@ func (Factory) Open(ctx context.Context, command string, args []string, env []st | |||
| 52 | 52 | ok := false | |
| 53 | 53 | defer func() { | |
| 54 | 54 | if !ok { | |
| 55 | - | _ = unix.Close(masterFd) | |
| 55 | + | _ = syscall.Close(masterFd) | |
| 56 | 56 | } | |
| 57 | 57 | }() | |
| 58 | 58 | ||
| 59 | - | ptn, err := unix.IoctlGetInt(masterFd, unix.TIOCGPTN) | |
| 59 | + | ptn, err := linuxterm.PtsNumber(masterFd) | |
| 60 | 60 | if err != nil { | |
| 61 | 61 | return nil, fmt.Errorf("pty: TIOCGPTN: %w", err) | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | 64 | // Unlock the slave so it can be opened. | |
| 65 | - | if err := unix.IoctlSetPointerInt(masterFd, unix.TIOCSPTLCK, 0); err != nil { | |
| 65 | + | if err := linuxterm.UnlockPt(masterFd); err != nil { | |
| 66 | 66 | return nil, fmt.Errorf("pty: TIOCSPTLCK: %w", err) | |
| 67 | 67 | } | |
| 68 | 68 | ||
| 69 | 69 | slaveName := fmt.Sprintf("/dev/pts/%d", ptn) | |
| 70 | - | slave, err := os.OpenFile(slaveName, os.O_RDWR|unix.O_NOCTTY, 0) | |
| 70 | + | slave, err := os.OpenFile(slaveName, os.O_RDWR|syscall.O_NOCTTY, 0) | |
| 71 | 71 | if err != nil { | |
| 72 | 72 | return nil, fmt.Errorf("pty: open %s: %w", slaveName, err) | |
| 73 | 73 | } | |
| @@ -141,7 +141,7 @@ var _ ports.PTY = (*linuxPTY)(nil) | |||
| 141 | 141 | // normal end of stream, so EIO is mapped to io.EOF here. | |
| 142 | 142 | func (p *linuxPTY) Read(b []byte) (int, error) { | |
| 143 | 143 | n, err := p.master.Read(b) | |
| 144 | - | if err != nil && errors.Is(err, unix.EIO) { | |
| 144 | + | if err != nil && errors.Is(err, syscall.EIO) { | |
| 145 | 145 | return n, io.EOF | |
| 146 | 146 | } | |
| 147 | 147 | return n, err | |
| @@ -189,7 +189,7 @@ func (p *linuxPTY) ForegroundPgid() (int, error) { | |||
| 189 | 189 | var pgid int | |
| 190 | 190 | var ioctlErr error | |
| 191 | 191 | if err := rc.Control(func(fd uintptr) { | |
| 192 | - | pgid, ioctlErr = unix.IoctlGetInt(int(fd), unix.TIOCGPGRP) | |
| 192 | + | pgid, ioctlErr = linuxterm.ForegroundProcessGroup(int(fd)) | |
| 193 | 193 | }); err != nil { | |
| 194 | 194 | return 0, fmt.Errorf("pty: foreground pgid: %w", err) | |
| 195 | 195 | } | |
| @@ -235,13 +235,10 @@ func (p *linuxPTY) Close() error { | |||
| 235 | 235 | // child as its own session and process-group leader, so a negative pid reaches | |
| 236 | 236 | // both the command and every descendant that remains in its process group. | |
| 237 | 237 | func signalProcessGroup(pid int, signal syscall.Signal) error { | |
| 238 | - | return unix.Kill(-pid, signal) | |
| 238 | + | return syscall.Kill(-pid, signal) | |
| 239 | 239 | } | |
| 240 | 240 | ||
| 241 | 241 | // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ. | |
| 242 | 242 | func setWinsize(fd int, sz domain.Size) error { | |
| 243 | - | return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{ | |
| 244 | - | Row: uint16(sz.Rows), | |
| 245 | - | Col: uint16(sz.Cols), | |
| 246 | - | }) | |
| 243 | + | return linuxterm.SetWinsize(fd, uint16(sz.Cols), uint16(sz.Rows)) | |
| 247 | 244 | } | |
| @@ -5,7 +5,7 @@ package pty | |||||
| 5 | // The Linux adapter drives /dev/ptmx directly (open master, TIOCGPTN to learn | 5 | // The Linux adapter drives /dev/ptmx directly (open master, TIOCGPTN to learn | ||
| 6 | // the slave index, TIOCSPTLCK to unlock, then open /dev/pts/<N>) rather than | 6 | // the slave index, TIOCSPTLCK to unlock, then open /dev/pts/<N>) rather than | ||
| 7 | // depending on a third-party pty package, keeping the dependency surface at | 7 | // depending on a third-party pty package, keeping the dependency surface at | ||
| 8 | - | // x/sys/unix plus the standard library. | 8 | + | // the standard library alone. |
| 9 | 9 | ||||
| 10 | import ( | 10 | import ( | ||
| 11 | "context" | 11 | "context" | ||
| @@ -21,7 +21,7 @@ import ( | |||||
| 21 | "github.com/bnema/vev/internal/domain" | 21 | "github.com/bnema/vev/internal/domain" | ||
| 22 | "github.com/bnema/vev/internal/platform" | 22 | "github.com/bnema/vev/internal/platform" | ||
| 23 | "github.com/bnema/vev/internal/ports" | 23 | "github.com/bnema/vev/internal/ports" | ||
| 24 | - | "golang.org/x/sys/unix" | 24 | + | "github.com/bnema/vev/pkg/linuxterm" |
| 25 | ) | 25 | ) | ||
| 26 | 26 | ||||
| 27 | // killGracePeriod is how long Close waits for a signalled child to exit before | 27 | // killGracePeriod is how long Close waits for a signalled child to exit before | ||
| @@ -43,7 +43,7 @@ func (Factory) Open(ctx context.Context, command string, args []string, env []st | |||||
| 43 | if err := ctx.Err(); err != nil { | 43 | if err := ctx.Err(); err != nil { | ||
| 44 | return nil, err | 44 | return nil, err | ||
| 45 | } | 45 | } | ||
| 46 | - | masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0) | 46 | + | masterFd, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0) |
| 47 | if err != nil { | 47 | if err != nil { | ||
| 48 | return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err) | 48 | return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err) | ||
| 49 | } | 49 | } | ||
| @@ -52,22 +52,22 @@ func (Factory) Open(ctx context.Context, command string, args []string, env []st | |||||
| 52 | ok := false | 52 | ok := false | ||
| 53 | defer func() { | 53 | defer func() { | ||
| 54 | if !ok { | 54 | if !ok { | ||
| 55 | - | _ = unix.Close(masterFd) | 55 | + | _ = syscall.Close(masterFd) |
| 56 | } | 56 | } | ||
| 57 | }() | 57 | }() | ||
| 58 | 58 | ||||
| 59 | - | ptn, err := unix.IoctlGetInt(masterFd, unix.TIOCGPTN) | 59 | + | ptn, err := linuxterm.PtsNumber(masterFd) |
| 60 | if err != nil { | 60 | if err != nil { | ||
| 61 | return nil, fmt.Errorf("pty: TIOCGPTN: %w", err) | 61 | return nil, fmt.Errorf("pty: TIOCGPTN: %w", err) | ||
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| 64 | // Unlock the slave so it can be opened. | 64 | // Unlock the slave so it can be opened. | ||
| 65 | - | if err := unix.IoctlSetPointerInt(masterFd, unix.TIOCSPTLCK, 0); err != nil { | 65 | + | if err := linuxterm.UnlockPt(masterFd); err != nil { |
| 66 | return nil, fmt.Errorf("pty: TIOCSPTLCK: %w", err) | 66 | return nil, fmt.Errorf("pty: TIOCSPTLCK: %w", err) | ||
| 67 | } | 67 | } | ||
| 68 | 68 | ||||
| 69 | slaveName := fmt.Sprintf("/dev/pts/%d", ptn) | 69 | slaveName := fmt.Sprintf("/dev/pts/%d", ptn) | ||
| 70 | - | slave, err := os.OpenFile(slaveName, os.O_RDWR|unix.O_NOCTTY, 0) | 70 | + | slave, err := os.OpenFile(slaveName, os.O_RDWR|syscall.O_NOCTTY, 0) |
| 71 | if err != nil { | 71 | if err != nil { | ||
| 72 | return nil, fmt.Errorf("pty: open %s: %w", slaveName, err) | 72 | return nil, fmt.Errorf("pty: open %s: %w", slaveName, err) | ||
| 73 | } | 73 | } | ||
| @@ -141,7 +141,7 @@ var _ ports.PTY = (*linuxPTY)(nil) | |||||
| 141 | // normal end of stream, so EIO is mapped to io.EOF here. | 141 | // normal end of stream, so EIO is mapped to io.EOF here. | ||
| 142 | func (p *linuxPTY) Read(b []byte) (int, error) { | 142 | func (p *linuxPTY) Read(b []byte) (int, error) { | ||
| 143 | n, err := p.master.Read(b) | 143 | n, err := p.master.Read(b) | ||
| 144 | - | if err != nil && errors.Is(err, unix.EIO) { | 144 | + | if err != nil && errors.Is(err, syscall.EIO) { |
| 145 | return n, io.EOF | 145 | return n, io.EOF | ||
| 146 | } | 146 | } | ||
| 147 | return n, err | 147 | return n, err | ||
| @@ -189,7 +189,7 @@ func (p *linuxPTY) ForegroundPgid() (int, error) { | |||||
| 189 | var pgid int | 189 | var pgid int | ||
| 190 | var ioctlErr error | 190 | var ioctlErr error | ||
| 191 | if err := rc.Control(func(fd uintptr) { | 191 | if err := rc.Control(func(fd uintptr) { | ||
| 192 | - | pgid, ioctlErr = unix.IoctlGetInt(int(fd), unix.TIOCGPGRP) | 192 | + | pgid, ioctlErr = linuxterm.ForegroundProcessGroup(int(fd)) |
| 193 | }); err != nil { | 193 | }); err != nil { | ||
| 194 | return 0, fmt.Errorf("pty: foreground pgid: %w", err) | 194 | return 0, fmt.Errorf("pty: foreground pgid: %w", err) | ||
| 195 | } | 195 | } | ||
| @@ -235,13 +235,10 @@ func (p *linuxPTY) Close() error { | |||||
| 235 | // child as its own session and process-group leader, so a negative pid reaches | 235 | // child as its own session and process-group leader, so a negative pid reaches | ||
| 236 | // both the command and every descendant that remains in its process group. | 236 | // both the command and every descendant that remains in its process group. | ||
| 237 | func signalProcessGroup(pid int, signal syscall.Signal) error { | 237 | func signalProcessGroup(pid int, signal syscall.Signal) error { | ||
| 238 | - | return unix.Kill(-pid, signal) | 238 | + | return syscall.Kill(-pid, signal) |
| 239 | } | 239 | } | ||
| 240 | 240 | ||||
| 241 | // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ. | 241 | // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ. | ||
| 242 | func setWinsize(fd int, sz domain.Size) error { | 242 | func setWinsize(fd int, sz domain.Size) error { | ||
| 243 | - | return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{ | 243 | + | return linuxterm.SetWinsize(fd, uint16(sz.Cols), uint16(sz.Rows)) |
| 244 | - | Row: uint16(sz.Rows), | |||
| 245 | - | Col: uint16(sz.Cols), | |||
| 246 | - | }) | |||
| 247 | } | 244 | } | ||
M internal/adapters/term/pty_integration_test.go +7 -7
| @@ -6,10 +6,11 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "io" | |
| 8 | 8 | "os" | |
| 9 | + | "syscall" | |
| 9 | 10 | "testing" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| 12 | - | "golang.org/x/sys/unix" | |
| 13 | + | "github.com/bnema/vev/pkg/linuxterm" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | // openPTYPair opens a real Linux PTY master/slave pair directly via | |
| @@ -18,24 +19,24 @@ import ( | |||
| 18 | 19 | func openPTYPair(t *testing.T) (master, slave *os.File) { | |
| 19 | 20 | t.Helper() | |
| 20 | 21 | ||
| 21 | - | m, err := os.OpenFile("/dev/ptmx", os.O_RDWR|unix.O_NOCTTY, 0) | |
| 22 | + | m, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY, 0) | |
| 22 | 23 | if err != nil { | |
| 23 | 24 | t.Fatalf("open /dev/ptmx: %v", err) | |
| 24 | 25 | } | |
| 25 | 26 | ||
| 26 | - | if err := unix.IoctlSetPointerInt(int(m.Fd()), unix.TIOCSPTLCK, 0); err != nil { | |
| 27 | + | if err := linuxterm.UnlockPt(int(m.Fd())); err != nil { | |
| 27 | 28 | _ = m.Close() | |
| 28 | 29 | t.Fatalf("unlock pty (TIOCSPTLCK): %v", err) | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 31 | - | n, err := unix.IoctlGetInt(int(m.Fd()), unix.TIOCGPTN) | |
| 32 | + | n, err := linuxterm.PtsNumber(int(m.Fd())) | |
| 32 | 33 | if err != nil { | |
| 33 | 34 | _ = m.Close() | |
| 34 | 35 | t.Fatalf("get pty number (TIOCGPTN): %v", err) | |
| 35 | 36 | } | |
| 36 | 37 | ||
| 37 | 38 | slavePath := fmt.Sprintf("/dev/pts/%d", n) | |
| 38 | - | s, err := os.OpenFile(slavePath, os.O_RDWR|unix.O_NOCTTY, 0) | |
| 39 | + | s, err := os.OpenFile(slavePath, os.O_RDWR|syscall.O_NOCTTY, 0) | |
| 39 | 40 | if err != nil { | |
| 40 | 41 | _ = m.Close() | |
| 41 | 42 | t.Fatalf("open slave %s: %v", slavePath, err) | |
| @@ -53,8 +54,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) { | |||
| 53 | 54 | defer func() { _ = master.Close() }() | |
| 54 | 55 | defer func() { _ = slave.Close() }() | |
| 55 | 56 | ||
| 56 | - | ws := &unix.Winsize{Row: 24, Col: 80} | |
| 57 | - | if err := unix.IoctlSetWinsize(int(slave.Fd()), unix.TIOCSWINSZ, ws); err != nil { | |
| 57 | + | if err := linuxterm.SetWinsize(int(slave.Fd()), 80, 24); err != nil { | |
| 58 | 58 | t.Fatalf("set winsize: %v", err) | |
| 59 | 59 | } | |
| 60 | 60 | ||
| @@ -6,10 +6,11 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "io" | 7 | "io" | ||
| 8 | "os" | 8 | "os" | ||
| 9 | + | "syscall" | |||
| 9 | "testing" | 10 | "testing" | ||
| 10 | "time" | 11 | "time" | ||
| 11 | 12 | ||||
| 12 | - | "golang.org/x/sys/unix" | 13 | + | "github.com/bnema/vev/pkg/linuxterm" |
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| 15 | // openPTYPair opens a real Linux PTY master/slave pair directly via | 16 | // openPTYPair opens a real Linux PTY master/slave pair directly via | ||
| @@ -18,24 +19,24 @@ import ( | |||||
| 18 | func openPTYPair(t *testing.T) (master, slave *os.File) { | 19 | func openPTYPair(t *testing.T) (master, slave *os.File) { | ||
| 19 | t.Helper() | 20 | t.Helper() | ||
| 20 | 21 | ||||
| 21 | - | m, err := os.OpenFile("/dev/ptmx", os.O_RDWR|unix.O_NOCTTY, 0) | 22 | + | m, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY, 0) |
| 22 | if err != nil { | 23 | if err != nil { | ||
| 23 | t.Fatalf("open /dev/ptmx: %v", err) | 24 | t.Fatalf("open /dev/ptmx: %v", err) | ||
| 24 | } | 25 | } | ||
| 25 | 26 | ||||
| 26 | - | if err := unix.IoctlSetPointerInt(int(m.Fd()), unix.TIOCSPTLCK, 0); err != nil { | 27 | + | if err := linuxterm.UnlockPt(int(m.Fd())); err != nil { |
| 27 | _ = m.Close() | 28 | _ = m.Close() | ||
| 28 | t.Fatalf("unlock pty (TIOCSPTLCK): %v", err) | 29 | t.Fatalf("unlock pty (TIOCSPTLCK): %v", err) | ||
| 29 | } | 30 | } | ||
| 30 | 31 | ||||
| 31 | - | n, err := unix.IoctlGetInt(int(m.Fd()), unix.TIOCGPTN) | 32 | + | n, err := linuxterm.PtsNumber(int(m.Fd())) |
| 32 | if err != nil { | 33 | if err != nil { | ||
| 33 | _ = m.Close() | 34 | _ = m.Close() | ||
| 34 | t.Fatalf("get pty number (TIOCGPTN): %v", err) | 35 | t.Fatalf("get pty number (TIOCGPTN): %v", err) | ||
| 35 | } | 36 | } | ||
| 36 | 37 | ||||
| 37 | slavePath := fmt.Sprintf("/dev/pts/%d", n) | 38 | slavePath := fmt.Sprintf("/dev/pts/%d", n) | ||
| 38 | - | s, err := os.OpenFile(slavePath, os.O_RDWR|unix.O_NOCTTY, 0) | 39 | + | s, err := os.OpenFile(slavePath, os.O_RDWR|syscall.O_NOCTTY, 0) |
| 39 | if err != nil { | 40 | if err != nil { | ||
| 40 | _ = m.Close() | 41 | _ = m.Close() | ||
| 41 | t.Fatalf("open slave %s: %v", slavePath, err) | 42 | t.Fatalf("open slave %s: %v", slavePath, err) | ||
| @@ -53,8 +54,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) { | |||||
| 53 | defer func() { _ = master.Close() }() | 54 | defer func() { _ = master.Close() }() | ||
| 54 | defer func() { _ = slave.Close() }() | 55 | defer func() { _ = slave.Close() }() | ||
| 55 | 56 | ||||
| 56 | - | ws := &unix.Winsize{Row: 24, Col: 80} | 57 | + | if err := linuxterm.SetWinsize(int(slave.Fd()), 80, 24); err != nil { |
| 57 | - | if err := unix.IoctlSetWinsize(int(slave.Fd()), unix.TIOCSWINSZ, ws); err != nil { | |||
| 58 | t.Fatalf("set winsize: %v", err) | 58 | t.Fatalf("set winsize: %v", err) | ||
| 59 | } | 59 | } | ||
| 60 | 60 | ||||
M internal/adapters/term/terminal.go +10 -11
| @@ -9,10 +9,9 @@ import ( | |||
| 9 | 9 | "sync" | |
| 10 | 10 | "syscall" | |
| 11 | 11 | ||
| 12 | - | "golang.org/x/term" | |
| 13 | - | ||
| 14 | 12 | "github.com/bnema/vev/internal/domain" | |
| 15 | 13 | "github.com/bnema/vev/internal/ports" | |
| 14 | + | "github.com/bnema/vev/pkg/linuxterm" | |
| 16 | 15 | ) | |
| 17 | 16 | ||
| 18 | 17 | // Compile-time check that Terminal satisfies ports.Terminal. | |
| @@ -50,10 +49,10 @@ type Terminal struct { | |||
| 50 | 49 | bw *batchWriter | |
| 51 | 50 | ||
| 52 | 51 | mu sync.Mutex | |
| 53 | - | orig *term.State // saved terminal state; nil when raw mode wasn't entered (or was restored) | |
| 54 | - | entered bool // true between a successful EnterRaw and its restore | |
| 55 | - | rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped | |
| 56 | - | restoreFn func() error // the single idempotent restore closure for the current session | |
| 52 | + | orig *linuxterm.State // saved terminal state; nil when raw mode wasn't entered (or was restored) | |
| 53 | + | entered bool // true between a successful EnterRaw and its restore | |
| 54 | + | rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped | |
| 55 | + | restoreFn func() error // the single idempotent restore closure for the current session | |
| 57 | 56 | ||
| 58 | 57 | // Resize watcher state, also guarded by mu so watcher start | |
| 59 | 58 | // (ResizeEvents) and watcher stop (restore) are strictly ordered: | |
| @@ -96,9 +95,9 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||
| 96 | 95 | return t.restoreFn, nil | |
| 97 | 96 | } | |
| 98 | 97 | ||
| 99 | - | var old *term.State | |
| 100 | - | if term.IsTerminal(t.fd) { | |
| 101 | - | s, err := term.MakeRaw(t.fd) | |
| 98 | + | var old *linuxterm.State | |
| 99 | + | if linuxterm.IsTerminal(t.fd) { | |
| 100 | + | s, err := linuxterm.MakeRaw(t.fd) | |
| 102 | 101 | if err != nil { | |
| 103 | 102 | return nil, fmt.Errorf("term: make raw: %w", err) | |
| 104 | 103 | } | |
| @@ -171,14 +170,14 @@ func (t *Terminal) restoreRawLocked() error { | |||
| 171 | 170 | if t.orig == nil { | |
| 172 | 171 | return nil | |
| 173 | 172 | } | |
| 174 | - | err := term.Restore(t.fd, t.orig) | |
| 173 | + | err := linuxterm.Restore(t.fd, t.orig) | |
| 175 | 174 | t.orig = nil | |
| 176 | 175 | return err | |
| 177 | 176 | } | |
| 178 | 177 | ||
| 179 | 178 | // Size returns the current terminal dimensions. | |
| 180 | 179 | func (t *Terminal) Size() (domain.Size, error) { | |
| 181 | - | cols, rows, err := term.GetSize(t.fd) | |
| 180 | + | cols, rows, err := linuxterm.GetSize(t.fd) | |
| 182 | 181 | if err != nil { | |
| 183 | 182 | return domain.Size{}, fmt.Errorf("term: get size: %w", err) | |
| 184 | 183 | } | |
| @@ -9,10 +9,9 @@ import ( | |||||
| 9 | "sync" | 9 | "sync" | ||
| 10 | "syscall" | 10 | "syscall" | ||
| 11 | 11 | ||||
| 12 | - | "golang.org/x/term" | |||
| 13 | - | ||||
| 14 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 15 | "github.com/bnema/vev/internal/ports" | 13 | "github.com/bnema/vev/internal/ports" | ||
| 14 | + | "github.com/bnema/vev/pkg/linuxterm" | |||
| 16 | ) | 15 | ) | ||
| 17 | 16 | ||||
| 18 | // Compile-time check that Terminal satisfies ports.Terminal. | 17 | // Compile-time check that Terminal satisfies ports.Terminal. | ||
| @@ -50,10 +49,10 @@ type Terminal struct { | |||||
| 50 | bw *batchWriter | 49 | bw *batchWriter | ||
| 51 | 50 | ||||
| 52 | mu sync.Mutex | 51 | mu sync.Mutex | ||
| 53 | - | orig *term.State // saved terminal state; nil when raw mode wasn't entered (or was restored) | 52 | + | orig *linuxterm.State // saved terminal state; nil when raw mode wasn't entered (or was restored) |
| 54 | - | entered bool // true between a successful EnterRaw and its restore | 53 | + | entered bool // true between a successful EnterRaw and its restore |
| 55 | - | rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped | 54 | + | rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped |
| 56 | - | restoreFn func() error // the single idempotent restore closure for the current session | 55 | + | restoreFn func() error // the single idempotent restore closure for the current session |
| 57 | 56 | ||||
| 58 | // Resize watcher state, also guarded by mu so watcher start | 57 | // Resize watcher state, also guarded by mu so watcher start | ||
| 59 | // (ResizeEvents) and watcher stop (restore) are strictly ordered: | 58 | // (ResizeEvents) and watcher stop (restore) are strictly ordered: | ||
| @@ -96,9 +95,9 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||||
| 96 | return t.restoreFn, nil | 95 | return t.restoreFn, nil | ||
| 97 | } | 96 | } | ||
| 98 | 97 | ||||
| 99 | - | var old *term.State | 98 | + | var old *linuxterm.State |
| 100 | - | if term.IsTerminal(t.fd) { | 99 | + | if linuxterm.IsTerminal(t.fd) { |
| 101 | - | s, err := term.MakeRaw(t.fd) | 100 | + | s, err := linuxterm.MakeRaw(t.fd) |
| 102 | if err != nil { | 101 | if err != nil { | ||
| 103 | return nil, fmt.Errorf("term: make raw: %w", err) | 102 | return nil, fmt.Errorf("term: make raw: %w", err) | ||
| 104 | } | 103 | } | ||
| @@ -171,14 +170,14 @@ func (t *Terminal) restoreRawLocked() error { | |||||
| 171 | if t.orig == nil { | 170 | if t.orig == nil { | ||
| 172 | return nil | 171 | return nil | ||
| 173 | } | 172 | } | ||
| 174 | - | err := term.Restore(t.fd, t.orig) | 173 | + | err := linuxterm.Restore(t.fd, t.orig) |
| 175 | t.orig = nil | 174 | t.orig = nil | ||
| 176 | return err | 175 | return err | ||
| 177 | } | 176 | } | ||
| 178 | 177 | ||||
| 179 | // Size returns the current terminal dimensions. | 178 | // Size returns the current terminal dimensions. | ||
| 180 | func (t *Terminal) Size() (domain.Size, error) { | 179 | func (t *Terminal) Size() (domain.Size, error) { | ||
| 181 | - | cols, rows, err := term.GetSize(t.fd) | 180 | + | cols, rows, err := linuxterm.GetSize(t.fd) |
| 182 | if err != nil { | 181 | if err != nil { | ||
| 183 | return domain.Size{}, fmt.Errorf("term: get size: %w", err) | 182 | return domain.Size{}, fmt.Errorf("term: get size: %w", err) | ||
| 184 | } | 183 | } | ||
A pkg/linuxterm/linuxterm.go +126 -0
| @@ -0,0 +1,126 @@ | |||
| 1 | + | // Package linuxterm provides the small set of terminal-control primitives | |
| 2 | + | // vev needs (raw mode, window size, pty allocation) directly on top of the | |
| 3 | + | // frozen stdlib syscall package, so the module does not depend on | |
| 4 | + | // golang.org/x/term or golang.org/x/sys. vev is Linux-only, and the Linux | |
| 5 | + | // TIOC*/TCGETS ioctl numbers and Termios layout syscall exposes are stable. | |
| 6 | + | package linuxterm | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "fmt" | |
| 10 | + | "syscall" | |
| 11 | + | "unsafe" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // State is an opaque snapshot of a terminal's termios settings, captured by | |
| 15 | + | // MakeRaw and consumed by Restore. | |
| 16 | + | type State struct { | |
| 17 | + | termios syscall.Termios | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // winsize mirrors the kernel's struct winsize for TIOCGWINSZ/TIOCSWINSZ. | |
| 21 | + | type winsize struct { | |
| 22 | + | Row uint16 | |
| 23 | + | Col uint16 | |
| 24 | + | Xpixel uint16 | |
| 25 | + | Ypixel uint16 | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // ioctl issues a SYS_IOCTL syscall against fd with the given request and | |
| 29 | + | // argument pointer, returning a Go error on failure. The pointer-to-uintptr | |
| 30 | + | // conversion must happen inside the Syscall argument list (unsafe.Pointer | |
| 31 | + | // rule 4), so arg stays a live pointer until the call. | |
| 32 | + | func ioctl(fd int, req uintptr, arg unsafe.Pointer) error { | |
| 33 | + | _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), req, uintptr(arg)) | |
| 34 | + | if errno != 0 { | |
| 35 | + | return errno | |
| 36 | + | } | |
| 37 | + | return nil | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // IsTerminal reports whether fd refers to a terminal device. | |
| 41 | + | func IsTerminal(fd int) bool { | |
| 42 | + | var t syscall.Termios | |
| 43 | + | return ioctl(fd, syscall.TCGETS, unsafe.Pointer(&t)) == nil | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // MakeRaw puts the terminal referenced by fd into raw mode and returns its | |
| 47 | + | // pre-modification state, so a caller can later restore it. The flag | |
| 48 | + | // changes applied match golang.org/x/term's MakeRaw semantics exactly. | |
| 49 | + | func MakeRaw(fd int) (*State, error) { | |
| 50 | + | var oldState State | |
| 51 | + | if err := ioctl(fd, syscall.TCGETS, unsafe.Pointer(&oldState.termios)); err != nil { | |
| 52 | + | return nil, fmt.Errorf("linuxterm: get termios: %w", err) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | raw := oldState.termios | |
| 56 | + | raw.Iflag &^= syscall.IGNBRK | syscall.BRKINT | syscall.PARMRK | syscall.ISTRIP | syscall.INLCR | syscall.IGNCR | syscall.ICRNL | syscall.IXON | |
| 57 | + | raw.Oflag &^= syscall.OPOST | |
| 58 | + | raw.Lflag &^= syscall.ECHO | syscall.ECHONL | syscall.ICANON | syscall.ISIG | syscall.IEXTEN | |
| 59 | + | raw.Cflag &^= syscall.CSIZE | syscall.PARENB | |
| 60 | + | raw.Cflag |= syscall.CS8 | |
| 61 | + | raw.Cc[syscall.VMIN] = 1 | |
| 62 | + | raw.Cc[syscall.VTIME] = 0 | |
| 63 | + | ||
| 64 | + | if err := ioctl(fd, syscall.TCSETS, unsafe.Pointer(&raw)); err != nil { | |
| 65 | + | return nil, fmt.Errorf("linuxterm: set termios: %w", err) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | return &oldState, nil | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // Restore applies a previously captured State back to the terminal | |
| 72 | + | // referenced by fd. | |
| 73 | + | func Restore(fd int, s *State) error { | |
| 74 | + | if err := ioctl(fd, syscall.TCSETS, unsafe.Pointer(&s.termios)); err != nil { | |
| 75 | + | return fmt.Errorf("linuxterm: set termios: %w", err) | |
| 76 | + | } | |
| 77 | + | return nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // GetSize returns the terminal's current column and row count. | |
| 81 | + | func GetSize(fd int) (cols, rows int, err error) { | |
| 82 | + | var ws winsize | |
| 83 | + | if err := ioctl(fd, syscall.TIOCGWINSZ, unsafe.Pointer(&ws)); err != nil { | |
| 84 | + | return 0, 0, fmt.Errorf("linuxterm: get winsize: %w", err) | |
| 85 | + | } | |
| 86 | + | return int(ws.Col), int(ws.Row), nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // SetWinsize sets the terminal's column and row count. | |
| 90 | + | func SetWinsize(fd int, cols, rows uint16) error { | |
| 91 | + | ws := winsize{Row: rows, Col: cols} | |
| 92 | + | if err := ioctl(fd, syscall.TIOCSWINSZ, unsafe.Pointer(&ws)); err != nil { | |
| 93 | + | return fmt.Errorf("linuxterm: set winsize: %w", err) | |
| 94 | + | } | |
| 95 | + | return nil | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // PtsNumber returns the pty slave number for the master referenced by | |
| 99 | + | // masterFD (TIOCGPTN). | |
| 100 | + | func PtsNumber(masterFD int) (int, error) { | |
| 101 | + | var n int32 | |
| 102 | + | if err := ioctl(masterFD, syscall.TIOCGPTN, unsafe.Pointer(&n)); err != nil { | |
| 103 | + | return 0, fmt.Errorf("linuxterm: get pts number: %w", err) | |
| 104 | + | } | |
| 105 | + | return int(n), nil | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // UnlockPt unlocks the pty slave associated with masterFD so it can be | |
| 109 | + | // opened (TIOCSPTLCK). | |
| 110 | + | func UnlockPt(masterFD int) error { | |
| 111 | + | var unlock int32 | |
| 112 | + | if err := ioctl(masterFD, syscall.TIOCSPTLCK, unsafe.Pointer(&unlock)); err != nil { | |
| 113 | + | return fmt.Errorf("linuxterm: unlock pty: %w", err) | |
| 114 | + | } | |
| 115 | + | return nil | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // ForegroundProcessGroup returns the process group id currently in the | |
| 119 | + | // foreground for the terminal referenced by fd (TIOCGPGRP). | |
| 120 | + | func ForegroundProcessGroup(fd int) (int, error) { | |
| 121 | + | var pgid int32 | |
| 122 | + | if err := ioctl(fd, syscall.TIOCGPGRP, unsafe.Pointer(&pgid)); err != nil { | |
| 123 | + | return 0, fmt.Errorf("linuxterm: get foreground process group: %w", err) | |
| 124 | + | } | |
| 125 | + | return int(pgid), nil | |
| 126 | + | } | |
| @@ -0,0 +1,126 @@ | |||||
| 1 | + | // Package linuxterm provides the small set of terminal-control primitives | |||
| 2 | + | // vev needs (raw mode, window size, pty allocation) directly on top of the | |||
| 3 | + | // frozen stdlib syscall package, so the module does not depend on | |||
| 4 | + | // golang.org/x/term or golang.org/x/sys. vev is Linux-only, and the Linux | |||
| 5 | + | // TIOC*/TCGETS ioctl numbers and Termios layout syscall exposes are stable. | |||
| 6 | + | package linuxterm | |||
| 7 | + | ||||
| 8 | + | import ( | |||
| 9 | + | "fmt" | |||
| 10 | + | "syscall" | |||
| 11 | + | "unsafe" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // State is an opaque snapshot of a terminal's termios settings, captured by | |||
| 15 | + | // MakeRaw and consumed by Restore. | |||
| 16 | + | type State struct { | |||
| 17 | + | termios syscall.Termios | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // winsize mirrors the kernel's struct winsize for TIOCGWINSZ/TIOCSWINSZ. | |||
| 21 | + | type winsize struct { | |||
| 22 | + | Row uint16 | |||
| 23 | + | Col uint16 | |||
| 24 | + | Xpixel uint16 | |||
| 25 | + | Ypixel uint16 | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // ioctl issues a SYS_IOCTL syscall against fd with the given request and | |||
| 29 | + | // argument pointer, returning a Go error on failure. The pointer-to-uintptr | |||
| 30 | + | // conversion must happen inside the Syscall argument list (unsafe.Pointer | |||
| 31 | + | // rule 4), so arg stays a live pointer until the call. | |||
| 32 | + | func ioctl(fd int, req uintptr, arg unsafe.Pointer) error { | |||
| 33 | + | _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), req, uintptr(arg)) | |||
| 34 | + | if errno != 0 { | |||
| 35 | + | return errno | |||
| 36 | + | } | |||
| 37 | + | return nil | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // IsTerminal reports whether fd refers to a terminal device. | |||
| 41 | + | func IsTerminal(fd int) bool { | |||
| 42 | + | var t syscall.Termios | |||
| 43 | + | return ioctl(fd, syscall.TCGETS, unsafe.Pointer(&t)) == nil | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // MakeRaw puts the terminal referenced by fd into raw mode and returns its | |||
| 47 | + | // pre-modification state, so a caller can later restore it. The flag | |||
| 48 | + | // changes applied match golang.org/x/term's MakeRaw semantics exactly. | |||
| 49 | + | func MakeRaw(fd int) (*State, error) { | |||
| 50 | + | var oldState State | |||
| 51 | + | if err := ioctl(fd, syscall.TCGETS, unsafe.Pointer(&oldState.termios)); err != nil { | |||
| 52 | + | return nil, fmt.Errorf("linuxterm: get termios: %w", err) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | raw := oldState.termios | |||
| 56 | + | raw.Iflag &^= syscall.IGNBRK | syscall.BRKINT | syscall.PARMRK | syscall.ISTRIP | syscall.INLCR | syscall.IGNCR | syscall.ICRNL | syscall.IXON | |||
| 57 | + | raw.Oflag &^= syscall.OPOST | |||
| 58 | + | raw.Lflag &^= syscall.ECHO | syscall.ECHONL | syscall.ICANON | syscall.ISIG | syscall.IEXTEN | |||
| 59 | + | raw.Cflag &^= syscall.CSIZE | syscall.PARENB | |||
| 60 | + | raw.Cflag |= syscall.CS8 | |||
| 61 | + | raw.Cc[syscall.VMIN] = 1 | |||
| 62 | + | raw.Cc[syscall.VTIME] = 0 | |||
| 63 | + | ||||
| 64 | + | if err := ioctl(fd, syscall.TCSETS, unsafe.Pointer(&raw)); err != nil { | |||
| 65 | + | return nil, fmt.Errorf("linuxterm: set termios: %w", err) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | return &oldState, nil | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // Restore applies a previously captured State back to the terminal | |||
| 72 | + | // referenced by fd. | |||
| 73 | + | func Restore(fd int, s *State) error { | |||
| 74 | + | if err := ioctl(fd, syscall.TCSETS, unsafe.Pointer(&s.termios)); err != nil { | |||
| 75 | + | return fmt.Errorf("linuxterm: set termios: %w", err) | |||
| 76 | + | } | |||
| 77 | + | return nil | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // GetSize returns the terminal's current column and row count. | |||
| 81 | + | func GetSize(fd int) (cols, rows int, err error) { | |||
| 82 | + | var ws winsize | |||
| 83 | + | if err := ioctl(fd, syscall.TIOCGWINSZ, unsafe.Pointer(&ws)); err != nil { | |||
| 84 | + | return 0, 0, fmt.Errorf("linuxterm: get winsize: %w", err) | |||
| 85 | + | } | |||
| 86 | + | return int(ws.Col), int(ws.Row), nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // SetWinsize sets the terminal's column and row count. | |||
| 90 | + | func SetWinsize(fd int, cols, rows uint16) error { | |||
| 91 | + | ws := winsize{Row: rows, Col: cols} | |||
| 92 | + | if err := ioctl(fd, syscall.TIOCSWINSZ, unsafe.Pointer(&ws)); err != nil { | |||
| 93 | + | return fmt.Errorf("linuxterm: set winsize: %w", err) | |||
| 94 | + | } | |||
| 95 | + | return nil | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // PtsNumber returns the pty slave number for the master referenced by | |||
| 99 | + | // masterFD (TIOCGPTN). | |||
| 100 | + | func PtsNumber(masterFD int) (int, error) { | |||
| 101 | + | var n int32 | |||
| 102 | + | if err := ioctl(masterFD, syscall.TIOCGPTN, unsafe.Pointer(&n)); err != nil { | |||
| 103 | + | return 0, fmt.Errorf("linuxterm: get pts number: %w", err) | |||
| 104 | + | } | |||
| 105 | + | return int(n), nil | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // UnlockPt unlocks the pty slave associated with masterFD so it can be | |||
| 109 | + | // opened (TIOCSPTLCK). | |||
| 110 | + | func UnlockPt(masterFD int) error { | |||
| 111 | + | var unlock int32 | |||
| 112 | + | if err := ioctl(masterFD, syscall.TIOCSPTLCK, unsafe.Pointer(&unlock)); err != nil { | |||
| 113 | + | return fmt.Errorf("linuxterm: unlock pty: %w", err) | |||
| 114 | + | } | |||
| 115 | + | return nil | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // ForegroundProcessGroup returns the process group id currently in the | |||
| 119 | + | // foreground for the terminal referenced by fd (TIOCGPGRP). | |||
| 120 | + | func ForegroundProcessGroup(fd int) (int, error) { | |||
| 121 | + | var pgid int32 | |||
| 122 | + | if err := ioctl(fd, syscall.TIOCGPGRP, unsafe.Pointer(&pgid)); err != nil { | |||
| 123 | + | return 0, fmt.Errorf("linuxterm: get foreground process group: %w", err) | |||
| 124 | + | } | |||
| 125 | + | return int(pgid), nil | |||
| 126 | + | } | |||
A pkg/linuxterm/linuxterm_test.go +143 -0
| @@ -0,0 +1,143 @@ | |||
| 1 | + | package linuxterm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "syscall" | |
| 8 | + | "testing" | |
| 9 | + | "unsafe" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // openPTYPair opens a real Linux PTY master/slave pair directly via | |
| 13 | + | // /dev/ptmx using the package under test, so these tests double as an | |
| 14 | + | // integration check of PtsNumber/UnlockPt against the kernel. | |
| 15 | + | func openPTYPair(t *testing.T) (master, slave *os.File) { | |
| 16 | + | t.Helper() | |
| 17 | + | ||
| 18 | + | masterFD, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0) | |
| 19 | + | if err != nil { | |
| 20 | + | t.Skipf("open /dev/ptmx: %v", err) | |
| 21 | + | } | |
| 22 | + | master = os.NewFile(uintptr(masterFD), "pty-master") | |
| 23 | + | ||
| 24 | + | if err := UnlockPt(masterFD); err != nil { | |
| 25 | + | _ = master.Close() | |
| 26 | + | t.Fatalf("UnlockPt: %v", err) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | n, err := PtsNumber(masterFD) | |
| 30 | + | if err != nil { | |
| 31 | + | _ = master.Close() | |
| 32 | + | t.Fatalf("PtsNumber: %v", err) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | slavePath := fmt.Sprintf("/dev/pts/%d", n) | |
| 36 | + | slave, err = os.OpenFile(slavePath, os.O_RDWR|syscall.O_NOCTTY, 0) | |
| 37 | + | if err != nil { | |
| 38 | + | _ = master.Close() | |
| 39 | + | t.Fatalf("open slave %s: %v", slavePath, err) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | return master, slave | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestIsTerminal(t *testing.T) { | |
| 46 | + | master, slave := openPTYPair(t) | |
| 47 | + | defer func() { _ = master.Close() }() | |
| 48 | + | defer func() { _ = slave.Close() }() | |
| 49 | + | ||
| 50 | + | if !IsTerminal(int(slave.Fd())) { | |
| 51 | + | t.Errorf("IsTerminal(pty slave) = false, want true") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | r, w, err := os.Pipe() | |
| 55 | + | if err != nil { | |
| 56 | + | t.Fatalf("os.Pipe: %v", err) | |
| 57 | + | } | |
| 58 | + | defer func() { _ = r.Close() }() | |
| 59 | + | defer func() { _ = w.Close() }() | |
| 60 | + | ||
| 61 | + | if IsTerminal(int(r.Fd())) { | |
| 62 | + | t.Errorf("IsTerminal(pipe) = true, want false") | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestSetWinsizeGetSizeRoundtrip(t *testing.T) { | |
| 67 | + | master, slave := openPTYPair(t) | |
| 68 | + | defer func() { _ = master.Close() }() | |
| 69 | + | defer func() { _ = slave.Close() }() | |
| 70 | + | ||
| 71 | + | tests := []struct { | |
| 72 | + | name string | |
| 73 | + | cols uint16 | |
| 74 | + | rows uint16 | |
| 75 | + | }{ | |
| 76 | + | {name: "typical", cols: 80, rows: 24}, | |
| 77 | + | {name: "wide", cols: 220, rows: 60}, | |
| 78 | + | {name: "minimal", cols: 1, rows: 1}, | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | for _, tt := range tests { | |
| 82 | + | t.Run(tt.name, func(t *testing.T) { | |
| 83 | + | if err := SetWinsize(int(slave.Fd()), tt.cols, tt.rows); err != nil { | |
| 84 | + | t.Fatalf("SetWinsize: %v", err) | |
| 85 | + | } | |
| 86 | + | gotCols, gotRows, err := GetSize(int(slave.Fd())) | |
| 87 | + | if err != nil { | |
| 88 | + | t.Fatalf("GetSize: %v", err) | |
| 89 | + | } | |
| 90 | + | if gotCols != int(tt.cols) || gotRows != int(tt.rows) { | |
| 91 | + | t.Fatalf("GetSize = (%d, %d), want (%d, %d)", gotCols, gotRows, tt.cols, tt.rows) | |
| 92 | + | } | |
| 93 | + | }) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestMakeRawRestore(t *testing.T) { | |
| 98 | + | master, slave := openPTYPair(t) | |
| 99 | + | defer func() { _ = master.Close() }() | |
| 100 | + | defer func() { _ = slave.Close() }() | |
| 101 | + | ||
| 102 | + | fd := int(slave.Fd()) | |
| 103 | + | ||
| 104 | + | old, err := MakeRaw(fd) | |
| 105 | + | if err != nil { | |
| 106 | + | t.Fatalf("MakeRaw: %v", err) | |
| 107 | + | } | |
| 108 | + | if old == nil { | |
| 109 | + | t.Fatalf("MakeRaw returned nil state") | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | var current syscall.Termios | |
| 113 | + | if err := ioctl(fd, syscall.TCGETS, unsafe.Pointer(¤t)); err != nil { | |
| 114 | + | t.Fatalf("TCGETS after MakeRaw: %v", err) | |
| 115 | + | } | |
| 116 | + | if current.Lflag&syscall.ECHO != 0 { | |
| 117 | + | t.Errorf("ECHO still set after MakeRaw") | |
| 118 | + | } | |
| 119 | + | if current.Lflag&syscall.ICANON != 0 { | |
| 120 | + | t.Errorf("ICANON still set after MakeRaw") | |
| 121 | + | } | |
| 122 | + | if current.Cc[syscall.VMIN] != 1 { | |
| 123 | + | t.Errorf("Cc[VMIN] = %d, want 1", current.Cc[syscall.VMIN]) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | if err := Restore(fd, old); err != nil { | |
| 127 | + | t.Fatalf("Restore: %v", err) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func TestForegroundProcessGroup(t *testing.T) { | |
| 132 | + | master, slave := openPTYPair(t) | |
| 133 | + | defer func() { _ = master.Close() }() | |
| 134 | + | defer func() { _ = slave.Close() }() | |
| 135 | + | ||
| 136 | + | // No session has been established on this pty (no process ever made it | |
| 137 | + | // a controlling terminal via Setsid+Setctty), so the kernel legitimately | |
| 138 | + | // has no foreground process group to report and returns ENOTTY. That | |
| 139 | + | // still exercises the ioctl path; only an unexpected error is a failure. | |
| 140 | + | if _, err := ForegroundProcessGroup(int(slave.Fd())); err != nil && !errors.Is(err, syscall.ENOTTY) { | |
| 141 | + | t.Fatalf("ForegroundProcessGroup: %v", err) | |
| 142 | + | } | |
| 143 | + | } | |
| @@ -0,0 +1,143 @@ | |||||
| 1 | + | package linuxterm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "syscall" | |||
| 8 | + | "testing" | |||
| 9 | + | "unsafe" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // openPTYPair opens a real Linux PTY master/slave pair directly via | |||
| 13 | + | // /dev/ptmx using the package under test, so these tests double as an | |||
| 14 | + | // integration check of PtsNumber/UnlockPt against the kernel. | |||
| 15 | + | func openPTYPair(t *testing.T) (master, slave *os.File) { | |||
| 16 | + | t.Helper() | |||
| 17 | + | ||||
| 18 | + | masterFD, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0) | |||
| 19 | + | if err != nil { | |||
| 20 | + | t.Skipf("open /dev/ptmx: %v", err) | |||
| 21 | + | } | |||
| 22 | + | master = os.NewFile(uintptr(masterFD), "pty-master") | |||
| 23 | + | ||||
| 24 | + | if err := UnlockPt(masterFD); err != nil { | |||
| 25 | + | _ = master.Close() | |||
| 26 | + | t.Fatalf("UnlockPt: %v", err) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | n, err := PtsNumber(masterFD) | |||
| 30 | + | if err != nil { | |||
| 31 | + | _ = master.Close() | |||
| 32 | + | t.Fatalf("PtsNumber: %v", err) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | slavePath := fmt.Sprintf("/dev/pts/%d", n) | |||
| 36 | + | slave, err = os.OpenFile(slavePath, os.O_RDWR|syscall.O_NOCTTY, 0) | |||
| 37 | + | if err != nil { | |||
| 38 | + | _ = master.Close() | |||
| 39 | + | t.Fatalf("open slave %s: %v", slavePath, err) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | return master, slave | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestIsTerminal(t *testing.T) { | |||
| 46 | + | master, slave := openPTYPair(t) | |||
| 47 | + | defer func() { _ = master.Close() }() | |||
| 48 | + | defer func() { _ = slave.Close() }() | |||
| 49 | + | ||||
| 50 | + | if !IsTerminal(int(slave.Fd())) { | |||
| 51 | + | t.Errorf("IsTerminal(pty slave) = false, want true") | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | r, w, err := os.Pipe() | |||
| 55 | + | if err != nil { | |||
| 56 | + | t.Fatalf("os.Pipe: %v", err) | |||
| 57 | + | } | |||
| 58 | + | defer func() { _ = r.Close() }() | |||
| 59 | + | defer func() { _ = w.Close() }() | |||
| 60 | + | ||||
| 61 | + | if IsTerminal(int(r.Fd())) { | |||
| 62 | + | t.Errorf("IsTerminal(pipe) = true, want false") | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestSetWinsizeGetSizeRoundtrip(t *testing.T) { | |||
| 67 | + | master, slave := openPTYPair(t) | |||
| 68 | + | defer func() { _ = master.Close() }() | |||
| 69 | + | defer func() { _ = slave.Close() }() | |||
| 70 | + | ||||
| 71 | + | tests := []struct { | |||
| 72 | + | name string | |||
| 73 | + | cols uint16 | |||
| 74 | + | rows uint16 | |||
| 75 | + | }{ | |||
| 76 | + | {name: "typical", cols: 80, rows: 24}, | |||
| 77 | + | {name: "wide", cols: 220, rows: 60}, | |||
| 78 | + | {name: "minimal", cols: 1, rows: 1}, | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | for _, tt := range tests { | |||
| 82 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 83 | + | if err := SetWinsize(int(slave.Fd()), tt.cols, tt.rows); err != nil { | |||
| 84 | + | t.Fatalf("SetWinsize: %v", err) | |||
| 85 | + | } | |||
| 86 | + | gotCols, gotRows, err := GetSize(int(slave.Fd())) | |||
| 87 | + | if err != nil { | |||
| 88 | + | t.Fatalf("GetSize: %v", err) | |||
| 89 | + | } | |||
| 90 | + | if gotCols != int(tt.cols) || gotRows != int(tt.rows) { | |||
| 91 | + | t.Fatalf("GetSize = (%d, %d), want (%d, %d)", gotCols, gotRows, tt.cols, tt.rows) | |||
| 92 | + | } | |||
| 93 | + | }) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestMakeRawRestore(t *testing.T) { | |||
| 98 | + | master, slave := openPTYPair(t) | |||
| 99 | + | defer func() { _ = master.Close() }() | |||
| 100 | + | defer func() { _ = slave.Close() }() | |||
| 101 | + | ||||
| 102 | + | fd := int(slave.Fd()) | |||
| 103 | + | ||||
| 104 | + | old, err := MakeRaw(fd) | |||
| 105 | + | if err != nil { | |||
| 106 | + | t.Fatalf("MakeRaw: %v", err) | |||
| 107 | + | } | |||
| 108 | + | if old == nil { | |||
| 109 | + | t.Fatalf("MakeRaw returned nil state") | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | var current syscall.Termios | |||
| 113 | + | if err := ioctl(fd, syscall.TCGETS, unsafe.Pointer(¤t)); err != nil { | |||
| 114 | + | t.Fatalf("TCGETS after MakeRaw: %v", err) | |||
| 115 | + | } | |||
| 116 | + | if current.Lflag&syscall.ECHO != 0 { | |||
| 117 | + | t.Errorf("ECHO still set after MakeRaw") | |||
| 118 | + | } | |||
| 119 | + | if current.Lflag&syscall.ICANON != 0 { | |||
| 120 | + | t.Errorf("ICANON still set after MakeRaw") | |||
| 121 | + | } | |||
| 122 | + | if current.Cc[syscall.VMIN] != 1 { | |||
| 123 | + | t.Errorf("Cc[VMIN] = %d, want 1", current.Cc[syscall.VMIN]) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | if err := Restore(fd, old); err != nil { | |||
| 127 | + | t.Fatalf("Restore: %v", err) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func TestForegroundProcessGroup(t *testing.T) { | |||
| 132 | + | master, slave := openPTYPair(t) | |||
| 133 | + | defer func() { _ = master.Close() }() | |||
| 134 | + | defer func() { _ = slave.Close() }() | |||
| 135 | + | ||||
| 136 | + | // No session has been established on this pty (no process ever made it | |||
| 137 | + | // a controlling terminal via Setsid+Setctty), so the kernel legitimately | |||
| 138 | + | // has no foreground process group to report and returns ENOTTY. That | |||
| 139 | + | // still exercises the ioctl path; only an unexpected error is a failure. | |||
| 140 | + | if _, err := ForegroundProcessGroup(int(slave.Fd())); err != nil && !errors.Is(err, syscall.ENOTTY) { | |||
| 141 | + | t.Fatalf("ForegroundProcessGroup: %v", err) | |||
| 142 | + | } | |||
| 143 | + | } | |||