After commit c018f5fd29 Fenrir is being switched over to go. If you do not want to be part of the testing process, please switch to master.

This commit is contained in:
Storm Dragon committed 2026-10-27 20:30:55 -04:00
1 parent c018f5fd29
commit 0e50de4c76
1047 files changed
+58364 -63747

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
// 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
}