From e5dbb622aca5b1d319f9c773dd117af7d7025081 Mon Sep 17 00:00:00 2001 From: beamologist Date: Mon, 28 Sep 2026 21:14:03 +0200 Subject: [PATCH] repin to macula-go v0.18.2: a call enters a handler at most once (0.3.1) libmacula v0.18.2 carries macula-go#8 (a call moves to the next provider only when a station cannot be reached, never after it went out, so a non-idempotent handler no longer runs twice) and #12 (no call goes out with a provider deadline past its caller's). The library floor is v0.18.2, abi/macula.h is its header, and _abi declares its four sealing *_opts functions, unused until the release on v0.19.0. Test, seen red on v0.17.0 (the call walked on and timed out): two providers on the two test stations answer slower than a call's share of its deadline; the call is answered and exactly one handler is entered. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ abi/MACULA_GO_REF | 2 +- abi/macula.h | 28 ++++++++++++++++++++++++++-- pyproject.toml | 2 +- src/macula_py/_abi.py | 17 ++++++++++++++--- tests/test_pool.py | 28 ++++++++++++++++++++++++++++ 6 files changed, 96 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1103cdc..137748c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 0.3.1 (2026-09-29) + +On macula-go v0.18.2's C ABI (was v0.17.0), for two fixes a Python caller +gets from the library: + +### Fixed + +- **A call enters a provider's handler at most once** (macula-go#8, fixed in + v0.18.1). A call used to move to the next provider after any failure but a + provider's answer, a timeout included, and a provider slower than one + candidate's share of the deadline was called again elsewhere: **a handler + that is not idempotent could run twice**. Now the call moves on only when a + provider's station cannot be reached, before anything is sent; once the + call has gone out, its outcome is returned as it is. A reply that is lost + ends in a timeout, and the handler ran once or not at all. +- **No call goes out with a provider deadline past its caller's** + (macula-go#12, v0.18.2). + +### Changed + +- The library floor is macula-go v0.18.2: an older library lacks these + fixes. `abi/macula.h` is v0.18.2's, and `macula_py._abi` declares its four + `*_opts` functions (sealing, v0.18.0) because the header has them. Nothing + in the Python API uses them yet: sealed calls and streams, and the seal + report, come in the release on macula-go v0.19.0. + ## 0.3.0 (2026-09-26) On macula-go v0.17.0's C ABI (was v0.13.0). diff --git a/abi/MACULA_GO_REF b/abi/MACULA_GO_REF index c03b0db..eabb74d 100644 --- a/abi/MACULA_GO_REF +++ b/abi/MACULA_GO_REF @@ -1 +1 @@ -v0.17.0 132d13411440cbe7e86f6fbbd6e5c8383fc659d8 +v0.18.2 de62b9b74920603484e591e07c0c791b8239bbe1 diff --git a/abi/macula.h b/abi/macula.h index 542efca..a2e5c55 100644 --- a/abi/macula.h +++ b/abi/macula.h @@ -124,7 +124,10 @@ char *macula_ucan_proof_id(const char *token, char **err_out); /* seeds_json: [{"host","port","node_id"}]. options_json (NULL for defaults): * {"realm_trust": {"": ""}, "replication_factor", - * "max_seeds", "max_direct_links", "respawn_delay_ms", "timeout_ms"}. */ + * "max_seeds", "max_direct_links", "respawn_delay_ms", "timeout_ms", + * "kem_advertise"}. kem_advertise (0 or 1, since macula-go v0.18.0) names + * this node's KEM key in its confidential procedures' advertisements: enable + * it only once every station runs macula 12.11 or later. */ macula_handle macula_pool_connect(macula_handle key, const char *seeds_json, const char *options_json, macula_handle cancel, char **err_out); /* Closes every link, subscription and served procedure; frees the handle. */ @@ -150,6 +153,14 @@ char *macula_pool_call(macula_handle pool, const uint8_t realm[32], const char * char *macula_pool_call_with(macula_handle pool, const uint8_t realm[32], const char *procedure, const char *payload_json, const uint8_t *provider_node_id, const char *ucan, const char *proofs_json, int64_t timeout_ms, macula_handle cancel, char **err_out); +/* A call with its options as JSON (NULL for none): {"provider": "", "ucan", "proofs": [...], "confidential": "preferred"|"required"}. + * It is sealed whenever the provider's advertisement names a KEM key; "off" is + * refused (invalid_argument): only an advertisement naming no key is called in + * the clear. A call that cannot be kept confidential fails with the kind + * "confidentiality". Since macula-go v0.18.0. */ +char *macula_pool_call_opts(macula_handle pool, const uint8_t realm[32], const char *procedure, const char *payload_json, + const char *options_json, int64_t timeout_ms, macula_handle cancel, char **err_out); /* [{"node","station"}], freshest first. */ char *macula_pool_providers(macula_handle pool, const uint8_t realm[32], const char *procedure, int64_t timeout_ms, macula_handle cancel, char **err_out); @@ -183,8 +194,15 @@ macula_handle macula_pool_serve_gated(macula_handle pool, const uint8_t realm[32 const char *policy_json, char **err_out); macula_handle macula_pool_serve_stream_gated(macula_handle pool, const uint8_t realm[32], const char *procedure, int32_t mode, const char *policy_json, char **err_out); +/* Serve with options as JSON (NULL for none): {"policy": , + * "confidential": "preferred"|"required"|"off"}. Since macula-go v0.18.0. */ +macula_handle macula_pool_serve_opts(macula_handle pool, const uint8_t realm[32], const char *procedure, + const char *options_json, char **err_out); +macula_handle macula_pool_serve_stream_opts(macula_handle pool, const uint8_t realm[32], const char *procedure, + int32_t mode, const char *options_json, char **err_out); /* The next call (*out_item: a pending call) or stream session (*out_item: a - * stream), and its request {"caller","realm","procedure","payload","deadline_ms"}. */ + * stream), and its request {"caller","realm","procedure","payload","deadline_ms", + * "sealed"} ("sealed" 0 or 1, since macula-go v0.18.0). */ char *macula_served_next(macula_handle served, int64_t timeout_ms, macula_handle cancel, macula_handle *out_item, int32_t *closed, char **err_out); /* Answer a pending call, once: with a result, or with an error the caller @@ -211,6 +229,12 @@ macula_handle macula_pool_open_stream_with(macula_handle pool, const uint8_t rea int32_t mode, const char *payload_json, const uint8_t *provider_node_id, const char *ucan, const char *proofs_json, int64_t deadline_ms, int64_t timeout_ms, macula_handle cancel, char **err_out); +/* macula_pool_open_stream with options as JSON, macula_pool_call_opts' + * set. Since macula-go v0.18.0. */ +macula_handle macula_pool_open_stream_opts(macula_handle pool, const uint8_t realm[32], const char *procedure, + int32_t mode, const char *payload_json, const char *options_json, + int64_t deadline_ms, int64_t timeout_ms, macula_handle cancel, + char **err_out); char *macula_stream_request(macula_handle stream, char **err_out); void macula_stream_send_bytes(macula_handle stream, const uint8_t *data, size_t data_len, char **err_out); void macula_stream_send_json(macula_handle stream, const char *value_json, char **err_out); diff --git a/pyproject.toml b/pyproject.toml index 5cd5d05..15f617d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "macula-py" -version = "0.3.0" +version = "0.3.1" description = "A Python node on the macula 12 mesh: post-quantum identity and key exchange, calls, streams, pub/sub, the DHT and node-served content, over macula-go's C ABI." readme = "README.md" license = "Apache-2.0" diff --git a/src/macula_py/_abi.py b/src/macula_py/_abi.py index 29ce746..10328d8 100644 --- a/src/macula_py/_abi.py +++ b/src/macula_py/_abi.py @@ -9,9 +9,11 @@ from __future__ import annotations ABI_VERSION = 1 -# The oldest macula-go release whose library exports every function below -# (a new function does not change ABI_VERSION). -LIBRARY_FLOOR = "v0.17.0" +# The oldest macula-go release this macula-py supports: its library exports +# every function below (a new function does not change ABI_VERSION), and it +# carries at-most-once delivery (macula-go#8, v0.18.1) and the deadline fix +# (macula-go#12, v0.18.2), which an older library would silently lack. +LIBRARY_FLOOR = "v0.18.2" H = "macula_handle" ERR = "char**" @@ -68,6 +70,7 @@ "char*", [H, REALM, "const char*", "const char*", "const uint8_t*", "const char*", "const char*", "int64_t", H, ERR], ), + "macula_pool_call_opts": ("char*", [H, REALM, "const char*", "const char*", "const char*", "int64_t", H, ERR]), "macula_pool_providers": ("char*", [H, REALM, "const char*", "int64_t", H, ERR]), # Publish / subscribe "macula_pool_publish": ("void", [H, REALM, "const char*", "const char*", "int64_t", ERR]), @@ -80,6 +83,10 @@ "macula_pool_serve_stream": (H, [H, REALM, "const char*", "int32_t", ERR]), "macula_pool_serve_gated": (H, [H, REALM, "const char*", "const char*", ERR]), "macula_pool_serve_stream_gated": (H, [H, REALM, "const char*", "int32_t", "const char*", ERR]), + # Sealing's *_opts functions (libmacula v0.18.0): declared, as the header requires, and not yet used: the Python + # API exposes sealing from the release on libmacula v0.19.0. + "macula_pool_serve_opts": (H, [H, REALM, "const char*", "const char*", ERR]), + "macula_pool_serve_stream_opts": (H, [H, REALM, "const char*", "int32_t", "const char*", ERR]), "macula_served_next": ("char*", [H, "int64_t", H, "macula_handle*", "int32_t*", ERR]), "macula_pending_reply": ("void", [H, "const char*", ERR]), "macula_pending_error": ("void", [H, "const char*", ERR]), @@ -89,6 +96,10 @@ H, [H, REALM, "const char*", "int32_t", "const char*", "const uint8_t*", "int64_t", "int64_t", H, ERR], ), + "macula_pool_open_stream_opts": ( + H, + [H, REALM, "const char*", "int32_t", "const char*", "const char*", "int64_t", "int64_t", H, ERR], + ), "macula_pool_open_stream_with": ( H, [H, REALM, "const char*", "int32_t", "const char*", "const uint8_t*", "const char*", "const char*", diff --git a/tests/test_pool.py b/tests/test_pool.py index 72e9e90..bb026c4 100644 --- a/tests/test_pool.py +++ b/tests/test_pool.py @@ -260,6 +260,34 @@ async def test_cancelling_a_waiting_subscription_ends_the_native_wait_at_once(se await waiting assert time.monotonic() - started < 1.0 + async def test_a_call_enters_a_providers_handler_at_most_once(self, env): + """macula-go#8, fixed in libmacula v0.18.1: a call walks to the next provider only when it cannot reach a + station, never after its CALL went out. Two providers serve one procedure from the two stations and answer + slower than a call's share of its deadline: the call is answered by one of them, which is entered once, and + the other is never entered. On v0.17.0 the call timed out at the first, was sent again to the second, and + both handlers ran.""" + procedure = f"{env.org}/once" + entered: list[str] = [] + + async def slow(request): + entered.append(request.caller) + await asyncio.sleep(2.5) + return "answered" + + first = await node(env, 0, admitted=True) + second = await node(env, 1, admitted=True) + caller = await node(env, 1) + async with first, second, caller, await first.serve(env.realm_id, procedure, slow), await second.serve( + env.realm_id, procedure, slow + ): + async def both_advertised() -> bool: + return len(await caller.providers(env.realm_id, procedure)) == 2 + + await eventually("both providers", both_advertised, 15) + assert await caller.call(env.realm_id, procedure, {}, timeout_ms=4_000) == "answered" + await asyncio.sleep(1) + assert len(entered) == 1, f"the call entered {len(entered)} handlers" + async def test_a_timed_out_call_on_a_task_wait_for_is_cancelled_not_left_running(self, env): provider = await node(env, 0, admitted=True) procedure = f"{env.org}/slow"