detached 9eb66e1

Report extension startup exit status

commit
9eb66e1af55f6dec750669c7583ee6db41c39e3c
author
patriceckhart · · 2026-07-28 06:04 UTC
parent
7792f99
signature
unsigned
refs
tag: v0.3.14
3 files · +70 -6
M docs/extensions.md +2 -1
@@ -647,7 +647,8 @@ error in red, including its message, and dismisses itself after five
647647 seconds. Extension failures from startup and `/reload-ext` remain in the
648648 scrolling chat until `/clear`, without being sent to the model or saved in
649649 the session transcript. Subprocess startup and handshake errors include the
650-exact stderr log path for the failed extension.
650+exact stderr log path for the failed extension. If a subprocess exits before
651+sending `hello`, the error also reports its exit status or terminating signal.
651652
652653 ### TypeScript / Python
653654
@@ -647,7 +647,8 @@ error in red, including its message, and dismisses itself after five
647 seconds. Extension failures from startup and `/reload-ext` remain in the647 seconds. Extension failures from startup and `/reload-ext` remain in the
648 scrolling chat until `/clear`, without being sent to the model or saved in648 scrolling chat until `/clear`, without being sent to the model or saved in
649 the session transcript. Subprocess startup and handshake errors include the649 the session transcript. Subprocess startup and handshake errors include the
650-exact stderr log path for the failed extension.650+exact stderr log path for the failed extension. If a subprocess exits before
651+sending `hello`, the error also reports its exit status or terminating signal.
651 652
652 ### TypeScript / Python653 ### TypeScript / Python
653 654
M packages/agent/extensions/manager.go +35 -1
@@ -512,6 +512,10 @@ func (m *Manager) WaitForReady(grace time.Duration) {
512512 wg.Wait()
513513 }
514514
515+// extensionExitGrace bounds how long startup waits for a process to exit
516+// after it closes stdout before completing the hello handshake.
517+const extensionExitGrace = time.Second
518+
515519 // spawn launches the subprocess, hooks up pipes, logs stderr, and
516520 // runs the synchronous portion of the hello handshake. Asynchronous
517521 // frames are processed in a goroutine started here.
@@ -599,7 +603,12 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
599603 if err := scanner.Err(); err != nil {
600604 return fmt.Errorf("read hello (stderr log: %s): %w", logPath, err)
601605 }
602- return fmt.Errorf("extension exited before hello (stderr log: %s)", logPath)
606+ status, exited := waitForExtensionExit(cmd, extensionExitGrace)
607+ started = false // waitForExtensionExit always reaps the process.
608+ if exited {
609+ return fmt.Errorf("extension exited before hello: %s (stderr log: %s)", status, logPath)
610+ }
611+ return fmt.Errorf("extension closed stdout before hello without exiting (stderr log: %s)", logPath)
603612 }
604613 var hello extproto.HelloFromExt
605614 if err := json.Unmarshal(scanner.Bytes(), &hello); err != nil {
@@ -641,6 +650,31 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
641650 return nil
642651 }
643652
653+// waitForExtensionExit returns the process status when it exits promptly.
654+// A process that closes stdout but remains alive has already violated the
655+// protocol, so kill and reap it rather than blocking extension discovery.
656+func waitForExtensionExit(cmd *exec.Cmd, grace time.Duration) (string, bool) {
657+ done := make(chan error, 1)
658+ go func() { done <- cmd.Wait() }()
659+
660+ timer := time.NewTimer(grace)
661+ defer timer.Stop()
662+ select {
663+ case err := <-done:
664+ if cmd.ProcessState != nil {
665+ return cmd.ProcessState.String(), true
666+ }
667+ if err != nil {
668+ return err.Error(), true
669+ }
670+ return "exit status 0", true
671+ case <-timer.C:
672+ _ = cmd.Process.Kill()
673+ <-done
674+ return "", false
675+ }
676+}
677+
644678 // readyIdleWindow is how long the manager waits for a frame after
645679 // hello before assuming an extension that doesn't send `ready` is
646680 // nevertheless ready. 250ms is enough for any well-behaved native
@@ -512,6 +512,10 @@ func (m *Manager) WaitForReady(grace time.Duration) {
512 wg.Wait()512 wg.Wait()
513 }513 }
514 514
515+// extensionExitGrace bounds how long startup waits for a process to exit
516+// after it closes stdout before completing the hello handshake.
517+const extensionExitGrace = time.Second
518+
515 // spawn launches the subprocess, hooks up pipes, logs stderr, and519 // spawn launches the subprocess, hooks up pipes, logs stderr, and
516 // runs the synchronous portion of the hello handshake. Asynchronous520 // runs the synchronous portion of the hello handshake. Asynchronous
517 // frames are processed in a goroutine started here.521 // frames are processed in a goroutine started here.
@@ -599,7 +603,12 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
599 if err := scanner.Err(); err != nil {603 if err := scanner.Err(); err != nil {
600 return fmt.Errorf("read hello (stderr log: %s): %w", logPath, err)604 return fmt.Errorf("read hello (stderr log: %s): %w", logPath, err)
601 }605 }
602- return fmt.Errorf("extension exited before hello (stderr log: %s)", logPath)606+ status, exited := waitForExtensionExit(cmd, extensionExitGrace)
607+ started = false // waitForExtensionExit always reaps the process.
608+ if exited {
609+ return fmt.Errorf("extension exited before hello: %s (stderr log: %s)", status, logPath)
610+ }
611+ return fmt.Errorf("extension closed stdout before hello without exiting (stderr log: %s)", logPath)
603 }612 }
604 var hello extproto.HelloFromExt613 var hello extproto.HelloFromExt
605 if err := json.Unmarshal(scanner.Bytes(), &hello); err != nil {614 if err := json.Unmarshal(scanner.Bytes(), &hello); err != nil {
@@ -641,6 +650,31 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
641 return nil650 return nil
642 }651 }
643 652
653+// waitForExtensionExit returns the process status when it exits promptly.
654+// A process that closes stdout but remains alive has already violated the
655+// protocol, so kill and reap it rather than blocking extension discovery.
656+func waitForExtensionExit(cmd *exec.Cmd, grace time.Duration) (string, bool) {
657+ done := make(chan error, 1)
658+ go func() { done <- cmd.Wait() }()
659+
660+ timer := time.NewTimer(grace)
661+ defer timer.Stop()
662+ select {
663+ case err := <-done:
664+ if cmd.ProcessState != nil {
665+ return cmd.ProcessState.String(), true
666+ }
667+ if err != nil {
668+ return err.Error(), true
669+ }
670+ return "exit status 0", true
671+ case <-timer.C:
672+ _ = cmd.Process.Kill()
673+ <-done
674+ return "", false
675+ }
676+}
677+
644 // readyIdleWindow is how long the manager waits for a frame after678 // readyIdleWindow is how long the manager waits for a frame after
645 // hello before assuming an extension that doesn't send `ready` is679 // hello before assuming an extension that doesn't send `ready` is
646 // nevertheless ready. 250ms is enough for any well-behaved native680 // nevertheless ready. 250ms is enough for any well-behaved native
M packages/agent/extensions/manager_test.go +33 -4
@@ -119,7 +119,7 @@ done
119119 }
120120 }
121121
122-func TestDiscoverReportsLogWhenExtensionExitsBeforeHello(t *testing.T) {
122+func TestDiscoverReportsExitStatusAndLogWhenExtensionExitsBeforeHello(t *testing.T) {
123123 if runtime.GOOS == "windows" {
124124 t.Skip("mock extension uses /bin/sh; skip on windows")
125125 }
@@ -129,7 +129,7 @@ func TestDiscoverReportsLogWhenExtensionExitsBeforeHello(t *testing.T) {
129129 if err := os.MkdirAll(extDir, 0o755); err != nil {
130130 t.Fatal(err)
131131 }
132- script := "#!/bin/sh\nprintf '%s\\n' 'compile failed' >&2\nexit 1\n"
132+ script := "#!/bin/sh\nprintf '%s\\n' 'compile failed' >&2\nexit 23\n"
133133 if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
134134 t.Fatal(err)
135135 }
@@ -147,8 +147,8 @@ func TestDiscoverReportsLogWhenExtensionExitsBeforeHello(t *testing.T) {
147147 if strings.Contains(got, "%!w") {
148148 t.Fatalf("error contains formatting artifact: %s", got)
149149 }
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)
150+ if !strings.Contains(got, "extension exited before hello: exit status 23") || !strings.Contains(got, logPath) {
151+ t.Fatalf("error does not report exit status and identify stderr log: %s", got)
152152 }
153153 logData, err := os.ReadFile(logPath)
154154 if err != nil {
@@ -159,6 +159,35 @@ func TestDiscoverReportsLogWhenExtensionExitsBeforeHello(t *testing.T) {
159159 }
160160 }
161161
162+func TestDiscoverReportsSignalWhenExtensionExitsBeforeHello(t *testing.T) {
163+ if runtime.GOOS == "windows" {
164+ t.Skip("mock extension uses Unix signals; skip on windows")
165+ }
166+
167+ tmp := t.TempDir()
168+ extDir := filepath.Join(tmp, "extensions", "signaled")
169+ if err := os.MkdirAll(extDir, 0o755); err != nil {
170+ t.Fatal(err)
171+ }
172+ script := "#!/bin/sh\nkill -TERM $$\n"
173+ if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
174+ t.Fatal(err)
175+ }
176+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"signaled","exec":"./run.sh"}`), 0o644); err != nil {
177+ t.Fatal(err)
178+ }
179+
180+ mgr := New(tmp, "", "0.0.0-test", "", "", nil)
181+ errs := mgr.Discover(context.Background())
182+ if len(errs) != 1 {
183+ t.Fatalf("discover errors = %v, want one", errs)
184+ }
185+ got := errs[0].Error()
186+ if !strings.Contains(got, "extension exited before hello: signal:") || !strings.Contains(got, "ext-signaled.log") {
187+ t.Fatalf("error does not report signal and identify stderr log: %s", got)
188+ }
189+}
190+
162191 func TestSpawnCleansUpProcessAfterInvalidHello(t *testing.T) {
163192 if runtime.GOOS == "windows" {
164193 t.Skip("mock extension uses /bin/sh; skip on windows")
@@ -119,7 +119,7 @@ done
119 }119 }
120 }120 }
121 121
122-func TestDiscoverReportsLogWhenExtensionExitsBeforeHello(t *testing.T) {122+func TestDiscoverReportsExitStatusAndLogWhenExtensionExitsBeforeHello(t *testing.T) {
123 if runtime.GOOS == "windows" {123 if runtime.GOOS == "windows" {
124 t.Skip("mock extension uses /bin/sh; skip on windows")124 t.Skip("mock extension uses /bin/sh; skip on windows")
125 }125 }
@@ -129,7 +129,7 @@ func TestDiscoverReportsLogWhenExtensionExitsBeforeHello(t *testing.T) {
129 if err := os.MkdirAll(extDir, 0o755); err != nil {129 if err := os.MkdirAll(extDir, 0o755); err != nil {
130 t.Fatal(err)130 t.Fatal(err)
131 }131 }
132- script := "#!/bin/sh\nprintf '%s\\n' 'compile failed' >&2\nexit 1\n"132+ script := "#!/bin/sh\nprintf '%s\\n' 'compile failed' >&2\nexit 23\n"
133 if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {133 if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
134 t.Fatal(err)134 t.Fatal(err)
135 }135 }
@@ -147,8 +147,8 @@ func TestDiscoverReportsLogWhenExtensionExitsBeforeHello(t *testing.T) {
147 if strings.Contains(got, "%!w") {147 if strings.Contains(got, "%!w") {
148 t.Fatalf("error contains formatting artifact: %s", got)148 t.Fatalf("error contains formatting artifact: %s", got)
149 }149 }
150- if !strings.Contains(got, "extension exited before hello") || !strings.Contains(got, logPath) {150+ if !strings.Contains(got, "extension exited before hello: exit status 23") || !strings.Contains(got, logPath) {
151- t.Fatalf("error does not explain failure and identify stderr log: %s", got)151+ t.Fatalf("error does not report exit status and identify stderr log: %s", got)
152 }152 }
153 logData, err := os.ReadFile(logPath)153 logData, err := os.ReadFile(logPath)
154 if err != nil {154 if err != nil {
@@ -159,6 +159,35 @@ func TestDiscoverReportsLogWhenExtensionExitsBeforeHello(t *testing.T) {
159 }159 }
160 }160 }
161 161
162+func TestDiscoverReportsSignalWhenExtensionExitsBeforeHello(t *testing.T) {
163+ if runtime.GOOS == "windows" {
164+ t.Skip("mock extension uses Unix signals; skip on windows")
165+ }
166+
167+ tmp := t.TempDir()
168+ extDir := filepath.Join(tmp, "extensions", "signaled")
169+ if err := os.MkdirAll(extDir, 0o755); err != nil {
170+ t.Fatal(err)
171+ }
172+ script := "#!/bin/sh\nkill -TERM $$\n"
173+ if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
174+ t.Fatal(err)
175+ }
176+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"signaled","exec":"./run.sh"}`), 0o644); err != nil {
177+ t.Fatal(err)
178+ }
179+
180+ mgr := New(tmp, "", "0.0.0-test", "", "", nil)
181+ errs := mgr.Discover(context.Background())
182+ if len(errs) != 1 {
183+ t.Fatalf("discover errors = %v, want one", errs)
184+ }
185+ got := errs[0].Error()
186+ if !strings.Contains(got, "extension exited before hello: signal:") || !strings.Contains(got, "ext-signaled.log") {
187+ t.Fatalf("error does not report signal and identify stderr log: %s", got)
188+ }
189+}
190+
162 func TestSpawnCleansUpProcessAfterInvalidHello(t *testing.T) {191 func TestSpawnCleansUpProcessAfterInvalidHello(t *testing.T) {
163 if runtime.GOOS == "windows" {192 if runtime.GOOS == "windows" {
164 t.Skip("mock extension uses /bin/sh; skip on windows")193 t.Skip("mock extension uses /bin/sh; skip on windows")

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