eden cli: Refactor handler.exe code a bit

Summary:
This diff moves the code that manages handler.exe to its own module and abstract the functionality into a base class, even though for now only Windows is implemented.

It also calls this new handle in the branch that deletes a non-Eden directory, because it's very convenient to test.

Reviewed By: genevievehelsel

Differential Revision: D53594227

fbshipit-source-id: 309efab3f89893f745a82fe5acd128ecd702d42e
This commit is contained in:
Carlos Fernandez 2024-02-13 17:26:11 -08:00 committed by Facebook GitHub Bot
parent b1b6d9b430
commit a23600b759
4 changed files with 120 additions and 63 deletions

View File

@ -141,6 +141,7 @@ python_library(
"doctor/facebook/lib/fake_vscode_extensions_checker.py", "doctor/facebook/lib/fake_vscode_extensions_checker.py",
"doctor/problem.py", "doctor/problem.py",
"doctor/util.py", "doctor/util.py",
"file_handler_tools.py",
"filesystem.py", "filesystem.py",
"hg_util.py", "hg_util.py",
"logfile.py", "logfile.py",

View File

@ -44,6 +44,7 @@ from facebook.eden.ttypes import MountInfo as ThriftMountInfo, MountState
from filelock import BaseFileLock, FileLock from filelock import BaseFileLock, FileLock
from . import configinterpolator, configutil, telemetry, util, version from . import configinterpolator, configutil, telemetry, util, version
from .file_handler_tools import WinFileHandlerReleaser
from .util import ( from .util import (
FUSE_MOUNT_PROTOCOL_STRING, FUSE_MOUNT_PROTOCOL_STRING,
HealthStatus, HealthStatus,
@ -862,51 +863,6 @@ Do you want to run `eden mount %s` instead?"""
with self.get_thrift_client_legacy(timeout=60) as client: with self.get_thrift_client_legacy(timeout=60) as client:
client.unmount(os.fsencode(path)) client.unmount(os.fsencode(path))
def get_handle_path(self) -> Optional[Path]:
handle = shutil.which("handle.exe")
if handle:
return Path(handle)
return None
def check_handle(self, mount: Path) -> None:
handle = self.get_handle_path()
if not handle:
return
print(f"Checking handle.exe for processes using '{mount}'...")
print("Press ctrl+c to skip.")
try:
output = subprocess.check_output(
[handle, "-nobanner", "-accepteula", mount]
)
except KeyboardInterrupt:
print("Handle check interrupted.\n")
print("If you want to find out which process is still using the repo, run:")
print(f" handle.exe {mount}\n")
return
parsed = [
line.split() for line in output.decode(errors="ignore").splitlines() if line
]
non_edenfs_process = any(filter(lambda x: x[0].lower() != "edenfs.exe", parsed))
# When no handle is found in the repo, handle.exe will report `"No
# matching handles found."`, which will be 4 words.
if not non_edenfs_process or not parsed or len(parsed[0]) == 4:
# Nothing other than edenfs.exe is holding handles to files from
# the repo, we can proceed with the removal
return
print(
"The following processes are still using the repo, please terminate them.\n"
)
for executable, _, pid, _, _type, _, path in parsed:
print(f"{executable}({pid}): {path}")
print()
return
def destroy_mount( def destroy_mount(
self, path: Union[Path, str], preserve_mount_point: bool = False self, path: Union[Path, str], preserve_mount_point: bool = False
) -> None: ) -> None:
@ -1007,22 +963,8 @@ trouble cleaning up leftovers. You will need to manually remove {path}.
) )
if used_by_other: if used_by_other:
if self.get_handle_path(): winhr = WinFileHandlerReleaser()
self.check_handle(path) winhr.try_release(path)
else:
print(
f"""\
It looks like {path} is still in use by another process. If you need help to
figure out which process, please try `handle.exe` from sysinternals:
handle.exe {path}
"""
)
print(
f"After terminating the processes, please manually delete {path}."
)
print()
raise errors[0][1] raise errors[0][1]

View File

@ -0,0 +1,106 @@
#!/usr/bin/env python3
# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2.
# pyre-strict
import shutil
import subprocess
import sys
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Optional
WINDOWS_HANDLE_BIN = "handle.exe"
class FileHandlerReleaser(ABC):
@abstractmethod
def get_handle_path(self) -> Optional[Path]:
"""Returns the path to the handle tool if it exists on the system, such as handle.exe on Windows or lsof on Linux."""
pass
@abstractmethod
def check_handle(self, mount: Path) -> None:
"""Displays processes keeping an open handle to files and if possible, offers to terminate them."""
pass
@abstractmethod
def try_release(self, mount: Path) -> None:
"""If a handle tool exist, use it to display info to the user with check handle."""
pass
class WinFileHandlerReleaser(FileHandlerReleaser):
def get_handle_path(self) -> Optional[Path]:
if sys.platform != "win32":
return None
handle = shutil.which(WINDOWS_HANDLE_BIN)
if handle:
return Path(handle)
return None
def check_handle(self, mount: Path) -> None:
handle = self.get_handle_path()
if not handle:
return
print(
f"Checking handle.exe for processes using '{mount}'. This can take a while..."
)
print("Press ctrl+c to skip.")
try:
output = subprocess.check_output(
[
handle,
"-nobanner",
"/accepteula",
mount,
] # / vs - is importart for accepteula, otherwise it won't find handles (??)
)
except KeyboardInterrupt:
print("Handle check interrupted.\n")
print("If you want to find out which process is still using the repo, run:")
print(f" handle.exe {mount}\n")
return
parsed = [
line.split() for line in output.decode(errors="ignore").splitlines() if line
]
non_edenfs_process = any(filter(lambda x: x[0].lower() != "edenfs.exe", parsed))
# When no handle is found in the repo, handle.exe will report `"No
# matching handles found."`, which will be 4 words.
if not non_edenfs_process or not parsed or len(parsed[0]) == 4:
# Nothing other than edenfs.exe is holding handles to files from
# the repo, we can proceed with the removal
return
print(
"The following processes are still using the repo, please terminate them.\n"
)
for executable, _, pid, _, _type, _, path in parsed:
print(f"{executable}({pid}): {path}")
print()
return
def try_release(self, mount: Path) -> None:
if self.get_handle_path():
self.check_handle(mount)
else:
print(
f"""\
It looks like {mount} is still in use by another process. If you need help to
figure out which process, please try `handle.exe` from sysinternals:
handle.exe {mount}
"""
)
print(f"After terminating the processes, please manually delete {mount}.")
print()

View File

@ -78,6 +78,7 @@ from .constants import (
SHUTDOWN_EXIT_CODE_REQUESTED_SHUTDOWN, SHUTDOWN_EXIT_CODE_REQUESTED_SHUTDOWN,
SHUTDOWN_EXIT_CODE_TERMINATED_VIA_SIGKILL, SHUTDOWN_EXIT_CODE_TERMINATED_VIA_SIGKILL,
) )
from .file_handler_tools import WinFileHandlerReleaser
from .stats_print import format_size from .stats_print import format_size
from .subcmd import Subcmd from .subcmd import Subcmd
from .util import get_environment_suitable_for_subprocess, print_stderr, ShutdownError from .util import get_environment_suitable_for_subprocess, print_stderr, ShutdownError
@ -1599,8 +1600,15 @@ Do you still want to delete {path}?"""
path.rmdir() path.rmdir()
return 0 return 0
except Exception as ex: except Exception as ex:
print(f"error: cannot remove contents of {path}: {ex}") if sys.platform != "win32":
return 1 print(
f"Error: cannot remove contents of {path}: {ex}"
)
return 1
else:
winhr = WinFileHandlerReleaser()
winhr.try_release(path)
return 0
else: else:
# We can't ask the user what their true intentions are, # We can't ask the user what their true intentions are,
# so let's fail by default. # so let's fail by default.