Integrate with wine-access logging.
This commit is contained in:
1 parent
8e98321e6b
commit
d756ceb2b2
4 files changed
+369
-7
No files matched your search
@@ -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
|
Restart Cthulhu after changing the selected helper. Keep the launcher, its
|
||||||
`.exe.so` sidecar, and the native hooks together in the installed directory.
|
`.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
|
Wine typing feedback in standard Edit controls follows the current key echo
|
||||||
settings, including the master switch, character echo, and alphabetic, numeric,
|
settings, including the master switch, character echo, and alphabetic, numeric,
|
||||||
punctuation, space and diacritical categories. Tabs and line breaks follow
|
punctuation, space and diacritical categories. Tabs and line breaks follow
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -29,6 +30,7 @@ type process struct {
|
|||||||
seen time.Time
|
seen time.Time
|
||||||
failures int
|
failures int
|
||||||
next time.Time
|
next time.Time
|
||||||
|
stopping atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Manager struct {
|
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 {
|
func (manager *Manager) launch(child *process, source prefix, now time.Time) error {
|
||||||
cmd := exec.Command(manager.helper)
|
cmd := exec.Command(manager.helper)
|
||||||
|
diagnostics := newHelperDiagnostics()
|
||||||
cmd.Stdin = nil
|
cmd.Stdin = nil
|
||||||
cmd.Stdout = 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}
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
environment := os.Environ()
|
environment := os.Environ()
|
||||||
for _, key := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"} {
|
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
|
return err
|
||||||
}
|
}
|
||||||
child.cmd, child.done, child.started = cmd, make(chan struct{}), now
|
child.cmd, child.done, child.started = cmd, make(chan struct{}), now
|
||||||
go manager.wait(cmd, child)
|
child.stopping.Store(false)
|
||||||
manager.logger.Info("Wine accessibility helper started")
|
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
|
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()
|
err := cmd.Wait()
|
||||||
if err != nil {
|
diagnostics.finish()
|
||||||
manager.logger.Warn("Wine accessibility helper exited", "error", err)
|
// 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 {
|
} else {
|
||||||
manager.logger.Info("Wine accessibility helper exited")
|
logger.Info("Wine accessibility helper exited", "requested_stop", child.stopping.Load())
|
||||||
}
|
}
|
||||||
close(child.done)
|
close(child.done)
|
||||||
}
|
}
|
||||||
@@ -222,6 +239,7 @@ func stop(child *process) {
|
|||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
child.stopping.Store(true)
|
||||||
_ = child.cmd.Process.Signal(syscall.SIGTERM)
|
_ = child.cmd.Process.Signal(syscall.SIGTERM)
|
||||||
select {
|
select {
|
||||||
case <-child.done:
|
case <-child.done:
|
||||||
|
|||||||
Reference in new issue
Block a user