// 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 . 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 }