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 | |||
| 647 | 647 | seconds. Extension failures from startup and `/reload-ext` remain in the | |
| 648 | 648 | scrolling chat until `/clear`, without being sent to the model or saved in | |
| 649 | 649 | 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 | 653 | ### TypeScript / Python | |
| 653 | 654 | ||
| @@ -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 the | 647 | seconds. Extension failures from startup and `/reload-ext` remain in the | ||
| 648 | scrolling chat until `/clear`, without being sent to the model or saved in | 648 | scrolling chat until `/clear`, without being sent to the model or saved in | ||
| 649 | the session transcript. Subprocess startup and handshake errors include the | 649 | 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 / Python | 653 | ### TypeScript / Python | ||
| 653 | 654 | ||||
M packages/agent/extensions/manager.go +35 -1
| @@ -512,6 +512,10 @@ func (m *Manager) WaitForReady(grace time.Duration) { | |||
| 512 | 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 | 519 | // spawn launches the subprocess, hooks up pipes, logs stderr, and | |
| 516 | 520 | // runs the synchronous portion of the hello handshake. Asynchronous | |
| 517 | 521 | // frames are processed in a goroutine started here. | |
| @@ -599,7 +603,12 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||
| 599 | 603 | if err := scanner.Err(); err != nil { | |
| 600 | 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 | 613 | var hello extproto.HelloFromExt | |
| 605 | 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 | 650 | 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 | 678 | // readyIdleWindow is how long the manager waits for a frame after | |
| 645 | 679 | // hello before assuming an extension that doesn't send `ready` is | |
| 646 | 680 | // 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, and | 519 | // spawn launches the subprocess, hooks up pipes, logs stderr, and | ||
| 516 | // runs the synchronous portion of the hello handshake. Asynchronous | 520 | // 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.HelloFromExt | 613 | 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 nil | 650 | 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 after | 678 | // readyIdleWindow is how long the manager waits for a frame after | ||
| 645 | // hello before assuming an extension that doesn't send `ready` is | 679 | // hello before assuming an extension that doesn't send `ready` is | ||
| 646 | // nevertheless ready. 250ms is enough for any well-behaved native | 680 | // nevertheless ready. 250ms is enough for any well-behaved native | ||
M packages/agent/extensions/manager_test.go +33 -4
| @@ -119,7 +119,7 @@ done | |||
| 119 | 119 | } | |
| 120 | 120 | } | |
| 121 | 121 | ||
| 122 | - | func TestDiscoverReportsLogWhenExtensionExitsBeforeHello(t *testing.T) { | |
| 122 | + | func TestDiscoverReportsExitStatusAndLogWhenExtensionExitsBeforeHello(t *testing.T) { | |
| 123 | 123 | if runtime.GOOS == "windows" { | |
| 124 | 124 | t.Skip("mock extension uses /bin/sh; skip on windows") | |
| 125 | 125 | } | |
| @@ -129,7 +129,7 @@ func TestDiscoverReportsLogWhenExtensionExitsBeforeHello(t *testing.T) { | |||
| 129 | 129 | if err := os.MkdirAll(extDir, 0o755); err != nil { | |
| 130 | 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 | 133 | if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil { | |
| 134 | 134 | t.Fatal(err) | |
| 135 | 135 | } | |
| @@ -147,8 +147,8 @@ func TestDiscoverReportsLogWhenExtensionExitsBeforeHello(t *testing.T) { | |||
| 147 | 147 | if strings.Contains(got, "%!w") { | |
| 148 | 148 | t.Fatalf("error contains formatting artifact: %s", got) | |
| 149 | 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) | |
| 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) | |
| 152 | 152 | } | |
| 153 | 153 | logData, err := os.ReadFile(logPath) | |
| 154 | 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 | 191 | func TestSpawnCleansUpProcessAfterInvalidHello(t *testing.T) { | |
| 163 | 192 | if runtime.GOOS == "windows" { | |
| 164 | 193 | 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") | ||