419 lines
14 KiB
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)
|
|
}
|
|
}
|