cot.tmppath 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/CHANGELOG.md +6 -0
  2. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/PKG-INFO +1 -1
  3. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/docs/core.md +7 -6
  4. cot_tmppath-0.2.1/src/cot/tmppath/_owner.py +141 -0
  5. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/test_concurrency.py +15 -0
  6. cot_tmppath-0.2.0/src/cot/tmppath/_owner.py +0 -92
  7. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/.github/dependabot.yml +0 -0
  8. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/.github/workflows/ci.yml +0 -0
  9. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/.github/workflows/release-proposal.yml +0 -0
  10. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/.github/workflows/release-tag.yml +0 -0
  11. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/.github/workflows/release.yml +0 -0
  12. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/.gitignore +0 -0
  13. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/.pre-commit-config.yaml +0 -0
  14. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/LICENSE +0 -0
  15. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/README.md +0 -0
  16. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/benchmarks/test_bench.py +0 -0
  17. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/changelog.d/README.md +0 -0
  18. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/changelog.d/template.md +0 -0
  19. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/docs/assessment.md +0 -0
  20. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/docs/goals.md +0 -0
  21. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/docs/pytest-replacement.md +0 -0
  22. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/docs/releasing.md +0 -0
  23. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/docs/research.md +0 -0
  24. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/pyproject.toml +0 -0
  25. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/src/cot/tmppath/__init__.py +0 -0
  26. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/src/cot/tmppath/__main__.py +0 -0
  27. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/src/cot/tmppath/_api.py +0 -0
  28. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/src/cot/tmppath/_fs.py +0 -0
  29. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/src/cot/tmppath/overtake_pytest.py +0 -0
  30. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/src/cot/tmppath/py.typed +0 -0
  31. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/conftest.py +0 -0
  32. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/test_cli.py +0 -0
  33. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/test_core.py +0 -0
  34. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/test_hardening.py +0 -0
  35. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/test_layout.py +0 -0
  36. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/test_overtake_pytest.py +0 -0
  37. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/test_pytest_replacement.py +0 -0
  38. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/test_retention.py +0 -0
  39. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/test_speed.py +0 -0
  40. {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/uv.lock +0 -0
@@ -2,6 +2,12 @@
2
2
 
3
3
  <!-- towncrier release notes start -->
4
4
 
5
+ ## 0.2.1 (2026-10-07)
6
+
7
+ ### Fixed
8
+
9
+ - A crashed run whose pid was reused by another process is collected: holder files record the process start time on Linux and Windows. Before, the run counted as alive until that other process ended. macOS still relies on the pid alone.
10
+
5
11
  ## 0.2.0 (2026-10-07)
6
12
 
7
13
  ### Removed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cot.tmppath
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Hardened, fast management of related temporary folders
5
5
  Project-URL: Source, https://github.com/cogs-of-testing/cot.tmppath
6
6
  Author-email: Ronny Pfannschmidt <opensource@ronnypfannschmidt.de>
@@ -58,13 +58,14 @@ The default root adds two levels above this:
58
58
  ## Liveness and retention (G4, G5)
59
59
 
60
60
  - A process that starts or joins a run writes a holder file with its pid,
61
- host and Linux boot id, and removes it when it closes the run. A run is
62
- live while any holder may be alive: same host and boot, and the pid
63
- exists. A holder from another host always counts as alive, so a run is
61
+ host, Linux boot id and process start time, and removes it when it closes
62
+ the run. A run is live while any holder may be alive: same host and boot,
63
+ the pid exists, and the process with that pid started when the holder
64
+ says. A holder from another host always counts as alive, so a run is
64
65
  never collected on a guess.
65
- - **Open:** a pid that is reused by an unrelated process keeps a crashed
66
- run alive until that process ends. Recording the process start time would
67
- close that gap.
66
+ - The start time comes from `/proc` on Linux and `GetProcessTimes` on
67
+ Windows. **Open:** macOS has neither, so there a pid reused by an
68
+ unrelated process keeps a crashed run alive until that process ends.
68
69
  - Closing a run applies retention to the whole root: the newest
69
70
  `keep_runs` runs stay, older ones that are not live go, and so do runs
70
71
  older than `max_age`. The run being closed counts as the newest. A
@@ -0,0 +1,141 @@
1
+ """Who holds a run, and whether they are still alive (G5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import socket
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+
13
+ def _boot_id() -> str:
14
+ # Linux names each boot; elsewhere a pid alone has to do
15
+ try:
16
+ return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
17
+ except OSError:
18
+ return ""
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Owner:
23
+ """A process: its pid, host, boot and start time, so a pid reused by
24
+ another process, after a reboot or on another machine is not mistaken
25
+ for it. ``boot`` and ``started`` are empty where the platform cannot
26
+ tell (macOS); the pid alone decides there."""
27
+
28
+ pid: int
29
+ host: str
30
+ boot: str
31
+ started: str = ""
32
+
33
+ @classmethod
34
+ def current(cls) -> Owner:
35
+ pid = os.getpid()
36
+ return cls(pid, socket.gethostname(), _BOOT, _process_start(pid))
37
+
38
+ def to_bytes(self) -> bytes:
39
+ return json.dumps(
40
+ {
41
+ "pid": self.pid,
42
+ "host": self.host,
43
+ "boot": self.boot,
44
+ "started": self.started,
45
+ }
46
+ ).encode()
47
+
48
+ @classmethod
49
+ def from_bytes(cls, data: bytes) -> Owner | None:
50
+ try:
51
+ raw = json.loads(data)
52
+ return cls(
53
+ int(raw["pid"]),
54
+ str(raw["host"]),
55
+ str(raw["boot"]),
56
+ # holder files written by 0.2.0 have no start time
57
+ str(raw.get("started", "")),
58
+ )
59
+ except (ValueError, KeyError, TypeError, AttributeError):
60
+ return None
61
+
62
+ def is_alive(self) -> bool:
63
+ """False only when this process is known to be gone.
64
+
65
+ An owner on another host cannot be checked from here, so it counts
66
+ as alive: a run is never collected on a guess.
67
+ """
68
+ here = Owner.current()
69
+ if self.host != here.host:
70
+ return True
71
+ if self.boot and here.boot and self.boot != here.boot:
72
+ return False
73
+ if not _pid_alive(self.pid):
74
+ return False
75
+ if self.started:
76
+ # a process with this pid exists; is it the one that wrote this?
77
+ now = _process_start(self.pid)
78
+ if now and now != self.started:
79
+ return False
80
+ return True
81
+
82
+
83
+ _BOOT = _boot_id()
84
+
85
+ if sys.platform == "win32":
86
+ import ctypes
87
+
88
+ _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
89
+ _PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
90
+ _STILL_ACTIVE = 259
91
+ _ERROR_ACCESS_DENIED = 5
92
+
93
+ class _FileTime(ctypes.Structure):
94
+ _fields_ = (("low", ctypes.c_ulong), ("high", ctypes.c_ulong))
95
+
96
+ def _process_start(pid: int) -> str:
97
+ handle = _kernel32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
98
+ if not handle:
99
+ return ""
100
+ try:
101
+ times = [_FileTime() for _ in range(4)]
102
+ if not _kernel32.GetProcessTimes(
103
+ handle, *(ctypes.byref(time) for time in times)
104
+ ):
105
+ return ""
106
+ return str(times[0].high << 32 | times[0].low)
107
+ finally:
108
+ _kernel32.CloseHandle(handle)
109
+
110
+ def _pid_alive(pid: int) -> bool:
111
+ handle = _kernel32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
112
+ if not handle:
113
+ return bool(ctypes.get_last_error() == _ERROR_ACCESS_DENIED)
114
+ try:
115
+ code = ctypes.c_ulong()
116
+ if not _kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
117
+ return True
118
+ return code.value == _STILL_ACTIVE
119
+ finally:
120
+ _kernel32.CloseHandle(handle)
121
+
122
+ else:
123
+
124
+ def _process_start(pid: int) -> str:
125
+ # Linux: field 22 of /proc/{pid}/stat, in clock ticks since boot;
126
+ # the name before it can hold spaces and parentheses
127
+ try:
128
+ stat = Path(f"/proc/{pid}/stat").read_text()
129
+ except OSError:
130
+ return ""
131
+ fields = stat.rpartition(")")[2].split()
132
+ return fields[19] if len(fields) > 19 else ""
133
+
134
+ def _pid_alive(pid: int) -> bool:
135
+ try:
136
+ os.kill(pid, 0)
137
+ except ProcessLookupError:
138
+ return False
139
+ except PermissionError:
140
+ return True
141
+ return True
@@ -11,6 +11,7 @@ import pytest
11
11
 
12
12
  from conftest import run_python
13
13
  from cot.tmppath import Retention, Root
14
+ from cot.tmppath._owner import Owner
14
15
 
15
16
 
16
17
  def test_another_process_can_join_a_run(root_path: Path) -> None:
@@ -87,3 +88,17 @@ def test_place_is_atomic(root_path: Path) -> None:
87
88
  staging.write_bytes(b"whole")
88
89
  assert destination.read_bytes() == b"whole"
89
90
  assert list(destination.parent.iterdir()) == [destination]
91
+
92
+
93
+ @pytest.mark.skipif(
94
+ not Owner.current().started, reason="no process start time on this platform"
95
+ )
96
+ def test_reused_pid_does_not_keep_a_run_alive(root_path: Path) -> None:
97
+ run = Root(root_path).start_run()
98
+ # what a crashed holder looks like once its pid went to another process
99
+ here = Owner.current()
100
+ reused = Owner(here.pid, here.host, here.boot, started="0")
101
+ (holder,) = (run.path / ".cot-holders").iterdir()
102
+ holder.write_bytes(reused.to_bytes())
103
+ Root(root_path, retention=Retention(keep_runs=0)).prune()
104
+ assert not run.path.exists()
@@ -1,92 +0,0 @@
1
- """Who holds a run, and whether they are still alive (G5)."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import os
7
- import socket
8
- import sys
9
- from dataclasses import dataclass
10
- from pathlib import Path
11
-
12
-
13
- def _boot_id() -> str:
14
- # Linux names each boot; elsewhere a pid alone has to do
15
- try:
16
- return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
17
- except OSError:
18
- return ""
19
-
20
-
21
- @dataclass(frozen=True)
22
- class Owner:
23
- """A process: its pid, host and boot, so a reused pid after a reboot
24
- or on another machine is not mistaken for it."""
25
-
26
- pid: int
27
- host: str
28
- boot: str
29
-
30
- @classmethod
31
- def current(cls) -> Owner:
32
- return cls(os.getpid(), socket.gethostname(), _BOOT)
33
-
34
- def to_bytes(self) -> bytes:
35
- return json.dumps(
36
- {"pid": self.pid, "host": self.host, "boot": self.boot}
37
- ).encode()
38
-
39
- @classmethod
40
- def from_bytes(cls, data: bytes) -> Owner | None:
41
- try:
42
- raw = json.loads(data)
43
- return cls(int(raw["pid"]), str(raw["host"]), str(raw["boot"]))
44
- except (ValueError, KeyError, TypeError):
45
- return None
46
-
47
- def is_alive(self) -> bool:
48
- """False only when this process is known to be gone.
49
-
50
- An owner on another host cannot be checked from here, so it counts
51
- as alive: a run is never collected on a guess.
52
- """
53
- here = Owner.current()
54
- if self.host != here.host:
55
- return True
56
- if self.boot and here.boot and self.boot != here.boot:
57
- return False
58
- return _pid_alive(self.pid)
59
-
60
-
61
- _BOOT = _boot_id()
62
-
63
- if sys.platform == "win32":
64
- import ctypes
65
-
66
- _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
67
- _PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
68
- _STILL_ACTIVE = 259
69
- _ERROR_ACCESS_DENIED = 5
70
-
71
- def _pid_alive(pid: int) -> bool:
72
- handle = _kernel32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
73
- if not handle:
74
- return bool(ctypes.get_last_error() == _ERROR_ACCESS_DENIED)
75
- try:
76
- code = ctypes.c_ulong()
77
- if not _kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
78
- return True
79
- return code.value == _STILL_ACTIVE
80
- finally:
81
- _kernel32.CloseHandle(handle)
82
-
83
- else:
84
-
85
- def _pid_alive(pid: int) -> bool:
86
- try:
87
- os.kill(pid, 0)
88
- except ProcessLookupError:
89
- return False
90
- except PermissionError:
91
- return True
92
- return True
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes