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