diff --git a/docs/evidence/issue233-lively-recovery.md b/docs/evidence/issue233-lively-recovery.md new file mode 100644 index 00000000..bf09ef4a --- /dev/null +++ b/docs/evidence/issue233-lively-recovery.md @@ -0,0 +1,77 @@ +# Windows managed wallpaper: #233 + +The offline counterexample reproduced on unmodified Amadeus main +`53f8f4f2b130edcc6e30cf22826b657bbac344ec`. A settled Set was followed by +a mount-confirmation timeout; Lively's simulated list returned no entries, and +its project Close succeeded without removing the managed scene. + +| Same synthetic case | Recovery journal | Owned-host shutdown | +| --- | --- | --- | +| Unmodified main, failed Close | Deleted | Requested | +| With the player check, failed Close | Retained; both errors reported | Not requested | +| Retry after Close actually removes the scene | Deleted | Requested | + +Run `python tools/probes/windows_wallpaper_counterexample.py` and +`python -m pytest tests/test_windows_wallpaper_helper.py`. The latter includes +a real Windows child process with synthetic player arguments, left running +after the fake registry has removed its row. It exercises native process +inspection; it is not a stock-Lively desktop reproduction. The host's +enumeration and close semantics still need independent correction upstream. + +## Span and process-exit regressions + +On PR head `5658a379`, the span repro refused both restoration attempts: the +synthetic registry moved the project from the old primary to the new one while +the real hidden test wrapper kept its original launch arguments. No Close was +sent. With the project-count check, the same repro closed the project once, +restored the original scene and cleared the journal. A separate two-wrapper +case still vetoes a partial report even when an unrelated project is listed. +These are Windows process and lifecycle tests with a simulated registry, not +a physical multi-monitor Lively run. + +The command-line query now translates NTSTATUS using Windows' own mapping. +`STATUS_PROCESS_IS_TERMINATING` reaches the same access-denied handling as +the image query, while an unrelated invalid-parameter error remains distinct. +The two-second post-Close window was introduced by this PR. The nine earlier +stock desktop runs belong to `5658a379`; they do not qualify this revision. + +## Upstream report drafts (not submitted) + +Destination: [lively-community/lively issues](https://github.com/lively-community/lively/issues). +The two reports concern different RPC contracts. Both were checked on the +official 2.2.1.0 release tag (`6860a4093fc50058c4815908658a4391c4449935`) +and default branch (`25a0a5f4ed1988ff42117d3a17feb321062237fe`). The +source findings below do not rely on a modified installed DLL. + +### GetWallpapers can complete successfully with an incomplete inventory + +**Problem.** Clients cannot distinguish an empty desktop from an error +enumerating its wallpapers. An exception after the first row can also yield a +successful partial inventory. + +**Upstream check and evidence.** In +[GetWallpapers](https://github.com/lively-community/lively/blob/25a0a5f4ed1988ff42117d3a17feb321062237fe/src/Lively/Lively/RPC/WinDesktopCoreServer.cs#L197-L241), +the server assigns the possibly null display name to a protobuf string inside +the enumeration loop. Its catch logs the exception and returns normally. +Suggested regression: supply a registry with a null display name, both as the +first and second entry. Return the complete valid records or a failed RPC; +never return success with just the prefix. This is source evidence, not a +new live reproduction of the fault. No matching report was found by method name. + +### CloseWallpaperLibrary success does not establish project/player cleanup + +**Problem.** A project-scoped Close can report success on a metadata error; +even when the registry removes a matching WebView2 wallpaper, the reply does +not establish that its player exited. + +**Upstream check and evidence.** [CloseWallpaperLibrary](https://github.com/lively-community/lively/blob/25a0a5f4ed1988ff42117d3a17feb321062237fe/src/Lively/Lively/RPC/WinDesktopCoreServer.cs#L285-L298) +catches exceptions constructing/closing the library item, then returns an empty +success response. [The core](https://github.com/lively-community/lively/blob/25a0a5f4ed1988ff42117d3a17feb321062237fe/src/Lively/Lively/Core/WinDesktopCore.cs#L981-L1005) +closes/disposes matching players and removes their records; the RPC does not +wait for a verified exit of the specific player. Check bad metadata and an +induced player-close failure separately from a valid project already absent. +The downstream [#233](https://github.com/Code-Amadeus/Amadeus/issues/233) +records both a real IPC close control and a separately labelled simulated +counterexample. [Lively #2939](https://github.com/lively-community/lively/issues/2939) +concerns browser subprocesses after switching wallpapers, not this RPC's +completion contract. diff --git a/docs/windows_wallpaper_lifecycle.md b/docs/windows_wallpaper_lifecycle.md index 9e8ac6d1..030f0002 100644 --- a/docs/windows_wallpaper_lifecycle.md +++ b/docs/windows_wallpaper_lifecycle.md @@ -205,22 +205,34 @@ Lively 不会继续迟到挂载。 回退旧 helper 前,须先用本 helper 处理未完成日志。旧程序不理解 `Phase`;日志尚存 时不支持降级,不会为允许降级而重置或删除恢复记录。 -#264 replaces the helper runtime while preserving phase-aware rollback and error -preservation. It does -**not** repair stock Lively 2.2.1.0's reporting/close protocol: `GetWallpapers` -can return a falsely empty stream, and project close can swallow errors and -return success without removing a player. A close response plus an empty stream -cannot prove strong recovery or player exit. For an originally empty desktop, -the retained baseline empty-report behavior can still delete the journal and -request owned-host shutdown while an unreported scene remains. This is a known -unresolved limitation, not an accepted strong-recovery guarantee. Scene removal -is not inferred from a PID, a layout file or a timing heuristic to hide it. - -本次替换 helper 运行时,保留按阶段回滚及异常合同,**未修复**原版 Lively 2.2.1.0 的报告及关闭 -协议:`GetWallpapers` 可能假空,项目关闭也可能吞错后返回成功却没有移除播放器。 -关闭回执加空流不能证明强恢复或播放器退出。对于原来为空的桌面,保留的 baseline -空报告行为仍可能在未报告场景存在时删除日志并请求停止自有宿主。这是明确未解决的 -限制,不能视为强恢复已验收;本轮不以 PID、磁盘 layout 或延时推断场景移除来掩盖它。 +**#233 — missing scene / surviving player.** Stock Lively 2.2.1.0 can return an +empty or partial list on enumeration error and acknowledge a close without +waiting for its player to exit. Amadeus mounts its loopback wallpaper using +Lively's WebView2 player. Before saving a before-image or committing recovery, +the helper counts live wrappers for the Amadeus loopback URL in the current +Windows session. If that count exceeds the number of entries for the managed +project, the list cannot confirm cleanup. It does not match the player's launch display +to a registry row: in span mode [Lively can update the primary display without +relaunching the player](https://github.com/lively-community/lively/blob/6860a4093fc50058c4815908658a4391c4449935/src/Lively/Lively/Core/WinDesktopCore.cs#L742-L750). +Counts can veto cleanup; the existing project and monitor checks still decide +which scenes can be restored. After a scoped Close, the helper allows up to two +seconds for fresh observations without issuing Close again. If the discrepancy +or an access denial persists, the recovery journal is retained and host shutdown +is blocked. Retry `recover` once the conflict is resolved; do not delete the journal. + +This check covers the managed WebView2 wrapper, not the complete registry of +all Lively plugins. A preview or another project using the same Amadeus endpoint +can also make the observation ambiguous; close that preview before retrying. +Absence of a wrapper does not certify an arbitrary empty/partial host registry, +or exit of every descendant. Those guarantees need truthful results from +[Lively's list and close RPCs](https://github.com/lively-community/lively/blob/6860a4093fc50058c4815908658a4391c4449935/src/Lively/Lively/RPC/WinDesktopCoreServer.cs#L197-L298). + +**#233:场景漏报,但播放器仍在运行。** helper 在保存桌面原状和确认恢复前,对比当前 +Windows 会话内使用 Amadeus 回环地址的 WebView2 播放器数量与托管项目的登记条目数。 +播放器多于条目才构成漏报。跨屏模式更换主屏时,Lively 会更新登记的显示器,不会重启 +播放器,因此不比较启动参数里的旧屏号。数量只用于否决清理;原有项目和显示器检查 +仍决定恢复哪张壁纸。对指定项目发出 Close 后,最多等两秒重新核验;持续漏报或拒绝 +访问时,保留恢复日志,不关闭宿主。排除冲突后可重试 `recover`,不要删除日志。 Deterministic contracts separate fake actual state from reported state and cover rollback, retries, late requests, user choices, journal compatibility and both @@ -229,16 +241,17 @@ execute before Session persists the actual pipe receiver, and executes zero time when identity, persistence or transport binding fails. These pipes never connect to Lively or change the desktop. They also cover a connection arriving after its response deadline and a persistence boundary blocked across that deadline. The -separate `--false-empty-counterexample` diagnostic records the -unresolved false-empty/no-op-close case without adding it to the passing contract -count. These checks do not establish real Lively mounting or close completion. +separate counterexample probe records the failed close and subsequent retry. +A native contract keeps an actual hidden test process alive after its registry +row is removed. It exercises the production Windows observer without replacing +the user's wallpaper. These checks do not certify stock Lively's whole lifecycle. 离线契约分离 fake 真实状态与报告状态,覆盖回滚、重试、迟到请求、用户选择、日志 兼容与双重异常。随机命名的本地管道契约还核验:Session 持久保存实际接收进程前 Set 不得执行,身份、持久化或传输绑定失败时执行次数为零;这些管道不连接 Lively, 也不改变桌面;还覆盖响应超时后连接迟到、持久化边界阻塞跨越响应超时的竞态。 -独立 `--false-empty-counterexample` 诊断记录未解决的假空及关闭 -no-op 反例,不计入契约通过数;这些检查不证明真实 Lively 挂载或关闭完成。 +独立反例探针记录假空、关闭未生效以及重试的结果;原生契约用真正的测试子进程验证 +漏报检测,不操作用户桌面,不等于原版 Lively 全链路实机验收。 If Lively must be started with its screenshot-to-wallpaper options temporarily disabled, `Lively Wallpaper\Amadeus.launch-settings.json` backs up just those diff --git a/electron/resources/windows-wallpaper/helper.py b/electron/resources/windows-wallpaper/helper.py index 38627ef7..bfd98399 100644 --- a/electron/resources/windows-wallpaper/helper.py +++ b/electron/resources/windows-wallpaper/helper.py @@ -215,6 +215,14 @@ def same_path(left: str, right: str) -> bool: return result == 2 # CSTR_EQUAL +def managed_url(raw: str) -> bool: + try: + url = urlsplit(raw) + return url.scheme == "http" and url.hostname == "127.0.0.1" and url.path == "/wallpaper/lively/index.html" + except ValueError: + return False + + class Session: """The journal owns recovery; process ownership never substitutes for it.""" def __init__(self, host, journal: Path): @@ -236,6 +244,28 @@ def read(self) -> Snapshot: def save(self, snapshot: Snapshot) -> None: atomic_json(self.journal, asdict(snapshot)) + def confirm_player_report(self, project, reported, *, closing=False): + # Lively removes its list entry without waiting for WebView2 to exit. + # Await that postcondition only after our scoped Close; never resend it. + deadline = time.monotonic() + (2.0 if closing else 0) + while True: + try: + if not self.host.unlisted_players(project, reported): + return reported + except PermissionError as error: + # A terminating wrapper can deny image or command-line queries + # before its handle is signalled. Denial is not exit proof: + # require a fresh registry AND kernel inventory within the + # same Close deadline, or propagate the inspection error. + if getattr(error, "winerror", None) != 5 or time.monotonic() >= deadline: + raise + else: + if time.monotonic() >= deadline: + raise OSError("Lively reports fewer entries for this project than running Amadeus wallpaper " + "players. Retry when the scene list and player count agree.") + time.sleep(0.1) + reported = self.host.get_wallpapers() + def release_unstarted_host(self, process: ProcessIdentity | None) -> None: if process is not None and process != self.managed_cleanup_process and not self.journal.exists(): self.host.stop_owned_process(process) @@ -243,6 +273,13 @@ def release_unstarted_host(self, process: ProcessIdentity | None) -> None: def mount(self, snapshot: Snapshot) -> None: if self.journal.exists(): raise RuntimeError("Recover the previous wallpaper session first.") + # An already visible contradiction is not a usable before-image. + # Do not release a host that may still be displaying that scene. + try: + self.confirm_player_report(snapshot.Project, snapshot.Wallpapers) + except OSError: + self.managed_cleanup_process = snapshot.OwnedProcess + raise snapshot = replace(snapshot, Mounted=False, Phase="Preparing", RequestProcess=None) self.save(snapshot) self.managed_cleanup_process = snapshot.OwnedProcess @@ -306,6 +343,7 @@ def restore(self, restarted_process: ProcessIdentity | None = None) -> None: self.host.restore_options(snapshot.Options) options = self.host.get_options() current = self.host.get_wallpapers() + self.confirm_player_report(snapshot.Project, current) changed = options.Arrangement != snapshot.Options.Arrangement or any( not any(old.Monitor == x.Monitor and same_path(old.Path, x.Path) for old in snapshot.Wallpapers) for x in current) @@ -323,7 +361,13 @@ def restore(self, restarted_process: ProcessIdentity | None = None) -> None: (not snapshot.Mounted or restarted_process is not None) and all(x.Monitor != monitor for x in current))} if snapshot.Options.Arrangement != 0 and ours: restorable = affected - if ours or (snapshot.Phase == "Settled" and not snapshot.Mounted): + unconfirmed_mount = snapshot.Phase == "Settled" and not snapshot.Mounted + if not unconfirmed_mount: + # In particular, do not interpret an omitted mounted scene as the + # user having cleared the display. The player cannot authorize us + # to choose a replacement scene or change the saved before-image. + self.confirm_player_report(snapshot.Project, current) + if ours or unconfirmed_mount: self.save(replace(snapshot, Mounted=False)) self.host.close_project(snapshot.Project) if layout_unchanged: @@ -332,7 +376,7 @@ def restore(self, restarted_process: ProcessIdentity | None = None) -> None: if wallpaper.Monitor in restorable: self.host.set_wallpaper(wallpaper) self.host.restore_options(snapshot.Options) - after = self.host.get_wallpapers() + after = self.confirm_player_report(snapshot.Project, self.host.get_wallpapers(), closing=bool(ours or unconfirmed_mount)) if any(same_path(x.Path, snapshot.Project) and not any( old.Monitor == x.Monitor and same_path(old.Path, x.Path) for old in snapshot.Wallpapers) for x in after) or ( layout_unchanged and any(not any(x.Monitor == old.Monitor and same_path(x.Path, old.Path) for x in after) @@ -340,7 +384,7 @@ def restore(self, restarted_process: ProcessIdentity | None = None) -> None: raise OSError("Wallpaper restoration was not confirmed; recovery journal retained.") user_changed = not layout_unchanged or any(not any( old.Monitor == x.Monitor and same_path(old.Path, x.Path) for old in snapshot.Wallpapers) for x in after) - if snapshot.OwnedProcess and not user_changed: + if snapshot.OwnedProcess and not user_changed and self.host.get_options().Arrangement == snapshot.Options.Arrangement: self.host.stop_owned_process(restarted_process or snapshot.OwnedProcess) self.journal.unlink() @@ -388,6 +432,10 @@ def __init__(self): self.process_api = ctypes.WinDLL("ntdll", use_last_error=True) self.process_api.NtQuerySystemInformation.argtypes = [w.ULONG, w.LPVOID, w.ULONG, ctypes.POINTER(w.ULONG)] self.process_api.NtQuerySystemInformation.restype = w.LONG + self.process_api.NtQueryInformationProcess.argtypes = [w.HANDLE, w.ULONG, w.LPVOID, w.ULONG, ctypes.POINTER(w.ULONG)] + self.process_api.NtQueryInformationProcess.restype = w.LONG + self.process_api.RtlNtStatusToDosError.argtypes = [w.LONG] + self.process_api.RtlNtStatusToDosError.restype = w.ULONG def check(self, result): if not result: @@ -478,26 +526,90 @@ def processes(self): offset += entry.NextEntryOffset def running(self) -> ProcessIdentity | None: - current = w.DWORD() - self.check(self.api.ProcessIdToSessionId(os.getpid(), ctypes.byref(current))) # Session membership comes from the system snapshot, without opening # foreign processes. A selected same-session identity must still verify. - for pid, session, image in self.processes(): - if session == current.value and image.lower() == "lively.exe": - try: - return self.identity(pid) - except ProcessLookupError: - pass + for pid in self.session_processes("lively.exe"): + try: + return self.identity(pid) + except ProcessLookupError: + pass return None + def session_processes(self, name): + current = self.session_id(os.getpid()) + return (pid for pid, session, image in self.processes() if session == current and image.lower() == name) + + def session_id(self, pid): + session = w.DWORD() + self.check(self.api.ProcessIdToSessionId(pid, ctypes.byref(session))) + return session.value + + def player_argv(self, pid): + handle = self.open_process(pid) + if handle is None: + return [] + try: + # The PID can be reused between enumeration and OpenProcess. + # Recheck the image on the handle used to read the command line. + if (self.session_id(pid) != self.session_id(os.getpid()) + or Path(self.image_path(handle)).name.lower() != "lively.player.webview2.exe"): + return [] + # ProcessCommandLineInformation returns a UNICODE_STRING plus its + # text in our own buffer. Do not inspect arbitrary process memory. + size = ctypes.sizeof(UNICODE_STRING) + 65536 + buffer, used = ctypes.create_string_buffer(size), w.ULONG() + status = self.process_api.NtQueryInformationProcess(handle, 60, buffer, size, ctypes.byref(used)) & 0xFFFFFFFF + if status: + raise ctypes.WinError(self.process_api.RtlNtStatusToDosError(status), + f"Cannot inspect wallpaper player command line (NTSTATUS 0x{status:08x}).") + line = UNICODE_STRING.from_buffer(buffer) + base = ctypes.addressof(buffer) + if not (ctypes.sizeof(UNICODE_STRING) <= used.value <= size and line.Length and not line.Length % 2 + and base + ctypes.sizeof(UNICODE_STRING) <= (line.Buffer or 0) + and line.Buffer + line.Length <= base + used.value): + raise OSError("Invalid wallpaper player command line; recovery not confirmed.") + argc = ctypes.c_int() + split = ctypes.WinDLL("shell32", use_last_error=True).CommandLineToArgvW + split.argtypes, split.restype = [w.LPCWSTR, ctypes.POINTER(ctypes.c_int)], ctypes.POINTER(w.LPWSTR) + argv = self.check(split(ctypes.wstring_at(line.Buffer, line.Length // 2), ctypes.byref(argc))) + try: + return argv[:argc.value] + finally: + free = self.api.LocalFree + free.argtypes, free.restype = [w.HLOCAL], w.HLOCAL + free(ctypes.cast(argv, w.HLOCAL)) + except OSError: + if self.api.WaitForSingleObject(handle, 0) == 0: + return [] + raise + finally: + self.close(handle) + + def managed_player_count(self) -> int: + count = 0 + for pid in self.session_processes("lively.player.webview2.exe"): + args = self.player_argv(pid) + if not args: + continue # exited before inspection + # Do not log command lines: other wallpapers' URLs may contain secrets. + urls = [args[i + 1] for i in range(len(args) - 1) if args[i] == "--wallpaper-url"] + if len(urls) != 1: + raise OSError("Cannot identify the Lively wallpaper player; recovery not confirmed.") + if managed_url(urls[0]): + count += 1 + return count + + def image_path(self, handle): + size, buffer = w.DWORD(32768), ctypes.create_unicode_buffer(32768) + self.check(self.api.QueryFullProcessImageNameW(handle, 0, buffer, ctypes.byref(size))) + return buffer.value + def executable(self, pid: int) -> str: handle = self.open_process(pid) if handle is None: raise ProcessLookupError("Lively exited") try: - size, buffer = w.DWORD(32768), ctypes.create_unicode_buffer(32768) - self.check(self.api.QueryFullProcessImageNameW(handle, 0, buffer, ctypes.byref(size))) - return buffer.value + return self.image_path(handle) finally: self.close(handle) @@ -870,6 +982,12 @@ def get_wallpapers(self) -> list[DesktopItem]: return [DesktopItem(text_value(value(item, 2, b"", 2), 1), text_value(item, 1)) for item in self.rpc.call("Desktop", "GetWallpapers", streaming=True)] + def unlisted_players(self, project, reported): + # A span player can be reassigned to a new primary without relaunch. + # Its launch display is not its identity. Each reported project row + # accounts for at most one live player; restoration still checks monitors. + return self.windows.managed_player_count() > sum(same_path(x.Path, project) for x in reported) + def get_screens(self): screens = self.rpc.call("Display", "GetScreens")[0] return [(DesktopItem(text_value(f.value, 1), ""), bool(value(f.value, 6))) @@ -973,8 +1091,7 @@ def main(args=None) -> int: if command not in ("run", "recover"): raise ValueError("Expected inspect, prepare, run or recover.") if command == "run": - url = urlsplit(args[1]) if len(args) == 2 else None - if url is None or url.scheme != "http" or url.hostname != "127.0.0.1" or url.path != "/wallpaper/lively/index.html": + if len(args) != 2 or not managed_url(args[1]): raise ValueError("Expected the local Amadeus wallpaper wrapper URL.") lock = windows.lock(state / "session.lock") if command == "recover" and not journal.exists(): diff --git a/electron/scripts/probe-windows-wallpaper.cjs b/electron/scripts/probe-windows-wallpaper.cjs index f9493418..d26eaca8 100644 --- a/electron/scripts/probe-windows-wallpaper.cjs +++ b/electron/scripts/probe-windows-wallpaper.cjs @@ -61,7 +61,7 @@ void (async () => { const current = await inspect() const slices = BrowserWindow.getAllWindows().filter(window => window.webContents.getURL().includes('/render/web/electron_slice.html') && !window.webContents.isLoadingMainFrame()) - if (current.wallpapers.some(item => path.basename(item.Path) === 'amadeus-managed') && slices.length > 0) { + if (current.wallpapers?.some(item => path.basename(item.Path) === 'amadeus-managed') && slices.length > 0) { await new Promise(resolve => setTimeout(resolve, 10000)) try { await executeHelper(['screenshot', path.join(output, 'electron-mounted-scene.jpg')]) diff --git a/electron/tests/fixtures/wallpaper_helper_fixture.py b/electron/tests/fixtures/wallpaper_helper_fixture.py index 9b75e2db..4700ee1e 100644 --- a/electron/tests/fixtures/wallpaper_helper_fixture.py +++ b/electron/tests/fixtures/wallpaper_helper_fixture.py @@ -49,6 +49,12 @@ def get_options(self): def get_wallpapers(self): return list(self.items) + def unlisted_players(self, project, reported): + # This fixture owns its synthetic desktop; never inspect real players + # on the machine running the Electron parent-lifetime tests. + return sum(h.same_path(item.Path, project) for item in self.items) > sum( + h.same_path(item.Path, project) for item in reported) + def set_session_options(self): pass diff --git a/tests/test_windows_wallpaper_helper.py b/tests/test_windows_wallpaper_helper.py index 87004ab7..670802fb 100644 --- a/tests/test_windows_wallpaper_helper.py +++ b/tests/test_windows_wallpaper_helper.py @@ -51,6 +51,9 @@ def get_wallpapers(self): self.reads += 1 return list(self.items if self.reported is None else self.reported) + def unlisted_players(self, project, reported): + return sum(h.same_path(x.Path, project) for x in self.items) > sum(h.same_path(x.Path, project) for x in reported) + def set_wallpaper(self, wallpaper, request_settled=None, request_dispatching=None): if request_dispatching: if self.dispatch: @@ -160,6 +163,108 @@ def test_static_wallpaper_revealed_and_owned_host_released(f): assert not session.journal.exists() +def test_false_empty_after_noop_close_retains_journal_until_retry(f): + host, session = f + owned = h.ProcessIdentity(10, 20) + host.reported, host.close_noop, host.fail_after = [], True, True + error = failure(lambda: session.mount(before(host, owned))) + assert error.exceptions[0] is host.mount_failure + assert isinstance(error.exceptions[1], OSError) + assert host.items == [h.DesktopItem("primary", "amadeus")] + assert session.journal.exists() and host.stopped is None + host.close_noop = False + h.Session(host, session.journal).restore() + assert host.items == [] and not session.journal.exists() and host.stopped == owned + + +@pytest.mark.parametrize("mounted_before_error", [True, False]) +def test_genuinely_empty_desktop_can_finish_settled_rollback(f, mounted_before_error): + host, session = f + host.reported = [] + host.fail_after, host.fail_next = mounted_before_error, not mounted_before_error + error = failure(lambda: session.mount(before(host, host.running))) + assert not isinstance(error, ExceptionGroup) + assert not host.items and host.stopped == host.running and not session.journal.exists() + + +def test_omitted_mounted_scene_is_not_a_user_clear_or_permission_to_replace_it(f): + host, session = f + original(host) + session.mount(before(host, host.running)) + prior_journal = session.journal.read_bytes() + host.reported = [] + assert isinstance(failure(session.restore), OSError) + assert session.journal.read_bytes() == prior_journal and not host.closed and host.stopped is None + host.reported = None + session.restore() + assert host.items == [h.DesktopItem("primary", "old")] and not session.journal.exists() + + +def test_incomplete_before_image_cannot_acquire_or_shut_down_host(f): + host, session = f + snapshot = before(host, host.running) + host.items = [h.DesktopItem("primary", "amadeus")] + assert isinstance(failure(lambda: session.mount(snapshot)), OSError) + session.release_unstarted_host(host.running) + assert host.set_calls == 0 and host.stopped is None and not session.journal.exists() + + +def test_confirmation_refresh_keeps_user_wallpaper_chosen_while_player_exits(f): + host, session = f + session.mount(before(host, host.running)) + waiting = True + def unlisted(project, reported): + nonlocal waiting + if not reported and waiting: + waiting = False + host.items = [h.DesktopItem("primary", "new user scene")] + return True + return False + host.unlisted_players = unlisted + session.restore() + assert not session.journal.exists() and host.stopped is None + assert host.items == [h.DesktopItem("primary", "new user scene")] + + +def test_unavailable_player_evidence_retains_the_original_mount_error(f): + host, session = f + host.fail_after = True + denied = PermissionError("cannot inspect player") + denied.winerror = 5 + def unlisted(project, reported): + if host.closed: + raise denied + return False + host.unlisted_players = unlisted + error = failure(lambda: session.mount(before(host, host.running))) + assert error.exceptions == (host.mount_failure, denied) + assert session.journal.exists() and host.stopped is None + + +def test_dying_player_access_denial_needs_new_scene_and_process_evidence(f): + host, session = f + session.mount(before(host, host.running)) + denied = PermissionError("terminating wrapper") + denied.winerror = 5 + observations = [] + + def inspect(project, reported): + if not host.closed: + return False + observations.append(list(reported)) + if len(observations) == 1: + # While inspection is unavailable the user chooses a new wallpaper. + host.items = [h.DesktopItem("primary", "new user scene")] + raise denied + assert session.journal.exists() and host.stopped is None + return False # a new OS inventory can now establish no managed wrapper + + host.unlisted_players = inspect + session.restore() + assert observations == [[], [h.DesktopItem("primary", "new user scene")]] + assert host.closed == ["amadeus"] and host.stopped is None and not session.journal.exists() + + @pytest.mark.parametrize("choice", ["user-new", None, "layout"]) def test_user_choices_win(f, choice): host, session = f @@ -671,6 +776,138 @@ def persist(identity): native = pytest.mark.skipif(sys.platform != "win32", reason="Native Windows process/message-pipe contract") +@native +def test_helper_run_reports_pending_and_recover_retries_the_failed_close(tmp_path, monkeypatch): + host = FakeHost() + host.started_process = host.running + host.ensure_running = lambda: None + host.write_project = lambda url: "amadeus" + host.primary_monitor = lambda: "primary" + host.reported, host.close_noop, host.fail_after = [], True, True + + class Lock: + def lock(self, file): + return 1 + + def close(self, handle): + pass + + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + monkeypatch.setattr(h, "Windows", Lock) + monkeypatch.setattr(h, "LivelyHost", lambda windows: host) + reports = [] + monkeypatch.setattr(h, "report", reports.append) + assert h.main(["run", "http://127.0.0.1:12345/wallpaper/lively/index.html"]) == 1 + assert reports[-1]["status"] == "error" and reports[-1]["recoveryPending"] is True + assert "mount confirmation timed out" in reports[-1]["error"] + assert host.stopped is None and host.items + host.close_noop = False + assert h.main(["recover"]) == 0 and reports[-1]["status"] == "restored" + assert not host.items and host.stopped == host.started_process + + +@pytest.fixture +def native_players(tmp_path, monkeypatch): + import shutil + + windows = h.Windows() + observer = h.LivelyHost(windows, tmp_path, object()) + wrapper = tmp_path / "Lively.Player.WebView2.exe" + shutil.copyfile(os.environ["ComSpec"], wrapper) + players = {} + processes = windows.processes + monkeypatch.setattr(windows, "processes", lambda: (p for p in processes() if p[0] in players)) + # /K REM accepts the same quoted arguments as Lively's wrapper and stays + # alive on the test's pipe. No Lively instance or desktop is involved. + def launch(display): + child = subprocess.Popen( + [str(wrapper), "/D", "/Q", "/K", "REM", "--wallpaper-url", + "http://127.0.0.1:50123/wallpaper/lively/index.html", "--wallpaper-display", display], + stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + players[child.pid] = child + return child + + try: + yield observer, launch + finally: + for child in players.values(): + with child: + if child.poll() is None: + child.kill() + child.wait(timeout=5) + + +@native +def test_recovery_observes_a_real_player_independently_of_the_host_registry(f, native_players): + """An ack may remove the registry row before the wrapper actually exits.""" + host, session = f + owned = h.ProcessIdentity(10, 20) + session.mount(before(host, owned)) + observer, launch = native_players + host.unlisted_players = observer.unlisted_players + display = "synthetic monitor 1" + host.items = [h.DesktopItem(display, "amadeus")] + player = launch(display) + assert not observer.unlisted_players("amadeus", host.items) + assert observer.unlisted_players("amadeus", [h.DesktopItem(display, "different project")]) + # Fake close removes its actual registry entry; it does not own our OS process. + assert isinstance(failure(session.restore), OSError) + assert host.items == [] and host.stopped is None and session.journal.exists() + player.kill() + player.wait(timeout=5) + h.Session(host, session.journal).restore() + assert not session.journal.exists() and host.stopped == owned + + +@native +def test_span_primary_change_allows_close_and_restores_previous_scene(f, native_players): + host, session = f + observer, launch = native_players + host.unlisted_players = observer.unlisted_players + host.options = replace(host.options, Arrangement=1) + previous = h.DesktopItem("primary", "original-span-project") + host.items = [previous] + session.mount(before(host)) + player = launch("primary") + # Lively reassigns Screen to the new primary without relaunching a span player. + host.items = [h.DesktopItem("new-primary", "amadeus")] + close = host.close_project + + def close_and_release(project): + close(project) + player.kill() + player.wait(timeout=5) + + host.close_project = close_and_release + session.restore() + assert host.closed == ["amadeus"] and host.items == [previous] + assert host.stopped is None and not session.journal.exists() + + +@native +def test_partial_inventory_of_duplicate_players_is_still_a_veto(native_players): + observer, launch = native_players + launch("primary") + launch("secondary") + reports = [h.DesktopItem("primary", "amadeus"), h.DesktopItem("secondary", "user-project")] + assert observer.unlisted_players("amadeus", reports) + assert not observer.unlisted_players("amadeus", [reports[0], h.DesktopItem("secondary", "amadeus")]) + + +@native +@pytest.mark.parametrize("ntstatus,winerror", [(0xC000010A, 5), (0xC000000D, 87)]) +def test_native_argv_error_preserves_win32_classification(monkeypatch, ntstatus, winerror): + windows = h.Windows() + # Keep the test process alive so a signalled handle cannot mask the error. + monkeypatch.setattr(windows, "image_path", lambda _: "Lively.Player.WebView2.exe") + monkeypatch.setattr(windows.process_api, "NtQueryInformationProcess", lambda *args: ntstatus) + error = failure(lambda: windows.player_argv(os.getpid())) + assert isinstance(error, OSError) and getattr(error, "winerror", None) == winerror + assert isinstance(error, PermissionError) == (winerror == 5) + + def test_running_filters_snapshot_sessions_before_opening_processes(): windows = h.Windows.__new__(h.Windows) class Kernel: diff --git a/tools/probes/windows_wallpaper_counterexample.py b/tools/probes/windows_wallpaper_counterexample.py index 409f609b..b599aed7 100644 --- a/tools/probes/windows_wallpaper_counterexample.py +++ b/tools/probes/windows_wallpaper_counterexample.py @@ -1,4 +1,4 @@ -"""Known #233 false-empty/no-op-close diagnostic; NEVER an acceptance pass.""" +"""Replay #233 against the lifecycle helper, using a fake host (no desktop).""" from dataclasses import asdict import json from pathlib import Path @@ -15,12 +15,18 @@ def main(): with tempfile.TemporaryDirectory(prefix="amadeus-wallpaper-counterexample-") as temporary: session = helper.Session(fake, Path(temporary) / "session.json") error = contract["failure"](lambda: session.mount(contract["before"](fake, helper.ProcessIdentity(10, 20)))) - print(json.dumps(dict(diagnostic="known false-empty protocol counterexample; not a recovery acceptance pass", - error=str(error), actual=[asdict(x) for x in fake.items], reported=fake.get_wallpapers(), - closeReturnedWithoutRemoval=bool(fake.closed and fake.items), - journalRetained=session.journal.exists(), stopRequested=fake.stopped is not None, - strongRecoveryProven=False), indent=2)) + observation = dict(error=helper.error_detail(error), actual=[asdict(x) for x in fake.items], + reported=fake.get_wallpapers(), closeReturnedWithoutRemoval=bool(fake.closed and fake.items), + journalRetained=session.journal.exists(), stopRequested=fake.stopped is not None) + fake.close_noop = False + session.restore() + retry = dict(actual=[asdict(x) for x in fake.items], journalRetained=session.journal.exists(), + stopRequested=fake.stopped is not None) + print(json.dumps(dict(scope="offline #233 counterexample", failedClose=observation, retry=retry), indent=2)) + return 0 if (observation["closeReturnedWithoutRemoval"] and observation["journalRetained"] + and not observation["stopRequested"] and not retry["actual"] + and not retry["journalRetained"] and retry["stopRequested"]) else 1 if __name__ == "__main__": - main() + raise SystemExit(main())