python / python/cpython

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

Aberta
#142,916 1 comentário 0 reações 0 responsáveis Ver no GitHub

Ninguém assumiu esta issue ainda.

stdlib topic-pathlib type-bug
Linguagem predominante
Python
Estrelas
77.2k
Forks
36k
Métricas de merge de PRs
Métricas de PR pendentes

Descrição

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

Guia de contribuição

Abrir o guia de contribuição

Primeiros passos

  1. Leia a issue inteira e depois o guia de contribuição do projeto.
  2. Comente na issue dizendo que vai assumir — evita que duas pessoas façam o mesmo trabalho.
  3. Faça um fork do repositório e trabalhe em uma branch.
  4. Abra um pull request que referencie o número da issue.

Direção de pesquisa

Comece em Lib/pathlib/_local.py para Python 3.13+ (ou em Lib/pathlib.py para versões anteriores), concentrando-se em Path.mkdir() e sua verificação is_dir(). Use a reprodução com monkey patching para observar o comportamento da condição de corrida. Está concluído quando mkdir(exist_ok=True) não gerar FileExistsError quando o caminho tiver sido excluído, preservando o comportamento documentado para diretórios e não diretórios.

Escrita pelo modelo de indexação a partir do texto da issue.

Avaliação

Stack de tecnologia
python
Domínio
operating-systems
Tipo de issue
Bug
Dificuldade
4/5
Tempo estimado
3-5 dias
Status de atividade
Estagnada
Clareza
Claramente especificada
Facilidade para iniciantes
25/100

Receba novas issues na sua caixa de entrada

Um resumo curto de issues do GitHub para quem está começando.