Keep Wine editor feedback responsive during reader reply stalls
PresentAccessibleEvent previously waited synchronously for the reader on Wine Access's message-loop thread. A slow D-Bus acknowledgment prevented that thread from receiving subsequent WM_COPYDATA editor notifications. The hooks time out after 50 ms, so feedback disappeared even though the application accepted the input. With a 350 ms delayed focus reply, the original helper inserted all of abbacdef but forwarded only e and f. Move menu, editor and standard-control event delivery to one reader worker. Keep a FIFO of at most 128 pending events, retain each variant and the bus connection until delivery completes, and preserve focus-before-text order. Keep the 500 ms reply deadline and cancel outstanding calls during normal shutdown without making the event thread wait for the reader. Event-thread success now means queued; controller RPC calls retain their synchronous reader status behavior. Retain the most recent focus dependency on the worker. After a transport failure, re-establish that focus before sending dependent editor actions. Treat a successful false reply as a completed exchange because filtering can suppress presentation while still establishing focus. Never replay a timed-out text action. Report transport failures, recovery and queue overflow without including event contents in diagnostics. Add a native editor fixture and a private-session D-Bus integration runner for delayed replies, reply timeouts, failed initial focus, repeated typing, event order, unchanged native text/caret, and shutdown with a withheld reply. Wait for editor-hook attachment before triggering typing so the 32-bit companion is exercised reliably. Document queue behavior, limits, regression commands and remaining live acceptance checks. Validation performed sequentially on a private Xorg dummy display with disposable Wine prefixes and recording reader services: - cmake --build build --parallel - python3 tests/reader_stall_integration.py --build-dir build - The reader-stall check with --32-bit, --timeout, --focus-error, and --32-bit --focus-error - ctest --test-dir build --output-on-failure: all four checks passed - python3 tests/menu_integration.py --build-dir build with --fixture menu, edit, edit --32-bit, edit --restart-helper, notepad, controls, and list --32-bit - python3 tests/event_stall_integration.py --build-dir build - python3 tests/controller_integration.py --build-dir build - git diff --check The delayed-reply regression failed against the original synchronous helper and passed after the worker change. The failed-focus scenario also failed before dependency recovery was added and passed afterward. These recordings establish the shared failure paths, not the cause of every live MUSHclient pause or audible speech and physical braille acceptance. Live checks remain for command typing, incoming plugin speech, output-buffer arrows, menus, switching between Wine and Linux, reader restart, and protected fields. An unavailable reader can still cause feedback loss or bounded queue overflow.
This commit is contained in:
1 parent
27203b0512
commit
7d3ac09ea1
8 files changed
+399
-4
No files matched your search
@@ -0,0 +1,124 @@
|
||||
"""Check ordered editor feedback while the reader delays or loses replies.
|
||||
|
||||
Uses a private D-Bus service, disposable Wine prefix and private X display.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--build-dir", default="build")
|
||||
parser.add_argument("--32-bit", dest="is32Bit", action="store_true")
|
||||
parser.add_argument("--timeout", action="store_true")
|
||||
parser.add_argument("--focus-error", action="store_true")
|
||||
parser.add_argument("--session", action="store_true")
|
||||
args = parser.parse_args()
|
||||
build = Path(args.build_dir).resolve()
|
||||
if not args.session:
|
||||
sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable, __file__,
|
||||
"--session", "--build-dir", str(build),
|
||||
*(["--32-bit"] if args.is32Bit else []),
|
||||
*(["--timeout"] if args.timeout else []),
|
||||
*(["--focus-error"] if args.focus_error else [])]))
|
||||
|
||||
interface = "org.stormux.Cthulhu1.Service"
|
||||
object_path = "/org/stormux/Cthulhu1/Service"
|
||||
xml = '<node><interface name="' + interface + '"><method name="PresentAccessibleEvent">'
|
||||
xml += ''.join('<arg type="' + kind + '" direction="in"/>' for kind in ["s"] * 7 + ["i", "i", "s"])
|
||||
xml += '<arg type="b" direction="out"/></method></interface></node>'
|
||||
events, held = [], []
|
||||
|
||||
|
||||
def pump_until(predicate, seconds):
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
while GLib.MainContext.default().iteration(False):
|
||||
pass
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(.005)
|
||||
raise AssertionError(f"condition timed out; events: {events}")
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="wine-reader-stall-") as temporary:
|
||||
root = Path(temporary)
|
||||
waiting, typed, resume = (root / name for name in ("waiting", "typed", "resume"))
|
||||
env = os.environ.copy()
|
||||
env.update(WINEPREFIX=str(root / "prefix"), WINEDEBUG="-all",
|
||||
WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1")
|
||||
for key, path in (("WAITING", waiting), ("TYPED", typed), ("RESUME", resume)):
|
||||
env["WINE_ACCESS_READER_" + key] = "Z:" + str(path).replace("/", "\\")
|
||||
|
||||
def record(connection, sender, path, iface, method, parameters, invocation):
|
||||
event = parameters.unpack()
|
||||
first_focus = not waiting.exists() and event[1] == "focus" and event[0].endswith(":text")
|
||||
if first_focus and args.focus_error:
|
||||
waiting.touch()
|
||||
invocation.return_dbus_error("org.freedesktop.DBus.Error.Failed", "Reader temporarily unavailable")
|
||||
return
|
||||
events.append(event)
|
||||
if first_focus:
|
||||
held.append(invocation)
|
||||
waiting.touch()
|
||||
elif event[1] == "text" and event[2] == "z":
|
||||
held.append(invocation)
|
||||
else:
|
||||
invocation.return_value(GLib.Variant("(b)", (True,)))
|
||||
|
||||
connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
connection.register_object(object_path, Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0], record, None, None)
|
||||
connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus",
|
||||
"RequestName", GLib.Variant("(su)", (interface, 0)), None,
|
||||
Gio.DBusCallFlags.NONE, 1000, None)
|
||||
fixture = root / "fixture.exe"
|
||||
compiler = "i686-w64-mingw32-gcc" if args.is32Bit else "x86_64-w64-mingw32-gcc"
|
||||
subprocess.run([compiler, "-Wall", "-Wextra", "-mwindows", "-o", str(fixture),
|
||||
str(Path(__file__).with_name("reader_stall_fixture.c")), "-luser32"], check=True)
|
||||
children = []
|
||||
try:
|
||||
subprocess.run(["wineboot", "-u"], env=env, check=True, timeout=45)
|
||||
helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller"], env=env)
|
||||
children.append(helper)
|
||||
time.sleep(1)
|
||||
app = subprocess.Popen(["wine", str(fixture)], env=env)
|
||||
children.append(app)
|
||||
pump_until(waiting.exists, 5)
|
||||
# Longer than the hook's 50 ms deadline, shorter than the bus timeout.
|
||||
if not args.focus_error:
|
||||
GLib.timeout_add(700 if args.timeout else 350,
|
||||
lambda: (held.pop(0).return_value(GLib.Variant("(b)", (True,))), False)[1])
|
||||
pump_until(typed.exists, 3)
|
||||
pump_until(lambda: len([e for e in events if e[1] == "text"]) >= 8, 3)
|
||||
actions = [e[2] for e in events if e[1] == "text"]
|
||||
assert actions == list("abbacdef"), f"editor feedback lost or reordered: {actions}"
|
||||
focused = None
|
||||
for event in events:
|
||||
if event[1] == "focus":
|
||||
focused = event[0]
|
||||
elif event[1] == "text":
|
||||
assert event[0] == focused, "text forwarded before its focus"
|
||||
print("PASS: delayed reader preserves repeated typing, order, native text and caret", flush=True)
|
||||
pump_until(lambda: not held, 2)
|
||||
resume.touch()
|
||||
pump_until(lambda: bool(held), 2)
|
||||
started = time.monotonic()
|
||||
quit_client = subprocess.Popen(["wine", str(fixture), "quit-helper"], env=env)
|
||||
children.append(quit_client)
|
||||
pump_until(lambda: helper.poll() is not None, .4)
|
||||
assert helper.returncode == 0, f"helper shutdown failed: {helper.returncode}"
|
||||
print(f"PASS: normal shutdown while reader withholds reply ({time.monotonic() - started:.3f} s)", flush=True)
|
||||
finally:
|
||||
for child in reversed(children):
|
||||
if child.poll() is None:
|
||||
child.terminate()
|
||||
try:
|
||||
child.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill(); child.wait(timeout=3)
|
||||
subprocess.run(["wineserver", "-k"], env=env, timeout=5, check=False)
|
||||
Reference in new issue
Block a user