Wait quietly for an absent braille display

Helper retries BrlAPI open in-process until BRLTTY answers, supervisor waits with the parked helper instead of kill/respawn cycles, exponential backoff and warn-once for real helper exits, bounded give-up so permanent failures still surface.
This commit is contained in:
hjozwiak committed 2026-11-03 10:35:00 -05:00
1 parent 6c10ed0517
commit 002e515edf
5 files changed
+279 -15

No files matched your search

+42 -2
View File
@@ -11,6 +11,43 @@ import (
"time"
)
// openRetryDelay and openRetryGiveUp bound the in-helper wait for a
// display to become ready. Variables so tests can shrink them.
var (
openRetryDelay = time.Second
openRetryGiveUp = 4 * time.Minute
)
// openWithRetry waits for a usable display inside the helper. While a
// display is switched off or still negotiating, BRLTTY stalls, refuses, or
// half-answers connection attempts (query failures, zero geometry); each
// is transient, so retrying here claims the display as soon as BRLTTY is
// ready instead of bouncing through supervisor respawn backoff. The last
// error is returned at the give-up bound so permanent misconfiguration
// (e.g. authorization) still surfaces to the parent's log.
func openWithRetry(ctx context.Context, backend Backend) (Geometry, error) {
deadline := time.NewTimer(openRetryGiveUp)
defer deadline.Stop()
for {
geometry, err := backend.Open()
if err == nil {
if _, cellErr := geometry.Cells(); cellErr == nil {
return geometry, nil
} else {
err = cellErr
}
_ = backend.Close()
}
select {
case <-ctx.Done():
return Geometry{}, ctx.Err()
case <-deadline.C:
return Geometry{}, err
case <-time.After(openRetryDelay):
}
}
}
// RunClient owns BrlAPI inside the disposable helper process. The parent can
// terminate this process if a native call blocks or BRLTTY disconnects.
func RunClient(ctx context.Context, backend Backend, input io.ReadCloser, ready io.Writer) (result error) {
@@ -18,11 +55,14 @@ func RunClient(ctx context.Context, backend Backend, input io.ReadCloser, ready
// produced an unexpected EOF with the live BRLTTY connection.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
geometry, err := backend.Open()
defer func() { result = errors.Join(result, backend.Close()) }()
geometry, err := openWithRetry(ctx, backend)
if err != nil {
if ctx.Err() != nil {
return nil
}
return err
}
defer func() { result = errors.Join(result, backend.Close()) }()
cells, err := geometry.Cells()
if err != nil {
return err
+81
View File
@@ -3,6 +3,7 @@ package braille
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
@@ -37,6 +38,86 @@ func TestRunClientShowsLiveFrameAndReleasesDisplay(t *testing.T) {
}
}
type flakyOpenBackend struct {
fakeBackend
failures int
cancel context.CancelFunc
}
func (backend *flakyOpenBackend) Open() (Geometry, error) {
if backend.failures > 0 {
backend.failures--
backend.opens++
return Geometry{}, errors.New("display not ready")
}
return backend.fakeBackend.Open()
}
func (backend *flakyOpenBackend) Write(text string, cursor int) error {
if err := backend.fakeBackend.Write(text, cursor); err != nil {
return err
}
backend.cancel()
return nil
}
// BRLTTY refuses or half-answers connection attempts while a display is
// still negotiating (observed live: query failures, then zero geometry,
// then success). The helper must keep trying in-process so the display is
// claimed as soon as BRLTTY is ready, without a supervisor respawn cycle.
func TestRunClientRetriesOpenUntilDisplayReady(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
backend := &flakyOpenBackend{fakeBackend: fakeBackend{geometry: Geometry{20, 1}}, failures: 2, cancel: cancel}
input := strings.NewReader("{\"text\":\"Hello\",\"cursor\":-1}\n")
var ready bytes.Buffer
if err := RunClient(ctx, backend, io.NopCloser(input), &ready); err != nil {
t.Fatal(err)
}
if ready.String() != "ready\n" || backend.opens != 3 || backend.closes != 1 || len(backend.writes) != 1 {
t.Fatalf("ready=%q backend=%+v", ready.String(), backend)
}
}
// Cancellation while the display has not appeared yet ends the wait
// cleanly and promptly.
func TestRunClientOpenWaitHonorsCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
backend := &flakyOpenBackend{fakeBackend: fakeBackend{geometry: Geometry{20, 1}}, failures: 1 << 30, cancel: cancel}
var ready bytes.Buffer
done := make(chan error, 1)
go func() { done <- RunClient(ctx, backend, io.NopCloser(strings.NewReader("")), &ready) }()
time.Sleep(100 * time.Millisecond)
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("cancelled open wait error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("open wait ignored cancellation")
}
if ready.Len() != 0 {
t.Fatalf("ready written without a display: %q", ready.String())
}
}
// A display that never becomes ready surfaces the last open error at the
// give-up bound instead of waiting silently forever.
func TestRunClientOpenWaitGivesUpWithLastError(t *testing.T) {
delay, giveUp := openRetryDelay, openRetryGiveUp
openRetryDelay, openRetryGiveUp = 10*time.Millisecond, 55*time.Millisecond
defer func() { openRetryDelay, openRetryGiveUp = delay, giveUp }()
backend := &flakyOpenBackend{fakeBackend: fakeBackend{geometry: Geometry{20, 1}}, failures: 1 << 30}
err := RunClient(context.Background(), backend, io.NopCloser(strings.NewReader("")), &bytes.Buffer{})
if err == nil || !strings.Contains(err.Error(), "display not ready") {
t.Fatalf("give-up error = %v", err)
}
if backend.opens < 2 {
t.Fatalf("open attempts = %d, want retries", backend.opens)
}
}
func TestRunClientPlacesCursorOnUnicodeCaret(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()