python / python/cpython

`pathlib.Path.mkdir()` raises `FileExistsError` for non-existent path due to TOCTOU race condition

Abierto
#142,916 1 comentario 0 reacciones 0 asignados Ver en GitHub

Nadie ha tomado este issue todavía.

stdlib topic-pathlib type-bug
Lenguaje dominante
Python
Estrellas
77.2k
Forks
35.9k
Métricas de merge de PR
Métricas de PR pendientes

Descripción

Bug report

Bug description:

pathlib.Path.mkdir() raises FileExistsError for non-existent path due to TOCTOU race condition

Summary

pathlib.Path.mkdir(exist_ok=True) can incorrectly raise FileExistsError when a directory is deleted between the os.mkdir() call and the subsequent is_dir() check. This occurs because is_dir() returns False both when a path exists as a non-directory (the intended error case) and when the path doesn't exist at all (a TOCTOU race condition that should be handled differently).

Bug Details

Current problematic code

In Lib/pathlib/_local.py (Python 3.13+) or Lib/pathlib.py (earlier versions):

try:
    os.mkdir(self, mode)
except FileNotFoundError:
    if not parents or self.parent == self:
        raise
    self.parent.mkdir(parents=True, exist_ok=True)
    self.mkdir(mode, parents=False, exist_ok=exist_ok)
except OSError:
    # Cannot rely on checking for EEXIST, since the operating system
    # could give priority to other errors like EACCES or EROFS
    if not exist_ok or not self.is_dir():
        raise

The logic if not exist_ok or not self.is_dir(): raise assumes is_dir() returning False means "path exists but is not a directory." However, is_dir() also returns False when the path doesn't exist at all, creating a TOCTOU vulnerability.

Race condition sequence
  1. Thread/Process A calls path.mkdir(exist_ok=True)
  2. os.mkdir() raises FileExistsError (directory existed at that moment)
  3. Thread/Process B deletes the directory before A's next line executes
  4. A calls self.is_dir() → returns False (path no longer exists)
  5. Code incorrectly re-raises FileExistsError for a non-existent path

Expected vs. Actual Behavior

Expected

When mkdir(exist_ok=True) is called:

  • If path exists as a directory: return successfully (no error)
  • If path exists as a non-directory: raise FileExistsError
  • If path doesn't exist: retry mkdir or raise FileNotFoundError
  • NEVER raise FileExistsError for a path that doesn't exist
Actual

FileExistsError can be raised even when the path doesn't exist if it's deleted during the race window.

Reproduction

Minimal example (monkey-patched simulation)
#!/usr/bin/env python3
"""Minimal reproduction of pathlib.mkdir() TOCTOU race condition."""

from pathlib import Path
import tempfile

# Create a test directory
tmpdir = Path(tempfile.mkdtemp())
test_path = tmpdir / "race_dir"
test_path.mkdir()

# Monkey-patch is_dir() to delete the directory before checking
original_is_dir = Path.is_dir

def racing_is_dir(self):
    if self == test_path and self.exists():
        self.rmdir()  # Simulate race: delete during is_dir() call
    return original_is_dir(self)

Path.is_dir = racing_is_dir

# This will raise FileExistsError even though path doesn't exist!
try:
    test_path.mkdir(exist_ok=True)
    print("No error - race didn't trigger")
except FileExistsError as e:
    print(f"FileExistsError: {e}")
    print(f"Path exists: {test_path.exists()}")  # False!
    print("✓ Bug reproduced: FileExistsError raised but path doesn't exist")

# Cleanup
Path.is_dir = original_is_dir
tmpdir.rmdir()

Output:

FileExistsError: [Errno 17] File exists: '/tmp/tmpXXXXXX/race_dir'
Path exists: False
✓ Bug reproduced: FileExistsError raised but path doesn't exist
Real-world scenario

This occurs naturally in multiprocessing/multithreading scenarios with:

  • Thread A: Creating shared memory arrays with Path.mkdir(parents=True, exist_ok=True)
  • Thread B: Cleaning up arrays via __del__, removing empty parent directories with rmdir()

The race occurs when cleanup happens between mkdir's FileExistsError and the is_dir() check.

Related Issues

  • BPO-29694 / GH-73880: Fixed race in mkdir(parents=True) but didn't address deletion races
  • BPO-35192: Duplicate of 29694, users still hitting races on older Python versions
  • GH-139001: New pathlib race in Python 3.14t free-threading

Workaround

Until fixed, applications must implement retry logic:

def robust_mkdir(path, max_retries=3):
    for attempt in range(max_retries):
        try:
            path.mkdir(parents=True, exist_ok=True)
            return
        except FileExistsError:
            if path.is_dir():
                return  # Race resolved
            if attempt == max_retries - 1:
                raise
            time.sleep(0.01 * (2 ** attempt))
Linked PRs
  • gh-143162

Guía de contribución

Abrir la guía de contribución

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Línea de trabajo

Comienza en Lib/pathlib/_local.py para Python 3.13+ (o en Lib/pathlib.py para versiones anteriores), centrándote en Path.mkdir() y su comprobación is_dir(). Usa la reproducción con monkey patching para observar el comportamiento de la condición de carrera. Se considera terminado cuando mkdir(exist_ok=True) no genera FileExistsError si la ruta ha sido eliminada, manteniendo el comportamiento documentado para directorios y no directorios.

Escrito por el modelo de indexación a partir del texto del issue.

Evaluación

Stack tecnológico
python
Área
operating-systems
Tipo de issue
Error
Dificultad
4/5
Tiempo estimado
3-5 días
Estado de actividad
Estancado
Claridad
Bien especificado
Aptitud para principiantes
25/100

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.