1+ // Copyright func-e contributors
12// SPDX-License-Identifier: Apache-2.0
23
34package envoy
@@ -8,13 +9,11 @@ import (
89 "errors"
910 "fmt"
1011 "io"
11- "net/http"
1212 "os"
1313 "os/exec"
1414 "path/filepath"
1515 "strconv"
1616 "strings"
17- "time"
1817)
1918
2019// Run execs the Envoy binary at the path with the args passed.
@@ -54,91 +53,76 @@ func (r *Runtime) Run(ctx context.Context, args []string) error {
5453 // Warn, but don't fail if we can't write the pid file for some reason
5554 r .maybeWarn (os .WriteFile (filepath .Join (r .o .RunDir , "envoy.pid" ), []byte (strconv .Itoa (cmd .Process .Pid )), 0o600 ))
5655
57- // Start a goroutine to scan stderr for "starting main dispatch loop"
58- go r .collectAdminDataOnceRunning (ctx , stderrPipe )
56+ errCh := make (chan error , 1 )
5957
60- // Wait for the process to exit.
61- err = cmd .Wait ()
62- if err == nil {
63- return nil
58+ // Process stderr in a goroutine
59+ go r .processStderr (ctx , stderrPipe , errCh )
60+
61+ // Wait for the process to exit
62+ waitErr := cmd .Wait ()
63+
64+ // After process exit, check for any stderr processing error
65+ stderrErr := <- errCh
66+ if stderrErr != nil {
67+ return stderrErr
6468 }
65- if errors .Is (ctx .Err (), context .Canceled ) {
66- return nil // e.g. graceful shutdown via API
69+
70+ if waitErr == nil || errors .Is (ctx .Err (), context .Canceled ) {
71+ return nil // don't treat context cancel (graceful shutdown) as an error
6772 }
68- return err
73+ return waitErr
6974}
7075
71- // collectAdminDataOnceRunning scans stderr for the admin address and waits for Envoy to be fully started
72- // before collecting config_dump to the run directory.
73- func (r * Runtime ) collectAdminDataOnceRunning (ctx context.Context , cmdStderrPipe io.Reader ) {
74- scanner := bufio .NewScanner (cmdStderrPipe )
75- adminCollected := false
76+ // processStderr scans stderr output and triggers the startup hook when Envoy is ready.
77+ func (r * Runtime ) processStderr (ctx context.Context , stderrPipe io.Reader , errCh chan <- error ) {
78+ var procErr error
79+ defer func () {
80+ if p := recover (); p != nil {
81+ if procErr == nil {
82+ procErr = fmt .Errorf ("processStderr panicked: %v" , p )
83+ }
84+ r .logf ("processStderr panicked: %v" , p )
85+ }
86+ errCh <- procErr
87+ }()
88+
89+ scanner := bufio .NewScanner (stderrPipe )
90+ hookTriggered := false
7691
7792 for scanner .Scan () {
7893 line := scanner .Text ()
79- // copy stderr to the output writer
94+ // Copy stderr line to the output writer
8095 fmt .Fprintln (r .Err , line ) //nolint:errcheck
8196
82- // Collect config dump when ready
83- if ! adminCollected && strings .Contains (line , "starting main dispatch loop" ) {
84- adminCollected = true
97+ // Trigger startup hook when admin is ready
98+ if ! hookTriggered && strings .Contains (line , "starting main dispatch loop" ) {
99+ hookTriggered = true
85100 adminAddrBytes , err := os .ReadFile (r .adminAddressPath )
86101 if err != nil {
87- r .logf ("failed to read admin address from %s: %v" , r .adminAddressPath , err )
88- continue
102+ procErr = fmt .Errorf ("failed to read admin address from %s: %w" , r .adminAddressPath , err )
103+ r .logf (procErr .Error ())
104+ break
89105 }
90106 adminAddress := strings .TrimSpace (string (adminAddrBytes ))
91107 r .adminAddress = adminAddress
92- // Use a separate goroutine to avoid blocking stderr scanning
93- go func (addr string ) {
94- if err := collectConfigDump (ctx , addr , r .GetRunDir ()); err != nil {
95- r .logf ("failed to collect config_dump from %s: %v" , addr , err )
96- } else {
97- r .logf ("collected config_dump from: %s" , addr )
98- }
99- }(adminAddress )
100- }
101- }
102-
103- if err := scanner .Err (); err != nil {
104- r .logf ("error scanning stderr: %v" , err )
105- }
106- }
107-
108- // collectConfigDump fetches config_dump from Envoy admin API
109- func collectConfigDump (ctx context.Context , adminAddress , runDir string ) error {
110- url := fmt .Sprintf ("http://%s/config_dump" , adminAddress )
111- file := filepath .Join (runDir , "config_dump.json" )
112108
113- ctx , cancel := context .WithTimeout (ctx , 10 * time .Second )
114- defer cancel ()
115- return copyURLToFile (ctx , url , file )
116- }
117-
118- func copyURLToFile (ctx context.Context , url , fullPath string ) error {
119- // #nosec -> runDir is allowed to be anywhere
120- f , err := os .OpenFile (fullPath , os .O_CREATE | os .O_WRONLY , 0o600 )
121- if err != nil {
122- return fmt .Errorf ("could not open %q: %w" , fullPath , err )
123- }
124- defer f .Close () //nolint:errcheck
125-
126- // #nosec -> adminAddress is written by Envoy and the paths are hard-coded
127- req , err := http .NewRequestWithContext (ctx , http .MethodGet , url , nil )
128- if err != nil {
129- return fmt .Errorf ("could not create request %v: %w" , url , err )
130- }
131- res , err := http .DefaultClient .Do (req )
132- if err != nil {
133- return fmt .Errorf ("could not read %v: %w" , url , err )
109+ // Call startup hook
110+ if err := r .startupHook (ctx , r .o .RunDir , adminAddress ); err != nil {
111+ procErr = err
112+ r .logf (err .Error ())
113+ break
114+ }
115+ }
134116 }
135- defer res .Body .Close () //nolint:errcheck
136117
137- if res .StatusCode != http .StatusOK {
138- return fmt .Errorf ("received %v from %v" , res .StatusCode , url )
139- }
140- if _ , err := io .Copy (f , res .Body ); err != nil {
141- return fmt .Errorf ("could not write response body of %v: %w" , url , err )
118+ // Log and propagate unexpected scanner errors, ignoring EOF, closed pipe, or context cancellation.
119+ if err := scanner .Err (); err != nil && ctx .Err () == nil {
120+ // Skip expected errors that indicate normal stream closure
121+ if ! errors .Is (err , io .EOF ) && ! errors .Is (err , io .ErrClosedPipe ) {
122+ r .logf ("error scanning stderr: %v" , err )
123+ if procErr == nil {
124+ procErr = fmt .Errorf ("error scanning stderr: %w" , err )
125+ }
126+ }
142127 }
143- return nil
144128}
0 commit comments