diff --git a/README.md b/README.md index 77d227a..c2b2377 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,23 @@ system locations, and the old name on `PATH` follows the new name. Restart Cthulhu after changing the selected helper. Keep the launcher, its `.exe.so` sidecar, and the native hooks together in the installed directory. +With an updated Wine Access helper, the log records the running helper's +embedded release version, Git revision and modified-build flag, followed by +initialization status for its workers, hooks, controller and control poll. +The process-start message alone does not establish successful initialization. +Debug logs also identify the selected executable, Wine loader and prefix; +check those paths for an older local copy when machines behave differently. +Helpers built before identity reporting do not provide these identity/status +records. The installed helper's `--version` option can be used for a manual +check; it does not require a running reader. + +Helper-prefixed stderr diagnostics are captured with a 4096-byte line limit, +a 64-record queue and up to 32 distinct records per ten-second window. Repeated, +oversized and excess records are counted in a suppression notice. Unrelated +Wine/application stderr is ignored. Capture does not log accessible control +contents or change speech delivery; output already present in debug speech +logs can still contain private text. Review debug logs before sharing them. + Wine typing feedback in standard Edit controls follows the current key echo settings, including the master switch, character echo, and alphabetic, numeric, punctuation, space and diacritical categories. Tabs and line breaks follow diff --git a/internal/wine/diagnostics.go b/internal/wine/diagnostics.go new file mode 100644 index 0000000..cf29ed7 --- /dev/null +++ b/internal/wine/diagnostics.go @@ -0,0 +1,160 @@ +package wine + +import ( + "encoding/json" + "log/slog" + "strings" + "sync/atomic" + "time" +) + +const diagnosticLineLimit = 4096 + +// helperDiagnostics is written only by os/exec's stderr copier. It drains +// arbitrary Wine output without waiting for the log sink. Each helper gets a +// bounded line buffer, queue and rate window. After cmd.Wait, its logging +// worker drains the remaining records independently of process shutdown. +type helperDiagnostics struct { + lines chan string + done chan struct{} + partial []byte + overlong bool + window time.Time + seen map[string]bool + suppressed atomic.Uint64 +} + +func newHelperDiagnostics() *helperDiagnostics { + return &helperDiagnostics{ + lines: make(chan string, 64), done: make(chan struct{}), + seen: make(map[string]bool), + } +} + +func (output *helperDiagnostics) Write(data []byte) (int, error) { + for _, char := range data { + if char == '\n' { + if output.overlong { + output.suppressed.Add(1) + } else { + output.queueLine(string(output.partial)) + } + output.partial = output.partial[:0] + output.overlong = false + } else if !output.overlong { + if len(output.partial) == diagnosticLineLimit { + output.overlong = true + output.partial = output.partial[:0] + } else { + output.partial = append(output.partial, char) + } + } + } + return len(data), nil +} + +func (output *helperDiagnostics) queueLine(line string) { + line = strings.TrimSuffix(line, "\r") + // Do not mix arbitrary Wine/application stderr into reader diagnostics. + if !strings.HasPrefix(line, "wine-access: ") { + return + } + now := time.Now() + if now.Sub(output.window) >= 10*time.Second { + output.window = now + clear(output.seen) + } + if output.seen[line] || len(output.seen) >= 32 { + output.suppressed.Add(1) + return + } + output.seen[line] = true + select { + case output.lines <- strings.TrimPrefix(line, "wine-access: "): + default: + output.suppressed.Add(1) + } +} + +// finish is called after Wait has stopped the stderr copier, including its +// WaitDelay deadline. It never waits for a slow log sink during helper shutdown. +func (output *helperDiagnostics) finish() { + if output.overlong { + output.suppressed.Add(1) + } else if len(output.partial) != 0 { + output.queueLine(string(output.partial)) + } + close(output.lines) +} + +func (output *helperDiagnostics) logRecords(logger *slog.Logger) { + defer close(output.done) + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + reportSuppressed := func() { + if count := output.suppressed.Swap(0); count != 0 { + logger.Warn("Wine helper diagnostics suppressed", "count", count) + } + } + for { + select { + case line, open := <-output.lines: + if !open { + reportSuppressed() + return + } + logHelperRecord(logger, line) + case <-ticker.C: + reportSuppressed() + } + } +} + +func logHelperRecord(logger *slog.Logger, line string) { + if body, found := strings.CutPrefix(line, "identity "); found { + var identity struct { + Version string `json:"version"` + Revision string `json:"revision"` + Modified bool `json:"modified"` + } + if err := json.Unmarshal([]byte(body), &identity); err != nil || identity.Version == "" || identity.Revision == "" { + logger.Warn("Invalid Wine helper identity record") + return + } + logger.Info("Wine accessibility helper identity", "version", identity.Version, + "revision", identity.Revision, "modified", identity.Modified) + return + } + if body, found := strings.CutPrefix(line, "ready "); found { + var status struct { + Reader string `json:"reader"` + ControlsEnabled bool `json:"controls_enabled"` + Controller bool `json:"controller"` + Review bool `json:"review"` + MSAAWorker bool `json:"msaa_worker"` + WinEvents bool `json:"win_events"` + MenuHook bool `json:"menu_hook"` + InputHook bool `json:"input_hook"` + Host32Started bool `json:"host32_started"` + ControlPoll bool `json:"control_poll"` + } + if err := json.Unmarshal([]byte(body), &status); err != nil || status.Reader == "" { + logger.Warn("Invalid Wine helper initialization record") + return + } + logger.Info("Wine accessibility helper initialized", "reader", status.Reader, + "controls_enabled", status.ControlsEnabled, "controller", status.Controller, + "review", status.Review, "msaa_worker", status.MSAAWorker, + "win_events", status.WinEvents, "menu_hook", status.MenuHook, + "input_hook", status.InputHook, "host32_started", status.Host32Started, + "control_poll", status.ControlPoll) + return + } + if strings.HasPrefix(line, "reader event replies resumed after ") || + line == "another helper already owns this Wine desktop" || + line == "disabled by command-line options" { + logger.Info("Wine accessibility helper diagnostic", "diagnostic", line) + } else { + logger.Warn("Wine accessibility helper diagnostic", "diagnostic", line) + } +} diff --git a/internal/wine/diagnostics_test.go b/internal/wine/diagnostics_test.go new file mode 100644 index 0000000..f9b4968 --- /dev/null +++ b/internal/wine/diagnostics_test.go @@ -0,0 +1,167 @@ +package wine + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestHelperDiagnosticsHandlesSplitOversizedAndUnrelatedOutput(t *testing.T) { + var log bytes.Buffer + output := newHelperDiagnostics() + go output.logRecords(slog.New(slog.NewJSONHandler(&log, nil))) + for _, chunk := range []string{ + "unrelated application text\nwine-acc", "ess: identity {\"version\":\"2026.10.15\",", + "\"revision\":\"abc123\",\"modified\":true}\r\n", + "wine-access: " + strings.Repeat("private", diagnosticLineLimit) + "\n", + "wine-access: ready {broken}\n", + "wine-access: ready {\"reader\":\"org.example.Reader\",\"controls_enabled\":true,\"control_poll\":true}\n", + "wine-access: native input hook unavailable", + } { + if count, err := output.Write([]byte(chunk)); err != nil || count != len(chunk) { + t.Fatalf("stderr write = (%d, %v)", count, err) + } + } + output.finish() + <-output.done + text := log.String() + for _, want := range []string{ + `"version":"2026.10.15"`, `"revision":"abc123"`, `"modified":true`, + `"msg":"Wine accessibility helper initialized"`, `"control_poll":true`, + "Invalid Wine helper initialization record", "native input hook unavailable", + "Wine helper diagnostics suppressed", + } { + if !strings.Contains(text, want) { + t.Errorf("missing %q in %s", want, text) + } + } + for _, unwanted := range []string{"unrelated application text", "private", "{broken}"} { + if strings.Contains(text, unwanted) { + t.Errorf("unexpected output %q in log", unwanted) + } + } + for _, line := range strings.Split(strings.TrimSpace(text), "\n") { + if !json.Valid([]byte(line)) { + t.Errorf("invalid log record: %s", line) + } + } +} + +func TestHelperDiagnosticsBoundsFloodAndRecoversNextWindow(t *testing.T) { + var log bytes.Buffer + output := newHelperDiagnostics() + // With no consumer, writes must still finish after filling the queue. + for i := 0; i < 1000; i++ { + output.Write([]byte("wine-access: repeated failure\n")) + output.Write([]byte("wine-access: failure " + strings.Repeat("x", i) + "\n")) + } + if len(output.lines) > 32 { + t.Fatalf("diagnostic burst queued %d records", len(output.lines)) + } + output.window = time.Now().Add(-11 * time.Second) + output.Write([]byte("wine-access: reader event replies resumed after 2 failed calls\n")) + output.finish() + go output.logRecords(slog.New(slog.NewJSONHandler(&log, nil))) + <-output.done + if strings.Count(log.String(), `"diagnostic":"repeated failure"`) != 1 { + t.Fatalf("repeated failure was not coalesced: %s", log.String()) + } + if !strings.Contains(log.String(), "reader event replies resumed") || !strings.Contains(log.String(), `"count":1968`) { + t.Fatalf("missing recovery or suppression count: %s", log.String()) + } +} + +func TestManagerCapturesActualHelperDiagnosticsAndBoundsInheritedStderr(t *testing.T) { + root := t.TempDir() + helper := filepath.Join(root, "helper") + // Leave a descendant holding stderr after the helper exits. WaitDelay must + // retire the copier without waiting for the descendant. + script := "#!/bin/sh\necho 'wine-access: identity {\"version\":\"2026.10.15\",\"revision\":\"abc123\",\"modified\":false}' >&2\n" + + "echo 'wine-access: native menu hook unavailable (error 126)' >&2\n" + + "sleep 2 &\nexit 0\n" + if err := os.WriteFile(helper, []byte(script), 0700); err != nil { + t.Fatal(err) + } + handler := &channelLogHandler{records: make(chan slog.Record, 32)} + manager := &Manager{helper: helper, logger: slog.New(handler)} + child := &process{} + started := time.Now() + if err := manager.launch(child, prefix{path: root}, started); err != nil { + t.Fatal(err) + } + select { + case <-child.done: + case <-time.After(time.Second): + t.Fatal("inherited stderr kept helper shutdown waiting") + } + identity, diagnostic := false, false + deadline := time.After(time.Second) + for !identity || !diagnostic { + select { + case record := <-handler.records: + record.Attrs(func(attr slog.Attr) bool { + identity = identity || attr.Key == "revision" && attr.Value.String() == "abc123" + diagnostic = diagnostic || attr.Key == "diagnostic" && strings.Contains(attr.Value.String(), "native menu hook unavailable") + return true + }) + case <-deadline: + t.Fatal("helper diagnostics missing") + } + } +} + +func TestManagerExpectedStopIsNotAHelperFailure(t *testing.T) { + helper := filepath.Join(t.TempDir(), "helper") + if err := os.WriteFile(helper, []byte("#!/bin/sh\nexec sleep 30\n"), 0700); err != nil { + t.Fatal(err) + } + handler := &channelLogHandler{records: make(chan slog.Record, 32)} + manager := &Manager{helper: helper, logger: slog.New(handler)} + child := &process{} + if err := manager.launch(child, prefix{path: t.TempDir()}, time.Now()); err != nil { + t.Fatal(err) + } + stop(child) + for { + select { + case record := <-handler.records: + if record.Message != "Wine accessibility helper exited" { + continue + } + if record.Level != slog.LevelInfo { + t.Fatalf("expected stop logged as failure: %v", record) + } + return + default: + t.Fatal("missing helper exit record") + } + } +} + +type channelLogHandler struct { + records chan slog.Record + attrs []slog.Attr +} + +func (handler *channelLogHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= slog.LevelInfo +} + +func (handler *channelLogHandler) Handle(_ context.Context, record slog.Record) error { + record = record.Clone() + record.AddAttrs(handler.attrs...) + handler.records <- record + return nil +} + +func (handler *channelLogHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &channelLogHandler{records: handler.records, attrs: append(append([]slog.Attr{}, handler.attrs...), attrs...)} +} + +func (handler *channelLogHandler) WithGroup(string) slog.Handler { return handler } diff --git a/internal/wine/manager.go b/internal/wine/manager.go index 4bf930f..5e2f6d1 100644 --- a/internal/wine/manager.go +++ b/internal/wine/manager.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strconv" "strings" + "sync/atomic" "syscall" "time" ) @@ -29,6 +30,7 @@ type process struct { seen time.Time failures int next time.Time + stopping atomic.Bool } type Manager struct { @@ -178,9 +180,12 @@ func backoff(failures int) time.Duration { func (manager *Manager) launch(child *process, source prefix, now time.Time) error { cmd := exec.Command(manager.helper) + diagnostics := newHelperDiagnostics() cmd.Stdin = nil cmd.Stdout = nil - cmd.Stderr = nil + cmd.Stderr = diagnostics + // Bound stderr draining if a Wine descendant retains the inherited pipe. + cmd.WaitDelay = 200 * time.Millisecond cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} environment := os.Environ() for _, key := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"} { @@ -197,17 +202,29 @@ func (manager *Manager) launch(child *process, source prefix, now time.Time) err return err } child.cmd, child.done, child.started = cmd, make(chan struct{}), now - go manager.wait(cmd, child) - manager.logger.Info("Wine accessibility helper started") + child.stopping.Store(false) + logger := manager.logger.With("helper_pid", cmd.Process.Pid) + logger.Info("Wine accessibility helper started") + logger.Debug("Wine accessibility helper launch configuration", "executable", cmd.Path, + "wine_loader", source.loader, "wine_prefix", source.path) + go diagnostics.logRecords(logger) + go manager.wait(cmd, child, diagnostics, logger) return nil } -func (manager *Manager) wait(cmd *exec.Cmd, child *process) { +func (manager *Manager) wait(cmd *exec.Cmd, child *process, diagnostics *helperDiagnostics, logger *slog.Logger) { err := cmd.Wait() - if err != nil { - manager.logger.Warn("Wine accessibility helper exited", "error", err) + diagnostics.finish() + // Preserve record order at normal exit without letting a slow log sink + // hold process supervision indefinitely. + select { + case <-diagnostics.done: + case <-time.After(100 * time.Millisecond): + } + if err != nil && !child.stopping.Load() { + logger.Warn("Wine accessibility helper exited", "error", err) } else { - manager.logger.Info("Wine accessibility helper exited") + logger.Info("Wine accessibility helper exited", "requested_stop", child.stopping.Load()) } close(child.done) } @@ -222,6 +239,7 @@ func stop(child *process) { return default: } + child.stopping.Store(true) _ = child.cmd.Process.Signal(syscall.SIGTERM) select { case <-child.done: