detached 4ab3b08

Merge pull request #101 from bnema/feat/darwin-arm64

feat: darwin/arm64 support
commit
4ab3b08db0603fd23f91f8cbb1d7171b4beb70aa
author
bnema · · 2026-07-17 06:40 UTC
committer
GitHub · · 2026-07-17 06:40 UTC
merge of
0214273 + 05ece64 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
41 files · +1410 -153
M .github/workflows/ci.yml +13 -2
@@ -14,9 +14,13 @@ permissions:
1414
1515 jobs:
1616 test:
17- name: Test
18- runs-on: ubuntu-latest
17+ name: Test (${{ matrix.os }})
18+ runs-on: ${{ matrix.os }}
1919 timeout-minutes: 15
20+ strategy:
21+ fail-fast: false
22+ matrix:
23+ os: [ubuntu-latest, macos-14]
2024 steps:
2125 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
2226 with:
@@ -32,3 +36,10 @@ jobs:
3236 run: make lint
3337 - name: Test
3438 run: make test
39+ - name: Cross-compile darwin/arm64
40+ if: matrix.os == 'ubuntu-latest'
41+ env:
42+ CGO_ENABLED: 0
43+ GOARCH: arm64
44+ GOOS: darwin
45+ run: go build ./...
@@ -14,9 +14,13 @@ permissions:
14 14
15 jobs:15 jobs:
16 test:16 test:
17- name: Test17+ name: Test (${{ matrix.os }})
18- runs-on: ubuntu-latest18+ runs-on: ${{ matrix.os }}
19 timeout-minutes: 1519 timeout-minutes: 15
20+ strategy:
21+ fail-fast: false
22+ matrix:
23+ os: [ubuntu-latest, macos-14]
20 steps:24 steps:
21 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.025 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
22 with:26 with:
@@ -32,3 +36,10 @@ jobs:
32 run: make lint36 run: make lint
33 - name: Test37 - name: Test
34 run: make test38 run: make test
39+ - name: Cross-compile darwin/arm64
40+ if: matrix.os == 'ubuntu-latest'
41+ env:
42+ CGO_ENABLED: 0
43+ GOARCH: arm64
44+ GOOS: darwin
45+ run: go build ./...
M cmd/vev-perf-harness/evidence.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build linux
2+
13 package main
24
35 import (
@@ -1,3 +1,5 @@
1+//go:build linux
2+
1 package main3 package main
2 4
3 import (5 import (
M cmd/vev-perf-harness/evidence_test.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build linux
2+
13 package main
24
35 import (
@@ -1,3 +1,5 @@
1+//go:build linux
2+
1 package main3 package main
2 4
3 import (5 import (
M cmd/vev-perf-harness/launcher.go +5 -3
@@ -1,3 +1,5 @@
1+//go:build linux
2+
13 package main
24
35 import (
@@ -15,7 +17,7 @@ import (
1517 "syscall"
1618 "time"
1719
18- "github.com/bnema/vev/pkg/linuxterm"
20+ "github.com/bnema/vev/pkg/rawterm"
1921 "github.com/bnema/vev/pkg/safedir"
2022 )
2123
@@ -731,11 +733,11 @@ func openPTY() (*os.File, *os.File, error) {
731733 return nil, nil, err
732734 }
733735 closeMaster := func(err error) (*os.File, *os.File, error) { _ = syscall.Close(masterFD); return nil, nil, err }
734- number, err := linuxterm.PtsNumber(masterFD)
736+ number, err := rawterm.PtsNumber(masterFD)
735737 if err != nil {
736738 return closeMaster(err)
737739 }
738- if err := linuxterm.UnlockPt(masterFD); err != nil {
740+ if err := rawterm.UnlockPt(masterFD); err != nil {
739741 return closeMaster(err)
740742 }
741743 slaveFD, err := syscall.Open(fmt.Sprintf("/dev/pts/%d", number), syscall.O_RDWR|syscall.O_NOCTTY, 0)
@@ -1,3 +1,5 @@
1+//go:build linux
2+
1 package main3 package main
2 4
3 import (5 import (
@@ -15,7 +17,7 @@ import (
15 "syscall"17 "syscall"
16 "time"18 "time"
17 19
18- "github.com/bnema/vev/pkg/linuxterm"20+ "github.com/bnema/vev/pkg/rawterm"
19 "github.com/bnema/vev/pkg/safedir"21 "github.com/bnema/vev/pkg/safedir"
20 )22 )
21 23
@@ -731,11 +733,11 @@ func openPTY() (*os.File, *os.File, error) {
731 return nil, nil, err733 return nil, nil, err
732 }734 }
733 closeMaster := func(err error) (*os.File, *os.File, error) { _ = syscall.Close(masterFD); return nil, nil, err }735 closeMaster := func(err error) (*os.File, *os.File, error) { _ = syscall.Close(masterFD); return nil, nil, err }
734- number, err := linuxterm.PtsNumber(masterFD)736+ number, err := rawterm.PtsNumber(masterFD)
735 if err != nil {737 if err != nil {
736 return closeMaster(err)738 return closeMaster(err)
737 }739 }
738- if err := linuxterm.UnlockPt(masterFD); err != nil {740+ if err := rawterm.UnlockPt(masterFD); err != nil {
739 return closeMaster(err)741 return closeMaster(err)
740 }742 }
741 slaveFD, err := syscall.Open(fmt.Sprintf("/dev/pts/%d", number), syscall.O_RDWR|syscall.O_NOCTTY, 0)743 slaveFD, err := syscall.Open(fmt.Sprintf("/dev/pts/%d", number), syscall.O_RDWR|syscall.O_NOCTTY, 0)
M cmd/vev-perf-harness/launcher_test.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build linux
2+
13 package main
24
35 import (
@@ -1,3 +1,5 @@
1+//go:build linux
2+
1 package main3 package main
2 4
3 import (5 import (
M cmd/vev-perf-harness/main.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build linux
2+
13 package main
24
35 import (
@@ -1,3 +1,5 @@
1+//go:build linux
2+
1 package main3 package main
2 4
3 import (5 import (
M cmd/vev-perf-harness/model.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build linux
2+
13 package main
24
35 import "time"
@@ -1,3 +1,5 @@
1+//go:build linux
2+
1 package main3 package main
2 4
3 import "time"5 import "time"
M cmd/vev-perf-harness/options_manifest.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build linux
2+
13 package main
24
35 import (
@@ -1,3 +1,5 @@
1+//go:build linux
2+
1 package main3 package main
2 4
3 import (5 import (
M cmd/vev-perf-harness/options_manifest_test.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build linux
2+
13 package main
24
35 import (
@@ -1,3 +1,5 @@
1+//go:build linux
2+
1 package main3 package main
2 4
3 import (5 import (
M cmd/vev-perf-harness/runner.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build linux
2+
13 package main
24
35 import (
@@ -1,3 +1,5 @@
1+//go:build linux
2+
1 package main3 package main
2 4
3 import (5 import (
M cmd/vev-perf-harness/runner_test.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build linux
2+
13 package main
24
35 import (
@@ -1,3 +1,5 @@
1+//go:build linux
2+
1 package main3 package main
2 4
3 import (5 import (
M cmd/vev-perf-harness/test_helpers_test.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build linux
2+
13 package main
24
35 import (
@@ -1,3 +1,5 @@
1+//go:build linux
2+
1 package main3 package main
2 4
3 import (5 import (
M cmd/vev-perf-harness/udp_netem.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build linux
2+
13 package main
24
35 import (
@@ -1,3 +1,5 @@
1+//go:build linux
2+
1 package main3 package main
2 4
3 import (5 import (
M cmd/vev-perf-harness/udp_netem_test.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build linux
2+
13 package main
24
35 import (
@@ -1,3 +1,5 @@
1+//go:build linux
2+
1 package main3 package main
2 4
3 import (5 import (
A internal/adapters/clipboard/osascript_darwin.go +49 -0
@@ -0,0 +1,49 @@
1+//go:build darwin
2+
3+package clipboard
4+
5+import (
6+ "context"
7+ "os/exec"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+const osaReadTimeout = time.Second
14+
15+// OSA implements ports.ClipboardReader using the macOS pasteboard through
16+// osascript. It holds no state and is safe for concurrent use.
17+type OSA struct{}
18+
19+var _ ports.ClipboardReader = (*OSA)(nil)
20+
21+// New returns the native clipboard reader for this operating system.
22+func New() ports.ClipboardReader { return &OSA{} }
23+
24+// ReadImage reads the preferred pasteboard image representation. osascript
25+// reports unavailable pasteboard flavors as command failures; those and all
26+// malformed outputs are intentionally indistinguishable to callers from an
27+// empty clipboard.
28+func (*OSA) ReadImage(ctx context.Context) (string, []byte, error) {
29+ for _, format := range []struct {
30+ class string
31+ mime string
32+ }{
33+ {class: "PNGf", mime: "image/png"},
34+ {class: "JPEG", mime: "image/jpeg"},
35+ } {
36+ commandCtx, cancel := context.WithTimeout(ctx, osaReadTimeout)
37+ out, err := exec.CommandContext(commandCtx, "osascript", "-e", "the clipboard as «class "+format.class+"»").Output()
38+ cancel()
39+ if err != nil {
40+ continue
41+ }
42+
43+ data, err := parseOSAData(string(out), format.class)
44+ if err == nil {
45+ return format.mime, data, nil
46+ }
47+ }
48+ return "", nil, ports.ErrNoClipboardImage
49+}
@@ -0,0 +1,49 @@
1+//go:build darwin
2+
3+package clipboard
4+
5+import (
6+ "context"
7+ "os/exec"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+const osaReadTimeout = time.Second
14+
15+// OSA implements ports.ClipboardReader using the macOS pasteboard through
16+// osascript. It holds no state and is safe for concurrent use.
17+type OSA struct{}
18+
19+var _ ports.ClipboardReader = (*OSA)(nil)
20+
21+// New returns the native clipboard reader for this operating system.
22+func New() ports.ClipboardReader { return &OSA{} }
23+
24+// ReadImage reads the preferred pasteboard image representation. osascript
25+// reports unavailable pasteboard flavors as command failures; those and all
26+// malformed outputs are intentionally indistinguishable to callers from an
27+// empty clipboard.
28+func (*OSA) ReadImage(ctx context.Context) (string, []byte, error) {
29+ for _, format := range []struct {
30+ class string
31+ mime string
32+ }{
33+ {class: "PNGf", mime: "image/png"},
34+ {class: "JPEG", mime: "image/jpeg"},
35+ } {
36+ commandCtx, cancel := context.WithTimeout(ctx, osaReadTimeout)
37+ out, err := exec.CommandContext(commandCtx, "osascript", "-e", "the clipboard as «class "+format.class+"»").Output()
38+ cancel()
39+ if err != nil {
40+ continue
41+ }
42+
43+ data, err := parseOSAData(string(out), format.class)
44+ if err == nil {
45+ return format.mime, data, nil
46+ }
47+ }
48+ return "", nil, ports.ErrNoClipboardImage
49+}
A internal/adapters/clipboard/osascript_parse.go +43 -0
@@ -0,0 +1,43 @@
1+package clipboard
2+
3+import (
4+ "encoding/hex"
5+ "errors"
6+ "strings"
7+)
8+
9+const (
10+ osaDataPrefix = "«data "
11+ osaDataSuffix = "»"
12+ osaClassLen = 4
13+)
14+
15+// parseOSAData decodes osascript's textual representation of an AppleScript
16+// data value: «data CLASSHEX». osascript terminates successful output with a
17+// newline, so surrounding whitespace is ignored; all other bytes must belong
18+// to the data value.
19+func parseOSAData(output, class string) ([]byte, error) {
20+ if len(class) != osaClassLen {
21+ return nil, errors.New("clipboard: invalid expected osascript data class")
22+ }
23+
24+ output = strings.TrimSpace(output)
25+ if !strings.HasPrefix(output, osaDataPrefix) || !strings.HasSuffix(output, osaDataSuffix) {
26+ return nil, errors.New("clipboard: invalid osascript data output")
27+ }
28+
29+ payload := strings.TrimSuffix(strings.TrimPrefix(output, osaDataPrefix), osaDataSuffix)
30+ if !strings.HasPrefix(payload, class) {
31+ return nil, errors.New("clipboard: unexpected osascript data class")
32+ }
33+ hexData := strings.TrimPrefix(payload, class)
34+ if hexData == "" {
35+ return nil, errors.New("clipboard: osascript data output has no data")
36+ }
37+
38+ data, err := hex.DecodeString(hexData)
39+ if err != nil || len(data) == 0 {
40+ return nil, errors.New("clipboard: invalid osascript data hex")
41+ }
42+ return data, nil
43+}
@@ -0,0 +1,43 @@
1+package clipboard
2+
3+import (
4+ "encoding/hex"
5+ "errors"
6+ "strings"
7+)
8+
9+const (
10+ osaDataPrefix = "«data "
11+ osaDataSuffix = "»"
12+ osaClassLen = 4
13+)
14+
15+// parseOSAData decodes osascript's textual representation of an AppleScript
16+// data value: «data CLASSHEX». osascript terminates successful output with a
17+// newline, so surrounding whitespace is ignored; all other bytes must belong
18+// to the data value.
19+func parseOSAData(output, class string) ([]byte, error) {
20+ if len(class) != osaClassLen {
21+ return nil, errors.New("clipboard: invalid expected osascript data class")
22+ }
23+
24+ output = strings.TrimSpace(output)
25+ if !strings.HasPrefix(output, osaDataPrefix) || !strings.HasSuffix(output, osaDataSuffix) {
26+ return nil, errors.New("clipboard: invalid osascript data output")
27+ }
28+
29+ payload := strings.TrimSuffix(strings.TrimPrefix(output, osaDataPrefix), osaDataSuffix)
30+ if !strings.HasPrefix(payload, class) {
31+ return nil, errors.New("clipboard: unexpected osascript data class")
32+ }
33+ hexData := strings.TrimPrefix(payload, class)
34+ if hexData == "" {
35+ return nil, errors.New("clipboard: osascript data output has no data")
36+ }
37+
38+ data, err := hex.DecodeString(hexData)
39+ if err != nil || len(data) == 0 {
40+ return nil, errors.New("clipboard: invalid osascript data hex")
41+ }
42+ return data, nil
43+}
A internal/adapters/clipboard/osascript_parse_test.go +83 -0
@@ -0,0 +1,83 @@
1+package clipboard
2+
3+import (
4+ "bytes"
5+ "testing"
6+)
7+
8+func TestParseOSAData(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ output string
12+ class string
13+ want []byte
14+ wantErr bool
15+ }{
16+ {
17+ name: "png data",
18+ output: "«data PNGf89504E470D0A1A0A»\n",
19+ class: "PNGf",
20+ want: []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a},
21+ },
22+ {
23+ name: "jpeg data",
24+ output: " «data JPEGffd8ffe000104a464946» ",
25+ class: "JPEG",
26+ want: []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46},
27+ },
28+ {
29+ name: "missing data wrapper",
30+ output: "PNGf89504E47",
31+ class: "PNGf",
32+ wantErr: true,
33+ },
34+ {
35+ name: "trailing output",
36+ output: "«data PNGf89504E47» unexpected",
37+ class: "PNGf",
38+ wantErr: true,
39+ },
40+ {
41+ name: "wrong data class",
42+ output: "«data JPEGffd8»",
43+ class: "PNGf",
44+ wantErr: true,
45+ },
46+ {
47+ name: "missing hex data",
48+ output: "«data PNGf»",
49+ class: "PNGf",
50+ wantErr: true,
51+ },
52+ {
53+ name: "odd length hex data",
54+ output: "«data PNGf895»",
55+ class: "PNGf",
56+ wantErr: true,
57+ },
58+ {
59+ name: "invalid hex data",
60+ output: "«data PNGf89504ZZ»",
61+ class: "PNGf",
62+ wantErr: true,
63+ },
64+ }
65+
66+ for _, tt := range tests {
67+ t.Run(tt.name, func(t *testing.T) {
68+ got, err := parseOSAData(tt.output, tt.class)
69+ if tt.wantErr {
70+ if err == nil {
71+ t.Fatal("parseOSAData() error = nil, want error")
72+ }
73+ return
74+ }
75+ if err != nil {
76+ t.Fatalf("parseOSAData() error = %v", err)
77+ }
78+ if !bytes.Equal(got, tt.want) {
79+ t.Fatalf("parseOSAData() = %x, want %x", got, tt.want)
80+ }
81+ })
82+ }
83+}
@@ -0,0 +1,83 @@
1+package clipboard
2+
3+import (
4+ "bytes"
5+ "testing"
6+)
7+
8+func TestParseOSAData(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ output string
12+ class string
13+ want []byte
14+ wantErr bool
15+ }{
16+ {
17+ name: "png data",
18+ output: "«data PNGf89504E470D0A1A0A»\n",
19+ class: "PNGf",
20+ want: []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a},
21+ },
22+ {
23+ name: "jpeg data",
24+ output: " «data JPEGffd8ffe000104a464946» ",
25+ class: "JPEG",
26+ want: []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46},
27+ },
28+ {
29+ name: "missing data wrapper",
30+ output: "PNGf89504E47",
31+ class: "PNGf",
32+ wantErr: true,
33+ },
34+ {
35+ name: "trailing output",
36+ output: "«data PNGf89504E47» unexpected",
37+ class: "PNGf",
38+ wantErr: true,
39+ },
40+ {
41+ name: "wrong data class",
42+ output: "«data JPEGffd8»",
43+ class: "PNGf",
44+ wantErr: true,
45+ },
46+ {
47+ name: "missing hex data",
48+ output: "«data PNGf»",
49+ class: "PNGf",
50+ wantErr: true,
51+ },
52+ {
53+ name: "odd length hex data",
54+ output: "«data PNGf895»",
55+ class: "PNGf",
56+ wantErr: true,
57+ },
58+ {
59+ name: "invalid hex data",
60+ output: "«data PNGf89504ZZ»",
61+ class: "PNGf",
62+ wantErr: true,
63+ },
64+ }
65+
66+ for _, tt := range tests {
67+ t.Run(tt.name, func(t *testing.T) {
68+ got, err := parseOSAData(tt.output, tt.class)
69+ if tt.wantErr {
70+ if err == nil {
71+ t.Fatal("parseOSAData() error = nil, want error")
72+ }
73+ return
74+ }
75+ if err != nil {
76+ t.Fatalf("parseOSAData() error = %v", err)
77+ }
78+ if !bytes.Equal(got, tt.want) {
79+ t.Fatalf("parseOSAData() = %x, want %x", got, tt.want)
80+ }
81+ })
82+ }
83+}
M internal/adapters/clipboard/wlpaste.go +3 -1
@@ -1,3 +1,5 @@
1+//go:build linux
2+
13 // Package clipboard implements ports.ClipboardReader over Wayland's
24 // wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote
35 // attach forward a locally clipped image to the daemon (see
@@ -50,7 +52,7 @@ type WlPaste struct {
5052
5153 // New returns a WlPaste clipboard reader that shells out to the real
5254 // wl-paste binary.
53-func New() *WlPaste {
55+func New() ports.ClipboardReader {
5456 return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout}
5557 }
5658
@@ -1,3 +1,5 @@
1+//go:build linux
2+
1 // Package clipboard implements ports.ClipboardReader over Wayland's3 // Package clipboard implements ports.ClipboardReader over Wayland's
2 // wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote4 // wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote
3 // attach forward a locally clipped image to the daemon (see5 // attach forward a locally clipped image to the daemon (see
@@ -50,7 +52,7 @@ type WlPaste struct {
50 52
51 // New returns a WlPaste clipboard reader that shells out to the real53 // New returns a WlPaste clipboard reader that shells out to the real
52 // wl-paste binary.54 // wl-paste binary.
53-func New() *WlPaste {55+func New() ports.ClipboardReader {
54 return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout}56 return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout}
55 }57 }
56 58
M internal/adapters/clipboard/wlpaste_test.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build linux
2+
13 package clipboard
24
35 import (
@@ -1,3 +1,5 @@
1+//go:build linux
2+
1 package clipboard3 package clipboard
2 4
3 import (5 import (
M internal/adapters/ipc/dial_test.go +2 -3
@@ -3,7 +3,6 @@ package ipc
33 import (
44 "context"
55 "errors"
6- "path/filepath"
76 "testing"
87 "time"
98
@@ -43,7 +42,7 @@ func TestDialContextCanceledOrExpired(t *testing.T) {
4342 ctx, cancel := tt.ctx()
4443 defer cancel()
4544
46- transport, err := DialContext(ctx, t.TempDir())
45+ transport, err := DialContext(ctx, shortSocketDir(t))
4746 if transport != nil {
4847 t.Fatal("DialContext returned a transport for a completed context")
4948 }
@@ -55,7 +54,7 @@ func TestDialContextCanceledOrExpired(t *testing.T) {
5554 }
5655
5756 func TestDialContextPreservesOptions(t *testing.T) {
58- dir := filepath.Join(t.TempDir(), "vev")
57+ dir := shortSocketDir(t, "vev")
5958 listener, err := Listen(dir)
6059 if err != nil {
6160 t.Fatalf("Listen: %v", err)
@@ -3,7 +3,6 @@ package ipc
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6- "path/filepath"
7 "testing"6 "testing"
8 "time"7 "time"
9 8
@@ -43,7 +42,7 @@ func TestDialContextCanceledOrExpired(t *testing.T) {
43 ctx, cancel := tt.ctx()42 ctx, cancel := tt.ctx()
44 defer cancel()43 defer cancel()
45 44
46- transport, err := DialContext(ctx, t.TempDir())45+ transport, err := DialContext(ctx, shortSocketDir(t))
47 if transport != nil {46 if transport != nil {
48 t.Fatal("DialContext returned a transport for a completed context")47 t.Fatal("DialContext returned a transport for a completed context")
49 }48 }
@@ -55,7 +54,7 @@ func TestDialContextCanceledOrExpired(t *testing.T) {
55 }54 }
56 55
57 func TestDialContextPreservesOptions(t *testing.T) {56 func TestDialContextPreservesOptions(t *testing.T) {
58- dir := filepath.Join(t.TempDir(), "vev")57+ dir := shortSocketDir(t, "vev")
59 listener, err := Listen(dir)58 listener, err := Listen(dir)
60 if err != nil {59 if err != nil {
61 t.Fatalf("Listen: %v", err)60 t.Fatalf("Listen: %v", err)
M internal/adapters/ipc/listener_test.go +5 -6
@@ -13,7 +13,7 @@ import (
1313 )
1414
1515 func TestListenAcceptRoundTrip(t *testing.T) {
16- dir := filepath.Join(t.TempDir(), "vev")
16+ dir := shortSocketDir(t, "vev")
1717
1818 ln, err := Listen(dir)
1919 if err != nil {
@@ -65,8 +65,7 @@ func TestListenAcceptRoundTrip(t *testing.T) {
6565 }
6666
6767 func TestListenMkdirsSocketDir(t *testing.T) {
68- base := t.TempDir()
69- dir := filepath.Join(base, "nested", "socketdir")
68+ dir := shortSocketDir(t, "nested", "socketdir")
7069
7170 ln, err := Listen(dir)
7271 if err != nil {
@@ -87,7 +86,7 @@ func TestListenMkdirsSocketDir(t *testing.T) {
8786 }
8887
8988 func TestListenRejectsHostileSocketDir(t *testing.T) {
90- dir := filepath.Join(t.TempDir(), "socketdir")
89+ dir := shortSocketDir(t, "socketdir")
9190 if err := os.MkdirAll(dir, 0o777); err != nil {
9291 t.Fatalf("mkdir hostile socket dir: %v", err)
9392 }
@@ -106,7 +105,7 @@ func TestListenRejectsHostileSocketDir(t *testing.T) {
106105 }
107106
108107 func TestListenStaleSocketCleanedUp(t *testing.T) {
109- dir := filepath.Join(t.TempDir(), "vev")
108+ dir := shortSocketDir(t, "vev")
110109 if err := os.Mkdir(dir, 0o700); err != nil {
111110 t.Fatalf("mkdir safe socket dir: %v", err)
112111 }
@@ -140,7 +139,7 @@ func TestListenStaleSocketCleanedUp(t *testing.T) {
140139 }
141140
142141 func TestListenLiveDaemonRejected(t *testing.T) {
143- dir := filepath.Join(t.TempDir(), "vev")
142+ dir := shortSocketDir(t, "vev")
144143
145144 first, err := Listen(dir)
146145 if err != nil {
@@ -13,7 +13,7 @@ import (
13 )13 )
14 14
15 func TestListenAcceptRoundTrip(t *testing.T) {15 func TestListenAcceptRoundTrip(t *testing.T) {
16- dir := filepath.Join(t.TempDir(), "vev")16+ dir := shortSocketDir(t, "vev")
17 17
18 ln, err := Listen(dir)18 ln, err := Listen(dir)
19 if err != nil {19 if err != nil {
@@ -65,8 +65,7 @@ func TestListenAcceptRoundTrip(t *testing.T) {
65 }65 }
66 66
67 func TestListenMkdirsSocketDir(t *testing.T) {67 func TestListenMkdirsSocketDir(t *testing.T) {
68- base := t.TempDir()68+ dir := shortSocketDir(t, "nested", "socketdir")
69- dir := filepath.Join(base, "nested", "socketdir")
70 69
71 ln, err := Listen(dir)70 ln, err := Listen(dir)
72 if err != nil {71 if err != nil {
@@ -87,7 +86,7 @@ func TestListenMkdirsSocketDir(t *testing.T) {
87 }86 }
88 87
89 func TestListenRejectsHostileSocketDir(t *testing.T) {88 func TestListenRejectsHostileSocketDir(t *testing.T) {
90- dir := filepath.Join(t.TempDir(), "socketdir")89+ dir := shortSocketDir(t, "socketdir")
91 if err := os.MkdirAll(dir, 0o777); err != nil {90 if err := os.MkdirAll(dir, 0o777); err != nil {
92 t.Fatalf("mkdir hostile socket dir: %v", err)91 t.Fatalf("mkdir hostile socket dir: %v", err)
93 }92 }
@@ -106,7 +105,7 @@ func TestListenRejectsHostileSocketDir(t *testing.T) {
106 }105 }
107 106
108 func TestListenStaleSocketCleanedUp(t *testing.T) {107 func TestListenStaleSocketCleanedUp(t *testing.T) {
109- dir := filepath.Join(t.TempDir(), "vev")108+ dir := shortSocketDir(t, "vev")
110 if err := os.Mkdir(dir, 0o700); err != nil {109 if err := os.Mkdir(dir, 0o700); err != nil {
111 t.Fatalf("mkdir safe socket dir: %v", err)110 t.Fatalf("mkdir safe socket dir: %v", err)
112 }111 }
@@ -140,7 +139,7 @@ func TestListenStaleSocketCleanedUp(t *testing.T) {
140 }139 }
141 140
142 func TestListenLiveDaemonRejected(t *testing.T) {141 func TestListenLiveDaemonRejected(t *testing.T) {
143- dir := filepath.Join(t.TempDir(), "vev")142+ dir := shortSocketDir(t, "vev")
144 143
145 first, err := Listen(dir)144 first, err := Listen(dir)
146 if err != nil {145 if err != nil {
A internal/adapters/ipc/test_helpers_test.go +35 -0
@@ -0,0 +1,35 @@
1+package ipc
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+const darwinUnixSocketPathMax = 103
10+
11+func shortSocketDir(t *testing.T, nested ...string) string {
12+ t.Helper()
13+
14+ root, err := os.MkdirTemp("/tmp", "v")
15+ if err != nil {
16+ t.Fatalf("create short socket test root: %v", err)
17+ }
18+ t.Cleanup(func() {
19+ if err := os.RemoveAll(root); err != nil {
20+ t.Errorf("remove short socket test root %q: %v", root, err)
21+ }
22+ })
23+
24+ dir := filepath.Join(append([]string{root}, nested...)...)
25+ assertDarwinSafeSocketPath(t, filepath.Join(dir, "daemon.sock"))
26+ return dir
27+}
28+
29+func assertDarwinSafeSocketPath(t *testing.T, path string) {
30+ t.Helper()
31+
32+ if len(path) >= darwinUnixSocketPathMax {
33+ t.Fatalf("test socket path %q is %d bytes; must be shorter than Darwin's %d-byte AF_UNIX pathname maximum", path, len(path), darwinUnixSocketPathMax)
34+ }
35+}
@@ -0,0 +1,35 @@
1+package ipc
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+const darwinUnixSocketPathMax = 103
10+
11+func shortSocketDir(t *testing.T, nested ...string) string {
12+ t.Helper()
13+
14+ root, err := os.MkdirTemp("/tmp", "v")
15+ if err != nil {
16+ t.Fatalf("create short socket test root: %v", err)
17+ }
18+ t.Cleanup(func() {
19+ if err := os.RemoveAll(root); err != nil {
20+ t.Errorf("remove short socket test root %q: %v", root, err)
21+ }
22+ })
23+
24+ dir := filepath.Join(append([]string{root}, nested...)...)
25+ assertDarwinSafeSocketPath(t, filepath.Join(dir, "daemon.sock"))
26+ return dir
27+}
28+
29+func assertDarwinSafeSocketPath(t *testing.T, path string) {
30+ t.Helper()
31+
32+ if len(path) >= darwinUnixSocketPathMax {
33+ t.Fatalf("test socket path %q is %d bytes; must be shorter than Darwin's %d-byte AF_UNIX pathname maximum", path, len(path), darwinUnixSocketPathMax)
34+ }
35+}
A internal/adapters/pty/pty_darwin_test.go +139 -0
@@ -0,0 +1,139 @@
1+//go:build darwin
2+
3+package pty_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "io"
9+ "os"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/vev/internal/adapters/pty"
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+func readAllDarwin(t *testing.T, p ports.PTY, deadline time.Duration) []byte {
21+ t.Helper()
22+ type result struct {
23+ output []byte
24+ err error
25+ }
26+ resultCh := make(chan result, 1)
27+ go func() {
28+ var output bytes.Buffer
29+ _, err := io.Copy(&output, p)
30+ resultCh <- result{output: output.Bytes(), err: err}
31+ }()
32+
33+ select {
34+ case result := <-resultCh:
35+ require.NoError(t, result.err)
36+ return result.output
37+ case <-time.After(deadline):
38+ t.Fatal("timed out reading from pty")
39+ return nil
40+ }
41+}
42+
43+func TestOpen_DarwinEchoAndOutputEOF(t *testing.T) {
44+ if testing.Short() {
45+ t.Skip("skipping pty integration test in -short mode")
46+ }
47+
48+ factory := pty.NewFactory()
49+ outputPTY, err := factory.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
50+ require.NoError(t, err)
51+ t.Cleanup(func() { _ = outputPTY.Close() })
52+ require.Equal(t, "hello", string(readAllDarwin(t, outputPTY, 5*time.Second)))
53+
54+ echoPTY, err := factory.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
55+ require.NoError(t, err)
56+ t.Cleanup(func() { _ = echoPTY.Close() })
57+ _, err = io.WriteString(echoPTY, "roundtrip\n")
58+ require.NoError(t, err)
59+
60+ output := readUntilDarwin(t, echoPTY, "roundtrip", 5*time.Second)
61+ require.Contains(t, output, "roundtrip")
62+}
63+
64+func readUntilDarwin(t *testing.T, p ports.PTY, marker string, deadline time.Duration) string {
65+ t.Helper()
66+ type result struct {
67+ output string
68+ err error
69+ }
70+ resultCh := make(chan result, 1)
71+ go func() {
72+ var output strings.Builder
73+ buf := make([]byte, 256)
74+ for !strings.Contains(output.String(), marker) {
75+ n, err := p.Read(buf)
76+ output.Write(buf[:n])
77+ if err != nil {
78+ resultCh <- result{output: output.String(), err: err}
79+ return
80+ }
81+ }
82+ resultCh <- result{output: output.String()}
83+ }()
84+
85+ select {
86+ case result := <-resultCh:
87+ require.NoError(t, result.err)
88+ return result.output
89+ case <-time.After(deadline):
90+ t.Fatal("timed out waiting for pty readiness")
91+ return ""
92+ }
93+}
94+
95+func TestOpen_DarwinResizeAndForegroundPgid(t *testing.T) {
96+ if testing.Short() {
97+ t.Skip("skipping pty integration test in -short mode")
98+ }
99+
100+ const ready = "__vev_ready__"
101+ p, err := pty.NewFactory().Open(context.Background(), "sh", []string{"-c", "stty -echo; printf '__vev_ready__\\n'; IFS= read -r release; stty size"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
102+ require.NoError(t, err)
103+ t.Cleanup(func() { _ = p.Close() })
104+
105+ require.Contains(t, readUntilDarwin(t, p, ready, 5*time.Second), ready)
106+ pgid, err := p.ForegroundPgid()
107+ require.NoError(t, err)
108+ require.Greater(t, pgid, 0)
109+ require.NoError(t, p.Resize(domain.Size{Cols: 120, Rows: 40}))
110+ _, err = io.WriteString(p, "release\n")
111+ require.NoError(t, err)
112+ require.Equal(t, "40 120", strings.TrimSpace(string(readAllDarwin(t, p, 5*time.Second))))
113+}
114+
115+func TestOpen_DarwinContextCancellationEndsStream(t *testing.T) {
116+ if testing.Short() {
117+ t.Skip("skipping pty integration test in -short mode")
118+ }
119+
120+ ctx, cancel := context.WithCancel(context.Background())
121+ defer cancel()
122+ p, err := pty.NewFactory().Open(ctx, "sh", []string{"-c", "exec sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
123+ require.NoError(t, err)
124+ t.Cleanup(func() { _ = p.Close() })
125+
126+ streamEnded := make(chan error, 1)
127+ go func() {
128+ _, err := io.Copy(io.Discard, p)
129+ streamEnded <- err
130+ }()
131+
132+ cancel()
133+ select {
134+ case err := <-streamEnded:
135+ require.NoError(t, err)
136+ case <-time.After(5 * time.Second):
137+ t.Fatal("pty stream did not end after context cancellation")
138+ }
139+}
@@ -0,0 +1,139 @@
1+//go:build darwin
2+
3+package pty_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "io"
9+ "os"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/vev/internal/adapters/pty"
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+func readAllDarwin(t *testing.T, p ports.PTY, deadline time.Duration) []byte {
21+ t.Helper()
22+ type result struct {
23+ output []byte
24+ err error
25+ }
26+ resultCh := make(chan result, 1)
27+ go func() {
28+ var output bytes.Buffer
29+ _, err := io.Copy(&output, p)
30+ resultCh <- result{output: output.Bytes(), err: err}
31+ }()
32+
33+ select {
34+ case result := <-resultCh:
35+ require.NoError(t, result.err)
36+ return result.output
37+ case <-time.After(deadline):
38+ t.Fatal("timed out reading from pty")
39+ return nil
40+ }
41+}
42+
43+func TestOpen_DarwinEchoAndOutputEOF(t *testing.T) {
44+ if testing.Short() {
45+ t.Skip("skipping pty integration test in -short mode")
46+ }
47+
48+ factory := pty.NewFactory()
49+ outputPTY, err := factory.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
50+ require.NoError(t, err)
51+ t.Cleanup(func() { _ = outputPTY.Close() })
52+ require.Equal(t, "hello", string(readAllDarwin(t, outputPTY, 5*time.Second)))
53+
54+ echoPTY, err := factory.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
55+ require.NoError(t, err)
56+ t.Cleanup(func() { _ = echoPTY.Close() })
57+ _, err = io.WriteString(echoPTY, "roundtrip\n")
58+ require.NoError(t, err)
59+
60+ output := readUntilDarwin(t, echoPTY, "roundtrip", 5*time.Second)
61+ require.Contains(t, output, "roundtrip")
62+}
63+
64+func readUntilDarwin(t *testing.T, p ports.PTY, marker string, deadline time.Duration) string {
65+ t.Helper()
66+ type result struct {
67+ output string
68+ err error
69+ }
70+ resultCh := make(chan result, 1)
71+ go func() {
72+ var output strings.Builder
73+ buf := make([]byte, 256)
74+ for !strings.Contains(output.String(), marker) {
75+ n, err := p.Read(buf)
76+ output.Write(buf[:n])
77+ if err != nil {
78+ resultCh <- result{output: output.String(), err: err}
79+ return
80+ }
81+ }
82+ resultCh <- result{output: output.String()}
83+ }()
84+
85+ select {
86+ case result := <-resultCh:
87+ require.NoError(t, result.err)
88+ return result.output
89+ case <-time.After(deadline):
90+ t.Fatal("timed out waiting for pty readiness")
91+ return ""
92+ }
93+}
94+
95+func TestOpen_DarwinResizeAndForegroundPgid(t *testing.T) {
96+ if testing.Short() {
97+ t.Skip("skipping pty integration test in -short mode")
98+ }
99+
100+ const ready = "__vev_ready__"
101+ p, err := pty.NewFactory().Open(context.Background(), "sh", []string{"-c", "stty -echo; printf '__vev_ready__\\n'; IFS= read -r release; stty size"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
102+ require.NoError(t, err)
103+ t.Cleanup(func() { _ = p.Close() })
104+
105+ require.Contains(t, readUntilDarwin(t, p, ready, 5*time.Second), ready)
106+ pgid, err := p.ForegroundPgid()
107+ require.NoError(t, err)
108+ require.Greater(t, pgid, 0)
109+ require.NoError(t, p.Resize(domain.Size{Cols: 120, Rows: 40}))
110+ _, err = io.WriteString(p, "release\n")
111+ require.NoError(t, err)
112+ require.Equal(t, "40 120", strings.TrimSpace(string(readAllDarwin(t, p, 5*time.Second))))
113+}
114+
115+func TestOpen_DarwinContextCancellationEndsStream(t *testing.T) {
116+ if testing.Short() {
117+ t.Skip("skipping pty integration test in -short mode")
118+ }
119+
120+ ctx, cancel := context.WithCancel(context.Background())
121+ defer cancel()
122+ p, err := pty.NewFactory().Open(ctx, "sh", []string{"-c", "exec sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
123+ require.NoError(t, err)
124+ t.Cleanup(func() { _ = p.Close() })
125+
126+ streamEnded := make(chan error, 1)
127+ go func() {
128+ _, err := io.Copy(io.Discard, p)
129+ streamEnded <- err
130+ }()
131+
132+ cancel()
133+ select {
134+ case err := <-streamEnded:
135+ require.NoError(t, err)
136+ case <-time.After(5 * time.Second):
137+ t.Fatal("pty stream did not end after context cancellation")
138+ }
139+}
R internal/adapters/pty/pty_linux.go → internal/adapters/pty/pty_unix.go +21 -33 renamed, 81% similar
@@ -1,11 +1,10 @@
1-//go:build linux
1+//go:build linux || darwin
22
33 package pty
44
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
7-// depending on a third-party pty package, keeping the dependency surface at
8-// the standard library alone.
5+// The Unix adapter drives /dev/ptmx directly and delegates slave preparation
6+// to rawterm rather than depending on a third-party pty package, keeping the
7+// dependency surface at the standard library alone.
98
109 import (
1110 "context"
@@ -21,17 +20,17 @@ import (
2120 "github.com/bnema/vev/internal/domain"
2221 "github.com/bnema/vev/internal/platform"
2322 "github.com/bnema/vev/internal/ports"
24- "github.com/bnema/vev/pkg/linuxterm"
23+ "github.com/bnema/vev/pkg/rawterm"
2524 )
2625
2726 // killGracePeriod is how long Close waits for a signalled child to exit before
2827 // escalating from SIGHUP to SIGKILL.
2928 const killGracePeriod = 2 * time.Second
3029
31-// Factory implements ports.PTYFactory for Linux.
30+// Factory implements ports.PTYFactory on supported Unix platforms.
3231 type Factory struct{}
3332
34-// NewFactory returns a Linux PTY factory.
33+// NewFactory returns a Unix PTY factory.
3534 func NewFactory() *Factory { return &Factory{} }
3635
3736 // Open spawns command with args attached to a freshly allocated pseudo-terminal
@@ -56,20 +55,9 @@ func (Factory) Open(ctx context.Context, command string, args []string, env []st
5655 }
5756 }()
5857
59- ptn, err := linuxterm.PtsNumber(masterFd)
58+ slave, err := rawterm.PreparePty(masterFd)
6059 if err != nil {
61- return nil, fmt.Errorf("pty: TIOCGPTN: %w", err)
62- }
63-
64- // Unlock the slave so it can be opened.
65- if err := linuxterm.UnlockPt(masterFd); err != nil {
66- return nil, fmt.Errorf("pty: TIOCSPTLCK: %w", err)
67- }
68-
69- slaveName := fmt.Sprintf("/dev/pts/%d", ptn)
70- slave, err := os.OpenFile(slaveName, os.O_RDWR|syscall.O_NOCTTY, 0)
71- if err != nil {
72- return nil, fmt.Errorf("pty: open %s: %w", slaveName, err)
60+ return nil, fmt.Errorf("pty: prepare pty: %w", err)
7361 }
7462 // The child dups the slave into its std fds and holds its own copies, so the
7563 // parent always drops the slave once Start has run (or on any error).
@@ -117,14 +105,14 @@ func (Factory) Open(ctx context.Context, command string, args []string, env []st
117105 }
118106 ok = true
119107
120- return &linuxPTY{
108+ return &unixPTY{
121109 master: os.NewFile(uintptr(masterFd), "pty-master"),
122110 cmd: cmd,
123111 }, nil
124112 }
125113
126-// linuxPTY is a running child attached to the master end of a pty.
127-type linuxPTY struct {
114+// unixPTY is a running child attached to the master end of a pty.
115+type unixPTY struct {
128116 master *os.File
129117 cmd *exec.Cmd
130118
@@ -132,14 +120,14 @@ type linuxPTY struct {
132120 closeErr error
133121 }
134122
135-var _ ports.PTY = (*linuxPTY)(nil)
123+var _ ports.PTY = (*unixPTY)(nil)
136124
137125 // Read returns child output read from the master.
138126 //
139127 // When the child (the last process holding the slave open) exits, the kernel
140128 // makes reads of the master fail with EIO. Callers treat "child gone" as a
141129 // normal end of stream, so EIO is mapped to io.EOF here.
142-func (p *linuxPTY) Read(b []byte) (int, error) {
130+func (p *unixPTY) Read(b []byte) (int, error) {
143131 n, err := p.master.Read(b)
144132 if err != nil && errors.Is(err, syscall.EIO) {
145133 return n, io.EOF
@@ -148,7 +136,7 @@ func (p *linuxPTY) Read(b []byte) (int, error) {
148136 }
149137
150138 // Write sends input bytes to the child via the master.
151-func (p *linuxPTY) Write(b []byte) (int, error) {
139+func (p *unixPTY) Write(b []byte) (int, error) {
152140 return p.master.Write(b)
153141 }
154142
@@ -160,7 +148,7 @@ func (p *linuxPTY) Write(b []byte) (int, error) {
160148 // every subsequent Read into a thread-parking syscall and breaking the
161149 // Close-unblocks-Read behavior. Control also pins the fd for the duration and
162150 // fails cleanly (os.ErrClosed) after Close.
163-func (p *linuxPTY) Resize(sz domain.Size) error {
151+func (p *unixPTY) Resize(sz domain.Size) error {
164152 rc, err := p.master.SyscallConn()
165153 if err != nil {
166154 return fmt.Errorf("pty: resize: %w", err)
@@ -175,13 +163,13 @@ func (p *linuxPTY) Resize(sz domain.Size) error {
175163 }
176164
177165 // Pid reports the child process id.
178-func (p *linuxPTY) Pid() int {
166+func (p *unixPTY) Pid() int {
179167 return p.cmd.Process.Pid
180168 }
181169
182170 // ForegroundPgid reports the process group currently in the foreground for the
183171 // pty's controlling terminal.
184-func (p *linuxPTY) ForegroundPgid() (int, error) {
172+func (p *unixPTY) ForegroundPgid() (int, error) {
185173 rc, err := p.master.SyscallConn()
186174 if err != nil {
187175 return 0, fmt.Errorf("pty: foreground pgid: %w", err)
@@ -189,7 +177,7 @@ func (p *linuxPTY) ForegroundPgid() (int, error) {
189177 var pgid int
190178 var ioctlErr error
191179 if err := rc.Control(func(fd uintptr) {
192- pgid, ioctlErr = linuxterm.ForegroundProcessGroup(int(fd))
180+ pgid, ioctlErr = rawterm.ForegroundProcessGroup(int(fd))
193181 }); err != nil {
194182 return 0, fmt.Errorf("pty: foreground pgid: %w", err)
195183 }
@@ -204,7 +192,7 @@ func (p *linuxPTY) ForegroundPgid() (int, error) {
204192 // escalates to SIGKILL. The child is reaped (via cmd.Wait) to avoid a zombie,
205193 // and the master fd is closed last so any blocked Read unblocks. Close is
206194 // idempotent: subsequent calls are no-ops and return the first result.
207-func (p *linuxPTY) Close() error {
195+func (p *unixPTY) Close() error {
208196 p.closeOnce.Do(func() {
209197 pid := p.cmd.Process.Pid
210198
@@ -240,5 +228,5 @@ func signalProcessGroup(pid int, signal syscall.Signal) error {
240228
241229 // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ.
242230 func setWinsize(fd int, sz domain.Size) error {
243- return linuxterm.SetWinsize(fd, uint16(sz.Cols), uint16(sz.Rows))
231+ return rawterm.SetWinsize(fd, uint16(sz.Cols), uint16(sz.Rows))
244232 }
@@ -1,11 +1,10 @@
1-//go:build linux1+//go:build linux || darwin
2 2
3 package pty3 package pty
4 4
5-// The Linux adapter drives /dev/ptmx directly (open master, TIOCGPTN to learn5+// The Unix adapter drives /dev/ptmx directly and delegates slave preparation
6-// the slave index, TIOCSPTLCK to unlock, then open /dev/pts/<N>) rather than6+// to rawterm rather than depending on a third-party pty package, keeping the
7-// depending on a third-party pty package, keeping the dependency surface at7+// dependency surface at the standard library alone.
8-// the standard library alone.
9 8
10 import (9 import (
11 "context"10 "context"
@@ -21,17 +20,17 @@ import (
21 "github.com/bnema/vev/internal/domain"20 "github.com/bnema/vev/internal/domain"
22 "github.com/bnema/vev/internal/platform"21 "github.com/bnema/vev/internal/platform"
23 "github.com/bnema/vev/internal/ports"22 "github.com/bnema/vev/internal/ports"
24- "github.com/bnema/vev/pkg/linuxterm"23+ "github.com/bnema/vev/pkg/rawterm"
25 )24 )
26 25
27 // killGracePeriod is how long Close waits for a signalled child to exit before26 // killGracePeriod is how long Close waits for a signalled child to exit before
28 // escalating from SIGHUP to SIGKILL.27 // escalating from SIGHUP to SIGKILL.
29 const killGracePeriod = 2 * time.Second28 const killGracePeriod = 2 * time.Second
30 29
31-// Factory implements ports.PTYFactory for Linux.30+// Factory implements ports.PTYFactory on supported Unix platforms.
32 type Factory struct{}31 type Factory struct{}
33 32
34-// NewFactory returns a Linux PTY factory.33+// NewFactory returns a Unix PTY factory.
35 func NewFactory() *Factory { return &Factory{} }34 func NewFactory() *Factory { return &Factory{} }
36 35
37 // Open spawns command with args attached to a freshly allocated pseudo-terminal36 // Open spawns command with args attached to a freshly allocated pseudo-terminal
@@ -56,20 +55,9 @@ func (Factory) Open(ctx context.Context, command string, args []string, env []st
56 }55 }
57 }()56 }()
58 57
59- ptn, err := linuxterm.PtsNumber(masterFd)58+ slave, err := rawterm.PreparePty(masterFd)
60 if err != nil {59 if err != nil {
61- return nil, fmt.Errorf("pty: TIOCGPTN: %w", err)60+ return nil, fmt.Errorf("pty: prepare pty: %w", err)
62- }
63-
64- // Unlock the slave so it can be opened.
65- if err := linuxterm.UnlockPt(masterFd); err != nil {
66- return nil, fmt.Errorf("pty: TIOCSPTLCK: %w", err)
67- }
68-
69- slaveName := fmt.Sprintf("/dev/pts/%d", ptn)
70- slave, err := os.OpenFile(slaveName, os.O_RDWR|syscall.O_NOCTTY, 0)
71- if err != nil {
72- return nil, fmt.Errorf("pty: open %s: %w", slaveName, err)
73 }61 }
74 // The child dups the slave into its std fds and holds its own copies, so the62 // The child dups the slave into its std fds and holds its own copies, so the
75 // parent always drops the slave once Start has run (or on any error).63 // parent always drops the slave once Start has run (or on any error).
@@ -117,14 +105,14 @@ func (Factory) Open(ctx context.Context, command string, args []string, env []st
117 }105 }
118 ok = true106 ok = true
119 107
120- return &linuxPTY{108+ return &unixPTY{
121 master: os.NewFile(uintptr(masterFd), "pty-master"),109 master: os.NewFile(uintptr(masterFd), "pty-master"),
122 cmd: cmd,110 cmd: cmd,
123 }, nil111 }, nil
124 }112 }
125 113
126-// linuxPTY is a running child attached to the master end of a pty.114+// unixPTY is a running child attached to the master end of a pty.
127-type linuxPTY struct {115+type unixPTY struct {
128 master *os.File116 master *os.File
129 cmd *exec.Cmd117 cmd *exec.Cmd
130 118
@@ -132,14 +120,14 @@ type linuxPTY struct {
132 closeErr error120 closeErr error
133 }121 }
134 122
135-var _ ports.PTY = (*linuxPTY)(nil)123+var _ ports.PTY = (*unixPTY)(nil)
136 124
137 // Read returns child output read from the master.125 // Read returns child output read from the master.
138 //126 //
139 // When the child (the last process holding the slave open) exits, the kernel127 // When the child (the last process holding the slave open) exits, the kernel
140 // makes reads of the master fail with EIO. Callers treat "child gone" as a128 // makes reads of the master fail with EIO. Callers treat "child gone" as a
141 // normal end of stream, so EIO is mapped to io.EOF here.129 // normal end of stream, so EIO is mapped to io.EOF here.
142-func (p *linuxPTY) Read(b []byte) (int, error) {130+func (p *unixPTY) Read(b []byte) (int, error) {
143 n, err := p.master.Read(b)131 n, err := p.master.Read(b)
144 if err != nil && errors.Is(err, syscall.EIO) {132 if err != nil && errors.Is(err, syscall.EIO) {
145 return n, io.EOF133 return n, io.EOF
@@ -148,7 +136,7 @@ func (p *linuxPTY) Read(b []byte) (int, error) {
148 }136 }
149 137
150 // Write sends input bytes to the child via the master.138 // Write sends input bytes to the child via the master.
151-func (p *linuxPTY) Write(b []byte) (int, error) {139+func (p *unixPTY) Write(b []byte) (int, error) {
152 return p.master.Write(b)140 return p.master.Write(b)
153 }141 }
154 142
@@ -160,7 +148,7 @@ func (p *linuxPTY) Write(b []byte) (int, error) {
160 // every subsequent Read into a thread-parking syscall and breaking the148 // every subsequent Read into a thread-parking syscall and breaking the
161 // Close-unblocks-Read behavior. Control also pins the fd for the duration and149 // Close-unblocks-Read behavior. Control also pins the fd for the duration and
162 // fails cleanly (os.ErrClosed) after Close.150 // fails cleanly (os.ErrClosed) after Close.
163-func (p *linuxPTY) Resize(sz domain.Size) error {151+func (p *unixPTY) Resize(sz domain.Size) error {
164 rc, err := p.master.SyscallConn()152 rc, err := p.master.SyscallConn()
165 if err != nil {153 if err != nil {
166 return fmt.Errorf("pty: resize: %w", err)154 return fmt.Errorf("pty: resize: %w", err)
@@ -175,13 +163,13 @@ func (p *linuxPTY) Resize(sz domain.Size) error {
175 }163 }
176 164
177 // Pid reports the child process id.165 // Pid reports the child process id.
178-func (p *linuxPTY) Pid() int {166+func (p *unixPTY) Pid() int {
179 return p.cmd.Process.Pid167 return p.cmd.Process.Pid
180 }168 }
181 169
182 // ForegroundPgid reports the process group currently in the foreground for the170 // ForegroundPgid reports the process group currently in the foreground for the
183 // pty's controlling terminal.171 // pty's controlling terminal.
184-func (p *linuxPTY) ForegroundPgid() (int, error) {172+func (p *unixPTY) ForegroundPgid() (int, error) {
185 rc, err := p.master.SyscallConn()173 rc, err := p.master.SyscallConn()
186 if err != nil {174 if err != nil {
187 return 0, fmt.Errorf("pty: foreground pgid: %w", err)175 return 0, fmt.Errorf("pty: foreground pgid: %w", err)
@@ -189,7 +177,7 @@ func (p *linuxPTY) ForegroundPgid() (int, error) {
189 var pgid int177 var pgid int
190 var ioctlErr error178 var ioctlErr error
191 if err := rc.Control(func(fd uintptr) {179 if err := rc.Control(func(fd uintptr) {
192- pgid, ioctlErr = linuxterm.ForegroundProcessGroup(int(fd))180+ pgid, ioctlErr = rawterm.ForegroundProcessGroup(int(fd))
193 }); err != nil {181 }); err != nil {
194 return 0, fmt.Errorf("pty: foreground pgid: %w", err)182 return 0, fmt.Errorf("pty: foreground pgid: %w", err)
195 }183 }
@@ -204,7 +192,7 @@ func (p *linuxPTY) ForegroundPgid() (int, error) {
204 // escalates to SIGKILL. The child is reaped (via cmd.Wait) to avoid a zombie,192 // escalates to SIGKILL. The child is reaped (via cmd.Wait) to avoid a zombie,
205 // and the master fd is closed last so any blocked Read unblocks. Close is193 // and the master fd is closed last so any blocked Read unblocks. Close is
206 // idempotent: subsequent calls are no-ops and return the first result.194 // idempotent: subsequent calls are no-ops and return the first result.
207-func (p *linuxPTY) Close() error {195+func (p *unixPTY) Close() error {
208 p.closeOnce.Do(func() {196 p.closeOnce.Do(func() {
209 pid := p.cmd.Process.Pid197 pid := p.cmd.Process.Pid
210 198
@@ -240,5 +228,5 @@ func signalProcessGroup(pid int, signal syscall.Signal) error {
240 228
241 // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ.229 // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ.
242 func setWinsize(fd int, sz domain.Size) error {230 func setWinsize(fd int, sz domain.Size) error {
243- return linuxterm.SetWinsize(fd, uint16(sz.Cols), uint16(sz.Rows))231+ return rawterm.SetWinsize(fd, uint16(sz.Cols), uint16(sz.Rows))
244 }232 }
M internal/adapters/term/pty_integration_test.go +4 -17
@@ -3,14 +3,13 @@
33 package term
44
55 import (
6- "fmt"
76 "io"
87 "os"
98 "syscall"
109 "testing"
1110 "time"
1211
13- "github.com/bnema/vev/pkg/linuxterm"
12+ "github.com/bnema/vev/pkg/rawterm"
1413 )
1514
1615 // openPTYPair opens a real Linux PTY master/slave pair directly via
@@ -24,22 +23,10 @@ func openPTYPair(t *testing.T) (master, slave *os.File) {
2423 t.Fatalf("open /dev/ptmx: %v", err)
2524 }
2625
27- if err := linuxterm.UnlockPt(int(m.Fd())); err != nil {
28- _ = m.Close()
29- t.Fatalf("unlock pty (TIOCSPTLCK): %v", err)
30- }
31-
32- n, err := linuxterm.PtsNumber(int(m.Fd()))
33- if err != nil {
34- _ = m.Close()
35- t.Fatalf("get pty number (TIOCGPTN): %v", err)
36- }
37-
38- slavePath := fmt.Sprintf("/dev/pts/%d", n)
39- s, err := os.OpenFile(slavePath, os.O_RDWR|syscall.O_NOCTTY, 0)
26+ s, err := rawterm.PreparePty(int(m.Fd()))
4027 if err != nil {
4128 _ = m.Close()
42- t.Fatalf("open slave %s: %v", slavePath, err)
29+ t.Fatalf("prepare pty: %v", err)
4330 }
4431
4532 return m, s
@@ -54,7 +41,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
5441 defer func() { _ = master.Close() }()
5542 defer func() { _ = slave.Close() }()
5643
57- if err := linuxterm.SetWinsize(int(slave.Fd()), 80, 24); err != nil {
44+ if err := rawterm.SetWinsize(int(slave.Fd()), 80, 24); err != nil {
5845 t.Fatalf("set winsize: %v", err)
5946 }
6047
@@ -3,14 +3,13 @@
3 package term3 package term
4 4
5 import (5 import (
6- "fmt"
7 "io"6 "io"
8 "os"7 "os"
9 "syscall"8 "syscall"
10 "testing"9 "testing"
11 "time"10 "time"
12 11
13- "github.com/bnema/vev/pkg/linuxterm"12+ "github.com/bnema/vev/pkg/rawterm"
14 )13 )
15 14
16 // openPTYPair opens a real Linux PTY master/slave pair directly via15 // openPTYPair opens a real Linux PTY master/slave pair directly via
@@ -24,22 +23,10 @@ func openPTYPair(t *testing.T) (master, slave *os.File) {
24 t.Fatalf("open /dev/ptmx: %v", err)23 t.Fatalf("open /dev/ptmx: %v", err)
25 }24 }
26 25
27- if err := linuxterm.UnlockPt(int(m.Fd())); err != nil {26+ s, err := rawterm.PreparePty(int(m.Fd()))
28- _ = m.Close()
29- t.Fatalf("unlock pty (TIOCSPTLCK): %v", err)
30- }
31-
32- n, err := linuxterm.PtsNumber(int(m.Fd()))
33- if err != nil {
34- _ = m.Close()
35- t.Fatalf("get pty number (TIOCGPTN): %v", err)
36- }
37-
38- slavePath := fmt.Sprintf("/dev/pts/%d", n)
39- s, err := os.OpenFile(slavePath, os.O_RDWR|syscall.O_NOCTTY, 0)
40 if err != nil {27 if err != nil {
41 _ = m.Close()28 _ = m.Close()
42- t.Fatalf("open slave %s: %v", slavePath, err)29+ t.Fatalf("prepare pty: %v", err)
43 }30 }
44 31
45 return m, s32 return m, s
@@ -54,7 +41,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
54 defer func() { _ = master.Close() }()41 defer func() { _ = master.Close() }()
55 defer func() { _ = slave.Close() }()42 defer func() { _ = slave.Close() }()
56 43
57- if err := linuxterm.SetWinsize(int(slave.Fd()), 80, 24); err != nil {44+ if err := rawterm.SetWinsize(int(slave.Fd()), 80, 24); err != nil {
58 t.Fatalf("set winsize: %v", err)45 t.Fatalf("set winsize: %v", err)
59 }46 }
60 47
M internal/adapters/term/terminal.go +10 -10
@@ -11,7 +11,7 @@ import (
1111
1212 "github.com/bnema/vev/internal/domain"
1313 "github.com/bnema/vev/internal/ports"
14- "github.com/bnema/vev/pkg/linuxterm"
14+ "github.com/bnema/vev/pkg/rawterm"
1515 )
1616
1717 // Compile-time check that Terminal satisfies ports.Terminal.
@@ -49,10 +49,10 @@ type Terminal struct {
4949 bw *batchWriter
5050
5151 mu sync.Mutex
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
52+ orig *rawterm.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
5656
5757 // Resize watcher state, also guarded by mu so watcher start
5858 // (ResizeEvents) and watcher stop (restore) are strictly ordered:
@@ -95,9 +95,9 @@ func (t *Terminal) EnterRaw() (func() error, error) {
9595 return t.restoreFn, nil
9696 }
9797
98- var old *linuxterm.State
99- if linuxterm.IsTerminal(t.fd) {
100- s, err := linuxterm.MakeRaw(t.fd)
98+ var old *rawterm.State
99+ if rawterm.IsTerminal(t.fd) {
100+ s, err := rawterm.MakeRaw(t.fd)
101101 if err != nil {
102102 return nil, fmt.Errorf("term: make raw: %w", err)
103103 }
@@ -170,14 +170,14 @@ func (t *Terminal) restoreRawLocked() error {
170170 if t.orig == nil {
171171 return nil
172172 }
173- err := linuxterm.Restore(t.fd, t.orig)
173+ err := rawterm.Restore(t.fd, t.orig)
174174 t.orig = nil
175175 return err
176176 }
177177
178178 // Size returns the current terminal dimensions.
179179 func (t *Terminal) Size() (domain.Size, error) {
180- cols, rows, err := linuxterm.GetSize(t.fd)
180+ cols, rows, err := rawterm.GetSize(t.fd)
181181 if err != nil {
182182 return domain.Size{}, fmt.Errorf("term: get size: %w", err)
183183 }
@@ -11,7 +11,7 @@ import (
11 11
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
14- "github.com/bnema/vev/pkg/linuxterm"14+ "github.com/bnema/vev/pkg/rawterm"
15 )15 )
16 16
17 // Compile-time check that Terminal satisfies ports.Terminal.17 // Compile-time check that Terminal satisfies ports.Terminal.
@@ -49,10 +49,10 @@ type Terminal struct {
49 bw *batchWriter49 bw *batchWriter
50 50
51 mu sync.Mutex51 mu sync.Mutex
52- orig *linuxterm.State // saved terminal state; nil when raw mode wasn't entered (or was restored)52+ orig *rawterm.State // saved terminal state; nil when raw mode wasn't entered (or was restored)
53- entered bool // true between a successful EnterRaw and its restore53+ entered bool // true between a successful EnterRaw and its restore
54- rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped54+ 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 session55+ restoreFn func() error // the single idempotent restore closure for the current session
56 56
57 // Resize watcher state, also guarded by mu so watcher start57 // Resize watcher state, also guarded by mu so watcher start
58 // (ResizeEvents) and watcher stop (restore) are strictly ordered:58 // (ResizeEvents) and watcher stop (restore) are strictly ordered:
@@ -95,9 +95,9 @@ func (t *Terminal) EnterRaw() (func() error, error) {
95 return t.restoreFn, nil95 return t.restoreFn, nil
96 }96 }
97 97
98- var old *linuxterm.State98+ var old *rawterm.State
99- if linuxterm.IsTerminal(t.fd) {99+ if rawterm.IsTerminal(t.fd) {
100- s, err := linuxterm.MakeRaw(t.fd)100+ s, err := rawterm.MakeRaw(t.fd)
101 if err != nil {101 if err != nil {
102 return nil, fmt.Errorf("term: make raw: %w", err)102 return nil, fmt.Errorf("term: make raw: %w", err)
103 }103 }
@@ -170,14 +170,14 @@ func (t *Terminal) restoreRawLocked() error {
170 if t.orig == nil {170 if t.orig == nil {
171 return nil171 return nil
172 }172 }
173- err := linuxterm.Restore(t.fd, t.orig)173+ err := rawterm.Restore(t.fd, t.orig)
174 t.orig = nil174 t.orig = nil
175 return err175 return err
176 }176 }
177 177
178 // Size returns the current terminal dimensions.178 // Size returns the current terminal dimensions.
179 func (t *Terminal) Size() (domain.Size, error) {179 func (t *Terminal) Size() (domain.Size, error) {
180- cols, rows, err := linuxterm.GetSize(t.fd)180+ cols, rows, err := rawterm.GetSize(t.fd)
181 if err != nil {181 if err != nil {
182 return domain.Size{}, fmt.Errorf("term: get size: %w", err)182 return domain.Size{}, fmt.Errorf("term: get size: %w", err)
183 }183 }
A internal/app/selfexe_darwin.go +9 -0
@@ -0,0 +1,9 @@
1+//go:build darwin
2+
3+package app
4+
5+import "os"
6+
7+func selfExePath() (string, error) {
8+ return os.Executable()
9+}
@@ -0,0 +1,9 @@
1+//go:build darwin
2+
3+package app
4+
5+import "os"
6+
7+func selfExePath() (string, error) {
8+ return os.Executable()
9+}
A internal/app/selfexe_linux.go +7 -0
@@ -0,0 +1,7 @@
1+//go:build linux
2+
3+package app
4+
5+func selfExePath() (string, error) {
6+ return "/proc/self/exe", nil
7+}
@@ -0,0 +1,7 @@
1+//go:build linux
2+
3+package app
4+
5+func selfExePath() (string, error) {
6+ return "/proc/self/exe", nil
7+}
A internal/app/selfexe_linux_test.go +15 -0
@@ -0,0 +1,15 @@
1+//go:build linux
2+
3+package app
4+
5+import "testing"
6+
7+func TestSelfExePath(t *testing.T) {
8+ got, err := selfExePath()
9+ if err != nil {
10+ t.Fatalf("selfExePath: %v", err)
11+ }
12+ if got != "/proc/self/exe" {
13+ t.Fatalf("selfExePath = %q, want %q", got, "/proc/self/exe")
14+ }
15+}
@@ -0,0 +1,15 @@
1+//go:build linux
2+
3+package app
4+
5+import "testing"
6+
7+func TestSelfExePath(t *testing.T) {
8+ got, err := selfExePath()
9+ if err != nil {
10+ t.Fatalf("selfExePath: %v", err)
11+ }
12+ if got != "/proc/self/exe" {
13+ t.Fatalf("selfExePath = %q, want %q", got, "/proc/self/exe")
14+ }
15+}
M internal/app/spawn.go +6 -1
@@ -157,13 +157,18 @@ func realDial(ctx context.Context, dir string) (ports.Transport, error) {
157157 // and never writes to the client's terminal. It logs via slog to the shared
158158 // log file instead.
159159 func realSpawn() error {
160+ exePath, err := selfExePath()
161+ if err != nil {
162+ return fmt.Errorf("resolving executable path: %w", err)
163+ }
164+
160165 devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
161166 if err != nil {
162167 return fmt.Errorf("opening %s: %w", os.DevNull, err)
163168 }
164169 defer func() { _ = devNull.Close() }()
165170
166- cmd := exec.Command("/proc/self/exe", "--daemon")
171+ cmd := exec.Command(exePath, "--daemon")
167172 cmd.Dir = platform.DirOrHome("")
168173 cmd.Stdin = devNull
169174 cmd.Stdout = devNull
@@ -157,13 +157,18 @@ func realDial(ctx context.Context, dir string) (ports.Transport, error) {
157 // and never writes to the client's terminal. It logs via slog to the shared157 // and never writes to the client's terminal. It logs via slog to the shared
158 // log file instead.158 // log file instead.
159 func realSpawn() error {159 func realSpawn() error {
160+ exePath, err := selfExePath()
161+ if err != nil {
162+ return fmt.Errorf("resolving executable path: %w", err)
163+ }
164+
160 devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)165 devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
161 if err != nil {166 if err != nil {
162 return fmt.Errorf("opening %s: %w", os.DevNull, err)167 return fmt.Errorf("opening %s: %w", os.DevNull, err)
163 }168 }
164 defer func() { _ = devNull.Close() }()169 defer func() { _ = devNull.Close() }()
165 170
166- cmd := exec.Command("/proc/self/exe", "--daemon")171+ cmd := exec.Command(exePath, "--daemon")
167 cmd.Dir = platform.DirOrHome("")172 cmd.Dir = platform.DirOrHome("")
168 cmd.Stdin = devNull173 cmd.Stdin = devNull
169 cmd.Stdout = devNull174 cmd.Stdout = devNull
A internal/platform/process.go +69 -0
@@ -0,0 +1,69 @@
1+package platform
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "fmt"
7+ "sort"
8+ "time"
9+)
10+
11+const processRecordCacheTTL = 250 * time.Millisecond
12+
13+type processRecord struct{ pid, pgrp int }
14+
15+func selectProcessGroupPID(recs []processRecord, pgid int, shellPid int) (int, bool) {
16+ if pgid <= 0 {
17+ return 0, false
18+ }
19+ matches := make([]int, 0)
20+ for _, r := range recs {
21+ if r.pgrp == pgid && r.pid != shellPid {
22+ matches = append(matches, r.pid)
23+ }
24+ }
25+ if len(matches) == 0 {
26+ return 0, false
27+ }
28+ sort.Ints(matches)
29+ return matches[0], true
30+}
31+
32+// parseKernProcArgs2 parses the KERN_PROCARGS2 buffer returned by Darwin.
33+// The layout is int argc, executable path, zero padding, argv, then envp.
34+func parseKernProcArgs2(data []byte) ([]string, error) {
35+ const argcSize = 4
36+ if len(data) < argcSize {
37+ return nil, fmt.Errorf("kern_procargs2: truncated argc")
38+ }
39+ argc := int(int32(binary.LittleEndian.Uint32(data[:argcSize])))
40+ if argc <= 0 {
41+ return nil, fmt.Errorf("kern_procargs2: invalid argc %d", argc)
42+ }
43+
44+ rest := data[argcSize:]
45+ execEnd := bytes.IndexByte(rest, 0)
46+ if execEnd < 0 {
47+ return nil, fmt.Errorf("kern_procargs2: unterminated executable path")
48+ }
49+ rest = rest[execEnd+1:]
50+ for len(rest) > 0 && rest[0] == 0 {
51+ rest = rest[1:]
52+ }
53+ // Each argument must contain at least its terminating NUL byte. Check this
54+ // before allocating from the kernel-provided argc.
55+ if argc > len(rest) {
56+ return nil, fmt.Errorf("kern_procargs2: argc %d exceeds remaining data", argc)
57+ }
58+
59+ argv := make([]string, 0, argc)
60+ for len(argv) < argc {
61+ end := bytes.IndexByte(rest, 0)
62+ if end < 0 {
63+ return nil, fmt.Errorf("kern_procargs2: truncated argv")
64+ }
65+ argv = append(argv, string(rest[:end]))
66+ rest = rest[end+1:]
67+ }
68+ return argv, nil
69+}
@@ -0,0 +1,69 @@
1+package platform
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "fmt"
7+ "sort"
8+ "time"
9+)
10+
11+const processRecordCacheTTL = 250 * time.Millisecond
12+
13+type processRecord struct{ pid, pgrp int }
14+
15+func selectProcessGroupPID(recs []processRecord, pgid int, shellPid int) (int, bool) {
16+ if pgid <= 0 {
17+ return 0, false
18+ }
19+ matches := make([]int, 0)
20+ for _, r := range recs {
21+ if r.pgrp == pgid && r.pid != shellPid {
22+ matches = append(matches, r.pid)
23+ }
24+ }
25+ if len(matches) == 0 {
26+ return 0, false
27+ }
28+ sort.Ints(matches)
29+ return matches[0], true
30+}
31+
32+// parseKernProcArgs2 parses the KERN_PROCARGS2 buffer returned by Darwin.
33+// The layout is int argc, executable path, zero padding, argv, then envp.
34+func parseKernProcArgs2(data []byte) ([]string, error) {
35+ const argcSize = 4
36+ if len(data) < argcSize {
37+ return nil, fmt.Errorf("kern_procargs2: truncated argc")
38+ }
39+ argc := int(int32(binary.LittleEndian.Uint32(data[:argcSize])))
40+ if argc <= 0 {
41+ return nil, fmt.Errorf("kern_procargs2: invalid argc %d", argc)
42+ }
43+
44+ rest := data[argcSize:]
45+ execEnd := bytes.IndexByte(rest, 0)
46+ if execEnd < 0 {
47+ return nil, fmt.Errorf("kern_procargs2: unterminated executable path")
48+ }
49+ rest = rest[execEnd+1:]
50+ for len(rest) > 0 && rest[0] == 0 {
51+ rest = rest[1:]
52+ }
53+ // Each argument must contain at least its terminating NUL byte. Check this
54+ // before allocating from the kernel-provided argc.
55+ if argc > len(rest) {
56+ return nil, fmt.Errorf("kern_procargs2: argc %d exceeds remaining data", argc)
57+ }
58+
59+ argv := make([]string, 0, argc)
60+ for len(argv) < argc {
61+ end := bytes.IndexByte(rest, 0)
62+ if end < 0 {
63+ return nil, fmt.Errorf("kern_procargs2: truncated argv")
64+ }
65+ argv = append(argv, string(rest[:end]))
66+ rest = rest[end+1:]
67+ }
68+ return argv, nil
69+}
A internal/platform/process_darwin_arm64.go +393 -0
@@ -0,0 +1,393 @@
1+//go:build darwin && arm64
2+
3+package platform
4+
5+import (
6+ "bytes"
7+ "fmt"
8+ "runtime"
9+ "sync"
10+ "syscall"
11+ "time"
12+ "unsafe"
13+)
14+
15+const (
16+ ctlKern = 1
17+ kernProc = 14
18+ kernProcAll = 0
19+ kernProcPID = 1
20+ kernProcArgs2 = 49
21+ procInfoCallPIDInfo = 2
22+ procPIDVnodePathInfo = 9
23+ darwinKinfoProcSize = 0x288
24+ darwinVnodePathInfoLen = 2352
25+ maxDarwinPID = 1<<31 - 1
26+ rawSysctlMaxRetries = 3
27+)
28+
29+// ProcessInspector implements ports.ProcessInspector using Darwin sysctl and
30+// proc_pidinfo interfaces.
31+type ProcessInspector struct {
32+ mu sync.Mutex
33+ cachedRecords []processRecord
34+ cachedRecordsUntil time.Time
35+}
36+
37+// NewProcessInspector returns a Darwin process inspector.
38+func NewProcessInspector() *ProcessInspector { return &ProcessInspector{} }
39+
40+func (p *ProcessInspector) Cwd(pid int) (string, error) { return ProcessCwd(pid) }
41+func (p *ProcessInspector) Comm(pid int) (string, error) { return ProcessComm(pid) }
42+func (p *ProcessInspector) Argv(pid int) ([]string, error) { return ProcessArgv(pid) }
43+func (p *ProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) {
44+ recs, err := p.processRecords()
45+ if err != nil {
46+ return nil, err
47+ }
48+ pid, ok := selectProcessGroupPID(recs, pgid, shellPid)
49+ if !ok {
50+ return nil, nil
51+ }
52+ return p.Argv(pid)
53+}
54+
55+func (p *ProcessInspector) processRecords() ([]processRecord, error) {
56+ p.mu.Lock()
57+ defer p.mu.Unlock()
58+ now := time.Now()
59+ if now.Before(p.cachedRecordsUntil) {
60+ return p.cachedRecords, nil
61+ }
62+ recs, err := readDarwinProcessRecords()
63+ if err != nil {
64+ return nil, err
65+ }
66+ p.cachedRecords = recs
67+ p.cachedRecordsUntil = now.Add(processRecordCacheTTL)
68+ return recs, nil
69+}
70+
71+// ProcessCwd returns the current working directory from proc_pidinfo.
72+func ProcessCwd(pid int) (string, error) {
73+ pid32, ok := darwinPID(pid)
74+ if !ok {
75+ return "", fmt.Errorf("process cwd: invalid pid %d", pid)
76+ }
77+ var info procVnodePathInfo
78+ n, _, errno := syscall.Syscall6(
79+ syscall.SYS_PROC_INFO,
80+ procInfoCallPIDInfo,
81+ uintptr(pid32),
82+ procPIDVnodePathInfo,
83+ 0,
84+ uintptr(unsafe.Pointer(&info)),
85+ unsafe.Sizeof(info),
86+ )
87+ runtime.KeepAlive(&info)
88+ if errno != 0 {
89+ return "", fmt.Errorf("process cwd: proc_pidinfo pid %d: %w", pid, errno)
90+ }
91+ if n != unsafe.Sizeof(info) {
92+ return "", fmt.Errorf("process cwd: proc_pidinfo pid %d returned %d bytes; want %d", pid, n, unsafe.Sizeof(info))
93+ }
94+ path := info.Cdir.Path[:]
95+ if end := bytes.IndexByte(path, 0); end >= 0 {
96+ path = path[:end]
97+ }
98+ if len(path) == 0 {
99+ return "", fmt.Errorf("process cwd: empty cwd for pid %d", pid)
100+ }
101+ return string(path), nil
102+}
103+
104+// ProcessComm returns the p_comm field from KERN_PROC_PID data.
105+func ProcessComm(pid int) (string, error) {
106+ pid32, ok := darwinPID(pid)
107+ if !ok {
108+ return "", fmt.Errorf("process comm: invalid pid %d", pid)
109+ }
110+ data, err := rawSysctl([]int32{ctlKern, kernProc, kernProcPID, pid32})
111+ if err != nil {
112+ return "", fmt.Errorf("process comm: sysctl pid %d: %w", pid, err)
113+ }
114+ if len(data) != darwinKinfoProcSize {
115+ return "", fmt.Errorf("process comm: sysctl pid %d returned %d bytes; want %d", pid, len(data), darwinKinfoProcSize)
116+ }
117+ proc := *(*kinfoProc)(unsafe.Pointer(&data[0]))
118+ runtime.KeepAlive(data)
119+ comm := proc.Proc.Comm[:]
120+ if end := bytes.IndexByte(comm, 0); end >= 0 {
121+ comm = comm[:end]
122+ }
123+ if len(comm) == 0 {
124+ return "", fmt.Errorf("process comm: empty comm for pid %d", pid)
125+ }
126+ return string(comm), nil
127+}
128+
129+// ProcessArgv returns argv from KERN_PROCARGS2.
130+func ProcessArgv(pid int) ([]string, error) {
131+ pid32, ok := darwinPID(pid)
132+ if !ok {
133+ return nil, fmt.Errorf("process argv: invalid pid %d", pid)
134+ }
135+ data, err := rawSysctl([]int32{ctlKern, kernProcArgs2, pid32})
136+ if err != nil {
137+ return nil, fmt.Errorf("process argv: sysctl pid %d: %w", pid, err)
138+ }
139+ argv, err := parseKernProcArgs2(data)
140+ if err != nil {
141+ return nil, fmt.Errorf("process argv: pid %d: %w", pid, err)
142+ }
143+ return argv, nil
144+}
145+
146+// ProcessGroupArgv returns argv for a restorable foreground process in pgid.
147+// A bare pane shell (the only process in its foreground group) returns nil argv.
148+func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) {
149+ return NewProcessInspector().GroupArgv(pgid, shellPid)
150+}
151+
152+func readDarwinProcessRecords() ([]processRecord, error) {
153+ data, err := rawSysctl([]int32{ctlKern, kernProc, kernProcAll})
154+ if err != nil {
155+ return nil, err
156+ }
157+ if len(data)%darwinKinfoProcSize != 0 {
158+ return nil, fmt.Errorf("kern_proc_all returned %d bytes, not a multiple of %d", len(data), darwinKinfoProcSize)
159+ }
160+ recs := make([]processRecord, 0, len(data)/darwinKinfoProcSize)
161+ for len(data) > 0 {
162+ proc := (*kinfoProc)(unsafe.Pointer(&data[0]))
163+ recs = append(recs, processRecord{pid: int(proc.Proc.Pid), pgrp: int(proc.Eproc.Pgid)})
164+ data = data[darwinKinfoProcSize:]
165+ }
166+ runtime.KeepAlive(data)
167+ return recs, nil
168+}
169+
170+func darwinPID(pid int) (int32, bool) {
171+ if pid <= 0 || pid > maxDarwinPID {
172+ return 0, false
173+ }
174+ return int32(pid), true
175+}
176+
177+// rawSysctl reads a binary sysctl MIB, retrying if a growing result races the
178+// size query. It is the stdlib syscall equivalent of sysctl(3).
179+func rawSysctl(mib []int32) ([]byte, error) {
180+ return rawSysctlWith(mib, sysctl)
181+}
182+
183+func rawSysctlWith(mib []int32, call func([]int32, *byte, *uintptr) error) ([]byte, error) {
184+ for retry := 0; retry <= rawSysctlMaxRetries; retry++ {
185+ var size uintptr
186+ if err := call(mib, nil, &size); err != nil {
187+ return nil, err
188+ }
189+ if size == 0 {
190+ return nil, nil
191+ }
192+ buf := make([]byte, size)
193+ n := size
194+ if err := call(mib, &buf[0], &n); err != nil {
195+ if err == syscall.ENOMEM && retry < rawSysctlMaxRetries {
196+ continue
197+ }
198+ if err == syscall.ENOMEM {
199+ return nil, fmt.Errorf("sysctl %v: result grew after %d retries: %w", mib, rawSysctlMaxRetries, err)
200+ }
201+ return nil, err
202+ }
203+ return buf[:n], nil
204+ }
205+ panic("unreachable")
206+}
207+
208+func sysctl(mib []int32, old *byte, oldlen *uintptr) error {
209+ _, _, errno := syscall.Syscall6(
210+ syscall.SYS___SYSCTL,
211+ uintptr(unsafe.Pointer(&mib[0])),
212+ uintptr(len(mib)),
213+ uintptr(unsafe.Pointer(old)),
214+ uintptr(unsafe.Pointer(oldlen)),
215+ 0,
216+ 0,
217+ )
218+ runtime.KeepAlive(mib)
219+ if errno != 0 {
220+ return errno
221+ }
222+ return nil
223+}
224+
225+// The following Darwin/arm64 ABI layouts are derived from Apple XNU
226+// bsd/sys/proc.h and bsd/sys/proc_info.h, via golang.org/x/sys/unix
227+// ztypes_darwin_arm64.go (BSD-3-Clause). Pointer fields are uintptr so this
228+// package has no dependency on x/sys and never lets kernel pointers escape.
229+type kinfoProc struct {
230+ Proc externProc
231+ Eproc eproc
232+}
233+
234+type eproc struct {
235+ Paddr uintptr
236+ Sess uintptr
237+ Pcred pcred
238+ Ucred ucred
239+ Vm vmspace
240+ Ppid int32
241+ Pgid int32
242+ Jobc int16
243+ Tdev int32
244+ Tpgid int32
245+ Tsess uintptr
246+ Wmesg [8]byte
247+ Xsize int32
248+ Xrssize int16
249+ Xccount int16
250+ Xswrss int16
251+ Flag int32
252+ Login [12]byte
253+ Spare [4]int32
254+ _ [4]byte
255+}
256+
257+type externProc struct {
258+ Un [16]byte
259+ Vmspace uintptr
260+ Sigacts uintptr
261+ Flag int32
262+ Stat int8
263+ Pid int32
264+ Oppid int32
265+ Dupfd int32
266+ _ [4]byte
267+ UserStack uintptr
268+ ExitThread uintptr
269+ Debugger int32
270+ Sigwait int32
271+ Estcpu uint32
272+ Cpticks int32
273+ Pctcpu uint32
274+ _ [4]byte
275+ Wchan uintptr
276+ Wmesg uintptr
277+ Swtime uint32
278+ Slptime uint32
279+ Realtimer itimerval
280+ Rtime timeval
281+ Uticks uint64
282+ Sticks uint64
283+ Iticks uint64
284+ Traceflag int32
285+ _ [4]byte
286+ Tracep uintptr
287+ Siglist int32
288+ _ [4]byte
289+ Textvp uintptr
290+ Holdcnt int32
291+ Sigmask uint32
292+ Sigignore uint32
293+ Sigcatch uint32
294+ Priority uint8
295+ Usrpri uint8
296+ Nice int8
297+ Comm [17]byte
298+ _ [4]byte
299+ Pgrp uintptr
300+ Addr uintptr
301+ Xstat uint16
302+ Acflag uint16
303+ _ [4]byte
304+ Ru uintptr
305+}
306+
307+type itimerval struct {
308+ Interval timeval
309+ Value timeval
310+}
311+
312+type timeval struct {
313+ Sec int64
314+ Usec int32
315+ _ [4]byte
316+}
317+
318+type pcred struct {
319+ Lock [72]int8
320+ Ucred uintptr
321+ Ruid uint32
322+ Svuid uint32
323+ Rgid uint32
324+ Svgid uint32
325+ Refcnt int32
326+ _ [4]byte
327+}
328+
329+type ucred struct {
330+ Ref int32
331+ UID uint32
332+ Ngroups int16
333+ Groups [16]uint32
334+}
335+
336+type vmspace struct {
337+ Dummy int32
338+ Dummy2 uintptr
339+ Dummy3 [5]int32
340+ Dummy4 [3]uintptr
341+}
342+
343+type vinfoStat struct {
344+ Dev uint32
345+ Mode uint16
346+ Nlink uint16
347+ Ino uint64
348+ UID uint32
349+ GID uint32
350+ Atime int64
351+ Atimensec int64
352+ Mtime int64
353+ Mtimensec int64
354+ Ctime int64
355+ Ctimensec int64
356+ Birthtime int64
357+ Birthtimensec int64
358+ Size int64
359+ Blocks int64
360+ Blksize int32
361+ Flags uint32
362+ Gen uint32
363+ Rdev uint32
364+ Qspare [2]int64
365+}
366+
367+type fsid struct{ Val [2]int32 }
368+
369+type vnodeInfo struct {
370+ Stat vinfoStat
371+ Type int32
372+ Pad int32
373+ Fsid fsid
374+}
375+
376+type vnodeInfoPath struct {
377+ Vi vnodeInfo
378+ Path [1024]byte
379+}
380+
381+type procVnodePathInfo struct {
382+ Cdir vnodeInfoPath
383+ Rdir vnodeInfoPath
384+}
385+
386+func init() {
387+ if unsafe.Sizeof(kinfoProc{}) != darwinKinfoProcSize {
388+ panic(fmt.Sprintf("darwin kinfo_proc ABI size = %d; want %d", unsafe.Sizeof(kinfoProc{}), darwinKinfoProcSize))
389+ }
390+ if unsafe.Sizeof(procVnodePathInfo{}) != darwinVnodePathInfoLen {
391+ panic(fmt.Sprintf("darwin proc_vnodepathinfo ABI size = %d; want %d", unsafe.Sizeof(procVnodePathInfo{}), darwinVnodePathInfoLen))
392+ }
393+}
@@ -0,0 +1,393 @@
1+//go:build darwin && arm64
2+
3+package platform
4+
5+import (
6+ "bytes"
7+ "fmt"
8+ "runtime"
9+ "sync"
10+ "syscall"
11+ "time"
12+ "unsafe"
13+)
14+
15+const (
16+ ctlKern = 1
17+ kernProc = 14
18+ kernProcAll = 0
19+ kernProcPID = 1
20+ kernProcArgs2 = 49
21+ procInfoCallPIDInfo = 2
22+ procPIDVnodePathInfo = 9
23+ darwinKinfoProcSize = 0x288
24+ darwinVnodePathInfoLen = 2352
25+ maxDarwinPID = 1<<31 - 1
26+ rawSysctlMaxRetries = 3
27+)
28+
29+// ProcessInspector implements ports.ProcessInspector using Darwin sysctl and
30+// proc_pidinfo interfaces.
31+type ProcessInspector struct {
32+ mu sync.Mutex
33+ cachedRecords []processRecord
34+ cachedRecordsUntil time.Time
35+}
36+
37+// NewProcessInspector returns a Darwin process inspector.
38+func NewProcessInspector() *ProcessInspector { return &ProcessInspector{} }
39+
40+func (p *ProcessInspector) Cwd(pid int) (string, error) { return ProcessCwd(pid) }
41+func (p *ProcessInspector) Comm(pid int) (string, error) { return ProcessComm(pid) }
42+func (p *ProcessInspector) Argv(pid int) ([]string, error) { return ProcessArgv(pid) }
43+func (p *ProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) {
44+ recs, err := p.processRecords()
45+ if err != nil {
46+ return nil, err
47+ }
48+ pid, ok := selectProcessGroupPID(recs, pgid, shellPid)
49+ if !ok {
50+ return nil, nil
51+ }
52+ return p.Argv(pid)
53+}
54+
55+func (p *ProcessInspector) processRecords() ([]processRecord, error) {
56+ p.mu.Lock()
57+ defer p.mu.Unlock()
58+ now := time.Now()
59+ if now.Before(p.cachedRecordsUntil) {
60+ return p.cachedRecords, nil
61+ }
62+ recs, err := readDarwinProcessRecords()
63+ if err != nil {
64+ return nil, err
65+ }
66+ p.cachedRecords = recs
67+ p.cachedRecordsUntil = now.Add(processRecordCacheTTL)
68+ return recs, nil
69+}
70+
71+// ProcessCwd returns the current working directory from proc_pidinfo.
72+func ProcessCwd(pid int) (string, error) {
73+ pid32, ok := darwinPID(pid)
74+ if !ok {
75+ return "", fmt.Errorf("process cwd: invalid pid %d", pid)
76+ }
77+ var info procVnodePathInfo
78+ n, _, errno := syscall.Syscall6(
79+ syscall.SYS_PROC_INFO,
80+ procInfoCallPIDInfo,
81+ uintptr(pid32),
82+ procPIDVnodePathInfo,
83+ 0,
84+ uintptr(unsafe.Pointer(&info)),
85+ unsafe.Sizeof(info),
86+ )
87+ runtime.KeepAlive(&info)
88+ if errno != 0 {
89+ return "", fmt.Errorf("process cwd: proc_pidinfo pid %d: %w", pid, errno)
90+ }
91+ if n != unsafe.Sizeof(info) {
92+ return "", fmt.Errorf("process cwd: proc_pidinfo pid %d returned %d bytes; want %d", pid, n, unsafe.Sizeof(info))
93+ }
94+ path := info.Cdir.Path[:]
95+ if end := bytes.IndexByte(path, 0); end >= 0 {
96+ path = path[:end]
97+ }
98+ if len(path) == 0 {
99+ return "", fmt.Errorf("process cwd: empty cwd for pid %d", pid)
100+ }
101+ return string(path), nil
102+}
103+
104+// ProcessComm returns the p_comm field from KERN_PROC_PID data.
105+func ProcessComm(pid int) (string, error) {
106+ pid32, ok := darwinPID(pid)
107+ if !ok {
108+ return "", fmt.Errorf("process comm: invalid pid %d", pid)
109+ }
110+ data, err := rawSysctl([]int32{ctlKern, kernProc, kernProcPID, pid32})
111+ if err != nil {
112+ return "", fmt.Errorf("process comm: sysctl pid %d: %w", pid, err)
113+ }
114+ if len(data) != darwinKinfoProcSize {
115+ return "", fmt.Errorf("process comm: sysctl pid %d returned %d bytes; want %d", pid, len(data), darwinKinfoProcSize)
116+ }
117+ proc := *(*kinfoProc)(unsafe.Pointer(&data[0]))
118+ runtime.KeepAlive(data)
119+ comm := proc.Proc.Comm[:]
120+ if end := bytes.IndexByte(comm, 0); end >= 0 {
121+ comm = comm[:end]
122+ }
123+ if len(comm) == 0 {
124+ return "", fmt.Errorf("process comm: empty comm for pid %d", pid)
125+ }
126+ return string(comm), nil
127+}
128+
129+// ProcessArgv returns argv from KERN_PROCARGS2.
130+func ProcessArgv(pid int) ([]string, error) {
131+ pid32, ok := darwinPID(pid)
132+ if !ok {
133+ return nil, fmt.Errorf("process argv: invalid pid %d", pid)
134+ }
135+ data, err := rawSysctl([]int32{ctlKern, kernProcArgs2, pid32})
136+ if err != nil {
137+ return nil, fmt.Errorf("process argv: sysctl pid %d: %w", pid, err)
138+ }
139+ argv, err := parseKernProcArgs2(data)
140+ if err != nil {
141+ return nil, fmt.Errorf("process argv: pid %d: %w", pid, err)
142+ }
143+ return argv, nil
144+}
145+
146+// ProcessGroupArgv returns argv for a restorable foreground process in pgid.
147+// A bare pane shell (the only process in its foreground group) returns nil argv.
148+func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) {
149+ return NewProcessInspector().GroupArgv(pgid, shellPid)
150+}
151+
152+func readDarwinProcessRecords() ([]processRecord, error) {
153+ data, err := rawSysctl([]int32{ctlKern, kernProc, kernProcAll})
154+ if err != nil {
155+ return nil, err
156+ }
157+ if len(data)%darwinKinfoProcSize != 0 {
158+ return nil, fmt.Errorf("kern_proc_all returned %d bytes, not a multiple of %d", len(data), darwinKinfoProcSize)
159+ }
160+ recs := make([]processRecord, 0, len(data)/darwinKinfoProcSize)
161+ for len(data) > 0 {
162+ proc := (*kinfoProc)(unsafe.Pointer(&data[0]))
163+ recs = append(recs, processRecord{pid: int(proc.Proc.Pid), pgrp: int(proc.Eproc.Pgid)})
164+ data = data[darwinKinfoProcSize:]
165+ }
166+ runtime.KeepAlive(data)
167+ return recs, nil
168+}
169+
170+func darwinPID(pid int) (int32, bool) {
171+ if pid <= 0 || pid > maxDarwinPID {
172+ return 0, false
173+ }
174+ return int32(pid), true
175+}
176+
177+// rawSysctl reads a binary sysctl MIB, retrying if a growing result races the
178+// size query. It is the stdlib syscall equivalent of sysctl(3).
179+func rawSysctl(mib []int32) ([]byte, error) {
180+ return rawSysctlWith(mib, sysctl)
181+}
182+
183+func rawSysctlWith(mib []int32, call func([]int32, *byte, *uintptr) error) ([]byte, error) {
184+ for retry := 0; retry <= rawSysctlMaxRetries; retry++ {
185+ var size uintptr
186+ if err := call(mib, nil, &size); err != nil {
187+ return nil, err
188+ }
189+ if size == 0 {
190+ return nil, nil
191+ }
192+ buf := make([]byte, size)
193+ n := size
194+ if err := call(mib, &buf[0], &n); err != nil {
195+ if err == syscall.ENOMEM && retry < rawSysctlMaxRetries {
196+ continue
197+ }
198+ if err == syscall.ENOMEM {
199+ return nil, fmt.Errorf("sysctl %v: result grew after %d retries: %w", mib, rawSysctlMaxRetries, err)
200+ }
201+ return nil, err
202+ }
203+ return buf[:n], nil
204+ }
205+ panic("unreachable")
206+}
207+
208+func sysctl(mib []int32, old *byte, oldlen *uintptr) error {
209+ _, _, errno := syscall.Syscall6(
210+ syscall.SYS___SYSCTL,
211+ uintptr(unsafe.Pointer(&mib[0])),
212+ uintptr(len(mib)),
213+ uintptr(unsafe.Pointer(old)),
214+ uintptr(unsafe.Pointer(oldlen)),
215+ 0,
216+ 0,
217+ )
218+ runtime.KeepAlive(mib)
219+ if errno != 0 {
220+ return errno
221+ }
222+ return nil
223+}
224+
225+// The following Darwin/arm64 ABI layouts are derived from Apple XNU
226+// bsd/sys/proc.h and bsd/sys/proc_info.h, via golang.org/x/sys/unix
227+// ztypes_darwin_arm64.go (BSD-3-Clause). Pointer fields are uintptr so this
228+// package has no dependency on x/sys and never lets kernel pointers escape.
229+type kinfoProc struct {
230+ Proc externProc
231+ Eproc eproc
232+}
233+
234+type eproc struct {
235+ Paddr uintptr
236+ Sess uintptr
237+ Pcred pcred
238+ Ucred ucred
239+ Vm vmspace
240+ Ppid int32
241+ Pgid int32
242+ Jobc int16
243+ Tdev int32
244+ Tpgid int32
245+ Tsess uintptr
246+ Wmesg [8]byte
247+ Xsize int32
248+ Xrssize int16
249+ Xccount int16
250+ Xswrss int16
251+ Flag int32
252+ Login [12]byte
253+ Spare [4]int32
254+ _ [4]byte
255+}
256+
257+type externProc struct {
258+ Un [16]byte
259+ Vmspace uintptr
260+ Sigacts uintptr
261+ Flag int32
262+ Stat int8
263+ Pid int32
264+ Oppid int32
265+ Dupfd int32
266+ _ [4]byte
267+ UserStack uintptr
268+ ExitThread uintptr
269+ Debugger int32
270+ Sigwait int32
271+ Estcpu uint32
272+ Cpticks int32
273+ Pctcpu uint32
274+ _ [4]byte
275+ Wchan uintptr
276+ Wmesg uintptr
277+ Swtime uint32
278+ Slptime uint32
279+ Realtimer itimerval
280+ Rtime timeval
281+ Uticks uint64
282+ Sticks uint64
283+ Iticks uint64
284+ Traceflag int32
285+ _ [4]byte
286+ Tracep uintptr
287+ Siglist int32
288+ _ [4]byte
289+ Textvp uintptr
290+ Holdcnt int32
291+ Sigmask uint32
292+ Sigignore uint32
293+ Sigcatch uint32
294+ Priority uint8
295+ Usrpri uint8
296+ Nice int8
297+ Comm [17]byte
298+ _ [4]byte
299+ Pgrp uintptr
300+ Addr uintptr
301+ Xstat uint16
302+ Acflag uint16
303+ _ [4]byte
304+ Ru uintptr
305+}
306+
307+type itimerval struct {
308+ Interval timeval
309+ Value timeval
310+}
311+
312+type timeval struct {
313+ Sec int64
314+ Usec int32
315+ _ [4]byte
316+}
317+
318+type pcred struct {
319+ Lock [72]int8
320+ Ucred uintptr
321+ Ruid uint32
322+ Svuid uint32
323+ Rgid uint32
324+ Svgid uint32
325+ Refcnt int32
326+ _ [4]byte
327+}
328+
329+type ucred struct {
330+ Ref int32
331+ UID uint32
332+ Ngroups int16
333+ Groups [16]uint32
334+}
335+
336+type vmspace struct {
337+ Dummy int32
338+ Dummy2 uintptr
339+ Dummy3 [5]int32
340+ Dummy4 [3]uintptr
341+}
342+
343+type vinfoStat struct {
344+ Dev uint32
345+ Mode uint16
346+ Nlink uint16
347+ Ino uint64
348+ UID uint32
349+ GID uint32
350+ Atime int64
351+ Atimensec int64
352+ Mtime int64
353+ Mtimensec int64
354+ Ctime int64
355+ Ctimensec int64
356+ Birthtime int64
357+ Birthtimensec int64
358+ Size int64
359+ Blocks int64
360+ Blksize int32
361+ Flags uint32
362+ Gen uint32
363+ Rdev uint32
364+ Qspare [2]int64
365+}
366+
367+type fsid struct{ Val [2]int32 }
368+
369+type vnodeInfo struct {
370+ Stat vinfoStat
371+ Type int32
372+ Pad int32
373+ Fsid fsid
374+}
375+
376+type vnodeInfoPath struct {
377+ Vi vnodeInfo
378+ Path [1024]byte
379+}
380+
381+type procVnodePathInfo struct {
382+ Cdir vnodeInfoPath
383+ Rdir vnodeInfoPath
384+}
385+
386+func init() {
387+ if unsafe.Sizeof(kinfoProc{}) != darwinKinfoProcSize {
388+ panic(fmt.Sprintf("darwin kinfo_proc ABI size = %d; want %d", unsafe.Sizeof(kinfoProc{}), darwinKinfoProcSize))
389+ }
390+ if unsafe.Sizeof(procVnodePathInfo{}) != darwinVnodePathInfoLen {
391+ panic(fmt.Sprintf("darwin proc_vnodepathinfo ABI size = %d; want %d", unsafe.Sizeof(procVnodePathInfo{}), darwinVnodePathInfoLen))
392+ }
393+}
A internal/platform/process_darwin_arm64_test.go +159 -0
@@ -0,0 +1,159 @@
1+//go:build darwin && arm64
2+
3+package platform
4+
5+import (
6+ "errors"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
11+ "syscall"
12+ "testing"
13+ "unsafe"
14+)
15+
16+func TestDarwinKinfoProcLayout(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ got uintptr
20+ want uintptr
21+ }{
22+ {"Proc.Pid", unsafe.Offsetof(kinfoProc{}.Proc) + unsafe.Offsetof(externProc{}.Pid), 40},
23+ {"Proc.Comm", unsafe.Offsetof(kinfoProc{}.Proc) + unsafe.Offsetof(externProc{}.Comm), 243},
24+ {"Eproc.Pgid", unsafe.Offsetof(kinfoProc{}.Eproc) + unsafe.Offsetof(eproc{}.Pgid), 564},
25+ {"kinfoProc size", unsafe.Sizeof(kinfoProc{}), darwinKinfoProcSize},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ if tt.got != tt.want {
30+ t.Fatalf("%s = %d; want %d", tt.name, tt.got, tt.want)
31+ }
32+ })
33+ }
34+}
35+
36+func TestDarwinProcessInspectorCurrentProcess(t *testing.T) {
37+ inspector := NewProcessInspector()
38+ pid := os.Getpid()
39+
40+ cwd, err := inspector.Cwd(pid)
41+ if err != nil {
42+ t.Fatalf("Cwd(%d): %v", pid, err)
43+ }
44+ cwdInfo, err := os.Stat(cwd)
45+ if err != nil {
46+ t.Fatalf("stat Cwd(%d) result %q: %v", pid, cwd, err)
47+ }
48+ currentDirInfo, err := os.Stat(".")
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ if !os.SameFile(cwdInfo, currentDirInfo) {
53+ t.Fatalf("Cwd(%d) = %q; does not identify the current directory", pid, cwd)
54+ }
55+
56+ comm, err := inspector.Comm(pid)
57+ if err != nil {
58+ t.Fatalf("Comm(%d): %v", pid, err)
59+ }
60+ wantComm := filepath.Base(os.Args[0])
61+ if len(wantComm) > len(externProc{}.Comm)-1 {
62+ wantComm = wantComm[:len(externProc{}.Comm)-1]
63+ }
64+ if comm != wantComm {
65+ t.Fatalf("Comm(%d) = %q; want %q", pid, comm, wantComm)
66+ }
67+
68+ argv, err := inspector.Argv(pid)
69+ if err != nil {
70+ t.Fatalf("Argv(%d): %v", pid, err)
71+ }
72+ if len(argv) == 0 || argv[0] != os.Args[0] {
73+ t.Fatalf("Argv(%d) = %#v; want argv[0] %q", pid, argv, os.Args[0])
74+ }
75+
76+ stdinReader, stdinWriter, err := os.Pipe()
77+ if err != nil {
78+ t.Fatal(err)
79+ }
80+ cmd := exec.Command("/bin/cat")
81+ cmd.Stdin = stdinReader
82+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
83+ if err := cmd.Start(); err != nil {
84+ _ = stdinReader.Close()
85+ _ = stdinWriter.Close()
86+ t.Fatal(err)
87+ }
88+ t.Cleanup(func() {
89+ _ = stdinReader.Close()
90+ _ = stdinWriter.Close()
91+ _ = cmd.Process.Kill()
92+ _ = cmd.Wait()
93+ })
94+ groupArgv, err := inspector.GroupArgv(cmd.Process.Pid, -1)
95+ if err != nil {
96+ t.Fatalf("GroupArgv(child group): %v", err)
97+ }
98+ if len(groupArgv) != 1 || groupArgv[0] != "/bin/cat" {
99+ t.Fatalf("GroupArgv(child group) = %#v; want [/bin/cat]", groupArgv)
100+ }
101+}
102+
103+func TestDarwinPIDRejectsUnrepresentableValues(t *testing.T) {
104+ for _, pid := range []int{0, -1, maxDarwinPID + 1} {
105+ if _, err := ProcessCwd(pid); err == nil {
106+ t.Errorf("ProcessCwd(%d) succeeded; want invalid PID error", pid)
107+ }
108+ if _, err := ProcessComm(pid); err == nil {
109+ t.Errorf("ProcessComm(%d) succeeded; want invalid PID error", pid)
110+ }
111+ if _, err := ProcessArgv(pid); err == nil {
112+ t.Errorf("ProcessArgv(%d) succeeded; want invalid PID error", pid)
113+ }
114+ }
115+}
116+
117+func TestRawSysctlRetriesAfterENOMEM(t *testing.T) {
118+ var sizeCalls, readCalls int
119+ data, err := rawSysctlWith([]int32{1, 2}, func(_ []int32, old *byte, oldlen *uintptr) error {
120+ if old == nil {
121+ sizeCalls++
122+ *oldlen = 1
123+ return nil
124+ }
125+ readCalls++
126+ if readCalls == 1 {
127+ return syscall.ENOMEM
128+ }
129+ *old = 'x'
130+ *oldlen = 1
131+ return nil
132+ })
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ if string(data) != "x" || sizeCalls != 2 || readCalls != 2 {
137+ t.Fatalf("rawSysctl calls = size %d, read %d, data %q; want 2, 2, x", sizeCalls, readCalls, data)
138+ }
139+}
140+
141+func TestRawSysctlStopsRetryingAfterLimit(t *testing.T) {
142+ var sizeCalls, readCalls int
143+ _, err := rawSysctlWith([]int32{1, 2}, func(_ []int32, old *byte, oldlen *uintptr) error {
144+ if old == nil {
145+ sizeCalls++
146+ *oldlen = 1
147+ return nil
148+ }
149+ readCalls++
150+ return syscall.ENOMEM
151+ })
152+ if !errors.Is(err, syscall.ENOMEM) || !strings.Contains(err.Error(), "result grew after") {
153+ t.Fatalf("rawSysctl exhaustion error = %v; want contextual ENOMEM", err)
154+ }
155+ wantCalls := rawSysctlMaxRetries + 1
156+ if sizeCalls != wantCalls || readCalls != wantCalls {
157+ t.Fatalf("rawSysctl calls = size %d, read %d; want %d each", sizeCalls, readCalls, wantCalls)
158+ }
159+}
@@ -0,0 +1,159 @@
1+//go:build darwin && arm64
2+
3+package platform
4+
5+import (
6+ "errors"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
11+ "syscall"
12+ "testing"
13+ "unsafe"
14+)
15+
16+func TestDarwinKinfoProcLayout(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ got uintptr
20+ want uintptr
21+ }{
22+ {"Proc.Pid", unsafe.Offsetof(kinfoProc{}.Proc) + unsafe.Offsetof(externProc{}.Pid), 40},
23+ {"Proc.Comm", unsafe.Offsetof(kinfoProc{}.Proc) + unsafe.Offsetof(externProc{}.Comm), 243},
24+ {"Eproc.Pgid", unsafe.Offsetof(kinfoProc{}.Eproc) + unsafe.Offsetof(eproc{}.Pgid), 564},
25+ {"kinfoProc size", unsafe.Sizeof(kinfoProc{}), darwinKinfoProcSize},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ if tt.got != tt.want {
30+ t.Fatalf("%s = %d; want %d", tt.name, tt.got, tt.want)
31+ }
32+ })
33+ }
34+}
35+
36+func TestDarwinProcessInspectorCurrentProcess(t *testing.T) {
37+ inspector := NewProcessInspector()
38+ pid := os.Getpid()
39+
40+ cwd, err := inspector.Cwd(pid)
41+ if err != nil {
42+ t.Fatalf("Cwd(%d): %v", pid, err)
43+ }
44+ cwdInfo, err := os.Stat(cwd)
45+ if err != nil {
46+ t.Fatalf("stat Cwd(%d) result %q: %v", pid, cwd, err)
47+ }
48+ currentDirInfo, err := os.Stat(".")
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ if !os.SameFile(cwdInfo, currentDirInfo) {
53+ t.Fatalf("Cwd(%d) = %q; does not identify the current directory", pid, cwd)
54+ }
55+
56+ comm, err := inspector.Comm(pid)
57+ if err != nil {
58+ t.Fatalf("Comm(%d): %v", pid, err)
59+ }
60+ wantComm := filepath.Base(os.Args[0])
61+ if len(wantComm) > len(externProc{}.Comm)-1 {
62+ wantComm = wantComm[:len(externProc{}.Comm)-1]
63+ }
64+ if comm != wantComm {
65+ t.Fatalf("Comm(%d) = %q; want %q", pid, comm, wantComm)
66+ }
67+
68+ argv, err := inspector.Argv(pid)
69+ if err != nil {
70+ t.Fatalf("Argv(%d): %v", pid, err)
71+ }
72+ if len(argv) == 0 || argv[0] != os.Args[0] {
73+ t.Fatalf("Argv(%d) = %#v; want argv[0] %q", pid, argv, os.Args[0])
74+ }
75+
76+ stdinReader, stdinWriter, err := os.Pipe()
77+ if err != nil {
78+ t.Fatal(err)
79+ }
80+ cmd := exec.Command("/bin/cat")
81+ cmd.Stdin = stdinReader
82+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
83+ if err := cmd.Start(); err != nil {
84+ _ = stdinReader.Close()
85+ _ = stdinWriter.Close()
86+ t.Fatal(err)
87+ }
88+ t.Cleanup(func() {
89+ _ = stdinReader.Close()
90+ _ = stdinWriter.Close()
91+ _ = cmd.Process.Kill()
92+ _ = cmd.Wait()
93+ })
94+ groupArgv, err := inspector.GroupArgv(cmd.Process.Pid, -1)
95+ if err != nil {
96+ t.Fatalf("GroupArgv(child group): %v", err)
97+ }
98+ if len(groupArgv) != 1 || groupArgv[0] != "/bin/cat" {
99+ t.Fatalf("GroupArgv(child group) = %#v; want [/bin/cat]", groupArgv)
100+ }
101+}
102+
103+func TestDarwinPIDRejectsUnrepresentableValues(t *testing.T) {
104+ for _, pid := range []int{0, -1, maxDarwinPID + 1} {
105+ if _, err := ProcessCwd(pid); err == nil {
106+ t.Errorf("ProcessCwd(%d) succeeded; want invalid PID error", pid)
107+ }
108+ if _, err := ProcessComm(pid); err == nil {
109+ t.Errorf("ProcessComm(%d) succeeded; want invalid PID error", pid)
110+ }
111+ if _, err := ProcessArgv(pid); err == nil {
112+ t.Errorf("ProcessArgv(%d) succeeded; want invalid PID error", pid)
113+ }
114+ }
115+}
116+
117+func TestRawSysctlRetriesAfterENOMEM(t *testing.T) {
118+ var sizeCalls, readCalls int
119+ data, err := rawSysctlWith([]int32{1, 2}, func(_ []int32, old *byte, oldlen *uintptr) error {
120+ if old == nil {
121+ sizeCalls++
122+ *oldlen = 1
123+ return nil
124+ }
125+ readCalls++
126+ if readCalls == 1 {
127+ return syscall.ENOMEM
128+ }
129+ *old = 'x'
130+ *oldlen = 1
131+ return nil
132+ })
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ if string(data) != "x" || sizeCalls != 2 || readCalls != 2 {
137+ t.Fatalf("rawSysctl calls = size %d, read %d, data %q; want 2, 2, x", sizeCalls, readCalls, data)
138+ }
139+}
140+
141+func TestRawSysctlStopsRetryingAfterLimit(t *testing.T) {
142+ var sizeCalls, readCalls int
143+ _, err := rawSysctlWith([]int32{1, 2}, func(_ []int32, old *byte, oldlen *uintptr) error {
144+ if old == nil {
145+ sizeCalls++
146+ *oldlen = 1
147+ return nil
148+ }
149+ readCalls++
150+ return syscall.ENOMEM
151+ })
152+ if !errors.Is(err, syscall.ENOMEM) || !strings.Contains(err.Error(), "result grew after") {
153+ t.Fatalf("rawSysctl exhaustion error = %v; want contextual ENOMEM", err)
154+ }
155+ wantCalls := rawSysctlMaxRetries + 1
156+ if sizeCalls != wantCalls || readCalls != wantCalls {
157+ t.Fatalf("rawSysctl calls = size %d, read %d; want %d each", sizeCalls, readCalls, wantCalls)
158+ }
159+}
M internal/platform/process_linux.go +0 -22
@@ -5,15 +5,12 @@ import (
55 "fmt"
66 "os"
77 "path/filepath"
8- "sort"
98 "strconv"
109 "strings"
1110 "sync"
1211 "time"
1312 )
1413
15-const processRecordCacheTTL = 250 * time.Millisecond
16-
1714 // ProcessInspector implements ports.ProcessInspector using Linux /proc.
1815 type ProcessInspector struct {
1916 root string
@@ -87,8 +84,6 @@ func processArgv(path string, pid int) ([]string, error) {
8784 return argv, nil
8885 }
8986
90-type processRecord struct{ pid, pgrp int }
91-
9287 // ProcessGroupArgv returns argv for a restorable foreground process in pgid.
9388 // A bare pane shell (the only process in its foreground group) returns nil argv.
9489 func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) {
@@ -103,23 +98,6 @@ func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) {
10398 return ProcessArgv(pid)
10499 }
105100
106-func selectProcessGroupPID(recs []processRecord, pgid int, shellPid int) (int, bool) {
107- if pgid <= 0 {
108- return 0, false
109- }
110- matches := make([]int, 0)
111- for _, r := range recs {
112- if r.pgrp == pgid && r.pid != shellPid {
113- matches = append(matches, r.pid)
114- }
115- }
116- if len(matches) == 0 {
117- return 0, false
118- }
119- sort.Ints(matches)
120- return matches[0], true
121-}
122-
123101 func readProcRecords(root string) ([]processRecord, error) {
124102 ents, err := os.ReadDir(root)
125103 if err != nil {
@@ -5,15 +5,12 @@ import (
5 "fmt"5 "fmt"
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8- "sort"
9 "strconv"8 "strconv"
10 "strings"9 "strings"
11 "sync"10 "sync"
12 "time"11 "time"
13 )12 )
14 13
15-const processRecordCacheTTL = 250 * time.Millisecond
16-
17 // ProcessInspector implements ports.ProcessInspector using Linux /proc.14 // ProcessInspector implements ports.ProcessInspector using Linux /proc.
18 type ProcessInspector struct {15 type ProcessInspector struct {
19 root string16 root string
@@ -87,8 +84,6 @@ func processArgv(path string, pid int) ([]string, error) {
87 return argv, nil84 return argv, nil
88 }85 }
89 86
90-type processRecord struct{ pid, pgrp int }
91-
92 // ProcessGroupArgv returns argv for a restorable foreground process in pgid.87 // ProcessGroupArgv returns argv for a restorable foreground process in pgid.
93 // A bare pane shell (the only process in its foreground group) returns nil argv.88 // A bare pane shell (the only process in its foreground group) returns nil argv.
94 func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) {89 func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) {
@@ -103,23 +98,6 @@ func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) {
103 return ProcessArgv(pid)98 return ProcessArgv(pid)
104 }99 }
105 100
106-func selectProcessGroupPID(recs []processRecord, pgid int, shellPid int) (int, bool) {
107- if pgid <= 0 {
108- return 0, false
109- }
110- matches := make([]int, 0)
111- for _, r := range recs {
112- if r.pgrp == pgid && r.pid != shellPid {
113- matches = append(matches, r.pid)
114- }
115- }
116- if len(matches) == 0 {
117- return 0, false
118- }
119- sort.Ints(matches)
120- return matches[0], true
121-}
122-
123 func readProcRecords(root string) ([]processRecord, error) {101 func readProcRecords(root string) ([]processRecord, error) {
124 ents, err := os.ReadDir(root)102 ents, err := os.ReadDir(root)
125 if err != nil {103 if err != nil {
M internal/platform/process_linux_test.go +59 -0
@@ -1,3 +1,5 @@
1+//go:build linux
2+
13 package platform
24
35 import (
@@ -140,6 +142,63 @@ func TestParseStatPgrp(t *testing.T) {
140142 }
141143 }
142144
145+func TestParseKernProcArgs2(t *testing.T) {
146+ tests := []struct {
147+ name string
148+ data []byte
149+ want []string
150+ wantErr bool
151+ }{
152+ {
153+ name: "argv after executable and padding",
154+ data: append([]byte{2, 0, 0, 0}, []byte("/bin/tool\x00\x00tool\x00arg\x00ENV=value\x00")...),
155+ want: []string{"tool", "arg"},
156+ },
157+ {
158+ name: "preserves empty argument",
159+ data: append([]byte{3, 0, 0, 0}, []byte("/bin/tool\x00tool\x00\x00arg\x00")...),
160+ want: []string{"tool", "", "arg"},
161+ },
162+ {
163+ name: "truncated argc",
164+ data: []byte{1, 0, 0},
165+ wantErr: true,
166+ },
167+ {
168+ name: "unterminated executable path",
169+ data: append([]byte{1, 0, 0, 0}, []byte("/bin/tool")...),
170+ wantErr: true,
171+ },
172+ {
173+ name: "truncated argv",
174+ data: append([]byte{1, 0, 0, 0}, []byte("/bin/tool\x00tool")...),
175+ wantErr: true,
176+ },
177+ {
178+ name: "negative argc",
179+ data: append([]byte{0xff, 0xff, 0xff, 0xff}, []byte("/bin/tool\x00tool\x00")...),
180+ wantErr: true,
181+ },
182+ {
183+ name: "argc exceeds remaining data",
184+ data: append([]byte{0xff, 0xff, 0xff, 0x7f}, []byte("/bin/tool\x00tool\x00")...),
185+ wantErr: true,
186+ },
187+ }
188+
189+ for _, tt := range tests {
190+ t.Run(tt.name, func(t *testing.T) {
191+ got, err := parseKernProcArgs2(tt.data)
192+ if (err != nil) != tt.wantErr {
193+ t.Fatalf("parseKernProcArgs2 error = %v, wantErr %v", err, tt.wantErr)
194+ }
195+ if !reflect.DeepEqual(got, tt.want) {
196+ t.Fatalf("parseKernProcArgs2 = %#v; want %#v", got, tt.want)
197+ }
198+ })
199+ }
200+}
201+
143202 func TestProcessArgvFileParsing(t *testing.T) {
144203 tests := []struct {
145204 name string
@@ -1,3 +1,5 @@
1+//go:build linux
2+
1 package platform3 package platform
2 4
3 import (5 import (
@@ -140,6 +142,63 @@ func TestParseStatPgrp(t *testing.T) {
140 }142 }
141 }143 }
142 144
145+func TestParseKernProcArgs2(t *testing.T) {
146+ tests := []struct {
147+ name string
148+ data []byte
149+ want []string
150+ wantErr bool
151+ }{
152+ {
153+ name: "argv after executable and padding",
154+ data: append([]byte{2, 0, 0, 0}, []byte("/bin/tool\x00\x00tool\x00arg\x00ENV=value\x00")...),
155+ want: []string{"tool", "arg"},
156+ },
157+ {
158+ name: "preserves empty argument",
159+ data: append([]byte{3, 0, 0, 0}, []byte("/bin/tool\x00tool\x00\x00arg\x00")...),
160+ want: []string{"tool", "", "arg"},
161+ },
162+ {
163+ name: "truncated argc",
164+ data: []byte{1, 0, 0},
165+ wantErr: true,
166+ },
167+ {
168+ name: "unterminated executable path",
169+ data: append([]byte{1, 0, 0, 0}, []byte("/bin/tool")...),
170+ wantErr: true,
171+ },
172+ {
173+ name: "truncated argv",
174+ data: append([]byte{1, 0, 0, 0}, []byte("/bin/tool\x00tool")...),
175+ wantErr: true,
176+ },
177+ {
178+ name: "negative argc",
179+ data: append([]byte{0xff, 0xff, 0xff, 0xff}, []byte("/bin/tool\x00tool\x00")...),
180+ wantErr: true,
181+ },
182+ {
183+ name: "argc exceeds remaining data",
184+ data: append([]byte{0xff, 0xff, 0xff, 0x7f}, []byte("/bin/tool\x00tool\x00")...),
185+ wantErr: true,
186+ },
187+ }
188+
189+ for _, tt := range tests {
190+ t.Run(tt.name, func(t *testing.T) {
191+ got, err := parseKernProcArgs2(tt.data)
192+ if (err != nil) != tt.wantErr {
193+ t.Fatalf("parseKernProcArgs2 error = %v, wantErr %v", err, tt.wantErr)
194+ }
195+ if !reflect.DeepEqual(got, tt.want) {
196+ t.Fatalf("parseKernProcArgs2 = %#v; want %#v", got, tt.want)
197+ }
198+ })
199+ }
200+}
201+
143 func TestProcessArgvFileParsing(t *testing.T) {202 func TestProcessArgvFileParsing(t *testing.T) {
144 tests := []struct {203 tests := []struct {
145 name string204 name string
M internal/usecase/daemon/picker_test.go +9 -0
@@ -877,6 +877,15 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
877877 require.Contains(t, opens[1], "TERM=xterm-direct")
878878 require.Contains(t, opens[1], "COLORTERM=truecolor")
879879 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
880+ require.Eventually(t, func() bool {
881+ tb := got.activeTab()
882+ if tb == nil {
883+ return false
884+ }
885+ tb.mu.Lock()
886+ defer tb.mu.Unlock()
887+ return tb.floating.pane != nil && tb.floating.pane.pty == floating
888+ }, time.Second, 5*time.Millisecond)
880889 _ = d.killSession(got, ports.ReasonSessionKilled, false)
881890 release1()
882891 release2()
@@ -877,6 +877,15 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
877 require.Contains(t, opens[1], "TERM=xterm-direct")877 require.Contains(t, opens[1], "TERM=xterm-direct")
878 require.Contains(t, opens[1], "COLORTERM=truecolor")878 require.Contains(t, opens[1], "COLORTERM=truecolor")
879 require.Contains(t, opens[1], "TERM_PROGRAM=vev")879 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
880+ require.Eventually(t, func() bool {
881+ tb := got.activeTab()
882+ if tb == nil {
883+ return false
884+ }
885+ tb.mu.Lock()
886+ defer tb.mu.Unlock()
887+ return tb.floating.pane != nil && tb.floating.pane.pty == floating
888+ }, time.Second, 5*time.Millisecond)
880 _ = d.killSession(got, ports.ReasonSessionKilled, false)889 _ = d.killSession(got, ports.ReasonSessionKilled, false)
881 release1()890 release1()
882 release2()891 release2()
R pkg/linuxterm/linuxterm.go → pkg/rawterm/rawterm.go +16 -36 renamed, 61% similar
@@ -1,9 +1,9 @@
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
1+//go:build linux || darwin
2+
3+// Package rawterm provides the small set of terminal-control primitives vev
4+// needs directly on top of the frozen stdlib syscall package. The common raw
5+// mode and terminal-size operations use platform-specific termios requests.
6+package rawterm
77
88 import (
99 "fmt"
@@ -40,7 +40,7 @@ func ioctl(fd int, req uintptr, arg unsafe.Pointer) error {
4040 // IsTerminal reports whether fd refers to a terminal device.
4141 func IsTerminal(fd int) bool {
4242 var t syscall.Termios
43- return ioctl(fd, syscall.TCGETS, unsafe.Pointer(&t)) == nil
43+ return ioctl(fd, reqGetTermios, unsafe.Pointer(&t)) == nil
4444 }
4545
4646 // MakeRaw puts the terminal referenced by fd into raw mode and returns its
@@ -48,8 +48,8 @@ func IsTerminal(fd int) bool {
4848 // changes applied match golang.org/x/term's MakeRaw semantics exactly.
4949 func MakeRaw(fd int) (*State, error) {
5050 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)
51+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&oldState.termios)); err != nil {
52+ return nil, fmt.Errorf("rawterm: get termios: %w", err)
5353 }
5454
5555 raw := oldState.termios
@@ -61,8 +61,8 @@ func MakeRaw(fd int) (*State, error) {
6161 raw.Cc[syscall.VMIN] = 1
6262 raw.Cc[syscall.VTIME] = 0
6363
64- if err := ioctl(fd, syscall.TCSETS, unsafe.Pointer(&raw)); err != nil {
65- return nil, fmt.Errorf("linuxterm: set termios: %w", err)
64+ if err := ioctl(fd, reqSetTermios, unsafe.Pointer(&raw)); err != nil {
65+ return nil, fmt.Errorf("rawterm: set termios: %w", err)
6666 }
6767
6868 return &oldState, nil
@@ -71,8 +71,8 @@ func MakeRaw(fd int) (*State, error) {
7171 // Restore applies a previously captured State back to the terminal
7272 // referenced by fd.
7373 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)
74+ if err := ioctl(fd, reqSetTermios, unsafe.Pointer(&s.termios)); err != nil {
75+ return fmt.Errorf("rawterm: set termios: %w", err)
7676 }
7777 return nil
7878 }
@@ -81,7 +81,7 @@ func Restore(fd int, s *State) error {
8181 func GetSize(fd int) (cols, rows int, err error) {
8282 var ws winsize
8383 if err := ioctl(fd, syscall.TIOCGWINSZ, unsafe.Pointer(&ws)); err != nil {
84- return 0, 0, fmt.Errorf("linuxterm: get winsize: %w", err)
84+ return 0, 0, fmt.Errorf("rawterm: get winsize: %w", err)
8585 }
8686 return int(ws.Col), int(ws.Row), nil
8787 }
@@ -90,27 +90,7 @@ func GetSize(fd int) (cols, rows int, err error) {
9090 func SetWinsize(fd int, cols, rows uint16) error {
9191 ws := winsize{Row: rows, Col: cols}
9292 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)
93+ return fmt.Errorf("rawterm: set winsize: %w", err)
11494 }
11595 return nil
11696 }
@@ -120,7 +100,7 @@ func UnlockPt(masterFD int) error {
120100 func ForegroundProcessGroup(fd int) (int, error) {
121101 var pgid int32
122102 if err := ioctl(fd, syscall.TIOCGPGRP, unsafe.Pointer(&pgid)); err != nil {
123- return 0, fmt.Errorf("linuxterm: get foreground process group: %w", err)
103+ return 0, fmt.Errorf("rawterm: get foreground process group: %w", err)
124104 }
125105 return int(pgid), nil
126106 }
@@ -1,9 +1,9 @@
1-// Package linuxterm provides the small set of terminal-control primitives1+//go:build linux || darwin
2-// vev needs (raw mode, window size, pty allocation) directly on top of the2+
3-// frozen stdlib syscall package, so the module does not depend on3+// Package rawterm provides the small set of terminal-control primitives vev
4-// golang.org/x/term or golang.org/x/sys. vev is Linux-only, and the Linux4+// needs directly on top of the frozen stdlib syscall package. The common raw
5-// TIOC*/TCGETS ioctl numbers and Termios layout syscall exposes are stable.5+// mode and terminal-size operations use platform-specific termios requests.
6-package linuxterm6+package rawterm
7 7
8 import (8 import (
9 "fmt"9 "fmt"
@@ -40,7 +40,7 @@ func ioctl(fd int, req uintptr, arg unsafe.Pointer) error {
40 // IsTerminal reports whether fd refers to a terminal device.40 // IsTerminal reports whether fd refers to a terminal device.
41 func IsTerminal(fd int) bool {41 func IsTerminal(fd int) bool {
42 var t syscall.Termios42 var t syscall.Termios
43- return ioctl(fd, syscall.TCGETS, unsafe.Pointer(&t)) == nil43+ return ioctl(fd, reqGetTermios, unsafe.Pointer(&t)) == nil
44 }44 }
45 45
46 // MakeRaw puts the terminal referenced by fd into raw mode and returns its46 // MakeRaw puts the terminal referenced by fd into raw mode and returns its
@@ -48,8 +48,8 @@ func IsTerminal(fd int) bool {
48 // changes applied match golang.org/x/term's MakeRaw semantics exactly.48 // changes applied match golang.org/x/term's MakeRaw semantics exactly.
49 func MakeRaw(fd int) (*State, error) {49 func MakeRaw(fd int) (*State, error) {
50 var oldState State50 var oldState State
51- if err := ioctl(fd, syscall.TCGETS, unsafe.Pointer(&oldState.termios)); err != nil {51+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&oldState.termios)); err != nil {
52- return nil, fmt.Errorf("linuxterm: get termios: %w", err)52+ return nil, fmt.Errorf("rawterm: get termios: %w", err)
53 }53 }
54 54
55 raw := oldState.termios55 raw := oldState.termios
@@ -61,8 +61,8 @@ func MakeRaw(fd int) (*State, error) {
61 raw.Cc[syscall.VMIN] = 161 raw.Cc[syscall.VMIN] = 1
62 raw.Cc[syscall.VTIME] = 062 raw.Cc[syscall.VTIME] = 0
63 63
64- if err := ioctl(fd, syscall.TCSETS, unsafe.Pointer(&raw)); err != nil {64+ if err := ioctl(fd, reqSetTermios, unsafe.Pointer(&raw)); err != nil {
65- return nil, fmt.Errorf("linuxterm: set termios: %w", err)65+ return nil, fmt.Errorf("rawterm: set termios: %w", err)
66 }66 }
67 67
68 return &oldState, nil68 return &oldState, nil
@@ -71,8 +71,8 @@ func MakeRaw(fd int) (*State, error) {
71 // Restore applies a previously captured State back to the terminal71 // Restore applies a previously captured State back to the terminal
72 // referenced by fd.72 // referenced by fd.
73 func Restore(fd int, s *State) error {73 func Restore(fd int, s *State) error {
74- if err := ioctl(fd, syscall.TCSETS, unsafe.Pointer(&s.termios)); err != nil {74+ if err := ioctl(fd, reqSetTermios, unsafe.Pointer(&s.termios)); err != nil {
75- return fmt.Errorf("linuxterm: set termios: %w", err)75+ return fmt.Errorf("rawterm: set termios: %w", err)
76 }76 }
77 return nil77 return nil
78 }78 }
@@ -81,7 +81,7 @@ func Restore(fd int, s *State) error {
81 func GetSize(fd int) (cols, rows int, err error) {81 func GetSize(fd int) (cols, rows int, err error) {
82 var ws winsize82 var ws winsize
83 if err := ioctl(fd, syscall.TIOCGWINSZ, unsafe.Pointer(&ws)); err != nil {83 if err := ioctl(fd, syscall.TIOCGWINSZ, unsafe.Pointer(&ws)); err != nil {
84- return 0, 0, fmt.Errorf("linuxterm: get winsize: %w", err)84+ return 0, 0, fmt.Errorf("rawterm: get winsize: %w", err)
85 }85 }
86 return int(ws.Col), int(ws.Row), nil86 return int(ws.Col), int(ws.Row), nil
87 }87 }
@@ -90,27 +90,7 @@ func GetSize(fd int) (cols, rows int, err error) {
90 func SetWinsize(fd int, cols, rows uint16) error {90 func SetWinsize(fd int, cols, rows uint16) error {
91 ws := winsize{Row: rows, Col: cols}91 ws := winsize{Row: rows, Col: cols}
92 if err := ioctl(fd, syscall.TIOCSWINSZ, unsafe.Pointer(&ws)); err != nil {92 if err := ioctl(fd, syscall.TIOCSWINSZ, unsafe.Pointer(&ws)); err != nil {
93- return fmt.Errorf("linuxterm: set winsize: %w", err)93+ return fmt.Errorf("rawterm: 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 }94 }
115 return nil95 return nil
116 }96 }
@@ -120,7 +100,7 @@ func UnlockPt(masterFD int) error {
120 func ForegroundProcessGroup(fd int) (int, error) {100 func ForegroundProcessGroup(fd int) (int, error) {
121 var pgid int32101 var pgid int32
122 if err := ioctl(fd, syscall.TIOCGPGRP, unsafe.Pointer(&pgid)); err != nil {102 if err := ioctl(fd, syscall.TIOCGPGRP, unsafe.Pointer(&pgid)); err != nil {
123- return 0, fmt.Errorf("linuxterm: get foreground process group: %w", err)103+ return 0, fmt.Errorf("rawterm: get foreground process group: %w", err)
124 }104 }
125 return int(pgid), nil105 return int(pgid), nil
126 }106 }
R pkg/linuxterm/linuxterm_test.go → pkg/rawterm/rawterm_test.go +21 -19 renamed, 81% similar
@@ -1,8 +1,9 @@
1-package linuxterm
1+//go:build linux
2+
3+package rawterm
24
35 import (
46 "errors"
5- "fmt"
67 "os"
78 "syscall"
89 "testing"
@@ -11,7 +12,7 @@ import (
1112
1213 // openPTYPair opens a real Linux PTY master/slave pair directly via
1314 // /dev/ptmx using the package under test, so these tests double as an
14-// integration check of PtsNumber/UnlockPt against the kernel.
15+// integration check of PreparePty against the kernel.
1516 func openPTYPair(t *testing.T) (master, slave *os.File) {
1617 t.Helper()
1718
@@ -21,22 +22,10 @@ func openPTYPair(t *testing.T) (master, slave *os.File) {
2122 }
2223 master = os.NewFile(uintptr(masterFD), "pty-master")
2324
24- if err := UnlockPt(masterFD); err != nil {
25- _ = master.Close()
26- t.Fatalf("UnlockPt: %v", err)
27- }
28-
29- n, err := PtsNumber(masterFD)
25+ slave, err = PreparePty(masterFD)
3026 if err != nil {
3127 _ = 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)
28+ t.Fatalf("PreparePty: %v", err)
4029 }
4130
4231 return master, slave
@@ -101,6 +90,11 @@ func TestMakeRawRestore(t *testing.T) {
10190
10291 fd := int(slave.Fd())
10392
93+ var original syscall.Termios
94+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&original)); err != nil {
95+ t.Fatalf("get termios before MakeRaw: %v", err)
96+ }
97+
10498 old, err := MakeRaw(fd)
10599 if err != nil {
106100 t.Fatalf("MakeRaw: %v", err)
@@ -110,8 +104,8 @@ func TestMakeRawRestore(t *testing.T) {
110104 }
111105
112106 var current syscall.Termios
113- if err := ioctl(fd, syscall.TCGETS, unsafe.Pointer(&current)); err != nil {
114- t.Fatalf("TCGETS after MakeRaw: %v", err)
107+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&current)); err != nil {
108+ t.Fatalf("get termios after MakeRaw: %v", err)
115109 }
116110 if current.Lflag&syscall.ECHO != 0 {
117111 t.Errorf("ECHO still set after MakeRaw")
@@ -126,6 +120,14 @@ func TestMakeRawRestore(t *testing.T) {
126120 if err := Restore(fd, old); err != nil {
127121 t.Fatalf("Restore: %v", err)
128122 }
123+
124+ var restored syscall.Termios
125+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&restored)); err != nil {
126+ t.Fatalf("get termios after Restore: %v", err)
127+ }
128+ if restored != original {
129+ t.Errorf("termios after Restore = %#v, want original %#v", restored, original)
130+ }
129131 }
130132
131133 func TestForegroundProcessGroup(t *testing.T) {
@@ -1,8 +1,9 @@
1-package linuxterm1+//go:build linux
2+
3+package rawterm
2 4
3 import (5 import (
4 "errors"6 "errors"
5- "fmt"
6 "os"7 "os"
7 "syscall"8 "syscall"
8 "testing"9 "testing"
@@ -11,7 +12,7 @@ import (
11 12
12 // openPTYPair opens a real Linux PTY master/slave pair directly via13 // openPTYPair opens a real Linux PTY master/slave pair directly via
13 // /dev/ptmx using the package under test, so these tests double as an14 // /dev/ptmx using the package under test, so these tests double as an
14-// integration check of PtsNumber/UnlockPt against the kernel.15+// integration check of PreparePty against the kernel.
15 func openPTYPair(t *testing.T) (master, slave *os.File) {16 func openPTYPair(t *testing.T) (master, slave *os.File) {
16 t.Helper()17 t.Helper()
17 18
@@ -21,22 +22,10 @@ func openPTYPair(t *testing.T) (master, slave *os.File) {
21 }22 }
22 master = os.NewFile(uintptr(masterFD), "pty-master")23 master = os.NewFile(uintptr(masterFD), "pty-master")
23 24
24- if err := UnlockPt(masterFD); err != nil {25+ slave, err = PreparePty(masterFD)
25- _ = master.Close()
26- t.Fatalf("UnlockPt: %v", err)
27- }
28-
29- n, err := PtsNumber(masterFD)
30 if err != nil {26 if err != nil {
31 _ = master.Close()27 _ = master.Close()
32- t.Fatalf("PtsNumber: %v", err)28+ t.Fatalf("PreparePty: %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 }29 }
41 30
42 return master, slave31 return master, slave
@@ -101,6 +90,11 @@ func TestMakeRawRestore(t *testing.T) {
101 90
102 fd := int(slave.Fd())91 fd := int(slave.Fd())
103 92
93+ var original syscall.Termios
94+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&original)); err != nil {
95+ t.Fatalf("get termios before MakeRaw: %v", err)
96+ }
97+
104 old, err := MakeRaw(fd)98 old, err := MakeRaw(fd)
105 if err != nil {99 if err != nil {
106 t.Fatalf("MakeRaw: %v", err)100 t.Fatalf("MakeRaw: %v", err)
@@ -110,8 +104,8 @@ func TestMakeRawRestore(t *testing.T) {
110 }104 }
111 105
112 var current syscall.Termios106 var current syscall.Termios
113- if err := ioctl(fd, syscall.TCGETS, unsafe.Pointer(&current)); err != nil {107+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&current)); err != nil {
114- t.Fatalf("TCGETS after MakeRaw: %v", err)108+ t.Fatalf("get termios after MakeRaw: %v", err)
115 }109 }
116 if current.Lflag&syscall.ECHO != 0 {110 if current.Lflag&syscall.ECHO != 0 {
117 t.Errorf("ECHO still set after MakeRaw")111 t.Errorf("ECHO still set after MakeRaw")
@@ -126,6 +120,14 @@ func TestMakeRawRestore(t *testing.T) {
126 if err := Restore(fd, old); err != nil {120 if err := Restore(fd, old); err != nil {
127 t.Fatalf("Restore: %v", err)121 t.Fatalf("Restore: %v", err)
128 }122 }
123+
124+ var restored syscall.Termios
125+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&restored)); err != nil {
126+ t.Fatalf("get termios after Restore: %v", err)
127+ }
128+ if restored != original {
129+ t.Errorf("termios after Restore = %#v, want original %#v", restored, original)
130+ }
129 }131 }
130 132
131 func TestForegroundProcessGroup(t *testing.T) {133 func TestForegroundProcessGroup(t *testing.T) {
A pkg/rawterm/term_darwin.go +50 -0
@@ -0,0 +1,50 @@
1+//go:build darwin
2+
3+package rawterm
4+
5+import (
6+ "bytes"
7+ "fmt"
8+ "os"
9+ "syscall"
10+ "unsafe"
11+)
12+
13+const (
14+ // TIOCGETA and TIOCSETA are Darwin's termios ioctl requests. They differ
15+ // from the Linux TCGETS and TCSETS requests despite operating on the same
16+ // syscall.Termios type.
17+ reqGetTermios = syscall.TIOCGETA
18+ reqSetTermios = syscall.TIOCSETA
19+)
20+
21+// PreparePty grants and unlocks the slave associated with masterFD, obtains
22+// its name from the kernel, and opens it. Darwin requires this exact sequence:
23+// TIOCPTYGRANT before TIOCPTYUNLK before TIOCPTYGNAME.
24+func PreparePty(masterFD int) (*os.File, error) {
25+ if err := ioctl(masterFD, syscall.TIOCPTYGRANT, nil); err != nil {
26+ return nil, fmt.Errorf("rawterm: grant pty: %w", err)
27+ }
28+ if err := ioctl(masterFD, syscall.TIOCPTYUNLK, nil); err != nil {
29+ return nil, fmt.Errorf("rawterm: unlock pty: %w", err)
30+ }
31+
32+ var slaveName [128]byte
33+ if err := ioctl(masterFD, syscall.TIOCPTYGNAME, unsafe.Pointer(&slaveName[0])); err != nil {
34+ return nil, fmt.Errorf("rawterm: get pty name: %w", err)
35+ }
36+ nul := bytes.IndexByte(slaveName[:], 0)
37+ if nul < 0 {
38+ return nil, fmt.Errorf("rawterm: get pty name: unterminated slave name")
39+ }
40+ if nul == 0 {
41+ return nil, fmt.Errorf("rawterm: get pty name: empty slave name")
42+ }
43+
44+ name := string(slaveName[:nul])
45+ slave, err := os.OpenFile(name, os.O_RDWR|syscall.O_NOCTTY, 0)
46+ if err != nil {
47+ return nil, fmt.Errorf("rawterm: open %s: %w", name, err)
48+ }
49+ return slave, nil
50+}
@@ -0,0 +1,50 @@
1+//go:build darwin
2+
3+package rawterm
4+
5+import (
6+ "bytes"
7+ "fmt"
8+ "os"
9+ "syscall"
10+ "unsafe"
11+)
12+
13+const (
14+ // TIOCGETA and TIOCSETA are Darwin's termios ioctl requests. They differ
15+ // from the Linux TCGETS and TCSETS requests despite operating on the same
16+ // syscall.Termios type.
17+ reqGetTermios = syscall.TIOCGETA
18+ reqSetTermios = syscall.TIOCSETA
19+)
20+
21+// PreparePty grants and unlocks the slave associated with masterFD, obtains
22+// its name from the kernel, and opens it. Darwin requires this exact sequence:
23+// TIOCPTYGRANT before TIOCPTYUNLK before TIOCPTYGNAME.
24+func PreparePty(masterFD int) (*os.File, error) {
25+ if err := ioctl(masterFD, syscall.TIOCPTYGRANT, nil); err != nil {
26+ return nil, fmt.Errorf("rawterm: grant pty: %w", err)
27+ }
28+ if err := ioctl(masterFD, syscall.TIOCPTYUNLK, nil); err != nil {
29+ return nil, fmt.Errorf("rawterm: unlock pty: %w", err)
30+ }
31+
32+ var slaveName [128]byte
33+ if err := ioctl(masterFD, syscall.TIOCPTYGNAME, unsafe.Pointer(&slaveName[0])); err != nil {
34+ return nil, fmt.Errorf("rawterm: get pty name: %w", err)
35+ }
36+ nul := bytes.IndexByte(slaveName[:], 0)
37+ if nul < 0 {
38+ return nil, fmt.Errorf("rawterm: get pty name: unterminated slave name")
39+ }
40+ if nul == 0 {
41+ return nil, fmt.Errorf("rawterm: get pty name: empty slave name")
42+ }
43+
44+ name := string(slaveName[:nul])
45+ slave, err := os.OpenFile(name, os.O_RDWR|syscall.O_NOCTTY, 0)
46+ if err != nil {
47+ return nil, fmt.Errorf("rawterm: open %s: %w", name, err)
48+ }
49+ return slave, nil
50+}
A pkg/rawterm/term_darwin_test.go +107 -0
@@ -0,0 +1,107 @@
1+//go:build darwin
2+
3+package rawterm
4+
5+import (
6+ "os"
7+ "syscall"
8+ "testing"
9+ "unsafe"
10+)
11+
12+// openPTYPair opens a real Darwin PTY master/slave pair through the package
13+// under test, exercising the grant, unlock, name, and open protocol.
14+func openPTYPair(t *testing.T) (master, slave *os.File) {
15+ t.Helper()
16+
17+ masterFD, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
18+ if err != nil {
19+ t.Skipf("open /dev/ptmx: %v", err)
20+ }
21+ master = os.NewFile(uintptr(masterFD), "pty-master")
22+
23+ slave, err = PreparePty(masterFD)
24+ if err != nil {
25+ _ = master.Close()
26+ t.Fatalf("PreparePty: %v", err)
27+ }
28+
29+ return master, slave
30+}
31+
32+func TestPreparePtyOpensSlave(t *testing.T) {
33+ master, slave := openPTYPair(t)
34+ defer func() { _ = master.Close() }()
35+ defer func() { _ = slave.Close() }()
36+
37+ if !IsTerminal(int(slave.Fd())) {
38+ t.Error("PreparePty slave is not a terminal")
39+ }
40+}
41+
42+func TestMakeRawRestore(t *testing.T) {
43+ master, slave := openPTYPair(t)
44+ defer func() { _ = master.Close() }()
45+ defer func() { _ = slave.Close() }()
46+
47+ fd := int(slave.Fd())
48+ old, err := MakeRaw(fd)
49+ if err != nil {
50+ t.Fatalf("MakeRaw: %v", err)
51+ }
52+ if old == nil {
53+ t.Fatal("MakeRaw returned nil state")
54+ }
55+
56+ var raw syscall.Termios
57+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&raw)); err != nil {
58+ t.Fatalf("get termios after MakeRaw: %v", err)
59+ }
60+ if raw.Lflag&(syscall.ECHO|syscall.ICANON) != 0 {
61+ t.Errorf("Lflag after MakeRaw = %#x, ECHO and ICANON must be clear", raw.Lflag)
62+ }
63+ if raw.Cc[syscall.VMIN] != 1 || raw.Cc[syscall.VTIME] != 0 {
64+ t.Errorf("Cc[VMIN, VTIME] = [%d, %d], want [1, 0]", raw.Cc[syscall.VMIN], raw.Cc[syscall.VTIME])
65+ }
66+
67+ if err := Restore(fd, old); err != nil {
68+ t.Fatalf("Restore: %v", err)
69+ }
70+ var restored syscall.Termios
71+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&restored)); err != nil {
72+ t.Fatalf("get termios after Restore: %v", err)
73+ }
74+ if restored != old.termios {
75+ t.Error("Restore did not restore the original termios settings")
76+ }
77+}
78+
79+func TestSetWinsizeGetSizeRoundTrip(t *testing.T) {
80+ master, slave := openPTYPair(t)
81+ defer func() { _ = master.Close() }()
82+ defer func() { _ = slave.Close() }()
83+
84+ tests := []struct {
85+ name string
86+ cols uint16
87+ rows uint16
88+ }{
89+ {name: "typical", cols: 80, rows: 24},
90+ {name: "wide", cols: 220, rows: 60},
91+ {name: "minimal", cols: 1, rows: 1},
92+ }
93+ for _, tt := range tests {
94+ t.Run(tt.name, func(t *testing.T) {
95+ if err := SetWinsize(int(slave.Fd()), tt.cols, tt.rows); err != nil {
96+ t.Fatalf("SetWinsize: %v", err)
97+ }
98+ cols, rows, err := GetSize(int(slave.Fd()))
99+ if err != nil {
100+ t.Fatalf("GetSize: %v", err)
101+ }
102+ if cols != int(tt.cols) || rows != int(tt.rows) {
103+ t.Errorf("GetSize = (%d, %d), want (%d, %d)", cols, rows, tt.cols, tt.rows)
104+ }
105+ })
106+ }
107+}
@@ -0,0 +1,107 @@
1+//go:build darwin
2+
3+package rawterm
4+
5+import (
6+ "os"
7+ "syscall"
8+ "testing"
9+ "unsafe"
10+)
11+
12+// openPTYPair opens a real Darwin PTY master/slave pair through the package
13+// under test, exercising the grant, unlock, name, and open protocol.
14+func openPTYPair(t *testing.T) (master, slave *os.File) {
15+ t.Helper()
16+
17+ masterFD, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
18+ if err != nil {
19+ t.Skipf("open /dev/ptmx: %v", err)
20+ }
21+ master = os.NewFile(uintptr(masterFD), "pty-master")
22+
23+ slave, err = PreparePty(masterFD)
24+ if err != nil {
25+ _ = master.Close()
26+ t.Fatalf("PreparePty: %v", err)
27+ }
28+
29+ return master, slave
30+}
31+
32+func TestPreparePtyOpensSlave(t *testing.T) {
33+ master, slave := openPTYPair(t)
34+ defer func() { _ = master.Close() }()
35+ defer func() { _ = slave.Close() }()
36+
37+ if !IsTerminal(int(slave.Fd())) {
38+ t.Error("PreparePty slave is not a terminal")
39+ }
40+}
41+
42+func TestMakeRawRestore(t *testing.T) {
43+ master, slave := openPTYPair(t)
44+ defer func() { _ = master.Close() }()
45+ defer func() { _ = slave.Close() }()
46+
47+ fd := int(slave.Fd())
48+ old, err := MakeRaw(fd)
49+ if err != nil {
50+ t.Fatalf("MakeRaw: %v", err)
51+ }
52+ if old == nil {
53+ t.Fatal("MakeRaw returned nil state")
54+ }
55+
56+ var raw syscall.Termios
57+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&raw)); err != nil {
58+ t.Fatalf("get termios after MakeRaw: %v", err)
59+ }
60+ if raw.Lflag&(syscall.ECHO|syscall.ICANON) != 0 {
61+ t.Errorf("Lflag after MakeRaw = %#x, ECHO and ICANON must be clear", raw.Lflag)
62+ }
63+ if raw.Cc[syscall.VMIN] != 1 || raw.Cc[syscall.VTIME] != 0 {
64+ t.Errorf("Cc[VMIN, VTIME] = [%d, %d], want [1, 0]", raw.Cc[syscall.VMIN], raw.Cc[syscall.VTIME])
65+ }
66+
67+ if err := Restore(fd, old); err != nil {
68+ t.Fatalf("Restore: %v", err)
69+ }
70+ var restored syscall.Termios
71+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&restored)); err != nil {
72+ t.Fatalf("get termios after Restore: %v", err)
73+ }
74+ if restored != old.termios {
75+ t.Error("Restore did not restore the original termios settings")
76+ }
77+}
78+
79+func TestSetWinsizeGetSizeRoundTrip(t *testing.T) {
80+ master, slave := openPTYPair(t)
81+ defer func() { _ = master.Close() }()
82+ defer func() { _ = slave.Close() }()
83+
84+ tests := []struct {
85+ name string
86+ cols uint16
87+ rows uint16
88+ }{
89+ {name: "typical", cols: 80, rows: 24},
90+ {name: "wide", cols: 220, rows: 60},
91+ {name: "minimal", cols: 1, rows: 1},
92+ }
93+ for _, tt := range tests {
94+ t.Run(tt.name, func(t *testing.T) {
95+ if err := SetWinsize(int(slave.Fd()), tt.cols, tt.rows); err != nil {
96+ t.Fatalf("SetWinsize: %v", err)
97+ }
98+ cols, rows, err := GetSize(int(slave.Fd()))
99+ if err != nil {
100+ t.Fatalf("GetSize: %v", err)
101+ }
102+ if cols != int(tt.cols) || rows != int(tt.rows) {
103+ t.Errorf("GetSize = (%d, %d), want (%d, %d)", cols, rows, tt.cols, tt.rows)
104+ }
105+ })
106+ }
107+}
A pkg/rawterm/term_linux.go +52 -0
@@ -0,0 +1,52 @@
1+//go:build linux
2+
3+package rawterm
4+
5+import (
6+ "fmt"
7+ "os"
8+ "syscall"
9+ "unsafe"
10+)
11+
12+const (
13+ reqGetTermios = syscall.TCGETS
14+ reqSetTermios = syscall.TCSETS
15+)
16+
17+// PtsNumber returns the pty slave number for the master referenced by
18+// masterFD (TIOCGPTN).
19+func PtsNumber(masterFD int) (int, error) {
20+ var n int32
21+ if err := ioctl(masterFD, syscall.TIOCGPTN, unsafe.Pointer(&n)); err != nil {
22+ return 0, fmt.Errorf("rawterm: get pts number: %w", err)
23+ }
24+ return int(n), nil
25+}
26+
27+// UnlockPt unlocks the pty slave associated with masterFD so it can be opened
28+// (TIOCSPTLCK).
29+func UnlockPt(masterFD int) error {
30+ var unlock int32
31+ if err := ioctl(masterFD, syscall.TIOCSPTLCK, unsafe.Pointer(&unlock)); err != nil {
32+ return fmt.Errorf("rawterm: unlock pty: %w", err)
33+ }
34+ return nil
35+}
36+
37+// PreparePty unlocks the slave associated with masterFD and opens it.
38+func PreparePty(masterFD int) (*os.File, error) {
39+ n, err := PtsNumber(masterFD)
40+ if err != nil {
41+ return nil, err
42+ }
43+ if err := UnlockPt(masterFD); err != nil {
44+ return nil, err
45+ }
46+ slaveName := fmt.Sprintf("/dev/pts/%d", n)
47+ slave, err := os.OpenFile(slaveName, os.O_RDWR|syscall.O_NOCTTY, 0)
48+ if err != nil {
49+ return nil, fmt.Errorf("rawterm: open %s: %w", slaveName, err)
50+ }
51+ return slave, nil
52+}
@@ -0,0 +1,52 @@
1+//go:build linux
2+
3+package rawterm
4+
5+import (
6+ "fmt"
7+ "os"
8+ "syscall"
9+ "unsafe"
10+)
11+
12+const (
13+ reqGetTermios = syscall.TCGETS
14+ reqSetTermios = syscall.TCSETS
15+)
16+
17+// PtsNumber returns the pty slave number for the master referenced by
18+// masterFD (TIOCGPTN).
19+func PtsNumber(masterFD int) (int, error) {
20+ var n int32
21+ if err := ioctl(masterFD, syscall.TIOCGPTN, unsafe.Pointer(&n)); err != nil {
22+ return 0, fmt.Errorf("rawterm: get pts number: %w", err)
23+ }
24+ return int(n), nil
25+}
26+
27+// UnlockPt unlocks the pty slave associated with masterFD so it can be opened
28+// (TIOCSPTLCK).
29+func UnlockPt(masterFD int) error {
30+ var unlock int32
31+ if err := ioctl(masterFD, syscall.TIOCSPTLCK, unsafe.Pointer(&unlock)); err != nil {
32+ return fmt.Errorf("rawterm: unlock pty: %w", err)
33+ }
34+ return nil
35+}
36+
37+// PreparePty unlocks the slave associated with masterFD and opens it.
38+func PreparePty(masterFD int) (*os.File, error) {
39+ n, err := PtsNumber(masterFD)
40+ if err != nil {
41+ return nil, err
42+ }
43+ if err := UnlockPt(masterFD); err != nil {
44+ return nil, err
45+ }
46+ slaveName := fmt.Sprintf("/dev/pts/%d", n)
47+ slave, err := os.OpenFile(slaveName, os.O_RDWR|syscall.O_NOCTTY, 0)
48+ if err != nil {
49+ return nil, fmt.Errorf("rawterm: open %s: %w", slaveName, err)
50+ }
51+ return slave, nil
52+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close