Files
cthulhu/internal/app/braille_session.go
Storm Dragon 4da61465b0 Fix document retention and helper retry regressions
Keep failed cursor trials and native recapture anchored to the original document, and restore the requested cached root before LRU eviction. Base braille retry resets on established connection time rather than stalled startup. Poll private AT-SPI daemon ownership without triggering service activation and clean up partial startup failures. Add focused regressions and update browser testing notes.
2026-11-03 11:37:36 -05:00

221 lines
5.9 KiB
Go

package app
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os/exec"
"strings"
"sync"
"time"
"git.stormux.org/storm/cthulhu/internal/braille"
)
// A helper connection attempt blocks inside BRLTTY until BRLTTY answers,
// which is exactly when a display becomes available: BRLTTY stalls BrlAPI
// while it looks for a switched-off (e.g. Bluetooth) display, and there is
// no cheaper presence query. The session therefore waits with the single
// helper instead of killing and respawning it. brailleReadyGiveUp only
// recycles a permanently wedged helper (e.g. BRLTTY died mid-handshake).
const (
brailleReadyGiveUp = 5 * time.Minute
brailleReadyNotice = 10 * time.Second
)
const brailleMaxBackoff = 30 * time.Second
// nextBrailleBackoff grows the delay between helper connection attempts so
// an absent display (switched off, BRLTTY not answering) is retried on a
// slow cadence instead of respawning the helper every few seconds forever.
func nextBrailleBackoff(current time.Duration) time.Duration {
if current <= 0 {
return time.Second
}
return min(current*2, brailleMaxBackoff)
}
func (worker *brailleWorker) run(ctx context.Context, done chan struct{}) {
defer close(done)
backoff := time.Duration(0)
lastError := ""
for ctx.Err() == nil {
connectedFor, err := worker.session(ctx)
// Only a healthy, established connection resets retry history. Waiting
// for the helper's ready message can itself take several minutes.
if connectedFor > 10*time.Second {
backoff = 0
lastError = ""
}
if err != nil && ctx.Err() == nil {
if err.Error() != lastError {
worker.logger.Warn("braille helper disconnected", "error", err)
lastError = err.Error()
} else {
worker.logger.Debug("braille helper still unavailable", "error", err)
}
}
backoff = nextBrailleBackoff(backoff)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
}
}
// connectedFor excludes startup waits so the supervisor can distinguish an
// established connection from a slow failed handshake.
func (worker *brailleWorker) session(ctx context.Context) (connectedFor time.Duration, result error) {
var connectedAt time.Time
defer func() {
if !connectedAt.IsZero() {
connectedFor = time.Since(connectedAt)
}
}()
defer func() { worker.mu.Lock(); worker.connected = false; worker.mu.Unlock() }()
childCtx, cancel := context.WithCancel(ctx)
defer cancel()
command := exec.CommandContext(childCtx, worker.path, "--client")
stdin, err := command.StdinPipe()
if err != nil {
return 0, err
}
stdout, err := command.StdoutPipe()
if err != nil {
return 0, err
}
var stderr bytes.Buffer
command.Stderr = &stderr
if err := command.Start(); err != nil {
return 0, err
}
waitDone := make(chan struct{})
var waitErr error
go func() { defer close(waitDone); waitErr = command.Wait() }()
var ioWorkers sync.WaitGroup
// Kill the disposable native owner, close pipes to unblock I/O, then join
// every goroutine before permitting reconnect or a new configured session.
defer func() {
cancel()
_ = stdin.Close()
_ = stdout.Close()
<-waitDone
ioWorkers.Wait()
if result != nil {
if detail := strings.TrimSpace(stderr.String()); detail != "" {
result = fmt.Errorf("%w: %s", result, detail)
} else if waitErr != nil {
result = fmt.Errorf("%w: %v", result, waitErr)
}
}
}()
ready := make(chan error, 1)
readErrors := make(chan error, 1)
// Routing keys retain FIFO order. Backpressure stops this pipe reader at
// 16 pending keys; cancellation releases it and retires the session queue.
routes := make(chan braille.RoutingEvent, 16)
ioWorkers.Add(1)
go func() {
defer ioWorkers.Done()
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 1024), 1024)
if !scanner.Scan() || scanner.Text() != "ready" {
ready <- errors.New("braille helper sent an invalid ready message")
return
}
ready <- nil
for scanner.Scan() {
var event braille.RoutingEvent
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
readErrors <- errors.New("invalid braille routing event")
return
}
select {
case routes <- event:
case <-childCtx.Done():
return
}
}
err := scanner.Err()
if err == nil {
err = io.EOF
}
readErrors <- err
}()
giveUp := worker.readyGiveUp
if giveUp <= 0 {
giveUp = brailleReadyGiveUp
}
giveUpTimer := time.NewTimer(giveUp)
defer giveUpTimer.Stop()
notice := time.NewTimer(brailleReadyNotice)
defer notice.Stop()
readyWait:
for {
select {
case err := <-ready:
if err != nil {
return 0, err
}
break readyWait
case <-notice.C:
worker.logger.Info("waiting for braille display")
case <-giveUpTimer.C:
return 0, errors.New("braille helper connection timed out")
case <-ctx.Done():
return 0, nil
}
}
connectedAt = time.Now()
worker.logger.Info("braille helper connected")
worker.mu.Lock()
worker.connected = true
worker.mu.Unlock()
writeError := make(chan error, 1)
ioWorkers.Add(1)
go func() {
defer ioWorkers.Done()
encoder := json.NewEncoder(stdin)
for {
frame := worker.current()
if err := encoder.Encode(frame); err != nil {
writeError <- err
return
}
// This confirms the helper write, not the physical display. Text is
// the uncontracted line; cursor offsets count Unicode characters.
if worker.logger.Enabled(context.Background(), slog.LevelDebug) {
worker.logger.Debug("sent braille frame", "frame_id", frame.ID,
"braille_text", frame.Text, "cursor_offset", frame.Cursor,
"contracted", frame.Contract, "table", frame.Table,
"word_wrap", frame.WordWrap, "expand_at_cursor", frame.ExpandAtCursor)
}
select {
case <-childCtx.Done():
return
case <-worker.wake:
}
}
}()
for {
select {
case <-waitDone:
return 0, waitErr
case err := <-writeError:
return 0, err
case err := <-readErrors:
return 0, err
case event := <-routes:
worker.route(childCtx, event)
case <-ctx.Done():
return 0, nil
}
}
}