accli 2.0.12__tar.gz → 2.0.14__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {accli-2.0.12 → accli-2.0.14}/PKG-INFO +1 -1
  2. {accli-2.0.12 → accli-2.0.14}/accli/AcceleratorTaskDispatcher.py +1 -1
  3. {accli-2.0.12 → accli-2.0.14}/accli/_version.py +1 -1
  4. {accli-2.0.12 → accli-2.0.14}/accli/cli.py +356 -94
  5. {accli-2.0.12 → accli-2.0.14}/accli/gui.py +33 -16
  6. {accli-2.0.12 → accli-2.0.14}/accli/nfs_watcher.py +5 -2
  7. {accli-2.0.12 → accli-2.0.14}/accli/setup-nfs.ps1 +14 -2
  8. {accli-2.0.12 → accli-2.0.14}/accli/token.py +7 -0
  9. {accli-2.0.12 → accli-2.0.14}/accli.egg-info/PKG-INFO +1 -1
  10. {accli-2.0.12 → accli-2.0.14}/accli.egg-info/SOURCES.txt +2 -1
  11. accli-2.0.14/tests/test_mount_lifecycle.py +139 -0
  12. {accli-2.0.12 → accli-2.0.14}/LICENSE +0 -0
  13. {accli-2.0.12 → accli-2.0.14}/README.md +0 -0
  14. {accli-2.0.12 → accli-2.0.14}/accli/AcceleratorJobProjectService.py +0 -0
  15. {accli-2.0.12 → accli-2.0.14}/accli/AcceleratorTerminalCliProjectService.py +0 -0
  16. {accli-2.0.12 → accli-2.0.14}/accli/CsvRegionalTimeseriesValidator.py +0 -0
  17. {accli-2.0.12 → accli-2.0.14}/accli/__init__.py +0 -0
  18. {accli-2.0.12 → accli-2.0.14}/accli/__main__.py +0 -0
  19. {accli-2.0.12 → accli-2.0.14}/accli/cleanup-nfs.ps1 +0 -0
  20. {accli-2.0.12 → accli-2.0.14}/accli/common.py +0 -0
  21. {accli-2.0.12 → accli-2.0.14}/accli/mount_downloader.py +0 -0
  22. {accli-2.0.12 → accli-2.0.14}/accli/refresh_explorer.ps1 +0 -0
  23. {accli-2.0.12 → accli-2.0.14}/accli.egg-info/dependency_links.txt +0 -0
  24. {accli-2.0.12 → accli-2.0.14}/accli.egg-info/entry_points.txt +0 -0
  25. {accli-2.0.12 → accli-2.0.14}/accli.egg-info/requires.txt +0 -0
  26. {accli-2.0.12 → accli-2.0.14}/accli.egg-info/top_level.txt +0 -0
  27. {accli-2.0.12 → accli-2.0.14}/pyproject.toml +0 -0
  28. {accli-2.0.12 → accli-2.0.14}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: accli
3
- Version: 2.0.12
3
+ Version: 2.0.14
4
4
  Summary: IIASA Accelerator Client
5
5
  Author-email: Wrufesh S <wrufesh@gmail.com>
6
6
  License: The MIT License (MIT)
@@ -196,7 +196,7 @@ class JobDispatchModel(BaseModel):
196
196
  node_id: Optional[str] = None
197
197
 
198
198
  ignore_duplicate_job: bool = False
199
- free_node: bool = False # Only applies to immediate children jobs
199
+ free_node: bool = True
200
200
 
201
201
  children: List['JobDispatchModel'] = []
202
202
  callback: Optional['JobDispatchModel'] = None
@@ -1,3 +1,3 @@
1
1
  # Please strictly put double quote to use this info for git tag
2
2
  # Read a developer guide for git tag command with regex
3
- VERSION = "v2.0.12"
3
+ VERSION = "v2.0.14"
@@ -589,17 +589,30 @@ mount_app = typer.Typer(
589
589
  app.add_typer(mount_app, name="mount")
590
590
 
591
591
 
592
+ def is_windows_drive_mapped(drive: str) -> bool:
593
+ """Non-blocking check if a Windows drive letter is currently mapped using Win32 GetLogicalDrives()."""
594
+ import ctypes
595
+ letter = drive.strip().rstrip(":\\/").upper()
596
+ if len(letter) != 1 or not letter.isalpha():
597
+ return os.path.exists(drive)
598
+ try:
599
+ bitmask = ctypes.windll.kernel32.GetLogicalDrives()
600
+ return bool(bitmask & (1 << (ord(letter) - ord("A"))))
601
+ except Exception:
602
+ return os.path.exists(f"{letter}:\\")
603
+
604
+
592
605
  def find_available_windows_drive(preferred: str) -> str:
593
606
  """Finds an available Windows drive letter, starting with preferred, then checking others."""
594
607
  import os
595
608
  preferred = preferred.upper()
596
- if not os.path.exists(f"{preferred}:\\"):
609
+ if not is_windows_drive_mapped(preferred):
597
610
  return f"{preferred}:"
598
611
 
599
612
  # Search order: subsequent letters first, then previous descending to D
600
613
  search_order = ["X", "Y", "Z"] + [chr(x) for x in range(ord("V"), ord("D") - 1, -1)]
601
614
  for letter in search_order:
602
- if not os.path.exists(f"{letter}:\\"):
615
+ if not is_windows_drive_mapped(letter):
603
616
  return f"{letter}:"
604
617
 
605
618
  raise RuntimeError("No available Windows drive letters found.")
@@ -672,6 +685,7 @@ def start_nfs_watcher(mount_point: str, interval: int):
672
685
  proc = subprocess.Popen(
673
686
  [executable, str(watcher_script), str(mount_point), str(interval)],
674
687
  creationflags=creationflags,
688
+ stdin=subprocess.DEVNULL,
675
689
  stdout=subprocess.DEVNULL,
676
690
  stderr=subprocess.DEVNULL,
677
691
  close_fds=True
@@ -722,6 +736,209 @@ def stop_nfs_watcher(mount_point: str):
722
736
  pass
723
737
 
724
738
 
739
+ def cleanup_stuck_windows_mount_processes():
740
+ """Terminate any orphaned mount.exe / umount.exe instances that block Windows NFS client."""
741
+ import subprocess
742
+ try:
743
+ subprocess.run(["taskkill", "/f", "/im", "mount.exe"], capture_output=True)
744
+ except Exception:
745
+ pass
746
+ try:
747
+ subprocess.run(["taskkill", "/f", "/im", "umount.exe"], capture_output=True)
748
+ subprocess.run(["taskkill", "/f", "/im", "net.exe"], capture_output=True)
749
+ except Exception:
750
+ pass
751
+
752
+
753
+ def cleanup_stale_rdp_namespace_entries():
754
+ """
755
+ Remove disconnected/stale RDP redirected drive entries (\\\\tsclient\\<drive>) from the user's
756
+ Explorer MyComputer Namespace registry key.
757
+ When Windows Client for NFS (NfsClnt) is active, any dead \\\\tsclient\\<drive> shortcut in
758
+ 'This PC' fails in RDPNP and falls through Windows MUP to NfsClnt, which attempts to resolve
759
+ 'tsclient' via DNS/NetBIOS/RPC and freezes new Explorer 'This PC' views for 21+ seconds.
760
+ """
761
+ import subprocess
762
+ ps_script = (
763
+ "$activeShares = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)\n"
764
+ "net use 2>$null | ForEach-Object {\n"
765
+ " if ($_ -match '(\\\\\\\\tsclient\\\\[a-zA-Z0-9_$]+)') {\n"
766
+ " [void]$activeShares.Add($matches[1])\n"
767
+ " }\n"
768
+ "}\n"
769
+ "Get-ChildItem 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\SessionInfo\\*\\MyComputer\\Namespace\\*' -ErrorAction SilentlyContinue | ForEach-Object {\n"
770
+ " $clsid = $_.PSChildName\n"
771
+ " $bag = Get-ItemProperty \"HKCU:\\Software\\Classes\\CLSID\\$clsid\\Instance\\InitPropertyBag\" -ErrorAction SilentlyContinue\n"
772
+ " if ($bag -and $bag.Target -and $bag.Target -match '^\\\\\\\\tsclient\\\\') {\n"
773
+ " if (-not $activeShares.Contains($bag.Target)) {\n"
774
+ " Remove-Item -LiteralPath $_.PSPath -Recurse -Force -ErrorAction SilentlyContinue\n"
775
+ " }\n"
776
+ " }\n"
777
+ "}\n"
778
+ )
779
+ try:
780
+ subprocess.run(
781
+ ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", ps_script],
782
+ capture_output=True,
783
+ text=True,
784
+ errors="replace",
785
+ timeout=5,
786
+ creationflags=0x08000000 # CREATE_NO_WINDOW
787
+ )
788
+ except Exception:
789
+ pass
790
+
791
+
792
+ def is_nfs_daemon_running() -> bool:
793
+ """Check if hf-mount-nfs.exe is currently running."""
794
+ import subprocess
795
+ try:
796
+ res = subprocess.run(["tasklist", "/FI", "IMAGENAME eq hf-mount-nfs.exe"], capture_output=True, text=True, errors="replace")
797
+ return res.stdout is not None and "hf-mount-nfs.exe" in res.stdout
798
+ except Exception:
799
+ return False
800
+
801
+
802
+ def is_nfs_kernel_driver_active() -> bool:
803
+ """Check if the Windows Client for NFS kernel redirector driver (NfsRdr) is loaded and running."""
804
+ import subprocess
805
+ import shutil
806
+ if not shutil.which("mount.exe") and not os.path.isfile("C:\\Windows\\System32\\mount.exe"):
807
+ return False
808
+ try:
809
+ res = subprocess.run(["sc.exe", "query", "NfsRdr"], capture_output=True, text=True, errors="replace")
810
+ if res.stdout and "RUNNING" in res.stdout:
811
+ return True
812
+ # If stopped, try to start it
813
+ subprocess.run(["sc.exe", "start", "NfsRdr"], capture_output=True)
814
+ check = subprocess.run(["sc.exe", "query", "NfsRdr"], capture_output=True, text=True, errors="replace")
815
+ return check.stdout is not None and "RUNNING" in check.stdout
816
+ except Exception:
817
+ return False
818
+
819
+
820
+ def wait_for_nfs_portmapper(host: str, port: int, timeout: float) -> bool:
821
+ """Poll until the NFS daemon's portmapper is accepting TCP connections and is stable."""
822
+ import socket
823
+ start = time.time()
824
+ consecutive_success = 0
825
+ while time.time() - start < timeout:
826
+ try:
827
+ with socket.create_connection((host, port), timeout=0.5):
828
+ consecutive_success += 1
829
+ if consecutive_success >= 2:
830
+ time.sleep(1.0)
831
+ return True
832
+ except (socket.error, OSError):
833
+ consecutive_success = 0
834
+ time.sleep(0.3)
835
+ return False
836
+
837
+
838
+ def ensure_nfs_daemon_stable(timeout: float) -> bool:
839
+ """
840
+ Ensure the NFS daemon starts and survives any delayed UMNT packet flushed by Windows NFS client.
841
+ If the daemon exits due to UMNT, re-triggers the Task Scheduler task to start a fresh daemon.
842
+ """
843
+ import subprocess
844
+ for attempt in range(3):
845
+ if not wait_for_nfs_portmapper("127.0.0.1", 111, timeout):
846
+ return False
847
+
848
+ # Windows NFS client kernel redirector flushes delayed UMNT within 7-8 seconds of portmapper binding
849
+ exited = False
850
+ for _ in range(16):
851
+ time.sleep(0.5)
852
+ if not is_nfs_daemon_running():
853
+ exited = True
854
+ break
855
+
856
+ if not exited:
857
+ # Daemon remained running and stable without being killed by delayed UMNT
858
+ return True
859
+
860
+ # Daemon consumed the queued UMNT packet and terminated cleanly. Re-trigger task!
861
+ time.sleep(1.0)
862
+ subprocess.run(["schtasks", "/run", "/tn", "accli-mount-nfs"], capture_output=True)
863
+
864
+ return wait_for_nfs_portmapper("127.0.0.1", 111, timeout)
865
+
866
+
867
+ def map_windows_network_drive(mount_point_abs, max_attempts: int) -> tuple[bool, str]:
868
+ """
869
+ Attempt to map the NFS share to the requested drive letter using mount.exe.
870
+ Retries with cleanup if Windows NFS client returns a transient error.
871
+ """
872
+ import subprocess
873
+ cleanup_stale_rdp_namespace_entries()
874
+ target_str = str(mount_point_abs).rstrip("\\/")
875
+ if re.match(r"^[a-zA-Z]$", target_str):
876
+ target_str += ":"
877
+
878
+ mount_cmd = [
879
+ "C:\\Windows\\System32\\mount.exe",
880
+ "-o", "mtype=soft",
881
+ "-o", "timeout=2",
882
+ "-o", "retry=2",
883
+ "-o", "rsize=32",
884
+ "-o", "wsize=32",
885
+ "-o", "anon",
886
+ "-o", "nolock",
887
+ "\\\\127.0.0.1\\!",
888
+ target_str
889
+ ]
890
+
891
+ last_err = ""
892
+ drive_root = str(mount_point_abs).rstrip("\\/") + "\\"
893
+ for attempt in range(1, max_attempts + 1):
894
+ cleanup_stuck_windows_mount_processes()
895
+
896
+ # If daemon died before or between attempts, revive it
897
+ if not is_nfs_daemon_running():
898
+ subprocess.run(["schtasks", "/run", "/tn", "accli-mount-nfs"], capture_output=True)
899
+ wait_for_nfs_portmapper("127.0.0.1", 111, 10.0)
900
+
901
+ try:
902
+ res = subprocess.run(mount_cmd, capture_output=True, text=True, errors="replace", timeout=15)
903
+ if res.returncode == 0:
904
+ # Confirm drive is actually mounted and directory listing is populated and queryable
905
+ drive_ready = False
906
+ for _ in range(30):
907
+ try:
908
+ if os.path.exists(drive_root):
909
+ entries = os.listdir(drive_root)
910
+ if len(entries) > 0:
911
+ drive_ready = True
912
+ return True, (res.stdout or "").strip()
913
+ except OSError:
914
+ pass
915
+ time.sleep(0.5)
916
+ if not drive_ready:
917
+ last_err = f"mount.exe reported success but drive {drive_root} was empty or not accessible"
918
+ # Clean up the stale mount connection so next attempt can remount fresh
919
+ subprocess.run(["net", "use", target_str, "/delete", "/y"], capture_output=True)
920
+ else:
921
+ last_err = (res.stderr or res.stdout or "").strip()
922
+ if "85" in last_err or "already in use" in last_err.lower():
923
+ # Release stale network drive letter without unmounting the daemon UNC path
924
+ subprocess.run(["net", "use", target_str, "/delete", "/y"], capture_output=True)
925
+ time.sleep(1.0)
926
+ except subprocess.TimeoutExpired:
927
+ last_err = "mount.exe command timed out"
928
+ cleanup_stuck_windows_mount_processes()
929
+ except Exception as e:
930
+ last_err = str(e)
931
+
932
+ if attempt < max_attempts:
933
+ # Check if daemon needs recovery
934
+ if not is_nfs_daemon_running():
935
+ subprocess.run(["schtasks", "/run", "/tn", "accli-mount-nfs"], capture_output=True)
936
+ wait_for_nfs_portmapper("127.0.0.1", 111, 3.0)
937
+ time.sleep(1.0 + (attempt * 0.5))
938
+
939
+ return False, last_err
940
+
941
+
725
942
  def enable_windows_nfs_features():
726
943
  """Automatically check and enable Windows Client for NFS features, registry key, and Task Scheduler gateway if needed."""
727
944
  import subprocess
@@ -759,7 +976,17 @@ def enable_windows_nfs_features():
759
976
  "$taskEnabled = $false\n"
760
977
  "if (Get-Command Get-ScheduledTask -ErrorAction SilentlyContinue) {\n"
761
978
  " $t = Get-ScheduledTask -TaskName 'accli-mount-nfs' -ErrorAction SilentlyContinue\n"
762
- " if ($t) { $taskEnabled = $true }\n"
979
+ " if ($t) {\n"
980
+ " $taskEnabled = $true\n"
981
+ " if ($t.Settings.ExecutionTimeLimit -ne 'PT0S' -and $t.Settings.ExecutionTimeLimit -ne 'P0D' -and -not [string]::IsNullOrEmpty($t.Settings.ExecutionTimeLimit)) {\n"
982
+ " try {\n"
983
+ " $s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero)\n"
984
+ " Set-ScheduledTask -TaskName 'accli-mount-nfs' -Settings $s -ErrorAction Stop | Out-Null\n"
985
+ " } catch {\n"
986
+ " $taskEnabled = $false\n"
987
+ " }\n"
988
+ " }\n"
989
+ " }\n"
763
990
  "} else {\n"
764
991
  " $query = schtasks /query /tn 'accli-mount-nfs' 2> $null\n"
765
992
  " if ($LASTEXITCODE -eq 0) { $taskEnabled = $true }\n"
@@ -780,8 +1007,8 @@ def enable_windows_nfs_features():
780
1007
  task_ok = False
781
1008
 
782
1009
  try:
783
- res = subprocess.run(check_cmd, capture_output=True, text=True, check=True)
784
- output = res.stdout.strip()
1010
+ res = subprocess.run(check_cmd, capture_output=True, text=True, errors="replace", check=True)
1011
+ output = (res.stdout or "").strip()
785
1012
  for part in output.split(';'):
786
1013
  if part.startswith("NFS:"):
787
1014
  nfs_ok = (part.split(':')[1].lower() == 'true')
@@ -851,7 +1078,7 @@ def enable_windows_nfs_features():
851
1078
  f"Start-Process powershell.exe -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"{temp_file_path}\" {params_str}' -Verb RunAs -Wait"
852
1079
  ]
853
1080
 
854
- result = subprocess.run(elevate_cmd, capture_output=True, text=True)
1081
+ result = subprocess.run(elevate_cmd, capture_output=True, text=True, errors="replace")
855
1082
  if result.returncode != 0:
856
1083
  print("[bold red][ERROR] Administrative configuration failed or UAC prompt was denied.[/bold red]")
857
1084
  if result.stderr:
@@ -860,13 +1087,16 @@ def enable_windows_nfs_features():
860
1087
 
861
1088
  print("[bold green][OK] Elevated administrative setup completed successfully![/bold green]")
862
1089
 
863
- # Reboot advisory for registry keys or NFS features
864
- if not reg_ok or not nfs_ok:
865
- print("")
866
- print("[bold yellow][WARNING] IMPORTANT: A Windows system restart is required for the new NFS Client features and registry policies to take effect.[/bold yellow]")
867
- print("[bold yellow]Please restart your computer, then run 'accli mount start' again to mount your drive passwordlessly![/bold yellow]")
868
- print("")
869
- raise typer.Exit(0)
1090
+ # Verify if kernel driver is loaded; if active, restart is NOT required and we continue mounting
1091
+ if is_nfs_kernel_driver_active():
1092
+ print("[bold green][OK] Windows NFS kernel redirector is active. Continuing with mount...[/bold green]")
1093
+ return
1094
+
1095
+ print("")
1096
+ print("[bold yellow][WARNING] IMPORTANT: A Windows system restart is required for the new NFS Client features and registry policies to take effect.[/bold yellow]")
1097
+ print("[bold yellow]Please restart your computer, then run 'accli mount start' again to mount your drive passwordlessly![/bold yellow]")
1098
+ print("")
1099
+ raise typer.Exit(0)
870
1100
 
871
1101
  except Exception as e:
872
1102
  if isinstance(e, typer.Exit):
@@ -909,7 +1139,12 @@ def mount_start(
909
1139
  try:
910
1140
  token = get_token()
911
1141
  server_url = get_server_url()
1142
+ if not token:
1143
+ print("[bold red]ERROR: No token found. Please run 'accli login' first.[/bold red]")
1144
+ raise typer.Exit(1)
912
1145
  except Exception as e:
1146
+ if isinstance(e, typer.Exit):
1147
+ raise e
913
1148
  print(f"[bold red]ERROR: Could not retrieve login details. Please run 'accli login' first. Details: {e}[/bold red]")
914
1149
  raise typer.Exit(1)
915
1150
 
@@ -934,26 +1169,19 @@ def mount_start(
934
1169
 
935
1170
  # Standardize mount_point and handle defaults / auto-selection
936
1171
  if sys_name == "Windows":
937
- # Check if the daemon is already running to prevent double mount
938
- import subprocess
939
- try:
940
- task_check = subprocess.run(["tasklist", "/FI", "IMAGENAME eq hf-mount-nfs.exe"], capture_output=True, text=True)
941
- if "hf-mount-nfs.exe" in task_check.stdout:
942
- config_file = Path("C:/ProgramData/accli/mount_config.json")
943
- active_msg = ""
944
- if config_file.is_file():
945
- try:
946
- import json
947
- cfg = json.loads(config_file.read_text(encoding="utf-8"))
948
- active_msg = f" for project '{cfg.get('project_slug')}' at '{cfg.get('mount_point')}'"
949
- except Exception:
950
- pass
951
- print(f"[bold red]ERROR: A mount daemon is already running{active_msg}.[/bold red]")
952
- print("[yellow]Please run 'accli mount stop' to unmount before starting a new mount.[/yellow]")
953
- raise typer.Exit(1)
954
- except Exception as e:
955
- if isinstance(e, typer.Exit):
956
- raise e
1172
+ if is_nfs_daemon_running():
1173
+ config_file = Path("C:/ProgramData/accli/mount_config.json")
1174
+ active_msg = ""
1175
+ if config_file.is_file():
1176
+ try:
1177
+ import json
1178
+ cfg = json.loads(config_file.read_text(encoding="utf-8"))
1179
+ active_msg = f" for project '{cfg.get('project_slug')}' at '{cfg.get('mount_point')}'"
1180
+ except Exception:
1181
+ pass
1182
+ print(f"[bold red]ERROR: A mount daemon is already running{active_msg}.[/bold red]")
1183
+ print("[yellow]Please run 'accli mount stop' to unmount before starting a new mount.[/yellow]")
1184
+ raise typer.Exit(1)
957
1185
 
958
1186
  if mount_point is None:
959
1187
  # Auto-detect available drive letter starting with W
@@ -965,10 +1193,10 @@ def mount_start(
965
1193
  print(f"[bold red]ERROR: {e}[/bold red]")
966
1194
  raise typer.Exit(1)
967
1195
  else:
968
- mount_point_str = str(mount_point)
969
- if re.match(r"^[a-zA-Z]:?\\?$", mount_point_str) or re.match(r"^[a-zA-Z]:/?$", mount_point_str):
1196
+ mount_point_str = str(mount_point).strip()
1197
+ if re.match(r"^[a-zA-Z]:?[\\/]*$", mount_point_str):
970
1198
  drive_letter = mount_point_str[0].upper()
971
- if os.path.exists(f"{drive_letter}:\\"):
1199
+ if is_windows_drive_mapped(drive_letter):
972
1200
  print(f"[yellow]Warning: Drive '{drive_letter}:' is already in use.[/yellow]")
973
1201
  try:
974
1202
  fallback_drive = find_available_windows_drive("W")
@@ -997,6 +1225,12 @@ def mount_start(
997
1225
  env["ACC_ENDPOINT"] = server_url
998
1226
  env["ACC_CAS_ENDPOINT"] = f"{server_url.rstrip('/')}/api/xet-cas"
999
1227
  env["ACC_TOKEN"] = token
1228
+ cache_base = Path("C:/ProgramData/accli/cache") if sys_name == "Windows" else Path.home() / ".accli" / "cache"
1229
+ (cache_base / "xet").mkdir(parents=True, exist_ok=True)
1230
+ (cache_base / "hf-mount").mkdir(parents=True, exist_ok=True)
1231
+ env["HF_XET_CACHE"] = str(cache_base / "xet")
1232
+ env["HF_HOME"] = str(cache_base)
1233
+ env["XDG_CACHE_HOME"] = str(cache_base)
1000
1234
 
1001
1235
  if sys_name == "Windows":
1002
1236
  # Enable Windows NFS Client features if needed
@@ -1018,6 +1252,7 @@ def mount_start(
1018
1252
  str(hf_mount_bin),
1019
1253
  "--token-file", db_path,
1020
1254
  "--hub-endpoint", server_url,
1255
+ "--cache-dir", str(cache_base / "hf-mount"),
1021
1256
  mode,
1022
1257
  project_slug,
1023
1258
  str(mount_point_abs)
@@ -1049,7 +1284,7 @@ def mount_start(
1049
1284
  has_task = False
1050
1285
  if not is_admin:
1051
1286
  try:
1052
- task_check = subprocess.run(["schtasks", "/query", "/tn", "accli-mount-nfs"], capture_output=True, text=True)
1287
+ task_check = subprocess.run(["schtasks", "/query", "/tn", "accli-mount-nfs"], capture_output=True, text=True, errors="replace")
1053
1288
  if task_check.returncode == 0:
1054
1289
  has_task = True
1055
1290
  except Exception:
@@ -1103,39 +1338,30 @@ def mount_start(
1103
1338
  config_file = config_dir / "mount_config.json"
1104
1339
  config_file.write_text(json.dumps(config_data, indent=2), encoding="utf-8")
1105
1340
 
1106
- run_res = subprocess.run(["schtasks", "/run", "/tn", "accli-mount-nfs"], capture_output=True, text=True)
1341
+ run_res = subprocess.run(["schtasks", "/run", "/tn", "accli-mount-nfs"], capture_output=True, text=True, errors="replace")
1107
1342
  if run_res.returncode == 0:
1108
1343
  print("[cyan]Waiting for elevated NFS daemon to initialize...[/cyan]")
1109
- time.sleep(2.0)
1344
+ if not ensure_nfs_daemon_stable(15.0):
1345
+ print("[bold red][ERROR] Timed out waiting for NFS daemon to bind to 127.0.0.1:111.[/bold red]")
1346
+ raise typer.Exit(1)
1110
1347
 
1111
1348
  # Map the network drive in the CURRENT user session (so it is visible in Explorer)
1112
- mount_cmd = [
1113
- "C:\\Windows\\System32\\mount.exe",
1114
- "-o", "mtype=hard",
1115
- "-o", "timeout=10",
1116
- "-o", "rsize=32",
1117
- "-o", "wsize=32",
1118
- "-o", "anon",
1119
- "-o", "nolock",
1120
- "\\\\127.0.0.1\\!",
1121
- str(mount_point_abs)
1122
- ]
1123
- mount_res = subprocess.run(mount_cmd, capture_output=True, text=True)
1124
- if mount_res.returncode == 0:
1349
+ success, err_msg = map_windows_network_drive(mount_point_abs, 5)
1350
+ if success:
1125
1351
  start_nfs_watcher(str(mount_point_abs), interval=30)
1126
1352
  print(f"[bold green][OK] NFS mount successfully mapped at [white]{mount_point_abs}[/white]![/bold green]")
1127
1353
  print("[cyan]Use 'umount' or 'accli mount stop' to unmount the drive.[/cyan]")
1128
1354
  return
1129
1355
  else:
1130
1356
  print(f"[bold red][ERROR] NFS daemon started, but failed to map drive {mount_point_abs} in user session.[/bold red]")
1131
- if mount_res.stderr:
1132
- print(f"[red]{mount_res.stderr.strip()}[/red]")
1357
+ if err_msg:
1358
+ print(f"[red]{err_msg}[/red]")
1133
1359
  print("[yellow]Tip: Make sure Windows NFS client feature was fully enabled and the PC was restarted.[/yellow]")
1134
1360
  raise typer.Exit(1)
1135
1361
  else:
1136
1362
  print("[bold red][ERROR] Failed to execute Scheduled Task 'accli-mount-nfs'.[/bold red]")
1137
- if trigger_res.stderr:
1138
- print(f"[red]{trigger_res.stderr.strip()}[/red]")
1363
+ if run_res.stderr:
1364
+ print(f"[red]{run_res.stderr.strip()}[/red]")
1139
1365
  print("[yellow]Falling back to UAC elevation...[/yellow]")
1140
1366
  has_task = False
1141
1367
  except Exception as e:
@@ -1199,7 +1425,7 @@ def mount_start(
1199
1425
  ps_command
1200
1426
  ]
1201
1427
 
1202
- elevate_res = subprocess.run(ps_args, capture_output=True, text=True)
1428
+ elevate_res = subprocess.run(ps_args, capture_output=True, text=True, errors="replace")
1203
1429
  if elevate_res.returncode != 0:
1204
1430
  print("[bold red][ERROR] Administrative elevation failed or was denied.[/bold red]")
1205
1431
  print("[yellow]Tip: Please request your IT Administrator to run 'setup-nfs.ps1' to configure the passwordless Task Scheduler gateway.[/yellow]")
@@ -1208,29 +1434,20 @@ def mount_start(
1208
1434
  raise typer.Exit(1)
1209
1435
 
1210
1436
  print("[cyan]Waiting for elevated NFS daemon to initialize...[/cyan]")
1211
- time.sleep(2.0)
1437
+ if not wait_for_nfs_portmapper("127.0.0.1", 111, 15.0):
1438
+ print("[bold red][ERROR] Timed out waiting for NFS daemon to bind to 127.0.0.1:111.[/bold red]")
1439
+ raise typer.Exit(1)
1212
1440
 
1213
1441
  # Map the network drive in the CURRENT user session (so it is visible in Explorer)
1214
- mount_cmd = [
1215
- "C:\\Windows\\System32\\mount.exe",
1216
- "-o", "mtype=hard",
1217
- "-o", "timeout=10",
1218
- "-o", "rsize=32",
1219
- "-o", "wsize=32",
1220
- "-o", "anon",
1221
- "-o", "nolock",
1222
- "\\\\127.0.0.1\\!",
1223
- str(mount_point_abs)
1224
- ]
1225
- mount_res = subprocess.run(mount_cmd, capture_output=True, text=True)
1226
- if mount_res.returncode == 0:
1442
+ success, err_msg = map_windows_network_drive(mount_point_abs, 5)
1443
+ if success:
1227
1444
  start_nfs_watcher(str(mount_point_abs), interval=30)
1228
1445
  print(f"[bold green][OK] NFS mount successfully mapped at [white]{mount_point_abs}[/white]![/bold green]")
1229
1446
  print("[cyan]Use 'umount' or 'accli mount stop' to unmount the drive.[/cyan]")
1230
1447
  else:
1231
1448
  print(f"[bold red][ERROR] NFS daemon spawned elevated, but failed to map drive {mount_point_abs} in user session.[/bold red]")
1232
- if mount_res.stderr:
1233
- print(f"[red]{mount_res.stderr.strip()}[/red]")
1449
+ if err_msg:
1450
+ print(f"[red]{err_msg}[/red]")
1234
1451
  raise typer.Exit(1)
1235
1452
  except Exception as e:
1236
1453
  if isinstance(e, typer.Exit):
@@ -1268,7 +1485,7 @@ def mount_start(
1268
1485
  print(f"[bold cyan]Starting mount for project '[white]{project_slug}[/white]' at '[white]{mount_point_abs}[/white]'...[/bold cyan]")
1269
1486
  import subprocess
1270
1487
  try:
1271
- result = subprocess.run(args, env=env, capture_output=True, text=True)
1488
+ result = subprocess.run(args, env=env, capture_output=True, text=True, errors="replace")
1272
1489
  if result.returncode == 0:
1273
1490
  print(f"[bold green][OK] Mount started successfully![/bold green]")
1274
1491
  if result.stdout:
@@ -1349,10 +1566,17 @@ def mount_stop(
1349
1566
  mount_point_abs = Path.home() / "accelerator" / "mnt" / project_slug
1350
1567
  print(f"[cyan]No mount point specified. Selected default directory: [bold white]{mount_point_abs}[/bold white][/cyan]")
1351
1568
  else:
1352
- mount_point_abs = mount_point.resolve()
1569
+ mount_point_str = str(mount_point).strip()
1570
+ if sys_name == "Windows" and re.match(r"^[a-zA-Z]:?[\\/]*$", mount_point_str):
1571
+ mount_point_abs = Path(f"{mount_point_str[0].upper()}:")
1572
+ else:
1573
+ mount_point_abs = mount_point.resolve()
1353
1574
 
1354
1575
  if sys_name == "Windows":
1355
- print(f"[bold cyan]Stopping mount at '[white]{mount_point_abs}[/white]' on Windows...[/bold cyan]")
1576
+ target_str = str(mount_point_abs).rstrip("\\/")
1577
+ if re.match(r"^[a-zA-Z]$", target_str):
1578
+ target_str += ":"
1579
+ print(f"[bold cyan]Stopping mount at '[white]{target_str}[/white]' on Windows...[/bold cyan]")
1356
1580
  import subprocess
1357
1581
 
1358
1582
  # Determine if current user is admin
@@ -1363,19 +1587,41 @@ def mount_stop(
1363
1587
  is_admin = False
1364
1588
 
1365
1589
  # 0. Stop background NFS watcher
1366
- stop_nfs_watcher(str(mount_point_abs))
1590
+ stop_nfs_watcher(target_str)
1591
+
1592
+ # 1. Unmap the network drive in the user session using net use
1593
+ # (Avoids sending RPC UMNT packet via umount.exe which causes kernel NFS redirector to queue stale UMNTs)
1594
+ # If the drive is mapped in Windows but the local NFS daemon has died (e.g., 72h task timeout),
1595
+ # Windows NFS kernel redirector (NfsRdr) with mtype=hard will block 'net use /delete' indefinitely
1596
+ # waiting for 127.0.0.1:111. Briefly revive the daemon so the kernel can complete the unmount cleanly.
1597
+ if is_windows_drive_mapped(target_str) and not is_nfs_daemon_running():
1598
+ try:
1599
+ subprocess.run(["schtasks", "/run", "/tn", "accli-mount-nfs"], capture_output=True, timeout=5)
1600
+ wait_for_nfs_portmapper("127.0.0.1", 111, 5.0)
1601
+ except Exception:
1602
+ pass
1603
+ res = None
1604
+ try:
1605
+ res = subprocess.run(["net", "use", target_str, "/delete", "/y"], capture_output=True, text=True, errors="replace", timeout=10)
1606
+ except subprocess.TimeoutExpired:
1607
+ cleanup_stuck_windows_mount_processes()
1608
+ if res is not None and res.returncode != 0:
1609
+ try:
1610
+ res = subprocess.run(["C:\\Windows\\System32\\umount.exe", "-f", target_str], capture_output=True, text=True, errors="replace", timeout=10)
1611
+ except subprocess.TimeoutExpired:
1612
+ cleanup_stuck_windows_mount_processes()
1613
+ try:
1614
+ subprocess.run(["net", "use", "\\\\127.0.0.1\\!", "/delete", "/y"], capture_output=True, timeout=5)
1615
+ except subprocess.TimeoutExpired:
1616
+ cleanup_stuck_windows_mount_processes()
1367
1617
 
1368
- # 1. Unmap the network drive in the user session
1369
- res = subprocess.run(["C:\\Windows\\System32\\umount.exe", "-f", str(mount_point_abs)], capture_output=True, text=True)
1370
- if res.returncode != 0:
1371
- # Fallback to force-releasing using net use
1372
- subprocess.run(["net", "use", str(mount_point_abs), "/delete", "/y"], capture_output=True)
1618
+ cleanup_stuck_windows_mount_processes()
1373
1619
 
1374
1620
  # 2. Terminate the background daemon
1375
1621
  has_umount_task = False
1376
1622
  if not is_admin:
1377
1623
  try:
1378
- task_check = subprocess.run(["schtasks", "/query", "/tn", "accli-umount-nfs"], capture_output=True, text=True)
1624
+ task_check = subprocess.run(["schtasks", "/query", "/tn", "accli-umount-nfs"], capture_output=True, text=True, errors="replace")
1379
1625
  if task_check.returncode == 0:
1380
1626
  has_umount_task = True
1381
1627
  except Exception:
@@ -1387,9 +1633,20 @@ def mount_stop(
1387
1633
  # Poll for up to 5 seconds to ensure hf-mount-nfs.exe has fully terminated
1388
1634
  for _ in range(10):
1389
1635
  time.sleep(0.5)
1390
- task_check = subprocess.run(["tasklist", "/FI", "IMAGENAME eq hf-mount-nfs.exe"], capture_output=True, text=True)
1391
- if "hf-mount-nfs.exe" not in task_check.stdout:
1636
+ if not is_nfs_daemon_running():
1637
+ break
1638
+ cleanup_stuck_windows_mount_processes()
1639
+ for _ in range(10):
1640
+ sched_check = subprocess.run(["schtasks", "/query", "/tn", "accli-mount-nfs"], capture_output=True, text=True, errors="replace")
1641
+ if not sched_check.stdout or "Running" not in sched_check.stdout:
1392
1642
  break
1643
+ time.sleep(0.5)
1644
+
1645
+ # Wait until drive letter is released from Windows namespace
1646
+ for _ in range(10):
1647
+ if not is_windows_drive_mapped(target_str):
1648
+ break
1649
+ time.sleep(0.3)
1393
1650
  else:
1394
1651
  # If admin or task scheduler gateway is not registered, stop/kill directly
1395
1652
  if is_admin:
@@ -1423,11 +1680,12 @@ def mount_stop(
1423
1680
  ps_command
1424
1681
  ], capture_output=True)
1425
1682
 
1426
- if res.returncode == 0 or "successfully" in res.stdout or "successfully" in res.stderr:
1683
+ if not is_windows_drive_mapped(target_str) or (res is not None and (res.returncode == 0 or (res.stdout and "successfully" in res.stdout) or (res.stderr and "successfully" in res.stderr))):
1427
1684
  print("[bold green][OK] Mount stopped successfully.[/bold green]")
1685
+ print("[cyan]Note: If mounting again, please allow a few seconds for the Windows NFS network cache to clear.[/cyan]")
1428
1686
  else:
1429
1687
  print("[yellow]Attempted to unmount. Please verify drive status manually using 'umount'.[/yellow]")
1430
- if res.stderr:
1688
+ if res is not None and res.stderr:
1431
1689
  print(f"[red]{res.stderr.strip()}[/red]")
1432
1690
  return
1433
1691
 
@@ -1454,7 +1712,7 @@ def mount_stop(
1454
1712
 
1455
1713
  import subprocess
1456
1714
  try:
1457
- result = subprocess.run(args, capture_output=True, text=True)
1715
+ result = subprocess.run(args, capture_output=True, text=True, errors="replace")
1458
1716
  if result.returncode == 0:
1459
1717
  print(f"[bold green][OK] Mount stopped successfully.[/bold green]")
1460
1718
  else:
@@ -1484,13 +1742,17 @@ def mount_status(
1484
1742
  print("[bold cyan]Active NFS mounts on Windows:[/bold cyan]")
1485
1743
  try:
1486
1744
  # Windows 'mount' command lists active NFS mounts
1487
- result = subprocess.run(["mount"], capture_output=True, text=True)
1488
- if result.returncode == 0 and result.stdout.strip():
1745
+ result = None
1746
+ if is_nfs_daemon_running():
1747
+ try:
1748
+ result = subprocess.run(["mount"], capture_output=True, text=True, errors="replace", timeout=5)
1749
+ except subprocess.TimeoutExpired:
1750
+ cleanup_stuck_windows_mount_processes()
1751
+ if result is not None and result.returncode == 0 and result.stdout and result.stdout.strip():
1489
1752
  print(result.stdout)
1490
1753
  else:
1491
1754
  # Check tasklist for active daemon process
1492
- task_res = subprocess.run(["tasklist", "/FI", "IMAGENAME eq hf-mount-nfs.exe"], capture_output=True, text=True)
1493
- if "hf-mount-nfs.exe" in task_res.stdout:
1755
+ if is_nfs_daemon_running():
1494
1756
  print("[cyan]hf-mount-nfs.exe process is running in the background.[/cyan]")
1495
1757
  else:
1496
1758
  print("[cyan]No running mounts found.[/cyan]")
@@ -1522,8 +1784,8 @@ def mount_status(
1522
1784
 
1523
1785
  # 1. Query hf-mount status directly
1524
1786
  try:
1525
- result = subprocess.run(args, capture_output=True, text=True)
1526
- if result.returncode == 0:
1787
+ result = subprocess.run(args, capture_output=True, text=True, errors="replace")
1788
+ if result.returncode == 0 and result.stdout:
1527
1789
  stdout = result.stdout.strip()
1528
1790
  if stdout:
1529
1791
  for line in stdout.splitlines():
@@ -150,6 +150,7 @@ class AccliGuiApp(tk.Tk):
150
150
  stdout=subprocess.PIPE,
151
151
  stderr=subprocess.STDOUT,
152
152
  text=True,
153
+ errors="replace",
153
154
  bufsize=1,
154
155
  startupinfo=startupinfo
155
156
  )
@@ -449,6 +450,9 @@ class AccliGuiApp(tk.Tk):
449
450
 
450
451
  mount_btn = ttk.Button(btn_frame, text="Start Mount", command=self.action_mount_start)
451
452
  mount_btn.pack(side=tk.LEFT, padx=5)
453
+
454
+ stop_form_btn = ttk.Button(btn_frame, text="Stop Mount", style="Stop.TButton", command=self.action_mount_stop)
455
+ stop_form_btn.pack(side=tk.LEFT, padx=5)
452
456
 
453
457
  # Right Panel (List of mounts)
454
458
  list_frame = ttk.LabelFrame(mounts_tab, text="Active Mounts", padding=10)
@@ -528,20 +532,33 @@ class AccliGuiApp(tk.Tk):
528
532
  self.run_cli_async(args, on_done=on_done, elevate=True)
529
533
 
530
534
  def action_mount_stop(self):
531
- selected = self.mounts_list.get(tk.ACTIVE)
532
- if not selected:
533
- messagebox.showwarning("Warning", "Please select a mount to stop from the list.")
534
- return
535
-
536
- # Parse the mount point from the status line
537
- # E.g. "W: \\\\127.0.0.1\\!" -> "W:"
538
- # Or parse folder paths
539
- parts = selected.split()
540
- if not parts:
541
- return
542
-
543
- mount_point = parts[0]
544
- args = ["mount", "stop", mount_point]
535
+ # 1. Check if user selected an item from the active mounts list
536
+ mount_point = ""
537
+ selected_indices = self.mounts_list.curselection()
538
+ if selected_indices:
539
+ selected = self.mounts_list.get(selected_indices[0])
540
+ parts = selected.split()
541
+ if parts:
542
+ mount_point = parts[0]
543
+ elif self.mounts_list.size() > 0:
544
+ selected = self.mounts_list.get(tk.ACTIVE)
545
+ if selected:
546
+ parts = selected.split()
547
+ if parts:
548
+ mount_point = parts[0]
549
+
550
+ # 2. If nothing selected or list is empty, fallback to the Mount Point / Drive Letter input field
551
+ if not mount_point:
552
+ entered_point = self.mount_point_val.get().strip()
553
+ if entered_point:
554
+ mount_point = entered_point
555
+
556
+ args = ["mount", "stop"]
557
+ if mount_point:
558
+ args.append(mount_point)
559
+ target_desc = mount_point
560
+ else:
561
+ target_desc = "active mount"
545
562
 
546
563
  self.status_bar_val.set("Stopping mount...")
547
564
  self.notebook.select(self.log_tab_idx)
@@ -550,10 +567,10 @@ class AccliGuiApp(tk.Tk):
550
567
  self.refresh_mounts_list()
551
568
  if code == 0:
552
569
  self.status_bar_val.set("Mount Stopped")
553
- messagebox.showinfo("Success", f"Successfully stopped mount at {mount_point}.")
570
+ messagebox.showinfo("Success", f"Successfully stopped mount ({target_desc}).")
554
571
  else:
555
572
  self.status_bar_val.set("Stop Failed")
556
- messagebox.showerror("Error", f"Failed to stop mount at {mount_point}.")
573
+ messagebox.showerror("Error", f"Failed to stop mount ({target_desc}). See logs for details.")
557
574
 
558
575
  self.run_cli_async(args, on_done=on_done, elevate=True)
559
576
 
@@ -17,7 +17,7 @@ WAIT_TIMEOUT = 0x00000102
17
17
  ERROR_ALREADY_EXISTS = 183
18
18
  EVENT_MODIFY_STATE = 0x0002
19
19
 
20
- kernel32 = ctypes.windll.kernel32
20
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
21
21
 
22
22
 
23
23
  def log(msg: str):
@@ -52,6 +52,7 @@ def get_open_explorer_paths(script_path: Path, mount_point: str) -> list[str]:
52
52
  ],
53
53
  capture_output=True,
54
54
  text=True,
55
+ errors="replace",
55
56
  creationflags=0x08000000 # CREATE_NO_WINDOW
56
57
  )
57
58
  if res.returncode == 0 and res.stdout:
@@ -83,6 +84,7 @@ def notify_explorer(script_path: Path, mount_point: str, path_str: str):
83
84
  ],
84
85
  capture_output=True,
85
86
  text=True,
87
+ errors="replace",
86
88
  creationflags=0x08000000 # CREATE_NO_WINDOW
87
89
  )
88
90
  except Exception as e:
@@ -142,8 +144,9 @@ def run_watcher(mount_point: str, interval: float):
142
144
  mutex_name, stop_event_name = get_sync_names(mount_point)
143
145
 
144
146
  # 1. Single-instance protection via Named Mutex
147
+ ctypes.set_last_error(0)
145
148
  mutex = kernel32.CreateMutexW(None, True, mutex_name)
146
- if kernel32.GetLastError() == ERROR_ALREADY_EXISTS:
149
+ if ctypes.get_last_error() == ERROR_ALREADY_EXISTS:
147
150
  log(f"Another watcher is already active for {drive_root} ({mutex_name}). Exiting.")
148
151
  sys.exit(0)
149
152
 
@@ -159,9 +159,21 @@ try {
159
159
  }
160
160
  } catch {}
161
161
 
162
+ # Force unified, accessible cache under C:\ProgramData\accli\cache
163
+ $cacheDir = "C:\ProgramData\accli\cache"
164
+ $hfXetCache = "$cacheDir\xet"
165
+ $hfMountCache = "$cacheDir\hf-mount"
166
+ New-Item -ItemType Directory -Path $hfXetCache -Force | Out-Null
167
+ New-Item -ItemType Directory -Path $hfMountCache -Force | Out-Null
168
+
169
+ $env:HF_XET_CACHE = $hfXetCache
170
+ $env:HF_HOME = $cacheDir
171
+ $env:XDG_CACHE_HOME = $cacheDir
172
+
162
173
  $mountArgs = @(
163
174
  "--token-file", $cfg.db_path,
164
175
  "--hub-endpoint", $cfg.server_url,
176
+ "--cache-dir", $hfMountCache,
165
177
  $cfg.mode,
166
178
  $cfg.project_slug,
167
179
  $cfg.mount_point
@@ -224,7 +236,7 @@ if (Get-Command Register-ScheduledTask -ErrorAction SilentlyContinue) {
224
236
  try {
225
237
  $action = New-ScheduledTaskAction -Execute $psActionPath -Argument $psActionArgs
226
238
  $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -RunLevel Highest
227
- $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
239
+ $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero)
228
240
 
229
241
  # Unregister task if already exists to update
230
242
  Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null
@@ -253,7 +265,7 @@ if (Get-Command Register-ScheduledTask -ErrorAction SilentlyContinue) {
253
265
  try {
254
266
  $action = New-ScheduledTaskAction -Execute $psActionPath -Argument $psUmountActionArgs
255
267
  $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -RunLevel Highest
256
- $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
268
+ $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero)
257
269
 
258
270
  Unregister-ScheduledTask -TaskName $umountTaskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null
259
271
  Register-ScheduledTask -TaskName $umountTaskName -Action $action -Principal $principal -Settings $settings -Force | Out-Null
@@ -139,6 +139,13 @@ def exchange_refresh_token(project_slug: str) -> tuple[str, str, int]:
139
139
  _cached_expires_at = expires_at
140
140
 
141
141
  return cas_token, access_token, expires_at
142
+ except requests.exceptions.HTTPError as e:
143
+ if e.response is not None and e.response.status_code in (400, 401, 403):
144
+ print("[bold red]ERROR: Your session has expired or the token is invalid.[/bold red]")
145
+ print("[yellow]Please run 'accli login' to authenticate again.[/yellow]")
146
+ raise typer.Exit(1)
147
+ print(f"[bold red]ERROR: Failed to authenticate/exchange refresh token: {e}[/bold red]")
148
+ raise typer.Exit(1)
142
149
  except Exception as e:
143
150
  print(f"[bold red]ERROR: Failed to authenticate/exchange refresh token: {e}[/bold red]")
144
151
  raise typer.Exit(1)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: accli
3
- Version: 2.0.12
3
+ Version: 2.0.14
4
4
  Summary: IIASA Accelerator Client
5
5
  Author-email: Wrufesh S <wrufesh@gmail.com>
6
6
  License: The MIT License (MIT)
@@ -22,4 +22,5 @@ accli.egg-info/SOURCES.txt
22
22
  accli.egg-info/dependency_links.txt
23
23
  accli.egg-info/entry_points.txt
24
24
  accli.egg-info/requires.txt
25
- accli.egg-info/top_level.txt
25
+ accli.egg-info/top_level.txt
26
+ tests/test_mount_lifecycle.py
@@ -0,0 +1,139 @@
1
+ """
2
+ Test for mounting lifecycle:
3
+ 1. Ensure clean initial state (mount stop)
4
+ 2. accli mount start <project_slug>
5
+ 3. Capture directory structure (ensure not empty) & read any file
6
+ 4. accli mount stop
7
+ 5. accli mount start <project_slug> again
8
+ 6. Capture directory structure (must not be empty) & read any file
9
+ """
10
+ import os
11
+ import sys
12
+ import time
13
+ import platform
14
+ import subprocess
15
+ import unittest
16
+ from pathlib import Path
17
+
18
+
19
+ class TestMountLifecycle(unittest.TestCase):
20
+ @classmethod
21
+ def setUpClass(cls):
22
+ cls.project_slug = os.environ.get("ACCLI_TEST_PROJECT", "test")
23
+ cls.mount_drive = os.environ.get("ACCLI_TEST_DRIVE", "W:")
24
+ cls.mount_arg = cls.mount_drive.rstrip("\\/")
25
+ if platform.system() == "Windows" and len(cls.mount_arg) == 1 and cls.mount_arg.isalpha():
26
+ cls.mount_arg += ":"
27
+ cls.mount_path = Path(f"{cls.mount_arg}\\") if platform.system() == "Windows" else Path.home() / "accelerator" / "mnt" / cls.project_slug
28
+
29
+ def run_cli(self, args: list, timeout: int):
30
+ cmd = [sys.executable, "-m", "accli"] + args
31
+ print(f"\n[TEST EXEC] {' '.join(cmd)}")
32
+ result = subprocess.run(
33
+ cmd,
34
+ stdin=subprocess.DEVNULL,
35
+ capture_output=True,
36
+ text=True,
37
+ errors="replace",
38
+ timeout=timeout
39
+ )
40
+ print(f"[EXIT CODE] {result.returncode}")
41
+ if result.stdout:
42
+ print(f"[STDOUT]\n{result.stdout.strip()}")
43
+ if result.stderr:
44
+ print(f"[STDERR]\n{result.stderr.strip()}")
45
+ return result
46
+
47
+ def get_structure(self, target_path: Path):
48
+ """List files and folders at the mounted location and ensure it is not empty."""
49
+ entries = []
50
+ for _ in range(10):
51
+ try:
52
+ if target_path.exists():
53
+ entries = [p.name for p in target_path.iterdir()]
54
+ if entries:
55
+ break
56
+ except OSError:
57
+ pass
58
+ time.sleep(0.5)
59
+
60
+ self.assertTrue(
61
+ target_path.exists(),
62
+ f"Mount path '{target_path}' does not exist or drive is not mapped."
63
+ )
64
+ print(f"[MOUNT STRUCTURE at {target_path}]: {entries}")
65
+ return entries
66
+
67
+ def read_any_file(self, target_path: Path):
68
+ """Locate any readable file under the mounted project path and read its content."""
69
+ search_dirs = [target_path / self.project_slug, target_path]
70
+ for sdir in search_dirs:
71
+ if not sdir.exists():
72
+ continue
73
+ for root, dirs, files in os.walk(sdir):
74
+ for file_name in files:
75
+ candidate = Path(root) / file_name
76
+ try:
77
+ print(f"[TEST READ FILE] Attempting to read: {candidate}")
78
+ with open(candidate, "rb") as fp:
79
+ data = fp.read(1024)
80
+ print(f"[TEST READ FILE] Successfully read {len(data)} bytes from '{candidate.name}'.")
81
+ return candidate, len(data)
82
+ except Exception as e:
83
+ print(f"[TEST READ FILE] Skipping unreadable candidate {candidate}: {e}")
84
+
85
+ self.fail(f"Could not read any file under mounted path '{target_path}'.")
86
+
87
+ def tearDown(self):
88
+ try:
89
+ self.run_cli(["mount", "stop", self.mount_arg], 30)
90
+ except Exception:
91
+ pass
92
+
93
+ def test_mount_start_stop_start_lifecycle(self):
94
+ # Step 0: Ensure clean initial state
95
+ print("\n--- Step 0: Ensure clean initial state ---")
96
+ self.run_cli(["mount", "stop", self.mount_arg], 30)
97
+ time.sleep(2)
98
+
99
+ # Step 1: First mount start
100
+ print(f"\n--- Step 1: Starting mount for '{self.project_slug}' at '{self.mount_arg}' ---")
101
+ start1 = self.run_cli(["mount", "start", self.project_slug, self.mount_arg], 60)
102
+ self.assertEqual(start1.returncode, 0, f"First mount start failed: {start1.stderr or start1.stdout}")
103
+
104
+ # Step 2: Check structure is not empty and try to read any file
105
+ print("\n--- Step 2: Checking structure & reading file after 1st mount ---")
106
+ structure1 = self.get_structure(self.mount_path)
107
+ self.assertGreater(
108
+ len(structure1), 0,
109
+ f"Expected structure to be not empty after 1st mount, got: {structure1}"
110
+ )
111
+ file1, bytes_read1 = self.read_any_file(self.mount_path)
112
+ self.assertIsNotNone(file1)
113
+
114
+ # Step 3: Mount stop (clean)
115
+ print(f"\n--- Step 3: Stopping mount at '{self.mount_arg}' ---")
116
+ stop = self.run_cli(["mount", "stop", self.mount_arg], 30)
117
+ self.assertEqual(stop.returncode, 0, f"Mount stop failed: {stop.stderr or stop.stdout}")
118
+ time.sleep(3)
119
+
120
+ # Step 4: Second mount start
121
+ print(f"\n--- Step 4: Starting mount for '{self.project_slug}' at '{self.mount_arg}' (2nd time) ---")
122
+ start2 = self.run_cli(["mount", "start", self.project_slug, self.mount_arg], 120)
123
+ self.assertEqual(start2.returncode, 0, f"Second mount start failed: {start2.stderr or start2.stdout}")
124
+
125
+ # Step 5: Check structure is not empty and try to read any file after clean & remount
126
+ print("\n--- Step 5: Checking structure & reading file after 2nd mount ---")
127
+ structure2 = self.get_structure(self.mount_path)
128
+ self.assertGreater(
129
+ len(structure2), 0,
130
+ f"Expected structure to be not empty after 2nd mount, got: {structure2}"
131
+ )
132
+ file2, bytes_read2 = self.read_any_file(self.mount_path)
133
+ self.assertIsNotNone(file2)
134
+
135
+ print("\n[SUCCESS] Mount start -> read -> stop -> start -> read lifecycle test completed successfully!")
136
+
137
+
138
+ if __name__ == "__main__":
139
+ unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes