MESH ONLINE│CODENAME: Breaking the Law
v0.45

C — Memory and Threading

The C ABI hands you memory and expects it back. Each buffer goes back to the allocator that made it: the library's buffers through the free function its header names, and your buffers through the deallocator you register. There is one non-obvious trap in the polling loop, and a set of guarantees the boundary makes so that a mistake returns an error instead of corrupting your process.

The event-bus rules

You got it fromYou free it with
net_init()net_shutdown()
net_poll_ex()net_free_poll_result()
net_generate_keypair() and similar string returnsnet_free_string()

net_version() returns a static string. Do not free it.

Buffers the library hands you

Byte buffers carry their length, and go back with it, to the function the header names for them:

You got it fromYou free it with
A blob adapter or the blob registry (net_mesh_blob_adapter_*, net_blob_*): refs, fetched bytesnet_blob_free_buffer(ptr, len)
Transfer (net_transport.h): net_fetch_blob / _discovered bytes, net_store_dir's manifest refnet_transport_free_buffer(ptr, len)
An nRPC responsenet_rpc_response_free(ptr, len)
An nRPC error stringnet_rpc_free_cstring(s)
A JSON result (net_dir_manifest_read, net_blob_ref_describe, ...)net_free_string(s)

Never free() one of them. The library allocated it with its own allocator, and whether your C runtime's free happens to accept the pointer depends on your toolchain, not on anything Net promises. Every other surface's header names its own free functions in the same way.

Buffers your callbacks hand back

When you implement a callback — an nRPC or organization handler, or a blob adapter's vtable — the response you return is memory you allocated. The library copies the bytes and gives the buffer back to you; it never frees it itself.

  • nRPC handlers: register the deallocator with net_rpc_set_callback_free before net_rpc_set_handler_dispatcher. Without it, the dispatcher registration returns -1, on every platform, because the library would have no way to release your buffers.
  • Organization handlers: the same rule, with net_org_set_callback_free before the org dispatchers.
  • Blob callback adapters: the vtable's free_buffer receives each buffer your fetch returned, once the library has copied it.

Free a buffer yourself after handing it over and you free it twice.

Because each buffer returns to the allocator that made it, the program's C runtime does not matter. CI runs the C consumer programs built with MSVC /MD and /MDd, and with MinGW-w64 against both UCRT and MSVCRT.

The cursor trap

net_free_poll_result frees next_id along with the events. Paging forward means reading next_id after the free unless you copy it first — a use-after-free that will usually appear to work.

▸ c
char *cursor = NULL;
while (running) {
    net_poll_result_t result;
    int rc = net_poll_ex(node, 100, cursor, &result);
    if (rc < 0) break;
 
    for (size_t i = 0; i < result.count; i++) {
        process(&result.events[i]);
    }
 
    /* Copy the cursor BEFORE freeing — net_free_poll_result frees next_id. */
    free(cursor);
    cursor = NULL;
    if (result.next_id) {
        size_t n = strlen(result.next_id) + 1;
        cursor = malloc(n);
        if (cursor) memcpy(cursor, result.next_id, n);
    }
    net_free_poll_result(&result);
}
free(cursor);

malloc and free come from <stdlib.h>, strlen and memcpy from <string.h>. strdup would collapse the copy to one line, but it is POSIX rather than ISO C and is not declared under -std=c11.

A NULL cursor starts from the earliest buffered event. There is no async subscribe in the C ABI — the SDK does not manage threads, so a live consumer is this loop on an interval.

net_free_poll_result is idempotent: it nulls events and next_id and zeros count / has_more, so a second call is a no-op and NULL is a no-op. If you wrote defensive field-nulling around it, you can drop it.

Dedup handles

The Redis consumer-side dedup helper is a handle you allocate and free, which is why it is documented here rather than beside the other bindings' versions: in C it is an ownership question, not a capability question. What duplicates are and why they occur is in Redis Streams Deduplication.

▸ c
net_redis_dedup_t *dedup = net_redis_dedup_new(600000);   /* 0 → default 4096 */
 
if (net_redis_dedup_is_duplicate(dedup, dedup_id) != 1) {
    process(entry);
}
 
net_redis_dedup_free(dedup);

net_redis_dedup_new(size_t capacity) never returns NULL. is_duplicate returns 1 for a duplicate the caller should skip, 0 for first sight. Also net_redis_dedup_len, net_redis_dedup_capacity, and net_redis_dedup_free.

It follows the event-bus rules above: you own the handle, free exactly once, and do not touch it afterwards. It is not internally synchronised — one handle per consumer thread, or your own lock around it.

Threading

All functions are thread-safe, and handles can be shared across threads.

Two exceptions worth knowing:

  • net_redis_dedup_t is per-thread. Use one helper per consumer thread rather than sharing one. See Redis Streams Deduplication.
  • Concurrent net_shutdown is serialized. Two threads racing to shut the same handle down will not double-free; one wins and the other is a no-op.

What the boundary guarantees

These hold at the FFI edge, so an error in your C code surfaces as a return code rather than undefined behaviour.

Panics do not unwind into your process. The cdylib is built with panic = "abort" and every extern "C" body is wrapped in catch_unwind. A Rust panic returns a defined error code or aborts cleanly — it never half-completes a call and corrupts state across the boundary.

Lengths are validated. Every entry point that builds a slice from a caller-supplied (ptr, len) rejects len > SSIZE_MAX before touching memory. A stray sign-extended -1 returns an error instead of triggering immediate undefined behaviour. This covers the ingest family, net_mesh_publish, net_redex_file_append, net_netdb_open_from_snapshot, net_mesh_subscribe_channel_with_token, the identity and token functions, and the blob functions.

Handles are alignment-checked. Every handle accessor checks alignment before dereferencing, so a misaligned pointer from a wrapper that allocated through a non-Rust allocator returns an error rather than reading garbage.

Undersized poll buffers are rejected before the cursor moves. net_poll rejects buffers below 256 bytes with NET_ERR_BUFFER_TOO_SMALL without advancing the cursor. Size for 4 KB and you will not think about it again. The structured net_poll_ex path is unaffected.

Batch ingest tells you how many it dropped, not which

▸ c
int net_ingest_raw_batch(
    net_handle_t handle,
    const char** jsons,
    const size_t* lens,
    size_t count
);

The return value is the number of entries accepted. An entry with a null pointer, a length above isize::MAX, or invalid UTF-8 is skipped — the runtime logs a warning naming the reason, and the entry is simply missing from the count.

A short return tells you drops happened; it does not tell you which indices. If you need that, ingest individually with net_ingest_raw and check each return, or validate your buffers before batching. There is no out-param form of this call.

Next