Integrate with wine-access logging.

This commit is contained in:
Storm Dragon committed 2026-10-15 12:50:15 -04:00
1 parent 8e98321e6b
commit d756ceb2b2
4 files changed
+369 -7

No files matched your search

+17
View File
@@ -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
+160
View File
@@ -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)
}
}
+167
View File
@@ -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 }
+25 -7
View File
@@ -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: