mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 18:47:04 +01:00
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
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:
parent
b8aa1389ef
commit
6832805e5d
10
docs/superpowers/contracts/a11ydump-lock-2026-09-25.json
Normal file
10
docs/superpowers/contracts/a11ydump-lock-2026-09-25.json
Normal 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."
|
||||
}
|
||||
36
docs/superpowers/evidence/a11ydump-lock-2026-09-25.json
Normal file
36
docs/superpowers/evidence/a11ydump-lock-2026-09-25.json
Normal 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"]
|
||||
}
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user