// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package tentacle import ( "context" "errors" "fmt" "io" "os" "os/exec" "os/signal" "path/filepath" "runtime" "strconv" "strings" "syscall" "time" ) var ErrSupervision = errors.New("tentacle process supervision failed") // RunSupervisor is a private per-session helper. Descriptors 0..2 carry only // the Tentacle protocol and diagnostics; 3 is a reader-owned lifetime pipe and // 4 is a bounded Spec. The child never inherits either private descriptor. // A separate subreaper keeps ownership of double-forked/setsid descendants // without making the reader a subreaper for unrelated desktop helpers. func RunSupervisor(ctx context.Context) error { // ExtraFiles arrive in blocking mode. Register the private pipes with Go's // poller so Close interrupts their readers on cancellation and child exit. for _, descriptor := range []int{3, 4} { syscall.CloseOnExec(descriptor) if err := syscall.SetNonblock(descriptor, true); err != nil { return fmt.Errorf("%w: private pipe: %w", ErrSupervision, err) } } lifetime := os.NewFile(3, "tentacle-lifetime") configuration := os.NewFile(4, "tentacle-spec") if lifetime == nil || configuration == nil { return ErrSupervision } defer configuration.Close() // PR_SET_CHILD_SUBREAPER is a Linux facility independent of the init system. if _, _, errno := syscall.Syscall6(syscall.SYS_PRCTL, 36, 1, 0, 0, 0, 0); errno != 0 { lifetime.Close() return fmt.Errorf("%w: subreaper: %w", ErrSupervision, errno) } ctx, cancel := context.WithCancel(ctx) defer cancel() readerDone := make(chan struct{}) go func() { defer close(readerDone) _, _ = io.Copy(io.Discard, lifetime) cancel() }() defer func() { lifetime.Close(); <-readerDone }() // Closing this descriptor also interrupts a partial startup on termination. stop := context.AfterFunc(ctx, func() { configuration.Close() }) defer stop() data, err := io.ReadAll(io.LimitReader(configuration, MaxFrameBytes+1)) if err != nil || len(data) > MaxFrameBytes { // No child exists yet. A partial configuration can result from startup // cancellation. Protocol EOF fails registration, while a zero helper // exit certifies cleanup and lets the host release its admission slot. return nil } configuration.Close() var spec Spec if decode_strict(data, &spec) != nil || validate_spec(spec) != nil { return nil } if ctx.Err() != nil { return nil } // Pdeathsig follows the creating thread; retain it for the child lifetime. runtime.LockOSThread() defer runtime.UnlockOSThread() command := exec.Command(spec.Program[0], spec.Program[1:]...) command.Dir = spec.Directory command.Stdin, command.Stdout, command.Stderr = os.Stdin, os.Stdout, os.Stderr command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGKILL} childSignals := make(chan os.Signal, 1) signal.Notify(childSignals, syscall.SIGCHLD) defer signal.Stop(childSignals) if err := command.Start(); err != nil { // No process was started. Protocol EOF reports the startup failure; // successful cleanup keeps the manager available for a later retry. return nil } exited := make(chan struct{}) go func() { _ = command.Wait(); close(exited) }() var supervisionError error running: for { select { case <-ctx.Done(): break running case <-exited: break running case <-childSignals: // The subreaper owns orphaned descendants for the entire session, // not just teardown. Only command.Wait may reap the main child. supervisionError = reap_exited_supervised_children(command.Process.Pid) if supervisionError != nil { break running } } } _ = command.Process.Kill() <-exited return errors.Join(supervisionError, reap_supervised_children(DefaultResourceLimits().CleanupTimeout)) } func supervised_children() ([]int, error) { paths, err := filepath.Glob("/proc/self/task/*/children") if err != nil || len(paths) == 0 { return nil, ErrSupervision } var children []int for _, path := range paths { data, err := os.ReadFile(path) if os.IsNotExist(err) || errors.Is(err, syscall.ESRCH) { // A runtime thread exited during the scan. continue } if err != nil { return nil, ErrSupervision } for _, value := range strings.Fields(string(data)) { pid, err := strconv.Atoi(value) if err != nil || pid <= 1 { return nil, ErrSupervision } children = append(children, pid) } } return children, nil } func reap_exited_supervised_children(mainPID int) error { children, err := supervised_children() if err != nil { return err } for _, childPID := range children { if childPID == mainPID { continue } for { _, err := syscall.Wait4(childPID, nil, syscall.WNOHANG, nil) if err == syscall.EINTR { continue } if err != nil && err != syscall.ECHILD { return fmt.Errorf("%w: reap exited descendant: %w", ErrSupervision, err) } break } } return nil } // Only this helper's children are killed. A child PID cannot be reused until // it has been reaped, and no other goroutine calls Wait after command.Wait joins. // Killing direct children adopts the next generation, including detached ones. func reap_supervised_children(timeout time.Duration) error { deadline := time.NewTimer(timeout) defer deadline.Stop() tick := time.NewTicker(5 * time.Millisecond) defer tick.Stop() for { children, err := supervised_children() if err != nil { return err } for _, pid := range children { if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && err != syscall.ESRCH { return fmt.Errorf("%w: terminate descendant: %w", ErrSupervision, err) } } for { var status syscall.WaitStatus pid, err := syscall.Wait4(-1, &status, syscall.WNOHANG, nil) if err == syscall.ECHILD { return nil } if err == syscall.EINTR { continue } if err != nil { return fmt.Errorf("%w: reap descendant: %w", ErrSupervision, err) } if pid == 0 { break } } select { case <-deadline.C: return fmt.Errorf("%w: descendant cleanup deadline expired", ErrSupervision) case <-tick.C: } } }