feat(platform): extract shared group selection and KERN_PROCARGS2 parser
- commit
- bb8d2ba6073cb1b808299c084635cc6f69afa18a
- author
- brice · · 2026-07-16 18:02 UTC
- committer
- brice · · 2026-07-16 18:05 UTC
- parent
- 3f44aac
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +113 -22
A internal/platform/process.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 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(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 | + | ||
| 54 | + | argv := make([]string, 0, argc) | |
| 55 | + | for len(argv) < argc { | |
| 56 | + | end := bytes.IndexByte(rest, 0) | |
| 57 | + | if end < 0 { | |
| 58 | + | return nil, fmt.Errorf("KERN_PROCARGS2: truncated argv") | |
| 59 | + | } | |
| 60 | + | argv = append(argv, string(rest[:end])) | |
| 61 | + | rest = rest[end+1:] | |
| 62 | + | } | |
| 63 | + | return argv, nil | |
| 64 | + | } | |
| @@ -0,0 +1,64 @@ | |||||
| 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(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 | + | ||||
| 54 | + | argv := make([]string, 0, argc) | |||
| 55 | + | for len(argv) < argc { | |||
| 56 | + | end := bytes.IndexByte(rest, 0) | |||
| 57 | + | if end < 0 { | |||
| 58 | + | return nil, fmt.Errorf("KERN_PROCARGS2: truncated argv") | |||
| 59 | + | } | |||
| 60 | + | argv = append(argv, string(rest[:end])) | |||
| 61 | + | rest = rest[end+1:] | |||
| 62 | + | } | |||
| 63 | + | return argv, nil | |||
| 64 | + | } | |||
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 +49 -0
| @@ -1,3 +1,5 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 1 | 3 | package platform | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| @@ -140,6 +142,53 @@ 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 | + | ||
| 179 | + | for _, tt := range tests { | |
| 180 | + | t.Run(tt.name, func(t *testing.T) { | |
| 181 | + | got, err := parseKernProcArgs2(tt.data) | |
| 182 | + | if (err != nil) != tt.wantErr { | |
| 183 | + | t.Fatalf("parseKernProcArgs2 error = %v, wantErr %v", err, tt.wantErr) | |
| 184 | + | } | |
| 185 | + | if !reflect.DeepEqual(got, tt.want) { | |
| 186 | + | t.Fatalf("parseKernProcArgs2 = %#v; want %#v", got, tt.want) | |
| 187 | + | } | |
| 188 | + | }) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 143 | 192 | func TestProcessArgvFileParsing(t *testing.T) { | |
| 144 | 193 | tests := []struct { | |
| 145 | 194 | 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,53 @@ 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 | + | ||||
| 179 | + | for _, tt := range tests { | |||
| 180 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 181 | + | got, err := parseKernProcArgs2(tt.data) | |||
| 182 | + | if (err != nil) != tt.wantErr { | |||
| 183 | + | t.Fatalf("parseKernProcArgs2 error = %v, wantErr %v", err, tt.wantErr) | |||
| 184 | + | } | |||
| 185 | + | if !reflect.DeepEqual(got, tt.want) { | |||
| 186 | + | t.Fatalf("parseKernProcArgs2 = %#v; want %#v", got, tt.want) | |||
| 187 | + | } | |||
| 188 | + | }) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 143 | func TestProcessArgvFileParsing(t *testing.T) { | 192 | func TestProcessArgvFileParsing(t *testing.T) { | ||
| 144 | tests := []struct { | 193 | tests := []struct { | ||
| 145 | name string | 194 | name string | ||