callserver 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.
- callserver/__init__.py +54 -0
- callserver/__main__.py +4 -0
- callserver/allocationmanager.py +176 -0
- callserver/allocationset.py +381 -0
- callserver/callserver.md +315 -0
- callserver/callserver.py +511 -0
- callserver-0.1.0.dist-info/METADATA +134 -0
- callserver-0.1.0.dist-info/RECORD +12 -0
- callserver-0.1.0.dist-info/WHEEL +5 -0
- callserver-0.1.0.dist-info/entry_points.txt +2 -0
- callserver-0.1.0.dist-info/licenses/LICENSE +21 -0
- callserver-0.1.0.dist-info/top_level.txt +1 -0
callserver/callserver.py
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CallServer for shared-memory RPC with inter-process function execution.
|
|
3
|
+
|
|
4
|
+
This module provides the CallServer class for making RPC calls between processes
|
|
5
|
+
on the same machine, using shared memory for efficient numpy array transfer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import threading
|
|
12
|
+
from multiprocessing import Pipe, Process
|
|
13
|
+
from multiprocessing.connection import Client, Connection, Listener
|
|
14
|
+
from typing import Any, Callable, Optional, Tuple, Union
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from .allocationmanager import AllocationSetManager
|
|
19
|
+
from .allocationset import AllocationSet
|
|
20
|
+
|
|
21
|
+
DEFAULT_AUTHKEY = b"callserver"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CallServer:
|
|
25
|
+
"""
|
|
26
|
+
Client interface for shared-memory RPC with inter-process function execution.
|
|
27
|
+
|
|
28
|
+
CallServer enables remote or local function execution with shared memory argument
|
|
29
|
+
passing. It manages allocation sets for efficient inter-process communication,
|
|
30
|
+
supports asynchronous and synchronous calls, and can operate as a client
|
|
31
|
+
(connecting to an existing process) or spawn a child server process.
|
|
32
|
+
|
|
33
|
+
Key Features:
|
|
34
|
+
- Automatic promotion of large numpy arrays to shared memory
|
|
35
|
+
- Support for both synchronous and asynchronous calls
|
|
36
|
+
- Server-side state management via context objects
|
|
37
|
+
- Connection to child processes or existing servers via TCP
|
|
38
|
+
|
|
39
|
+
Limitations:
|
|
40
|
+
- Only suitable for inter-process communication on the same machine
|
|
41
|
+
(shared memory cannot be used across machines)
|
|
42
|
+
- Only one async call can be in progress at a time
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
name: The unique name for this server instance.
|
|
46
|
+
port: TCP port for remote connections (None for child process mode).
|
|
47
|
+
context: Server-side state object or initializer.
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
>>> import numpy as np
|
|
51
|
+
>>> from callserver import CallServer
|
|
52
|
+
>>> server = CallServer('adder')
|
|
53
|
+
>>> server.start()
|
|
54
|
+
>>> result = server.call(np.add, np.ones((1024, 1024)), np.ones((1024, 1024)))
|
|
55
|
+
>>> server.stop()
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
name: Union[str, Tuple[str, Union[int, Tuple[str, int]]]],
|
|
61
|
+
context: Optional[Any] = None,
|
|
62
|
+
*args: Any,
|
|
63
|
+
authkey: bytes = DEFAULT_AUTHKEY,
|
|
64
|
+
**kwargs: Any,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Initialize a CallServer client.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
name: Either a string name (spawns a child process), or a tuple of
|
|
71
|
+
(name, endpoint) where endpoint is a port number or (address, port)
|
|
72
|
+
tuple for connecting to an existing server.
|
|
73
|
+
context: Server-side context object, or a callable that will be
|
|
74
|
+
invoked on the server to create the context.
|
|
75
|
+
*args: Arguments passed to the context initializer.
|
|
76
|
+
authkey: Authentication key used when connecting to a standalone
|
|
77
|
+
server. Must match the key the server was started with.
|
|
78
|
+
**kwargs: Keyword arguments passed to the context initializer.
|
|
79
|
+
"""
|
|
80
|
+
self.name = name
|
|
81
|
+
self.port: Optional[int] = None
|
|
82
|
+
self.address: str = "127.0.0.1"
|
|
83
|
+
if isinstance(name, tuple):
|
|
84
|
+
self.name, endpoint = name
|
|
85
|
+
self.address, self.port = (
|
|
86
|
+
endpoint if isinstance(endpoint, tuple) else ("127.0.0.1", endpoint)
|
|
87
|
+
)
|
|
88
|
+
self.context = context
|
|
89
|
+
self.context_args = args
|
|
90
|
+
self.context_kwargs = kwargs
|
|
91
|
+
self._alloc_manager = AllocationSetManager(self.name)
|
|
92
|
+
self._call_in_progress = False
|
|
93
|
+
self._started = False
|
|
94
|
+
if self.port:
|
|
95
|
+
self._pipe: Connection = Client(
|
|
96
|
+
(self.address, self.port), authkey=authkey
|
|
97
|
+
)
|
|
98
|
+
self._server_process: Optional[Process] = None
|
|
99
|
+
else:
|
|
100
|
+
self._pipe, self._server_pipe = Pipe()
|
|
101
|
+
self._server_process = Process(
|
|
102
|
+
name=self.name, target=run, args=(self._server_pipe,), daemon=True
|
|
103
|
+
)
|
|
104
|
+
self._last_result: Any = None
|
|
105
|
+
self._last_result_raw: Any = None
|
|
106
|
+
self._call_unpack_fn: Optional[Callable] = None
|
|
107
|
+
self._call_unpack_argset: Optional[AllocationSet] = None
|
|
108
|
+
|
|
109
|
+
def __repr__(self) -> str:
|
|
110
|
+
"""Return a string representation of the CallServer."""
|
|
111
|
+
mode = "remote" if self.port else "child"
|
|
112
|
+
status = "started" if self._started else "stopped"
|
|
113
|
+
return f"CallServer(name={self.name!r}, mode={mode}, status={status})"
|
|
114
|
+
|
|
115
|
+
def __enter__(self) -> "CallServer":
|
|
116
|
+
"""Enter context manager, starting the server."""
|
|
117
|
+
return self.start()
|
|
118
|
+
|
|
119
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
120
|
+
"""Exit context manager, stopping the server."""
|
|
121
|
+
self.stop()
|
|
122
|
+
|
|
123
|
+
def start(self) -> "CallServer":
|
|
124
|
+
"""
|
|
125
|
+
Start the server process or connect to a remote server.
|
|
126
|
+
|
|
127
|
+
For child process mode, spawns the server process. For remote mode,
|
|
128
|
+
sends the initial configuration to the server.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
self, for method chaining.
|
|
132
|
+
"""
|
|
133
|
+
if self._server_process:
|
|
134
|
+
self._server_process.start()
|
|
135
|
+
self._pipe.send(
|
|
136
|
+
(self.name, self.context, self.context_args, self.context_kwargs)
|
|
137
|
+
)
|
|
138
|
+
self._started = True
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
def add_allocation_set(self, fn: Union[Callable, str]) -> AllocationSet:
|
|
142
|
+
"""
|
|
143
|
+
Add a new AllocationSet for the given function.
|
|
144
|
+
|
|
145
|
+
Use this to pre-allocate additional buffer sets for double-buffering
|
|
146
|
+
in async call patterns.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
fn: A callable or string identifier for the function.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
The newly created AllocationSet.
|
|
153
|
+
"""
|
|
154
|
+
return self._alloc_manager.add_allocation_set(fn)
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def alloc(
|
|
158
|
+
callserver: Union["CallServer", str],
|
|
159
|
+
fn: Union[Callable, str],
|
|
160
|
+
shape: Tuple[int, ...],
|
|
161
|
+
dtype: type = float,
|
|
162
|
+
alloc_set_id: int = 0,
|
|
163
|
+
) -> np.ndarray:
|
|
164
|
+
"""
|
|
165
|
+
Pre-allocate a shared memory buffer for a function.
|
|
166
|
+
|
|
167
|
+
This static method can be used from both client and server code to
|
|
168
|
+
pre-allocate buffers that will be used for argument passing.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
callserver: A CallServer instance, or the server name string.
|
|
172
|
+
fn: A callable or string identifier for the function.
|
|
173
|
+
shape: Shape of the buffer to allocate.
|
|
174
|
+
dtype: Data type of the buffer (default: float).
|
|
175
|
+
alloc_set_id: Which allocation set to use (default: 0).
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
A numpy array backed by shared memory.
|
|
179
|
+
"""
|
|
180
|
+
alloc_mgr = (
|
|
181
|
+
callserver._alloc_manager
|
|
182
|
+
if isinstance(callserver, CallServer)
|
|
183
|
+
else AllocationSetManager(callserver)
|
|
184
|
+
)
|
|
185
|
+
argset = alloc_mgr.get_allocation_set(fn, alloc_set_id)
|
|
186
|
+
descriptor = argset.alloc("buffer", shape, dtype)
|
|
187
|
+
return argset.from_sharedmemory(descriptor)
|
|
188
|
+
|
|
189
|
+
def get_allocation_set(self, fn: Union[Callable, str]) -> AllocationSet:
|
|
190
|
+
"""
|
|
191
|
+
Get the current AllocationSet for the given function.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
fn: A callable or string identifier for the function.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
The current AllocationSet for the function.
|
|
198
|
+
"""
|
|
199
|
+
return self._alloc_manager.get_allocation_set(fn)
|
|
200
|
+
|
|
201
|
+
def _call_async(
|
|
202
|
+
self,
|
|
203
|
+
fn: Callable,
|
|
204
|
+
argset: AllocationSet,
|
|
205
|
+
unpack_fn: Callable,
|
|
206
|
+
*args: Any,
|
|
207
|
+
**kwargs: Any,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""
|
|
210
|
+
Internal method to initiate an async call.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
fn: The function to call on the server.
|
|
214
|
+
argset: The AllocationSet containing the arguments.
|
|
215
|
+
unpack_fn: Function to convert the result from shared memory.
|
|
216
|
+
*args: Positional arguments (already converted to descriptors).
|
|
217
|
+
**kwargs: Keyword arguments (already converted to descriptors).
|
|
218
|
+
"""
|
|
219
|
+
self._call_in_progress = True
|
|
220
|
+
self._call_unpack_fn = unpack_fn
|
|
221
|
+
self._call_unpack_argset = argset
|
|
222
|
+
self._pipe.send((fn, argset.ix, args, kwargs))
|
|
223
|
+
|
|
224
|
+
def call_async(self, fn: Callable, *args: Any, **kwargs: Any) -> None:
|
|
225
|
+
"""
|
|
226
|
+
Initiate an asynchronous function call.
|
|
227
|
+
|
|
228
|
+
The call executes on the server in parallel. Use get_result() to
|
|
229
|
+
retrieve the result when ready.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
fn: The function to call on the server.
|
|
233
|
+
*args: Positional arguments to pass to the function.
|
|
234
|
+
**kwargs: Keyword arguments to pass to the function.
|
|
235
|
+
|
|
236
|
+
Raises:
|
|
237
|
+
RuntimeError: If an async call is already in progress.
|
|
238
|
+
"""
|
|
239
|
+
if self._call_in_progress:
|
|
240
|
+
raise RuntimeError(
|
|
241
|
+
"An async call is already in progress. Call get_result to "
|
|
242
|
+
"complete the current call before starting a new one."
|
|
243
|
+
)
|
|
244
|
+
arg_set = self.get_allocation_set(fn)
|
|
245
|
+
args, kwargs = arg_set.args_to_sharedmemory(*args, **kwargs)
|
|
246
|
+
self._call_async(fn, arg_set, AllocationSet.retval_from_sharedmemory, *args, **kwargs)
|
|
247
|
+
|
|
248
|
+
def call(self, fn: Callable, *args: Any, **kwargs: Any) -> Any:
|
|
249
|
+
"""
|
|
250
|
+
Make a synchronous function call.
|
|
251
|
+
|
|
252
|
+
Equivalent to call_async() followed by get_result().
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
fn: The function to call on the server.
|
|
256
|
+
*args: Positional arguments to pass to the function.
|
|
257
|
+
**kwargs: Keyword arguments to pass to the function.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
The result of the function call.
|
|
261
|
+
"""
|
|
262
|
+
self.call_async(fn, *args, **kwargs)
|
|
263
|
+
return self.get_result()
|
|
264
|
+
|
|
265
|
+
def call_in_progress(self) -> bool:
|
|
266
|
+
"""
|
|
267
|
+
Check if an async call is currently in progress.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
True if a call is in progress, False otherwise.
|
|
271
|
+
"""
|
|
272
|
+
return self._call_in_progress
|
|
273
|
+
|
|
274
|
+
def has_result(self) -> bool:
|
|
275
|
+
"""
|
|
276
|
+
Check if a result is available without blocking.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
True if a result is ready to be retrieved, False otherwise.
|
|
280
|
+
"""
|
|
281
|
+
return self._pipe.poll()
|
|
282
|
+
|
|
283
|
+
def wait_for_result(self, timeout: Optional[float] = None) -> bool:
|
|
284
|
+
"""
|
|
285
|
+
Wait for a result to become available.
|
|
286
|
+
|
|
287
|
+
If no async call is in progress, returns True immediately (a previous
|
|
288
|
+
result may be available via get_result()).
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
timeout: Maximum time to wait in seconds. None means wait forever.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
True if a result is available (or no call is in progress),
|
|
295
|
+
False if timeout expired while waiting.
|
|
296
|
+
"""
|
|
297
|
+
if not self._call_in_progress:
|
|
298
|
+
return True
|
|
299
|
+
return self._pipe.poll(timeout)
|
|
300
|
+
|
|
301
|
+
def get_result(self, timeout: Optional[float] = None) -> Any:
|
|
302
|
+
"""
|
|
303
|
+
Retrieve the result of the current or last async call.
|
|
304
|
+
|
|
305
|
+
If no call is in progress, returns the result of the last call.
|
|
306
|
+
Otherwise, blocks until the result is available or timeout expires.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
timeout: Maximum time to wait in seconds. None means wait forever.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
The result of the function call, with numpy arrays converted
|
|
313
|
+
from shared memory.
|
|
314
|
+
|
|
315
|
+
Raises:
|
|
316
|
+
TimeoutError: If timeout expires before result is available.
|
|
317
|
+
"""
|
|
318
|
+
if not self._call_in_progress:
|
|
319
|
+
return self._last_result
|
|
320
|
+
if not self._pipe.poll(timeout):
|
|
321
|
+
raise TimeoutError(
|
|
322
|
+
f"Timed out waiting for result after {timeout} seconds"
|
|
323
|
+
)
|
|
324
|
+
self._last_result_raw = self._pipe.recv()
|
|
325
|
+
if isinstance(self._last_result_raw, Exception):
|
|
326
|
+
self._call_in_progress = False
|
|
327
|
+
raise self._last_result_raw from self._last_result_raw
|
|
328
|
+
self._last_result = self._call_unpack_fn(
|
|
329
|
+
self._call_unpack_argset, self._last_result_raw
|
|
330
|
+
)
|
|
331
|
+
self._call_in_progress = False
|
|
332
|
+
return self._last_result
|
|
333
|
+
|
|
334
|
+
def stop(self) -> None:
|
|
335
|
+
"""
|
|
336
|
+
Stop the server and clean up resources.
|
|
337
|
+
|
|
338
|
+
Closes the connection and waits for the server process to terminate
|
|
339
|
+
(if in child process mode).
|
|
340
|
+
"""
|
|
341
|
+
if not self._started:
|
|
342
|
+
return
|
|
343
|
+
self._started = False
|
|
344
|
+
self._pipe.close()
|
|
345
|
+
if self._server_process is not None:
|
|
346
|
+
self._server_process.join()
|
|
347
|
+
self._server_process = None
|
|
348
|
+
self._alloc_manager.close()
|
|
349
|
+
|
|
350
|
+
def __del__(self) -> None:
|
|
351
|
+
"""Clean up resources when the client is garbage collected."""
|
|
352
|
+
try:
|
|
353
|
+
self.stop()
|
|
354
|
+
except Exception:
|
|
355
|
+
pass # Ignore errors during cleanup
|
|
356
|
+
|
|
357
|
+
def run(
|
|
358
|
+
endpoint_or_connection: Union[Connection, Tuple[str, int, bytes]],
|
|
359
|
+
) -> None:
|
|
360
|
+
"""
|
|
361
|
+
Server loop for handling RPC calls.
|
|
362
|
+
|
|
363
|
+
This function runs the main server loop, processing function calls from
|
|
364
|
+
clients and returning results. It can operate in two modes:
|
|
365
|
+
|
|
366
|
+
1. Child process mode: Receives a connection from a parent process
|
|
367
|
+
2. Standalone mode: Listens for TCP connections on a specified endpoint.
|
|
368
|
+
In this mode the function never returns; after a client disconnects,
|
|
369
|
+
it waits for the next client.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
endpoint_or_connection: Either a Connection object (for child process
|
|
373
|
+
mode), or a tuple of (address, port, authkey) for standalone mode.
|
|
374
|
+
|
|
375
|
+
Example:
|
|
376
|
+
>>> from callserver import run
|
|
377
|
+
>>> run(("127.0.0.1", 5000, b"callserver")) # blocks forever
|
|
378
|
+
"""
|
|
379
|
+
keep_alive = isinstance(endpoint_or_connection, tuple)
|
|
380
|
+
pipe: Optional[Connection] = None if keep_alive else endpoint_or_connection
|
|
381
|
+
config: Optional[Tuple] = None
|
|
382
|
+
|
|
383
|
+
while keep_alive or pipe:
|
|
384
|
+
if keep_alive:
|
|
385
|
+
pipe = _wait_for_client_connection(*endpoint_or_connection)
|
|
386
|
+
try:
|
|
387
|
+
name, context_init, context_args, context_kwargs = pipe.recv()
|
|
388
|
+
except (EOFError, BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
|
389
|
+
if not keep_alive:
|
|
390
|
+
return
|
|
391
|
+
continue
|
|
392
|
+
|
|
393
|
+
alloc_manager = AllocationSetManager(name)
|
|
394
|
+
if not config or config != (name, context_init, context_args, context_kwargs):
|
|
395
|
+
config = (name, context_init, context_args, context_kwargs)
|
|
396
|
+
try:
|
|
397
|
+
context = (
|
|
398
|
+
context_init(*context_args, **context_kwargs)
|
|
399
|
+
if callable(context_init)
|
|
400
|
+
else context_init
|
|
401
|
+
)
|
|
402
|
+
except Exception as e:
|
|
403
|
+
try:
|
|
404
|
+
pipe.send(e)
|
|
405
|
+
except (EOFError, BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
|
406
|
+
pass
|
|
407
|
+
if not keep_alive:
|
|
408
|
+
return
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
while pipe:
|
|
412
|
+
try:
|
|
413
|
+
cmd = pipe.recv()
|
|
414
|
+
except (EOFError, BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
|
415
|
+
if not keep_alive:
|
|
416
|
+
return
|
|
417
|
+
break
|
|
418
|
+
|
|
419
|
+
fn, arg_set_idx, args, kwargs = cmd
|
|
420
|
+
argset = alloc_manager.get_allocation_set(fn, arg_set_idx)
|
|
421
|
+
args, kwargs = argset.args_from_sharedmemory(*args, **kwargs)
|
|
422
|
+
result = (
|
|
423
|
+
fn(context, *args, **kwargs) if context is not None else fn(*args, **kwargs)
|
|
424
|
+
)
|
|
425
|
+
result = argset.retval_to_sharedmemory(result)
|
|
426
|
+
try:
|
|
427
|
+
pipe.send(result)
|
|
428
|
+
except (EOFError, BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
|
429
|
+
if not keep_alive:
|
|
430
|
+
return
|
|
431
|
+
break
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _wait_for_client_connection(
|
|
435
|
+
address: str,
|
|
436
|
+
port: int,
|
|
437
|
+
authkey: bytes,
|
|
438
|
+
) -> Connection:
|
|
439
|
+
"""
|
|
440
|
+
Wait for a client connection on the specified endpoint.
|
|
441
|
+
|
|
442
|
+
Starts a listener thread and waits for a client to connect.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
address: The address to listen on.
|
|
446
|
+
port: The port to listen on.
|
|
447
|
+
authkey: Authentication key for the connection.
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
The established Connection object.
|
|
451
|
+
"""
|
|
452
|
+
listener_thread = threading.Thread(
|
|
453
|
+
target=_wait_for_client_connection_thread,
|
|
454
|
+
args=(address, port, authkey),
|
|
455
|
+
daemon=True,
|
|
456
|
+
)
|
|
457
|
+
listener_thread.start()
|
|
458
|
+
while listener_thread.is_alive():
|
|
459
|
+
listener_thread.join(timeout=1)
|
|
460
|
+
return listener_thread.connection
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _wait_for_client_connection_thread(
|
|
464
|
+
address: str,
|
|
465
|
+
port: int,
|
|
466
|
+
authkey: bytes,
|
|
467
|
+
) -> None:
|
|
468
|
+
"""
|
|
469
|
+
Thread target for listening for client connections.
|
|
470
|
+
|
|
471
|
+
Creates a listener and waits for a client to connect. Stores the
|
|
472
|
+
connection on the thread object for retrieval by the parent.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
address: The address to listen on.
|
|
476
|
+
port: The port to listen on.
|
|
477
|
+
authkey: Authentication key for the connection.
|
|
478
|
+
"""
|
|
479
|
+
with Listener((address, port), authkey=authkey) as listener:
|
|
480
|
+
print(f"Listening for connections on {address}:{port}")
|
|
481
|
+
threading.current_thread().connection = listener.accept()
|
|
482
|
+
print("Connection accepted from", listener.last_accepted)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def run_console() -> None:
|
|
486
|
+
"""
|
|
487
|
+
Command-line entry point for running a standalone RPC server.
|
|
488
|
+
|
|
489
|
+
Parses command-line arguments for address, port, and authentication key,
|
|
490
|
+
then starts the server loop. The server can be stopped with Ctrl+C.
|
|
491
|
+
|
|
492
|
+
Command-line arguments:
|
|
493
|
+
--port: Port to listen on (required)
|
|
494
|
+
--address: Address to listen on (default: 127.0.0.1)
|
|
495
|
+
--authkey: Authentication key (default: callserver)
|
|
496
|
+
"""
|
|
497
|
+
parser = argparse.ArgumentParser(description="Start a standalone CallServer.")
|
|
498
|
+
parser.add_argument("--port", type=int, required=True)
|
|
499
|
+
parser.add_argument("--address", type=str, default="127.0.0.1")
|
|
500
|
+
parser.add_argument(
|
|
501
|
+
"--authkey", type=str.encode, default=DEFAULT_AUTHKEY.decode()
|
|
502
|
+
)
|
|
503
|
+
args = parser.parse_args()
|
|
504
|
+
try:
|
|
505
|
+
run((args.address, args.port, args.authkey))
|
|
506
|
+
except KeyboardInterrupt:
|
|
507
|
+
pass
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
if __name__ == "__main__":
|
|
511
|
+
run_console()
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: callserver
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared-memory RPC for fast inter-process function calls with numpy arrays.
|
|
5
|
+
Author: dexman-ai
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: numpy
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest; extra == "test"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# callserver
|
|
18
|
+
|
|
19
|
+
Fast inter-process function calls for Python, with numpy arrays passed through shared memory.
|
|
20
|
+
|
|
21
|
+
## Overview
|
|
22
|
+
|
|
23
|
+
`callserver` runs Python functions in another process on the same machine. Large numpy arrays in arguments and return values are moved to shared memory automatically, so they are not pickled or pushed through a pipe. Shared memory is allocated on the first call and reused on every call after that.
|
|
24
|
+
|
|
25
|
+
It is built for low-latency, high-throughput workloads that need a separate process, such as:
|
|
26
|
+
|
|
27
|
+
- Real-time robot control
|
|
28
|
+
- Vision processing pipelines
|
|
29
|
+
- Compute and data-intensive workloads that benefit from zero-copy data sharing
|
|
30
|
+
|
|
31
|
+
**Features**
|
|
32
|
+
|
|
33
|
+
- Automatic shared memory transfer of large numpy arrays, in both directions
|
|
34
|
+
- Zero-copy transfer with pre-allocated shared memory buffers
|
|
35
|
+
- Synchronous and asynchronous calls, with double-buffering to overlap work
|
|
36
|
+
- Persistent server-side state
|
|
37
|
+
- Runs the server as a child process, or connects to a standalone server over TCP
|
|
38
|
+
|
|
39
|
+
`callserver` works on a single machine only. It is not meant for distributed execution.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install callserver
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Requires Python 3.8+ and numpy.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
### Simple Example
|
|
51
|
+
Arrays are transferred via shared memory both ways. No serialization!
|
|
52
|
+
Shared memory is only allocated the first time the RPC is made. Subsequent calls reuse the same buffers.
|
|
53
|
+
```python
|
|
54
|
+
import numpy as np
|
|
55
|
+
from callserver import CallServer
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
# Spawns a child process. np.add runs there; the arrays travel through shared memory.
|
|
59
|
+
with CallServer("adder") as server:
|
|
60
|
+
result = np.zeros((1024, 1024))
|
|
61
|
+
ones = np.ones((1024, 1024))
|
|
62
|
+
for _ in range(100):
|
|
63
|
+
result = server.call(np.add, result, ones)
|
|
64
|
+
print(result[0, 0]) # 100.0
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Server-Side State
|
|
68
|
+
A context passed to the server (instance or callable) holds state for subsequent calls:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import numpy as np
|
|
72
|
+
from callserver import CallServer
|
|
73
|
+
|
|
74
|
+
class Accumulator:
|
|
75
|
+
def __init__(self, shape):
|
|
76
|
+
self.total = np.zeros(shape)
|
|
77
|
+
|
|
78
|
+
def accumulate(acc, values):
|
|
79
|
+
acc.total += values
|
|
80
|
+
return acc.total
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
# Arguments after the initializer are passed to it on the server.
|
|
84
|
+
with CallServer("accumulator", Accumulator, (512, 512)) as server:
|
|
85
|
+
for _ in range(10):
|
|
86
|
+
total = server.call(accumulate, np.ones((512, 512)))
|
|
87
|
+
print(total[0, 0]) # 10.0
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Standalone Server
|
|
91
|
+
A server can also run as a separate, long-lived process that clients connect to over TCP. Start it from the command line (the port is required):
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
callserver --port 5000
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Then connect by passing a `(name, (address, port))` tuple instead of a name:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import numpy as np
|
|
101
|
+
from callserver import CallServer
|
|
102
|
+
|
|
103
|
+
with CallServer(("adder", ("127.0.0.1", 5000))) as server:
|
|
104
|
+
result = server.call(np.add, np.ones((1024, 1024)), np.ones((1024, 1024)))
|
|
105
|
+
print(result[0, 0]) # 2.0
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The server handles one client at a time and keeps running after a client disconnects. Functions sent to it must be importable by the server process.
|
|
109
|
+
|
|
110
|
+
> Server-side functions must be defined at module level so they can be pickled. The `if __name__ == "__main__":` guard is required on Windows and macOS, because the child process re-imports the main module.
|
|
111
|
+
|
|
112
|
+
## Documentation
|
|
113
|
+
|
|
114
|
+
- [Usage guide](callserver/callserver.md): async calls, double-buffering, pre-allocated buffers, standalone servers, the API reference, and how it works.
|
|
115
|
+
- [Examples](examples): runnable scripts.
|
|
116
|
+
|
|
117
|
+
| Example | Shows |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| [simple_call.py](examples/simple_call.py) | Basic synchronous call |
|
|
120
|
+
| [server_state.py](examples/server_state.py) | Server-side state with a context initializer |
|
|
121
|
+
| [async_calls.py](examples/async_calls.py) | Async calls with double-buffering |
|
|
122
|
+
| [preallocated_buffers.py](examples/preallocated_buffers.py) | Zero-copy with pre-allocated shared memory |
|
|
123
|
+
| [standalone_client.py](examples/standalone_client.py) | Connecting to a standalone server |
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
pip install -e .[test]
|
|
129
|
+
python -m pytest
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
callserver/__init__.py,sha256=30WA17EO6SlfsTlfJvyflUoZ5i-yhgV73l7qmeR8vCI,2278
|
|
2
|
+
callserver/__main__.py,sha256=_NcLxprCoPvMH3EY67oNQmekohRdsUijBv2pIKfDgss,86
|
|
3
|
+
callserver/allocationmanager.py,sha256=YfIEEgIYDyOGzPnlx24e_KtxIKBUVx-zUs24lZ419vg,6156
|
|
4
|
+
callserver/allocationset.py,sha256=FsTWmPqPYiIbyRTikL_QKUievoBz4IAzYwHwjp-ea-w,13928
|
|
5
|
+
callserver/callserver.md,sha256=CxJ7E4d-8K7av2X4E6CWzWgkM_P6SuGmZ6_0PlmNKs8,16273
|
|
6
|
+
callserver/callserver.py,sha256=OZf8HYemEtSLgfhFxKaquF7YelCSf5s_29Y7ND91vk4,18575
|
|
7
|
+
callserver-0.1.0.dist-info/licenses/LICENSE,sha256=LTNa9cWvBv1-sdawR17EUQqa0qHPhBOruWDUG3USeMw,1087
|
|
8
|
+
callserver-0.1.0.dist-info/METADATA,sha256=OAV1lCkg0R5rpXOyjcCZGKFA07w-49hyhxVKlGAGHBk,4725
|
|
9
|
+
callserver-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
callserver-0.1.0.dist-info/entry_points.txt,sha256=7mjHwrukbpFrDTOxW0gyuIeHyaCNnosidZLt0owH_Gg,54
|
|
11
|
+
callserver-0.1.0.dist-info/top_level.txt,sha256=cqSj8s_AUeodbipcxrZ5mrMHN1ksexfyR_M-Y9zUV20,11
|
|
12
|
+
callserver-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dexman-ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
callserver
|