"""Check cached path answers, with optional bounded waiting for unknown paths.
`spacr/qt/widgets/file_list.py` asked `os.path.exists` for every remembered
path, on the GUI thread, to colour the missing ones red. That is free for a
local disk and it is not free for a network one: measured on one workstation,
``os.path.exists`` on a path under ``/nas_mnt`` -- an ``autofs`` mount with
``timeout=600`` -- had not returned after twenty seconds, because the stat is
what triggers the automount and the share was asleep.
A blocked GUI thread does not look like a slow path check. It looks like
this, and every one of these was reported as a separate defect on 2026-09-04:
* "opening map barcodes crashes spacr" -- it is not a crash, it is a
freeze with no traceback, which is why nothing reached the logs. The
journal shows `automount request ... triggered by spacr` immediately
before each force-quit.
* "it kind of flickers ... several events are happening upon hover and
they sometimes lag for a couple of seconds" -- hover events queue while
the thread is stalled and then replay in a burst.
* "i see millisecond glimmers of parts of the module screens on the home
screen" -- repaints are deferred, so the stack shows what it has not
been given time to paint over.
* "it seems to happen ... after i have opened one or two moduals" -- each
module adds its own remembered paths, so there is more to stat.
Code on the GUI thread must therefore not access the filesystem for a path
the user supplied; this module provides the check it uses instead.
Non-waiting checks return a cached answer or the caller's ``default``
immediately and queue an unknown path for a background check. The default
is ``True`` for :func:`exists` and ``False`` for :func:`isdir`. A background
result updates the cache; :data:`probes` emits its ``answered`` signal only
when that answer differs from the cached one. The optimistic existence
default avoids drawing an unknown path as missing while its check is
pending. Passing ``wait=True``
instead waits synchronously for an uncached answer, as described by
:func:`exists`.
"""
from __future__ import annotations
import os
import queue
import threading
from typing import Dict, Optional
from PySide6.QtCore import QObject, Signal
#: How long a worker waits for one stat before giving up on it. A path that
#: cannot answer in this long is not going to be useful to the user either,
#: and the answer it eventually gives is not worth a thread parked forever.
PROBE_TIMEOUT_S = 5.0
#: How many paths are probed at once. Small: these are stat calls, and the
#: point is to keep one slow mount from starving the rest, not throughput.
WORKERS = 4
class _Probes(QObject):
"""Emits when a background check has changed what :func:`exists` says."""
#: The path whose answer just changed, and what it changed to.
answered = Signal(str, bool)
#: The signal source. Connect to `probes.answered` to redraw when a path's
#: state is finally known.
probes = _Probes()
#: Keyed on ``(path, want_dir)``: a path can exist and not be a directory,
#: and the two questions have different answers and different callers.
_cache: Dict[tuple, bool] = {}
_pending: set = set()
_lock = threading.Lock()
_queue: "queue.Queue[tuple]" = queue.Queue()
_started = False
def _worker() -> None:
"""Stat one path at a time, forever, off the GUI thread."""
while True:
key = _queue.get()
path, want_dir = key
answer = _stat_with_timeout(path, want_dir)
with _lock:
changed = _cache.get(key) != answer
_cache[key] = answer
_pending.discard(key)
if changed:
probes.answered.emit(path, answer)
_queue.task_done()
def _stat_with_timeout(path: str, want_dir: bool = False) -> bool:
"""``os.path.exists(path)``, but bounded by :data:`PROBE_TIMEOUT_S`.
A stat cannot be cancelled, so the timeout is on WAITING for it rather
than on the call: the thread that made it stays parked until the kernel
lets go, and this one stops waiting and reports the path as present.
Reporting it present is the conservative answer -- it is what the widget
already assumed, and it does not paint a path red on the strength of a
mount being slow.
"""
done = threading.Event()
result = [True]
def run() -> None:
"""Ask the filesystem the question that is allowed to block.
It runs on a worker, so an `autofs` mount that takes twenty seconds
to wake does not freeze the window. `OSError` is an answer of "no",
not a failure -- a path that cannot be stat-ed cannot be used either.
"""
try:
result[0] = (os.path.isdir(path) if want_dir
else os.path.exists(path))
except OSError:
result[0] = False
finally:
done.set()
thread = threading.Thread(target=run, daemon=True,
name=f"spacr-path-probe:{path[:40]}")
thread.start()
done.wait(PROBE_TIMEOUT_S)
return result[0]
def _ensure_started() -> None:
"""Start the probe worker threads, once.
Daemon threads, so a probe still blocked on a sleeping mount cannot hold
the application open at exit -- which is the whole reason path checks
were moved off the GUI thread in the first place.
"""
global _started
with _lock:
if _started:
return
_started = True
for index in range(WORKERS):
threading.Thread(target=_worker, daemon=True,
name=f"spacr-path-probe-{index}").start()
[docs]
def exists(path, *, default: bool = True, want_dir: bool = False,
wait: bool = False) -> bool:
"""Return a cached path answer, or probe an unknown path.
:param path: the path to ask about. Anything falsy is ``False``.
:param default: what to return for an unknown path when ``wait=False``.
``True`` by default; this value does not control a waiting probe's
timeout answer.
:param want_dir: ask ``isdir`` rather than ``exists``. Cached
separately, because a path can exist and not be a directory.
:param wait: return a cached answer immediately, or synchronously wait
up to :data:`PROBE_TIMEOUT_S` for an uncached stat and cache its
answer. A timeout returns and caches ``True``, even if ``default``
is ``False``. It does not cancel the stat thread, and that thread's
later result does not update this cached timeout answer.
:returns: ``False`` for a falsy path; otherwise a cached answer, the
non-waiting ``default`` with a check queued or pending, or the
waiting probe's answer (``True`` on timeout).
"""
text = str(path or "")
if not text:
return False
if wait:
known_answer = known(text, want_dir=want_dir)
if known_answer is not None:
return known_answer
answer = _stat_with_timeout(text, want_dir=want_dir)
prime(text, answer, want_dir=want_dir)
return answer
key = (text, bool(want_dir))
with _lock:
if key in _cache:
return _cache[key]
if key in _pending:
return default
_pending.add(key)
_ensure_started()
_queue.put(key)
return default
[docs]
def isdir(path, *, default: bool = False, wait: bool = False) -> bool:
"""Whether ``path`` is a directory, answered from cache.
``default`` is False here and True in :func:`exists`, and the asymmetry
is deliberate: the callers of this one are choosing a folder to OPEN a
dialog in, and opening it somewhere that turns out not to exist is worse
than opening it at the default location.
:param path: the path to ask about; passed to :func:`exists` with
``want_dir=True``, so anything falsy is ``False``.
:param wait: use the synchronous waiting mode of :func:`exists` for an
uncached path. Its timeout answer is ``True``, even though this
function's non-waiting default is ``False``.
``wait`` is off by default. The freeze this module was written for came
from statting remembered paths -- a passive pass over everything a
settings file mentions, on the GUI thread, to colour the missing ones
red. No user action waits on that pass, so answering from cache and
checking later costs nothing.
Expanding a folder the user has just dropped or chosen is the opposite
situation. They performed an action and are waiting for its result, and
the unknown-path default turns "add this folder's files" into "add this
folder as a file" -- silently, and only the first time a path is seen.
The calling thread waits up to :data:`PROBE_TIMEOUT_S` (5 s) for the
stat. A timeout caches ``True`` without cancelling the stat thread;
that thread's later answer does not replace the cached timeout result.
"""
return exists(path, default=default, want_dir=True, wait=wait)
[docs]
def known(path, *, want_dir: bool = False) -> Optional[bool]:
"""The cached answer for ``path``, or ``None`` when it is not known yet.
:param path: the path whose cached answer is wanted, looked up by its
string form (a falsy path as the empty string).
"""
with _lock:
return _cache.get((str(path or ""), bool(want_dir)))
[docs]
def forget(path=None) -> None:
"""Remove cached answers without cancelling queued or running probes.
A subsequent non-waiting :func:`exists` returns its default while an
existing probe is pending; it queues a new check only when none is
pending. A probe already in progress can repopulate the cache after
this call.
:param path: one path, or ``None`` for all of them. Called when the
user has just created or deleted something and the cache would
otherwise keep answering with what was true before.
"""
with _lock:
if path is None:
_cache.clear()
else:
text = str(path or "")
_cache.pop((text, False), None)
_cache.pop((text, True), None)
[docs]
def prime(path, answer: bool, *, want_dir: bool = False) -> None:
"""Cache a known answer without starting a probe.
For example, a file dialog has just reported that a path exists; asking the filesystem
again would be a second stat for a fact already in hand. This clears
the matching pending marker but does not cancel a queued or running
probe. Such a probe can later overwrite the primed answer and emit
the ``answered`` signal on :data:`probes` if its result changes the
cached value. This call itself emits no signal.
:param path: the path the answer is about.
:param answer: what is already known to be true of it.
:param want_dir: record it against :func:`isdir` rather than
:func:`exists`. The two are cached separately -- a path can exist and
not be a directory -- so a caller who knows both must say so twice.
Without it, :func:`isdir` answers with its ``False`` default until a
background probe completes.
"""
text = str(path or "")
if text:
key = (text, bool(want_dir))
with _lock:
_cache[key] = bool(answer)
_pending.discard(key)