Merge pull request #101 from bnema/feat/darwin-arm64
feat: darwin/arm64 support
41 files · +1410 -153
M .github/workflows/ci.yml +13 -2
| @@ -14,9 +14,13 @@ permissions: | |||
| 14 | 14 | ||
| 15 | 15 | jobs: | |
| 16 | 16 | test: | |
| 17 | - | name: Test | |
| 18 | - | runs-on: ubuntu-latest | |
| 17 | + | name: Test (${{ matrix.os }}) | |
| 18 | + | runs-on: ${{ matrix.os }} | |
| 19 | 19 | timeout-minutes: 15 | |
| 20 | + | strategy: | |
| 21 | + | fail-fast: false | |
| 22 | + | matrix: | |
| 23 | + | os: [ubuntu-latest, macos-14] | |
| 20 | 24 | steps: | |
| 21 | 25 | - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| 22 | 26 | with: | |
| @@ -32,3 +36,10 @@ jobs: | |||
| 32 | 36 | run: make lint | |
| 33 | 37 | - name: Test | |
| 34 | 38 | 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: Test | 17 | + | name: Test (${{ matrix.os }}) |
| 18 | - | runs-on: ubuntu-latest | 18 | + | runs-on: ${{ matrix.os }} |
| 19 | timeout-minutes: 15 | 19 | 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.0 | 25 | - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | ||
| 22 | with: | 26 | with: | ||
| @@ -32,3 +36,10 @@ jobs: | |||||
| 32 | run: make lint | 36 | run: make lint | ||
| 33 | - name: Test | 37 | - name: Test | ||
| 34 | run: make test | 38 | 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 | + | ||
| 1 | 3 | package main | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 1 | package main | 3 | 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 | + | ||
| 1 | 3 | package main | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 1 | package main | 3 | 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 | + | ||
| 1 | 3 | package main | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -15,7 +17,7 @@ import ( | |||
| 15 | 17 | "syscall" | |
| 16 | 18 | "time" | |
| 17 | 19 | ||
| 18 | - | "github.com/bnema/vev/pkg/linuxterm" | |
| 20 | + | "github.com/bnema/vev/pkg/rawterm" | |
| 19 | 21 | "github.com/bnema/vev/pkg/safedir" | |
| 20 | 22 | ) | |
| 21 | 23 | ||
| @@ -731,11 +733,11 @@ func openPTY() (*os.File, *os.File, error) { | |||
| 731 | 733 | return nil, nil, err | |
| 732 | 734 | } | |
| 733 | 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 | 737 | if err != nil { | |
| 736 | 738 | return closeMaster(err) | |
| 737 | 739 | } | |
| 738 | - | if err := linuxterm.UnlockPt(masterFD); err != nil { | |
| 740 | + | if err := rawterm.UnlockPt(masterFD); err != nil { | |
| 739 | 741 | return closeMaster(err) | |
| 740 | 742 | } | |
| 741 | 743 | 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 main | 3 | 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, err | 733 | 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 | + | ||
| 1 | 3 | package main | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 1 | package main | 3 | 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 | + | ||
| 1 | 3 | package main | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 1 | package main | 3 | 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 | + | ||
| 1 | 3 | package main | |
| 2 | 4 | ||
| 3 | 5 | import "time" | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 1 | package main | 3 | 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 | + | ||
| 1 | 3 | package main | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 1 | package main | 3 | 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 | + | ||
| 1 | 3 | package main | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 1 | package main | 3 | 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 | + | ||
| 1 | 3 | package main | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 1 | package main | 3 | 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 | + | ||
| 1 | 3 | package main | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 1 | package main | 3 | 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 | + | ||
| 1 | 3 | package main | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 1 | package main | 3 | 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 | + | ||
| 1 | 3 | package main | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 1 | package main | 3 | 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 | + | ||
| 1 | 3 | package main | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 1 | package main | 3 | 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 | + | ||
| 1 | 3 | // Package clipboard implements ports.ClipboardReader over Wayland's | |
| 2 | 4 | // wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote | |
| 3 | 5 | // attach forward a locally clipped image to the daemon (see | |
| @@ -50,7 +52,7 @@ type WlPaste struct { | |||
| 50 | 52 | ||
| 51 | 53 | // New returns a WlPaste clipboard reader that shells out to the real | |
| 52 | 54 | // wl-paste binary. | |
| 53 | - | func New() *WlPaste { | |
| 55 | + | func New() ports.ClipboardReader { | |
| 54 | 56 | return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout} | |
| 55 | 57 | } | |
| 56 | 58 | ||
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 1 | // Package clipboard implements ports.ClipboardReader over Wayland's | 3 | // Package clipboard implements ports.ClipboardReader over Wayland's | ||
| 2 | // wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote | 4 | // 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 (see | 5 | // 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 real | 53 | // 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 | + | ||
| 1 | 3 | package clipboard | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 1 | package clipboard | 3 | package clipboard | ||
| 2 | 4 | ||||
| 3 | import ( | 5 | import ( | ||
M internal/adapters/ipc/dial_test.go +2 -3
| @@ -3,7 +3,6 @@ package ipc | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | - | "path/filepath" | |
| 7 | 6 | "testing" | |
| 8 | 7 | "time" | |
| 9 | 8 | ||
| @@ -43,7 +42,7 @@ func TestDialContextCanceledOrExpired(t *testing.T) { | |||
| 43 | 42 | ctx, cancel := tt.ctx() | |
| 44 | 43 | defer cancel() | |
| 45 | 44 | ||
| 46 | - | transport, err := DialContext(ctx, t.TempDir()) | |
| 45 | + | transport, err := DialContext(ctx, shortSocketDir(t)) | |
| 47 | 46 | if transport != nil { | |
| 48 | 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 | 56 | func TestDialContextPreservesOptions(t *testing.T) { | |
| 58 | - | dir := filepath.Join(t.TempDir(), "vev") | |
| 57 | + | dir := shortSocketDir(t, "vev") | |
| 59 | 58 | listener, err := Listen(dir) | |
| 60 | 59 | if err != nil { | |
| 61 | 60 | 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 ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | func TestListenAcceptRoundTrip(t *testing.T) { | |
| 16 | - | dir := filepath.Join(t.TempDir(), "vev") | |
| 16 | + | dir := shortSocketDir(t, "vev") | |
| 17 | 17 | ||
| 18 | 18 | ln, err := Listen(dir) | |
| 19 | 19 | if err != nil { | |
| @@ -65,8 +65,7 @@ func TestListenAcceptRoundTrip(t *testing.T) { | |||
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | 67 | func TestListenMkdirsSocketDir(t *testing.T) { | |
| 68 | - | base := t.TempDir() | |
| 69 | - | dir := filepath.Join(base, "nested", "socketdir") | |
| 68 | + | dir := shortSocketDir(t, "nested", "socketdir") | |
| 70 | 69 | ||
| 71 | 70 | ln, err := Listen(dir) | |
| 72 | 71 | if err != nil { | |
| @@ -87,7 +86,7 @@ func TestListenMkdirsSocketDir(t *testing.T) { | |||
| 87 | 86 | } | |
| 88 | 87 | ||
| 89 | 88 | func TestListenRejectsHostileSocketDir(t *testing.T) { | |
| 90 | - | dir := filepath.Join(t.TempDir(), "socketdir") | |
| 89 | + | dir := shortSocketDir(t, "socketdir") | |
| 91 | 90 | if err := os.MkdirAll(dir, 0o777); err != nil { | |
| 92 | 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 | 107 | func TestListenStaleSocketCleanedUp(t *testing.T) { | |
| 109 | - | dir := filepath.Join(t.TempDir(), "vev") | |
| 108 | + | dir := shortSocketDir(t, "vev") | |
| 110 | 109 | if err := os.Mkdir(dir, 0o700); err != nil { | |
| 111 | 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 | 141 | func TestListenLiveDaemonRejected(t *testing.T) { | |
| 143 | - | dir := filepath.Join(t.TempDir(), "vev") | |
| 142 | + | dir := shortSocketDir(t, "vev") | |
| 144 | 143 | ||
| 145 | 144 | first, err := Listen(dir) | |
| 146 | 145 | 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 | |
| 2 | 2 | ||
| 3 | 3 | package pty | |
| 4 | 4 | ||
| 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. | |
| 9 | 8 | ||
| 10 | 9 | import ( | |
| 11 | 10 | "context" | |
| @@ -21,17 +20,17 @@ import ( | |||
| 21 | 20 | "github.com/bnema/vev/internal/domain" | |
| 22 | 21 | "github.com/bnema/vev/internal/platform" | |
| 23 | 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 | 26 | // killGracePeriod is how long Close waits for a signalled child to exit before | |
| 28 | 27 | // escalating from SIGHUP to SIGKILL. | |
| 29 | 28 | const killGracePeriod = 2 * time.Second | |
| 30 | 29 | ||
| 31 | - | // Factory implements ports.PTYFactory for Linux. | |
| 30 | + | // Factory implements ports.PTYFactory on supported Unix platforms. | |
| 32 | 31 | type Factory struct{} | |
| 33 | 32 | ||
| 34 | - | // NewFactory returns a Linux PTY factory. | |
| 33 | + | // NewFactory returns a Unix PTY factory. | |
| 35 | 34 | func NewFactory() *Factory { return &Factory{} } | |
| 36 | 35 | ||
| 37 | 36 | // 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 | 59 | 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) | |
| 73 | 61 | } | |
| 74 | 62 | // The child dups the slave into its std fds and holds its own copies, so the | |
| 75 | 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 | 106 | ok = true | |
| 119 | 107 | ||
| 120 | - | return &linuxPTY{ | |
| 108 | + | return &unixPTY{ | |
| 121 | 109 | master: os.NewFile(uintptr(masterFd), "pty-master"), | |
| 122 | 110 | cmd: cmd, | |
| 123 | 111 | }, nil | |
| 124 | 112 | } | |
| 125 | 113 | ||
| 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 { | |
| 128 | 116 | master *os.File | |
| 129 | 117 | cmd *exec.Cmd | |
| 130 | 118 | ||
| @@ -132,14 +120,14 @@ type linuxPTY struct { | |||
| 132 | 120 | closeErr error | |
| 133 | 121 | } | |
| 134 | 122 | ||
| 135 | - | var _ ports.PTY = (*linuxPTY)(nil) | |
| 123 | + | var _ ports.PTY = (*unixPTY)(nil) | |
| 136 | 124 | ||
| 137 | 125 | // Read returns child output read from the master. | |
| 138 | 126 | // | |
| 139 | 127 | // When the child (the last process holding the slave open) exits, the kernel | |
| 140 | 128 | // makes reads of the master fail with EIO. Callers treat "child gone" as a | |
| 141 | 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 | 131 | n, err := p.master.Read(b) | |
| 144 | 132 | if err != nil && errors.Is(err, syscall.EIO) { | |
| 145 | 133 | return n, io.EOF | |
| @@ -148,7 +136,7 @@ func (p *linuxPTY) Read(b []byte) (int, error) { | |||
| 148 | 136 | } | |
| 149 | 137 | ||
| 150 | 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 | 140 | return p.master.Write(b) | |
| 153 | 141 | } | |
| 154 | 142 | ||
| @@ -160,7 +148,7 @@ func (p *linuxPTY) Write(b []byte) (int, error) { | |||
| 160 | 148 | // every subsequent Read into a thread-parking syscall and breaking the | |
| 161 | 149 | // Close-unblocks-Read behavior. Control also pins the fd for the duration and | |
| 162 | 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 | 152 | rc, err := p.master.SyscallConn() | |
| 165 | 153 | if err != nil { | |
| 166 | 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 | 165 | // Pid reports the child process id. | |
| 178 | - | func (p *linuxPTY) Pid() int { | |
| 166 | + | func (p *unixPTY) Pid() int { | |
| 179 | 167 | return p.cmd.Process.Pid | |
| 180 | 168 | } | |
| 181 | 169 | ||
| 182 | 170 | // ForegroundPgid reports the process group currently in the foreground for the | |
| 183 | 171 | // pty's controlling terminal. | |
| 184 | - | func (p *linuxPTY) ForegroundPgid() (int, error) { | |
| 172 | + | func (p *unixPTY) ForegroundPgid() (int, error) { | |
| 185 | 173 | rc, err := p.master.SyscallConn() | |
| 186 | 174 | if err != nil { | |
| 187 | 175 | return 0, fmt.Errorf("pty: foreground pgid: %w", err) | |
| @@ -189,7 +177,7 @@ func (p *linuxPTY) ForegroundPgid() (int, error) { | |||
| 189 | 177 | var pgid int | |
| 190 | 178 | var ioctlErr error | |
| 191 | 179 | if err := rc.Control(func(fd uintptr) { | |
| 192 | - | pgid, ioctlErr = linuxterm.ForegroundProcessGroup(int(fd)) | |
| 180 | + | pgid, ioctlErr = rawterm.ForegroundProcessGroup(int(fd)) | |
| 193 | 181 | }); err != nil { | |
| 194 | 182 | return 0, fmt.Errorf("pty: foreground pgid: %w", err) | |
| 195 | 183 | } | |
| @@ -204,7 +192,7 @@ func (p *linuxPTY) ForegroundPgid() (int, error) { | |||
| 204 | 192 | // escalates to SIGKILL. The child is reaped (via cmd.Wait) to avoid a zombie, | |
| 205 | 193 | // and the master fd is closed last so any blocked Read unblocks. Close is | |
| 206 | 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 | 196 | p.closeOnce.Do(func() { | |
| 209 | 197 | pid := p.cmd.Process.Pid | |
| 210 | 198 | ||
| @@ -240,5 +228,5 @@ func signalProcessGroup(pid int, signal syscall.Signal) error { | |||
| 240 | 228 | ||
| 241 | 229 | // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ. | |
| 242 | 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 | } | |
| @@ -1,11 +1,10 @@ | |||||
| 1 | - | //go:build linux | 1 | + | //go:build linux || darwin |
| 2 | 2 | ||||
| 3 | package pty | 3 | package pty | ||
| 4 | 4 | ||||
| 5 | - | // The Linux adapter drives /dev/ptmx directly (open master, TIOCGPTN to learn | 5 | + | // The Unix adapter drives /dev/ptmx directly and delegates slave preparation |
| 6 | - | // the slave index, TIOCSPTLCK to unlock, then open /dev/pts/<N>) rather than | 6 | + | // 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 at | 7 | + | // 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 before | 26 | // 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.Second | 28 | 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-terminal | 36 | // 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 the | 62 | // 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 = true | 106 | 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 | }, nil | 111 | }, 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.File | 116 | master *os.File | ||
| 129 | cmd *exec.Cmd | 117 | cmd *exec.Cmd | ||
| 130 | 118 | ||||
| @@ -132,14 +120,14 @@ type linuxPTY struct { | |||||
| 132 | closeErr error | 120 | 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 kernel | 127 | // 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 a | 128 | // 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.EOF | 133 | 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 the | 148 | // every subsequent Read into a thread-parking syscall and breaking the | ||
| 161 | // Close-unblocks-Read behavior. Control also pins the fd for the duration and | 149 | // 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.Pid | 167 | return p.cmd.Process.Pid | ||
| 180 | } | 168 | } | ||
| 181 | 169 | ||||
| 182 | // ForegroundPgid reports the process group currently in the foreground for the | 170 | // 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 int | 177 | var pgid int | ||
| 190 | var ioctlErr error | 178 | 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 is | 193 | // 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.Pid | 197 | 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 @@ | |||
| 3 | 3 | package term | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | - | "fmt" | |
| 7 | 6 | "io" | |
| 8 | 7 | "os" | |
| 9 | 8 | "syscall" | |
| 10 | 9 | "testing" | |
| 11 | 10 | "time" | |
| 12 | 11 | ||
| 13 | - | "github.com/bnema/vev/pkg/linuxterm" | |
| 12 | + | "github.com/bnema/vev/pkg/rawterm" | |
| 14 | 13 | ) | |
| 15 | 14 | ||
| 16 | 15 | // 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 | 23 | t.Fatalf("open /dev/ptmx: %v", err) | |
| 25 | 24 | } | |
| 26 | 25 | ||
| 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())) | |
| 40 | 27 | if err != nil { | |
| 41 | 28 | _ = m.Close() | |
| 42 | - | t.Fatalf("open slave %s: %v", slavePath, err) | |
| 29 | + | t.Fatalf("prepare pty: %v", err) | |
| 43 | 30 | } | |
| 44 | 31 | ||
| 45 | 32 | return m, s | |
| @@ -54,7 +41,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) { | |||
| 54 | 41 | defer func() { _ = master.Close() }() | |
| 55 | 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 | 45 | t.Fatalf("set winsize: %v", err) | |
| 59 | 46 | } | |
| 60 | 47 | ||
| @@ -3,14 +3,13 @@ | |||||
| 3 | package term | 3 | 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 via | 15 | // 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, s | 32 | 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 ( | |||
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | 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 | 17 | // Compile-time check that Terminal satisfies ports.Terminal. | |
| @@ -49,10 +49,10 @@ type Terminal struct { | |||
| 49 | 49 | bw *batchWriter | |
| 50 | 50 | ||
| 51 | 51 | 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 | |
| 56 | 56 | ||
| 57 | 57 | // Resize watcher state, also guarded by mu so watcher start | |
| 58 | 58 | // (ResizeEvents) and watcher stop (restore) are strictly ordered: | |
| @@ -95,9 +95,9 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||
| 95 | 95 | return t.restoreFn, nil | |
| 96 | 96 | } | |
| 97 | 97 | ||
| 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) | |
| 101 | 101 | if err != nil { | |
| 102 | 102 | return nil, fmt.Errorf("term: make raw: %w", err) | |
| 103 | 103 | } | |
| @@ -170,14 +170,14 @@ func (t *Terminal) restoreRawLocked() error { | |||
| 170 | 170 | if t.orig == nil { | |
| 171 | 171 | return nil | |
| 172 | 172 | } | |
| 173 | - | err := linuxterm.Restore(t.fd, t.orig) | |
| 173 | + | err := rawterm.Restore(t.fd, t.orig) | |
| 174 | 174 | t.orig = nil | |
| 175 | 175 | return err | |
| 176 | 176 | } | |
| 177 | 177 | ||
| 178 | 178 | // Size returns the current terminal dimensions. | |
| 179 | 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 | 181 | if err != nil { | |
| 182 | 182 | return domain.Size{}, fmt.Errorf("term: get size: %w", err) | |
| 183 | 183 | } | |
| @@ -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 *batchWriter | 49 | bw *batchWriter | ||
| 50 | 50 | ||||
| 51 | mu sync.Mutex | 51 | 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 restore | 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 | 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 | 55 | + | restoreFn func() error // the single idempotent restore closure for the current session |
| 56 | 56 | ||||
| 57 | // Resize watcher state, also guarded by mu so watcher start | 57 | // 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, nil | 95 | return t.restoreFn, nil | ||
| 96 | } | 96 | } | ||
| 97 | 97 | ||||
| 98 | - | var old *linuxterm.State | 98 | + | 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 nil | 171 | return nil | ||
| 172 | } | 172 | } | ||
| 173 | - | err := linuxterm.Restore(t.fd, t.orig) | 173 | + | err := rawterm.Restore(t.fd, t.orig) |
| 174 | t.orig = nil | 174 | t.orig = nil | ||
| 175 | return err | 175 | 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) { | |||
| 157 | 157 | // and never writes to the client's terminal. It logs via slog to the shared | |
| 158 | 158 | // log file instead. | |
| 159 | 159 | func realSpawn() error { | |
| 160 | + | exePath, err := selfExePath() | |
| 161 | + | if err != nil { | |
| 162 | + | return fmt.Errorf("resolving executable path: %w", err) | |
| 163 | + | } | |
| 164 | + | ||
| 160 | 165 | devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | |
| 161 | 166 | if err != nil { | |
| 162 | 167 | return fmt.Errorf("opening %s: %w", os.DevNull, err) | |
| 163 | 168 | } | |
| 164 | 169 | defer func() { _ = devNull.Close() }() | |
| 165 | 170 | ||
| 166 | - | cmd := exec.Command("/proc/self/exe", "--daemon") | |
| 171 | + | cmd := exec.Command(exePath, "--daemon") | |
| 167 | 172 | cmd.Dir = platform.DirOrHome("") | |
| 168 | 173 | cmd.Stdin = devNull | |
| 169 | 174 | 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 shared | 157 | // 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 = devNull | 173 | cmd.Stdin = devNull | ||
| 169 | cmd.Stdout = devNull | 174 | 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 ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | 7 | "path/filepath" | |
| 8 | - | "sort" | |
| 9 | 8 | "strconv" | |
| 10 | 9 | "strings" | |
| 11 | 10 | "sync" | |
| 12 | 11 | "time" | |
| 13 | 12 | ) | |
| 14 | 13 | ||
| 15 | - | const processRecordCacheTTL = 250 * time.Millisecond | |
| 16 | - | ||
| 17 | 14 | // ProcessInspector implements ports.ProcessInspector using Linux /proc. | |
| 18 | 15 | type ProcessInspector struct { | |
| 19 | 16 | root string | |
| @@ -87,8 +84,6 @@ func processArgv(path string, pid int) ([]string, error) { | |||
| 87 | 84 | return argv, nil | |
| 88 | 85 | } | |
| 89 | 86 | ||
| 90 | - | type processRecord struct{ pid, pgrp int } | |
| 91 | - | ||
| 92 | 87 | // ProcessGroupArgv returns argv for a restorable foreground process in pgid. | |
| 93 | 88 | // A bare pane shell (the only process in its foreground group) returns nil argv. | |
| 94 | 89 | func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) { | |
| @@ -103,23 +98,6 @@ func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) { | |||
| 103 | 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 | 101 | func readProcRecords(root string) ([]processRecord, error) { | |
| 124 | 102 | ents, err := os.ReadDir(root) | |
| 125 | 103 | 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 string | 16 | root string | ||
| @@ -87,8 +84,6 @@ func processArgv(path string, pid int) ([]string, error) { | |||||
| 87 | return argv, nil | 84 | 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 | + | ||
| 1 | 3 | package platform | |
| 2 | 4 | ||
| 3 | 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 | 202 | func TestProcessArgvFileParsing(t *testing.T) { | |
| 144 | 203 | tests := []struct { | |
| 145 | 204 | name string | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 1 | package platform | 3 | 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 string | 204 | name string | ||
M internal/usecase/daemon/picker_test.go +9 -0
| @@ -877,6 +877,15 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) { | |||
| 877 | 877 | require.Contains(t, opens[1], "TERM=xterm-direct") | |
| 878 | 878 | require.Contains(t, opens[1], "COLORTERM=truecolor") | |
| 879 | 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 | 889 | _ = d.killSession(got, ports.ReasonSessionKilled, false) | |
| 881 | 890 | release1() | |
| 882 | 891 | 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 | |
| 7 | 7 | ||
| 8 | 8 | import ( | |
| 9 | 9 | "fmt" | |
| @@ -40,7 +40,7 @@ func ioctl(fd int, req uintptr, arg unsafe.Pointer) error { | |||
| 40 | 40 | // IsTerminal reports whether fd refers to a terminal device. | |
| 41 | 41 | func IsTerminal(fd int) bool { | |
| 42 | 42 | var t syscall.Termios | |
| 43 | - | return ioctl(fd, syscall.TCGETS, unsafe.Pointer(&t)) == nil | |
| 43 | + | return ioctl(fd, reqGetTermios, unsafe.Pointer(&t)) == nil | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | 46 | // MakeRaw puts the terminal referenced by fd into raw mode and returns its | |
| @@ -48,8 +48,8 @@ func IsTerminal(fd int) bool { | |||
| 48 | 48 | // changes applied match golang.org/x/term's MakeRaw semantics exactly. | |
| 49 | 49 | func MakeRaw(fd int) (*State, error) { | |
| 50 | 50 | var oldState State | |
| 51 | - | if err := ioctl(fd, syscall.TCGETS, unsafe.Pointer(&oldState.termios)); err != nil { | |
| 52 | - | return nil, fmt.Errorf("linuxterm: get termios: %w", err) | |
| 51 | + | if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&oldState.termios)); err != nil { | |
| 52 | + | return nil, fmt.Errorf("rawterm: get termios: %w", err) | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 55 | raw := oldState.termios | |
| @@ -61,8 +61,8 @@ func MakeRaw(fd int) (*State, error) { | |||
| 61 | 61 | raw.Cc[syscall.VMIN] = 1 | |
| 62 | 62 | raw.Cc[syscall.VTIME] = 0 | |
| 63 | 63 | ||
| 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) | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | 68 | return &oldState, nil | |
| @@ -71,8 +71,8 @@ func MakeRaw(fd int) (*State, error) { | |||
| 71 | 71 | // Restore applies a previously captured State back to the terminal | |
| 72 | 72 | // referenced by fd. | |
| 73 | 73 | func Restore(fd int, s *State) error { | |
| 74 | - | if err := ioctl(fd, syscall.TCSETS, unsafe.Pointer(&s.termios)); err != nil { | |
| 75 | - | return fmt.Errorf("linuxterm: set termios: %w", err) | |
| 74 | + | if err := ioctl(fd, reqSetTermios, unsafe.Pointer(&s.termios)); err != nil { | |
| 75 | + | return fmt.Errorf("rawterm: set termios: %w", err) | |
| 76 | 76 | } | |
| 77 | 77 | return nil | |
| 78 | 78 | } | |
| @@ -81,7 +81,7 @@ func Restore(fd int, s *State) error { | |||
| 81 | 81 | func GetSize(fd int) (cols, rows int, err error) { | |
| 82 | 82 | var ws winsize | |
| 83 | 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 | 86 | return int(ws.Col), int(ws.Row), nil | |
| 87 | 87 | } | |
| @@ -90,27 +90,7 @@ func GetSize(fd int) (cols, rows int, err error) { | |||
| 90 | 90 | func SetWinsize(fd int, cols, rows uint16) error { | |
| 91 | 91 | ws := winsize{Row: rows, Col: cols} | |
| 92 | 92 | if err := ioctl(fd, syscall.TIOCSWINSZ, unsafe.Pointer(&ws)); err != nil { | |
| 93 | - | return fmt.Errorf("linuxterm: set winsize: %w", err) | |
| 94 | - | } | |
| 95 | - | return nil | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | // PtsNumber returns the pty slave number for the master referenced by | |
| 99 | - | // masterFD (TIOCGPTN). | |
| 100 | - | func PtsNumber(masterFD int) (int, error) { | |
| 101 | - | var n int32 | |
| 102 | - | if err := ioctl(masterFD, syscall.TIOCGPTN, unsafe.Pointer(&n)); err != nil { | |
| 103 | - | return 0, fmt.Errorf("linuxterm: get pts number: %w", err) | |
| 104 | - | } | |
| 105 | - | return int(n), nil | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | // UnlockPt unlocks the pty slave associated with masterFD so it can be | |
| 109 | - | // opened (TIOCSPTLCK). | |
| 110 | - | func UnlockPt(masterFD int) error { | |
| 111 | - | var unlock int32 | |
| 112 | - | if err := ioctl(masterFD, syscall.TIOCSPTLCK, unsafe.Pointer(&unlock)); err != nil { | |
| 113 | - | return fmt.Errorf("linuxterm: unlock pty: %w", err) | |
| 93 | + | return fmt.Errorf("rawterm: set winsize: %w", err) | |
| 114 | 94 | } | |
| 115 | 95 | return nil | |
| 116 | 96 | } | |
| @@ -120,7 +100,7 @@ func UnlockPt(masterFD int) error { | |||
| 120 | 100 | func ForegroundProcessGroup(fd int) (int, error) { | |
| 121 | 101 | var pgid int32 | |
| 122 | 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 | 105 | return int(pgid), nil | |
| 126 | 106 | } | |
| @@ -1,9 +1,9 @@ | |||||
| 1 | - | // Package linuxterm provides the small set of terminal-control primitives | 1 | + | //go:build linux || darwin |
| 2 | - | // vev needs (raw mode, window size, pty allocation) directly on top of the | 2 | + | |
| 3 | - | // frozen stdlib syscall package, so the module does not depend on | 3 | + | // 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 Linux | 4 | + | // 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 linuxterm | 6 | + | 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.Termios | 42 | var t syscall.Termios | ||
| 43 | - | return ioctl(fd, syscall.TCGETS, unsafe.Pointer(&t)) == nil | 43 | + | 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 its | 46 | // 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 State | 50 | 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.termios | 55 | raw := oldState.termios | ||
| @@ -61,8 +61,8 @@ func MakeRaw(fd int) (*State, error) { | |||||
| 61 | raw.Cc[syscall.VMIN] = 1 | 61 | raw.Cc[syscall.VMIN] = 1 | ||
| 62 | raw.Cc[syscall.VTIME] = 0 | 62 | 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, nil | 68 | return &oldState, nil | ||
| @@ -71,8 +71,8 @@ func MakeRaw(fd int) (*State, error) { | |||||
| 71 | // Restore applies a previously captured State back to the terminal | 71 | // 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 nil | 77 | 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 winsize | 82 | 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), nil | 86 | 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 nil | 95 | 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 int32 | 101 | 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), nil | 105 | 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 | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| 4 | 6 | "errors" | |
| 5 | - | "fmt" | |
| 6 | 7 | "os" | |
| 7 | 8 | "syscall" | |
| 8 | 9 | "testing" | |
| @@ -11,7 +12,7 @@ import ( | |||
| 11 | 12 | ||
| 12 | 13 | // openPTYPair opens a real Linux PTY master/slave pair directly via | |
| 13 | 14 | // /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 | 16 | func openPTYPair(t *testing.T) (master, slave *os.File) { | |
| 16 | 17 | t.Helper() | |
| 17 | 18 | ||
| @@ -21,22 +22,10 @@ func openPTYPair(t *testing.T) (master, slave *os.File) { | |||
| 21 | 22 | } | |
| 22 | 23 | master = os.NewFile(uintptr(masterFD), "pty-master") | |
| 23 | 24 | ||
| 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) | |
| 30 | 26 | if err != nil { | |
| 31 | 27 | _ = 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) | |
| 40 | 29 | } | |
| 41 | 30 | ||
| 42 | 31 | return master, slave | |
| @@ -101,6 +90,11 @@ func TestMakeRawRestore(t *testing.T) { | |||
| 101 | 90 | ||
| 102 | 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 | 98 | old, err := MakeRaw(fd) | |
| 105 | 99 | if err != nil { | |
| 106 | 100 | t.Fatalf("MakeRaw: %v", err) | |
| @@ -110,8 +104,8 @@ func TestMakeRawRestore(t *testing.T) { | |||
| 110 | 104 | } | |
| 111 | 105 | ||
| 112 | 106 | var current syscall.Termios | |
| 113 | - | if err := ioctl(fd, syscall.TCGETS, unsafe.Pointer(¤t)); err != nil { | |
| 114 | - | t.Fatalf("TCGETS after MakeRaw: %v", err) | |
| 107 | + | if err := ioctl(fd, reqGetTermios, unsafe.Pointer(¤t)); err != nil { | |
| 108 | + | t.Fatalf("get termios after MakeRaw: %v", err) | |
| 115 | 109 | } | |
| 116 | 110 | if current.Lflag&syscall.ECHO != 0 { | |
| 117 | 111 | t.Errorf("ECHO still set after MakeRaw") | |
| @@ -126,6 +120,14 @@ func TestMakeRawRestore(t *testing.T) { | |||
| 126 | 120 | if err := Restore(fd, old); err != nil { | |
| 127 | 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 | 133 | func TestForegroundProcessGroup(t *testing.T) { | |
| @@ -1,8 +1,9 @@ | |||||
| 1 | - | package linuxterm | 1 | + | //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 via | 13 | // openPTYPair opens a real Linux PTY master/slave pair directly via | ||
| 13 | // /dev/ptmx using the package under test, so these tests double as an | 14 | // /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, slave | 31 | 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.Termios | 106 | var current syscall.Termios | ||
| 113 | - | if err := ioctl(fd, syscall.TCGETS, unsafe.Pointer(¤t)); err != nil { | 107 | + | if err := ioctl(fd, reqGetTermios, unsafe.Pointer(¤t)); 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 | + | } | |||