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:
1 parent
c018f5fd29
commit
0e50de4c76
1047 files changed
+58364
-63747
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user