singleserver 0.1.0__py3-none-any.whl
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.
- singleserver/__init__.py +42 -0
- singleserver/client.py +305 -0
- singleserver/lock.py +282 -0
- singleserver/process.py +489 -0
- singleserver/server.py +432 -0
- singleserver-0.1.0.dist-info/METADATA +185 -0
- singleserver-0.1.0.dist-info/RECORD +9 -0
- singleserver-0.1.0.dist-info/WHEEL +4 -0
- singleserver-0.1.0.dist-info/licenses/LICENSE +21 -0
singleserver/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
singleserver - Manage singleton server processes across multiple workers.
|
|
3
|
+
|
|
4
|
+
A Python library for coordinating a single instance of a server process
|
|
5
|
+
across multiple workers/processes using atomic socket binding.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
from singleserver import SingleServer
|
|
9
|
+
|
|
10
|
+
# Define the server
|
|
11
|
+
api_server = SingleServer(
|
|
12
|
+
name="api-server",
|
|
13
|
+
command=["python", "-m", "my_api", "--port", "{port}"],
|
|
14
|
+
port=8765,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Connect (starts if needed, connects if already running)
|
|
18
|
+
with api_server.connect() as client:
|
|
19
|
+
response = client.get("/data.json")
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .client import ServerClient, ServerNotReady
|
|
23
|
+
from .lock import LockFile, SocketLock
|
|
24
|
+
from .process import ProcessOwner, ProcessState
|
|
25
|
+
from .server import ManagedServer, SingleServer
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
# Main classes
|
|
31
|
+
"SingleServer",
|
|
32
|
+
"ManagedServer",
|
|
33
|
+
# Supporting classes
|
|
34
|
+
"ServerClient",
|
|
35
|
+
"ProcessOwner",
|
|
36
|
+
"ProcessState",
|
|
37
|
+
# Lock utilities
|
|
38
|
+
"SocketLock",
|
|
39
|
+
"LockFile",
|
|
40
|
+
# Exceptions
|
|
41
|
+
"ServerNotReady",
|
|
42
|
+
]
|
singleserver/client.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client for connecting to managed servers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import socket
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urljoin
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import requests
|
|
17
|
+
from requests.adapters import HTTPAdapter
|
|
18
|
+
from urllib3.util.retry import Retry
|
|
19
|
+
|
|
20
|
+
REQUESTS_AVAILABLE = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
REQUESTS_AVAILABLE = False
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import httpx # noqa: F401
|
|
26
|
+
|
|
27
|
+
HTTPX_AVAILABLE = True
|
|
28
|
+
except ImportError:
|
|
29
|
+
HTTPX_AVAILABLE = False
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ServerNotReady(Exception):
|
|
35
|
+
"""Raised when the server is not ready within the timeout."""
|
|
36
|
+
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ServerClient:
|
|
41
|
+
"""
|
|
42
|
+
Client for connecting to a managed server.
|
|
43
|
+
|
|
44
|
+
Provides:
|
|
45
|
+
- wait_ready() to block until the server is accepting connections
|
|
46
|
+
- HTTP request methods (get, post, etc.) if requests library is available
|
|
47
|
+
- Base URL construction for manual HTTP client usage
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
host: str = "127.0.0.1",
|
|
53
|
+
port: int | None = None,
|
|
54
|
+
socket_path: str | Path | None = None,
|
|
55
|
+
health_check_url: str = "/",
|
|
56
|
+
startup_timeout: float = 30.0,
|
|
57
|
+
):
|
|
58
|
+
"""
|
|
59
|
+
Initialize the client.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
host: Host to connect to (for TCP connections).
|
|
63
|
+
port: TCP port to connect to.
|
|
64
|
+
socket_path: Unix socket path to connect to.
|
|
65
|
+
health_check_url: URL path to use for readiness checks.
|
|
66
|
+
startup_timeout: Max seconds to wait for server to be ready.
|
|
67
|
+
"""
|
|
68
|
+
if port is None and socket_path is None:
|
|
69
|
+
raise ValueError("Must provide either port or socket_path")
|
|
70
|
+
|
|
71
|
+
self.host = host
|
|
72
|
+
self.port = port
|
|
73
|
+
self.socket_path = Path(socket_path) if socket_path else None
|
|
74
|
+
self.health_check_url = health_check_url
|
|
75
|
+
self.startup_timeout = startup_timeout
|
|
76
|
+
self._session: Any | None = None # requests.Session or httpx.Client
|
|
77
|
+
self._ready = False
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def base_url(self) -> str:
|
|
81
|
+
"""Base URL for making requests to the server."""
|
|
82
|
+
if self.socket_path:
|
|
83
|
+
# For Unix sockets, use http+unix:// scheme
|
|
84
|
+
return f"http+unix://{self.socket_path}"
|
|
85
|
+
return f"http://{self.host}:{self.port}"
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def is_ready(self) -> bool:
|
|
89
|
+
"""Return True if the server is confirmed ready."""
|
|
90
|
+
return self._ready
|
|
91
|
+
|
|
92
|
+
def _check_tcp_connection(self) -> bool:
|
|
93
|
+
"""Check if we can establish a TCP connection."""
|
|
94
|
+
try:
|
|
95
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
96
|
+
sock.settimeout(1.0)
|
|
97
|
+
result = sock.connect_ex((self.host, self.port))
|
|
98
|
+
sock.close()
|
|
99
|
+
return result == 0
|
|
100
|
+
except OSError:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
def _check_unix_connection(self) -> bool:
|
|
104
|
+
"""Check if we can establish a Unix socket connection."""
|
|
105
|
+
if not self.socket_path or not self.socket_path.exists():
|
|
106
|
+
return False
|
|
107
|
+
try:
|
|
108
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
109
|
+
sock.settimeout(1.0)
|
|
110
|
+
sock.connect(str(self.socket_path))
|
|
111
|
+
sock.close()
|
|
112
|
+
return True
|
|
113
|
+
except OSError:
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
def _check_http_health(self) -> bool:
|
|
117
|
+
"""Check if the HTTP health endpoint is responding."""
|
|
118
|
+
if not REQUESTS_AVAILABLE:
|
|
119
|
+
# Fall back to socket check
|
|
120
|
+
if self.socket_path:
|
|
121
|
+
return self._check_unix_connection()
|
|
122
|
+
return self._check_tcp_connection()
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
if self.socket_path:
|
|
126
|
+
# Use requests-unixsocket for Unix sockets if available
|
|
127
|
+
try:
|
|
128
|
+
import requests_unixsocket
|
|
129
|
+
|
|
130
|
+
session = requests_unixsocket.Session()
|
|
131
|
+
url = f"http+unix://{str(self.socket_path).replace('/', '%2F')}{self.health_check_url}"
|
|
132
|
+
response = session.get(url, timeout=2.0)
|
|
133
|
+
return bool(response.ok)
|
|
134
|
+
except ImportError:
|
|
135
|
+
# Fall back to socket check
|
|
136
|
+
return self._check_unix_connection()
|
|
137
|
+
else:
|
|
138
|
+
response = requests.get(
|
|
139
|
+
f"http://{self.host}:{self.port}{self.health_check_url}",
|
|
140
|
+
timeout=2.0,
|
|
141
|
+
)
|
|
142
|
+
return bool(response.ok)
|
|
143
|
+
except Exception:
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
def wait_ready(self, timeout: float | None = None) -> bool:
|
|
147
|
+
"""
|
|
148
|
+
Wait for the server to be ready.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
timeout: Max seconds to wait. Uses startup_timeout if not specified.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
True if server is ready.
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
ServerNotReady: If timeout is reached before server is ready.
|
|
158
|
+
"""
|
|
159
|
+
if timeout is None:
|
|
160
|
+
timeout = self.startup_timeout
|
|
161
|
+
|
|
162
|
+
deadline = time.time() + timeout
|
|
163
|
+
check_interval = 0.1 # Start with fast checks
|
|
164
|
+
max_interval = 1.0
|
|
165
|
+
|
|
166
|
+
while time.time() < deadline:
|
|
167
|
+
if self._check_http_health():
|
|
168
|
+
self._ready = True
|
|
169
|
+
logger.debug("Server is ready")
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
time.sleep(check_interval)
|
|
173
|
+
# Exponential backoff up to max_interval
|
|
174
|
+
check_interval = min(check_interval * 1.5, max_interval)
|
|
175
|
+
|
|
176
|
+
raise ServerNotReady(f"Server did not become ready within {timeout} seconds")
|
|
177
|
+
|
|
178
|
+
def check_ready(self) -> bool:
|
|
179
|
+
"""
|
|
180
|
+
Check if the server is ready without waiting.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
True if server is ready, False otherwise.
|
|
184
|
+
"""
|
|
185
|
+
ready = self._check_http_health()
|
|
186
|
+
self._ready = ready
|
|
187
|
+
return ready
|
|
188
|
+
|
|
189
|
+
def _get_session(self) -> Any:
|
|
190
|
+
"""Get or create the HTTP session."""
|
|
191
|
+
if self._session is not None:
|
|
192
|
+
return self._session
|
|
193
|
+
|
|
194
|
+
if REQUESTS_AVAILABLE:
|
|
195
|
+
self._session = requests.Session()
|
|
196
|
+
|
|
197
|
+
# Configure retry logic
|
|
198
|
+
retry_strategy = Retry(
|
|
199
|
+
total=3,
|
|
200
|
+
backoff_factor=0.5,
|
|
201
|
+
status_forcelist=[500, 502, 503, 504],
|
|
202
|
+
)
|
|
203
|
+
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
204
|
+
self._session.mount("http://", adapter)
|
|
205
|
+
self._session.mount("https://", adapter)
|
|
206
|
+
return self._session
|
|
207
|
+
|
|
208
|
+
raise ImportError(
|
|
209
|
+
"requests library is required for HTTP methods. Install with: pip install requests"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _make_url(self, path: str) -> str:
|
|
213
|
+
"""Construct full URL from path."""
|
|
214
|
+
if self.socket_path:
|
|
215
|
+
# Can't use requests for Unix sockets without requests-unixsocket
|
|
216
|
+
raise NotImplementedError("HTTP methods with Unix sockets require requests-unixsocket")
|
|
217
|
+
return urljoin(f"http://{self.host}:{self.port}/", path.lstrip("/"))
|
|
218
|
+
|
|
219
|
+
def get(self, path: str, **kwargs: Any) -> Any:
|
|
220
|
+
"""
|
|
221
|
+
Make a GET request to the server.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
path: URL path (will be joined with base URL).
|
|
225
|
+
**kwargs: Additional arguments passed to requests.get().
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
requests.Response object.
|
|
229
|
+
"""
|
|
230
|
+
session = self._get_session()
|
|
231
|
+
return session.get(self._make_url(path), **kwargs)
|
|
232
|
+
|
|
233
|
+
def post(self, path: str, **kwargs: Any) -> Any:
|
|
234
|
+
"""
|
|
235
|
+
Make a POST request to the server.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
path: URL path (will be joined with base URL).
|
|
239
|
+
**kwargs: Additional arguments passed to requests.post().
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
requests.Response object.
|
|
243
|
+
"""
|
|
244
|
+
session = self._get_session()
|
|
245
|
+
return session.post(self._make_url(path), **kwargs)
|
|
246
|
+
|
|
247
|
+
def put(self, path: str, **kwargs: Any) -> Any:
|
|
248
|
+
"""
|
|
249
|
+
Make a PUT request to the server.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
path: URL path (will be joined with base URL).
|
|
253
|
+
**kwargs: Additional arguments passed to requests.put().
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
requests.Response object.
|
|
257
|
+
"""
|
|
258
|
+
session = self._get_session()
|
|
259
|
+
return session.put(self._make_url(path), **kwargs)
|
|
260
|
+
|
|
261
|
+
def delete(self, path: str, **kwargs: Any) -> Any:
|
|
262
|
+
"""
|
|
263
|
+
Make a DELETE request to the server.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
path: URL path (will be joined with base URL).
|
|
267
|
+
**kwargs: Additional arguments passed to requests.delete().
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
requests.Response object.
|
|
271
|
+
"""
|
|
272
|
+
session = self._get_session()
|
|
273
|
+
return session.delete(self._make_url(path), **kwargs)
|
|
274
|
+
|
|
275
|
+
def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
276
|
+
"""
|
|
277
|
+
Make an arbitrary HTTP request to the server.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
method: HTTP method (GET, POST, etc.).
|
|
281
|
+
path: URL path (will be joined with base URL).
|
|
282
|
+
**kwargs: Additional arguments passed to requests.request().
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
requests.Response object.
|
|
286
|
+
"""
|
|
287
|
+
session = self._get_session()
|
|
288
|
+
return session.request(method, self._make_url(path), **kwargs)
|
|
289
|
+
|
|
290
|
+
def close(self) -> None:
|
|
291
|
+
"""Close the client session."""
|
|
292
|
+
if self._session is not None:
|
|
293
|
+
self._session.close()
|
|
294
|
+
self._session = None
|
|
295
|
+
|
|
296
|
+
def __enter__(self) -> ServerClient:
|
|
297
|
+
return self
|
|
298
|
+
|
|
299
|
+
def __exit__(
|
|
300
|
+
self,
|
|
301
|
+
exc_type: type[BaseException] | None,
|
|
302
|
+
exc_val: BaseException | None,
|
|
303
|
+
exc_tb: TracebackType | None,
|
|
304
|
+
) -> None:
|
|
305
|
+
self.close()
|
singleserver/lock.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Atomic lock using socket binding.
|
|
3
|
+
|
|
4
|
+
Socket binding is atomic at the OS level - two processes cannot bind the same
|
|
5
|
+
port/socket simultaneously. This provides a distributed lock mechanism without
|
|
6
|
+
needing external coordination services.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import errno
|
|
12
|
+
import os
|
|
13
|
+
import socket
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import TracebackType
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SocketLock:
|
|
19
|
+
"""
|
|
20
|
+
Atomic lock using socket binding.
|
|
21
|
+
|
|
22
|
+
Uses the OS-level guarantee that only one process can bind a socket at a time.
|
|
23
|
+
This works for both TCP ports and Unix domain sockets.
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
lock = SocketLock(port=8765)
|
|
27
|
+
if lock.try_acquire():
|
|
28
|
+
# We're the owner
|
|
29
|
+
try:
|
|
30
|
+
run_server()
|
|
31
|
+
finally:
|
|
32
|
+
lock.release()
|
|
33
|
+
else:
|
|
34
|
+
# Someone else owns it
|
|
35
|
+
connect_to_existing_server()
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
port: int | None = None,
|
|
41
|
+
socket_path: str | Path | None = None,
|
|
42
|
+
host: str = "127.0.0.1",
|
|
43
|
+
):
|
|
44
|
+
"""
|
|
45
|
+
Initialize the lock.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
port: TCP port to bind for locking. Mutually exclusive with socket_path.
|
|
49
|
+
socket_path: Unix socket path for locking. Mutually exclusive with port.
|
|
50
|
+
host: Host to bind to when using TCP port. Defaults to localhost.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
ValueError: If neither port nor socket_path is provided, or both are.
|
|
54
|
+
"""
|
|
55
|
+
# Initialize all attributes first so __del__ doesn't fail if validation raises
|
|
56
|
+
self._socket: socket.socket | None = None
|
|
57
|
+
self._acquired = False
|
|
58
|
+
self.port = port
|
|
59
|
+
self.socket_path = Path(socket_path) if socket_path else None
|
|
60
|
+
self.host = host
|
|
61
|
+
|
|
62
|
+
if port is None and socket_path is None:
|
|
63
|
+
raise ValueError("Must provide either port or socket_path")
|
|
64
|
+
if port is not None and socket_path is not None:
|
|
65
|
+
raise ValueError("Cannot provide both port and socket_path")
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def is_acquired(self) -> bool:
|
|
69
|
+
"""Return True if this lock instance has acquired the lock."""
|
|
70
|
+
return self._acquired
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def address(self) -> tuple[str, int] | str:
|
|
74
|
+
"""Return the address this lock binds to."""
|
|
75
|
+
if self.socket_path:
|
|
76
|
+
return str(self.socket_path)
|
|
77
|
+
assert self.port is not None # Guaranteed by __init__ validation
|
|
78
|
+
return (self.host, self.port)
|
|
79
|
+
|
|
80
|
+
def try_acquire(self) -> bool:
|
|
81
|
+
"""
|
|
82
|
+
Try to acquire the lock.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
True if the lock was acquired, False if already held by another process.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
OSError: For socket errors other than address-in-use.
|
|
89
|
+
RuntimeError: If called when lock is already acquired.
|
|
90
|
+
"""
|
|
91
|
+
if self._acquired:
|
|
92
|
+
raise RuntimeError("Lock already acquired by this instance")
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
if self.socket_path:
|
|
96
|
+
# Clean up stale socket file if it exists
|
|
97
|
+
# This handles the case where a previous process crashed without cleanup
|
|
98
|
+
if self.socket_path.exists():
|
|
99
|
+
# Try to connect first to see if someone is actually listening
|
|
100
|
+
test_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
101
|
+
try:
|
|
102
|
+
test_sock.connect(str(self.socket_path))
|
|
103
|
+
# Connection succeeded, someone else has the lock
|
|
104
|
+
test_sock.close()
|
|
105
|
+
return False
|
|
106
|
+
except (ConnectionRefusedError, FileNotFoundError):
|
|
107
|
+
# No one is listening, safe to remove stale socket
|
|
108
|
+
try:
|
|
109
|
+
self.socket_path.unlink()
|
|
110
|
+
except FileNotFoundError:
|
|
111
|
+
pass
|
|
112
|
+
finally:
|
|
113
|
+
test_sock.close()
|
|
114
|
+
|
|
115
|
+
self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
116
|
+
self._socket.bind(str(self.socket_path))
|
|
117
|
+
# Start listening so others can detect the socket is in use
|
|
118
|
+
self._socket.listen(1)
|
|
119
|
+
else:
|
|
120
|
+
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
121
|
+
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
122
|
+
self._socket.bind((self.host, self.port))
|
|
123
|
+
# Start listening so others can detect the socket is in use
|
|
124
|
+
self._socket.listen(1)
|
|
125
|
+
|
|
126
|
+
self._acquired = True
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
except OSError as e:
|
|
130
|
+
if e.errno in (errno.EADDRINUSE, errno.EADDRNOTAVAIL):
|
|
131
|
+
if self._socket:
|
|
132
|
+
self._socket.close()
|
|
133
|
+
self._socket = None
|
|
134
|
+
return False
|
|
135
|
+
# Re-raise unexpected errors
|
|
136
|
+
if self._socket:
|
|
137
|
+
self._socket.close()
|
|
138
|
+
self._socket = None
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
def release(self) -> None:
|
|
142
|
+
"""
|
|
143
|
+
Release the lock.
|
|
144
|
+
|
|
145
|
+
Safe to call even if lock was not acquired.
|
|
146
|
+
"""
|
|
147
|
+
if self._socket:
|
|
148
|
+
try:
|
|
149
|
+
self._socket.close()
|
|
150
|
+
except OSError:
|
|
151
|
+
pass
|
|
152
|
+
self._socket = None
|
|
153
|
+
|
|
154
|
+
if self.socket_path and self.socket_path.exists():
|
|
155
|
+
try:
|
|
156
|
+
self.socket_path.unlink()
|
|
157
|
+
except (FileNotFoundError, OSError):
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
self._acquired = False
|
|
161
|
+
|
|
162
|
+
def __enter__(self) -> SocketLock:
|
|
163
|
+
"""Context manager entry - tries to acquire but doesn't raise if fails."""
|
|
164
|
+
self.try_acquire()
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
def __exit__(
|
|
168
|
+
self,
|
|
169
|
+
exc_type: type[BaseException] | None,
|
|
170
|
+
exc_val: BaseException | None,
|
|
171
|
+
exc_tb: TracebackType | None,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Context manager exit - releases the lock."""
|
|
174
|
+
self.release()
|
|
175
|
+
|
|
176
|
+
def __del__(self) -> None:
|
|
177
|
+
"""Destructor - ensure lock is released."""
|
|
178
|
+
self.release()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class LockFile:
|
|
182
|
+
"""
|
|
183
|
+
Alternative lock implementation using a lock file with PID.
|
|
184
|
+
|
|
185
|
+
This is useful as a secondary coordination mechanism alongside SocketLock,
|
|
186
|
+
particularly for detecting stale locks and storing metadata about the lock owner.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def __init__(self, path: str | Path):
|
|
190
|
+
"""
|
|
191
|
+
Initialize the lock file.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
path: Path to the lock file.
|
|
195
|
+
"""
|
|
196
|
+
self.path = Path(path)
|
|
197
|
+
self._acquired = False
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def is_acquired(self) -> bool:
|
|
201
|
+
"""Return True if this instance holds the lock."""
|
|
202
|
+
return self._acquired
|
|
203
|
+
|
|
204
|
+
def try_acquire(self) -> bool:
|
|
205
|
+
"""
|
|
206
|
+
Try to acquire the lock by creating a lock file with our PID.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
True if acquired, False if another process holds it.
|
|
210
|
+
"""
|
|
211
|
+
if self._acquired:
|
|
212
|
+
raise RuntimeError("Lock already acquired by this instance")
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
# Use O_EXCL for atomic file creation
|
|
216
|
+
fd = os.open(
|
|
217
|
+
self.path,
|
|
218
|
+
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
|
219
|
+
0o644,
|
|
220
|
+
)
|
|
221
|
+
try:
|
|
222
|
+
os.write(fd, f"{os.getpid()}\n".encode())
|
|
223
|
+
finally:
|
|
224
|
+
os.close(fd)
|
|
225
|
+
self._acquired = True
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
except FileExistsError:
|
|
229
|
+
# Lock file exists, check if the owning process is still alive
|
|
230
|
+
try:
|
|
231
|
+
with open(self.path) as f:
|
|
232
|
+
pid = int(f.read().strip())
|
|
233
|
+
# Check if process is alive
|
|
234
|
+
os.kill(pid, 0)
|
|
235
|
+
# Process is alive, lock is held
|
|
236
|
+
return False
|
|
237
|
+
except (ValueError, ProcessLookupError, PermissionError):
|
|
238
|
+
# Invalid PID or process is dead, try to clean up
|
|
239
|
+
try:
|
|
240
|
+
self.path.unlink()
|
|
241
|
+
return self.try_acquire()
|
|
242
|
+
except FileNotFoundError:
|
|
243
|
+
return self.try_acquire()
|
|
244
|
+
except OSError:
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
def release(self) -> None:
|
|
248
|
+
"""Release the lock by removing the lock file."""
|
|
249
|
+
if self._acquired:
|
|
250
|
+
try:
|
|
251
|
+
self.path.unlink()
|
|
252
|
+
except FileNotFoundError:
|
|
253
|
+
pass
|
|
254
|
+
self._acquired = False
|
|
255
|
+
|
|
256
|
+
def get_owner_pid(self) -> int | None:
|
|
257
|
+
"""
|
|
258
|
+
Get the PID of the process that holds the lock.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
PID if lock file exists and is valid, None otherwise.
|
|
262
|
+
"""
|
|
263
|
+
try:
|
|
264
|
+
with open(self.path) as f:
|
|
265
|
+
return int(f.read().strip())
|
|
266
|
+
except (FileNotFoundError, ValueError):
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
def __enter__(self) -> LockFile:
|
|
270
|
+
self.try_acquire()
|
|
271
|
+
return self
|
|
272
|
+
|
|
273
|
+
def __exit__(
|
|
274
|
+
self,
|
|
275
|
+
exc_type: type[BaseException] | None,
|
|
276
|
+
exc_val: BaseException | None,
|
|
277
|
+
exc_tb: TracebackType | None,
|
|
278
|
+
) -> None:
|
|
279
|
+
self.release()
|
|
280
|
+
|
|
281
|
+
def __del__(self) -> None:
|
|
282
|
+
self.release()
|