| // Copyright 2026 Google LLC |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package wrapper |
| |
| import ( |
| "context" |
| "fmt" |
| "io" |
| "os" |
| "os/exec" |
| "runtime/debug" |
| "syscall" |
| |
| "github.com/golang/glog" |
| ) |
| |
| // Invocation encapsulates the parameters and execution logic for wrapping a command. |
| type Invocation struct { |
| Command []string |
| SignalPolicy SignalPolicy |
| // Env provides additional environment variables for the child process. |
| Env []string |
| // BaseEnv provides the base environment for the child process. |
| BaseEnv []string |
| // Sigs is a channel for receiving OS signals to be forwarded. |
| Sigs <-chan os.Signal |
| |
| // Stdout and Stderr are the streams to which the wrapped command's output |
| // will be written. |
| Stdout io.Writer |
| Stderr io.Writer |
| } |
| |
| // PopulateSystemDefaults fills missing fields with OS standard globals. |
| // This is a helper for CLI applications that want to use standard OS streams. |
| func (i *Invocation) PopulateSystemDefaults() { |
| if i.Stdout == nil { |
| i.Stdout = os.Stdout |
| } |
| if i.Stderr == nil { |
| i.Stderr = os.Stderr |
| } |
| if i.BaseEnv == nil { |
| i.BaseEnv = os.Environ() |
| } |
| if i.SignalPolicy == "" { |
| i.SignalPolicy = PolicyRelay |
| } |
| } |
| |
| // Run executes the command according to the specified policy. |
| // It handles process isolation, signal forwarding, and exit code propagation. |
| // Callers should ensure that Stdout, Stderr, and BaseEnv are set, or call |
| // PopulateSystemDefaults() first. |
| func (i *Invocation) Run(ctx context.Context) int { |
| if len(i.Command) == 0 { |
| return 0 |
| } |
| |
| cmd := exec.CommandContext(ctx, i.Command[0], i.Command[1:]...) |
| cmd.Stdout = i.Stdout |
| cmd.Stderr = i.Stderr |
| cmd.Env = append(append([]string{}, i.BaseEnv...), i.Env...) |
| |
| // Process isolation: run the command in its own process group unless passive mode is used. |
| if i.SignalPolicy != PolicyPassive { |
| cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} |
| } |
| |
| if err := cmd.Start(); err != nil { |
| glog.Errorf("Failed to start wrapped command: %v", err) |
| return 1 |
| } |
| |
| // Forward signals to the wrapped command. |
| done := make(chan struct{}) |
| go func() { |
| // The recover() block guards against unrecovered panics in this background |
| // goroutine, which in Go results in an immediate and silent process |
| // termination with exit code 2. This ensures that any runtime failures |
| // (e.g., unexpected signal states or nil pointer dereferences) are |
| // captured in the logs with a full stack trace before the process exits. |
| defer func() { |
| if r := recover(); r != nil { |
| glog.Errorf("[wrapper] FATAL PANIC in signal forwarder: %v\nStack trace:\n%s", r, debug.Stack()) |
| glog.Flush() |
| os.Exit(3) // Distinct code for internal panic |
| } |
| }() |
| for { |
| select { |
| case sig, ok := <-i.Sigs: |
| if !ok { |
| return |
| } |
| if i.SignalPolicy == PolicyPassive { |
| continue |
| } |
| s, ok := sig.(syscall.Signal) |
| if !ok { |
| glog.V(1).Infof("Received non-syscall signal %v, skipping forwarding", sig) |
| continue |
| } |
| if cmd.Process == nil { |
| glog.V(1).Infof("Process not initialized, cannot forward signal %v", sig) |
| continue |
| } |
| target := cmd.Process.Pid |
| if i.SignalPolicy == PolicyRelayGroup { |
| target = -target |
| } |
| fmt.Fprintf(os.Stderr, "[wrapper] Forwarding signal %v to target %v\n", sig, target) |
| if err := syscall.Kill(target, s); err != nil { |
| // It's possible the process already exited between reading from |
| // the channel and calling kill. |
| glog.V(1).Infof("Failed to forward signal %v to target %v: %v", sig, target, err) |
| } |
| case <-ctx.Done(): |
| glog.V(1).Infof("[wrapper] Context done, stopping signal forwarder") |
| return |
| case <-done: |
| return |
| } |
| } |
| }() |
| |
| var exitCode int |
| if err := cmd.Wait(); err != nil { |
| glog.Infof("Wrapped command finished with error: %v", err) |
| if exitError, ok := err.(*exec.ExitError); ok { |
| exitCode = exitError.ExitCode() |
| } else { |
| // e.g. if the command was terminated by a signal. |
| exitCode = -1 |
| } |
| } else { |
| glog.Infof("Wrapped command finished successfully.") |
| } |
| close(done) |
| return exitCode |
| } |