Skip to content

Retention and cleanup are user-scoped, not project-scoped: cross-project eviction and lossy provenance under /tmp/pytest-of-<user>/ #14935

Description

@jawauntb

Retention and cleanup are user-scoped, not project-scoped: cross-project eviction and lossy provenance under /tmp/pytest-of-<user>/

Summary

TempPathFactory.getbasetemp() keys the scratch root by user (pytest-of-<user>/), then applies tmp_path_retention_count and lock-based cleanup globally inside that root. Every rootdir a user runs pytest against — every checkout, every worktree, every unrelated project — shares the same numbered sequence and the same retention window. Two consequences:

  1. Cross-project eviction. A run in project B rotates project A's most recent runs out from under it. Users who work in more than one checkout at a time (git worktrees, multiple clones, monorepos + side projects) lose scratch they may still want, and the loss is invisible until they go looking for it.
  2. Lossy provenance. Nothing in the session dir records which rootdir produced it. The .lock file identifies the pid, but no file names the project. External cleanup tooling (or a human trying to reclaim disk) can't tell whose scratch is whose without walking /proc or lsof. Third-party "safe cleanup" scripts end up conservative-and-slow, or they delete something they shouldn't.

A related bug in the same neighborhood: ensure_deletable (_pytest/pathlib.py) treats a .lock as dead purely by st_mtime age (LOCK_TIMEOUT = 3 days). The pid is recorded but never consulted, so a still-running session whose lock is 4 days old is fair game, and a dead session whose lock is 2 days old survives. Fixing this changes ownership recovery from "guess by clock" to "read the file."

Reproduction

# Any two projects will do — git worktrees are the sharpest case.
$ cd ~/proj-a && pytest -x                # creates /tmp/pytest-of-me/pytest-0
$ cd ~/proj-b && pytest -x                # creates /tmp/pytest-of-me/pytest-1
$ cd ~/proj-b && pytest -x                # /tmp/pytest-of-me/pytest-2
$ cd ~/proj-b && pytest -x                # /tmp/pytest-of-me/pytest-3, evicts pytest-0 (proj-a)
$ ls /tmp/pytest-of-me/                   # proj-a's most recent run is gone

grep -R proj-a /tmp/pytest-of-me/ returns nothing — no path in the tree records that pytest-0 came from ~/proj-a.

Impact

  • Users with git worktrees regularly hit 1–2 GB spikes under /tmp/pytest-of-<user>/ because each worktree's suite gets copied 3× into a shared pool (default tmp_path_retention_count=3), and the pool grows with the number of concurrent projects, not with retention_count. See Cleaning up tmpdir's #1120 (2015) for the same complaint reported as "disk fills up with pytest tempfiles".
  • Debug artifacts from the run they cared about get evicted by an unrelated run in another checkout.
  • Anything trying to safely reclaim pytest-of-<user>/ disk from the outside — a CI cleanup step, a workstation janitor, an editor's temp-sweep — has to reconstruct ownership at delete time from process state. Getting this wrong deletes live scratch; being conservative leaves the disk full.

Proposal

Two independent, backwards-compatible changes. Each is useful on its own; together they close both the retention and the provenance problem.

(1) Scope retention per rootdir

Inside pytest-of-<user>/, add a subdirectory named for the rootdir — a stable, filesystem-safe token derived from config.rootpath. The numbered dirs and the retention window live under that subdirectory.

/tmp/pytest-of-me/
├── proj-a-8f3c1a2b/          # short hash of /Users/me/proj-a
│   ├── pytest-0
│   ├── pytest-1
│   └── pytest-current -> pytest-1
├── proj-b-1d9f4e77/          # short hash of /Users/me/proj-b
│   ├── pytest-0
│   ├── pytest-1
│   └── pytest-2
  • Retention is now per rootdir: tmp_path_retention_count=3 means "3 most recent runs of this project", which is what almost every user thinks it already means.
  • Cross-project eviction stops as a category. Removing a git worktree's checkout no longer risks evicting an unrelated project's scratch.
  • Human-legible layout: the subdirectory name should include a human-readable prefix (last path component or config.rootpath.name) followed by a short hash of the absolute rootdir to disambiguate homonyms (~/work/foo and ~/play/foo). Example: foo-8f3c1a2b/.
  • Rollout: guarded by a new ini option, off by default for one release, then defaulted on. The old flat layout is inspected on cleanup for one more release so existing pytest-N/ dirs still get reaped.

(2) Provenance sidecar

Alongside .lock, write a .origin file containing (one per line):

  • absolute rootpath
  • pytest version
  • pid (redundant with .lock, useful when .lock has been reaped)
  • hostname (helpful on shared filesystems)
$ cat /tmp/pytest-of-me/foo-8f3c1a2b/pytest-2/.origin
rootpath=/Users/me/proj-a
version=8.3.3
pid=48211
host=laptop.local

Cost: a few dozen bytes per session. Benefit: any external tool can attribute a session dir with a cat. This closes the "safest to infer ownership from lsof" gap that forces cleanup tooling to be overly conservative.

(3) Liveness check on the lock (separate, smaller fix)

In ensure_deletable, when a .lock is present, check whether its pid is alive (os.kill(pid, 0) on POSIX; OpenProcess on Windows) before falling back to the mtime timeout. LOCK_TIMEOUT becomes a bound on how long we wait when the pid is unknown/uncheckable, not the primary signal. Removes the "dead session held its scratch for 3 days" and "long suite got its scratch reaped mid-run" failure modes at the same time.

Backwards compatibility

  • (1) is opt-in for one release via tmp_path_layout = "per-rootdir" | "flat" (default "flat"). One release later, switch the default. Both layouts remain readable by cleanup so no orphaned bytes.
  • (2) is additive; nothing reads .origin yet, so writing it can't break anyone.
  • (3) is a behavior change but a strict improvement for both failure modes. Could go in the same release as (1)'s default flip.
  • No public API changes to TempPathFactory, tmp_path, tmp_path_factory, or --basetemp semantics. PYTEST_DEBUG_TEMPROOT behavior unchanged.

Prior art / related

Files touched (sketch)

  • src/_pytest/tmpdir.pyTempPathFactory.getbasetemp(): add per-rootdir subdir, plumb config.rootpath into the factory.
  • src/_pytest/tmpdir.py — write .origin next to .lock when a session dir is created.
  • src/_pytest/pathlib.pyensure_deletable: pid-liveness check ahead of mtime timeout.
  • New ini tmp_path_layout; changelog fragment; docs (doc/en/how-to/tmp_path.rst, doc/en/reference/reference.rst).

What I'm offering

I'll send this as three separate PRs so each can be reviewed on its own merits:

  1. .origin sidecar (lowest risk, useful immediately for external tooling).
  2. Pid-liveness in ensure_deletable.
  3. Per-rootdir layout behind tmp_path_layout, followed by a default flip.

Happy to iterate on the layout naming, the ini surface, and Windows liveness before writing code. Wanted to open the discussion first because the design choices above (in particular the layout token and the rollout shape) benefit from maintainer input.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions