//nolint:err113,mnd // Preserve the original host-process errors and retry policy. package hostprocess import ( "context" "errors" "maps" "net" "os" "os/exec" "path/filepath" "strings" "syscall" "time" "github.com/avast/retry-go/v4" "github.com/cirruslabs/orchard/internal/worker/socketalias" v1 "github.com/cirruslabs/orchard/pkg/resource/v1" ) type Process struct { cancel context.CancelFunc socketPath string done chan struct{} } func NewProcess( spec v1.HostProcess, workerName string, vmName string, controlSocket string, ) (*Process, error) { // Create a directory where the host process will create its Unix socket // // To keep the Unix socket path short enough and fit the platform limit, // we're specifically requesting "/var/tmp" instead of "/var/folders/.../T/". // // And unlike "/tmp", "/var/tmp" is not subject to macOS's nightly cleanup. runtimeDir, err := os.MkdirTemp("/var/tmp", "orchard-hp-") if err != nil { return nil, err } // Use a fixed socket name within the process-specific runtime directory socketPath := filepath.Join(runtimeDir, "process.sock") // Route the Tart control socket through the runtime directory as well // to work around macOS's 104-byte Unix-domain socket limit controlSocket, err = socketalias.Create(runtimeDir, controlSocket) if err != nil { return nil, errors.Join(err, os.RemoveAll(runtimeDir)) } // Give the process an independently cancellable lifetime cmdCtx, cmdCtxCancel := context.WithCancel(context.Background()) //nolint:gosec // Executing the configured host process is the purpose of this API command := exec.CommandContext( cmdCtx, spec.Program, expandArgs(spec.Args, workerName, vmName, controlSocket, socketPath)..., ) command.Dir = runtimeDir // Ensure that we terminate the host process and any children it spawns command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} command.Cancel = func() error { return syscall.Kill(-command.Process.Pid, syscall.SIGKILL) } // Craft the environment starting from the host process specification env := make(map[string]string) maps.Copy(env, spec.Env) // Preserve only PATH from the worker and make Orchard-managed variables authoritative env["PATH"] = os.Getenv("PATH") env["ORCHARD_WORKER_NAME"] = workerName env["ORCHARD_VM_NAME"] = vmName env["ORCHARD_VM_CONTROL_SOCKET"] = controlSocket env["ORCHARD_PROCESS_SOCKET"] = socketPath // Convert the environment to the format expected by exec.Cmd for name, value := range env { command.Env = append(command.Env, name+"="+value) } if err := command.Start(); err != nil { cmdCtxCancel() return nil, errors.Join(err, os.RemoveAll(runtimeDir)) } process := &Process{ cancel: cmdCtxCancel, socketPath: socketPath, done: make(chan struct{}), } go func() { defer close(process.done) // Reap the process; callers observe termination through done regardless of exit status _ = command.Wait() _ = command.Cancel() _ = os.RemoveAll(runtimeDir) }() return process, nil } func (process *Process) Dial(ctx context.Context) (net.Conn, error) { var dialer net.Dialer return retry.DoWithData(func() (net.Conn, error) { select { case <-process.done: return nil, retry.Unrecoverable(errors.New("process exited before accepting connections")) default: // Continue } return dialer.DialContext(ctx, "unix", process.socketPath) }, retry.Context(ctx), retry.Attempts(200), retry.Delay(50*time.Millisecond), retry.DelayType(retry.FixedDelay), retry.LastErrorOnly(true), ) } func (process *Process) Close() { process.cancel() <-process.done } func expandArgs(args []string, workerName string, vmName string, controlSocket string, socketPath string) []string { replacer := strings.NewReplacer( "${ORCHARD_WORKER_NAME}", workerName, "${ORCHARD_VM_NAME}", vmName, "${ORCHARD_VM_CONTROL_SOCKET}", controlSocket, "${ORCHARD_PROCESS_SOCKET}", socketPath, ) var expanded []string for _, arg := range args { expanded = append(expanded, replacer.Replace(arg)) } return expanded }