204 lines
6.0 KiB
Go
204 lines
6.0 KiB
Go
// 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:
|
|
}
|
|
}
|
|
}
|