299 lines
7.5 KiB
Go
299 lines
7.5 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package tentacle
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
func (session *Session) run() {
|
|
var cleanupError error
|
|
starting := true
|
|
ready := func() {
|
|
if starting {
|
|
starting = false
|
|
<-session.manager.starting
|
|
close(session.ready)
|
|
}
|
|
}
|
|
defer func() {
|
|
session.cancel()
|
|
session.mutex.Lock()
|
|
if cleanupError != nil {
|
|
session.fail_locked(cleanupError)
|
|
// Even a requested disable must report incomplete containment.
|
|
session.state, session.failure = Failed, cleanupError
|
|
} else if session.state == Stopping {
|
|
session.state = Stopped
|
|
} else if session.state != Failed {
|
|
session.fail_locked(ErrExited)
|
|
}
|
|
session.mutex.Unlock()
|
|
session.callbacks.Wait()
|
|
// A retained status/session handle must not retain unsent argument text.
|
|
for len(session.outbound) != 0 {
|
|
<-session.outbound
|
|
}
|
|
ready()
|
|
session.manager.mutex.Lock()
|
|
if cleanupError != nil {
|
|
// Keep the admission slot and reject further starts. Do not claim
|
|
// capacity was reclaimed when owned processes may remain alive.
|
|
session.manager.cleanupError = cleanupError
|
|
} else {
|
|
delete(session.manager.sessions, session.spec.ID)
|
|
}
|
|
close(session.done)
|
|
session.manager.mutex.Unlock()
|
|
}()
|
|
select {
|
|
case <-session.stop:
|
|
return
|
|
case <-session.ctx.Done():
|
|
session.fail(session.ctx.Err())
|
|
return
|
|
default:
|
|
}
|
|
|
|
// Use owned files instead of exec's copier goroutines. Closing our ends
|
|
// unblocks workers even when a descendant retains an inherited pipe.
|
|
var pipes []*os.File
|
|
defer func() {
|
|
for _, pipe := range pipes {
|
|
pipe.Close()
|
|
}
|
|
}()
|
|
newPipe := func() (*os.File, *os.File, error) {
|
|
reader, writer, err := os.Pipe()
|
|
if err == nil {
|
|
pipes = append(pipes, reader, writer)
|
|
}
|
|
return reader, writer, err
|
|
}
|
|
childInput, input, err := newPipe()
|
|
if err != nil {
|
|
session.fail(err)
|
|
return
|
|
}
|
|
output, childOutput, err := newPipe()
|
|
if err != nil {
|
|
session.fail(err)
|
|
return
|
|
}
|
|
diagnostics, childDiagnostics, err := newPipe()
|
|
if err != nil {
|
|
session.fail(err)
|
|
return
|
|
}
|
|
command := exec.Command(session.spec.Program[0], session.spec.Program[1:]...)
|
|
command.Dir = session.spec.Directory
|
|
command.Stdin, command.Stdout, command.Stderr = childInput, childOutput, childDiagnostics
|
|
command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
var group *cgroup
|
|
if containment := session.manager.containment; containment != nil {
|
|
group, err = create_cgroup(filepath.Join(containment.group.path, session.identity), containment.limits.PerSession, 0)
|
|
if err != nil {
|
|
session.fail(err)
|
|
return
|
|
}
|
|
defer func() { cleanupError = group.kill_and_remove(containment.limits.CleanupTimeout) }()
|
|
// clone3(CLONE_INTO_CGROUP) applies limits before the child executes.
|
|
// Moving an already-running PID would leave a fork/allocation race.
|
|
command.SysProcAttr.UseCgroupFD = true
|
|
command.SysProcAttr.CgroupFD = int(group.file.Fd())
|
|
}
|
|
var pid int
|
|
var exited <-chan struct{}
|
|
if session.manager.delegation != nil {
|
|
var closeLaunch func()
|
|
pid, exited, closeLaunch, err = session.manager.delegation.launch(session.ctx, session.spec, childInput, childOutput, childDiagnostics, group.file)
|
|
if err == nil {
|
|
defer closeLaunch()
|
|
}
|
|
} else {
|
|
err = command.Start()
|
|
if err == nil {
|
|
pid = command.Process.Pid
|
|
done := make(chan struct{})
|
|
exited = done
|
|
go func() { _ = command.Wait(); close(done) }()
|
|
}
|
|
}
|
|
if err != nil {
|
|
// Do not retain the command line, environment or child-supplied text.
|
|
if group != nil {
|
|
var errno syscall.Errno
|
|
if errors.As(err, &errno) {
|
|
session.fail(fmt.Errorf("%w: child placement/exec: %w", ErrContainment, errno))
|
|
} else {
|
|
session.fail(ErrContainment)
|
|
}
|
|
} else {
|
|
session.fail(ErrExited)
|
|
}
|
|
return
|
|
}
|
|
childInput.Close()
|
|
childOutput.Close()
|
|
childDiagnostics.Close()
|
|
var workers sync.WaitGroup
|
|
workers.Add(3)
|
|
go func() { defer workers.Done(); session.write_requests(input) }()
|
|
go func() { defer workers.Done(); session.read_replies(output) }()
|
|
go func() { defer workers.Done(); session.drain_diagnostics(diagnostics) }()
|
|
defer func() {
|
|
session.cancel()
|
|
if group != nil {
|
|
_ = write_control(group.path, "cgroup.kill", "1")
|
|
if command.Process != nil {
|
|
_ = command.Process.Kill()
|
|
}
|
|
} else {
|
|
// Explicit trusted-fixture mode only; detached descendants escape.
|
|
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
|
}
|
|
input.Close()
|
|
output.Close()
|
|
diagnostics.Close()
|
|
<-exited
|
|
workers.Wait()
|
|
}()
|
|
|
|
session.mutex.Lock()
|
|
var initialized <-chan Result
|
|
if session.state == Starting && session.ctx.Err() == nil {
|
|
apis := []string{"commands"}
|
|
if session.spec.Presentation {
|
|
apis = append(apis, "presentation")
|
|
}
|
|
if session.spec.Interaction {
|
|
apis = append(apis, "interaction")
|
|
}
|
|
initialized, err = session.request_locked(session.ctx, "initialize", Initialization{
|
|
Protocol: Version{Major: 1, Minor: 0}, Session: session.identity,
|
|
APIs: apis, Capabilities: apis,
|
|
}, session.manager.limits.StartupTimeout)
|
|
} else {
|
|
err = ErrUnavailable
|
|
}
|
|
session.mutex.Unlock()
|
|
if err != nil {
|
|
session.fail(err)
|
|
return
|
|
}
|
|
select {
|
|
case result := <-initialized:
|
|
if result.Err != nil {
|
|
session.fail(result.Err)
|
|
return
|
|
}
|
|
var registration Registration
|
|
if decode_strict(result.Value, ®istration) != nil || validate_registration(registration, session.identity) != nil {
|
|
session.fail(ErrProtocol)
|
|
return
|
|
}
|
|
for _, command := range registration.Commands {
|
|
if command.Presentation && !session.spec.Presentation || command.Interaction && (!command.Presentation || !session.spec.Interaction) {
|
|
session.fail(ErrProtocol)
|
|
return
|
|
}
|
|
}
|
|
session.mutex.Lock()
|
|
if session.state == Starting && session.ctx.Err() == nil {
|
|
session.commands = registration.Commands
|
|
session.state = Ready
|
|
}
|
|
session.mutex.Unlock()
|
|
ready()
|
|
case <-session.ctx.Done():
|
|
session.fail(session.ctx.Err())
|
|
return
|
|
case <-exited:
|
|
session.fail(ErrExited)
|
|
return
|
|
case <-session.stop:
|
|
session.wait_shutdown(exited)
|
|
return
|
|
}
|
|
select {
|
|
case <-session.ctx.Done():
|
|
session.fail(session.ctx.Err())
|
|
case <-exited:
|
|
session.fail(ErrExited)
|
|
case <-session.stop:
|
|
session.wait_shutdown(exited)
|
|
}
|
|
}
|
|
|
|
func (session *Session) wait_shutdown(exited <-chan struct{}) {
|
|
timer := time.NewTimer(session.manager.limits.ShutdownTimeout)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-exited:
|
|
case <-timer.C:
|
|
case <-session.ctx.Done():
|
|
}
|
|
}
|
|
|
|
func (session *Session) write_requests(writer io.Writer) {
|
|
for {
|
|
select {
|
|
case <-session.ctx.Done():
|
|
return
|
|
case item := <-session.outbound:
|
|
if item.id != "" {
|
|
session.mutex.Lock()
|
|
call := session.pending[item.id]
|
|
eligible := call != nil && call.ctx.Err() == nil
|
|
session.mutex.Unlock()
|
|
if !eligible {
|
|
continue
|
|
}
|
|
}
|
|
if _, err := writer.Write(item.frame); err != nil {
|
|
session.fail(ErrExited)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (session *Session) read_replies(reader io.Reader) {
|
|
buffer := bufio.NewReaderSize(reader, 4096)
|
|
for session.ctx.Err() == nil {
|
|
frame, err := read_frame(buffer)
|
|
if err != nil {
|
|
if err == io.EOF {
|
|
err = ErrExited
|
|
}
|
|
session.fail(err)
|
|
return
|
|
}
|
|
reply, err := decode_response(frame)
|
|
if err != nil {
|
|
session.fail(err)
|
|
return
|
|
}
|
|
session.accept_reply(reply)
|
|
}
|
|
}
|
|
|
|
func (session *Session) drain_diagnostics(reader io.Reader) {
|
|
count, err := io.Copy(io.Discard, io.LimitReader(reader, MaxDiagnosticBytes+1))
|
|
if count > MaxDiagnosticBytes {
|
|
session.fail(ErrLimit)
|
|
} else if err != nil && session.ctx.Err() == nil {
|
|
session.fail(ErrExited)
|
|
}
|
|
}
|