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

+135
View File
@@ -0,0 +1,135 @@
// 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 preserves Python Fenrir's external script-key contract.
// Scripts are executables, not in-process extensions.
package scripts
import (
"fmt"
"os"
"path/filepath"
"strings"
"git.stormux.org/storm/fenrir/internal/command"
"git.stormux.org/storm/fenrir/internal/input"
)
const SystemPath = "/usr/share/fenrirscreenreader/scripts"
type Script struct {
ID command.ID
Name, Path string
Keys []string
}
// Paths follows CommandManager.get_script_paths, including the resource root
// itself (there is no additional user "scripts" subdirectory).
func Paths(home, configured, bundled string) []string {
var paths []string
seen := map[string]bool{}
for _, path := range []string{filepath.Join(home, ".local/stormux/fenrir"), configured, bundled} {
if path == "" {
continue
}
if path == "~" {
path = home
} else if strings.HasPrefix(path, "~/") {
path = filepath.Join(home, path[2:])
}
path, err := filepath.Abs(path)
if err == nil && !seen[path] {
paths = append(paths, path)
seen[path] = true
}
}
return paths
}
func Parse_filename(filename string) (Script, bool) {
if strings.HasPrefix(filename, ".") {
return Script{}, false
}
stem := strings.TrimSuffix(filename, filepath.Ext(filename))
if strings.HasPrefix(stem, "__") {
return Script{}, false
}
parts := strings.Split(strings.ToUpper(stem), "__-__")
if len(parts) > 2 {
return Script{}, false
}
keys := strings.Split(parts[len(parts)-1], "__+__")
hasScript := false
for _, key := range keys {
if !input.Valid_script_key(key) {
return Script{}, false
}
hasScript = hasScript || key == "KEY_SCRIPT"
}
if !hasScript {
keys = append(keys, "KEY_SCRIPT")
}
return Script{ID: command.ID("script:" + strings.ToUpper(stem)), Name: parts[0], Keys: keys}, true
}
// Discover does not execute files or require their executable bit. Python
// checks executability on invocation. Directory errors affect that directory
// only. Same-directory variants may share a name; earlier directories shadow
// all later variants of that name. ReadDir provides Python's sorted order.
func Discover(paths []string, occupied map[string]bool) ([]Script, []error) {
loadedNames := map[string]bool{}
loadedIDs := map[command.ID]bool{}
shortcuts := map[string]bool{}
for chord, used := range occupied {
shortcuts[chord] = used
}
var result []Script
var problems []error
for _, path := range paths {
entries, err := os.ReadDir(path)
if os.IsNotExist(err) {
continue
}
if err != nil {
problems = append(problems, fmt.Errorf("scripts %s: %w", path, err))
continue
}
names := map[string]bool{}
for _, entry := range entries {
fullPath := filepath.Join(path, entry.Name())
info, err := os.Stat(fullPath) // Follow symlinks, as os.path.isfile does.
if err != nil || !info.Mode().IsRegular() {
continue
}
script, ok := Parse_filename(entry.Name())
if !ok || loadedNames[script.Name] || loadedIDs[script.ID] {
continue
}
chord := input.Script_chord(script.Keys)
if shortcuts[chord] {
continue
}
script.Path = fullPath
result = append(result, script)
names[script.Name], loadedIDs[script.ID], shortcuts[chord] = true, true, true
}
for name := range names {
loadedNames[name] = true
}
}
return result, problems
}
+104
View File
@@ -0,0 +1,104 @@
// 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 (
"os"
"path/filepath"
"reflect"
"testing"
"git.stormux.org/storm/fenrir/internal/input"
)
func TestPythonFilenameGrammar(t *testing.T) {
for _, fixture := range []struct {
name string
keys []string
}{
{"musicplayer__-__KEY_SHIFT__+__KEY_U.sh", []string{"KEY_SHIFT", "KEY_U", "KEY_SCRIPT"}},
{"KEY_A", []string{"KEY_A", "KEY_SCRIPT"}},
{"Name with spaces__-__key_ctrl__+__key_f12.py", []string{"KEY_CTRL", "KEY_F12", "KEY_SCRIPT"}},
{"explicit__-__KEY_SCRIPT__+__KEY_KPPLUS", []string{"KEY_SCRIPT", "KEY_KPPLUS"}},
{"keys__-__KEY_A__+__KEY_B", []string{"KEY_A", "KEY_B", "KEY_SCRIPT"}},
{"media__-__KEY_PLAYPAUSE", []string{"KEY_PLAYPAUSE", "KEY_SCRIPT"}},
} {
script, ok := Parse_filename(fixture.name)
if !ok || !reflect.DeepEqual(script.Keys, fixture.keys) {
t.Fatal(fixture.name, script, ok)
}
}
for _, name := range []string{"__hidden__-__KEY_A.sh", ".hidden__-__KEY_A", "bad__-__KEY_A__-__KEY_B", "bad__-__KEY_NO_SUCH_KEY", "bad__-__KEY_A__+__", "unbound.sh"} {
if _, ok := Parse_filename(name); ok {
t.Fatal("accepted invalid filename", name)
}
}
}
func TestPythonDiscoveryPrecedenceVariantsAndConflicts(t *testing.T) {
root := t.TempDir()
local, system := filepath.Join(root, "local"), filepath.Join(root, "system")
for _, directory := range []string{local, system} {
if err := os.Mkdir(directory, 0700); err != nil {
t.Fatal(err)
}
}
write := func(directory, name string) {
t.Helper()
if err := os.WriteFile(filepath.Join(directory, name), []byte("#!/bin/sh\n"), 0600); err != nil {
t.Fatal(err)
}
}
write(local, "same__-__KEY_A.sh")
write(local, "music__-__KEY_SHIFT__+__KEY_U.sh")
write(local, "music__-__KEY_SHIFT__+__KEY_X.sh")
write(system, "same__-__KEY_B.sh")
write(system, "other__-__KEY_C.sh")
write(system, "conflict__-__KEY_D.sh")
write(system, "occupied__-__KEY_C.sh")
if err := os.Symlink(filepath.Join(local, "same__-__KEY_A.sh"), filepath.Join(local, "link__-__KEY_E.sh")); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(local, "directory__-__KEY_F.sh"), 0700); err != nil {
t.Fatal(err)
}
result, problems := Discover([]string{filepath.Join(root, "missing"), local, system}, map[string]bool{input.Script_chord([]string{"KEY_D", "KEY_SCRIPT"}): true})
if len(problems) != 0 {
t.Fatal(problems)
}
var names []string
for _, script := range result {
names = append(names, filepath.Base(script.Path))
}
want := []string{"link__-__KEY_E.sh", "music__-__KEY_SHIFT__+__KEY_U.sh", "music__-__KEY_SHIFT__+__KEY_X.sh", "same__-__KEY_A.sh", "occupied__-__KEY_C.sh"}
if !reflect.DeepEqual(names, want) {
t.Fatal(names, want)
}
}
func TestPathsKeepPythonResourceRootAndDeduplicate(t *testing.T) {
home := t.TempDir()
got := Paths(home, "~/.local/stormux/fenrir", "")
want := []string{filepath.Join(home, ".local/stormux/fenrir")}
if !reflect.DeepEqual(got, want) {
t.Fatal(got, want)
}
if got := Paths(home, SystemPath, "/bundle/config/scripts"); len(got) != 3 || got[1] != SystemPath {
t.Fatal(got)
}
}
+78
View File
@@ -0,0 +1,78 @@
// 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"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Exercise an unchanged real music controller, with every external action
// replaced by a private executable. Never control the contributor's player.
func TestInstalledMusicControllerWithFakePlayerctl(t *testing.T) {
source := os.Getenv("FENRIR_MUSIC_SCRIPT")
if source == "" {
t.Skip("set FENRIR_MUSIC_SCRIPT to test an existing music controller")
}
data, err := os.ReadFile(source)
if err != nil {
t.Fatal(err)
}
root := t.TempDir()
bin := filepath.Join(root, "bin")
if err := os.Mkdir(bin, 0700); err != nil {
t.Fatal(err)
}
write := func(path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0700); err != nil {
t.Fatal(err)
}
}
write(filepath.Join(bin, "id"), "#!/bin/sh\ncase \"$1\" in -un) echo Username;; *) echo 1000;; esac\n")
write(filepath.Join(bin, "sleep"), "#!/bin/sh\nexit 0\n")
write(filepath.Join(bin, "sudo"), "#!/bin/sh\nexit 99\n")
write(filepath.Join(bin, "playerctl"), "#!/bin/sh\necho \"$*\" >> \"$SCRIPT_ACTION_LOG\"\ncase \"$*\" in *metadata*) echo 'Sample title by Sample artist';; esac\n")
t.Setenv("PATH", bin)
t.Setenv("SCRIPT_ACTION_LOG", filepath.Join(root, "actions"))
path := filepath.Join(root, "musicplayer.sh")
if err := os.WriteFile(path, data, 0700); err != nil {
t.Fatal(err)
}
runner := New_runner(context.Background(), Limits{Workers: 1, Queue: 1, OutputBytes: 4096, Timeout: time.Second}, func(int) string { return "Username" })
defer runner.Close()
for _, fixture := range []struct{ key, action string }{{"z", "previous"}, {"x", "play"}, {"c", "play-pause"}, {"v", "stop"}, {"b", "next"}, {"s", "shuffle toggle"}, {"u", "metadata"}, {"minus", "volume 0.05-"}, {"equal", "volume 0.05+"}} {
link := filepath.Join(root, "musicplayer__-__key_shift__+__key_"+fixture.key+".sh")
if err := os.Symlink(path, link); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "actions"), nil, 0600); err != nil {
t.Fatal(err)
}
runner.Submit(Invocation{Script: Script{Path: link}})
result := receive_result(t, runner)
actions, err := os.ReadFile(filepath.Join(root, "actions"))
if err != nil || result.Err != nil || result.Stderr != "" || !strings.Contains(string(actions), fixture.action) {
t.Fatal(fixture, result, string(actions), err)
}
}
}
+84
View File
@@ -0,0 +1,84 @@
// 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 (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
)
// This is an optional differential test against a contributor's real Python
// checkout. Normal Go builds/tests never require Python Fenrir to be installed.
func TestPythonLoaderParity(t *testing.T) {
source := os.Getenv("FENRIR_PYTHON_SOURCE")
if source == "" {
t.Skip("set FENRIR_PYTHON_SOURCE to compare the real Python loader")
}
root := t.TempDir()
local, system := filepath.Join(root, "local"), filepath.Join(root, "system")
for _, directory := range []string{local, system} {
if err := os.Mkdir(directory, 0700); err != nil {
t.Fatal(err)
}
}
names := []string{"Music__-__KEY_SHIFT__+__KEY_U.sh", "Music__-__KEY_SHIFT__+__KEY_X.sh", "same__-__KEY_B.py", "KEY_A", "explicit__-__KEY_SCRIPT__+__KEY_KPPLUS", "space name__-__key_ctrl__+__key_f12.sh", "multi__-__KEY_A__+__KEY_B", "bad__-__KEY_UNKNOWN", "bad__-__KEY_A__+__", "__skip__-__KEY_Z.sh", ".hidden__-__KEY_V.sh", "bad__-__KEY_A__-__KEY_B"}
for _, name := range names {
if err := os.WriteFile(filepath.Join(local, name), nil, 0600); err != nil {
t.Fatal(err)
}
}
for _, name := range []string{"same__-__KEY_N.sh", "other__-__KEY_Q", "another__-__KEY_A", "Music__-__KEY_Y.sh"} {
if err := os.WriteFile(filepath.Join(system, name), nil, 0600); err != nil {
t.Fatal(err)
}
}
command := exec.Command("python3", "../../tests/python_script_contract.py", source, local, system)
command.Env = append(os.Environ(), "PYTHONDONTWRITEBYTECODE=1")
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("Python contract fixture: %v\n%s", err, output)
}
type record struct {
Name string `json:"name"`
Path string `json:"path"`
Keys []string `json:"keys"`
}
var python []record
if err := json.Unmarshal(output, &python); err != nil {
t.Fatal(err, string(output))
}
installed, problems := Discover([]string{local, system}, nil)
if len(problems) != 0 {
t.Fatal(problems)
}
var goRecords []record
for _, script := range installed {
keys := append([]string(nil), script.Keys...)
sort.Strings(keys)
goRecords = append(goRecords, record{Name: strings.TrimPrefix(string(script.ID), "script:"), Path: script.Path, Keys: keys})
}
if !reflect.DeepEqual(goRecords, python) {
t.Fatalf("Go: %+v\nPython: %+v", goRecords, python)
}
}
+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
}
+141
View File
@@ -0,0 +1,141 @@
// 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"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func fixture_script(t *testing.T, body string) Script {
t.Helper()
path := filepath.Join(t.TempDir(), "script with spaces__-__KEY_A.sh")
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0700); err != nil {
t.Fatal(err)
}
return Script{Path: path}
}
func receive_result(t *testing.T, runner *Runner) Result {
t.Helper()
select {
case result := <-runner.Results():
return result
case <-time.After(3 * time.Second):
t.Fatal("script did not finish")
return Result{}
}
}
func TestRunnerPreservesArgumentsOutputAndNonzeroExit(t *testing.T) {
runner := New_runner(context.Background(), Limits{Workers: 1, Queue: 2, OutputBytes: 4096}, func(terminal int) string {
if terminal != 3 {
t.Error(terminal)
}
return "Username; echo not-a-shell"
})
defer runner.Close()
script := fixture_script(t, "echo \"$1\"\necho problem >&2\nexit 9")
if !runner.Submit(Invocation{Script: script, Terminal: 3}) {
t.Fatal("submit failed")
}
result := receive_result(t, runner)
if result.Err != nil || result.Stdout != "Username; echo not-a-shell\n" || result.Stderr != "problem\n" {
t.Fatal(result)
}
}
func TestRunnerDrainsFloodedOutputAndBoundsRetention(t *testing.T) {
runner := New_runner(context.Background(), Limits{Workers: 1, Queue: 1, OutputBytes: 64, Timeout: time.Second}, nil)
defer runner.Close()
script := fixture_script(t, "i=0; while [ \"$i\" -lt 10000 ]; do echo output; echo error >&2; i=$((i+1)); done")
runner.Submit(Invocation{Script: script})
result := receive_result(t, runner)
if result.Err != nil || !result.Truncated || len(result.Stdout) != 64 || len(result.Stderr) != 64 {
t.Fatal(result)
}
}
func TestRunnerTimeoutFreesWorkerAndCancellationInvalidatesQueuedWork(t *testing.T) {
runner := New_runner(context.Background(), Limits{Workers: 1, Queue: 2, OutputBytes: 4096, Timeout: 150 * time.Millisecond}, nil)
defer runner.Close()
slow := fixture_script(t, "sleep 30 &\nwait")
runner.Submit(Invocation{Script: slow})
if result := receive_result(t, runner); !errors.Is(result.Err, context.DeadlineExceeded) {
t.Fatal(result)
}
runner.Submit(Invocation{Script: fixture_script(t, "echo recovered")})
if result := receive_result(t, runner); result.Err != nil || result.Stdout != "recovered\n" {
t.Fatal(result)
}
generation := runner.Cancel()
marker := filepath.Join(t.TempDir(), "must-not-run")
runner.Submit(Invocation{Script: fixture_script(t, "touch '"+marker+"'")})
if result := receive_result(t, runner); !errors.Is(result.Err, context.Canceled) {
t.Fatal(result)
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatal("stale invocation executed", err)
}
runner.Submit(Invocation{Script: fixture_script(t, "echo new-session"), Generation: generation})
if result := receive_result(t, runner); result.Stdout != "new-session\n" {
t.Fatal(result)
}
}
func TestRunnerBoundsInheritedPipeWaitAndShutdown(t *testing.T) {
runner := New_runner(context.Background(), Limits{Workers: 1, Queue: 1, OutputBytes: 4096}, nil)
script := fixture_script(t, "sleep 30 &\necho parent-finished")
runner.Submit(Invocation{Script: script})
result := receive_result(t, runner)
if result.Err == nil || !strings.Contains(result.Stdout, "parent-finished") {
t.Fatal(result)
}
runner.Submit(Invocation{Script: fixture_script(t, "sleep 30")})
started := time.Now()
runner.Close()
if time.Since(started) > time.Second {
t.Fatal("shutdown waited for script")
}
}
func TestRunnerFileChecksAndQueueBound(t *testing.T) {
runner := New_runner(context.Background(), Limits{Workers: 1, Queue: 1, OutputBytes: 32}, nil)
defer runner.Close()
path := filepath.Join(t.TempDir(), "not-executable")
if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0600); err != nil {
t.Fatal(err)
}
for _, fixture := range []struct{ path, want string }{{path, "Script file is not executable"}, {filepath.Dir(path), "Script source is not a valid file"}, {path + "missing", "Script file not found"}} {
runner.Submit(Invocation{Script: Script{Path: fixture.path}})
result := receive_result(t, runner)
if result.Err == nil || result.Err.Error() != fixture.want {
t.Fatal(result)
}
}
// Directly fill a runner without workers to prove admission cannot block.
bounded := &Runner{ctx: context.Background(), jobs: make(chan Invocation, 1)}
if !bounded.Submit(Invocation{}) || bounded.Submit(Invocation{}) {
t.Fatal("unbounded admission")
}
}