更新
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
"""Read-only-ish timing probe for the desktop update commit sequence.
|
||||
|
||||
The probe imports production code and replaces only its external download/apply
|
||||
edges in memory. It does not modify production sources or existing tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import weakref
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QEvent, QObject, QThreadPool, QTimer
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.services import app_update as update_service
|
||||
from doctor_workstation.services.app_update import (
|
||||
PACKAGE_TYPE_INNO_SETUP,
|
||||
UpdateOffer,
|
||||
UpdatePackage,
|
||||
)
|
||||
from doctor_workstation.ui.dialogs import app_update as update_ui
|
||||
|
||||
|
||||
def _record(events: list[dict[str, Any]], name: str, started: float) -> None:
|
||||
events.append(
|
||||
{
|
||||
"event": name,
|
||||
"elapsed_ms": round((time.perf_counter() - started) * 1000, 3),
|
||||
"thread_id": threading.get_ident(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def exercise_session(iterations: int = 25) -> dict[str, Any]:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
main_thread = threading.get_ident()
|
||||
original_edges = {
|
||||
"is_frozen_install": update_ui.is_frozen_install,
|
||||
"frozen_install_root": update_ui.frozen_install_root,
|
||||
"download_package": update_ui.download_package,
|
||||
"apply_downloaded_update": update_ui.apply_downloaded_update,
|
||||
}
|
||||
failures: list[dict[str, Any]] = []
|
||||
samples: list[list[dict[str, Any]]] = []
|
||||
collected_signals = 0
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="zyt-update-commit-") as raw_tmp:
|
||||
temp_root = Path(raw_tmp)
|
||||
install_root = temp_root / "installed"
|
||||
install_root.mkdir()
|
||||
(install_root / "DoctorWorkstation.exe").write_bytes(b"MZ")
|
||||
update_ui.is_frozen_install = lambda: True
|
||||
update_ui.frozen_install_root = lambda: install_root
|
||||
|
||||
for index in range(iterations):
|
||||
run_root = temp_root / f"run-{index}"
|
||||
run_root.mkdir()
|
||||
events: list[dict[str, Any]] = []
|
||||
started = time.perf_counter()
|
||||
|
||||
def fake_download(
|
||||
_url: str,
|
||||
destination: Path,
|
||||
*,
|
||||
_events: list[dict[str, Any]] = events,
|
||||
_started: float = started,
|
||||
**kwargs: Any,
|
||||
) -> Path:
|
||||
_record(_events, "download_enter", _started)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(b"MZ" + b"probe")
|
||||
progress = kwargs.get("progress")
|
||||
if callable(progress):
|
||||
progress(7, 7)
|
||||
_record(_events, "download_return", _started)
|
||||
return destination
|
||||
|
||||
def fake_apply(
|
||||
_payload: Path,
|
||||
*,
|
||||
package_type: str,
|
||||
_events: list[dict[str, Any]] = events,
|
||||
_started: float = started,
|
||||
) -> None:
|
||||
assert package_type == PACKAGE_TYPE_INNO_SETUP
|
||||
_record(_events, "apply_enter", _started)
|
||||
_record(_events, "apply_return", _started)
|
||||
|
||||
update_ui.download_package = fake_download
|
||||
update_ui.apply_downloaded_update = fake_apply
|
||||
|
||||
host = QObject()
|
||||
host.config = SimpleNamespace( # type: ignore[attr-defined]
|
||||
config_dir=run_root,
|
||||
verify_ssl=True,
|
||||
)
|
||||
|
||||
def request_quit(
|
||||
_events: list[dict[str, Any]] = events,
|
||||
_started: float = started,
|
||||
) -> None:
|
||||
_record(_events, "request_quit", _started)
|
||||
|
||||
host.request_quit = request_quit # type: ignore[attr-defined]
|
||||
session = update_ui.AppUpdateSession(host)
|
||||
offer = UpdateOffer(
|
||||
has_update=True,
|
||||
force=True,
|
||||
enabled=True,
|
||||
current_version="1.0.0",
|
||||
latest_version=f"1.0.{index + 1}",
|
||||
min_version="",
|
||||
title="probe",
|
||||
notes="probe",
|
||||
platform="windows",
|
||||
arch="x64",
|
||||
package=UpdatePackage(
|
||||
url="https://example.invalid/DoctorWorkstation-Setup.exe",
|
||||
sha256="a" * 64,
|
||||
size=7,
|
||||
filename="DoctorWorkstation-Setup.exe",
|
||||
type=PACKAGE_TYPE_INNO_SETUP,
|
||||
),
|
||||
can_install=True,
|
||||
)
|
||||
dialog = update_ui.AppUpdateDialog(offer)
|
||||
session.dialog = dialog
|
||||
|
||||
original_finish = session._finish_install
|
||||
original_finished = session._on_install_finished
|
||||
|
||||
def finish_probe(
|
||||
*args: Any,
|
||||
_events: list[dict[str, Any]] = events,
|
||||
_started: float = started,
|
||||
_original: Any = original_finish,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
_record(_events, "result_slot_enter", _started)
|
||||
_original(*args, **kwargs)
|
||||
_record(_events, "result_slot_return", _started)
|
||||
|
||||
def finished_probe(
|
||||
*args: Any,
|
||||
_events: list[dict[str, Any]] = events,
|
||||
_started: float = started,
|
||||
_original: Any = original_finished,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
_record(_events, "finished_slot_enter", _started)
|
||||
_original(*args, **kwargs)
|
||||
_record(_events, "finished_slot_return", _started)
|
||||
|
||||
session._finish_install = finish_probe # type: ignore[method-assign]
|
||||
session._on_install_finished = finished_probe # type: ignore[method-assign]
|
||||
session._start_install(dialog, offer)
|
||||
signal_ref = weakref.ref(session._active_install_signals)
|
||||
|
||||
deadline = time.perf_counter() + 3.0
|
||||
while time.perf_counter() < deadline:
|
||||
app.processEvents()
|
||||
if any(item["event"] == "request_quit" for item in events):
|
||||
break
|
||||
time.sleep(0.001)
|
||||
QThreadPool.globalInstance().waitForDone(3000)
|
||||
app.processEvents()
|
||||
|
||||
names = [item["event"] for item in events]
|
||||
expected = [
|
||||
"download_enter",
|
||||
"download_return",
|
||||
"result_slot_enter",
|
||||
"apply_enter",
|
||||
"apply_return",
|
||||
"result_slot_return",
|
||||
"finished_slot_enter",
|
||||
"request_quit",
|
||||
"finished_slot_return",
|
||||
]
|
||||
slot_threads = {
|
||||
item["thread_id"]
|
||||
for item in events
|
||||
if item["event"] in {"result_slot_enter", "finished_slot_enter", "request_quit"}
|
||||
}
|
||||
if names != expected or slot_threads != {main_thread}:
|
||||
failures.append(
|
||||
{
|
||||
"iteration": index,
|
||||
"events": events,
|
||||
"main_thread_id": main_thread,
|
||||
}
|
||||
)
|
||||
if index < 3:
|
||||
samples.append(events)
|
||||
|
||||
session.deleteLater()
|
||||
dialog.deleteLater()
|
||||
del session, dialog, host
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
app.processEvents()
|
||||
gc.collect()
|
||||
app.processEvents()
|
||||
if signal_ref() is None:
|
||||
collected_signals += 1
|
||||
finally:
|
||||
update_ui.is_frozen_install = original_edges["is_frozen_install"]
|
||||
update_ui.frozen_install_root = original_edges["frozen_install_root"]
|
||||
update_ui.download_package = original_edges["download_package"]
|
||||
update_ui.apply_downloaded_update = original_edges["apply_downloaded_update"]
|
||||
|
||||
return {
|
||||
"iterations": iterations,
|
||||
"failures": failures,
|
||||
"signals_collected_after_iteration": collected_signals,
|
||||
"main_thread_id": main_thread,
|
||||
"samples": samples,
|
||||
}
|
||||
|
||||
|
||||
def exercise_real_popen() -> dict[str, Any]:
|
||||
if sys.platform != "win32":
|
||||
return {"skipped": f"requires win32, got {sys.platform}"}
|
||||
with tempfile.TemporaryDirectory(prefix="zyt-update-helper-") as raw_tmp:
|
||||
temp_root = Path(raw_tmp)
|
||||
script = temp_root / "probe_helper.ps1"
|
||||
script.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"param(",
|
||||
" [int]$TargetPid, [string]$Installer, [string]$RestartExe,",
|
||||
" [string]$HelperLogFile, [string]$InstallerLogFile",
|
||||
")",
|
||||
"Set-Content -LiteralPath $HelperLogFile -Value 'child-started'",
|
||||
"Start-Sleep -Milliseconds 1200",
|
||||
"Set-Content -LiteralPath $HelperLogFile -Value 'child-complete'",
|
||||
]
|
||||
),
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
installer = temp_root / "Setup.exe"
|
||||
installer.write_bytes(b"MZ")
|
||||
restart = temp_root / "DoctorWorkstation.exe"
|
||||
restart.write_bytes(b"MZ")
|
||||
helper_log = temp_root / "helper.log"
|
||||
fixed_log = temp_root / "fixed.log"
|
||||
installer_log = temp_root / "installer.log"
|
||||
original_script = script.read_text(encoding="utf-8-sig")
|
||||
fixed_log_literal = str(fixed_log).replace("'", "''")
|
||||
script.write_text(
|
||||
original_script.replace(
|
||||
"Set-Content -LiteralPath $HelperLogFile -Value 'child-started'",
|
||||
"Set-Content -LiteralPath '"
|
||||
+ fixed_log_literal
|
||||
+ "' -Value (\"helper=<{0}> args=<{1}>\" -f $HelperLogFile, ($args -join '|'))\n"
|
||||
+ "Set-Content -LiteralPath $HelperLogFile -Value 'child-started'",
|
||||
),
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
captured: list[subprocess.Popen[Any]] = []
|
||||
captured_calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
|
||||
real_popen = update_service.subprocess.Popen
|
||||
|
||||
def capture_popen(*args: Any, **kwargs: Any) -> subprocess.Popen[Any]:
|
||||
captured_calls.append((args, kwargs.copy()))
|
||||
process = real_popen(*args, **kwargs)
|
||||
captured.append(process)
|
||||
return process
|
||||
|
||||
update_service.subprocess.Popen = capture_popen
|
||||
try:
|
||||
started = time.perf_counter()
|
||||
update_service._spawn_inno_setup_applier(
|
||||
script,
|
||||
installer=installer,
|
||||
restart_exe=restart,
|
||||
helper_log_file=helper_log,
|
||||
installer_log_file=installer_log,
|
||||
)
|
||||
returned_ms = round((time.perf_counter() - started) * 1000, 3)
|
||||
finally:
|
||||
update_service.subprocess.Popen = real_popen
|
||||
deadline = time.perf_counter() + 2.5
|
||||
child_log = ""
|
||||
while time.perf_counter() < deadline:
|
||||
if helper_log.exists():
|
||||
child_log = helper_log.read_text(encoding="utf-8").strip()
|
||||
if child_log == "child-complete":
|
||||
break
|
||||
time.sleep(0.05)
|
||||
return_code = captured[0].poll() if captured else None
|
||||
if captured and return_code is None:
|
||||
return_code = captured[0].wait(timeout=2.0)
|
||||
matrix: dict[str, Any] = {}
|
||||
if captured_calls:
|
||||
command = list(captured_calls[0][0][0])
|
||||
flag_cases = {
|
||||
"zero": 0,
|
||||
"detached": getattr(subprocess, "DETACHED_PROCESS", 0),
|
||||
"new_process_group": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
|
||||
"no_window": getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
"detached_new_group": getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
|
||||
"detached_no_window": getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
| getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
"new_group_no_window": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
| getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
"production_all": getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
| getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
}
|
||||
running: dict[str, tuple[subprocess.Popen[Any], Path]] = {}
|
||||
helper_parameter = command.index("-HelperLogFile") + 1
|
||||
for name, flags in flag_cases.items():
|
||||
case_command = command.copy()
|
||||
case_log = temp_root / f"matrix-{name}.log"
|
||||
case_command[helper_parameter] = str(case_log)
|
||||
process = real_popen(
|
||||
case_command,
|
||||
close_fds=True,
|
||||
creationflags=flags,
|
||||
cwd=str(temp_root),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
running[name] = (process, case_log)
|
||||
production_flags = flag_cases["production_all"]
|
||||
detached_flags = flag_cases["detached"]
|
||||
extra_cases = {
|
||||
"production_close_false": (production_flags, False, False),
|
||||
"production_all_devnull": (production_flags, True, True),
|
||||
"detached_close_false": (detached_flags, False, False),
|
||||
"detached_all_devnull": (detached_flags, True, True),
|
||||
}
|
||||
for name, (flags, close_fds, all_devnull) in extra_cases.items():
|
||||
case_command = command.copy()
|
||||
case_log = temp_root / f"matrix-{name}.log"
|
||||
case_command[helper_parameter] = str(case_log)
|
||||
stream_kwargs = (
|
||||
{
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
}
|
||||
if all_devnull
|
||||
else {}
|
||||
)
|
||||
process = real_popen(
|
||||
case_command,
|
||||
close_fds=close_fds,
|
||||
creationflags=flags,
|
||||
cwd=str(temp_root),
|
||||
**stream_kwargs,
|
||||
)
|
||||
flag_cases[name] = flags
|
||||
running[name] = (process, case_log)
|
||||
matrix_deadline = time.perf_counter() + 3.0
|
||||
while time.perf_counter() < matrix_deadline:
|
||||
if all(case_log.exists() for _, case_log in running.values()):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
for name, (process, case_log) in running.items():
|
||||
matrix[name] = {
|
||||
"flags": flag_cases[name],
|
||||
"log_created": case_log.exists(),
|
||||
"return_code": process.poll(),
|
||||
}
|
||||
control_return_code = None
|
||||
control_stdout = ""
|
||||
control_stderr = ""
|
||||
if not fixed_log.exists() and captured_calls:
|
||||
call_args, call_kwargs = captured_calls[0]
|
||||
call_kwargs.update(
|
||||
creationflags=0,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
control = real_popen(*call_args, **call_kwargs)
|
||||
control_stdout, control_stderr = control.communicate(timeout=5.0)
|
||||
control_return_code = control.returncode
|
||||
return {
|
||||
"command": captured_calls[0][0][0] if captured_calls else [],
|
||||
"spawn_returned_ms": returned_ms,
|
||||
"child_completed": child_log == "child-complete",
|
||||
"child_log": child_log,
|
||||
"fixed_log": fixed_log.read_text(encoding="utf-8").strip()
|
||||
if fixed_log.exists()
|
||||
else "",
|
||||
"child_return_code": return_code,
|
||||
"flag_matrix": matrix,
|
||||
"control_return_code": control_return_code,
|
||||
"control_stdout": control_stdout,
|
||||
"control_stderr": control_stderr,
|
||||
}
|
||||
|
||||
|
||||
def exercise_controller_quit() -> dict[str, Any]:
|
||||
"""Run the production request_quit method against a real Qt event loop."""
|
||||
|
||||
from doctor_workstation.app import ApplicationController
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
events: list[str] = []
|
||||
holder = SimpleNamespace(application=app, _shutting_down=False)
|
||||
app.aboutToQuit.connect(lambda: events.append("aboutToQuit"))
|
||||
QTimer.singleShot(
|
||||
0,
|
||||
lambda: (
|
||||
events.append("request_quit_enter"),
|
||||
ApplicationController.request_quit(holder),
|
||||
events.append("request_quit_return"),
|
||||
),
|
||||
)
|
||||
watchdog = QTimer()
|
||||
watchdog.setSingleShot(True)
|
||||
watchdog.timeout.connect(lambda: (events.append("watchdog"), app.quit()))
|
||||
watchdog.start(1000)
|
||||
started = time.perf_counter()
|
||||
return_code = app.exec()
|
||||
return {
|
||||
"events": events,
|
||||
"return_code": return_code,
|
||||
"returned_ms": round((time.perf_counter() - started) * 1000, 3),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
iteration_count = int(sys.argv[1]) if len(sys.argv) > 1 else 25
|
||||
output = {
|
||||
"session": exercise_session(iteration_count),
|
||||
"real_popen": exercise_real_popen(),
|
||||
"controller_quit": exercise_controller_quit(),
|
||||
}
|
||||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||
Reference in New Issue
Block a user