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.
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/CHANGELOG.md +6 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/PKG-INFO +1 -1
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/docs/core.md +7 -6
- cot_tmppath-0.2.1/src/cot/tmppath/_owner.py +141 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/test_concurrency.py +15 -0
- cot_tmppath-0.2.0/src/cot/tmppath/_owner.py +0 -92
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/.github/dependabot.yml +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/.github/workflows/ci.yml +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/.github/workflows/release-proposal.yml +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/.github/workflows/release-tag.yml +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/.github/workflows/release.yml +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/.gitignore +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/.pre-commit-config.yaml +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/LICENSE +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/README.md +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/benchmarks/test_bench.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/changelog.d/README.md +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/changelog.d/template.md +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/docs/assessment.md +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/docs/goals.md +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/docs/pytest-replacement.md +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/docs/releasing.md +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/docs/research.md +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/pyproject.toml +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/src/cot/tmppath/__init__.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/src/cot/tmppath/__main__.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/src/cot/tmppath/_api.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/src/cot/tmppath/_fs.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/src/cot/tmppath/overtake_pytest.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/src/cot/tmppath/py.typed +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/conftest.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/test_cli.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/test_core.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/test_hardening.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/test_layout.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/test_overtake_pytest.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/test_pytest_replacement.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/test_retention.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.2.1}/testing/test_speed.py +0 -0
- {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.
|
|
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
|
|
62
|
-
live while any holder may be alive: same host and boot,
|
|
63
|
-
|
|
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
|
-
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|