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,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user