More fixes for fenrir's self-voice and notification system. Make sure proper sockets can be accessed.

This commit is contained in:
Storm Dragon committed 2026-11-04 22:51:18 -05:00
1 parent c4e9cb4b75
commit 69b94d14c6
9 files changed
+514 -16

No files matched your search

+8 -4
View File
@@ -330,8 +330,11 @@ echo "ls" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-daemon.sock
``` ```
The standard socket routes reader descendants to their ancestor reader. For The standard socket routes reader descendants to their ancestor reader. For
reattached terminal sessions or detached helpers, it matches the caller's GNU Screen sessions, it follows the server's current display terminals and
`WINDOWID` and `DISPLAY` to a unique terminal reader owned by the same user. their reader ancestry, so reattachment does not depend on Screen's inherited
`WINDOWID`. Detached sessions and sessions attached to multiple readers return
a routing error. For other detached helpers, it matches the caller's `WINDOWID`
and `DISPLAY` to a unique terminal reader owned by the same user.
Requests from a Linux virtual console reach the console reader, subject to Requests from a Linux virtual console reach the console reader, subject to
its usual authorization. Missing or ambiguous identity produces a routing its usual authorization. Missing or ambiguous identity produces a routing
error; a sole unrelated reader is never selected as a default. Instance error; a sole unrelated reader is never selected as a default. Instance
@@ -362,8 +365,9 @@ and Escape or the Close button to return to the application. Reopen the list
to include messages received while it was open. to include messages received while it was open.
Detached notifiers must identify the intended reader, using its private socket Detached notifiers must identify the intended reader, using its private socket
when needed. Check socket replies for routing errors; changing `say` to `notify` when needed. Check socket replies for routing errors. GNU Screen callers use
does not repair an outdated `WINDOWID` in a long-running notifier. their current attachment; other long-running notifiers must keep their terminal
identity current. Both `say` and `notify` use these routing rules.
`command application ID` selects an override for the reader's active terminal, `command application ID` selects an override for the reader's active terminal,
like Fenrir+A. IDs are `default`, `networkmanager`, `ninjam`, `rogue`, and `codex`. like Fenrir+A. IDs are `default`, `networkmanager`, `ninjam`, `rogue`, and `codex`.
+11 -4
View File
@@ -1006,10 +1006,17 @@ self\-voicing also allows an account logged into the active console.
.PP .PP
Notifications use the same routing and command authorization as Notifications use the same routing and command authorization as
self\-voicing. self\-voicing.
A detached notifier with an outdated \f[CR]WINDOWID\f[R] may fail to For GNU Screen callers, Fenrir finds the session\(cqs current attached
identify a reader. reader instead of relying on the server\(cqs original
Target the intended private socket in that case and check returned \f[CR]WINDOWID\f[R].
errors; successful delivery returns an empty reply. Detached Screen sessions and sessions attached to multiple readers
return a routing error.
This requires the updated Fenrir binary in the instance owning the
shared socket too.
Other detached notifiers with outdated terminal identities may still
fail to identify a reader.
Target the intended private socket and check returned errors; successful
delivery returns an empty reply.
Do not select an arbitrary socket when several readers are running. Do not select an arbitrary socket when several readers are running.
.SS Command\-line options .SS Command\-line options
Fenrir runs as \f[CR]fenrir [options]\f[R]. Fenrir runs as \f[CR]fenrir [options]\f[R].
+6 -2
View File
@@ -707,8 +707,12 @@ requests have separate switches, `enable_command_remote` and
console self-voicing also allows an account logged into the active console. console self-voicing also allows an account logged into the active console.
Notifications use the same routing and command authorization as self-voicing. Notifications use the same routing and command authorization as self-voicing.
A detached notifier with an outdated `WINDOWID` may fail to identify a reader. For GNU Screen callers, Fenrir finds the session's current attached reader
Target the intended private socket in that case and check returned errors; instead of relying on the server's original `WINDOWID`. Detached Screen sessions
and sessions attached to multiple readers return a routing error. This requires
the updated Fenrir binary in the instance owning the shared socket too.
Other detached notifiers with outdated terminal identities may still fail to
identify a reader. Target the intended private socket and check returned errors;
successful delivery returns an empty reply. Do not select an arbitrary socket successful delivery returns an empty reply. Do not select an arbitrary socket
when several readers are running. when several readers are running.
+9 -4
View File
@@ -731,10 +731,15 @@ account; console self-voicing also allows an account logged into the
active console. active console.
Notifications use the same routing and command authorization as Notifications use the same routing and command authorization as
self-voicing. A detached notifier with an outdated WINDOWID may fail to self-voicing. For GNU Screen callers, Fenrir finds the session’s current
identify a reader. Target the intended private socket in that case and attached reader instead of relying on the server’s original WINDOWID.
check returned errors; successful delivery returns an empty reply. Do Detached Screen sessions and sessions attached to multiple readers
not select an arbitrary socket when several readers are running. return a routing error. This requires the updated Fenrir binary in the
instance owning the shared socket too. Other detached notifiers with
outdated terminal identities may still fail to identify a reader. Target
the intended private socket and check returned errors; successful
delivery returns an empty reply. Do not select an arbitrary socket when
several readers are running.
Command-line options Command-line options
+18 -2
View File
@@ -74,19 +74,35 @@ func (s *Server) route_target(pid, uid int) (string, error) {
} }
} }
seen := map[int]bool{} seen := map[int]bool{}
var ancestors []int
var ancestorReader *instance
ancestry:
for ancestor := pid; ancestor > 1 && !seen[ancestor]; ancestor = parent_pid(ancestor) { for ancestor := pid; ancestor > 1 && !seen[ancestor]; ancestor = parent_pid(ancestor) {
seen[ancestor] = true seen[ancestor] = true
ancestors = append(ancestors, ancestor)
for _, data := range terminals { for _, data := range terminals {
if data.PID == ancestor { if data.PID == ancestor {
return s.destination(data) ancestorReader = &data
break ancestry
} }
} }
for _, data := range consoles { for _, data := range consoles {
if data.PID == ancestor { if data.PID == ancestor {
return s.destination(data) ancestorReader = &data
break ancestry
} }
} }
} }
readers := append(slices.Clone(terminals), consoles...)
if reader, handled, err := attached_screen_reader("/proc", ancestors, uid, readers); handled {
if err != nil {
return "", err
}
return s.destination(reader)
}
if ancestorReader != nil {
return s.destination(*ancestorReader)
}
identity := process_identity(pid, uid) identity := process_identity(pid, uid)
return s.route_identity(identity, uid, terminals, consoles) return s.route_identity(identity, uid, terminals, consoles)
} }
+139
View File
@@ -0,0 +1,139 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package control
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
)
// A Screen server keeps its original environment after reattachment. Locate
// its current display processes through the server's open terminal devices,
// then use their reader ancestry. No Screen commands or init services are used.
// procRoot is explicit so device, ownership and ambiguity cases can be tested.
func attached_screen_reader(procRoot string, ancestors []int, uid int, readers []instance) (instance, bool, error) {
for _, pid := range ancestors {
processPath := filepath.Join(procRoot, strconv.Itoa(pid))
info, err := os.Stat(processPath)
if err != nil || !owned_by(info, uid) {
continue
}
command, err := os.ReadFile(filepath.Join(processPath, "cmdline"))
if err != nil || filepath.Base(strings.TrimSpace(string(bytes.SplitN(command, []byte{0}, 2)[0]))) != "SCREEN" {
continue
}
executablePath, err := os.Readlink(filepath.Join(processPath, "exe"))
name := strings.TrimSuffix(filepath.Base(executablePath), " (deleted)")
if err != nil || (name != "screen" && !strings.HasPrefix(name, "screen-")) {
continue
}
executable, err := os.Stat(filepath.Join(processPath, "exe"))
if err != nil {
continue
}
reader, err := screen_display_reader(procRoot, processPath, uid, executable, readers)
return reader, true, err
}
return instance{}, false, nil
}
func owned_by(info os.FileInfo, uid int) bool {
stat, ok := info.Sys().(*syscall.Stat_t)
return ok && int(stat.Uid) == uid
}
func screen_display_reader(procRoot, serverPath string, uid int, executable os.FileInfo, readers []instance) (instance, error) {
fdPath := filepath.Join(serverPath, "fd")
files, err := os.ReadDir(fdPath)
if err != nil {
return instance{}, fmt.Errorf("cannot inspect Screen attachments: %w", err)
}
displays := map[uint32]bool{}
for _, file := range files {
path := filepath.Join(fdPath, file.Name())
info, err := os.Stat(path)
if err != nil || info.Mode()&os.ModeCharDevice == 0 {
continue
}
// /proc/PID/stat encodes tty_nr as a signed 32-bit device number.
stat, ok := info.Sys().(*syscall.Stat_t)
if ok && stat.Rdev != 0 {
displays[uint32(stat.Rdev)] = true
}
}
processes, err := os.ReadDir(procRoot)
if err != nil {
return instance{}, fmt.Errorf("cannot inspect Screen display processes: %w", err)
}
matches := map[int]instance{}
for _, process := range processes {
pid, err := strconv.Atoi(process.Name())
if err != nil || pid <= 1 {
continue
}
path := filepath.Join(procRoot, process.Name())
info, err := os.Stat(path)
if err != nil || !owned_by(info, uid) {
continue
}
data, err := os.ReadFile(filepath.Join(path, "stat"))
if err != nil {
continue
}
_, tty := process_parent_tty(data)
if tty == 0 || !displays[tty] {
continue
}
info, err = os.Stat(filepath.Join(path, "exe"))
if err != nil || !os.SameFile(info, executable) {
continue
}
// Display clients and the server run the same Screen executable.
// The daemon has no controlling terminal; display clients have one.
seen := map[int]bool{}
for ancestor := pid; ancestor > 1 && !seen[ancestor]; {
seen[ancestor] = true
for _, reader := range readers {
if reader.PID == ancestor {
matches[reader.PID] = reader
ancestor = 0
break
}
}
if ancestor == 0 {
break
}
data, err := os.ReadFile(filepath.Join(procRoot, strconv.Itoa(ancestor), "stat"))
if err != nil {
break
}
ancestor, _ = process_parent_tty(data)
}
}
if len(matches) == 1 {
for _, reader := range matches {
return reader, nil
}
}
return instance{}, fmt.Errorf("Screen session does not identify a unique attached reader; use its private socket")
}
func process_parent_tty(data []byte) (int, uint32) {
end := bytes.LastIndexByte(data, ')')
if end < 0 {
return 0, 0
}
fields := strings.Fields(string(data[end+1:]))
if len(fields) < 5 {
return 0, 0
}
parent, _ := strconv.Atoi(fields[1])
tty, _ := strconv.ParseInt(fields[4], 10, 64)
return parent, uint32(tty)
}
+187
View File
@@ -0,0 +1,187 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package control
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
"unsafe"
)
// The caller lives inside a private, real Screen session. A separate test
// socket coordinates requests without typing into any terminal.
func TestScreenCallerProcess(t *testing.T) {
path := os.Getenv("FENRIR_SCREEN_TEST_COORDINATOR")
if path == "" {
return
}
listener, err := net.Listen("unix", path)
if err != nil {
os.Exit(1)
}
defer listener.Close()
for {
connection, err := listener.Accept()
if err != nil {
os.Exit(1)
}
var request processCommand
if json.NewDecoder(connection).Decode(&request) != nil {
connection.Close()
continue
}
reply := processReply{PID: os.Getpid()}
target, err := net.DialTimeout("unix", request.Path, time.Second)
if err == nil {
target.SetDeadline(time.Now().Add(2 * time.Second))
_, err = io.WriteString(target, request.Text+"\n")
if err == nil {
var data []byte
data, err = io.ReadAll(target)
reply.Response = string(data)
}
target.Close()
}
if err != nil {
reply.Error = err.Error()
}
json.NewEncoder(connection).Encode(reply)
connection.Close()
}
}
func TestNativeScreenReattachmentRoutesSayAndNotify(t *testing.T) {
screen, err := exec.LookPath("screen")
if err != nil {
t.Skip("GNU Screen not installed")
}
options := private_options(t)
// The shared owner and an unrelated old-window reader must never receive
// messages intended for the terminal displaying the reattached session.
t.Setenv("DISPLAY", ":fixture")
t.Setenv("WINDOWID", "old")
start_reader_process(t, options.Directory, "tty")
start_reader_process(t, options.Directory, "terminal")
options.TerminalMode, options.WindowID = true, "current"
server, err := Open(context.Background(), options)
if err != nil {
t.Fatal(err)
}
defer server.Close()
go func() {
for {
select {
case request := <-server.Requests():
data, _ := json.Marshal(requestReceipt{os.Getpid(), request.PID, request.UID, request.Text})
request.Reply <- string(data)
case <-server.ctx.Done():
return
}
}
}()
screenDirectory := filepath.Join(options.Directory, "screen")
if err := os.Mkdir(screenDirectory, 0700); err != nil {
t.Fatal(err)
}
coordinator := filepath.Join(options.Directory, "caller.sock")
session := fmt.Sprintf("fenrir-test-%d", os.Getpid())
environment := append(os.Environ(), "SCREENDIR="+screenDirectory, "TERM=xterm", "STY=", "FENRIR_SCREEN_TEST_COORDINATOR="+coordinator)
start := exec.Command(screen, "-dmS", session, "-c", "/dev/null", os.Args[0], "-test.run=^TestScreenCallerProcess$")
start.Env = environment
if output, err := start.CombinedOutput(); err != nil {
t.Fatal(string(output), err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
stop := exec.CommandContext(ctx, screen, "-S", session, "-X", "quit")
stop.Env = environment
_ = stop.Run()
})
send := func(text string) processReply {
t.Helper()
connection, err := net.DialTimeout("unix", coordinator, time.Second)
if err != nil {
t.Fatal(err)
}
defer connection.Close()
connection.SetDeadline(time.Now().Add(3 * time.Second))
if err := json.NewEncoder(connection).Encode(processCommand{Path: server.main_path(), Text: text}); err != nil {
t.Fatal(err)
}
var reply processReply
if err := json.NewDecoder(connection).Decode(&reply); err != nil || reply.Error != "" {
t.Fatal(reply, err)
}
return reply
}
deadline := time.Now().Add(3 * time.Second)
for {
if _, err := os.Stat(coordinator); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("private Screen caller did not start")
}
time.Sleep(10 * time.Millisecond)
}
if reply := send("COMMAND SAY detached"); !strings.Contains(reply.Response, "unique attached reader") {
for pid := reply.PID; pid > 1; pid = parent_pid(pid) {
command, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
executable, _ := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
t.Logf("ancestor %d command %q executable %q", pid, command, executable)
}
t.Fatal("detached Screen used the old window", reply)
}
fd, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_NONBLOCK|syscall.O_CLOEXEC, 0)
if err != nil {
t.Fatal(err)
}
master := os.NewFile(uintptr(fd), "private Screen display")
defer master.Close()
var unlock int32
if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&unlock))); err != 0 {
t.Fatal(err)
}
var number uint32
if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&number))); err != 0 {
t.Fatal(err)
}
slave, err := os.OpenFile(fmt.Sprintf("/dev/pts/%d", number), os.O_RDWR|syscall.O_NOCTTY, 0)
if err != nil {
t.Fatal(err)
}
defer slave.Close()
attach := exec.Command(screen, "-r", session)
attach.Env = append(environment, "WINDOWID=current")
attach.Stdin, attach.Stdout, attach.Stderr = slave, slave, slave
attach.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0}
if err := attach.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = attach.Process.Kill(); _ = attach.Wait() })
deadline = time.Now().Add(3 * time.Second)
for {
reply := send("COMMAND SAY attached")
if !strings.HasPrefix(reply.Response, "Remote routing failed:") {
check_receipt(t, reply, os.Getpid(), "COMMAND SAY attached")
break
}
if time.Now().After(deadline) {
t.Fatal("reattached Screen did not find its reader", reply)
}
time.Sleep(10 * time.Millisecond)
}
check_receipt(t, send("COMMAND NOTIFY notification"), os.Getpid(), "COMMAND NOTIFY notification")
}
+134
View File
@@ -0,0 +1,134 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package control
import (
"fmt"
"os"
"path/filepath"
"strconv"
"syscall"
"testing"
)
type screenFixture struct {
root, executable string
t *testing.T
}
func new_screen_fixture(t *testing.T) screenFixture {
t.Helper()
f := screenFixture{root: t.TempDir(), t: t}
f.executable = filepath.Join(f.root, "screen")
if err := os.WriteFile(f.executable, nil, 0700); err != nil {
t.Fatal(err)
}
f.process(100, 1, 0, "/usr/bin/SCREEN", f.executable)
f.process(120, 100, 0, "notifier", "")
f.process(300, 1, 0, "reader", "")
return f
}
func (f screenFixture) process(pid, parent int, tty uint32, command, executable string) {
f.t.Helper()
path := filepath.Join(f.root, strconv.Itoa(pid))
if err := os.MkdirAll(filepath.Join(path, "fd"), 0700); err != nil {
f.t.Fatal(err)
}
// A parenthesized name checks the /proc/stat field boundary too.
stat := fmt.Sprintf("%d (process (name)) S %d %d %d %d 0 0", pid, parent, pid, pid, int32(tty))
for name, value := range map[string]string{"stat": stat, "cmdline": command + "\x00"} {
if err := os.WriteFile(filepath.Join(path, name), []byte(value), 0600); err != nil {
f.t.Fatal(err)
}
}
if executable != "" {
if err := os.Symlink(executable, filepath.Join(path, "exe")); err != nil {
f.t.Fatal(err)
}
}
}
func (f screenFixture) attach(pid, reader int, device string, sameExecutable bool) {
f.t.Helper()
info, err := os.Stat(device)
if err != nil {
f.t.Fatal(err)
}
executable := f.executable
if !sameExecutable {
executable = filepath.Join(f.root, "other")
if err := os.WriteFile(executable, nil, 0700); err != nil {
f.t.Fatal(err)
}
}
f.process(pid, reader, uint32(info.Sys().(*syscall.Stat_t).Rdev), "screen", executable)
if err := os.Symlink(device, filepath.Join(f.root, "100", "fd", strconv.Itoa(pid))); err != nil {
f.t.Fatal(err)
}
}
func TestScreenAttachmentRouting(t *testing.T) {
for _, scenario := range []string{"attached", "detached", "other executable", "ambiguous", "duplicate displays", "reattached", "no reader", "wrong uid"} {
t.Run(scenario, func(t *testing.T) {
f := new_screen_fixture(t)
uid := os.Geteuid()
readers := []instance{{PID: 300}, {PID: 400}}
if scenario != "detached" {
f.attach(200, 300, "/dev/null", scenario != "other executable")
}
if scenario == "ambiguous" {
f.process(400, 1, 0, "reader", "")
f.attach(201, 400, "/dev/zero", true)
}
if scenario == "duplicate displays" {
f.attach(201, 300, "/dev/zero", true)
}
if scenario == "no reader" {
readers = nil
}
if scenario == "wrong uid" {
uid++
}
if scenario == "reattached" {
if err := os.Remove(filepath.Join(f.root, "100", "fd", "200")); err != nil {
t.Fatal(err)
}
f.process(400, 1, 0, "reader", "")
f.attach(201, 400, "/dev/zero", true)
}
reader, handled, err := attached_screen_reader(f.root, []int{120, 100}, uid, readers)
valid := scenario == "attached" || scenario == "duplicate displays" || scenario == "reattached"
if handled != (scenario != "wrong uid") || (err == nil && handled) != valid {
t.Fatal(reader, handled, err)
}
if valid {
want := 300
if scenario == "reattached" {
want = 400
}
if reader.PID != want {
t.Fatal("wrong reader", reader.PID, want)
}
}
})
}
}
func TestScreenRoutingIgnoresEnvironmentWithoutServerAncestor(t *testing.T) {
f := new_screen_fixture(t)
f.attach(200, 300, "/dev/null", true)
t.Setenv("STY", "100.session")
if _, handled, err := attached_screen_reader(f.root, []int{120}, os.Geteuid(), []instance{{PID: 300}}); handled || err != nil {
t.Fatal("environment selected an unrelated Screen session", handled, err)
}
// A broken ancestry cycle must terminate without choosing an unrelated reader.
f.process(301, 302, 0, "cycle", "")
f.process(302, 301, 0, "cycle", "")
f.attach(201, 301, "/dev/zero", true)
reader, handled, err := attached_screen_reader(f.root, []int{120, 100}, os.Geteuid(), []instance{{PID: 300}})
if !handled || err != nil || reader.PID != 300 {
t.Fatal(reader, handled, err)
}
}
+2
View File
@@ -25,6 +25,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
@@ -295,6 +296,7 @@ func (s *Server) handle(connection *net.UnixConn, path string) {
return return
} }
if routingErr != nil { if routingErr != nil {
slog.Debug("remote routing failed", "pid", pid, "uid", uid, "error", routingErr, "at", time.Now())
_ = connection.SetWriteDeadline(time.Now().Add(250 * time.Millisecond)) _ = connection.SetWriteDeadline(time.Now().Add(250 * time.Millisecond))
_, _ = io.WriteString(connection, "Remote routing failed: "+routingErr.Error()+"\n") _, _ = io.WriteString(connection, "Remote routing failed: "+routingErr.Error()+"\n")
return return