A controlled reader renders accepted application frames in a new virtual-only cthulhu-braille mode at the controller's width and applies its pan and route gestures; reader UI, flashes and secure frames are never exported, and hidden frames are unroutable. A controller reader reports its display width, shows peer cells as an untranslated raw frame and forwards local pan and routing keys. The physical helper now reports its width and edge pans. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
232 lines
6.3 KiB
Go
232 lines
6.3 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)
|
|
// "ready N" reports N display cells; a bare "ready" is an older helper.
|
|
cells, valid := 0, scanner.Scan()
|
|
if valid && scanner.Text() != "ready" {
|
|
var parsed int
|
|
_, err := fmt.Sscanf(scanner.Text(), "ready %d", &parsed)
|
|
valid = err == nil && parsed > 0 && parsed <= 4096 && scanner.Text() == fmt.Sprintf("ready %d", parsed)
|
|
cells = parsed
|
|
}
|
|
if !valid {
|
|
ready <- errors.New("braille helper sent an invalid ready message")
|
|
return
|
|
}
|
|
worker.mu.Lock()
|
|
worker.cells = cells
|
|
worker.mu.Unlock()
|
|
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
|
|
}
|
|
}
|
|
}
|