Skip to content

Commit 21b7cf9

Browse files
committed
Reject inconclusive live EI cleanup evidence
1 parent c26399e commit 21b7cf9

16 files changed

Lines changed: 220 additions & 128 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's
1313

1414
## Unreleased
1515

16+
- Fail live EI cleanup verification on unrelated child errors or missing native
17+
completion evidence; retain bounded fault traces and release owned device refs
18+
before the context. Record native half-open SIGSEGV and socket-peer refusal
19+
evidence while retaining runtime protection pending helper isolation.
20+
1621
- Collect isolated native EI lifecycle evidence on selected dev pushes. Bound
1722
the half-open teardown child, retain fault traces and descriptor counts,
1823
reject unrelated probe failures, and verify native refusal separately from

‎Progress.md‎

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010
實作計畫:[分階段交付計畫](docs/superpowers/plans/2026-10-02-modernization-index.md),待審閱。
1111
現有 `[Answer]` 決策沿用。
1212

13-
`TODO` — 真機與付費 API 驗收暫緩,待明確恢復後執行;相關項目保留待驗證。
13+
`TODO` — 真機與付費 API 驗收暫緩,待明確恢復後執行;其餘目前缺環境的實驗先略過,相關項目保留未驗證。
1414

1515
`WIP` — 改版仍缺以下內容;原生平台與正式環境驗收缺口另列於後。
1616
計畫 D1 仍缺 portal session 關閉/恢復、compositor 重啟的狀態與原生驗收、
1717
擷取結果與擁有者 context 的接合,以及原生 session 的重試/取消/停止控制。
18-
D2 仍缺 `ei-lifecycle.yml` 原生結果確認與據此判斷 helper 隔離;D3 的錄製/全域停止/Windows 原生崩潰證據仍缺,詳見計畫與下方生命週期待辦。
18+
D2 仍缺 helper 隔離與半開交握資源回收;D3 的錄製/全域停止/Windows 原生崩潰證據仍缺,詳見計畫與下方生命週期待辦。
1919
計畫 E(Android/iOS)、G(MCP 逐步揭露)、H(型別深化與總驗收)尚未開始。F 還缺:
2020

2121
- **Remote Desktop 與 Script Builder 仍在啟動時建立**(預設開啟),啟動時間 2.6–2.7 秒裡大半是它們與門面匯入。
@@ -192,18 +192,16 @@ pip install --dry-run --only-binary=:all: --platform win_arm64 --python-version
192192

193193
---
194194

195-
## libei 的 `ei_unref` 在半開交握上會 SIGSEGV
195+
## libei 半開交握的資源隔離尚未完成
196196

197-
`BLOCKED` — 上游(libei 1.3.901)
197+
`TODO` — D2 helper 隔離;不能直接移除 `linux_wayland/libei.py::_teardown` 的半開保護。
198198

199-
`linux_wayland/libei.py` 的 `_teardown` **刻意每個行程漏一個 context 與一個 fd**,
200-
因為對一個還沒完成交握的 handle 呼叫 `ei_unref` 會直接 SIGSEGV。
201-
這是在驅動使用者桌面的函式庫裡的 crash,所以寧可漏也不能當。
202-
203-
待驗證腳本:`docker/libei_verify.py` 與 `docker/eis_verify.py`。
204-
205-
重驗方式就是跑那兩支腳本(`eis-verification` job 已經在跑);哪天 banner 不再
206-
出現,就把 `_teardown` 的迴避拿掉。形狀與 arm64 那條一樣:卡上游、有一行重驗。
199+
需依 D2 計畫把原生配置、portal/交握、事件送出與 teardown 放進擁有的 helper,
200+
補上有界 IPC、request ID、逾時與取消,以及 parent close/reap。
201+
半開交握失敗仍會留下 context/fd;直接重複建立 backend 可能重複累積,
202+
需驗證 helper 結束後資源可回收,且取消不影響其他擁有者。
203+
`docker/libei_verify.py` 與 `docker/eis_verify.py` 保留原生故障/成功邊界檢查;
204+
裝置暫停與序號回讀、真實合成器權限流程仍待對應環境驗證。
207205

208206
---
209207

‎README.md‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -657,14 +657,17 @@ Literal argv (including whitespace/quotes) and handled `SIGINT` reach the
657657
child unchanged. Linux first verifies the runner outside the suite's debugger;
658658
its preflight logs are included in the same artifact.
659659

660-
Selected dev pushes now run `.github/workflows/ei-lifecycle.yml` against an
661-
isolated libei/libeis container. It runs the silent-peer teardown/refusal probe
662-
and live EIS peer independently, retaining available version/native logs in
663-
`ei-lifecycle-<run>-<attempt>` artifacts for 14 days. The teardown child has a
664-
10-second deadline and faulthandler; only SIGSEGV reached inside `ei_unref` is
665-
classified as the known upstream fault. A successful cleanup must report equal
666-
descriptor counts. Other failures fail CI. No compositor, device or consent
667-
dialog is involved; results must be inspected before changing native cleanup.
660+
Selected dev pushes run `.github/workflows/ei-lifecycle.yml` in an isolated
661+
libei/libeis container, retaining versions and both native logs for 14 days in
662+
`ei-lifecycle-<run>-<attempt>` artifacts. Half-open and live cleanup children
663+
have 10/60-second deadlines and faulthandler. Unrelated child failures fail CI;
664+
successful cleanup requires begin/end markers. The half-open probe also requires
665+
equal integer descriptor counts; live cleanup releases owned device refs first.
666+
Run 39507190704 on libei/libeis 1.3.901-1 passes 9/9 and 20/20 checks, including
667+
native out-of-region refusal without CLI fallback. Half-open `ei_unref` still
668+
segfaults, so runtime protection remains and helper isolation is pending.
669+
Device pause and sequence readback remain unverified. These controlled socket
670+
peers do not establish compositor consent, physical or multi-monitor acceptance.
668671

669672
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) and
670673
[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). Two rules the CI enforces: `import

‎README/README_zh-CN.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -576,12 +576,14 @@ Linux 安装 GDB 后,可在 `--` 前加入 `--native`。
576576
含空格/引号的 argv 仍是原始参数,已处理的 `SIGINT` 会传给子进程。
577577
Linux 先在完整套件的 debugger 外验证诊断工具;前置检查记录也收在同一 artifact。
578578

579-
选定的 dev 推送会由 `.github/workflows/ei-lifecycle.yml` 在隔离的 libei/libeis
580-
容器验证静默 peer 的释放/拒绝与独立的 live EIS peer,将可取得的版本/原生记录保留于
581-
`ei-lifecycle-<run>-<attempt>` artifact 十四天。释放子进程有十秒时限与 faulthandler;
582-
只有已进入 `ei_unref` 的 SIGSEGV 才分类为既有上游故障。释放成功必须有相同 fd 数量的
583-
证据,其余失败仍使 CI 失败。此流程不涉及合成器、设备或授权对话框;修改原生清理前仍须
584-
检查实际结果。
579+
选定的 dev 推送由 `.github/workflows/ei-lifecycle.yml` 在隔离 libei/libeis
580+
容器执行,将版本及两份原生记录保留于 `ei-lifecycle-<run>-<attempt>` artifact 十四天。
581+
半开与 live 释放子进程分别有十/六十秒时限与 faulthandler;其他子进程失败使 CI 失败,
582+
释放成功必须有 begin/end 标记。半开探测另须有相同整数 fd 数量,live 清理先释放拥有的
583+
device refs。run 39507190704 在 libei/libeis 1.3.901-1 通过 9/9 与 20/20 检查,
584+
包含坐标越界拒绝且不转用 CLI。半开 `ei_unref` 仍会 SIGSEGV,因此保留 runtime 保护,
585+
helper 隔离仍待完成。设备暂停与序号回读仍未验证;受控 socket peer 不代表合成器授权、
586+
真机或多屏幕验收。
585587

586588
欢迎贡献——请见 [CONTRIBUTING.md](../CONTRIBUTING.md) 与
587589
[CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md)。CI 会强制两条规则:`import je_auto_control`

‎README/README_zh-TW.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -576,12 +576,14 @@ Linux 安裝 GDB 後,可在 `--` 前加入 `--native`。
576576
含空白/引號的 argv 仍是原始參數,已處理的 `SIGINT` 會傳給子行程。
577577
Linux 先在完整套件的 debugger 外驗證診斷工具;前置檢查紀錄也收在同一 artifact。
578578

579-
選定的 dev 推送會由 `.github/workflows/ei-lifecycle.yml` 在隔離的 libei/libeis
580-
容器驗證靜默 peer 的釋放/拒絕與獨立的 live EIS peer,將可取得的版本/原生紀錄保留於
581-
`ei-lifecycle-<run>-<attempt>` artifact 十四天。釋放子行程有十秒時限與 faulthandler;
582-
只有已進入 `ei_unref` 的 SIGSEGV 才分類為既有上游故障。釋放成功必須有相同 fd 數量的
583-
證據,其餘失敗仍使 CI 失敗。此流程不涉及合成器、裝置或授權對話框;修改原生清理前仍須
584-
檢查實際結果。
579+
選定的 dev 推送由 `.github/workflows/ei-lifecycle.yml` 在隔離 libei/libeis
580+
容器執行,將版本及兩份原生紀錄保留於 `ei-lifecycle-<run>-<attempt>` artifact 十四天。
581+
半開與 live 釋放子行程分別有十/六十秒時限與 faulthandler;其他子行程失敗使 CI 失敗,
582+
釋放成功必須有 begin/end 標記。半開探測另須有相同整數 fd 數量,live 清理先釋放擁有的
583+
device refs。run 39507190704 在 libei/libeis 1.3.901-1 通過 9/9 與 20/20 檢查,
584+
包含座標越界拒絕且不轉用 CLI。半開 `ei_unref` 仍會 SIGSEGV,因此保留 runtime 保護,
585+
helper 隔離仍待完成。裝置暫停與序號回讀仍未驗證;受控 socket peer 不代表合成器授權、
586+
真機或多螢幕驗收。
585587

586588
歡迎貢獻——請見 [CONTRIBUTING.md](../CONTRIBUTING.md) 與
587589
[CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md)。CI 會強制兩條規則:`import je_auto_control`

‎architecture.md‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,17 @@ the runner before tracing the suite. Native aborts retain all-thread
8282
backtraces and a failing exit. Dev/main upload diagnostics on success or failure.
8383
It does not import the framework, open devices or change SDK/GUI behavior.
8484

85-
Selected dev pushes now run `.github/workflows/ei-lifecycle.yml` against an
86-
isolated libei/libeis container. It runs the silent-peer teardown/refusal probe
87-
and live EIS peer independently, retaining available version/native logs in
88-
`ei-lifecycle-<run>-<attempt>` artifacts for 14 days. The teardown child has a
89-
10-second deadline and faulthandler; only SIGSEGV reached inside `ei_unref` is
90-
classified as the known upstream fault. A successful cleanup must report equal
91-
descriptor counts. Other failures fail CI. No compositor, device or consent
92-
dialog is involved; results must be inspected before changing native cleanup.
85+
Selected dev pushes run `.github/workflows/ei-lifecycle.yml` in an isolated
86+
libei/libeis container, retaining versions and both native logs for 14 days in
87+
`ei-lifecycle-<run>-<attempt>` artifacts. Half-open and live cleanup children
88+
have 10/60-second deadlines and faulthandler. Unrelated child failures fail CI;
89+
successful cleanup requires begin/end markers. The half-open probe also requires
90+
equal integer descriptor counts; live cleanup releases owned device refs first.
91+
Run 39507190704 on libei/libeis 1.3.901-1 passes 9/9 and 20/20 checks, including
92+
native out-of-region refusal without CLI fallback. Half-open `ei_unref` still
93+
segfaults, so runtime protection remains and helper isolation is pending.
94+
Device pause and sequence readback remain unverified. These controlled socket
95+
peers do not establish compositor consent, physical or multi-monitor acceptance.
9396

9497
Wayland input uses Qt-free `_input_state` metadata to distinguish optional
9598
absence from authority/protocol refusal through portal, libei cache, selector

‎architecture_explore.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、
2626
| 套件門面 `__all__` 公開名稱數 | 1380 |
2727
| GUI 分頁數(`main_widget` 註冊) | 49 |
2828
| MCP 工具數(`build_default_tool_registry()` 實測) | 711 |
29-
| `test_*.py` 測試檔/測試函式 | 846 / 8,820 |
29+
| `test_*.py` 測試檔/測試函式 | 846 / 8,825 |
3030
| 範例腳本 | 43 |
3131

3232
**技術基線**:Python ≥ 3.10、MIT 授權、必要相依只有 `je_open_cv`/`opencv-python`/`pillow`/`mss`/
@@ -1163,7 +1163,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,只需要 PySide6
11631163
| `scripts/dev_release.py` | 只用標準函式庫的發佈輔助腳本 | dev 通道(PyPI 的 `je_auto_control_dev`)的版本與比對:`prepare` 把 `dev.toml` 寫成 `pyproject.toml`,版本取 PyPI 最新一版加一個 patch(`dev.toml` 的版本只是下限,不回寫 repo);`changed dist` 把建好的 wheel 跟 PyPI 最新一版逐檔比對,內容沒變就不上傳。只給 `dev.yml` 的 `publish-dev` job 與拋棄式的 checkout 用,因為 `prepare` 會蓋掉 `pyproject.toml`。 |
11641164
| `docs/` | Sphinx(`API`/`Eng`/`Zh`/`getting_started`) | Read the Docs 文件。 |
11651165
| `architecture_diagram/` | drawio + png | 既有的架構圖原始檔。 |
1166-
| `test/` | `unit_test/headless`(主要)、`unit_test/flow_control`、`integrated_test`、`gui_test`、`manual_test`、`verify`、`test_source` | 846 個 `test_*.py`/8,820 個測試函式。**注意**:`test/unit_test/` 下的 `*_test.py` 是會真的驅動滑鼠鍵盤的手動示範腳本,因此 `pyproject.toml` 把 `python_files` 釘成 `test_*.py`。`unit_test/headless/conftest.py` 有一個 autouse fixture,每個測試結束都沖掉 Qt 排隊中的 `deleteLater()`——不沖會讓殘留的 widget 在後面某個不相干的測試裡被銷毀,曾經整個直譯器 `__fastfail`。`test_doc_counts.py` 守住文件引用的指令/工具/子套件/範例數,`test_doc_line_counts.py` 守住所有行數(`--fix` 可一次重新產生)。 `verify/macos_verify.py` 是在真的 `macos-14` runner 上量測 TCC 到底允許什麼的探針(macOS 是唯一沒有容器可用的支援平台),不被 pytest 收集。 |
1166+
| `test/` | `unit_test/headless`(主要)、`unit_test/flow_control`、`integrated_test`、`gui_test`、`manual_test`、`verify`、`test_source` | 846 個 `test_*.py`/8,825 個測試函式。**注意**:`test/unit_test/` 下的 `*_test.py` 是會真的驅動滑鼠鍵盤的手動示範腳本,因此 `pyproject.toml` 把 `python_files` 釘成 `test_*.py`。`unit_test/headless/conftest.py` 有一個 autouse fixture,每個測試結束都沖掉 Qt 排隊中的 `deleteLater()`——不沖會讓殘留的 widget 在後面某個不相干的測試裡被銷毀,曾經整個直譯器 `__fastfail`。`test_doc_counts.py` 守住文件引用的指令/工具/子套件/範例數,`test_doc_line_counts.py` 守住所有行數(`--fix` 可一次重新產生)。 `verify/macos_verify.py` 是在真的 `macos-14` runner 上量測 TCC 到底允許什麼的探針(macOS 是唯一沒有容器可用的支援平台),不被 pytest 收集。 |
11671167

11681168
---
11691169

@@ -1201,7 +1201,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,只需要 PySide6
12011201
| `quality.yml` | ruff、bandit、dependency review、九格矩陣的 headless pytest(含 coverage 地板)與 mypy。 |
12021202
| `platform-smoke.yml` | 跨平台煙霧測試。 |
12031203
| `docker.yml` | 容器映像建置。 |
1204-
| `ei-lifecycle.yml` | 選定 dev 推送的隔離 libei/libeis 驗證;獨立執行有界半開釋放與 live peer,保留版本/原生紀錄十四天,失敗仍失敗,不開啟裝置。 |
1204+
| `ei-lifecycle.yml` | 選定 dev 推送的隔離 libei/libeis 驗證;獨立執行有界半開釋放與 live peer,保留版本/原生紀錄十四天;live 子行程先釋放 device refs,缺少完成標記或其他失敗不得算通過,不開啟裝置。 |
12051205
| `action-json-lint.yml` | 用 `python -m je_auto_control.utils.action_lint` 檢查 action JSON。 |
12061206

12071207
**設定基線**(`pyproject.toml`):

‎docker/eis_verify.py‎

Lines changed: 39 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@
2020
the key codes, coordinates, button codes and scroll signs intended —
2121
including the axis flip ``mouse.scroll()`` applies between the kernel's
2222
``REL_WHEEL`` frame and libei's, and the negative value that flip makes;
23-
* tearing down a *live* context is finally testable — it is safe, which is
24-
what lets ``_teardown`` release a completed session instead of leaking a
25-
context and an fd per process, and this file is the sentinel for that.
23+
* live cleanup is measured in a bounded child after releasing owned device
24+
refs; successful process exit and both native boundary markers are required.
25+
Unrelated child failures cannot count as evidence for safe teardown.
2626
2727
Two things it measures but cannot settle, and says so rather than claiming
2828
either way: ``eis_device_pause()`` puts nothing on the wire for a sender
@@ -386,47 +386,44 @@ def _check_resume_recovers(backend, server) -> str:
386386

387387

388388
def _live_teardown_sentinel(path: str) -> str:
389-
"""Is ``ei_unref`` safe once the handshake has actually completed?
390-
391-
``libei_verify.py`` established that it segfaults on a context whose
392-
backend opened but never handshook. Whether a *live* context is safe was
393-
untestable without a peer; it is testable now, and it is safe — which is
394-
what lets ``_teardown`` release a completed session rather than leaking
395-
its context. This is therefore a regression sentinel for that decision,
396-
and it runs in a subprocess because the answer is a signal, not an
397-
exception.
398-
"""
399-
program = (
400-
"import sys; sys.path.insert(0, '/opt/verify');"
401-
"from eis_server import RecordingEisServer;"
402-
"from je_auto_control.linux_wayland import libei;"
403-
f"srv = RecordingEisServer({path + '-teardown'!r}); srv.start();"
404-
"b = libei.LibeiBackend();"
405-
f"b.connect(timeout=5.0, socket_path={(path + '-teardown').encode()!r});"
406-
"assert b.is_connected;"
407-
"sym = libei._load_symbols();"
408-
"sym.ei_unref(b._ei);"
409-
"print('survived')"
389+
"""Measure live cleanup after owned device refs are released; unrelated failures never pass."""
390+
program = f"""
391+
import sys
392+
sys.path.insert(0, '/opt/verify')
393+
from eis_server import RecordingEisServer
394+
from je_auto_control.linux_wayland import libei
395+
server = RecordingEisServer({path+'-teardown'!r})
396+
server.start()
397+
backend = libei.LibeiBackend()
398+
backend.connect(timeout=5.0, socket_path={(path+'-teardown').encode()!r})
399+
if not backend.is_connected:
400+
raise RuntimeError('live cleanup never reached a connected device')
401+
symbols = libei._load_symbols()
402+
if symbols is None:
403+
raise RuntimeError('libei symbols did not resolve for live cleanup')
404+
for device in sorted(backend._refs):
405+
symbols.ei_device_unref(device)
406+
print('ei_unref live: begin', flush=True)
407+
symbols.ei_unref(backend._ei)
408+
print('ei_unref live: end', flush=True)
409+
"""
410+
finished = subprocess.run( # nosec B603 # nosemgrep # reason: fixed interpreter/program, isolate native teardown
411+
[sys.executable, '-X', 'faulthandler', '-c', program], capture_output=True,
412+
timeout=60, text=True, encoding='utf-8', errors='replace', check=False,
410413
)
411-
# This interpreter, running a program built from literals above; no shell.
412-
finished = subprocess.run([sys.executable, "-c", program], # nosec B603 # nosemgrep
413-
capture_output=True, timeout=60)
414-
if finished.returncode == 0:
415-
return ("safe, which is what _teardown now relies on to release a "
416-
"completed session instead of leaking its context")
414+
print(finished.stdout, end='')
415+
print(finished.stderr, end='')
416+
began = 'ei_unref live: begin' in finished.stdout.splitlines()
417+
ended = 'ei_unref live: end' in finished.stdout.splitlines()
417418
if finished.returncode == -11:
418-
print()
419-
print(" *** REVISIT *** ei_unref now SEGFAULTS on a live")
420-
print(" context too. LibeiBackend._teardown releases completed")
421-
print(" sessions on the measurement that it is safe, so that")
422-
print(" must go back to abandoning them — this is a crash in a")
423-
print(" library that drives the user's desktop. See Progress.md.")
424-
print()
425-
raise AssertionError(
426-
"ei_unref segfaults on a live context (rc=-11); _teardown's "
427-
"release path is no longer safe on this libei")
428-
detail = finished.stderr.decode("utf-8", "replace").strip().splitlines()
429-
return f"inconclusive (rc={finished.returncode}): {detail[-1] if detail else ''}"
419+
if not began:
420+
raise AssertionError('native fault before live ei_unref boundary; inspect native trace')
421+
raise AssertionError('live ei_unref probe exited with SIGSEGV; inspect its recorded boundary and native trace')
422+
if finished.returncode != 0:
423+
raise AssertionError(f'unexpected live ei_unref probe exit {finished.returncode}')
424+
if not began or not ended:
425+
raise AssertionError('live ei_unref did not reach completed native cleanup')
426+
return 'safe in this probe: owned device refs released, live ei_unref completed (rc=0)'
430427

431428

432429
#: An offset region layout: the shape a compositor must advertise for a

0 commit comments

Comments
 (0)