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.
221 lines
5.9 KiB
Go
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
|
|
}
|
|
}
|
|
}
|