Files

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
}