a11ydump: queue dumps of one phone behind a host lock
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run

Android runs one instrumentation per package: a second `am instrument`
force-stops the first ("stop com.kuhy.a11ydump due to start instr"), and
so does `install -r`. The live two-session run on the Pixel caught two
sessions' dumps killing each other ("Process crashed"). dump_display now
runs install + instrument under a per-serial flock; a dump takes well
under a second, so queueing costs nothing noticeable.

After the fix the same 300 s run had 0 failures across 659 actions, and
the real screen and both apps stayed where they were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpA6wW2DKiovQADTdjWJoH
This commit is contained in:
Krzysztof Rudnicki 2026-09-25 16:36:50 +02:00
parent b8aa1389ef
commit 6832805e5d
8 changed files with 119 additions and 77 deletions

View File

@ -0,0 +1,10 @@
{
"title": "a11ydump: queue dumps of one phone behind a host lock",
"objective": "Concurrent android_ui --display calls from different sessions never crash each other's instrumentation.",
"acceptance_criteria": [
"install + am instrument run under a per-serial flock",
"a 300 s live run with two fake sessions has 0 failed taps/dumps"
],
"out_of_scope": ["cross-host locking"],
"verifier": "Live two-session run on the Pixel 6a; pytest python_pkg/android_ui at 100% branch coverage."
}

View File

@ -0,0 +1,36 @@
{
"intent": "Two sessions reading their own virtual displays at the same moment must not kill each other's dump.",
"scope": [
"python_pkg/android_ui/_a11y_helper.py (lock), driver.py (passes the serial), tests, README"
],
"changes": [
"dump_display runs install + am instrument under a per-phone flock at ~/.cache/android_ui/a11ydump-<serial>.lock",
"AndroidUi passes its serial so different phones do not queue behind each other"
],
"verification": [
{
"command": "live two-session run on the Pixel 6a before the fix (fake owners claude:fakeA / claude:fakeB, 300 s)",
"result": "fail",
"evidence": "logcat: 'stop com.kuhy.a11ydump due to start instr' then 'Crash of app com.kuhy.a11ydump running instrumentation'; android_ui: 'a11y helper returned no tree: Process crashed'"
},
{
"command": "same run after the fix: /tmp/two_session/run.sh, DURATION=300",
"result": "pass",
"evidence": "A 219 element taps on Calculator (display 14), B 440 dp taps + dumps on Clock (display 13), 0 failures, empty stderr; 99 samples: display 0 stayed on Signal, 14 on Calculator, 13 on Clock; all five alarms still off"
},
{
"command": "raw adb from a Claude Bash call while claude:fakeA held pkg:__screen__",
"result": "pass",
"evidence": "`adb shell input -d 0 tap` blocked after 40 s naming claude:fakeA; `input keyevent 0` without -d blocked at once; `input -d 13 tap` refused as claude_fakeB's display"
},
{
"command": "pytest python_pkg/android_ui python_pkg/phone_guard --cov --cov-branch",
"result": "pass",
"evidence": "153 passed; every module 100% line and branch"
}
],
"risks": [
"The lock is per host: sessions on another machine sharing the phone over adb are not serialised."
],
"rollback": ["git revert the commit"]
}

View File

@ -1,56 +0,0 @@
{
"intent": "Test nvidia_config.sh to 100% line coverage and take it off the allowlist, finishing the eight-lib fixes/lib campaign.",
"scope": [
"linux_configuration/scripts/single_use/fixes/lib/nvidia_config.sh (four test seams)",
"linux_configuration/scripts/single_use/fixes/lib/tests/nvidia_config_harness.sh (new)",
"linux_configuration/scripts/single_use/fixes/lib/tests/test_nvidia_config.sh and test_nvidia_config_pyroveil.sh (new)",
"meta/shell-coverage-allowlist.txt (one line removed)"
],
"changes": [
"Added XORG_CONF, XORG_CONF_D, PROFILE_FILE and USER_HOME seams. XORG_CONF and XORG_CONF_D are plain globals ASSIGNED inside configure_xorg, so the default had to move to the assignment itself -- an exported value alone would have been overwritten on entry",
"Added nvidia_config_harness.sh, the sixth family harness, supplying INTERACTIVE_MODE and SUDO_USER plus readers for the drop-in, the profile and the backup count",
"Added 45 assertions over 15 cases across two files covering all four functions",
"Removed nvidia_config.sh from meta/shell-coverage-allowlist.txt, taking it from 85 to 84 entries"
],
"verification": [
{
"command": "shell_coverage_jail.sh --timeout 600s --measure nvidia_config.sh --min 100 --fail-on-case-error",
"result": "pass",
"evidence": "nvidia_config.sh: 76/76 lines = 100.00%, exit 0, no case timeout."
},
{
"command": "shell_coverage_jail.sh --timeout 600s --measure bt_audio.sh --min 100 (canary, with the two new test files present)",
"result": "pass",
"evidence": "bt_audio.sh: 127/127 lines = 100.00%, so the additions did not displace an already-cleared lib."
},
{
"command": "ls -la /etc/X11/xorg.conf.d/20-nvidia.conf; ls -d ~/pyroveil",
"result": "pass",
"evidence": "The real drop-in is still dated Jun 26 2025, untouched by the suite, and ~/pyroveil does not exist. The seams kept every write inside the throwaway tmpdir."
},
{
"command": "bash linux_configuration/scripts/single_use/fixes/lib/tests/run_all.sh",
"result": "pass",
"evidence": "All fixes lib test files passed; 21 + 24 = 45 new assertions, 0 failed."
},
{
"command": "shellcheck + shfmt on the lib, the harness and both test files",
"result": "pass",
"evidence": "No findings, zero suppressions. Lib is 184 lines, under the 250-line cap."
},
{
"command": "Manual run of install_pyroveil under INTERACTIVE_MODE with stdin fed from a heredoc",
"result": "pass",
"evidence": "An assertion on the `read -p` prompt text failed because bash writes that prompt straight to the terminal, not to stdout or stderr, so it is not observable in captured output. Replaced with an assertion that the interactive arm does NOT emit the non-interactive branch's 'Auto-installing' line, which is observable and distinguishes the two paths."
}
],
"risks": [
"install_pyroveil's interactive arm cannot be asserted on its prompt text; the tests distinguish the branches by the auto-install message instead.",
"The tests stub git/cmake/ninja, so they verify the orchestration (clone vs update, sudo -u, chown, helper contents) rather than that pyroveil actually builds.",
"configure_xorg's RenderAccel value is asserted explicitly as 'true' and asserted never to be 'false', because 'false' forces software rendering and pins Xorg above 30% CPU -- the bug the comment in the lib records."
],
"rollback": [
"git revert the commit, then re-add the nvidia_config.sh line to meta/shell-coverage-allowlist.txt",
"Re-run check_shell_coverage.sh to confirm the gate passes again with the exemption restored"
]
}

View File

@ -108,7 +108,10 @@ that app's lease, so several sessions can act at once:
comes from the `com.kuhy.a11ydump` instrumentation helper (`A11yDump.java`,
`_a11y_helper.py`): built once into `~/.cache/android_ui/` and installed
with `adb install -r -t` on first use. Its package must stay in
phone-focus-mode's whitelist, or the focus-owner sweep hides it. Every
phone-focus-mode's whitelist, or the focus-owner sweep hides it. Android
runs one instrumentation per package, so a second `am instrument` kills
the first: dumps of one phone queue behind a host-side `flock`
(`~/.cache/android_ui/a11ydump-<serial>.lock`). Every
`input` call carries `-d` (0 for the real screen), because input without it
goes to whichever display last took focus. A numeric `--display` that is
another session's virtual display is refused.

View File

@ -14,7 +14,9 @@ never committed: the repo's binary gate forbids it, and the source is enough.
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Iterator
import contextlib
import fcntl
import hashlib
import os
from pathlib import Path
@ -208,18 +210,36 @@ def parse_result(output: str) -> str:
raise UiAutomationError(msg)
def dump_display(run: AdbRun, display: int) -> str:
@contextlib.contextmanager
def _one_at_a_time(phone: str) -> Iterator[None]:
"""Hold this host's lock on the helper for one phone.
Android runs one instrumentation per package: a second ``am instrument``
force-stops the first ("stop ... due to start instr"), and so does an
``install -r``. Two sessions dumping at once killed each other's dump in
the 2026-09-25 live test. A dump takes well under a second, so queueing
costs nothing noticeable; closing the file releases the lock.
"""
path = cache_dir() / f"a11ydump-{phone}.lock"
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a") as handle:
fcntl.flock(handle, fcntl.LOCK_EX)
yield
def dump_display(run: AdbRun, display: int, phone: str = "any") -> str:
"""Return ``display``'s accessibility tree as uiautomator-style XML."""
ensure_installed(run)
output = run(
"shell",
"am",
"instrument",
"-w",
"-e",
"display",
str(display),
_RUNNER,
timeout=45.0,
)
with _one_at_a_time(phone):
ensure_installed(run)
output = run(
"shell",
"am",
"instrument",
"-w",
"-e",
"display",
str(display),
_RUNNER,
timeout=45.0,
)
return parse_result(output)

View File

@ -117,7 +117,10 @@ class AndroidUi(TextEntryMixin):
def _dump_once(self) -> list[UiElement]:
"""Pull one accessibility-tree snapshot."""
if self._display is not None:
return _parse_tree(_a11y_helper.dump_display(self._run, self._display))
xml = _a11y_helper.dump_display(
self._run, self._display, self._serial or "any"
)
return _parse_tree(xml)
self._run("shell", "uiautomator", "dump", _REMOTE_DUMP, timeout=45.0)
with tempfile.TemporaryDirectory() as tmp:
local = Path(tmp) / "ui.xml"

View File

@ -7,6 +7,7 @@ The real build and a real dump were verified on the Pixel 6a on 2026-09-25.
from __future__ import annotations
import fcntl
from pathlib import Path
import shutil
import subprocess
@ -187,7 +188,10 @@ class TestDevice:
with pytest.raises(UiAutomationError, match="no tree"):
helper.parse_result("INSTRUMENTATION_STATUS: nothing\n")
def test_dump_display(self) -> None:
def test_dump_display(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("ANDROID_UI_CACHE", str(tmp_path))
run = MagicMock(return_value="INSTRUMENTATION_RESULT: xml=<h/>\n")
with patch(f"{MOD}.ensure_installed") as ensure:
assert helper.dump_display(run, 9) == "<h/>"
@ -203,3 +207,25 @@ class TestDevice:
"com.kuhy.a11ydump/.A11yDump",
timeout=45.0,
)
def test_dumps_of_one_phone_queue_behind_each_other(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# A second `am instrument` force-stops the first, so the whole
# install + instrument must run under the phone's lock.
monkeypatch.setenv("ANDROID_UI_CACHE", str(tmp_path))
held: list[bool] = []
def run(*_args: str, **_kw: object) -> str:
with (tmp_path / "a11ydump-R58.lock").open("a") as other:
try:
fcntl.flock(other, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
held.append(True)
else:
held.append(False)
return "INSTRUMENTATION_RESULT: xml=<h/>\n"
with patch(f"{MOD}.ensure_installed", side_effect=lambda r: r()):
assert helper.dump_display(run, 9, "R58") == "<h/>"
assert held == [True, True]

View File

@ -195,15 +195,15 @@ class TestVirtualDisplay:
monkeypatch.setattr(instance, "_run", device.run)
monkeypatch.setattr(f"{MOD}.time.sleep", lambda _s: None)
xml = _tree(_node("Go", bounds="[0,0][100,100]"))
calls: list[int] = []
calls: list[tuple[int, str]] = []
def dump(run: object, display: int) -> str:
def dump(run: object, display: int, phone: str) -> str:
del run
calls.append(display)
calls.append((display, phone))
return xml
monkeypatch.setattr(f"{MOD}._a11y_helper.dump_display", dump)
instance.tap("Go")
assert calls
assert set(calls) == {9}
assert set(calls) == {(9, "S")}
assert ("shell", "input", "-d", "9", "tap", "50", "50") in device.calls