Files
cthulhu/internal/app/braille_session.go
Chase CichorzandClaude Opus 5.5 551ef86d5f Export and display Remote Braille through the reader
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>
2026-11-06 16:06:38 -05:00

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
}
}
}