File: Frigcal/code/Frigcal--source/hangless.py

"""
========================================================================
NO-HANG IO (part of Frigcal)

Avoid hangs in filesystem (or other) calls with thread timeouts.
FC: this does not apply to SAF ops on Android, but it may apply to 
POSIX/filepath ops on PCs and possibly to file descriptors on Android.

TBD: is this still useful in FC 4.0?

YE:  this file is currently used in FC 4.0 but only for folder 
     listings on PCs to check access.  

     It is not used for the SAF chooser on Android (which runs 
     atomically), the Kivy MD chooser on PCs (which doesn't have
     a filesystem-wrpper API), SAF pre-read/write calls on Android, 
     or POSIX read/write calls for files and descriptors on all.

     Android SAF calls before read/write might use this too but 
     currently do not, on the assumption that the rarely accessed
     calendar files in this app are not hosted on hangable mediums.  

     See storage.py for most file-ccess code in this app.
========================================================================
"""


from common import *




def try_any_nohang(func, *args, 
                   waitsecs=0.25, onfail=None, log=True):

    """
    --------------------------------------------------------------------
    [from PPUS] Run any system call with a timeout to avoid GUI hangs.
    Returns onfail for exception or timeout, else func(*args)'s result.
    onfail means immediate fail (unmounted?) or hung (inaccessible?).

    For portability, runs func(*args) in a thread with a timed join.
    Also wraps the call in a try handler to catch any/all exceptions.
    Initially coded for os.listdir() only, then updated for any func.

    LATER: os.isdir() hangs on Windows (but not macOS) too: generalize
    this to call any folder-access function with a timeout.  This now 
    returns onfail if func(*args) either raised an exc before timeout 
    or hung; else returns func result if it ended without exc or hang.
 
    Still not universal (no **kargs), but handles this app's use cases.
    Windows drive enumeration now also uses this, not a custom version.
    Timeouts can also be had with signals on Unix but not on Windows,
    and they are also available in multiprocessing but not on Android.

    Threads always finish even if timeout: no simple way to kill, but 
    a shared result object per thread ensures that new results aren't 
    overwritten.  Hung thread is left to finish (exit on target return)
    and modify a result object that is ignored by the calling thread.
    But set daemon=True so thread auto-killed at app exit if hung that
    long (unlikely); else inherits False from app process and may delay 
    app exit (which _may_ have triggered a kivy logger loop on exit).

    INITIAL: run an os.listdir() to verify folder access before ops
    that rely on it, but run it in a thread with a timeout to avoid
    lags/hangs for drives that are inaccessible - temporarily or 
    permanently.  This includes mounted but disconnected network 
    drives, but also others (e.g., USB drives not yet fully mounted).
    Returns False iff os.listdir() either failed immediately or hung.

    Used on all platforms and all contexts - chooser start folder, 
    chooser storage tap/reset, and Main action folder prechecks.
    Also used for an Android hang in storage_path_app_specific().
 
    LATER: change waitsecs default timer duration from 0.50 to 0.25 
    seconds; half a second is a bit jarring when a Windows mapped
    network drive is offline (as they regularly are), and a quarter
    of a second should be plenty to notice a ghost.  Also added 
    logs=True parameter to allow callers to turn off traces (traces
    can be too much, but never happen in released Android apps).
    --------------------------------------------------------------------
    """

    def try_any(func, args, result, onfail):
        try:
            got = func(*args)        # run folder-access call: hangware
        except:
            result.append(onfail)    # failure, immediate, or later if hung
        else:
            result.append(got)       # success, now or later; got may == onfail

    result = []                      # new shared object for each thread
    nohang = threading.Thread(
                 target=try_any, args=(func, args, result, onfail), daemon=True)

    nohang.start()
    nohang.join(timeout=waitsecs)    # wait till thread finished or timeout

    if nohang.is_alive():
        if log: 
            trace('try thread timeout')
        sendback = onfail            # let thread finish and set its ignored result
    else:
        if log: 
            trace('try thread exit')
        sendback = result[0]         # thead finished and set result: ok or exc
    return sendback                  # can be onfail for exc, timeout, or got 




#----------------------------------------------------------------------------
# No-hang helpers
#
# try_listdir_nohang() => True IFF listable, empty or not: no list returned;
# use a try_any_nohang() instead if list is needed too (see examples ahead);
# try_isdir_nohang() => False if exc, timeout, or false (callers don't care);
#----------------------------------------------------------------------------


def try_listdir_nohang(path):
    return try_any_nohang(os.listdir, path, onfail=None) != None


def try_isdir_nohang(path):
    return try_any_nohang(os.path.isdir, path, onfail=False)


# Other try_any() clients: Windows drive enumeration, Android drive name, file-system




class FileSystemLocalNoHang(FileSystemLocal):

    """
    -----------------------------------------------------------
    TBD: is this used by KivyMD's MDFileManager too?  
    And will MDFileManager be used by this app on PCs?

    Wrap Kivy folder-chooser's filesystem ops to avoid hangs.
    One instance, created by and stored in App instance.

    Wrap methods of the Kivy FileChooser's FileSystemLocal 
    interface class (os module calls, mostly) in thread timouts.
    Else the chooser can stall (block and possibly hang) as
    users navigate by tapping its UI.  

    Network drives, cameras, etc., may go offline, especially 
    on Windows, causing raw, unwrapped calls to block indefinitely.
    This happens deep in the Kivy FileChooser code, but its 
    file_system hook supports mods.  Code here fixes the chooser 
    widget; filesystem calls in the app itself are also wrapped 
    in thread timeouts ahead.  

    Coding notes: this doesn't call os functions directly, because  
    Kivy's FileSystemLocal.is_hidden() uses tight magic to access
    and use a private win32 call on Windows.  APPROOT is the global
    App instance: see App.on_start().  This also adds a thread per 
    chooser nav tap, but PCs and phones are fast.

    TBD: is this useful only, or at least primarily, on Windows?
    If so, could avoid one thread per os-module call elsewhere.
    To date, this is the only platform where nav hangs have 
    been seen, though PCs and phones are both quick...
    -----------------------------------------------------------
    """

    def __init__(self, app):
        self.app = app           # link back to main.py's App instance


    def listdir(self, filepath, warn=True):
        """
        called for both test and result: warn user
        """
        trace('NoHang.listdir')
        listed = try_any_nohang(super().listdir, filepath, onfail=None, log=False)
        if listed != None:
            return listed         # empty or not (no exc or timeout)
        else:
            trace('Skipping dir: listdir')
            if warn: 
                self.app.info_message('Cannot access folder', usetoast=True) 
            raise OSError         # kill dir in filechooser (run in try)


    def getsize(self, filepath, warn=False):
        """
        may be used if is_dir fails, but rare
        """
        trace('NoHang.getsize')
        getsize = try_any_nohang(super().getsize, filepath, onfail=None, log=False)
        if getsize != None:
            return getsize        # dir or not (no exc or timeout)
        else:
            trace('Skipping file: getsize')
            if warn:
                self.app.info_message('Cannot access file', usetoast=True)
            raise OSError         # kill dir in filechooser (run in try)


    def is_hidden(self, filepath, warn=False):
        """
        win32 privates used in super
        called numerous times: don't warn user
        """
        #trace('NoHang.is_hidden')
        ishidden = try_any_nohang(super().is_hidden, filepath, onfail=None, log=False)
        if ishidden != None:
            return ishidden       # hidden or not (no exc or timeout)
        else:
            trace('Skipping dir: is_hidden')
            if warn: 
                self.app.info_message('Cannot access folder', usetoast=True)
            raise False           # kill dir in filechooser (run in try)


    def is_dir(self, filepath, warn=False):
        """
        called an insane number of times: don't warn user
        """
        #trace('NoHang.is_dir')
        isdir = try_any_nohang(super().is_dir, filepath, onfail=None, log=False)
        if isdir != None:
            return isdir          # dir or not (no exc or timeout)
        else:
            trace('Skipping dir: is_dir')
            if warn:
                self.app.info_message('Cannot access folder', usetoast=True)
            return False          # kill dir in filechooser