Repository: giampaolo/psutil

Issue:
[linux] "No such file or directory: '/proc/xxxxxxx/cwd'" with `psutil.process_iter`
## Summary

* OS: Ubuntu 22.04.5 LTS
* Architecture: x86_64
* Psutil version: 7.0.0
* Python version: 3.11.10
* Type: core

## Description
Issue very similar to https://github.com/giampaolo/psutil/issues/2418

When using `psutil.process_iter`, a `FileNotFoundError` can be raised when `/proc/xxxxxxx/cwd` is not found. 
Similarly to the issue 2418, this could be a race condition when `/proc/xxxxxxx/cwd` was deleted but `/proc/xxxxxxx` still exists. 

This one is hard to reproduce, this happens very rarely.
Stack trace exemple:
```
  File "/home/ubuntu/data/spindata/spindata/Utilities/misc_tools.py", line 878, in get_current_process
    return [p for p in psutil.process_iter(attrs=["pid", "cwd", "cmdline"]) if p.info["pid"] == os.getpid()][0]
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/data/spindata/spindata/Utilities/misc_tools.py", line 878, in <listcomp>
    return [p for p in psutil.process_iter(attrs=["pid", "cwd", "cmdline"]) if p.info["pid"] == os.getpid()][0]
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/spindata-2prIfHBO-py3.11/lib/python3.11/site-packages/psutil/__init__.py", line 1527, in process_iter
    proc.info = proc.as_dict(attrs=attrs, ad_value=ad_value)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/spindata-2prIfHBO-py3.11/lib/python3.11/site-packages/psutil/__init__.py", line 573, in as_dict
    ret = meth()
          ^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/spindata-2prIfHBO-py3.11/lib/python3.11/site-packages/psutil/__init__.py", line 777, in cwd
    return self._proc.cwd()
           ^^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/spindata-2prIfHBO-py3.11/lib/python3.11/site-packages/psutil/_pslinux.py", line 1646, in wrapper
    return fun(self, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/spindata-2prIfHBO-py3.11/lib/python3.11/site-packages/psutil/_pslinux.py", line 2057, in cwd
    return readlink(f"{self._procfs_path}/{self.pid}/cwd")
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/spindata-2prIfHBO-py3.11/lib/python3.11/site-packages/psutil/_pslinux.py", line 202, in readlink
    path = os.readlink(path)
           ^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: '/proc/295094/cwd'
```

Hints from issue comments:
@giampaolo: Actually `Process.exe()` already handles this:
https://github.com/giampaolo/psutil/blob/74b707fc735b64ea159e118e26acc2ecfb78a719/psutil/_pslinux.py#L1772-L1775

...but `Process.cwd()` does not:
https://github.com/giampaolo/psutil/blob/74b707fc735b64ea159e118e26acc2ecfb78a719/psutil/_pslinux.py#L2055-L2057

So yes, `cwd()` should do the same as `exe()`.

Solution patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index f84aab61..c05e4114 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -1,5 +1,15 @@
 *Bug tracker at https://github.com/giampaolo/psutil/issues*
 
+7.1.0
+=====
+
+XXXX-XX-XX
+
+**Bug fixes**
+
+- 2514_, [Linux]: `Process.cwd()`_ sometimes fail with `FileNotFoundError` due
+  to a race condition.
+
 7.0.0
 =====
 
diff --git a/psutil/_pslinux.py b/psutil/_pslinux.py
index 8cc64e9a..73e76297 100644
--- a/psutil/_pslinux.py
+++ b/psutil/_pslinux.py
@@ -83,6 +83,7 @@ CLOCK_TICKS = os.sysconf("SC_CLK_TCK")
 PAGESIZE = cext_posix.getpagesize()
 BOOT_TIME = None  # set later
 LITTLE_ENDIAN = sys.byteorder == 'little'
+UNSET = object()
 
 # "man iostat" states that sectors are equivalent with blocks and have
 # a size of 512 bytes. Despite this value can be queried at runtime
@@ -1698,6 +1699,22 @@ class Process:
         # incorrect or incomplete result.
         os.stat(f"{self._procfs_path}/{self.pid}")
 
+    def _readlink(self, path, fallback=UNSET):
+        # * https://github.com/giampaolo/psutil/issues/503
+        #   os.readlink('/proc/pid/exe') may raise ESRCH (ProcessLookupError)
+        #   instead of ENOENT (FileNotFoundError) when it races.
+        # * ENOENT may occur also if the path actually exists if PID is
+        #   a low PID (~0-20 range).
+        # * https://github.com/giampaolo/psutil/issues/2514
+        try:
+            return readlink(path)
+        except (FileNotFoundError, ProcessLookupError):
+            if os.path.lexists(f"{self._procfs_path}/{self.pid}"):
+                self._raise_if_zombie()
+                if fallback is not UNSET:
+                    return fallback
+            raise
+
     @wrap_exceptions
     @memoize_when_activated
     def _parse_stat_file(self):
@@ -1770,16 +1787,9 @@ class Process:
 
     @wrap_exceptions
     def exe(self):
-        try:
-            return readlink(f"{self._procfs_path}/{self.pid}/exe")
-        except (FileNotFoundError, ProcessLookupError):
-            self._raise_if_zombie()
-            # no such file error; might be raised also if the
-            # path actually exists for system processes with
-            # low pids (about 0-20)
-            if os.path.lexists(f"{self._procfs_path}/{self.pid}"):
-                return ""
-            raise
+        return self._readlink(
+            f"{self._procfs_path}/{self.pid}/exe", fallback=""
+        )
 
     @wrap_exceptions
     def cmdline(self):
@@ -2054,7 +2064,9 @@ class Process:
 
     @wrap_exceptions
     def cwd(self):
-        return readlink(f"{self._procfs_path}/{self.pid}/cwd")
+        return self._readlink(
+            f"{self._procfs_path}/{self.pid}/cwd", fallback=""
+        )
 
     @wrap_exceptions
     def num_ctx_switches(


Test patch:
diff --git a/psutil/tests/test_linux.py b/psutil/tests/test_linux.py
index 7d25fc84..836311e2 100755
--- a/psutil/tests/test_linux.py
+++ b/psutil/tests/test_linux.py
@@ -2064,6 +2064,15 @@ class TestProcess(PsutilTestCase):
                 assert m.called
                 assert ret == ""
 
+    def test_cwd_mocked(self):
+        # https://github.com/giampaolo/psutil/issues/2514
+        with mock.patch(
+            'psutil._pslinux.readlink', side_effect=FileNotFoundError
+        ) as m:
+            ret = psutil.Process().cwd()
+            assert m.called
+            assert ret == ""
+
     def test_issue_1014(self):
         # Emulates a case where smaps file does not exist. In this case
         # wrap_exception decorator should not raise NoSuchProcess.