Files
fenrir/internal/control/coexistence_test.go

419 lines
14 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package control
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
type processCommand struct {
Path, Text string
Stop bool
}
type processReply struct {
PID int
Response string
Error string
}
type requestReceipt struct {
InstancePID, CallerPID, CallerUID int
Text string
}
// Each fixture reader runs in a separate process so PID endpoints, ancestry,
// registry ownership and descriptor credentials are exercised through the OS.
func TestControlProcess(t *testing.T) {
mode := os.Getenv("FENRIR_CONTROL_PROCESS")
if mode == "" {
return
}
if mode == "client" {
reply := processReply{PID: os.Getpid()}
connection, err := net.DialTimeout("unix", os.Getenv("FENRIR_CONTROL_PATH"), time.Second)
if err == nil {
defer connection.Close()
_ = connection.SetDeadline(time.Now().Add(3 * time.Second))
_, err = io.WriteString(connection, os.Getenv("FENRIR_CONTROL_TEXT")+"\n")
if err == nil {
data, readErr := io.ReadAll(connection)
reply.Response, err = string(data), readErr
}
}
if err != nil {
reply.Error = err.Error()
}
_ = json.NewEncoder(os.Stdout).Encode(reply)
os.Exit(0)
}
run_reader_process(mode)
os.Exit(0)
}
func child_command(mode string) *exec.Cmd {
command := exec.Command(os.Args[0], "-test.run=^TestControlProcess$")
command.Env = append(os.Environ(), "FENRIR_CONTROL_PROCESS="+mode)
return command
}
func run_reader_process(mode string) {
encoder := json.NewEncoder(os.Stdout)
directory := os.Getenv("FENRIR_CONTROL_DIRECTORY")
server, err := Open(context.Background(), Options{Directory: directory, RegistryDirectory: filepath.Join(directory, "registry"), TerminalMode: mode == "terminal", WindowID: os.Getenv("WINDOWID")})
if err != nil {
_ = encoder.Encode(processReply{Error: err.Error()})
return
}
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
}
}
}()
_ = encoder.Encode(processReply{PID: os.Getpid()})
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
var request processCommand
if json.Unmarshal(scanner.Bytes(), &request) != nil || request.Stop {
return
}
command := child_command("client")
command.Env = append(command.Env, "FENRIR_CONTROL_PATH="+request.Path, "FENRIR_CONTROL_TEXT="+request.Text)
data, err := command.Output()
var reply processReply
if err != nil {
reply.Error = err.Error()
} else if err := json.Unmarshal(data, &reply); err != nil {
reply.Error = err.Error()
}
_ = encoder.Encode(reply)
}
}
type readerProcess struct {
command *exec.Cmd
input *json.Encoder
output *json.Decoder
pid int
stopped bool
}
func start_reader_process(t *testing.T, directory, mode string) *readerProcess {
t.Helper()
command := child_command(mode)
command.Env = append(command.Env, "FENRIR_CONTROL_DIRECTORY="+directory)
command.Stderr = os.Stderr
input, err := command.StdinPipe()
if err != nil {
t.Fatal(err)
}
output, err := command.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := command.Start(); err != nil {
t.Fatal(err)
}
process := &readerProcess{command: command, input: json.NewEncoder(input), output: json.NewDecoder(output)}
t.Cleanup(func() { process.stop(t) })
var ready processReply
if err := process.output.Decode(&ready); err != nil || ready.Error != "" {
t.Fatal("reader startup", ready, err)
}
process.pid = ready.PID
return process
}
func (p *readerProcess) stop(t *testing.T) {
t.Helper()
if p.stopped {
return
}
p.stopped = true
_ = p.input.Encode(processCommand{Stop: true})
done := make(chan error, 1)
go func() { done <- p.command.Wait() }()
select {
case err := <-done:
if err != nil {
t.Error("reader exit", err)
}
case <-time.After(5 * time.Second):
_ = p.command.Process.Kill()
<-done
t.Error("reader shutdown blocked")
}
}
func (p *readerProcess) send(t *testing.T, path, text string) processReply {
t.Helper()
if err := p.input.Encode(processCommand{Path: path, Text: text}); err != nil {
t.Fatal(err)
}
var reply processReply
if err := p.output.Decode(&reply); err != nil || reply.Error != "" {
t.Fatal("remote request", reply, err)
}
return reply
}
func check_receipt(t *testing.T, reply processReply, instancePID int, text string) {
t.Helper()
var receipt requestReceipt
if err := json.Unmarshal([]byte(reply.Response), &receipt); err != nil {
t.Fatal("invalid remote reply", reply, err)
}
if receipt.InstancePID != instancePID || receipt.CallerPID != reply.PID || receipt.CallerUID != os.Geteuid() || receipt.Text != text {
t.Fatalf("receipt=%+v; want instance=%d caller=%d uid=%d text=%q", receipt, instancePID, reply.PID, os.Geteuid(), text)
}
}
func wait_main_owner(t *testing.T, observer *Server, expectedCount int, excludedPID int) int {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
instances := observer.instances()
owners, owner := 0, 0
for _, data := range instances {
if data.MainSocket {
owners++
owner = data.PID
}
}
if len(instances) == expectedCount && owners == 1 && owner != excludedPID {
return owner
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("shared endpoint ownership did not settle", observer.instances())
return 0
}
func TestReadersCoexistInBothStartupOrdersAndSurviveOwnerExit(t *testing.T) {
for _, order := range []string{"tty-first", "terminal-first"} {
t.Run(order, func(t *testing.T) {
options := private_options(t)
observer := &Server{ctx: context.Background(), options: options}
mainPath := observer.main_path()
var tty, first, second, owner *readerProcess
if order == "tty-first" {
tty = start_reader_process(t, options.Directory, "tty")
owner = tty
first = start_reader_process(t, options.Directory, "terminal")
} else {
first = start_reader_process(t, options.Directory, "terminal")
owner = first
tty = start_reader_process(t, options.Directory, "tty")
}
second = start_reader_process(t, options.Directory, "terminal")
if got := wait_main_owner(t, observer, 3, 0); got != owner.pid {
t.Fatal("startup replaced the shared owner", got, owner.pid)
}
listing := first.send(t, mainPath, "COMMAND LS").Response
for _, reader := range []*readerProcess{tty, first, second} {
if !strings.Contains(listing, fmt.Sprintf("pid=%d ", reader.pid)) {
t.Fatal("missing reader", reader.pid, listing)
}
}
for _, reader := range []*readerProcess{tty, first, second} {
check_receipt(t, reader.send(t, mainPath, "COMMAND SAY shared"), reader.pid, "COMMAND SAY shared")
privatePath := filepath.Join(options.Directory, fmt.Sprintf("fenrirscreenreader-%d.sock", reader.pid))
check_receipt(t, second.send(t, privatePath, "COMMAND SAY private"), reader.pid, "COMMAND SAY private")
}
// An unrelated caller must not fall through to the console reader.
client := child_command("client")
client.Env = append(client.Env, "FENRIR_CONTROL_PATH="+mainPath, "FENRIR_CONTROL_TEXT=COMMAND SAY legacy")
data, err := client.Output()
if err != nil {
t.Fatal(err)
}
var reply processReply
if err := json.Unmarshal(data, &reply); err != nil {
t.Fatal(err)
}
if !strings.Contains(reply.Response, "unique reader") {
t.Fatal("unidentified caller reached a reader", reply)
}
owner.stop(t)
wait_main_owner(t, observer, 2, owner.pid)
for _, reader := range []*readerProcess{tty, first, second} {
if reader != owner {
check_receipt(t, reader.send(t, mainPath, "COMMAND SAY survivor"), reader.pid, "COMMAND SAY survivor")
}
}
})
}
}
func external_request(t *testing.T, path, text string) processReply {
t.Helper()
client := child_command("client")
client.Env = append(client.Env, "FENRIR_CONTROL_PATH="+path, "FENRIR_CONTROL_TEXT="+text)
data, err := client.Output()
if err != nil {
t.Fatal(err)
}
var reply processReply
if err := json.Unmarshal(data, &reply); err != nil || reply.Error != "" {
t.Fatal(reply, err)
}
return reply
}
func TestTerminalReadersRejectAmbiguityAndRecoverAfterOwnerFailure(t *testing.T) {
options := private_options(t)
observer := &Server{ctx: context.Background(), options: options}
mainPath := observer.main_path()
first := start_reader_process(t, options.Directory, "terminal")
second := start_reader_process(t, options.Directory, "terminal")
third := start_reader_process(t, options.Directory, "terminal")
wait_main_owner(t, observer, 3, 0)
for _, reader := range []*readerProcess{first, second, third} {
check_receipt(t, reader.send(t, mainPath, "COMMAND SAY terminal"), reader.pid, "COMMAND SAY terminal")
}
if reply := external_request(t, mainPath, "COMMAND SAY ambiguous"); !strings.Contains(reply.Response, "unique reader") {
t.Fatal("arbitrary terminal selected", reply)
}
if reply := external_request(t, mainPath, "LIST"); strings.Count(reply.Response, "pid=") != 6 {
// Each record contains both pid= and ppid=.
t.Fatal("ambiguous caller could not discover instances", reply)
}
// Registration failure after binding a new private socket must preserve
// the active shared endpoint and every existing registry record.
before, err := os.Stat(mainPath)
if err != nil {
t.Fatal(err)
}
broken := options
broken.RegistryDirectory = filepath.Join(options.Directory, "registry-file")
if err := os.WriteFile(broken.RegistryDirectory, []byte("preserve"), 0600); err != nil {
t.Fatal(err)
}
if server, err := Open(context.Background(), broken); err == nil {
server.Close()
t.Fatal("accepted a registry file")
}
privatePath := filepath.Join(options.Directory, fmt.Sprintf("fenrirscreenreader-%d.sock", os.Getpid()))
if _, err := os.Lstat(privatePath); !os.IsNotExist(err) {
t.Fatal("failed startup leaked a private socket", err)
}
after, err := os.Stat(mainPath)
if err != nil || !os.SameFile(before, after) || len(observer.instances()) != 3 {
t.Fatal("failed startup disturbed active endpoints", err)
}
first.stopped = true
if err := first.command.Process.Kill(); err != nil {
t.Fatal(err)
}
_ = first.command.Wait()
wait_main_owner(t, observer, 2, first.pid)
for _, reader := range []*readerProcess{second, third} {
check_receipt(t, reader.send(t, mainPath, "COMMAND SAY recovered"), reader.pid, "COMMAND SAY recovered")
}
}
// Screen reattachment and Codex's app-server can leave the caller outside the
// reader's descendant tree. The connecting process retains its X11 identity.
func TestWindowRoutingWithoutReaderAncestor(t *testing.T) {
for _, consoleFirst := range []bool{true, false} {
name := "terminal-first"
if consoleFirst {
name = "console-first"
}
t.Run(name, func(t *testing.T) {
options := private_options(t)
observer := &Server{ctx: context.Background(), options: options}
mainPath := observer.main_path()
t.Setenv("DISPLAY", ":fixture")
t.Setenv("WINDOWID", "42")
var terminal *readerProcess
if consoleFirst {
start_reader_process(t, options.Directory, "tty")
}
terminal = start_reader_process(t, options.Directory, "terminal")
if !consoleFirst {
start_reader_process(t, options.Directory, "tty")
}
// This client is a sibling, not a child, of the terminal reader.
text := "command application codex"
check_receipt(t, external_request(t, mainPath, text), terminal.pid, text)
t.Setenv("WINDOWID", "43")
second := start_reader_process(t, options.Directory, "terminal")
check_receipt(t, external_request(t, mainPath, text), second.pid, text)
t.Setenv("WINDOWID", "42")
check_receipt(t, external_request(t, mainPath, text), terminal.pid, text)
// Even a sole terminal on a different display is not a match.
t.Setenv("DISPLAY", ":other")
if reply := external_request(t, mainPath, text); !strings.Contains(reply.Response, "unique reader") {
t.Fatal("cross-display routing", reply)
}
t.Setenv("DISPLAY", ":fixture")
t.Setenv("WINDOWID", "unknown")
if reply := external_request(t, mainPath, text); !strings.Contains(reply.Response, "unique reader") {
t.Fatal("unknown window routed", reply)
}
t.Setenv("WINDOWID", "")
if reply := external_request(t, mainPath, text); !strings.Contains(reply.Response, "unique reader") {
t.Fatal("unidentified background caller routed", reply)
}
t.Setenv("WINDOWID", "42")
start_reader_process(t, options.Directory, "terminal")
if reply := external_request(t, mainPath, text); !strings.Contains(reply.Response, "unique reader") {
t.Fatal("duplicate window routed arbitrarily", reply)
}
// Discovery remains usable when routing is ambiguous.
if reply := external_request(t, mainPath, "ls"); !strings.Contains(reply.Response, "pid=") {
t.Fatal("discovery failed", reply)
}
})
}
}
func TestConsoleIdentityAndUnknownCallers(t *testing.T) {
options := private_options(t)
console := instance{PID: os.Getpid(), uid: os.Geteuid(), ScreenDriver: "vcsaDriver"}
server := &Server{ctx: context.Background(), options: options}
for _, trial := range []struct {
name string
identity processIdentity
consoles []instance
valid bool
}{
{"console", processIdentity{console: true}, []instance{console}, true},
{"console ignores stale window", processIdentity{console: true, window: "42", display: ":fixture"}, []instance{console}, true},
{"multiple consoles", processIdentity{console: true}, []instance{console, console}, false},
{"no console", processIdentity{console: true}, nil, false},
{"background", processIdentity{}, []instance{console}, false},
{"unknown window", processIdentity{window: "42", display: ":fixture"}, []instance{console}, false},
} {
t.Run(trial.name, func(t *testing.T) {
_, err := server.route_identity(trial.identity, os.Geteuid(), nil, trial.consoles)
if (err == nil) != trial.valid {
t.Fatal("unexpected routing", err)
}
})
}
if got := process_identity(os.Getpid(), os.Geteuid()+1); got != (processIdentity{}) {
t.Fatal("read identity of a different UID", got)
}
}