Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
2 changes: 1 addition & 1 deletion abi/MACULA_GO_REF
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.17.0 132d13411440cbe7e86f6fbbd6e5c8383fc659d8
v0.18.2 de62b9b74920603484e591e07c0c791b8239bbe1
28 changes: 26 additions & 2 deletions abi/macula.h
Original file line number Diff line number Diff line change
Expand Up @@ -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": {"<realm hex>": "<realm key hex>"}, "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. */
Expand All @@ -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": "<node_id
* hex>", "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);
Expand Down Expand Up @@ -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": <a policy_json>,
* "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
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
17 changes: 14 additions & 3 deletions src/macula_py/_abi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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**"
Expand Down Expand Up @@ -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]),
Expand All @@ -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]),
Expand All @@ -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*",
Expand Down
28 changes: 28 additions & 0 deletions tests/test_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading