Standard fenrir backgrounding and -d flags restored.

This commit is contained in:
Storm Dragon committed 2026-10-28 00:58:49 -04:00
1 parent 4f5a875d0e
commit eaf450587c
13 files changed
+408 -20

No files matched your search

+7 -1
View File
@@ -58,6 +58,11 @@ From a Linux text console:
sudo ./fenrir
```
Console mode starts in the background by default. Use `sudo ./fenrir -f` to
keep it in the foreground, including when running under a service manager.
The launcher waits for startup and reports initialization errors. Use
`--debug-file PATH` to keep diagnostics after it detaches.
Console mode needs access to the console devices, keyboard event devices, and
`/dev/uinput`. Run only one reader on the same console. Fenrir releases keyboard
input on graphical virtual terminals and on text consoles listed in
@@ -212,7 +217,8 @@ requests can also be enabled separately in the `[remote]` section.
Run `./fenrir --help` for all options. Useful options include `--config`,
`--keyboard-layout`, `--punctuation-profile`, `--speech-driver`, `--speech-rate`,
`--speech-module`, `--input-device`, and `--debug-file`. `--version` prints the
version and release channel. Fenrir runs in the foreground.
version and release channel. Console mode runs in the background unless `-f`
is given; `-x` always runs in the foreground.
## Localization
+98
View File
@@ -0,0 +1,98 @@
// 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 main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"syscall"
)
const daemonStatusEnv = "FENRIR_DAEMON_STATUS_FD"
type daemonStatus struct {
Code int
Error string
}
// Re-exec rather than forking a running Go runtime. Keep the working directory
// and environment so relative configuration paths and audio access still work.
func start_daemon(args []string, stderr io.Writer) int {
executable, err := os.Executable()
if err != nil {
fmt.Fprintln(stderr, "fenrir: daemon executable:", err)
return 1
}
child := exec.Command(executable, append(append([]string{}, args...), "-f")...)
return launch_daemon(child, stderr)
}
func launch_daemon(child *exec.Cmd, stderr io.Writer) int {
reader, writer, err := os.Pipe()
if err != nil {
fmt.Fprintln(stderr, "fenrir: daemon startup pipe:", err)
return 1
}
defer reader.Close()
defer writer.Close()
child.Env = append(os.Environ(), daemonStatusEnv+"=3")
child.ExtraFiles = []*os.File{writer}
child.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
// Nil standard streams connect to /dev/null, releasing the launching TTY.
if err := child.Start(); err != nil {
fmt.Fprintln(stderr, "fenrir: start daemon:", err)
return 1
}
writer.Close()
var status daemonStatus
if err := json.NewDecoder(reader).Decode(&status); err != nil {
// A child that cannot report readiness must not keep input grabbed.
child.Process.Kill()
child.Wait()
fmt.Fprintln(stderr, "fenrir: daemon startup:", err)
return 1
}
if status.Code != 0 {
child.Wait()
fmt.Fprint(stderr, status.Error)
return status.Code
}
child.Process.Release()
return 0
}
func run_daemon_child(args []string, stdout io.Writer, statusFile *os.File) int {
defer statusFile.Close()
var diagnostics bytes.Buffer
reported := false
code := run_with_ready(args, stdout, &diagnostics, func() error {
if err := json.NewEncoder(statusFile).Encode(daemonStatus{}); err != nil {
return fmt.Errorf("report daemon readiness: %w", err)
}
reported = true
return statusFile.Close()
})
if !reported {
json.NewEncoder(statusFile).Encode(daemonStatus{Code: code, Error: diagnostics.String()})
}
return code
}
+148
View File
@@ -0,0 +1,148 @@
// 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 main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)
// Runs only in private subprocesses: no console, speech or keyboard devices.
func TestDaemonProcess(t *testing.T) {
if os.Getenv(daemonStatusEnv) != "3" {
return
}
os.Unsetenv(daemonStatusEnv)
statusFile := os.NewFile(3, "daemon startup")
mode := os.Args[len(os.Args)-2]
recordPath := os.Args[len(os.Args)-1]
switch mode {
case "failure":
os.Exit(run_daemon_child([]string{"--unknown"}, os.Stdout, statusFile))
case "crash":
os.Exit(17)
case "ready":
ctx, stop := termination_context()
defer stop()
sessionID, _, err := syscall.Syscall(syscall.SYS_GETSID, 0, 0, 0)
if err != 0 || int(sessionID) != os.Getpid() || syscall.Getpgrp() != os.Getpid() {
os.Exit(18)
}
if tty, err := os.Open("/dev/tty"); err == nil {
tty.Close()
os.Exit(19)
}
for _, fd := range []string{"0", "1", "2"} {
target, err := os.Readlink("/proc/self/fd/" + fd)
if err != nil || target != os.DevNull {
os.Exit(20)
}
}
cwd, _ := os.Getwd()
record := fmt.Sprintf("%d\n%s\n%s", os.Getpid(), cwd, os.Getenv("FENRIR_DAEMON_TEST_VALUE"))
if err := os.WriteFile(recordPath, []byte(record), 0600); err != nil {
os.Exit(21)
}
json.NewEncoder(statusFile).Encode(daemonStatus{})
statusFile.Close()
select {
case <-ctx.Done():
case <-time.After(10 * time.Second):
}
os.Exit(0)
}
os.Exit(22)
}
func TestDaemonDetachesAndReportsReadiness(t *testing.T) {
root := t.TempDir()
t.Setenv("FENRIR_DAEMON_TEST_VALUE", "preserved")
recordPath := filepath.Join(root, "child")
child := exec.Command(os.Args[0], "-test.run=^TestDaemonProcess$", "--", "ready", recordPath)
child.Dir = root
var stderr bytes.Buffer
if status := launch_daemon(child, &stderr); status != 0 {
t.Fatal(status, stderr.String())
}
record, err := os.ReadFile(recordPath)
if err != nil {
t.Fatal(err)
}
// Release invalidates child.Process.Pid. Read the child's own recorded PID
// and reject process-group/broadcast values before any signal or wait.
var pid int
if _, err := fmt.Sscanf(string(record), "%d", &pid); err != nil || pid <= 1 {
t.Fatal("invalid private child PID", pid, err)
}
privateChild, err := os.FindProcess(pid)
if err != nil {
t.Fatal(err)
}
defer privateChild.Release()
reaped := false
defer func() {
if !reaped {
privateChild.Kill()
syscall.Wait4(pid, nil, 0, nil)
}
}()
if string(record) != fmt.Sprintf("%d\n%s\npreserved", pid, root) {
t.Fatal("detached child lost working directory or environment", string(record))
}
if err := privateChild.Signal(syscall.Signal(0)); err != nil {
t.Fatal("launcher did not return while child was running", err)
}
// The existing termination handler must still shut down a detached reader.
if err := privateChild.Signal(syscall.SIGTERM); err != nil {
t.Fatal(err)
}
var status syscall.WaitStatus
waited, err := syscall.Wait4(pid, &status, 0, nil)
reaped = waited == pid
if err != nil || !status.Exited() || status.ExitStatus() != 0 {
t.Fatal("detached child did not handle SIGTERM", status, err)
}
}
func TestDaemonStartupFailuresReturnToLauncher(t *testing.T) {
for _, mode := range []string{"failure", "crash", "missing-executable"} {
t.Run(mode, func(t *testing.T) {
child := exec.Command(os.Args[0], "-test.run=^TestDaemonProcess$", "--", mode, "unused")
wantCode, wantText := 1, "daemon startup:"
if mode == "failure" {
wantCode, wantText = 2, "flag provided but not defined"
}
if mode == "missing-executable" {
child = exec.Command(filepath.Join(t.TempDir(), "missing"))
wantText = "start daemon:"
}
var stderr bytes.Buffer
if code := launch_daemon(child, &stderr); code != wantCode || !strings.Contains(stderr.String(), wantText) {
t.Fatal(code, stderr.String())
}
})
}
}
+24 -5
View File
@@ -39,14 +39,26 @@ import (
func main() {
i18n.Use(i18n.New(i18n.LocaleDir, i18n.Language()))
if os.Getenv(daemonStatusEnv) == "3" {
// Do not pass the private startup channel to scripts or synthesizers.
os.Unsetenv(daemonStatusEnv)
syscall.CloseOnExec(3)
statusFile := os.NewFile(3, "daemon startup")
os.Exit(run_daemon_child(os.Args[1:], os.Stdout, statusFile))
}
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
return run_with_ready(args, stdout, stderr, nil)
}
func run_with_ready(args []string, stdout, stderr io.Writer, ready func() error) int {
flags := flag.NewFlagSet("fenrir", flag.ContinueOnError)
flags.SetOutput(stderr)
showVersion := flags.Bool("version", false, "print the version and release channel")
terminalMode := flags.Bool("x", false, "run an unprivileged shell in this X11 terminal")
foreground := flags.Bool("f", false, "stay in the foreground (always enabled with -x)")
configPath := flags.String("config", "", "settings.toml path (default: Fenrir user/system settings directories)")
keyboardLayout := flags.String("keyboard-layout", "", "keyboard layout name or TOML path (overrides keyboard.layout)")
punctuationProfile := flags.String("punctuation-profile", "", "punctuation profile name or TOML path (overrides reading.punctuation_profile)")
@@ -127,6 +139,12 @@ func run(args []string, stdout, stderr io.Writer) int {
defer file.Close()
logOutput, logLevel = file, slog.LevelDebug
}
if !*foreground && !*terminalMode && ready == nil {
return start_daemon(args, stderr)
}
if ready != nil && *debugFile == "" {
logOutput = io.Discard
}
previousLogger := slog.Default()
defer slog.SetDefault(previousLogger)
slog.SetDefault(slog.New(slog.NewTextHandler(logOutput, &slog.HandlerOptions{
@@ -142,10 +160,6 @@ func run(args []string, stdout, stderr io.Writer) int {
slog.Info("reader starting", "pid", os.Getpid(), "terminal_mode", *terminalMode, "at", time.Now())
ctx, stop := termination_context()
defer stop()
start := app.Start
if *terminalMode {
start = app.Start_terminal
}
if path == "" {
home, err := os.UserHomeDir()
if err != nil {
@@ -154,7 +168,12 @@ func run(args []string, stdout, stderr io.Writer) int {
}
path = config.Paths(home, os.Geteuid())[0]
}
if err := start(ctx, settings, path, bindings); err != nil {
if *terminalMode {
err = app.Start_terminal(ctx, settings, path, bindings)
} else {
err = app.Start_with_ready(ctx, settings, path, bindings, ready)
}
if err != nil {
if *debugFile != "" {
slog.Error("reader stopped", "pid", os.Getpid(), "error", err, "at", time.Now())
}
+3
View File
@@ -36,11 +36,14 @@ func TestCLIWithoutOpeningDevices(t *testing.T) {
}{
{[]string{"--help"}, 0, "keypad 7/8/9"},
{[]string{"--help"}, 0, "(default 80)"},
{[]string{"-f", "--help"}, 0, "stay in the foreground"},
{[]string{"-f", "--version"}, 0, "fenrir " + version.Label()},
{[]string{"--version"}, 0, "fenrir " + version.Label()},
{[]string{"--unknown"}, 2, "flag provided but not defined"},
{[]string{"--speech-socket=/tmp/unused.sock"}, 2, "flag provided but not defined"},
{[]string{"unexpected"}, 2, "unexpected positional arguments"},
{[]string{"--speech-rate=101"}, 2, "presentation.speech.rate must be"},
{[]string{"-f", "--keyboard-layout", "../../config/keyboard/desktop.toml", "--punctuation-profile", "../../config/punctuation/default.toml", "--check-config"}, 0, "Settings valid"},
{[]string{"-x", "--keyboard-layout", "../../config/keyboard/desktop.toml", "--punctuation-profile", "../../config/punctuation/default.toml", "--check-config"}, 0, "Settings valid"},
{[]string{"-x", "--input-device=/dev/input/event1"}, 2, "-x does not use"},
} {
+23 -2
View File
@@ -21,6 +21,9 @@ Run these commands from the checkout root after building
\f[I]# Linux text console, with console and keyboard device access\f[R]
sudo ./fenrir
\f[I]# Linux console in the foreground (also use \-f with service managers)\f[R]
sudo ./fenrir \-f
\f[I]# X11 terminal, as your regular user\f[R]
\&./fenrir \-x
@@ -28,8 +31,19 @@ sudo ./fenrir
\&./fenrir \-x \-\-keyboard\-layout laptop
.EE
.PP
Fenrir runs in the foreground.
Press \f[B]Fenrir+Q\f[R] to quit.
Console mode starts in the background by default.
Add \f[CR]\-f\f[R] to keep it in the foreground, including under a
service manager.
The launcher returns after the reader initializes and reports startup
failures in the launching terminal.
Use \f[CR]\-\-debug\-file PATH\f[R] to retain diagnostics after
detaching.
Help, version, and configuration\-only commands always run in the
foreground.
.PP
X11 mode (\f[CR]\-x\f[R]) always stays in the foreground because it owns
the terminal and its child shell.
Press \f[B]Fenrir+Q\f[R] to quit either mode.
Run only one reader on a given Linux console.
Separate X11 terminal instances can run at once.
.PP
@@ -550,6 +564,13 @@ Purpose: Show usage and options
Purpose: Print version and release channel
.RE
.IP \(bu 2
\f[B]\-f\f[R]
.RS 2
.PP
Purpose: Keep console mode in the foreground (always enabled with
\f[CR]\-x\f[R])
.RE
.IP \(bu 2
\f[B]\-x\f[R]
.RS 2
.PP
+16 -3
View File
@@ -17,6 +17,9 @@ Run these commands from the checkout root after building `fenrir`:
# Linux text console, with console and keyboard device access
sudo ./fenrir
# Linux console in the foreground (also use -f with service managers)
sudo ./fenrir -f
# X11 terminal, as your regular user
./fenrir -x
@@ -24,9 +27,17 @@ sudo ./fenrir
./fenrir -x --keyboard-layout laptop
----
Fenrir runs in the foreground. Press *Fenrir{plus}Q* to quit. Run only
one reader on a given Linux console. Separate X11 terminal instances can
run at once.
Console mode starts in the background by default. Add `-f` to keep it in
the foreground, including under a service manager. The launcher returns
after the reader initializes and reports startup failures in the
launching terminal. Use `--debug-file PATH` to retain diagnostics after
detaching. Help, version, and configuration-only commands always run in
the foreground.
X11 mode (`-x`) always stays in the foreground because it owns the
terminal and its child shell. Press *Fenrir{plus}Q* to quit either mode.
Run only one reader on a given Linux console. Separate X11 terminal
instances can run at once.
X11 mode starts a shell in a new PTY. The launching terminal must supply
valid `DISPLAY` and `WINDOWID` environment variables. Keyboard
@@ -384,6 +395,8 @@ reference.
|`--version` |Print version and release channel
|`-f` |Keep console mode in the foreground (always enabled with `-x`)
|`-x` |Start a shell inside this X11 terminal
|`--config PATH` |Load a specific TOML settings file
+14 -2
View File
@@ -16,6 +16,9 @@ Run these commands from the checkout root after building `fenrir`:
# Linux text console, with console and keyboard device access
sudo ./fenrir
# Linux console in the foreground (also use -f with service managers)
sudo ./fenrir -f
# X11 terminal, as your regular user
./fenrir -x
@@ -23,8 +26,16 @@ sudo ./fenrir
./fenrir -x --keyboard-layout laptop
```
Fenrir runs in the foreground. Press **Fenrir+Q** to quit. Run only one reader
on a given Linux console. Separate X11 terminal instances can run at once.
Console mode starts in the background by default. Add `-f` to keep it in the
foreground, including under a service manager. The launcher returns after the
reader initializes and reports startup failures in the launching terminal.
Use `--debug-file PATH` to retain diagnostics after detaching. Help, version,
and configuration-only commands always run in the foreground.
X11 mode (`-x`) always stays in the foreground because it owns the terminal
and its child shell. Press **Fenrir+Q** to quit either mode. Run only one
reader on a given Linux console. Separate X11 terminal instances can run at
once.
X11 mode starts a shell in a new PTY. The launching terminal must supply valid
`DISPLAY` and `WINDOWID` environment variables. Keyboard interception is scoped
@@ -327,6 +338,7 @@ Fenrir runs as `fenrir [options]`. Use `--help` for the built-in reference.
| --- | --- |
| `--help` | Show usage and options |
| `--version` | Print version and release channel |
| `-f` | Keep console mode in the foreground (always enabled with `-x`) |
| `-x` | Start a shell inside this X11 terminal |
| `--config PATH` | Load a specific TOML settings file |
| `--keyboard-layout NAME_OR_PATH` | Select a named layout or TOML file |
+17 -3
View File
@@ -15,15 +15,26 @@ Run these commands from the checkout root after building fenrir:
# Linux text console, with console and keyboard device access
sudo ./fenrir
# Linux console in the foreground (also use -f with service managers)
sudo ./fenrir -f
# X11 terminal, as your regular user
./fenrir -x
# Either mode can select the laptop layout
./fenrir -x --keyboard-layout laptop
Fenrir runs in the foreground. Press Fenrir+Q to quit. Run only one
reader on a given Linux console. Separate X11 terminal instances can run
at once.
Console mode starts in the background by default. Add -f to keep it in
the foreground, including under a service manager. The launcher returns
after the reader initializes and reports startup failures in the
launching terminal. Use --debug-file PATH to retain diagnostics after
detaching. Help, version, and configuration-only commands always run in
the foreground.
X11 mode (-x) always stays in the foreground because it owns the
terminal and its child shell. Press Fenrir+Q to quit either mode. Run
only one reader on a given Linux console. Separate X11 terminal
instances can run at once.
X11 mode starts a shell in a new PTY. The launching terminal must supply
valid DISPLAY and WINDOWID environment variables. Keyboard interception
@@ -357,6 +368,9 @@ Fenrir runs as fenrir [options]. Use --help for the built-in reference.
--version Print version and release channel
-f Keep console mode in the foreground
(always enabled with -x)
-x Start a shell inside this X11
terminal
+1
View File
@@ -70,6 +70,7 @@ type SoundPlayer interface {
}
type RunOptions struct {
Ready func() error
Bindings []input.Binding
Punctuation *punctuation.Processor
Settings config.Settings
+42
View File
@@ -772,3 +772,45 @@ func TestScreenWatcherFailureReleasesKeyboards(t *testing.T) {
t.Fatal("screen watcher failure left keyboards open")
}
}
func TestStartupReadinessFollowsInitialScreenAndInput(t *testing.T) {
for _, failure := range []string{"", "screen", "input", "notification"} {
t.Run(failure, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
problem := errors.New("startup fixture failure")
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1}}
keys := &fakeKeys{}
read, polled, ready := false, false, false
source.afterRead = func() { read = true }
if failure == "screen" {
source.readErr = problem
}
keys.poll = func(bool, func(input.Decision)) error {
polled = true
if failure == "input" {
return problem
}
return nil
}
opts := RunOptions{Settings: config.Defaults(), Ready: func() error {
if !read || !polled {
t.Fatal("readiness preceded initial screen or input")
}
ready = true
cancel()
if failure == "notification" {
return problem
}
return nil
}}
err := run(ctx, source, keys, &fakeVoice{}, nil, opts)
if failure != "" && !errors.Is(err, problem) || failure == "" && err != nil {
t.Fatal("unexpected startup result", err)
}
if ready != (failure == "" || failure == "notification") || !keys.closed {
t.Fatal("startup failure reported readiness or left input open", ready, keys.closed)
}
})
}
}
+5
View File
@@ -97,6 +97,11 @@ func (a *application) loop(ticks <-chan time.Time) error {
if err := a.poll_input(true); err != nil {
return err
}
if a.options.Ready != nil {
if err := a.options.Ready(); err != nil {
return err
}
}
for {
if a.quitting {
return nil
+10 -4
View File
@@ -53,15 +53,21 @@ func (disabledVoice) Errors() <-chan error { return nil }
func (disabledVoice) Close() {}
func Start(ctx context.Context, settings config.Settings, configPath string, bindings []input.Binding) error {
return start(ctx, settings, false, configPath, bindings)
return Start_with_ready(ctx, settings, configPath, bindings, nil)
}
// Start_with_ready reports console startup after the first screen and input poll.
// A failed notification tears down the reader and releases its keyboards.
func Start_with_ready(ctx context.Context, settings config.Settings, configPath string, bindings []input.Binding, ready func() error) error {
return start(ctx, settings, false, configPath, bindings, ready)
}
// Start_terminal runs the same reader and command dispatcher in an X11 PTY.
func Start_terminal(ctx context.Context, settings config.Settings, configPath string, bindings []input.Binding) error {
return start(ctx, settings, true, configPath, bindings)
return start(ctx, settings, true, configPath, bindings, nil)
}
func start(ctx context.Context, settings config.Settings, terminalMode bool, configPath string, bindings []input.Binding) (result error) {
func start(ctx context.Context, settings config.Settings, terminalMode bool, configPath string, bindings []input.Binding, ready func() error) (result error) {
if err := settings.Validate(); err != nil {
return err
}
@@ -168,7 +174,7 @@ func start(ctx context.Context, settings config.Settings, terminalMode bool, con
}
transfers := clipboard.New_worker(ctx, paste)
defer transfers.Close()
result = Run(ctx, source, keyboards, voices, RunOptions{Spellcheck: spellChecker, Bindings: bindings, Settings: settings, EffectiveSpeech: effectiveSpeech, Punctuation: punctuationProcessor, Sounds: cues, ConfigPath: configPath, Clipboard: transfers, ClipboardHistory: historyStore, SettingsChoices: preferences.Discover_choices, Scripts: scriptCommands, ScriptRunner: scriptRunner, Remote: remote, TerminalMode: terminalMode})
result = Run(ctx, source, keyboards, voices, RunOptions{Ready: ready, Spellcheck: spellChecker, Bindings: bindings, Settings: settings, EffectiveSpeech: effectiveSpeech, Punctuation: punctuationProcessor, Sounds: cues, ConfigPath: configPath, Clipboard: transfers, ClipboardHistory: historyStore, SettingsChoices: preferences.Discover_choices, Scripts: scriptCommands, ScriptRunner: scriptRunner, Remote: remote, TerminalMode: terminalMode})
if result == nil && player != nil {
if err := player.Finish(context.Background()); err != nil {
slog.Warn("shutdown sound incomplete", "error", err, "at", time.Now())