OverviewFilesCommitsIssuesDocsReleasesPeers

hyperswarm-peer commits / 54eb4c5

swarmd: a C++ client library, so the wire has ONE implementation
docs/SWARMD-DESIGN.md §9 step 5, and the thing that gates step 6. Until now every client hand-assembled frames, and both of them were wrong at least once in ways the daemon could not distinguish from a bad daemon: the first channel gate hand-rolled compact-encoding in Python and got "undecodable message" for every frame, and swarmd-probe gave its sends an id range that overlapped the content calls and reported a fetch reply as a send result. Same mistake twice — a client is a wire implementation, and there should be one. `swarmd::Client` covers the whole surface: connect/HELLO, join/leave, channel open/close, send, status, and the four content calls, with events arriving through callbacks. ⛔ SETUP CALLS BLOCK, EVENTS DO NOT WAIT. channel_open has to return a handle before anything can be sent on it, while a peer arriving has nobody waiting for it. And a blocking call still dispatches what it reads on the way: frames do not queue politely behind the reply you asked for, so a client that discarded them would lose a peer-up or a finished 100 MB fetch with no error anywhere. fixed 28/28 mutated 25/28 (events dropped while awaiting a reply) ⛔ The interleave assertion needed the ordering ESTABLISHED, not hoped for. Its first version asserted straight after the cancel and failed against a client that was working — the daemon answers status just as fast as it produces the event. It now waits until the event is known to be sitting unread in the socket, which is what makes the next blocking call have to read past it. A gate that fails against a working fix is one this repo has already paid for (#73). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
54eb4c589745d5f9aafa86417870b7619e0be7b8
by plankatron <fleeky@gmail.com> · 2026-09-10T22:07:53.000Z

Changed files (5) +829 -1

swarmd/.gitignore +1 -0
……@@ -2,5 +2,6 @@ node_modules/
22test/test_uds_backpressure
33test/test_control
44test/test_send_truth
5test/test_client
56swarmd
67tools/swarmd-probe
swarmd/build.sh +10 -1
……@@ -7,7 +7,7 @@ cd "$(dirname "$0")"
77
88KB=/storage/projects/code/hypercore-kb/holepunchto/repos
99PEER=..
10INC="-Isrc -Ischema -I$PEER/src -I$PEER/liboverlay/include -I$PEER/thirdparty/libudx/include -I$KB/tier5/libcompact/include -I$KB/tier4/libutf/include -I$KB/tier4/librpc/include"
10INC="-Isrc -Ischema -Iclient -I$PEER/src -I$PEER/liboverlay/include -I$PEER/thirdparty/libudx/include -I$KB/tier5/libcompact/include -I$KB/tier4/libutf/include -I$KB/tier4/librpc/include"
1111OBJ=${OBJ:-/tmp/swarmd-obj}
1212mkdir -p "$OBJ"
1313
……@@ -37,6 +37,9 @@ echo "Building generated wire..."
3737gcc -std=c99 -O2 $INC -c -o "$OBJ/swarmd_schema.o" schema/swarmd_schema.c
3838gcc -std=c99 -O2 $INC -c -o "$OBJ/swarmd_hrpc.o" schema/swarmd_hrpc.c
3939
40echo "Building the client library..."
41g++ -std=c++17 -O2 $INC -c -o "$OBJ/client.o" client/client.cpp
42
4043echo "Building daemon sources..."
4144g++ -std=c++17 -O2 $INC -c -o "$OBJ/uds_server.o" src/uds_server.cpp
4245g++ -std=c++17 -O2 $INC -c -o "$OBJ/control.o" src/control.cpp
……@@ -96,6 +99,12 @@ g++ -std=c++17 -O2 $INC -o test/test_send_truth \
9699 "$OBJ/swarmd_schema.o" "$OBJ/swarmd_hrpc.o" $CLIBS $TLIBS \
97100 -lsodium -luv -lpthread
98101
102g++ -std=c++17 -O2 $INC -o test/test_client \
103 test/test_client.cpp "$OBJ/client.o" "$OBJ/daemon.o" "$OBJ/uds_server.o" \
104 "$OBJ/control.o" "$OBJ/content.o" \
105 "$OBJ/swarmd_schema.o" "$OBJ/swarmd_hrpc.o" $CLIBS $TLIBS \
106 -lsodium -luv -lpthread
107
99108echo "Building tools..."
100109g++ -std=c++17 -O2 $INC -o tools/swarmd-probe tools/swarmd_probe.cpp \
101110 "$OBJ/swarmd_schema.o" "$OBJ/swarmd_hrpc.o" $CLIBS
swarmd/client/client.cpp +461 -0
……@@ -0,0 +1,461 @@
1#include "client.h"
2
3#include <cerrno>
4#include <cstdio>
5#include <cstdlib>
6#include <cstring>
7
8#include <poll.h>
9#include <sys/socket.h>
10#include <sys/un.h>
11#include <unistd.h>
12
13extern "C" {
14#include <compact.h>
15#include <rpc.h>
16#include "swarmd_hrpc.h"
17#include "swarmd_schema.h"
18}
19
20#include "../src/uds_server.h" // UDS_MAX_FRAME — one definition of the cap
21
22namespace swarmd {
23
24// ---- framing -------------------------------------------------------------
25
26static uint32_t le32(const uint8_t *p) {
27 return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
28}
29
30static bool write_all(int fd, const uint8_t *p, size_t n) {
31 size_t off = 0;
32 while (off < n) {
33 const ssize_t w = ::write(fd, p + off, n - off);
34 if (w > 0) { off += (size_t)w; continue; }
35 if (w < 0 && (errno == EINTR)) continue;
36 if (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
37 // The socket is blocking, so this is not expected — but a short wait
38 // is still the right answer to a full kernel buffer, and dropping
39 // half a frame is not.
40 struct pollfd pfd = { fd, POLLOUT, 0 };
41 if (::poll(&pfd, 1, 1000) > 0) continue;
42 }
43 return false;
44 }
45 return true;
46}
47
48Client::~Client() { close(); }
49
50void Client::close() {
51 if (_fd >= 0) ::close(_fd);
52 _fd = -1;
53 _in.clear();
54 _reply.clear();
55}
56
57void Client::_fail(const std::string &why) {
58 if (_fd < 0) return; // already reported
59 _err = why;
60 close();
61 if (on.on_disconnect) on.on_disconnect(why);
62}
63
64bool Client::_send_frame(uint8_t *buf, size_t len) {
65 if (_fd < 0 || buf == nullptr) { free(buf); return false; }
66 uint8_t hdr[4] = { (uint8_t)(len & 0xff), (uint8_t)((len >> 8) & 0xff),
67 (uint8_t)((len >> 16) & 0xff), (uint8_t)((len >> 24) & 0xff) };
68 const bool ok = write_all(_fd, hdr, 4) && write_all(_fd, buf, len);
69 free(buf); // ⛔ the caller frees what *_encode_* allocated
70 if (!ok) _fail("the daemon closed while we were writing to it");
71 return ok;
72}
73
74// ---- reading -------------------------------------------------------------
75
76bool Client::_read_some(int timeout_ms) {
77 if (_fd < 0) return false;
78 struct pollfd pfd = { _fd, POLLIN, 0 };
79 const int r = ::poll(&pfd, 1, timeout_ms);
80 if (r < 0) {
81 if (errno == EINTR) return true;
82 _fail("poll failed on the daemon socket");
83 return false;
84 }
85 if (r == 0) return true; // nothing yet, not an error
86
87 uint8_t tmp[65536];
88 const ssize_t n = ::read(_fd, tmp, sizeof(tmp));
89 if (n == 0) { _fail("the daemon closed the connection"); return false; }
90 if (n < 0) {
91 if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) return true;
92 _fail("read failed on the daemon socket");
93 return false;
94 }
95 _in.insert(_in.end(), tmp, tmp + n);
96 return true;
97}
98
99void Client::_dispatch_event(const void *vmsg) {
100 const rpc_message_t *msg = (const rpc_message_t *)vmsg;
101 swarmd_hrpc_handlers_t h;
102 memset(&h, 0, sizeof(h));
103 h.ctx = this;
104 h.on_on_message = [](void *ctx, const swarmd_message_t *m) {
105 Client *c = (Client *)ctx;
106 if (c->on.on_message)
107 c->on.on_message(m->channel, m->slot, m->origin_kind, m->origin_id,
108 m->bytes, m->bytes_len);
109 };
110 h.on_on_peer_up = [](void *ctx, const swarmd_peer_up_t *m) {
111 Client *c = (Client *)ctx;
112 if (c->on.on_peer_up) c->on.on_peer_up(m->peer, m->pubkey);
113 };
114 h.on_on_peer_down = [](void *ctx, const swarmd_peer_down_t *m) {
115 Client *c = (Client *)ctx;
116 if (c->on.on_peer_down) c->on.on_peer_down(m->peer);
117 };
118 h.on_on_drain = [](void *ctx, const swarmd_channel_ref_t *m) {
119 Client *c = (Client *)ctx;
120 if (c->on.on_drain) c->on.on_drain(m->channel);
121 };
122 h.on_on_content_progress = [](void *ctx, const swarmd_content_progress_t *m) {
123 Client *c = (Client *)ctx;
124 if (c->on.on_content_progress) c->on.on_content_progress(m->hash, m->received, m->total);
125 };
126 h.on_on_content_done = [](void *ctx, const swarmd_content_done_t *m) {
127 Client *c = (Client *)ctx;
128 if (c->on.on_content_done)
129 c->on.on_content_done(m->hash, std::string((const char *)m->path.data, m->path.len));
130 };
131 h.on_on_content_failed = [](void *ctx, const swarmd_content_failed_t *m) {
132 Client *c = (Client *)ctx;
133 if (c->on.on_content_failed)
134 c->on.on_content_failed(m->hash, std::string((const char *)m->reason.data, m->reason.len));
135 };
136 // An event carries no reply, so anything the dispatcher hands back is
137 // discarded rather than sent — but it must still be freed.
138 uint8_t *rep = nullptr;
139 size_t rl = 0;
140 swarmd_hrpc_dispatch(&h, msg, &rep, &rl);
141 if (rep) free(rep);
142}
143
144bool Client::_drain_frames(uint64_t await_id, const uint8_t **body, size_t *body_len) {
145 for (;;) {
146 if (_in.size() < 4) return false;
147 const uint32_t n = le32(_in.data());
148 if (n > UDS_MAX_FRAME) {
149 // A length this large is a framing bug, not a frame — and the stream
150 // is desynchronised, so there is nothing to resume from.
151 _fail("the daemon sent a frame length past the cap; the stream is out of sync");
152 return false;
153 }
154 if (_in.size() < 4 + (size_t)n) return false;
155
156 // Copy out before consuming: a decoded view points INTO this buffer, and
157 // the buffer moves as soon as the next frame is taken off it.
158 std::vector<uint8_t> frame(_in.begin() + 4, _in.begin() + 4 + n);
159 _in.erase(_in.begin(), _in.begin() + 4 + n);
160
161 compact_state_t st = { 0, frame.size(), frame.data() };
162 rpc_message_t msg;
163 if (rpc_decode_message(&st, &msg) < 0) {
164 _fail("the daemon sent a message this client could not decode");
165 return false;
166 }
167
168 if (msg.type == rpc_response) {
169 if (msg.id != await_id) continue; // a reply nobody is waiting for
170 if (msg.error) {
171 _err = "the daemon answered with an error";
172 // Not a disconnect: the call failed, the session is fine.
173 _reply.clear();
174 if (body) *body = nullptr;
175 if (body_len) *body_len = 0;
176 return true;
177 }
178 _reply.assign(msg.data, msg.data + msg.len);
179 if (body) *body = _reply.empty() ? nullptr : _reply.data();
180 if (body_len) *body_len = _reply.size();
181 return true;
182 }
183
184 // ⛔ EVENTS ARE DELIVERED EVEN WHILE A CALL IS WAITING. A peer arriving,
185 // or a fetch finishing, does not queue politely behind the answer to
186 // channel_open.
187 if (msg.type == rpc_request) _dispatch_event(&msg);
188 }
189}
190
191bool Client::_await(uint64_t id, int timeout_ms, const uint8_t **body, size_t *body_len) {
192 if (body) *body = nullptr;
193 if (body_len) *body_len = 0;
194 // Anything already buffered may hold the answer.
195 if (_drain_frames(id, body, body_len)) return true;
196 if (_fd < 0) return false;
197
198 int left = timeout_ms;
199 while (left > 0) {
200 const int slice = left > 200 ? 200 : left;
201 if (!_read_some(slice)) return false;
202 if (_drain_frames(id, body, body_len)) return true;
203 if (_fd < 0) return false;
204 left -= slice;
205 }
206 _err = "the daemon did not answer in time";
207 return false;
208}
209
210bool Client::poll(int timeout_ms) {
211 if (_fd < 0) return false;
212 if (!_read_some(timeout_ms)) return false;
213 // id 0 is never a request id, so nothing matches and every frame is an event.
214 _drain_frames(0, nullptr, nullptr);
215 return _fd >= 0;
216}
217
218// ---- calls ---------------------------------------------------------------
219
220bool Client::connect(const std::string &socket_path, const std::string &name,
221 const uint8_t app_identity[32], int timeout_ms) {
222 close();
223 _err.clear();
224
225 _fd = ::socket(AF_UNIX, SOCK_STREAM, 0);
226 if (_fd < 0) { _err = "could not create a unix socket"; return false; }
227 struct sockaddr_un a;
228 memset(&a, 0, sizeof(a));
229 a.sun_family = AF_UNIX;
230 strncpy(a.sun_path, socket_path.c_str(), sizeof(a.sun_path) - 1);
231 if (::connect(_fd, (struct sockaddr *)&a, sizeof(a)) != 0) {
232 ::close(_fd);
233 _fd = -1;
234 _err = "no daemon at " + socket_path;
235 return false;
236 }
237
238 swarmd_hello_request_t req;
239 memset(&req, 0, sizeof(req));
240 req.name.data = (utf8_t *)name.c_str();
241 req.name.len = name.size();
242 if (app_identity) memcpy(req.app_identity, app_identity, 32);
243 req.version = 1;
244
245 const uint64_t id = _next_id++;
246 uint8_t *buf = nullptr;
247 size_t len = 0;
248 if (swarmd_encode_hello(id, &req, &buf, &len) < 0) { _fail("could not encode HELLO"); return false; }
249 if (!_send_frame(buf, len)) return false;
250
251 const uint8_t *body = nullptr;
252 size_t body_len = 0;
253 if (!_await(id, timeout_ms, &body, &body_len)) { close(); return false; }
254
255 swarmd_hello_response_t res;
256 memset(&res, 0, sizeof(res));
257 if (body) {
258 compact_state_t in = { 0, body_len, (uint8_t *)body };
259 if (swarmd_hello_response_decode(&in, &res) < 0) {
260 _fail("the daemon's HELLO reply did not decode");
261 return false;
262 }
263 }
264 _client_id = res.client_id;
265 memcpy(_daemon_pubkey, res.daemon_pubkey, 32);
266 return true;
267}
268
269// One shape for every call that answers with `ok`.
270#define SWARMD_OK_CALL(ENCODE, REQPTR) \
271 do { \
272 if (_fd < 0) { _err = "not connected"; return false; } \
273 const uint64_t id = _next_id++; \
274 uint8_t *buf = nullptr; \
275 size_t len = 0; \
276 if (ENCODE(id, REQPTR, &buf, &len) < 0) { _fail("encode failed"); return false; } \
277 if (!_send_frame(buf, len)) return false; \
278 const uint8_t *body = nullptr; \
279 size_t body_len = 0; \
280 if (!_await(id, timeout_ms, &body, &body_len)) return false; \
281 if (!body) return false; \
282 swarmd_ok_t ok; \
283 memset(&ok, 0, sizeof(ok)); \
284 compact_state_t in = { 0, body_len, (uint8_t *)body }; \
285 if (swarmd_ok_decode(&in, &ok) < 0) { _fail("ok reply did not decode"); return false; } \
286 if (!ok.ok) _err = "the daemon refused the request"; \
287 return ok.ok; \
288 } while (0)
289
290bool Client::join(const uint8_t topic[32], bool server, bool client, int timeout_ms) {
291 swarmd_swarm_join_t req;
292 memset(&req, 0, sizeof(req));
293 memcpy(req.topic, topic, 32);
294 req.server = server;
295 req.client = client;
296 SWARMD_OK_CALL(swarmd_encode_swarm_join, &req);
297}
298
299bool Client::leave(const uint8_t topic[32], int timeout_ms) {
300 swarmd_topic_ref_t req;
301 memset(&req, 0, sizeof(req));
302 memcpy(req.topic, topic, 32);
303 SWARMD_OK_CALL(swarmd_encode_swarm_leave, &req);
304}
305
306bool Client::close_channel(uint64_t channel, int timeout_ms) {
307 swarmd_channel_ref_t req;
308 memset(&req, 0, sizeof(req));
309 req.channel = channel;
310 SWARMD_OK_CALL(swarmd_encode_channel_close, &req);
311}
312
313bool Client::content_serve(const uint8_t hash[32], const std::string &path, int timeout_ms) {
314 swarmd_content_serve_t req;
315 memset(&req, 0, sizeof(req));
316 memcpy(req.hash, hash, 32);
317 req.path.data = (utf8_t *)path.c_str();
318 req.path.len = path.size();
319 SWARMD_OK_CALL(swarmd_encode_content_serve, &req);
320}
321
322bool Client::content_fetch(const uint8_t hash[32], const std::string &dest_path, int timeout_ms) {
323 swarmd_content_fetch_t req;
324 memset(&req, 0, sizeof(req));
325 memcpy(req.hash, hash, 32);
326 req.dest_path.data = (utf8_t *)dest_path.c_str();
327 req.dest_path.len = dest_path.size();
328 SWARMD_OK_CALL(swarmd_encode_content_fetch, &req);
329}
330
331bool Client::content_cancel(const uint8_t hash[32], bool discard, int timeout_ms) {
332 swarmd_content_cancel_t req;
333 memset(&req, 0, sizeof(req));
334 memcpy(req.hash, hash, 32);
335 req.discard = discard;
336 SWARMD_OK_CALL(swarmd_encode_content_cancel, &req);
337}
338
339uint64_t Client::open_channel(const std::string &protocol, const std::vector<uint8_t> &id_bytes,
340 uint32_t slots, int timeout_ms) {
341 if (_fd < 0) { _err = "not connected"; return 0; }
342 swarmd_channel_open_t req;
343 memset(&req, 0, sizeof(req));
344 req.protocol.data = (utf8_t *)protocol.c_str();
345 req.protocol.len = protocol.size();
346 req.id = id_bytes.empty() ? nullptr : (uint8_t *)id_bytes.data();
347 req.id_len = id_bytes.size();
348 req.slots = slots ? slots : 1;
349
350 const uint64_t id = _next_id++;
351 uint8_t *buf = nullptr;
352 size_t len = 0;
353 if (swarmd_encode_channel_open(id, &req, &buf, &len) < 0) { _fail("encode failed"); return 0; }
354 if (!_send_frame(buf, len)) return 0;
355
356 const uint8_t *body = nullptr;
357 size_t body_len = 0;
358 if (!_await(id, timeout_ms, &body, &body_len) || !body) return 0;
359
360 swarmd_channel_ref_t ref;
361 memset(&ref, 0, sizeof(ref));
362 compact_state_t in = { 0, body_len, (uint8_t *)body };
363 if (swarmd_channel_ref_decode(&in, &ref) < 0) { _fail("channel reply did not decode"); return 0; }
364 if (ref.channel == 0) _err = "the daemon did not open the channel";
365 return ref.channel;
366}
367
368bool Client::send(uint64_t channel, uint32_t slot, uint64_t target,
369 const uint8_t *bytes, size_t len_bytes, bool *writable, int timeout_ms) {
370 if (writable) *writable = false;
371 if (_fd < 0) { _err = "not connected"; return false; }
372
373 swarmd_send_request_t req;
374 memset(&req, 0, sizeof(req));
375 req.channel = channel;
376 req.slot = slot;
377 req.target = target;
378 req.bytes = (uint8_t *)bytes;
379 req.bytes_len = len_bytes;
380
381 const uint64_t id = _next_id++;
382 uint8_t *buf = nullptr;
383 size_t len = 0;
384 if (swarmd_encode_send(id, &req, &buf, &len) < 0) { _fail("encode failed"); return false; }
385 if (!_send_frame(buf, len)) return false;
386
387 const uint8_t *body = nullptr;
388 size_t body_len = 0;
389 if (!_await(id, timeout_ms, &body, &body_len) || !body) return false;
390
391 swarmd_send_result_t res;
392 memset(&res, 0, sizeof(res));
393 compact_state_t in = { 0, body_len, (uint8_t *)body };
394 if (swarmd_send_result_decode(&in, &res) < 0) { _fail("send reply did not decode"); return false; }
395 if (writable) *writable = res.writable;
396 if (!res.accepted) _err = "no peer has this channel open yet";
397 return res.accepted;
398}
399
400bool Client::status(Status &out, int timeout_ms) {
401 if (_fd < 0) { _err = "not connected"; return false; }
402 swarmd_none_t req;
403 memset(&req, 0, sizeof(req));
404 const uint64_t id = _next_id++;
405 uint8_t *buf = nullptr;
406 size_t len = 0;
407 if (swarmd_encode_status_get(id, &req, &buf, &len) < 0) { _fail("encode failed"); return false; }
408 if (!_send_frame(buf, len)) return false;
409
410 const uint8_t *body = nullptr;
411 size_t body_len = 0;
412 if (!_await(id, timeout_ms, &body, &body_len) || !body) return false;
413
414 swarmd_status_t st;
415 memset(&st, 0, sizeof(st));
416 compact_state_t in = { 0, body_len, (uint8_t *)body };
417 if (swarmd_status_decode(&in, &st) < 0) { _fail("status reply did not decode"); return false; }
418 out.dht_state = st.dht_state;
419 out.bootstrapped = st.bootstrapped;
420 out.peers_known = st.peers_known;
421 out.peers_connected = st.peers_connected;
422 out.connecting = st.connecting;
423 out.queued = st.queued;
424 out.local_clients = st.local_clients;
425 return true;
426}
427
428bool Client::content_hash(const std::string &path, uint8_t out_hash[32], int timeout_ms) {
429 memset(out_hash, 0, 32);
430 if (_fd < 0) { _err = "not connected"; return false; }
431 swarmd_path_t req;
432 memset(&req, 0, sizeof(req));
433 req.path.data = (utf8_t *)path.c_str();
434 req.path.len = path.size();
435
436 const uint64_t id = _next_id++;
437 uint8_t *buf = nullptr;
438 size_t len = 0;
439 if (swarmd_encode_content_hash(id, &req, &buf, &len) < 0) { _fail("encode failed"); return false; }
440 if (!_send_frame(buf, len)) return false;
441
442 const uint8_t *body = nullptr;
443 size_t body_len = 0;
444 if (!_await(id, timeout_ms, &body, &body_len) || !body) return false;
445
446 swarmd_hash_t res;
447 memset(&res, 0, sizeof(res));
448 compact_state_t in = { 0, body_len, (uint8_t *)body };
449 if (swarmd_hash_decode(&in, &res) < 0) { _fail("hash reply did not decode"); return false; }
450 memcpy(out_hash, res.hash, 32);
451 static const uint8_t zero[32] = { 0 };
452 if (memcmp(out_hash, zero, 32) == 0) {
453 // The daemon answers all-zero when it cannot read the file. Saying so is
454 // better than handing back a hash nothing will ever match.
455 _err = "the daemon could not hash " + path;
456 return false;
457 }
458 return true;
459}
460
461} // namespace swarmd
swarmd/client/client.h +153 -0
……@@ -0,0 +1,153 @@
1#ifndef SWARMD_CLIENT_H
2#define SWARMD_CLIENT_H
3
4// A C++ client for swarmd. docs/SWARMD-DESIGN.md §7 item 7, §9 step 5.
5//
6// "Any program in C++" is the goal, and until now the only clients were a
7// Python test harness and one 250-line tool. Both hand-assembled frames.
8//
9// ⛔ NOTHING HERE IS HAND-WRITTEN WIRE, and that is the entire reason this file
10// exists rather than a page of documentation telling each consumer how to do it
11// themselves. Two clients in this repo have already been wrong that way — the
12// first channel gate hand-rolled compact-encoding in Python and the daemon
13// answered "undecodable message" to every frame, and swarmd-probe gave its sends
14// an id range that overlapped the content calls and reported a fetch reply as a
15// send result. Both are the same mistake: a client is a wire implementation, and
16// there should be ONE.
17//
18// SHAPE. Setup calls block for their reply; everything the daemon says on its
19// own arrives through callbacks during poll(). That split is not arbitrary:
20// channel_open has to return a handle before you can send on it, while a peer
21// arriving has nobody waiting for it.
22//
23// ⛔ AND A BLOCKING CALL STILL DISPATCHES EVENTS. Frames do not queue politely
24// behind the reply you asked for: a peer can come up, or a 100 MB fetch can
25// finish, while you are waiting for the answer to channel_open. Anything read on
26// the way to a reply is delivered, not dropped and not deferred.
27//
28// THREADING. One Client belongs to one thread. It owns a socket and a buffer and
29// guards neither.
30
31#include <cstddef>
32#include <cstdint>
33#include <functional>
34#include <map>
35#include <string>
36#include <vector>
37
38namespace swarmd {
39
40// What the daemon says on its own. Every one of these arrives inside poll(), or
41// inside a blocking call that read it on the way to a reply.
42struct ClientCallbacks {
43 // A frame from a peer, on a channel this client opened.
44 std::function<void(uint64_t channel, uint64_t slot, uint64_t origin_kind,
45 uint64_t origin_id, const uint8_t *bytes, size_t len)> on_message;
46 std::function<void(uint64_t peer, const uint8_t pubkey[32])> on_peer_up;
47 std::function<void(uint64_t peer)> on_peer_down;
48 // The channel has drained and will accept writes again.
49 std::function<void(uint64_t channel)> on_drain;
50
51 std::function<void(const uint8_t hash[32], uint64_t received, uint64_t total)> on_content_progress;
52 std::function<void(const uint8_t hash[32], const std::string &path)> on_content_done;
53 std::function<void(const uint8_t hash[32], const std::string &reason)> on_content_failed;
54
55 // The daemon went away, or sent something this client could not read. After
56 // this the Client is closed; connect() again to resume.
57 std::function<void(const std::string &reason)> on_disconnect;
58};
59
60struct Status {
61 uint64_t dht_state = 0; // 0 starting, 1 connecting, 2 up
62 bool bootstrapped = false;
63 uint64_t peers_known = 0;
64 uint64_t peers_connected = 0;
65 uint64_t connecting = 0;
66 uint64_t queued = 0;
67 uint64_t local_clients = 0;
68};
69
70class Client {
71public:
72 Client() = default;
73 ~Client();
74 Client(const Client &) = delete;
75 Client &operator=(const Client &) = delete;
76
77 ClientCallbacks on;
78
79 // Dial the daemon and say HELLO. `name` and `app_identity` are what the
80 // daemon records for this client; app_identity is what echo suppression and
81 // membership will key on, so a program that has one should pass it.
82 bool connect(const std::string &socket_path, const std::string &name,
83 const uint8_t app_identity[32] = nullptr, int timeout_ms = 5000);
84 void close();
85 bool connected() const { return _fd >= 0; }
86
87 uint64_t client_id() const { return _client_id; }
88 const uint8_t *daemon_pubkey() const { return _daemon_pubkey; }
89 const std::string &last_error() const { return _err; }
90
91 // Serve/receive on a topic. Both flags default true, which is what a peer
92 // that wants to meet anyone on the topic wants.
93 bool join(const uint8_t topic[32], bool server = true, bool client = true,
94 int timeout_ms = 5000);
95 bool leave(const uint8_t topic[32], int timeout_ms = 5000);
96
97 // Returns a channel handle, or 0. `slots` is how many message types the
98 // channel carries.
99 uint64_t open_channel(const std::string &protocol,
100 const std::vector<uint8_t> &id = {},
101 uint32_t slots = 1, int timeout_ms = 5000);
102 bool close_channel(uint64_t channel, int timeout_ms = 5000);
103
104 // ⛔ `accepted` FALSE MEANS THE FRAME WAS NOT TAKEN — retry, do not assume.
105 // The daemon refuses when the handle is unknown or the channel is open on no
106 // peer, which is every moment before the remote opens its side. `writable`
107 // says whether more will be accepted right now; on false, wait for on_drain.
108 // `target` 0 means every peer holding this channel.
109 bool send(uint64_t channel, uint32_t slot, uint64_t target,
110 const uint8_t *bytes, size_t len, bool *writable = nullptr,
111 int timeout_ms = 5000);
112
113 bool status(Status &out, int timeout_ms = 5000);
114
115 // ---- content ----
116 // The overlay's hash of a file the DAEMON can read — blake2b over the
117 // hypercore leaf construction. A hash from anywhere else is one the overlay
118 // will reject, which is why this asks the daemon rather than computing it.
119 bool content_hash(const std::string &path, uint8_t out_hash[32], int timeout_ms = 30000);
120 bool content_serve(const uint8_t hash[32], const std::string &path, int timeout_ms = 5000);
121 // Returns once the fetch has STARTED. Watch on_content_done / _failed.
122 bool content_fetch(const uint8_t hash[32], const std::string &dest_path, int timeout_ms = 5000);
123 bool content_cancel(const uint8_t hash[32], bool discard, int timeout_ms = 5000);
124
125 // Read whatever is there and dispatch it. Returns false once disconnected.
126 // `timeout_ms` 0 polls without waiting.
127 bool poll(int timeout_ms);
128
129private:
130 // Send one encoded request. Takes ownership of `buf` exactly as the
131 // generated encoders hand it over: heap-allocated, caller frees.
132 bool _send_frame(uint8_t *buf, size_t len);
133 // Pump until the response to `id` lands, dispatching events on the way.
134 // `body`/`body_len` point into `_reply` and are valid until the next pump.
135 bool _await(uint64_t id, int timeout_ms, const uint8_t **body, size_t *body_len);
136 bool _read_some(int timeout_ms);
137 // Consume whole frames from `_in`. Returns true if the awaited id landed.
138 bool _drain_frames(uint64_t await_id, const uint8_t **body, size_t *body_len);
139 void _dispatch_event(const void *msg);
140 void _fail(const std::string &why);
141
142 int _fd = -1;
143 uint64_t _next_id = 1;
144 uint64_t _client_id = 0;
145 uint8_t _daemon_pubkey[32] = { 0 };
146 std::string _err;
147 std::vector<uint8_t> _in; // partial inbound
148 std::vector<uint8_t> _reply; // the frame a blocking call is returning
149};
150
151} // namespace swarmd
152
153#endif
swarmd/test/test_client.cpp +204 -0
……@@ -0,0 +1,204 @@
1// Gate: the C++ client library, against a real Daemon over a real socket.
2//
3// docs/SWARMD-DESIGN.md §9 step 5. Until now the wire had two client
4// implementations — a Python harness and swarmd-probe — and both were wrong at
5// least once in ways the daemon could not distinguish from a bad daemon. This
6// asserts the one that replaces them.
7//
8// ⛔ NO NETWORK. The daemon is pointed at a bootstrap that answers nothing, so
9// it never has a peer. Everything here is about the LOCAL contract: the calls,
10// their replies, and the events. Peer-to-peer behaviour is
11// test_channel_e2e.py's and test_content_e2e.py's job, on the real DHT.
12//
13// The daemon runs on its own thread because that is how a daemon runs. A client
14// that blocks for a reply has nobody to pump the thing it is waiting for
15// otherwise, and pretending otherwise is what a single-threaded gate would be
16// asserting instead.
17
18#include <atomic>
19#include <cstdio>
20#include <cstdlib>
21#include <cstring>
22#include <string>
23#include <chrono>
24#include <thread>
25#include <vector>
26
27#include <unistd.h>
28#include <sys/stat.h>
29#include <cerrno>
30
31#include "client.h"
32#include "daemon.h"
33
34static int checks = 0, failures = 0;
35static void check(bool c, const char *what) {
36 checks++;
37 if (c) { printf(" PASS: %s\n", what); return; }
38 failures++;
39 printf(" FAIL: %s\n", what);
40}
41
42int main() {
43 char sock[128], file[128];
44 snprintf(sock, sizeof(sock), "/tmp/swarmd-client-%d.sock", (int)getpid());
45 snprintf(file, sizeof(file), "/tmp/swarmd-client-%d.bin", (int)getpid());
46
47 // A file for the content calls to name. Small: this gate is about the
48 // contract, not about throughput.
49 {
50 FILE *f = fopen(file, "wb");
51 if (!f) { printf("cannot write %s\n", file); return 1; }
52 std::vector<uint8_t> bytes(64 * 1024);
53 for (size_t i = 0; i < bytes.size(); i++) bytes[i] = (uint8_t)(i * 31 + 7);
54 fwrite(bytes.data(), 1, bytes.size(), f);
55 fclose(f);
56 }
57
58 char store[128];
59 snprintf(store, sizeof(store), "/tmp/swarmd-client-store-%d", (int)getpid());
60 if (mkdir(store, 0755) != 0 && errno != EEXIST) { printf("cannot make %s\n", store); return 1; }
61
62 swarmd::Daemon d;
63 if (!d.start(sock, "127.0.0.1:1", store)) { printf("daemon start failed\n"); return 1; }
64
65 std::atomic<bool> stop(false);
66 std::thread pump([&d, &stop]() { while (!stop.load()) d.poll(5); });
67
68 int rc = 0;
69 {
70 swarmd::Client c;
71
72 // Events the daemon raises on its own.
73 int content_failed = 0;
74 std::string last_fail;
75 int failed_during_status = 0;
76 bool counting = false;
77 c.on.on_content_failed = [&](const uint8_t *, const std::string &why) {
78 content_failed++;
79 last_fail = why;
80 if (counting) failed_during_status++;
81 };
82 int disconnects = 0;
83 c.on.on_disconnect = [&](const std::string &) { disconnects++; };
84
85 uint8_t identity[32];
86 memset(identity, 0x5c, 32);
87
88 check(c.connect(sock, "gate", identity), "connect() reached the daemon and said HELLO");
89 check(c.connected(), "and reports itself connected");
90
91 // ⛔ THESE TWO ARE THE POINT OF HELLO. §5 says the reply carries
92 // {client_id, daemon_pubkey}; §7 item 4 says client ids exist so a client
93 // can suppress its own echo. A client that cannot learn its own id cannot
94 // do that, and a zeroed daemon key tells it nothing about who it reached.
95 check(c.client_id() != 0, "HELLO answered with this client's OWN id");
96 {
97 static const uint8_t zero[32] = { 0 };
98 check(memcmp(c.daemon_pubkey(), zero, 32) != 0,
99 "and with the daemon's real public key, not 32 zeroes");
100 }
101
102 uint8_t topic[32];
103 memset(topic, 0x3a, 32);
104 check(c.join(topic), "join() was accepted");
105
106 const uint64_t ch = c.open_channel("client-gate/v1", {}, 2);
107 check(ch != 0, "open_channel() returned a handle");
108
109 // The send-truth contract, now through the library: with no peer there
110 // is nowhere to put the frame, and saying so is the whole point.
111 bool writable = true;
112 const char *payload = "nowhere-to-go";
113 check(!c.send(ch, 0, 0, (const uint8_t *)payload, strlen(payload), &writable),
114 "send() on a channel no peer has open is REFUSED, not silently dropped");
115 check(!writable, "and the channel is not reported writable");
116
117 swarmd::Status st;
118 check(c.status(st), "status() answered");
119 check(st.local_clients >= 1, "and counts this client");
120 check(st.peers_connected == 0, "and reports no peers, which is the truth here");
121
122 // ---- content ----
123 uint8_t hash[32], hash2[32];
124 check(c.content_hash(file, hash), "content_hash() hashed a real file");
125 check(c.content_hash(file, hash2) && memcmp(hash, hash2, 32) == 0,
126 "and the same bytes hash the same way twice");
127 {
128 static const uint8_t zero[32] = { 0 };
129 check(memcmp(hash, zero, 32) != 0, "and the hash is not all zeroes");
130 }
131 check(!c.content_hash("/nonexistent/path/at/all", hash2),
132 "hashing a file that is not there FAILS rather than returning zeroes");
133
134 check(c.content_serve(hash, file), "content_serve() registered the file");
135
136 // Cancelling a fetch nobody started is refused by liboverlay, and the
137 // daemon reports that refusal as a FAILED event rather than swallowing it.
138 uint8_t missing[32];
139 memset(missing, 0x77, 32);
140 check(c.content_cancel(missing, false),
141 "content_cancel() was accepted by the daemon (the RPC, not the outcome)");
142
143 // ⛔ THE INTERLEAVE — and the wait is what MAKES it one, not a patch over
144 // a flake.
145 //
146 // The cancel's ok reply is written by the control handler synchronously;
147 // the FAILED event is produced later, on the UDX loop, and broadcast by a
148 // subsequent Daemon::poll. Asserting straight after the cancel asserts a
149 // race: status() is answered just as fast, and the first version of this
150 // gate failed against a client that was working. Waiting until the event
151 // is known to be sitting in our socket is what establishes the condition
152 // the assertion is about — a frame queued AHEAD of the reply a blocking
153 // call is waiting for. A client that drops what it reads on the way to
154 // its own answer loses it, invisibly: no error, just an event that never
155 // fires.
156 {
157 const uint64_t deadline = 3000;
158 uint64_t waited = 0;
159 while (content_failed == 0 && waited < deadline) {
160 std::this_thread::sleep_for(std::chrono::milliseconds(20));
161 waited += 20;
162 // Deliberately NOT polling the client: the point is to leave the
163 // frame unread so the next blocking call has to handle it.
164 }
165 // It is in our socket now, unread, because nothing has read it.
166 std::this_thread::sleep_for(std::chrono::milliseconds(50));
167 }
168 counting = true;
169 check(c.status(st), "a second status() answered");
170 counting = false;
171 check(content_failed >= 1, "the daemon reported the refused cancel at all");
172 check(failed_during_status >= 1,
173 "an event queued AHEAD of a reply was delivered by the blocking call "
174 "that read past it, not dropped");
175 check(last_fail.find("no such fetch") != std::string::npos,
176 "and it is the event the daemon actually sent");
177
178 check(c.close_channel(ch), "close_channel() was accepted");
179 check(disconnects == 0, "nothing in that sequence looked like a disconnect");
180
181 // ---- what a closed client does ----
182 c.close();
183 check(!c.connected(), "close() closes");
184 check(!c.join(topic), "and a call on a closed client fails rather than crashing");
185 check(c.last_error() == "not connected", "with an error that says why");
186
187 // ---- a daemon that is not there ----
188 swarmd::Client absent;
189 check(!absent.connect("/tmp/swarmd-does-not-exist.sock", "gate"),
190 "connecting to a socket with no daemon fails");
191 check(absent.last_error().find("no daemon at") != std::string::npos,
192 "and says so");
193
194 rc = failures ? 1 : 0;
195 }
196
197 stop.store(true);
198 pump.join();
199 d.stop();
200 unlink(file);
201
202 printf("\n%d/%d passed\n", checks - failures, checks);
203 return rc;
204}