Fix app hang on startup/shutdown with no internet connection - #541
Merged
Conversation
spyne 2.14.0 (the only PyPI release) vendors a 2020-era copy of `six` whose meta path importer only implements the legacy PEP 302 find_module()/load_module() protocol. Python 3.12 dropped the compatibility shim for finders lacking PEP 451's find_spec(), so `spyne.util.six.moves.*` imports fail during `import spyne` itself with "ModuleNotFoundError: No module named 'spyne.util.six.moves'" (see arskom/spyne#711, open/unreleased). On Python 3.13+, a second break surfaces once the first is patched: spyne's SOAP11 protocol and WSGI transport still `import cgi`, which was removed from the stdlib in 3.13 (PEP 594). Two import-time shims in TimeTrackerSOAP_Server.py fix both without new dependencies: one loads spyne's vendored six.py directly and adds find_spec() to its importer; the other registers a minimal `cgi` stub (parse_header only, reimplemented via email.message.EmailMessage) when the real module is absent. Both are inert if spyne is ever fixed upstream. Verified across Python 3.10-3.14 in fresh venvs - all SOAP tests execute and pass instead of being silently skipped.
Two independent unbounded network calls could hang the app indefinitely with no internet connection, both hitting the classic Python gotcha: a `requests`/`subprocess` `timeout=` only bounds the connect()/read() phases of a socket that already knows its target - it never covers DNS resolution, which runs first and can hang far longer than any such timeout when there's no network route at all (especially on Windows, or whenever DNS packets are silently dropped). - tt/TimeTracker.py's _check_and_install_dependencies() runs a bare `pip install` subprocess with no timeout at all, before any UI, in every entry point. Now bounded by PIP_INSTALL_TIMEOUT (120s); a timeout is treated like a failed install (skip the package, keep going) instead of hanging app startup indefinitely. Same fix applied to install.py's standalone setup script for consistency. - update.py's check_for_updates()/download_update() (the GitHub release check) now run under a hard wall-clock deadline via a new _call_with_deadline() helper: the actual network call runs in a daemon thread with a bounded join(); if it's still running past the deadline, the caller gives up instead of waiting forever. A daemon thread (not concurrent.futures.ThreadPoolExecutor) is deliberate - ThreadPoolExecutor registers its worker threads to be joined at interpreter exit, which would just relocate a genuine hang from "before the check" to "at app shutdown". - sl/SL_Menu.py's "new version available" banner used to check GitHub exactly once per session; it now re-checks on every view change (via the new update.should_check_for_updates() helper) while still skipping repeated reruns of the same view (a keystroke, the 5s auto-refresh tick). Combined with the deadline above, a dead network now means the check is silently skipped rather than blocking the view change. Verified: new regression tests simulate network calls that never return and assert bounded return times; live-tested in a browser across multiple view changes with no errors. Full suite: 236 tests passing.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two independent fixes, both around code hanging indefinitely with no clear failure:
sixwhose meta path importer lacks Python 3.12's requiredfind_spec(), soimport spyneitself fails withModuleNotFoundError: No module named 'spyne.util.six.moves'. On Python 3.13+, a second break surfaces (spyne's SOAP11/WSGI code still doesimport cgi, removed from the stdlib in 3.13). Both were causing every test intests/test_TimeTrackerSOAP_Server.pyto silently skip via a broadexcept SystemExit— the SOAP interface had effectively zero real test coverage.requests'/subprocess's owntimeout=only bounds the connect()/read() phases of a socket that already knows its target — it never covers DNS resolution, which runs first and can hang far longer than any such timeout with no network route at all. Two call sites were affected:tt/TimeTracker.py's_check_and_install_dependencies()(a barepip installsubprocess with no timeout, running before any UI in every entry point) andupdate.py's GitHub release check (called once at CLI/GUI exit, and — after finding my branch was 14+ PRs behindmainand merging up — once per Streamlit session onsl/SL_Menu.py's "new version available" banner).Changes
TimeTrackerSOAP_Server.py: two import-time shims — one loads spyne's vendoredsix.pydirectly and patchesfind_spec()onto its importer; the other registers a minimalcgistand-in (parse_headeronly, viaemail.message.EmailMessage) when the real module is absent. Both no-ops if spyne is ever fixed upstream, no new dependencies.update.py: new_call_with_deadline()helper runs the actual network call in a daemon thread with a boundedjoin()— deliberately notconcurrent.futures.ThreadPoolExecutor, which registers worker threads to be joined at interpreter exit and would just relocate a genuine hang to app shutdown.check_for_updates()/download_update()now catch the resultingTimeoutErroralongside their existingrequests.exceptions.RequestExceptionhandling. Also addsshould_check_for_updates(session_state, current_menu), a small testable helper for the throttling decision below.tt/TimeTracker.py:_check_and_install_dependencies()'spip installsubprocess now has aPIP_INSTALL_TIMEOUT(120s); a timeout is treated like a failed install (skip the package, keep going) instead of hanging startup indefinitely.install.py: same timeout fix applied to the standalone setup script for consistency.sl/SL_Menu.py: the "new version available" banner now re-checks on every view change (not just once per session) while still skipping repeated reruns of the same view (a keystroke, the 5s auto-refresh tick), viashould_check_for_updates().tests/test_update.py(new) andtests/test_TimeTracker.py: regression tests simulating network/subprocess calls that never return, asserting bounded return times; tests for the view-change throttling decision.Test plan
python -m unittest discover tests)pip install spyne lxml), confirmingtests/test_TimeTrackerSOAP_Server.pyexecutes and passes instead of skippingThreadPoolExecutordesign claim verified empirically: a control test usingThreadPoolExecutorfor the same scenario genuinely hung process exit, confirming the daemon-thread approach is necessary, not just theoretically nicerNotes for reviewer
update.pywere not added (they fall back to English, same as one pre-existing untranslated string already does) — this repo's translations are historically done in dedicated follow-up commits with propermsgfmttooling, not alongside the code change.fetch_emails_to_tasks()'s IMAP connection has the same zero-timeout pattern, but it's opt-in (disabled by default) and only reached via explicit user action, so it doesn't block startup the way the fixes above did.