fipc-python 1.0.0__py3-none-macosx_14_0_arm64.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.
- fipc/__init__.py +75 -0
- fipc/_fipc.py +760 -0
- fipc/_native/libfastipc.dylib +0 -0
- fipc/py.typed +0 -0
- fipc_python-1.0.0.dist-info/METADATA +325 -0
- fipc_python-1.0.0.dist-info/RECORD +9 -0
- fipc_python-1.0.0.dist-info/WHEEL +5 -0
- fipc_python-1.0.0.dist-info/licenses/LICENSE +21 -0
- fipc_python-1.0.0.dist-info/top_level.txt +1 -0
fipc/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastIPC: shared-memory messages between two processes (streams), broadcasts from one publisher to many
|
|
3
|
+
subscribers, work queues from many producers to many consumers, and services from many clients to a pool of servers,
|
|
4
|
+
with replies, over the C API of include/fipc.h.
|
|
5
|
+
|
|
6
|
+
import fipc
|
|
7
|
+
|
|
8
|
+
with fipc.stream_create("my_name", 1 << 20) as listener: # the server, in one process
|
|
9
|
+
conn = listener.accept()
|
|
10
|
+
conn = fipc.stream_connect("my_name", timeout_ms=5000) # the client, in another (or on another thread)
|
|
11
|
+
|
|
12
|
+
stream_connect waits for the server's accept, so a client and its server run on different threads or processes. A
|
|
13
|
+
broadcast: `broadcast_create` returns the publisher's Conn, and `broadcast_join` subscribes (SUBSCRIBER_LOSSY or
|
|
14
|
+
SUBSCRIBER_GATED). A work queue: `queue_create` returns a Queue, and `queue_join` joins it as a PRODUCER or a CONSUMER
|
|
15
|
+
(the owner too). A service: `service_create` returns a Service, and `service_join` joins it as a CLIENT, whose `call`
|
|
16
|
+
sends a request and returns its reply, or a SERVER (the owner too). The library admits a broadcast's, a queue's or a
|
|
17
|
+
service's joiners itself, in the owner's process.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from fipc._fipc import (
|
|
21
|
+
CLIENT,
|
|
22
|
+
CONSUMER,
|
|
23
|
+
FOREVER,
|
|
24
|
+
NO_WAIT,
|
|
25
|
+
PRODUCER,
|
|
26
|
+
SERVER,
|
|
27
|
+
SUBSCRIBER_GATED,
|
|
28
|
+
SUBSCRIBER_LOSSY,
|
|
29
|
+
Conn,
|
|
30
|
+
FipcError,
|
|
31
|
+
Listener,
|
|
32
|
+
Queue,
|
|
33
|
+
Result,
|
|
34
|
+
Service,
|
|
35
|
+
broadcast_create,
|
|
36
|
+
broadcast_join,
|
|
37
|
+
ffi,
|
|
38
|
+
lib,
|
|
39
|
+
queue_create,
|
|
40
|
+
queue_join,
|
|
41
|
+
result_str,
|
|
42
|
+
service_create,
|
|
43
|
+
service_join,
|
|
44
|
+
stream_connect,
|
|
45
|
+
stream_create,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__version__ = "1.0.0"
|
|
49
|
+
__all__ = [
|
|
50
|
+
"CLIENT",
|
|
51
|
+
"CONSUMER",
|
|
52
|
+
"FOREVER",
|
|
53
|
+
"NO_WAIT",
|
|
54
|
+
"PRODUCER",
|
|
55
|
+
"SERVER",
|
|
56
|
+
"SUBSCRIBER_GATED",
|
|
57
|
+
"SUBSCRIBER_LOSSY",
|
|
58
|
+
"Conn",
|
|
59
|
+
"FipcError",
|
|
60
|
+
"Listener",
|
|
61
|
+
"Queue",
|
|
62
|
+
"Result",
|
|
63
|
+
"Service",
|
|
64
|
+
"broadcast_create",
|
|
65
|
+
"broadcast_join",
|
|
66
|
+
"ffi",
|
|
67
|
+
"lib",
|
|
68
|
+
"queue_create",
|
|
69
|
+
"queue_join",
|
|
70
|
+
"result_str",
|
|
71
|
+
"service_create",
|
|
72
|
+
"service_join",
|
|
73
|
+
"stream_connect",
|
|
74
|
+
"stream_create",
|
|
75
|
+
]
|
fipc/_fipc.py
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The FastIPC C API (include/fipc.h) for Python, through CFFI's ABI mode: the shared library is loaded at run time,
|
|
3
|
+
with no build step.
|
|
4
|
+
|
|
5
|
+
A stream's server creates a name (`stream_create`) and accepts one client at a time; a client connects to the name
|
|
6
|
+
(`stream_connect`). A broadcast's publisher (`broadcast_create`) sends every message to each of its subscribers
|
|
7
|
+
(`broadcast_join`). A work queue's owner creates it (`queue_create`), and its producers and consumers join it
|
|
8
|
+
(`queue_join`, the owner too): each message a producer sends goes to one consumer. A service's owner creates it
|
|
9
|
+
(`service_create`), and its clients and servers join it (`service_join`, the owner too): each request a client sends
|
|
10
|
+
goes to one server, whose reply goes back to that client. The library admits a broadcast's, a queue's or a service's
|
|
11
|
+
joiners itself, in the owner's process. Timeouts are int milliseconds: NO_WAIT (0) doesn't wait, FOREVER
|
|
12
|
+
(-1) waits for ever. A call that fails raises FipcError, whose `result` is the library's Result (Result.TIMEOUT when the
|
|
13
|
+
timeout ran out). The header's rules hold: on a connection, one thread at a time sends and one receives; `cancel` may
|
|
14
|
+
be called from any thread; `close` only when no other thread is inside a call on the handle (cancel, join the
|
|
15
|
+
threads, close). A Conn or a Listener left unclosed is closed when it is garbage-collected; a Queue and a Service never
|
|
16
|
+
are: they live until their close or the end of the process.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import platform
|
|
23
|
+
import time
|
|
24
|
+
from collections import deque
|
|
25
|
+
from enum import IntEnum
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Deque, List, Optional, Sequence, Union
|
|
28
|
+
|
|
29
|
+
from cffi import FFI
|
|
30
|
+
|
|
31
|
+
ffi = FFI()
|
|
32
|
+
ffi.cdef("""
|
|
33
|
+
typedef struct fipc_listener fipc_listener_t;
|
|
34
|
+
typedef struct fipc_conn fipc_conn_t;
|
|
35
|
+
typedef struct fipc_queue fipc_queue_t;
|
|
36
|
+
typedef struct fipc_service fipc_service_t;
|
|
37
|
+
typedef int fipc_result_t;
|
|
38
|
+
|
|
39
|
+
const char* fipc_result_str(fipc_result_t result);
|
|
40
|
+
|
|
41
|
+
fipc_result_t fipc_stream_create(const char* name, size_t capacity, fipc_listener_t** out_listener);
|
|
42
|
+
fipc_result_t fipc_stream_accept(fipc_listener_t* listener, fipc_conn_t** out_conn, int timeout_ms);
|
|
43
|
+
void fipc_listener_cancel(fipc_listener_t* listener);
|
|
44
|
+
void fipc_listener_close(fipc_listener_t* listener);
|
|
45
|
+
fipc_result_t fipc_stream_connect(const char* name, fipc_conn_t** out_conn, int timeout_ms);
|
|
46
|
+
size_t fipc_max_piece(const fipc_conn_t* conn);
|
|
47
|
+
void fipc_cancel(fipc_conn_t* conn);
|
|
48
|
+
void fipc_close(fipc_conn_t* conn);
|
|
49
|
+
|
|
50
|
+
fipc_result_t fipc_send(fipc_conn_t* conn, const void* data, size_t len, int timeout_ms);
|
|
51
|
+
fipc_result_t fipc_recv(fipc_conn_t* conn, void* buf, size_t buf_len, size_t* out_len, int timeout_ms);
|
|
52
|
+
fipc_result_t fipc_send_acquire(fipc_conn_t* conn, size_t len, void** out_buf, int timeout_ms);
|
|
53
|
+
fipc_result_t fipc_send_commit(fipc_conn_t* conn, size_t len);
|
|
54
|
+
fipc_result_t fipc_recv_acquire(fipc_conn_t* conn, const void** out_data, size_t* out_len, int timeout_ms);
|
|
55
|
+
void fipc_recv_release(fipc_conn_t* conn);
|
|
56
|
+
fipc_result_t fipc_send_many(fipc_conn_t* conn, const void* const* datas, const size_t* lens, size_t count,
|
|
57
|
+
size_t* out_sent, int timeout_ms);
|
|
58
|
+
fipc_result_t fipc_recv_many(fipc_conn_t* conn, void* buf, size_t buf_len, size_t* lens, size_t max_count,
|
|
59
|
+
size_t* out_count, int timeout_ms);
|
|
60
|
+
|
|
61
|
+
fipc_result_t fipc_broadcast_create(const char* name, size_t capacity, uint32_t max_subscribers, int max_stall_ms,
|
|
62
|
+
fipc_conn_t** out_publisher);
|
|
63
|
+
fipc_result_t fipc_broadcast_join(const char* name, int role, fipc_conn_t** out_conn, int timeout_ms);
|
|
64
|
+
fipc_result_t fipc_broadcast_wait_for_members(fipc_conn_t* publisher, uint32_t at_least, uint32_t* out_count,
|
|
65
|
+
int timeout_ms);
|
|
66
|
+
|
|
67
|
+
fipc_result_t fipc_queue_create(const char* name, size_t capacity, uint32_t max_producers, uint32_t max_consumers,
|
|
68
|
+
fipc_queue_t** out_queue);
|
|
69
|
+
fipc_result_t fipc_queue_join(const char* name, int role, fipc_conn_t** out_conn, int timeout_ms);
|
|
70
|
+
fipc_result_t fipc_queue_wait_for_members(fipc_queue_t* queue, int role, uint32_t at_least, uint32_t* out_count,
|
|
71
|
+
int timeout_ms);
|
|
72
|
+
void fipc_queue_cancel(fipc_queue_t* queue);
|
|
73
|
+
void fipc_queue_close(fipc_queue_t* queue);
|
|
74
|
+
|
|
75
|
+
fipc_result_t fipc_service_create(const char* name, size_t capacity, size_t max_message, uint32_t max_clients,
|
|
76
|
+
uint32_t max_servers, fipc_service_t** out_service);
|
|
77
|
+
fipc_result_t fipc_service_join(const char* name, int role, fipc_conn_t** out_conn, int timeout_ms);
|
|
78
|
+
fipc_result_t fipc_service_wait_for_members(fipc_service_t* service, int role, uint32_t at_least,
|
|
79
|
+
uint32_t* out_count, int timeout_ms);
|
|
80
|
+
void fipc_service_cancel(fipc_service_t* service);
|
|
81
|
+
void fipc_service_close(fipc_service_t* service);
|
|
82
|
+
""")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Linux and macOS: the library is never unloaded (docs/platform-support.md), but CFFI unloads it when the interpreter
|
|
86
|
+
# exits and frees the handle, and a library thread on its way out of the library's code at that moment crashes the
|
|
87
|
+
# exit. RTLD_NODELETE keeps it loaded for the life of the process; Windows has no such flag and needs none.
|
|
88
|
+
_PIN = getattr(ffi, "RTLD_NODELETE", 0)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _load() -> Any:
|
|
92
|
+
"""The shared library, kept loaded (`_PIN`): in the folder the environment variable FASTIPC_LIB_DIR names, when it
|
|
93
|
+
is set (nowhere else); in a development checkout, the repository's zig-out build (ahead of any copy in _native/,
|
|
94
|
+
which may be older); else the one bundled in the package's _native/ folder (an installed wheel); else the
|
|
95
|
+
system's search (LD_LIBRARY_PATH, DYLD_LIBRARY_PATH, PATH)."""
|
|
96
|
+
package = Path(__file__).parent
|
|
97
|
+
repo = package.parent.parent.parent # fipc -> python -> bindings -> the repository
|
|
98
|
+
names = {"Windows": ["fastipc.dll"], "Darwin": ["libfastipc.dylib"]}.get(platform.system(), ["libfastipc.so"])
|
|
99
|
+
directory = os.environ.get("FASTIPC_LIB_DIR")
|
|
100
|
+
if directory:
|
|
101
|
+
for name in names:
|
|
102
|
+
if (Path(directory) / name).exists():
|
|
103
|
+
return ffi.dlopen(str((Path(directory) / name).resolve()), _PIN)
|
|
104
|
+
raise OSError(f"FastIPC library not found: no {names[0]} in {directory}, the folder FASTIPC_LIB_DIR names")
|
|
105
|
+
folders = [repo / "zig-out" / "lib", repo / "zig-out" / "bin"] if (repo / "build.zig").exists() else []
|
|
106
|
+
for folder in (*folders, package / "_native"):
|
|
107
|
+
for name in names:
|
|
108
|
+
if (folder / name).exists():
|
|
109
|
+
return ffi.dlopen(str(folder / name), _PIN)
|
|
110
|
+
for name in names:
|
|
111
|
+
try:
|
|
112
|
+
return ffi.dlopen(name, _PIN)
|
|
113
|
+
except OSError:
|
|
114
|
+
pass
|
|
115
|
+
raise OSError(f"FastIPC library not found: {names} in {package / '_native'}, zig-out or the system's search")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
lib = _load()
|
|
119
|
+
|
|
120
|
+
NO_WAIT = 0
|
|
121
|
+
FOREVER = -1
|
|
122
|
+
# broadcast_join's roles: a lossy subscriber never holds the publisher back (it may lose messages: OVERRUN); a gated
|
|
123
|
+
# one misses nothing (the publisher waits for it)
|
|
124
|
+
SUBSCRIBER_LOSSY = 1
|
|
125
|
+
SUBSCRIBER_GATED = 2
|
|
126
|
+
# queue_join's and Queue.wait_for_members' roles: a producer sends, a consumer receives
|
|
127
|
+
PRODUCER = 3
|
|
128
|
+
CONSUMER = 4
|
|
129
|
+
# service_join's and Service.wait_for_members' roles: a client sends requests and receives their replies, a server
|
|
130
|
+
# receives requests and replies to them
|
|
131
|
+
CLIENT = 5
|
|
132
|
+
SERVER = 6
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Result(IntEnum):
|
|
136
|
+
"""fipc_result_t."""
|
|
137
|
+
OK = 0
|
|
138
|
+
TIMEOUT = 1
|
|
139
|
+
DISCONNECTED = 2
|
|
140
|
+
CANCELLED = 3
|
|
141
|
+
TOO_LARGE = 4
|
|
142
|
+
INVALID = 5
|
|
143
|
+
NO_MEMORY = 6
|
|
144
|
+
ADDR_IN_USE = 7
|
|
145
|
+
OVERRUN = 8
|
|
146
|
+
UNSUBSCRIBED = 9
|
|
147
|
+
ABANDONED = 10
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def result_str(result: int) -> str:
|
|
151
|
+
"""The library's name for a result ("FIPC_OK", ...)."""
|
|
152
|
+
return ffi.string(lib.fipc_result_str(result)).decode()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class FipcError(Exception):
|
|
156
|
+
"""A call returned `result`, not OK. `lost`: on OVERRUN, how many messages the lossy subscriber lost (at least 1),
|
|
157
|
+
else None. `count`: when a `wait_for_members` raised TIMEOUT, CANCELLED or DISCONNECTED, how many members were
|
|
158
|
+
joined, else None (INVALID included). `sent`: when a `send_many` raised, how many of its messages went (all those
|
|
159
|
+
before the one that didn't), else None. `why`, if given, says why in the message (a call's INVALID while a reply to
|
|
160
|
+
a send is owed)."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, result: int, call: str, lost: Optional[int] = None, count: Optional[int] = None,
|
|
163
|
+
why: Optional[str] = None, sent: Optional[int] = None) -> None:
|
|
164
|
+
detail = f" ({lost} lost)" if lost is not None else f" ({count} joined)" if count is not None else ""
|
|
165
|
+
detail = f" ({sent} sent)" if sent is not None else detail
|
|
166
|
+
detail = f" ({why})" if why is not None else detail
|
|
167
|
+
super().__init__(f"{call}: {result_str(result)}{detail}")
|
|
168
|
+
self.result = Result(result) if result in Result._value2member_map_ else result
|
|
169
|
+
self.lost = lost
|
|
170
|
+
self.count = count
|
|
171
|
+
self.sent = sent
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _check(result: int, call: str) -> None:
|
|
175
|
+
if result != Result.OK:
|
|
176
|
+
raise FipcError(result, call)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _check_recv(result: int, length: int, call: str = "fipc_recv") -> None:
|
|
180
|
+
"""As _check for fipc_recv (or fipc_recv_many), whose length out-parameter holds OVERRUN's count of the messages
|
|
181
|
+
lost."""
|
|
182
|
+
if result == Result.OVERRUN:
|
|
183
|
+
raise FipcError(result, call, lost=length)
|
|
184
|
+
_check(result, call)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _left(timeout_ms: int, deadline: float) -> int:
|
|
188
|
+
"""What is left of timeout_ms at `deadline` (FOREVER stays FOREVER), in whole milliseconds, at least 0."""
|
|
189
|
+
return timeout_ms if timeout_ms < 0 else max(0, int((deadline - time.monotonic()) * 1000))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _check_count(result: int, count: int, call: str) -> None:
|
|
193
|
+
"""As _check for a wait for members, whose count out-parameter holds the count at every result but INVALID."""
|
|
194
|
+
if result not in (Result.OK, Result.INVALID):
|
|
195
|
+
raise FipcError(result, call, count=count)
|
|
196
|
+
_check(result, call)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
Bytes = Union[bytes, bytearray, memoryview]
|
|
200
|
+
|
|
201
|
+
# The least room recv_many receives into: a batch takes the messages that fit it (it grows for a longer first message)
|
|
202
|
+
_BATCH_BYTES = 1 << 16
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class Conn:
|
|
206
|
+
"""One connection: a stream's (`stream_connect`, a client; `Listener.accept`, a server), with two rings and one
|
|
207
|
+
session; a broadcast's publisher (`broadcast_create`), whose sends go to every subscriber, or one of its subscribers
|
|
208
|
+
(`broadcast_join`), which receives; a work queue's producer or consumer (`queue_join`): a producer sends, and a
|
|
209
|
+
consumer receives, each message going to one consumer; or a service's client or server (`service_join`): a client
|
|
210
|
+
sends requests and receives their replies, in the order of its requests (or `call`s), and a server receives the
|
|
211
|
+
next request of any client and replies to it with its next send.
|
|
212
|
+
|
|
213
|
+
A Conn left unclosed is closed when it is garbage-collected, as by `close` (a publisher's ends its broadcast, a
|
|
214
|
+
server's answers the request it holds ABANDONED). A memoryview that `send_acquire` or `recv_acquire` returned keeps
|
|
215
|
+
its Conn referenced, so the connection's memory stays mapped while the view exists; and a thread inside a call
|
|
216
|
+
holds the Conn, so the collection never closes it under a call."""
|
|
217
|
+
|
|
218
|
+
def __init__(self, handle: Any) -> None:
|
|
219
|
+
self._handle = ffi.gc(handle, lib.fipc_close) # the finalizer, which close() disarms
|
|
220
|
+
# Out-parameters and the receive buffer, per connection (one receiving thread at a time)
|
|
221
|
+
self._len = ffi.new("size_t*")
|
|
222
|
+
self._ptr = ffi.new("void**")
|
|
223
|
+
self._cptr = ffi.new("const void**")
|
|
224
|
+
self._buf = ffi.new("char[]", 4096)
|
|
225
|
+
self._lens = ffi.new("size_t[]", 16) # recv_many's lengths, grown for a larger max_count
|
|
226
|
+
|
|
227
|
+
def __enter__(self) -> "Conn":
|
|
228
|
+
return self
|
|
229
|
+
|
|
230
|
+
def __exit__(self, *exc: Any) -> None:
|
|
231
|
+
self.close()
|
|
232
|
+
|
|
233
|
+
def max_piece(self) -> int:
|
|
234
|
+
"""The largest message the zero-copy calls take on this connection: its capacity less 64 bytes (a broadcast's or
|
|
235
|
+
a work queue's: a quarter of its capacity, the largest message it takes; a service's: its max_message, the
|
|
236
|
+
largest request or reply)."""
|
|
237
|
+
return lib.fipc_max_piece(self._handle)
|
|
238
|
+
|
|
239
|
+
def cancel(self) -> None:
|
|
240
|
+
"""Every call of this connection that waits, now or later, raises CANCELLED (a service client's `call` too,
|
|
241
|
+
sending nothing). Any thread."""
|
|
242
|
+
lib.fipc_cancel(self._handle)
|
|
243
|
+
|
|
244
|
+
def wait_for_members(self, at_least: int = 0, timeout_ms: int = FOREVER) -> int:
|
|
245
|
+
"""A broadcast's publisher: waits up to timeout_ms until at least `at_least` subscribers are joined (0 returns
|
|
246
|
+
the count at once), and returns how many are. To send nothing before N subscribers are there, call it with N
|
|
247
|
+
before the first send. TIMEOUT and CANCELLED (after `cancel`) carry the count in the error's `count`; INVALID
|
|
248
|
+
on any other connection, or for at_least over the broadcast's max_subscribers. Any thread."""
|
|
249
|
+
count = ffi.new("uint32_t*")
|
|
250
|
+
result = lib.fipc_broadcast_wait_for_members(self._handle, at_least, count, timeout_ms)
|
|
251
|
+
_check_count(result, count[0], "fipc_broadcast_wait_for_members")
|
|
252
|
+
return count[0]
|
|
253
|
+
|
|
254
|
+
def close(self) -> None:
|
|
255
|
+
"""Ends the connection; the peer gets DISCONNECTED. A publisher's close ends the broadcast and frees its name:
|
|
256
|
+
its subscribers receive what it sent, then DISCONNECTED. A subscriber, a producer or a consumer leaves (a
|
|
257
|
+
producer's messages stay for the consumers). A service's client or server leaves: a client's requests that no
|
|
258
|
+
server took are dropped, and the replies to those taken are lost; the request a server holds is answered
|
|
259
|
+
ABANDONED. No other thread may be inside a call on it. Closing it again does nothing."""
|
|
260
|
+
if self._handle is not None:
|
|
261
|
+
handle, self._handle = self._handle, None
|
|
262
|
+
ffi.gc(handle, None) # disarms the finalizer: closed once
|
|
263
|
+
lib.fipc_close(handle)
|
|
264
|
+
|
|
265
|
+
def _view(self, pointer: Any, length: int) -> memoryview:
|
|
266
|
+
"""A memoryview of the `length` bytes at `pointer`, in the connection's shared memory, that keeps the Conn
|
|
267
|
+
referenced while it exists (through the destructor of the pointer it holds), so the finalizer can't unmap that
|
|
268
|
+
memory under it."""
|
|
269
|
+
return memoryview(ffi.buffer(ffi.gc(pointer, self._keep), length))
|
|
270
|
+
|
|
271
|
+
def _keep(self, pointer: Any) -> None:
|
|
272
|
+
"""The destructor of a view's pointer: nothing to free; being a bound method, it holds the Conn for the view."""
|
|
273
|
+
|
|
274
|
+
# === Messages ===
|
|
275
|
+
|
|
276
|
+
def send(self, data: Bytes, timeout_ms: int = FOREVER) -> None:
|
|
277
|
+
"""Sends one message of any size (at least 1 byte; on a broadcast, a work queue or a service, at most
|
|
278
|
+
`max_piece()`: else TOO_LARGE). A work queue's producer waits for room while the consumers haven't taken its
|
|
279
|
+
earlier messages. A service's client sends a request, waiting for room in its ring (which its replies not yet
|
|
280
|
+
received hold too); a server replies to the request it holds, and never waits (INVALID with none held)."""
|
|
281
|
+
_check(lib.fipc_send(self._handle, ffi.from_buffer(data), len(data), timeout_ms), "fipc_send")
|
|
282
|
+
|
|
283
|
+
def recv(self, timeout_ms: int = FOREVER) -> bytes:
|
|
284
|
+
"""Receives one message of any size. A lossy subscriber that fell a lap behind gets OVERRUN instead, with the
|
|
285
|
+
number of messages lost in the error's `lost`; its next receive goes on from a recent message. A work queue's
|
|
286
|
+
consumer takes a message of any producer. A service's client receives the reply to its oldest request whose
|
|
287
|
+
reply it hasn't received, or ABANDONED if that request's server ended before it replied (the request counts as
|
|
288
|
+
answered; it may or may not have been carried out); a receive that times out or is cancelled leaves the reply to
|
|
289
|
+
come, for the next receive, and after a `call` that gave up, the next `recv` returns that call's late reply
|
|
290
|
+
first. A server takes the next request of any client and holds it until it replies: its receives raise INVALID
|
|
291
|
+
until then."""
|
|
292
|
+
length = self._recv(timeout_ms) # may replace the buffer with a bigger one
|
|
293
|
+
return ffi.buffer(self._buf, length)[:]
|
|
294
|
+
|
|
295
|
+
def recv_into(self, buf: Union[bytearray, memoryview], timeout_ms: int = FOREVER) -> int:
|
|
296
|
+
"""Receives one message into `buf` and returns its length; TOO_LARGE (the message stays) if it doesn't fit: on
|
|
297
|
+
a work queue another consumer may take it first. OVERRUN as `recv`."""
|
|
298
|
+
_check_recv(lib.fipc_recv(self._handle, ffi.from_buffer(buf), len(buf), self._len, timeout_ms), self._len[0])
|
|
299
|
+
return self._len[0]
|
|
300
|
+
|
|
301
|
+
def _recv(self, timeout_ms: int) -> int:
|
|
302
|
+
"""Receives into the connection's buffer, growing it for a message that doesn't fit (on a work queue another
|
|
303
|
+
consumer may take that one first, and the next may be longer still); returns the length. Every receive waits
|
|
304
|
+
only for what is left of timeout_ms."""
|
|
305
|
+
deadline = time.monotonic() + timeout_ms / 1000
|
|
306
|
+
result = lib.fipc_recv(self._handle, self._buf, len(self._buf), self._len, timeout_ms)
|
|
307
|
+
while result == Result.TOO_LARGE:
|
|
308
|
+
self._buf = ffi.new("char[]", self._len[0])
|
|
309
|
+
result = lib.fipc_recv(self._handle, self._buf, len(self._buf), self._len, _left(timeout_ms, deadline))
|
|
310
|
+
_check_recv(result, self._len[0])
|
|
311
|
+
return self._len[0]
|
|
312
|
+
|
|
313
|
+
def send_many(self, messages: Sequence[Bytes], timeout_ms: int = FOREVER) -> int:
|
|
314
|
+
"""Sends `messages` (at least one, each of at least 1 byte) in order and publishes them together, with at most
|
|
315
|
+
one wake of the receivers for the batch, and returns how many went: all of them. timeout_ms bounds the whole
|
|
316
|
+
call (NO_WAIT: only what fits now). An error is the first message that didn't go, as `send` raises it, and its
|
|
317
|
+
`sent` is how many went, all those before it. Every message is checked first, before any goes: INVALID for an
|
|
318
|
+
empty one or an empty batch, TOO_LARGE over `max_piece()` on a broadcast, a work queue or a service; on a stream
|
|
319
|
+
a longer message goes in pieces, as `send` sends it. A service's client sends a batch of requests, whose replies
|
|
320
|
+
come in their order and are owed as `send`'s are; a server's batch is INVALID. Batch both ends: a receiver that
|
|
321
|
+
takes one message per call falls behind a batching sender, and the two run slower than without batches."""
|
|
322
|
+
count = len(messages)
|
|
323
|
+
datas = ffi.new("const void*[]", count)
|
|
324
|
+
lens = ffi.new("size_t[]", count)
|
|
325
|
+
keep = [ffi.from_buffer(message) for message in messages] # referenced until the call returns
|
|
326
|
+
for i, data in enumerate(keep):
|
|
327
|
+
datas[i] = data
|
|
328
|
+
lens[i] = len(data)
|
|
329
|
+
sent = ffi.new("size_t*")
|
|
330
|
+
result = lib.fipc_send_many(self._handle, datas, lens, count, sent, timeout_ms)
|
|
331
|
+
if result != Result.OK:
|
|
332
|
+
raise FipcError(result, "fipc_send_many", sent=sent[0])
|
|
333
|
+
return sent[0]
|
|
334
|
+
|
|
335
|
+
def recv_many(self, max_count: int = 16, timeout_ms: int = FOREVER) -> List[bytes]:
|
|
336
|
+
"""Waits up to timeout_ms for the next message, as `recv`, then takes every further message already there, up to
|
|
337
|
+
`max_count` (at least 1) and as many as fit the connection's receive buffer (64 KiB, grown for a longer first
|
|
338
|
+
message), and returns them in order. An error is the first message's, as `recv` raises it, with nothing else
|
|
339
|
+
taken: OVERRUN with the number lost in `lost`, a service client's ABANDONED. After the first it never waits,
|
|
340
|
+
and stops before a message that isn't there yet, doesn't fit, is in pieces, or would bring another result: the
|
|
341
|
+
next receive returns it. A broadcast's subscriber takes, after its first, only the messages its publisher has
|
|
342
|
+
moved past (the newest come in the next calls); a work queue's consumer acknowledges each message before the
|
|
343
|
+
call returns, as `recv` does; a service's client receives its replies in its requests' order. INVALID on a
|
|
344
|
+
publisher, a producer and a service's server, which holds one request at a time."""
|
|
345
|
+
if len(self._buf) < _BATCH_BYTES:
|
|
346
|
+
self._buf = ffi.new("char[]", _BATCH_BYTES)
|
|
347
|
+
if len(self._lens) < max_count:
|
|
348
|
+
self._lens = ffi.new("size_t[]", max_count)
|
|
349
|
+
deadline = time.monotonic() + timeout_ms / 1000
|
|
350
|
+
result = lib.fipc_recv_many(self._handle, self._buf, len(self._buf), self._lens, max_count, self._len,
|
|
351
|
+
timeout_ms)
|
|
352
|
+
while result == Result.TOO_LARGE: # the first message stays: make room for it (or the next, maybe longer)
|
|
353
|
+
self._buf = ffi.new("char[]", self._lens[0])
|
|
354
|
+
result = lib.fipc_recv_many(self._handle, self._buf, len(self._buf), self._lens, max_count, self._len,
|
|
355
|
+
_left(timeout_ms, deadline))
|
|
356
|
+
_check_recv(result, self._lens[0], "fipc_recv_many")
|
|
357
|
+
received = ffi.buffer(self._buf)
|
|
358
|
+
messages = []
|
|
359
|
+
at = 0
|
|
360
|
+
for i in range(self._len[0]):
|
|
361
|
+
messages.append(received[at:at + self._lens[i]])
|
|
362
|
+
at += self._lens[i]
|
|
363
|
+
return messages
|
|
364
|
+
|
|
365
|
+
def send_acquire(self, length: int, timeout_ms: int = FOREVER) -> memoryview:
|
|
366
|
+
"""Zero-copy send, step 1: `length` bytes (at most `max_piece()`) in the ring to write the message into. The
|
|
367
|
+
memoryview keeps the Conn referenced (it is never collected under it)."""
|
|
368
|
+
_check(lib.fipc_send_acquire(self._handle, length, self._ptr, timeout_ms), "fipc_send_acquire")
|
|
369
|
+
return self._view(self._ptr[0], length)
|
|
370
|
+
|
|
371
|
+
def send_commit(self, length: int) -> None:
|
|
372
|
+
"""Zero-copy send, step 2: sends the first `length` bytes of the acquired room as one message."""
|
|
373
|
+
_check(lib.fipc_send_commit(self._handle, length), "fipc_send_commit")
|
|
374
|
+
|
|
375
|
+
def recv_acquire(self, timeout_ms: int = FOREVER) -> memoryview:
|
|
376
|
+
"""Zero-copy receive, step 1: the next message in the ring (read-only), valid until `recv_release`, the next
|
|
377
|
+
receive or `close` (the memoryview keeps the Conn referenced: it is never collected under it). TOO_LARGE for a
|
|
378
|
+
message of several pieces: `recv` it. A gated subscriber's message holds the publisher back until its release; a
|
|
379
|
+
lossy subscriber's acquire is INVALID. A work queue's consumer takes the message: it holds its producer back
|
|
380
|
+
once the producer is a ring ahead of it. Once a receive raised UNSUBSCRIBED, never read a memoryview of a
|
|
381
|
+
message held in place: its memory is gone, and reading it crashes the process. A service's client takes its next
|
|
382
|
+
reply in place (or ABANDONED, as `recv`); a server takes the next request, which stays valid until its reply's
|
|
383
|
+
`send` or `send_commit`."""
|
|
384
|
+
_check(lib.fipc_recv_acquire(self._handle, self._cptr, self._len, timeout_ms), "fipc_recv_acquire")
|
|
385
|
+
return self._view(self._cptr[0], self._len[0]).toreadonly()
|
|
386
|
+
|
|
387
|
+
def recv_release(self) -> None:
|
|
388
|
+
"""Zero-copy receive, step 2: frees the acquired message's room. On a service's server it does nothing: the
|
|
389
|
+
request stays until the reply."""
|
|
390
|
+
lib.fipc_recv_release(self._handle)
|
|
391
|
+
|
|
392
|
+
def call(self, request: Bytes, timeout_ms: int = FOREVER) -> bytes:
|
|
393
|
+
"""A service's client: sends `request` (1 to `max_piece()` bytes) and returns its reply, waiting up to
|
|
394
|
+
timeout_ms in all; CANCELLED, sending nothing, after `cancel`; INVALID on any other connection. See
|
|
395
|
+
`service_join`."""
|
|
396
|
+
raise FipcError(Result.INVALID, "call")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class _ServiceClient(Conn):
|
|
400
|
+
"""A service's client (`service_join` with CLIENT), a Conn that keeps, oldest first, the requests whose replies it
|
|
401
|
+
hasn't received yet, so that `call` never takes the reply to another request: True for a request whose `call`
|
|
402
|
+
gave up on its reply (it timed out or was cancelled), which the next `call` receives and drops (at most one, as a
|
|
403
|
+
call first drops what is owed); False for one sent with `send` or `send_commit`, whose reply is the caller's to
|
|
404
|
+
receive. A request's entry is added before it is sent, since a thread that receives may take its reply before the
|
|
405
|
+
send returns, and removed unless the send returned: whatever it raised (FipcError, a TypeError for an argument that
|
|
406
|
+
isn't bytes, KeyboardInterrupt, ...). `_cancelled` once `cancel` has run: a call then sends nothing, as it could
|
|
407
|
+
never receive the reply."""
|
|
408
|
+
|
|
409
|
+
def __init__(self, handle: Any) -> None:
|
|
410
|
+
super().__init__(handle)
|
|
411
|
+
self._owed: Deque[bool] = deque()
|
|
412
|
+
self._cancelled = False
|
|
413
|
+
|
|
414
|
+
def cancel(self) -> None:
|
|
415
|
+
self._cancelled = True # before the native cancel: a call that doesn't see it sent its request before it
|
|
416
|
+
super().cancel()
|
|
417
|
+
|
|
418
|
+
def _counted(self, entry: bool, send: Any, *args: Any) -> None:
|
|
419
|
+
"""Calls `send` with `entry` added for its request, and removes it unless the send returned (the sending thread
|
|
420
|
+
is the one that adds, so the newest entry is this one)."""
|
|
421
|
+
self._owed.append(entry)
|
|
422
|
+
sent = False
|
|
423
|
+
try:
|
|
424
|
+
send(*args)
|
|
425
|
+
sent = True
|
|
426
|
+
finally:
|
|
427
|
+
if not sent:
|
|
428
|
+
self._owed.pop()
|
|
429
|
+
|
|
430
|
+
def send(self, data: Bytes, timeout_ms: int = FOREVER) -> None:
|
|
431
|
+
self._counted(False, super().send, data, timeout_ms)
|
|
432
|
+
|
|
433
|
+
def send_commit(self, length: int) -> None:
|
|
434
|
+
self._counted(False, super().send_commit, length)
|
|
435
|
+
|
|
436
|
+
def send_many(self, messages: Sequence[Bytes], timeout_ms: int = FOREVER) -> int:
|
|
437
|
+
"""As Conn.send_many, with an entry added for each request before the batch goes, and removed for each one that
|
|
438
|
+
didn't go (the newest entries: the sending thread is the one that adds)."""
|
|
439
|
+
count = len(messages)
|
|
440
|
+
self._owed.extend([False] * count)
|
|
441
|
+
sent = 0
|
|
442
|
+
try:
|
|
443
|
+
sent = super().send_many(messages, timeout_ms)
|
|
444
|
+
except FipcError as e:
|
|
445
|
+
sent = e.sent or 0
|
|
446
|
+
raise
|
|
447
|
+
finally:
|
|
448
|
+
for _ in range(count - sent):
|
|
449
|
+
self._owed.pop()
|
|
450
|
+
return sent
|
|
451
|
+
|
|
452
|
+
def _answered(self, receive: Any, *args: Any) -> Any:
|
|
453
|
+
"""Calls `receive`, and takes the oldest request off the list when it returned a reply or raised ABANDONED (an
|
|
454
|
+
answer too)."""
|
|
455
|
+
try:
|
|
456
|
+
value = receive(*args)
|
|
457
|
+
except FipcError as e:
|
|
458
|
+
if e.result == Result.ABANDONED and self._owed:
|
|
459
|
+
self._owed.popleft()
|
|
460
|
+
raise
|
|
461
|
+
if self._owed:
|
|
462
|
+
self._owed.popleft()
|
|
463
|
+
return value
|
|
464
|
+
|
|
465
|
+
def recv(self, timeout_ms: int = FOREVER) -> bytes:
|
|
466
|
+
return self._answered(super().recv, timeout_ms)
|
|
467
|
+
|
|
468
|
+
def recv_into(self, buf: Union[bytearray, memoryview], timeout_ms: int = FOREVER) -> int:
|
|
469
|
+
return self._answered(super().recv_into, buf, timeout_ms)
|
|
470
|
+
|
|
471
|
+
def recv_acquire(self, timeout_ms: int = FOREVER) -> memoryview:
|
|
472
|
+
return self._answered(super().recv_acquire, timeout_ms)
|
|
473
|
+
|
|
474
|
+
def recv_many(self, max_count: int = 16, timeout_ms: int = FOREVER) -> List[bytes]:
|
|
475
|
+
"""As Conn.recv_many, taking a request off the list for each reply, or for an ABANDONED."""
|
|
476
|
+
try:
|
|
477
|
+
replies = super().recv_many(max_count, timeout_ms)
|
|
478
|
+
except FipcError as e:
|
|
479
|
+
if e.result == Result.ABANDONED and self._owed:
|
|
480
|
+
self._owed.popleft()
|
|
481
|
+
raise
|
|
482
|
+
for _ in replies:
|
|
483
|
+
if self._owed:
|
|
484
|
+
self._owed.popleft()
|
|
485
|
+
return replies
|
|
486
|
+
|
|
487
|
+
def call(self, request: Bytes, timeout_ms: int = FOREVER) -> bytes:
|
|
488
|
+
deadline = time.monotonic() + timeout_ms / 1000
|
|
489
|
+
data = ffi.from_buffer(request) # a TypeError for a request that isn't bytes, before anything is counted
|
|
490
|
+
length = len(data)
|
|
491
|
+
if length == 0:
|
|
492
|
+
raise FipcError(Result.INVALID, "call")
|
|
493
|
+
if length > self.max_piece():
|
|
494
|
+
raise FipcError(Result.TOO_LARGE, "call")
|
|
495
|
+
if self._cancelled:
|
|
496
|
+
raise FipcError(Result.CANCELLED, "call") # its reply could never be received: the request never goes
|
|
497
|
+
if not all(self._owed): # a reply to a request of send(): the caller's, not ours to drop
|
|
498
|
+
raise FipcError(Result.INVALID, "call", why="a reply to a request of send() is owed: receive it first")
|
|
499
|
+
dropped = False
|
|
500
|
+
while self._owed: # the reply to a call that gave up on it: dropped
|
|
501
|
+
dropped = True
|
|
502
|
+
try:
|
|
503
|
+
self._answered(self._recv, _left(timeout_ms, deadline))
|
|
504
|
+
except FipcError as e:
|
|
505
|
+
if e.result != Result.ABANDONED:
|
|
506
|
+
raise
|
|
507
|
+
if dropped and timeout_ms > 0 and time.monotonic() >= deadline:
|
|
508
|
+
raise FipcError(Result.TIMEOUT, "call") # its time went on the drop: the request never goes
|
|
509
|
+
if self._cancelled:
|
|
510
|
+
raise FipcError(Result.CANCELLED, "call") # cancelled while it dropped
|
|
511
|
+
self._counted(True, _check_send, self, data, length, _left(timeout_ms, deadline))
|
|
512
|
+
length = self._answered(self._recv, _left(timeout_ms, deadline))
|
|
513
|
+
return ffi.buffer(self._buf, length)[:]
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _check_send(conn: Conn, data: Any, length: int, timeout_ms: int) -> None:
|
|
517
|
+
_check(lib.fipc_send(conn._handle, data, length, timeout_ms), "fipc_send")
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class Listener:
|
|
521
|
+
"""A stream's name, held by its server (`stream_create`): `accept` one client at a time. Its close frees the name;
|
|
522
|
+
the connections it accepted stay open.
|
|
523
|
+
|
|
524
|
+
A Listener left unclosed is closed when it is garbage-collected, as by `close`, which frees the name; a thread
|
|
525
|
+
inside `accept` holds the Listener, so the collection never closes it under a call."""
|
|
526
|
+
|
|
527
|
+
def __init__(self, handle: Any) -> None:
|
|
528
|
+
self._handle: Optional[Any] = ffi.gc(handle, lib.fipc_listener_close) # the finalizer, which close() disarms
|
|
529
|
+
|
|
530
|
+
def __enter__(self) -> "Listener":
|
|
531
|
+
return self
|
|
532
|
+
|
|
533
|
+
def __exit__(self, *exc: Any) -> None:
|
|
534
|
+
self.close()
|
|
535
|
+
|
|
536
|
+
def accept(self, timeout_ms: int = FOREVER) -> Conn:
|
|
537
|
+
"""Waits for a client and sets its connection up on the calling thread; a call that times out in the middle
|
|
538
|
+
of a client's setup keeps it for the next call, so NO_WAIT polls. INVALID while the connection it returned
|
|
539
|
+
last is open, NO_MEMORY once the listener has failed for good."""
|
|
540
|
+
out = ffi.new("fipc_conn_t**")
|
|
541
|
+
_check(lib.fipc_stream_accept(self._handle, out, timeout_ms), "fipc_stream_accept")
|
|
542
|
+
return Conn(out[0])
|
|
543
|
+
|
|
544
|
+
def cancel(self) -> None:
|
|
545
|
+
"""Every accept that waits, now or later, raises CANCELLED; a client whose setup an accept began is dropped. Any
|
|
546
|
+
thread."""
|
|
547
|
+
lib.fipc_listener_cancel(self._handle)
|
|
548
|
+
|
|
549
|
+
def close(self) -> None:
|
|
550
|
+
"""Stops listening and frees the name (on Windows once the connections it accepted are closed too); the
|
|
551
|
+
connections it accepted stay open. No other thread may be inside a call on it."""
|
|
552
|
+
if self._handle is not None:
|
|
553
|
+
handle, self._handle = self._handle, None
|
|
554
|
+
ffi.gc(handle, None) # disarms the finalizer: closed once
|
|
555
|
+
lib.fipc_listener_close(handle)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
class Queue:
|
|
559
|
+
"""A work queue, held by its owner (`queue_create`). Its producers and consumers, the owner's own included, join
|
|
560
|
+
with `queue_join`. A queue lives until `close()` (or the end of its `with` block) or the end of its process;
|
|
561
|
+
dropping the object doesn't end it (it has no finalizer)."""
|
|
562
|
+
|
|
563
|
+
def __init__(self, handle: Any) -> None:
|
|
564
|
+
self._handle: Optional[Any] = handle
|
|
565
|
+
|
|
566
|
+
def __enter__(self) -> "Queue":
|
|
567
|
+
return self
|
|
568
|
+
|
|
569
|
+
def __exit__(self, *exc: Any) -> None:
|
|
570
|
+
self.close()
|
|
571
|
+
|
|
572
|
+
def wait_for_members(self, role: Optional[int] = None, at_least: int = 0, timeout_ms: int = FOREVER) -> int:
|
|
573
|
+
"""Waits up to timeout_ms until at least `at_least` members of `role` (PRODUCER or CONSUMER; None counts both)
|
|
574
|
+
are joined (0 returns the count at once), and returns how many are. TIMEOUT, CANCELLED and DISCONNECTED (the
|
|
575
|
+
queue has ended) carry the count in the error's `count`; INVALID for another role, or for at_least over the
|
|
576
|
+
role's maximum (max_producers, max_consumers, or their sum for None). Any thread."""
|
|
577
|
+
count = ffi.new("uint32_t*")
|
|
578
|
+
result = lib.fipc_queue_wait_for_members(self._handle, role or 0, at_least, count, timeout_ms)
|
|
579
|
+
_check_count(result, count[0], "fipc_queue_wait_for_members")
|
|
580
|
+
return count[0]
|
|
581
|
+
|
|
582
|
+
def cancel(self) -> None:
|
|
583
|
+
"""Every `wait_for_members` that waits, now or later, raises CANCELLED. The queue goes on. Any thread."""
|
|
584
|
+
lib.fipc_queue_cancel(self._handle)
|
|
585
|
+
|
|
586
|
+
def close(self) -> None:
|
|
587
|
+
"""Ends the queue and frees its name: producers' sends raise DISCONNECTED, and consumers receive what is left,
|
|
588
|
+
then DISCONNECTED. The members' connections, the owner's own included, stay open until their close. No other
|
|
589
|
+
thread may be inside a call on it."""
|
|
590
|
+
if self._handle is not None:
|
|
591
|
+
lib.fipc_queue_close(self._handle)
|
|
592
|
+
self._handle = None
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
class Service:
|
|
596
|
+
"""A service, held by its owner (`service_create`). Its clients and servers, the owner's own included, join with
|
|
597
|
+
`service_join`. A service lives until `close()` (or the end of its `with` block) or the end of its process;
|
|
598
|
+
dropping the object doesn't end it (it has no finalizer)."""
|
|
599
|
+
|
|
600
|
+
def __init__(self, handle: Any) -> None:
|
|
601
|
+
self._handle: Optional[Any] = handle
|
|
602
|
+
|
|
603
|
+
def __enter__(self) -> "Service":
|
|
604
|
+
return self
|
|
605
|
+
|
|
606
|
+
def __exit__(self, *exc: Any) -> None:
|
|
607
|
+
self.close()
|
|
608
|
+
|
|
609
|
+
def wait_for_members(self, role: Optional[int] = None, at_least: int = 0, timeout_ms: int = FOREVER) -> int:
|
|
610
|
+
"""Waits up to timeout_ms until at least `at_least` members of `role` (CLIENT or SERVER; None counts both) are
|
|
611
|
+
joined (0 returns the count at once), and returns how many are. TIMEOUT, CANCELLED and DISCONNECTED (the
|
|
612
|
+
service has ended) carry the count in the error's `count`; INVALID for another role, or for at_least over the
|
|
613
|
+
role's maximum (max_clients, max_servers, or their sum for None). Any thread."""
|
|
614
|
+
count = ffi.new("uint32_t*")
|
|
615
|
+
result = lib.fipc_service_wait_for_members(self._handle, role or 0, at_least, count, timeout_ms)
|
|
616
|
+
_check_count(result, count[0], "fipc_service_wait_for_members")
|
|
617
|
+
return count[0]
|
|
618
|
+
|
|
619
|
+
def cancel(self) -> None:
|
|
620
|
+
"""Every `wait_for_members` that waits, now or later, raises CANCELLED. The service goes on. Any thread."""
|
|
621
|
+
lib.fipc_service_cancel(self._handle)
|
|
622
|
+
|
|
623
|
+
def close(self) -> None:
|
|
624
|
+
"""Ends the service and frees its name: clients' sends and servers' receives raise DISCONNECTED, a client
|
|
625
|
+
receives the replies that came before the end, then DISCONNECTED, and a server's reply raises DISCONNECTED.
|
|
626
|
+
The members' connections, the owner's own included, stay open until their close. No other thread may be inside
|
|
627
|
+
a call on it."""
|
|
628
|
+
if self._handle is not None:
|
|
629
|
+
lib.fipc_service_close(self._handle)
|
|
630
|
+
self._handle = None
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# === Streams ===
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def stream_create(name: str, capacity: int) -> Listener:
|
|
637
|
+
"""A stream's server: claims `name` and listens on it, with rings of `capacity` bytes (a power of two, 1 KiB to
|
|
638
|
+
2 GiB) for each connection, and returns its Listener. Doesn't wait: clients may connect from now on, and
|
|
639
|
+
`Listener.accept` sets each one up. ADDR_IN_USE if another stream, broadcast, queue or service holds the name."""
|
|
640
|
+
out = ffi.new("fipc_listener_t**")
|
|
641
|
+
_check(lib.fipc_stream_create(name.encode(), capacity, out), "fipc_stream_create")
|
|
642
|
+
return Listener(out[0])
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def stream_connect(name: str, timeout_ms: int = FOREVER) -> Conn:
|
|
646
|
+
"""A stream's client: connects to the server of the stream on `name` and sets the connection up on the calling
|
|
647
|
+
thread, waiting up to timeout_ms for the server to create the stream and to call accept: the server runs on another
|
|
648
|
+
thread or in another process. FOREVER waits for a server however late it starts. INVALID if a broadcast, a queue or
|
|
649
|
+
a service holds the name."""
|
|
650
|
+
out = ffi.new("fipc_conn_t**")
|
|
651
|
+
_check(lib.fipc_stream_connect(name.encode(), out, timeout_ms), "fipc_stream_connect")
|
|
652
|
+
return Conn(out[0])
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
# === Broadcasts ===
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def broadcast_create(name: str, capacity: int, max_subscribers: int, max_stall_ms: int) -> Conn:
|
|
659
|
+
"""A broadcast's publisher: claims `name` for a broadcast of `capacity` bytes of messages (a power of two, 4 KiB to
|
|
660
|
+
2 GiB; messages of up to a quarter of it) and up to `max_subscribers` subscribers (1 to 1024), and returns the
|
|
661
|
+
publisher's connection, the broadcast's one handle: its sends go to every subscriber, and its close ends the
|
|
662
|
+
broadcast, as does its garbage collection: keep it referenced while the broadcast is in use. Doesn't wait:
|
|
663
|
+
subscribers may join from now on, and this process admits them by itself. A gated subscriber that holds the
|
|
664
|
+
publisher back for max_stall_ms is unsubscribed; FOREVER never, NO_WAIT refuses gated subscribers. ADDR_IN_USE if
|
|
665
|
+
another stream, broadcast, queue or service holds the name."""
|
|
666
|
+
out = ffi.new("fipc_conn_t**")
|
|
667
|
+
_check(lib.fipc_broadcast_create(name.encode(), capacity, max_subscribers, max_stall_ms, out),
|
|
668
|
+
"fipc_broadcast_create")
|
|
669
|
+
return Conn(out[0])
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def broadcast_join(name: str, role: int, timeout_ms: int = FOREVER) -> Conn:
|
|
673
|
+
"""A subscriber: joins the broadcast on `name` as `role` (SUBSCRIBER_LOSSY, which never holds the publisher back;
|
|
674
|
+
SUBSCRIBER_GATED, which misses nothing), waiting up to timeout_ms for the publisher to create the broadcast, for a
|
|
675
|
+
free slot, and for the publisher's process to admit it. It receives the messages sent from its admission on, then
|
|
676
|
+
DISCONNECTED once the publisher closed. INVALID for another role, a name a queue or a service holds, or a gated
|
|
677
|
+
subscriber on a broadcast that refuses them. On a name a stream holds, the join learns the kind when the stream's
|
|
678
|
+
server accepts it, which brings INVALID; while no accept runs, it waits and raises TIMEOUT."""
|
|
679
|
+
out = ffi.new("fipc_conn_t**")
|
|
680
|
+
_check(lib.fipc_broadcast_join(name.encode(), role, out, timeout_ms), "fipc_broadcast_join")
|
|
681
|
+
return Conn(out[0])
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# === Work queues ===
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def queue_create(name: str, capacity: int, max_producers: int, max_consumers: int) -> Queue:
|
|
688
|
+
"""A work queue's owner: claims `name` for a work queue of up to `max_producers` producers (1 to 63) and
|
|
689
|
+
`max_consumers` consumers (1 to 64), each producer with a ring of `capacity` bytes of messages (a power of two,
|
|
690
|
+
4 KiB to 2 GiB; messages of up to a quarter of it), and returns the Queue. Doesn't wait: members may join from now
|
|
691
|
+
on, and this process admits them by itself; the owner joins with `queue_join` too, from any thread. A queue lives
|
|
692
|
+
until `close()` or the end of its process; dropping the object doesn't end it. ADDR_IN_USE if another stream,
|
|
693
|
+
broadcast, queue or service holds the name."""
|
|
694
|
+
out = ffi.new("fipc_queue_t**")
|
|
695
|
+
_check(lib.fipc_queue_create(name.encode(), capacity, max_producers, max_consumers, out), "fipc_queue_create")
|
|
696
|
+
return Queue(out[0])
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def queue_join(name: str, role: int, timeout_ms: int = FOREVER) -> Conn:
|
|
700
|
+
"""A producer or a consumer: joins the work queue on `name` as `role` (PRODUCER or CONSUMER), waiting up to
|
|
701
|
+
timeout_ms for the owner to create the queue, for a free place of the role, and for the owner's process to admit
|
|
702
|
+
it. A consumer takes each message of the queue's producers that no other consumer took, in no promised order, then
|
|
703
|
+
DISCONNECTED once the queue ended; a producer's sends raise DISCONNECTED from then on. INVALID for another role, or
|
|
704
|
+
a name a broadcast or a service holds. On a name a stream holds, the join learns the kind when the stream's server
|
|
705
|
+
accepts it, which brings INVALID; while no accept runs, it waits and raises TIMEOUT."""
|
|
706
|
+
out = ffi.new("fipc_conn_t**")
|
|
707
|
+
_check(lib.fipc_queue_join(name.encode(), role, out, timeout_ms), "fipc_queue_join")
|
|
708
|
+
return Conn(out[0])
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# === Services ===
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def service_create(name: str, capacity: int, max_message: int, max_clients: int, max_servers: int) -> Service:
|
|
715
|
+
"""A service's owner: claims `name` for a service of up to `max_clients` clients (1 to 64) and `max_servers`
|
|
716
|
+
servers (1 to 64), each client with a ring of `capacity` bytes (a power of two, 4 KiB to 2 GiB) for its requests and
|
|
717
|
+
their replies, each of 1 to `max_message` bytes (1 to capacity / 4), and returns the Service. A request holds its
|
|
718
|
+
length rounded up to 64 bytes, plus max_message rounded up to 64, of its client's ring from its send until its
|
|
719
|
+
reply has been received. Doesn't wait: members may join from now on, and this process admits them by itself; the
|
|
720
|
+
owner joins with `service_join` too, from any thread. A service lives until `close()` or the end of its process;
|
|
721
|
+
dropping the object doesn't end it. ADDR_IN_USE if another stream, broadcast, queue or service holds the name."""
|
|
722
|
+
out = ffi.new("fipc_service_t**")
|
|
723
|
+
_check(lib.fipc_service_create(name.encode(), capacity, max_message, max_clients, max_servers, out),
|
|
724
|
+
"fipc_service_create")
|
|
725
|
+
return Service(out[0])
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def service_join(name: str, role: int, timeout_ms: int = FOREVER) -> Conn:
|
|
729
|
+
"""A client or a server: joins the service on `name` as `role` (CLIENT or SERVER), waiting up to timeout_ms for the
|
|
730
|
+
owner to create the service, for a free place of the role, and for the owner's process to admit it. INVALID for
|
|
731
|
+
another role, or a name a broadcast or a queue holds. On a name a stream holds, the join learns the kind when the
|
|
732
|
+
stream's server accepts it, which brings INVALID; while no accept runs, it waits and raises TIMEOUT.
|
|
733
|
+
|
|
734
|
+
A client's `send` sends a request, and its `recv` (`recv_into`, `recv_acquire`) receives the reply to its oldest
|
|
735
|
+
request whose reply it hasn't received yet, so the n-th reply answers the n-th request; or ABANDONED, if the server
|
|
736
|
+
that took that request ended before it replied (the request counts as answered; it may or may not have been carried
|
|
737
|
+
out, and it is never sent again). `call(request)` does both: it sends the request and returns its reply. A call
|
|
738
|
+
checks its request first: INVALID for an empty one, TOO_LARGE over `max_piece()`, at once, even while a reply is
|
|
739
|
+
owed. Then a call on a cancelled connection raises CANCELLED, whatever its timeout, and sends nothing, as it could
|
|
740
|
+
never receive the reply. A call whose receive timed out or was cancelled leaves its reply owed: the connection keeps
|
|
741
|
+
count, and the next call first receives and drops that reply (at most one is owed, since every call drops it first;
|
|
742
|
+
an ABANDONED in its place counts too), within its own timeout and before it sends, so it never returns another
|
|
743
|
+
request's reply. A call that had to drop an owed reply first sends its request only if its timeout has time left:
|
|
744
|
+
once a positive timeout has run out, it raises TIMEOUT without sending, so no server sees that request; with NO_WAIT
|
|
745
|
+
no step waits, and it sends if there is room. After a call that gave up, a plain `recv` returns that call's late
|
|
746
|
+
reply (or ABANDONED) first: replies come in request order. A call raises INVALID while a request sent with `send`
|
|
747
|
+
still has its reply to be received: receive it first. A call sends and receives, so no other thread may send or
|
|
748
|
+
receive on the connection meanwhile.
|
|
749
|
+
|
|
750
|
+
A server's `recv` takes the next request of any client, and its `send` replies to it, never waiting; a server holds
|
|
751
|
+
one request at a time, so its receives raise INVALID until it replies, and its replies raise INVALID with no request
|
|
752
|
+
held. A request taken in place (`recv_acquire`) stays valid until the reply's `send` or `send_commit`;
|
|
753
|
+
`recv_release` does nothing. A reply to a client that has left is dropped. A server's close answers the request it
|
|
754
|
+
holds ABANDONED, and so does the garbage collection of a server's `Conn` left unclosed: a server holds its request,
|
|
755
|
+
and its client waits for the reply, until the server is closed or garbage-collected (or its process ends). Once the
|
|
756
|
+
service has ended, clients' sends and servers' receives raise DISCONNECTED; a client receives the replies that came
|
|
757
|
+
before the end, then DISCONNECTED."""
|
|
758
|
+
out = ffi.new("fipc_conn_t**")
|
|
759
|
+
_check(lib.fipc_service_join(name.encode(), role, out, timeout_ms), "fipc_service_join")
|
|
760
|
+
return _ServiceClient(out[0]) if role == CLIENT else Conn(out[0])
|
|
Binary file
|
fipc/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fipc-python
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Shared-memory IPC for Python: streams, broadcasts, work queues and services between processes on one machine (FastIPC)
|
|
5
|
+
Author: Hayden Donnelly
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://fastipc.github.io/fastipc/
|
|
8
|
+
Project-URL: Repository, https://github.com/fastipc/fastipc
|
|
9
|
+
Project-URL: Documentation, https://fastipc.github.io/fastipc/python.html
|
|
10
|
+
Project-URL: Issues, https://github.com/fastipc/fastipc/issues
|
|
11
|
+
Keywords: ipc,shared-memory,interprocess-communication,zero-copy,messaging
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Classifier: Topic :: System :: Networking
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: cffi>=1.15.0
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# fipc
|
|
29
|
+
|
|
30
|
+
Messages between processes on one machine, through shared memory: the Python binding of
|
|
31
|
+
[FastIPC](https://github.com/fastipc/fastipc), a small library written in Zig. Four kinds, each created on a name:
|
|
32
|
+
|
|
33
|
+
- **Streams:** a server creates a stream (`fipc.stream_create`) and its `Listener` accepts one client at a time; a
|
|
34
|
+
client connects to the name (`fipc.stream_connect`). Each connection has a shared-memory segment with one ring per
|
|
35
|
+
direction, and reports the peer's end as soon as its process exits or closes the connection.
|
|
36
|
+
- **[Broadcasts](#broadcasts):** one publisher (`fipc.broadcast_create`) sends every message to each of its
|
|
37
|
+
subscribers (`fipc.broadcast_join`).
|
|
38
|
+
- **[Work queues](#work-queues):** producers and consumers join a queue (`fipc.queue_create`, `fipc.queue_join`), and
|
|
39
|
+
each message goes to one consumer.
|
|
40
|
+
- **[Services](#services):** clients and servers join a service (`fipc.service_create`, `fipc.service_join`): each
|
|
41
|
+
request goes to one server, and its reply back to its client.
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install fipc-python
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The wheels bundle the native library: Windows x64 (Windows 11 / Server 2022 or newer) and Linux x64 (glibc 2.34 or
|
|
50
|
+
newer: Ubuntu 22.04, Debian 12, RHEL 9 and later), which need an x86-64-v3 CPU (AVX2: Intel Haswell, AMD Zen or
|
|
51
|
+
later), Linux on ARM64 (`manylinux_2_34_aarch64`, glibc 2.34 or newer) and macOS 14.4 or newer on Apple Silicon. The
|
|
52
|
+
macOS wheel is tagged `macosx_14_0_arm64` (pip matches macOS 11
|
|
53
|
+
and later by the major version only), so pip also installs it on macOS 14.0 to 14.3, where the library doesn't load.
|
|
54
|
+
The binding loads it through CFFI's ABI mode, so one wheel per platform fits every Python 3.9+. A library of your own
|
|
55
|
+
comes first: set the environment variable `FASTIPC_LIB_DIR` to its folder (when it is set, the binding looks nowhere
|
|
56
|
+
else).
|
|
57
|
+
|
|
58
|
+
## Use
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
# server.py
|
|
62
|
+
import fipc
|
|
63
|
+
|
|
64
|
+
# rings of 1 MiB each way
|
|
65
|
+
with fipc.stream_create("my_channel", 1 << 20) as listener:
|
|
66
|
+
with listener.accept() as conn: # waits for a client
|
|
67
|
+
message = conn.recv()
|
|
68
|
+
conn.send(message.upper())
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
# client.py
|
|
73
|
+
import fipc
|
|
74
|
+
|
|
75
|
+
with fipc.stream_connect("my_channel", timeout_ms=5000) as conn:
|
|
76
|
+
conn.send(b"ping")
|
|
77
|
+
print(conn.recv()) # b'PING'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Run each in its own terminal: `python server.py`, then `python client.py`. The client sends the message `ping`, and the
|
|
81
|
+
server answers with its uppercase, `PING`. Every example of the
|
|
82
|
+
[repository's README](https://github.com/fastipc/fastipc#examples) makes this exchange, so its Zig, C, C++, C#, Java,
|
|
83
|
+
Rust, Lua, JavaScript and Go clients work against this server too, and this client against their servers.
|
|
84
|
+
|
|
85
|
+
A name holds a stream, a broadcast, a work queue or a service, and each kind has its functions: `stream_create`,
|
|
86
|
+
`stream_connect`; `broadcast_create`, `broadcast_join`; `queue_create`, `queue_join`; `service_create`,
|
|
87
|
+
`service_join`. They are the only way to make a `Listener`, a `Conn`, a `Queue` or a `Service`.
|
|
88
|
+
|
|
89
|
+
- **Streams:** `fipc.stream_create(name, capacity)` claims the name and returns its `Listener`, whose `accept()` sets
|
|
90
|
+
one client at a time up on the calling thread (`NO_WAIT` polls); `fipc.stream_connect(name)` connects to it.
|
|
91
|
+
`stream_connect` waits for the server's `accept`, so a client and its server run in different processes or on
|
|
92
|
+
different threads.
|
|
93
|
+
- **Copied messages:** `send` and `recv` (a message of any size: it travels in pieces and arrives whole), or
|
|
94
|
+
`recv_into` a buffer of your own.
|
|
95
|
+
- **Zero-copy** (messages of one piece, up to `max_piece()` bytes): `send_acquire(length)`, write into the
|
|
96
|
+
memoryview, `send_commit(length)`; `recv_acquire()`, read, `recv_release()`.
|
|
97
|
+
- **Batches:** `send_many(messages)` sends a list of messages, published together with at most one wake of the
|
|
98
|
+
receivers, and returns how many went (an error's `sent` says how many went before it); `recv_many(max_count=16)`
|
|
99
|
+
waits for the next message, then takes every further one already there, and returns a list of `bytes`. On every
|
|
100
|
+
kind: a broadcast's subscriber takes those its publisher has moved past, and a service's client sends a batch of
|
|
101
|
+
requests (owed as its sends are: `call` waits until their replies are received) and receives their replies in
|
|
102
|
+
order; a server's batches raise `INVALID`. Batch both ends: a receiver that takes one message per call falls
|
|
103
|
+
behind a batching sender.
|
|
104
|
+
|
|
105
|
+
Timeouts are int milliseconds, the last parameter: `NO_WAIT` (0) doesn't wait, `FOREVER` (-1, the default) waits for
|
|
106
|
+
ever. A call that fails raises `FipcError`, whose `result` is a `Result` (`Result.TIMEOUT`, `Result.DISCONNECTED`,
|
|
107
|
+
...). On a connection, one thread at a time sends and one receives; `cancel` may be called from any thread, `close`
|
|
108
|
+
only when no other thread is inside a call on the connection. A disconnected connection is final: close it, and
|
|
109
|
+
accept or connect a new one. A `Conn` left unclosed is closed when it is garbage-collected (a thread inside a call on
|
|
110
|
+
it, or a memoryview of `send_acquire` or `recv_acquire`, keeps it referenced), and so is a `Listener`, which frees its
|
|
111
|
+
name; a `Queue` and a `Service` never are.
|
|
112
|
+
|
|
113
|
+
## Broadcasts
|
|
114
|
+
|
|
115
|
+
A broadcast sends every message of one publisher to each of its subscribers, in any processes of the user: each
|
|
116
|
+
message is stored once, in memory every subscriber maps, and is of one piece (up to a quarter of the capacity).
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
# publisher.py
|
|
120
|
+
import fipc
|
|
121
|
+
|
|
122
|
+
# 64 KiB of messages, up to 8 subscribers; one that holds the publisher back 5 s is dropped
|
|
123
|
+
with fipc.broadcast_create("my_broadcast", 1 << 16, 8, 5000) as publisher:
|
|
124
|
+
publisher.wait_for_members(1) # waits for the first subscriber
|
|
125
|
+
for i in (1, 2, 3):
|
|
126
|
+
publisher.send(f"tick {i}".encode())
|
|
127
|
+
# closing the publisher ends the broadcast: subscribers get DISCONNECTED after the ticks
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
# subscriber.py
|
|
132
|
+
import fipc
|
|
133
|
+
|
|
134
|
+
# gated: misses nothing (the publisher waits for it)
|
|
135
|
+
with fipc.broadcast_join("my_broadcast", fipc.SUBSCRIBER_GATED, timeout_ms=5000) as subscriber:
|
|
136
|
+
try:
|
|
137
|
+
while True:
|
|
138
|
+
print(subscriber.recv().decode()) # tick 1, tick 2, tick 3
|
|
139
|
+
except fipc.FipcError as e:
|
|
140
|
+
if e.result != fipc.Result.DISCONNECTED: # the publisher closed
|
|
141
|
+
raise
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Run `python publisher.py`, then `python subscriber.py` (or in the other order: `broadcast_join` waits up to its
|
|
145
|
+
timeout for the publisher), which prints `tick 1` to `tick 3`. `fipc.broadcast_create(name, capacity,
|
|
146
|
+
max_subscribers, max_stall_ms)` returns the publisher, a `Conn` and the broadcast's one handle, which the `with` block
|
|
147
|
+
closes.
|
|
148
|
+
|
|
149
|
+
- **Joining:** `fipc.broadcast_join(name, role)`; the publisher's process admits each subscriber by itself, on a
|
|
150
|
+
thread of the library's. `publisher.wait_for_members(at_least)` waits until that many have joined (at a timeout or
|
|
151
|
+
a cancel, the error's `count` holds the count), so that nothing is sent before they are there.
|
|
152
|
+
- **Gated** (`SUBSCRIBER_GATED`): misses nothing, as the publisher waits for it, and may receive in place
|
|
153
|
+
(`recv_acquire`, `recv_release`). One that holds the publisher back for `max_stall_ms` is unsubscribed: its
|
|
154
|
+
receives raise `UNSUBSCRIBED`.
|
|
155
|
+
- **Lossy** (`SUBSCRIBER_LOSSY`): never holds the publisher back. One that falls a lap behind gets `OVERRUN` instead
|
|
156
|
+
of a message, and the error's `lost` says how many messages it lost; the next receive goes on from a recent message.
|
|
157
|
+
- **The end:** closing the publisher ends the broadcast and frees its name; each subscriber receives what was sent,
|
|
158
|
+
then `DISCONNECTED`. The publisher's garbage collection closes it too: keep it referenced while the broadcast is in
|
|
159
|
+
use.
|
|
160
|
+
|
|
161
|
+
## Work queues
|
|
162
|
+
|
|
163
|
+
A work queue takes messages from up to 63 producers and hands each to one of up to 64 consumers, in any processes of
|
|
164
|
+
the user. Each producer has a ring of its own; a consumer takes a message of any producer, and the queue promises no
|
|
165
|
+
order. Messages are of one piece (up to a quarter of the capacity) and wait in the queue until a consumer takes them.
|
|
166
|
+
Each message goes to one consumer, and again only if that consumer's process ends before acknowledging it
|
|
167
|
+
(**Delivery**, below).
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
# producer.py
|
|
171
|
+
import fipc
|
|
172
|
+
|
|
173
|
+
# 4 KiB of messages per producer, 1 producer and 1 consumer: this process owns the queue and is its producer
|
|
174
|
+
with fipc.queue_create("my_queue", 4096, 1, 1) as queue:
|
|
175
|
+
with fipc.queue_join("my_queue", fipc.PRODUCER) as producer:
|
|
176
|
+
for i in (1, 2, 3):
|
|
177
|
+
producer.send(f"job {i}".encode()) # waits in the queue until a consumer takes it
|
|
178
|
+
queue.wait_for_members(fipc.CONSUMER, 1) # waits for the consumer to join
|
|
179
|
+
# closing the queue ends it: the consumer gets DISCONNECTED after the jobs
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
# consumer.py
|
|
184
|
+
import fipc
|
|
185
|
+
|
|
186
|
+
with fipc.queue_join("my_queue", fipc.CONSUMER, timeout_ms=5000) as consumer:
|
|
187
|
+
try:
|
|
188
|
+
while True:
|
|
189
|
+
print(consumer.recv().decode()) # job 1, job 2, job 3
|
|
190
|
+
except fipc.FipcError as e:
|
|
191
|
+
if e.result != fipc.Result.DISCONNECTED: # the queue ended
|
|
192
|
+
raise
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Run `python producer.py`, then `python consumer.py` (or in the other order: `queue_join` waits up to its timeout for
|
|
196
|
+
the queue), which prints `job 1` to `job 3`. `fipc.queue_create(name, capacity, max_producers, max_consumers)`
|
|
197
|
+
returns the `Queue`, which the `with` block closes.
|
|
198
|
+
|
|
199
|
+
- **Joining:** `fipc.queue_join(name, PRODUCER)` or `fipc.queue_join(name, CONSUMER)`, the owner's members too, from
|
|
200
|
+
any thread: the owner's process admits each joiner by itself, on a thread of the library's. The example's producer
|
|
201
|
+
sends first (the jobs wait in the queue), then waits for its consumer, so it works in either start order.
|
|
202
|
+
- **The owner's queue:** `queue.wait_for_members(role, at_least)` waits until that many members of the role
|
|
203
|
+
(`PRODUCER`, `CONSUMER`, or `None` for both) have joined (at a timeout or a cancel, the error's `count` holds the
|
|
204
|
+
count); `queue.cancel()` makes its waits raise `CANCELLED`, from any thread. A `Queue` never closes itself (it has
|
|
205
|
+
no finalizer): a queue lives until `close()` or the end of its process, and dropping the object doesn't end it.
|
|
206
|
+
- **Receiving:** `recv()`, `recv_into(buf)` (`TOO_LARGE` takes nothing: another consumer may take that message) or in
|
|
207
|
+
place, `recv_acquire()` until `recv_release()`.
|
|
208
|
+
- **The end:** after the queue's close, a producer's sends raise `DISCONNECTED`, and each consumer receives what is
|
|
209
|
+
left in the queue, then `DISCONNECTED`. A producer's or a consumer's close only leaves the queue.
|
|
210
|
+
|
|
211
|
+
**Delivery.** Each message goes to one consumer. It goes again, to another consumer, only if the process of the consumer
|
|
212
|
+
that took it ends before acknowledging it, and when a consumer acknowledges is its own choice, not the queue's. A
|
|
213
|
+
message received in place is acknowledged at its release (or the consumer's next receive, or its close).
|
|
214
|
+
|
|
215
|
+
| How the consumer receives | When it acknowledges | If its process ends | What the application gets |
|
|
216
|
+
|---|---|---|---|
|
|
217
|
+
| `recv()` or `recv_into()` (copying) | inside the call, before it returns | inside the call: the message goes to another consumer, and this one never saw it; after the call returned: it doesn't go again | **at most once**: no duplicates |
|
|
218
|
+
| `recv_acquire()`, then `recv_release()` at once, then the work | at the release, before the work | as above | **at most once**: no duplicates |
|
|
219
|
+
| `recv_acquire()`, the work, then `recv_release()` | at the release, after the work | before the release: the message goes to another consumer | **at least once**: a duplicate is possible, so the work must be idempotent |
|
|
220
|
+
|
|
221
|
+
- **Choose by the work, per consumer.** Work that may safely run twice (rendering, a computation, re-indexing,
|
|
222
|
+
converting a file) acknowledges after it; work whose effects must never repeat (a payment, an email, a motor command)
|
|
223
|
+
acknowledges before it.
|
|
224
|
+
- **Not covered:** a consumer that hangs while its process runs (there are no timeouts, by design); the owner's process,
|
|
225
|
+
with which the queue ends; a reboot. A consumer holds at most one unacknowledged message at a time, the one received
|
|
226
|
+
in place.
|
|
227
|
+
- **A message that goes again comes in no set order,** after messages its producer sent later: the queue promises no
|
|
228
|
+
order anyway.
|
|
229
|
+
- **A message held in place holds back its producer's room** until its release: a consumer that acknowledges after a
|
|
230
|
+
long job keeps that message's room, and so the room of the producer's messages after it, until then.
|
|
231
|
+
- **A message that crashes every consumer that takes it,** acknowledged after the work, goes to each consumer in turn,
|
|
232
|
+
with no limit: acknowledge before the work where that can happen.
|
|
233
|
+
|
|
234
|
+
## Services
|
|
235
|
+
|
|
236
|
+
A service takes requests from up to 64 clients and hands each to one of up to 64 servers, in any processes of the
|
|
237
|
+
user; the server that took a request replies to it, and the reply goes back to the client that sent it. A server waits
|
|
238
|
+
for the requests of every client at once (one `recv`), and the servers of a pool share the load: each takes the next
|
|
239
|
+
request of any client. Each client has a ring of its own, which holds its requests and the room for their replies, so a
|
|
240
|
+
server never waits for a client. Requests and replies are of one piece, 1 to `max_message` bytes, the owner's choice.
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
# service_server.py
|
|
244
|
+
import fipc
|
|
245
|
+
|
|
246
|
+
# 4 KiB per client, requests and replies of up to 256 bytes, 1 client and 1 server: this process owns the service
|
|
247
|
+
# and is its server
|
|
248
|
+
with fipc.service_create("my_service", 4096, 256, 1, 1):
|
|
249
|
+
with fipc.service_join("my_service", fipc.SERVER, timeout_ms=5000) as server:
|
|
250
|
+
for _ in range(3): # three requests, then the end
|
|
251
|
+
request = server.recv() # the next request of any client
|
|
252
|
+
server.send(request.upper()) # the reply goes to its client, and never waits
|
|
253
|
+
# closing the service ends it: the client still receives the replies sent
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
# service_client.py
|
|
258
|
+
import fipc
|
|
259
|
+
|
|
260
|
+
with fipc.service_join("my_service", fipc.CLIENT, timeout_ms=5000) as client:
|
|
261
|
+
for i in (1, 2, 3):
|
|
262
|
+
# sends the request and returns its reply; FipcError (ABANDONED) if the server ended before it replied
|
|
263
|
+
reply = client.call(f"ping {i}".encode())
|
|
264
|
+
print(reply.decode()) # PING 1, PING 2, PING 3
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Run `python service_server.py`, then `python service_client.py` (or in the other order: `service_join` waits up to its
|
|
268
|
+
timeout for the service), which prints `PING 1` to `PING 3`. `fipc.service_create(name, capacity, max_message,
|
|
269
|
+
max_clients, max_servers)` returns the `Service`, which the `with` block closes.
|
|
270
|
+
|
|
271
|
+
- **Joining:** `fipc.service_join(name, CLIENT)` or `fipc.service_join(name, SERVER)`, the owner's members too, from
|
|
272
|
+
any thread: the owner's process admits each joiner by itself, on a thread of the library's. Clients wait while no
|
|
273
|
+
server is joined, and servers while no client is.
|
|
274
|
+
- **The owner's service:** `service.wait_for_members(role, at_least)` waits until that many members of the role
|
|
275
|
+
(`CLIENT`, `SERVER`, or `None` for both) have joined (at a timeout or a cancel, the error's `count` holds the count);
|
|
276
|
+
`service.cancel()` makes its waits raise `CANCELLED`, from any thread. A `Service` never closes itself (it has no
|
|
277
|
+
finalizer): a service lives until `close()` or the end of its process, and dropping the object doesn't end it.
|
|
278
|
+
- **A client:** `send(request)` sends a request, and `recv()` (`recv_into(buf)`, or in place `recv_acquire()` until
|
|
279
|
+
`recv_release()`) receives the reply to its oldest request whose reply it hasn't received yet: the n-th reply answers
|
|
280
|
+
the n-th request, whichever server replies first. A client may send several requests before it receives (as many as
|
|
281
|
+
its ring has room for: each holds its length rounded up to 64 bytes, plus `max_message` rounded up to 64, until its
|
|
282
|
+
reply has been received), and the servers work on them at once. A receive that times out or is cancelled leaves its
|
|
283
|
+
reply to come: the next receive returns it.
|
|
284
|
+
- **`call(request)`** sends a request and returns its reply. A call checks its request first: `INVALID` for an empty
|
|
285
|
+
one, `TOO_LARGE` over `max_piece()`, at once, even while a reply is owed. Then a call on a cancelled connection raises
|
|
286
|
+
`CANCELLED` at once, whatever its timeout, and sends nothing: it could never receive the reply. The connection keeps
|
|
287
|
+
count of the replies still owed: a call whose receive timed out or was cancelled leaves its reply owed, and the next
|
|
288
|
+
call first receives and drops that reply (at most one is owed, since every call drops it first; an `ABANDONED` in its
|
|
289
|
+
place counts too), within its own timeout and before it sends, so a call never returns the reply to another request. A
|
|
290
|
+
call that had to drop an owed reply first sends its request only if its timeout has time left: once a positive timeout
|
|
291
|
+
has run out, it raises `TIMEOUT` without sending, so no server sees that request; with `NO_WAIT` (0) no step waits,
|
|
292
|
+
and it sends if there is room. After a call that gave up, a plain `recv` returns that call's late reply (or
|
|
293
|
+
`ABANDONED`) first: replies come in request order. A call raises `INVALID` while a request sent with `send` still has
|
|
294
|
+
its reply to be received: that reply is yours, so receive it first. A call sends and receives, so no other thread may
|
|
295
|
+
send or receive on the connection meanwhile.
|
|
296
|
+
- **A server:** `recv()` takes the next request of any client (each client's in the order it sent them), and
|
|
297
|
+
`send(reply)` replies to it and never waits. A server holds one request at a time: its receives raise `INVALID` until
|
|
298
|
+
it replies, and a reply with no request held raises `INVALID`. A request received in place (`recv_acquire()`) stays
|
|
299
|
+
valid until the reply's `send` or `send_commit`; `recv_release()` does nothing. A reply to a client that has left is
|
|
300
|
+
dropped. A server may receive on one thread and reply on another. Its `close()` answers the request it holds
|
|
301
|
+
`ABANDONED`, and so does the garbage collection of a server's `Conn` left unclosed: a server holds its request, and
|
|
302
|
+
its client waits for the reply, until it is closed (with `with` or `close()`) or garbage-collected (or its process
|
|
303
|
+
ends).
|
|
304
|
+
- **`ABANDONED`:** a client's receive (or call) raises `FipcError` with `Result.ABANDONED` when the server that took
|
|
305
|
+
the request ended before it replied: it closed its connection, or its process ended. The request counts as answered,
|
|
306
|
+
and the next receive returns the next reply. An abandoned request may or may not have been carried out; the service
|
|
307
|
+
never sends it again, and the client decides whether to.
|
|
308
|
+
- **The end:** after the service's close, clients' sends and servers' receives raise `DISCONNECTED`; a client receives
|
|
309
|
+
the replies that came before the end, then `DISCONNECTED`, and a server's reply raises `DISCONNECTED`. A client's or
|
|
310
|
+
a server's close only leaves the service: a client's requests that no server took are dropped.
|
|
311
|
+
- **Not covered:** a server that hangs while its process runs (there are no timeouts, by design: its client waits,
|
|
312
|
+
within its own timeouts); the owner's process, with which the service ends.
|
|
313
|
+
|
|
314
|
+
## Documentation
|
|
315
|
+
|
|
316
|
+
- [The website](https://fastipc.github.io/fastipc/python.html): this binding's guide, with examples of every call.
|
|
317
|
+
- [`include/fipc.h`](https://github.com/fastipc/fastipc/blob/main/include/fipc.h): the C API under this binding, and every call's contract (results,
|
|
318
|
+
timeouts, threads, messages in pieces, the peer's end).
|
|
319
|
+
- [Platform support](https://github.com/fastipc/fastipc/blob/main/docs/platform-support.md), the [protocol](https://github.com/fastipc/fastipc/blob/main/docs/protocol.md) and the
|
|
320
|
+
[benchmarks](https://github.com/fastipc/fastipc/blob/main/docs/perf/bindings-baseline.md).
|
|
321
|
+
- [Changelog](https://github.com/fastipc/fastipc/blob/main/CHANGELOG.md), [issues](https://github.com/fastipc/fastipc/issues).
|
|
322
|
+
|
|
323
|
+
## License
|
|
324
|
+
|
|
325
|
+
MIT. Copyright (c) 2025-2026 Hayden Donnelly. See [LICENSE](https://github.com/fastipc/fastipc/blob/main/LICENSE).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
fipc/__init__.py,sha256=VnAQDawa-dcqBJUhhiqFA1EowbPPeUru95TqGn0vo4o,2051
|
|
2
|
+
fipc/_fipc.py,sha256=Tmz6WbFVgxnSoJFDnrb9CcZ4STepldH0ThweKAC9eoU,44812
|
|
3
|
+
fipc/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
fipc/_native/libfastipc.dylib,sha256=BePaSb5FX0_Mek_pictoc5vhGl5CLb9RPXk7-VvPdMs,539632
|
|
5
|
+
fipc_python-1.0.0.dist-info/licenses/LICENSE,sha256=MOwjp6PAUn5cman8LKZHxggBBEe2qZjai87CKCfm3lA,1077
|
|
6
|
+
fipc_python-1.0.0.dist-info/METADATA,sha256=AZwOo4K_OdhEvwI57z5tBGShrSN6CqD4t2uXKfoU9NU,21010
|
|
7
|
+
fipc_python-1.0.0.dist-info/WHEEL,sha256=FhEgcH91d6QFK-RbYU7zbRbSEPxYLL8iqNY8lJNbOc4,106
|
|
8
|
+
fipc_python-1.0.0.dist-info/top_level.txt,sha256=Au6R2A9qZGata9VKymUS6hxDN1rjJ9wSzHC6y2q2ZNQ,5
|
|
9
|
+
fipc_python-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 Hayden Donnelly
|
|
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
|
+
fipc
|