detached 966d045

Fix extension startup error diagnostics

commit
966d045f7872b7d5d706235dbdc70609a8f75ed7
author
patriceckhart · · 2026-07-26 10:00 UTC
parent
210ef98
signature
unsigned
refs
tag: v0.3.9
3 files · +108 -10
M docs/extensions.md +2 -1
@@ -644,7 +644,8 @@ Useful while developing an extension: edit, save, `/reload-ext`,
644644 done. Explicit `--ext` paths are remembered and reloaded alongside
645645 discovered extensions. The temporary reload status reports each load
646646 error in red, including its message, and dismisses itself after five
647-seconds.
647+seconds. Subprocess startup and handshake errors include the exact stderr
648+log path for the failed extension.
648649
649650 ### TypeScript / Python
650651
@@ -644,7 +644,8 @@ Useful while developing an extension: edit, save, `/reload-ext`,
644 done. Explicit `--ext` paths are remembered and reloaded alongside644 done. Explicit `--ext` paths are remembered and reloaded alongside
645 discovered extensions. The temporary reload status reports each load645 discovered extensions. The temporary reload status reports each load
646 error in red, including its message, and dismisses itself after five646 error in red, including its message, and dismisses itself after five
647-seconds.647+seconds. Subprocess startup and handshake errors include the exact stderr
648+log path for the failed extension.
648 649
649 ### TypeScript / Python650 ### TypeScript / Python
650 651
M packages/agent/extensions/manager.go +36 -9
@@ -553,17 +553,39 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
553553 cmd.Stderr = logFile
554554 isolateExtensionProcess(cmd)
555555
556- stdin, err := cmd.StdinPipe()
556+ var stdin io.WriteCloser
557+ var stdout io.ReadCloser
558+ started := false
559+ cleanup := true
560+ defer func() {
561+ if !cleanup {
562+ return
563+ }
564+ if stdin != nil {
565+ _ = stdin.Close()
566+ }
567+ if stdout != nil {
568+ _ = stdout.Close()
569+ }
570+ if started {
571+ _ = cmd.Process.Kill()
572+ _ = cmd.Wait()
573+ }
574+ _ = logFile.Close()
575+ }()
576+
577+ stdin, err = cmd.StdinPipe()
557578 if err != nil {
558- return err
579+ return fmt.Errorf("stdin pipe (stderr log: %s): %w", logPath, err)
559580 }
560- stdout, err := cmd.StdoutPipe()
581+ stdout, err = cmd.StdoutPipe()
561582 if err != nil {
562- return err
583+ return fmt.Errorf("stdout pipe (stderr log: %s): %w", logPath, err)
563584 }
564585 if err := cmd.Start(); err != nil {
565- return fmt.Errorf("spawn: %w", err)
586+ return fmt.Errorf("spawn (stderr log: %s): %w", logPath, err)
566587 }
588+ started = true
567589 ext.cmd = cmd
568590 ext.stdin = stdin
569591 ext.stdout = stdout
@@ -574,14 +596,17 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
574596 scanner := bufio.NewScanner(stdout)
575597 scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
576598 if !scanner.Scan() {
577- return fmt.Errorf("extension exited before hello: %w", scanner.Err())
599+ if err := scanner.Err(); err != nil {
600+ return fmt.Errorf("read hello (stderr log: %s): %w", logPath, err)
601+ }
602+ return fmt.Errorf("extension exited before hello (stderr log: %s)", logPath)
578603 }
579604 var hello extproto.HelloFromExt
580605 if err := json.Unmarshal(scanner.Bytes(), &hello); err != nil {
581- return fmt.Errorf("parse hello: %w", err)
606+ return fmt.Errorf("parse hello (stderr log: %s): %w", logPath, err)
582607 }
583608 if hello.Type != "hello" || hello.Name == "" {
584- return fmt.Errorf("first frame must be hello (got %q)", hello.Type)
609+ return fmt.Errorf("first frame must be hello (got %q; stderr log: %s)", hello.Type, logPath)
585610 }
586611 // Trust the manifest's name; ignore mismatch from the hello.
587612 ext.helloAck = true
@@ -597,9 +622,11 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
597622 DataDir: ext.Dir,
598623 })
599624 if _, err := stdin.Write(ack); err != nil {
600- return fmt.Errorf("send hello_ack: %w", err)
625+ return fmt.Errorf("send hello_ack (stderr log: %s): %w", logPath, err)
601626 }
602627
628+ cleanup = false
629+
603630 // Spin up the read loop now that the handshake is done.
604631 go m.readLoop(ext, scanner)
605632
@@ -553,17 +553,39 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
553 cmd.Stderr = logFile553 cmd.Stderr = logFile
554 isolateExtensionProcess(cmd)554 isolateExtensionProcess(cmd)
555 555
556- stdin, err := cmd.StdinPipe()556+ var stdin io.WriteCloser
557+ var stdout io.ReadCloser
558+ started := false
559+ cleanup := true
560+ defer func() {
561+ if !cleanup {
562+ return
563+ }
564+ if stdin != nil {
565+ _ = stdin.Close()
566+ }
567+ if stdout != nil {
568+ _ = stdout.Close()
569+ }
570+ if started {
571+ _ = cmd.Process.Kill()
572+ _ = cmd.Wait()
573+ }
574+ _ = logFile.Close()
575+ }()
576+
577+ stdin, err = cmd.StdinPipe()
557 if err != nil {578 if err != nil {
558- return err579+ return fmt.Errorf("stdin pipe (stderr log: %s): %w", logPath, err)
559 }580 }
560- stdout, err := cmd.StdoutPipe()581+ stdout, err = cmd.StdoutPipe()
561 if err != nil {582 if err != nil {
562- return err583+ return fmt.Errorf("stdout pipe (stderr log: %s): %w", logPath, err)
563 }584 }
564 if err := cmd.Start(); err != nil {585 if err := cmd.Start(); err != nil {
565- return fmt.Errorf("spawn: %w", err)586+ return fmt.Errorf("spawn (stderr log: %s): %w", logPath, err)
566 }587 }
588+ started = true
567 ext.cmd = cmd589 ext.cmd = cmd
568 ext.stdin = stdin590 ext.stdin = stdin
569 ext.stdout = stdout591 ext.stdout = stdout
@@ -574,14 +596,17 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
574 scanner := bufio.NewScanner(stdout)596 scanner := bufio.NewScanner(stdout)
575 scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)597 scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
576 if !scanner.Scan() {598 if !scanner.Scan() {
577- return fmt.Errorf("extension exited before hello: %w", scanner.Err())599+ if err := scanner.Err(); err != nil {
600+ return fmt.Errorf("read hello (stderr log: %s): %w", logPath, err)
601+ }
602+ return fmt.Errorf("extension exited before hello (stderr log: %s)", logPath)
578 }603 }
579 var hello extproto.HelloFromExt604 var hello extproto.HelloFromExt
580 if err := json.Unmarshal(scanner.Bytes(), &hello); err != nil {605 if err := json.Unmarshal(scanner.Bytes(), &hello); err != nil {
581- return fmt.Errorf("parse hello: %w", err)606+ return fmt.Errorf("parse hello (stderr log: %s): %w", logPath, err)
582 }607 }
583 if hello.Type != "hello" || hello.Name == "" {608 if hello.Type != "hello" || hello.Name == "" {
584- return fmt.Errorf("first frame must be hello (got %q)", hello.Type)609+ return fmt.Errorf("first frame must be hello (got %q; stderr log: %s)", hello.Type, logPath)
585 }610 }
586 // Trust the manifest's name; ignore mismatch from the hello.611 // Trust the manifest's name; ignore mismatch from the hello.
587 ext.helloAck = true612 ext.helloAck = true
@@ -597,9 +622,11 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
597 DataDir: ext.Dir,622 DataDir: ext.Dir,
598 })623 })
599 if _, err := stdin.Write(ack); err != nil {624 if _, err := stdin.Write(ack); err != nil {
600- return fmt.Errorf("send hello_ack: %w", err)625+ return fmt.Errorf("send hello_ack (stderr log: %s): %w", logPath, err)
601 }626 }
602 627
628+ cleanup = false
629+
603 // Spin up the read loop now that the handshake is done.630 // Spin up the read loop now that the handshake is done.
604 go m.readLoop(ext, scanner)631 go m.readLoop(ext, scanner)
605 632
M packages/agent/extensions/manager_test.go +70 -0
@@ -119,6 +119,76 @@ done
119119 }
120120 }
121121
122+func TestDiscoverReportsLogWhenExtensionExitsBeforeHello(t *testing.T) {
123+ if runtime.GOOS == "windows" {
124+ t.Skip("mock extension uses /bin/sh; skip on windows")
125+ }
126+
127+ tmp := t.TempDir()
128+ extDir := filepath.Join(tmp, "extensions", "broken")
129+ if err := os.MkdirAll(extDir, 0o755); err != nil {
130+ t.Fatal(err)
131+ }
132+ script := "#!/bin/sh\nprintf '%s\\n' 'compile failed' >&2\nexit 1\n"
133+ if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
134+ t.Fatal(err)
135+ }
136+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"broken","exec":"./run.sh"}`), 0o644); err != nil {
137+ t.Fatal(err)
138+ }
139+
140+ mgr := New(tmp, "", "0.0.0-test", "", "", nil)
141+ errs := mgr.Discover(context.Background())
142+ if len(errs) != 1 {
143+ t.Fatalf("discover errors = %v, want one", errs)
144+ }
145+ logPath := filepath.Join(tmp, "logs", "ext-broken.log")
146+ got := errs[0].Error()
147+ if strings.Contains(got, "%!w") {
148+ t.Fatalf("error contains formatting artifact: %s", got)
149+ }
150+ if !strings.Contains(got, "extension exited before hello") || !strings.Contains(got, logPath) {
151+ t.Fatalf("error does not explain failure and identify stderr log: %s", got)
152+ }
153+ logData, err := os.ReadFile(logPath)
154+ if err != nil {
155+ t.Fatalf("read extension log: %v", err)
156+ }
157+ if !strings.Contains(string(logData), "compile failed") {
158+ t.Fatalf("extension stderr missing from log: %s", logData)
159+ }
160+}
161+
162+func TestSpawnCleansUpProcessAfterInvalidHello(t *testing.T) {
163+ if runtime.GOOS == "windows" {
164+ t.Skip("mock extension uses /bin/sh; skip on windows")
165+ }
166+
167+ tmp := t.TempDir()
168+ extDir := filepath.Join(tmp, "broken")
169+ if err := os.MkdirAll(extDir, 0o755); err != nil {
170+ t.Fatal(err)
171+ }
172+ scriptPath := filepath.Join(extDir, "run.sh")
173+ script := "#!/bin/sh\nprintf '%s\\n' 'not-json'\nwhile IFS= read -r line; do :; done\n"
174+ if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
175+ t.Fatal(err)
176+ }
177+
178+ ext := &Extension{Manifest: Manifest{Name: "broken", Exec: "./run.sh"}, Dir: extDir}
179+ mgr := New(tmp, "", "0.0.0-test", "", "", nil)
180+ err := mgr.spawn(context.Background(), ext)
181+ if err == nil || !strings.Contains(err.Error(), "parse hello") {
182+ t.Fatalf("spawn error = %v, want parse hello failure", err)
183+ }
184+ if ext.cmd == nil || ext.cmd.ProcessState == nil {
185+ t.Fatal("failed handshake process was not reaped")
186+ }
187+ if _, err := ext.logFile.WriteString("after cleanup"); err == nil {
188+ t.Fatal("failed handshake log file was not closed")
189+ }
190+}
191+
122192 func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
123193 tmp := t.TempDir()
124194 extDir := filepath.Join(tmp, "extensions", "theme-only")
@@ -119,6 +119,76 @@ done
119 }119 }
120 }120 }
121 121
122+func TestDiscoverReportsLogWhenExtensionExitsBeforeHello(t *testing.T) {
123+ if runtime.GOOS == "windows" {
124+ t.Skip("mock extension uses /bin/sh; skip on windows")
125+ }
126+
127+ tmp := t.TempDir()
128+ extDir := filepath.Join(tmp, "extensions", "broken")
129+ if err := os.MkdirAll(extDir, 0o755); err != nil {
130+ t.Fatal(err)
131+ }
132+ script := "#!/bin/sh\nprintf '%s\\n' 'compile failed' >&2\nexit 1\n"
133+ if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
134+ t.Fatal(err)
135+ }
136+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"broken","exec":"./run.sh"}`), 0o644); err != nil {
137+ t.Fatal(err)
138+ }
139+
140+ mgr := New(tmp, "", "0.0.0-test", "", "", nil)
141+ errs := mgr.Discover(context.Background())
142+ if len(errs) != 1 {
143+ t.Fatalf("discover errors = %v, want one", errs)
144+ }
145+ logPath := filepath.Join(tmp, "logs", "ext-broken.log")
146+ got := errs[0].Error()
147+ if strings.Contains(got, "%!w") {
148+ t.Fatalf("error contains formatting artifact: %s", got)
149+ }
150+ if !strings.Contains(got, "extension exited before hello") || !strings.Contains(got, logPath) {
151+ t.Fatalf("error does not explain failure and identify stderr log: %s", got)
152+ }
153+ logData, err := os.ReadFile(logPath)
154+ if err != nil {
155+ t.Fatalf("read extension log: %v", err)
156+ }
157+ if !strings.Contains(string(logData), "compile failed") {
158+ t.Fatalf("extension stderr missing from log: %s", logData)
159+ }
160+}
161+
162+func TestSpawnCleansUpProcessAfterInvalidHello(t *testing.T) {
163+ if runtime.GOOS == "windows" {
164+ t.Skip("mock extension uses /bin/sh; skip on windows")
165+ }
166+
167+ tmp := t.TempDir()
168+ extDir := filepath.Join(tmp, "broken")
169+ if err := os.MkdirAll(extDir, 0o755); err != nil {
170+ t.Fatal(err)
171+ }
172+ scriptPath := filepath.Join(extDir, "run.sh")
173+ script := "#!/bin/sh\nprintf '%s\\n' 'not-json'\nwhile IFS= read -r line; do :; done\n"
174+ if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
175+ t.Fatal(err)
176+ }
177+
178+ ext := &Extension{Manifest: Manifest{Name: "broken", Exec: "./run.sh"}, Dir: extDir}
179+ mgr := New(tmp, "", "0.0.0-test", "", "", nil)
180+ err := mgr.spawn(context.Background(), ext)
181+ if err == nil || !strings.Contains(err.Error(), "parse hello") {
182+ t.Fatalf("spawn error = %v, want parse hello failure", err)
183+ }
184+ if ext.cmd == nil || ext.cmd.ProcessState == nil {
185+ t.Fatal("failed handshake process was not reaped")
186+ }
187+ if _, err := ext.logFile.WriteString("after cleanup"); err == nil {
188+ t.Fatal("failed handshake log file was not closed")
189+ }
190+}
191+
122 func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {192 func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
123 tmp := t.TempDir()193 tmp := t.TempDir()
124 extDir := filepath.Join(tmp, "extensions", "theme-only")194 extDir := filepath.Join(tmp, "extensions", "theme-only")

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 1

Tags 476

↑↓ moveenter openesc close