220 lines
6.3 KiB
Go
220 lines
6.3 KiB
Go
// Fenrir console screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify it
|
|
// under the terms of the GNU General Public License as published by the Free
|
|
// Software Foundation, either version 3 of the License, or (at your option)
|
|
// any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
// more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License along with
|
|
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
|
|
package scripts
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
type Limits struct {
|
|
Workers int
|
|
Queue int
|
|
OutputBytes int
|
|
Timeout time.Duration // Zero preserves Python's unlimited invocation lifetime.
|
|
}
|
|
|
|
type Invocation struct {
|
|
Script Script
|
|
Terminal int
|
|
Generation uint64
|
|
}
|
|
|
|
type Result struct {
|
|
Invocation
|
|
Stdout, Stderr string
|
|
Err error
|
|
Truncated bool
|
|
}
|
|
|
|
// Runner owns subprocesses only. The application loop owns bindings, session
|
|
// state and speech. Its bounded channels never block the input dispatcher.
|
|
type Runner struct {
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
limits Limits
|
|
username func(int) string
|
|
jobs chan Invocation
|
|
results chan Result
|
|
workers sync.WaitGroup
|
|
mu sync.Mutex
|
|
generation uint64
|
|
active map[*Invocation]context.CancelFunc
|
|
done chan struct{}
|
|
}
|
|
|
|
func New_runner(ctx context.Context, limits Limits, username func(int) string) *Runner {
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
r := &Runner{ctx: ctx, cancel: cancel, limits: limits, username: username, jobs: make(chan Invocation, limits.Queue), results: make(chan Result, limits.Workers), active: make(map[*Invocation]context.CancelFunc), done: make(chan struct{})}
|
|
for range limits.Workers {
|
|
r.workers.Go(r.work)
|
|
}
|
|
go func() { r.workers.Wait(); close(r.done) }()
|
|
return r
|
|
}
|
|
|
|
func (r *Runner) Submit(invocation Invocation) bool {
|
|
if r.ctx.Err() != nil {
|
|
return false
|
|
}
|
|
select {
|
|
case r.jobs <- invocation:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (r *Runner) Results() <-chan Result { return r.results }
|
|
func (r *Runner) Close() {
|
|
r.cancel()
|
|
// A kernel task stuck in uninterruptible filesystem I/O cannot be reaped
|
|
// until the kernel releases it. It still must not delay reader shutdown.
|
|
select {
|
|
case <-r.done:
|
|
case <-time.After(750 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
// Cancel invalidates queued work and cancels active invocations without waiting
|
|
// on the caller. Session handoff therefore never waits for a script to exit.
|
|
func (r *Runner) Cancel() uint64 {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.generation++
|
|
for _, cancel := range r.active {
|
|
cancel()
|
|
}
|
|
return r.generation
|
|
}
|
|
|
|
func (r *Runner) work() {
|
|
for {
|
|
select {
|
|
case <-r.ctx.Done():
|
|
return
|
|
case invocation := <-r.jobs:
|
|
if r.ctx.Err() != nil {
|
|
return
|
|
}
|
|
result := r.execute(invocation)
|
|
select {
|
|
case r.results <- result:
|
|
case <-r.ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type boundedOutput struct {
|
|
data []byte
|
|
limit int
|
|
truncated bool
|
|
}
|
|
|
|
func (b *boundedOutput) Write(data []byte) (int, error) {
|
|
count := min(len(data), b.limit-len(b.data))
|
|
b.data = append(b.data, data[:count]...)
|
|
b.truncated = b.truncated || count < len(data)
|
|
return len(data), nil // Keep draining after reaching the retention limit.
|
|
}
|
|
|
|
func (b *boundedOutput) text() string {
|
|
text := string(b.data)
|
|
if b.truncated {
|
|
text = strings.ToValidUTF8(text, "")
|
|
}
|
|
return strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n")
|
|
}
|
|
|
|
func (r *Runner) execute(invocation Invocation) Result {
|
|
result := Result{Invocation: invocation}
|
|
r.mu.Lock()
|
|
if invocation.Generation != r.generation {
|
|
r.mu.Unlock()
|
|
result.Err = context.Canceled
|
|
return result
|
|
}
|
|
ctx, cancelInvocation := context.WithCancel(r.ctx)
|
|
r.active[&invocation] = cancelInvocation
|
|
r.mu.Unlock()
|
|
defer func() { cancelInvocation(); r.mu.Lock(); delete(r.active, &invocation); r.mu.Unlock() }()
|
|
info, err := os.Stat(invocation.Script.Path)
|
|
switch {
|
|
case errors.Is(err, os.ErrNotExist):
|
|
result.Err = errors.New("Script file not found")
|
|
return result
|
|
case err != nil:
|
|
result.Err = err
|
|
return result
|
|
case !info.Mode().IsRegular():
|
|
result.Err = errors.New("Script source is not a valid file")
|
|
return result
|
|
case syscall.Access(invocation.Script.Path, 1) != nil:
|
|
result.Err = errors.New("Script file is not executable")
|
|
return result
|
|
}
|
|
if r.limits.Timeout > 0 {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(ctx, r.limits.Timeout)
|
|
defer cancel()
|
|
}
|
|
// Preserve Python's [script_path, current_user], inherited identity,
|
|
// environment, cwd and stdin. No shell command string is constructed.
|
|
name := ""
|
|
if r.username != nil {
|
|
name = r.username(invocation.Terminal)
|
|
}
|
|
cmd := exec.CommandContext(ctx, invocation.Script.Path, name)
|
|
cmd.Stdin = os.Stdin
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
stdout, stderr := &boundedOutput{limit: r.limits.OutputBytes}, &boundedOutput{limit: r.limits.OutputBytes}
|
|
cmd.Stdout, cmd.Stderr = stdout, stderr
|
|
// CommandContext's default cancellation kills only the shell. Signal its
|
|
// process group, allow bounded cleanup, then kill any remaining group members.
|
|
cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) }
|
|
cmd.WaitDelay = 250 * time.Millisecond
|
|
err = cmd.Run()
|
|
if (errors.Is(err, exec.ErrWaitDelay) || ctx.Err() != nil) && cmd.Process != nil {
|
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
|
}
|
|
result.Stdout, result.Stderr = stdout.text(), stderr.text()
|
|
result.Truncated = stdout.truncated || stderr.truncated
|
|
// Python presents stderr then stdout even for a nonzero exit status.
|
|
var exitError *exec.ExitError
|
|
if err != nil && !errors.As(err, &exitError) {
|
|
result.Err = err
|
|
}
|
|
if ctx.Err() != nil {
|
|
result.Err = ctx.Err()
|
|
}
|
|
if !utf8.Valid(stdout.data) && !stdout.truncated || !utf8.Valid(stderr.data) && !stderr.truncated {
|
|
result.Stdout, result.Stderr, result.Err = "", "", fmt.Errorf("script output is not UTF-8")
|
|
}
|
|
return result
|
|
}
|