detached bb8d2ba

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 (
55 "fmt"
66 "os"
77 "path/filepath"
8- "sort"
98 "strconv"
109 "strings"
1110 "sync"
1211 "time"
1312 )
1413
15-const processRecordCacheTTL = 250 * time.Millisecond
16-
1714 // ProcessInspector implements ports.ProcessInspector using Linux /proc.
1815 type ProcessInspector struct {
1916 root string
@@ -87,8 +84,6 @@ func processArgv(path string, pid int) ([]string, error) {
8784 return argv, nil
8885 }
8986
90-type processRecord struct{ pid, pgrp int }
91-
9287 // ProcessGroupArgv returns argv for a restorable foreground process in pgid.
9388 // A bare pane shell (the only process in its foreground group) returns nil argv.
9489 func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) {
@@ -103,23 +98,6 @@ func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) {
10398 return ProcessArgv(pid)
10499 }
105100
106-func selectProcessGroupPID(recs []processRecord, pgid int, shellPid int) (int, bool) {
107- if pgid <= 0 {
108- return 0, false
109- }
110- matches := make([]int, 0)
111- for _, r := range recs {
112- if r.pgrp == pgid && r.pid != shellPid {
113- matches = append(matches, r.pid)
114- }
115- }
116- if len(matches) == 0 {
117- return 0, false
118- }
119- sort.Ints(matches)
120- return matches[0], true
121-}
122-
123101 func readProcRecords(root string) ([]processRecord, error) {
124102 ents, err := os.ReadDir(root)
125103 if err != nil {
@@ -5,15 +5,12 @@ import (
5 "fmt"5 "fmt"
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8- "sort"
9 "strconv"8 "strconv"
10 "strings"9 "strings"
11 "sync"10 "sync"
12 "time"11 "time"
13 )12 )
14 13
15-const processRecordCacheTTL = 250 * time.Millisecond
16-
17 // ProcessInspector implements ports.ProcessInspector using Linux /proc.14 // ProcessInspector implements ports.ProcessInspector using Linux /proc.
18 type ProcessInspector struct {15 type ProcessInspector struct {
19 root string16 root string
@@ -87,8 +84,6 @@ func processArgv(path string, pid int) ([]string, error) {
87 return argv, nil84 return argv, nil
88 }85 }
89 86
90-type processRecord struct{ pid, pgrp int }
91-
92 // ProcessGroupArgv returns argv for a restorable foreground process in pgid.87 // ProcessGroupArgv returns argv for a restorable foreground process in pgid.
93 // A bare pane shell (the only process in its foreground group) returns nil argv.88 // A bare pane shell (the only process in its foreground group) returns nil argv.
94 func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) {89 func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) {
@@ -103,23 +98,6 @@ func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) {
103 return ProcessArgv(pid)98 return ProcessArgv(pid)
104 }99 }
105 100
106-func selectProcessGroupPID(recs []processRecord, pgid int, shellPid int) (int, bool) {
107- if pgid <= 0 {
108- return 0, false
109- }
110- matches := make([]int, 0)
111- for _, r := range recs {
112- if r.pgrp == pgid && r.pid != shellPid {
113- matches = append(matches, r.pid)
114- }
115- }
116- if len(matches) == 0 {
117- return 0, false
118- }
119- sort.Ints(matches)
120- return matches[0], true
121-}
122-
123 func readProcRecords(root string) ([]processRecord, error) {101 func readProcRecords(root string) ([]processRecord, error) {
124 ents, err := os.ReadDir(root)102 ents, err := os.ReadDir(root)
125 if err != nil {103 if err != nil {
M internal/platform/process_linux_test.go +49 -0
@@ -1,3 +1,5 @@
1+//go:build linux
2+
13 package platform
24
35 import (
@@ -140,6 +142,53 @@ func TestParseStatPgrp(t *testing.T) {
140142 }
141143 }
142144
145+func TestParseKernProcArgs2(t *testing.T) {
146+ tests := []struct {
147+ name string
148+ data []byte
149+ want []string
150+ wantErr bool
151+ }{
152+ {
153+ name: "argv after executable and padding",
154+ data: append([]byte{2, 0, 0, 0}, []byte("/bin/tool\x00\x00tool\x00arg\x00ENV=value\x00")...),
155+ want: []string{"tool", "arg"},
156+ },
157+ {
158+ name: "preserves empty argument",
159+ data: append([]byte{3, 0, 0, 0}, []byte("/bin/tool\x00tool\x00\x00arg\x00")...),
160+ want: []string{"tool", "", "arg"},
161+ },
162+ {
163+ name: "truncated argc",
164+ data: []byte{1, 0, 0},
165+ wantErr: true,
166+ },
167+ {
168+ name: "unterminated executable path",
169+ data: append([]byte{1, 0, 0, 0}, []byte("/bin/tool")...),
170+ wantErr: true,
171+ },
172+ {
173+ name: "truncated argv",
174+ data: append([]byte{1, 0, 0, 0}, []byte("/bin/tool\x00tool")...),
175+ wantErr: true,
176+ },
177+ }
178+
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+
143192 func TestProcessArgvFileParsing(t *testing.T) {
144193 tests := []struct {
145194 name string
@@ -1,3 +1,5 @@
1+//go:build linux
2+
1 package platform3 package platform
2 4
3 import (5 import (
@@ -140,6 +142,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 string194 name string

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close