C FFI Layer Requirements — Hurray Implementation Requirements
Overview
The hurray-ffi crate exposes a stable C ABI that allows non-Rust runtimes to
consume and produce Hurray tensors without depending on Rust tooling. It is the
foundation for all non-Python language bindings.
ABI Stability
- All public symbols MUST use the
#[no_mangle]attribute andextern "C"linkage. - The ABI MUST be declared stable across patch versions and SHOULD be stable across minor versions. Breaking ABI changes require a major version bump.
- All struct layouts exposed across the FFI boundary MUST be
#[repr(C)]. - Enums exposed across the FFI boundary MUST be
#[repr(u8)]or#[repr(i32)]as appropriate, never#[repr(Rust)].
C ABI Version
The C ABI carries a single uint32 version identifier exposed via a constant
HURRAY_C_ABI_VERSION and a runtime accessor hurray_c_abi_version(). The
current version is 10:
| Version | Changes |
|---|---|
1 | Initial C ABI: opaque handles, buffer release callbacks, panic-safe error returns. |
2 | Per-mode buffer handoff sync payloads (SYNC_PRODUCER_SYNCED, SYNC_EVENT, SYNC_CONSUMER_STREAM) and the event-release callback. See Buffer Handoff Synchronisation. |
3 | HurrayBufferList for multi-buffer tensors, and the native protocol capsule carried a list rather than a single HurrayBuffer (ADR-030). Narrowed at version 10, where a capsule carries the tensor handle that owns the list. See Buffer Lists. |
4 | A capsule's descriptor bytes and the producing build's ABI version became readable from any language, through a separate context handle, HurrayTensorContext, beside the capsule's buffer list (ADR-034). Folded into HurrayTensor at version 10, which deletes it. See Tensor Handle. |
5 | Layout-payload field reads, so a consumer can interpret every Tier 1 layout it can decode (ADR-039). See Descriptor Field Reads. |
6 | The field identifier space grew to cover all five optional descriptor sections, which renamed the three ADR-039 accessors and added two (ADR-040). See Descriptor Field Reads. |
7 | HurrayReader and HurrayWriter became real handles over a transport vtable, and HurrayTensor pairs a descriptor with its buffers (ADR-041). See Streaming Over a Transport Vtable. |
8 | The transport vtable grew read_at and size, and HurrayFileReader / HurrayFileWriter read and write the HRRYFILE container (ADR-041 § 8). See File Containers Over a Transport Vtable. |
9 | Composites cross the stream and file surfaces whole: hurray_reader_next_item, hurray_file_reader_read_composite, hurray_writer_write_composite, hurray_file_writer_write_composite, and the HurrayItem handle (ADR-041 step 6). See Composites. |
10 | HurrayTensor became the whole tensor — the ABI version and an opaque owner moved onto it, hurray_tensor_new constructs one, and the separate context handle is deleted. A native-protocol capsule carries one HurrayTensor* and no context (ADR-043). See Tensor Handle. |
A consumer of the C ABI MUST query hurray_c_abi_version() before invoking any
function whose contract changed in a later version. A consumer compiled against
version 1 of the ABI that links against a runtime providing version 2 will
receive buffer handles whose sync_mode is SYNC_PRODUCER_SYNCED (0x00) by
default, which is the safe fallback: a version-1 consumer that does not
inspect sync_mode will still observe the strongest synchronisation guarantee
and will not race against the producer's device writes.
Opaque Handles
All Hurray objects crossing the FFI boundary MUST be represented as opaque pointer handles. Callers MUST NOT dereference or inspect the pointed-to memory directly.
| Handle type | Represents |
|---|---|
HurrayDescriptor* | A parsed tensor descriptor |
HurrayBuffer* | A buffer handle (data + metadata) |
HurrayBufferList* | An ordered, owning collection of buffer handles |
HurrayTensor* | A tensor: a descriptor and its buffers, owned together. What a stream or file read produces, and what a native-protocol capsule carries |
HurrayReader* | A blocking stream reader over a caller-supplied transport |
HurrayWriter* | A blocking stream writer over a caller-supplied transport |
Each handle is obtained from a hurray_*_create function and MUST be released by
the corresponding hurray_*_destroy function. Double-free and use-after-free are
undefined behaviour on the caller side; the implementation MUST detect them in debug
builds (e.g., via a poisoned sentinel).
Buffer Lists
A tensor whose descriptor references more than one buffer — per-channel / NF4 /
MXFP quantization, sparse layouts, block-paged, composite — needs all of its
buffers to travel together. HurrayBufferList is the ordered collection that
holds them (ADR-030), and the thing a producer accumulates them into before
handing the finished list to hurray_tensor_new, which takes ownership of it
(see Tensor Handle). The list builds; the tensor is the
finished product.
| Function | Contract |
|---|---|
hurray_buffer_list_new(capacity, out_list) | Creates an empty list. capacity is a hint. |
hurray_buffer_list_push(list, buffer) | Appends buffer, transferring ownership to the list on success. On failure ownership stays with the caller. |
hurray_buffer_list_len(list, out_len) | Number of buffers in the list. |
hurray_buffer_list_get(list, index, out_buffer) | Borrows the handle at index. Returns HURRAY_ERR_INDEX_OUT_OF_BOUNDS if index >= len. |
hurray_buffer_list_destroy(list) | Destroys the list and every handle it owns, then writes null through list. |
The following rules are normative.
- A list owns every handle pushed into it. A handle obtained from
hurray_buffer_list_getis borrowed: the caller MUST NOT callhurray_buffer_destroyon it. It remains valid until the list is destroyed. - Buffers MUST be pushed in descriptor buffer-table order: element
iof the list is buffer indexiof the descriptor. hurray_buffer_list_destroytakes a pointer to the caller's handle variable and MUST write null through it, so the caller's variable is observably dead and a repeated destroy is a no-op. This is the sound half of the "release marks the structure released" discipline: the list allocation is freed, so only memory the caller owns can carry the marker.- Destroying a list MUST destroy every owned handle exactly once, and MUST null each owned slot as it goes, so that a release callback which panics or re-enters cannot cause a double free.
Tensor Handle
A tensor is a descriptor and the buffers that descriptor describes. HurrayTensor is
that pair as one owned handle (ADR-043), and every path that produces a tensor
produces it: a stream read, a file read, and a native-protocol capsule, which carries
one HurrayTensor* and nothing else.
Before ADR-043 a capsule carried its buffers as its pointer and everything else — the encoded descriptor, the producing build's ABI version, the owner reference — in a separate context handle. Nothing enforced the pairing: two independent fetches returned two unrelated handles, so a consumer that read one without the other got either bytes it could not interpret or a description of bytes it did not have. Teardown was two calls in a required order. Pairing the two halves inside one handle makes both problems structural rather than documentary.
typedef struct HurrayTensor HurrayTensor;
typedef void (*HurrayOwnerReleaseFn)(void *owner);
HurrayStatus hurray_tensor_new(uint32_t abi_version,
const uint8_t *descriptor_bytes,
uint64_t descriptor_len,
struct HurrayBufferList *buffers, /* ownership transferred */
void *owner,
HurrayOwnerReleaseFn owner_release,
struct HurrayTensor **out_tensor);
HurrayStatus hurray_tensor_abi_version(const struct HurrayTensor *tensor, uint32_t *out);
HurrayStatus hurray_tensor_descriptor(const struct HurrayTensor *tensor,
struct HurrayDescriptor **out_descriptor);
HurrayStatus hurray_tensor_buffers(const struct HurrayTensor *tensor,
struct HurrayBufferList **out_buffers);
HurrayStatus hurray_tensor_destroy(struct HurrayTensor **tensor);
Construction
hurray_tensor_newMUST decodedescriptor_bytesduring the call and MUST store only the decoded form. A producer therefore cannot export a tensor whose descriptor does not parse: bytes that fail to decode MUST be rejected with the status the decode failed with, and MUST NOT produce a handle.- A null
descriptor_bytes, or adescriptor_lenof0, MUST be rejected withHURRAY_ERR_NULL_POINTER. A tensor with no descriptor is a carrier for bytes nothing can interpret, and eager decoding rules it out in any case. - The encoded bytes MUST NOT be retained and are not reachable from C. Every descriptor
field is read through the field accessors of
Descriptor Field Reads on the borrowed
HurrayDescriptor*, which is the one shape the capsule path and the read path share — a tensor from a stream or file read has no descriptor bytes to offer, because the decode happens out of a staging buffer the reader drops. buffersMUST be a list built withhurray_buffer_list_newandhurray_buffer_list_push(see Buffer Lists). On success its ownership transfers to the tensor, and the caller MUST NOT thereafter push into it, destroy it, or use the handle at all. On failure ownership stays with the caller, who MUST destroy it.abi_versionis what the producing build'sHURRAY_C_ABI_VERSIONconstant says. A tensor produced by a stream or file read MUST report this library'sHURRAY_C_ABI_VERSIONand MUST carry no owner; one produced byhurray_tensor_newMUST report what its producer passed.ownerandowner_releaseare opaque and MUST NOT be interpreted by this crate. They exist so a producer can keep whatever owns the tensor's memory alive for the handle's lifetime —hurray-pythonparks a Python object reference there, which is how a Python type is kept out of the C ABI entirely. Both MAY be null, which is a tensor with no owner to release.
Reading
hurray_tensor_abi_versionMUST be callable on aHurrayTensor*produced by any version of this ABI, and a consumer MUST call it before any other accessor and before trusting anything else about the handle. Its signature MUST NOT change. Every other accessor MAY assume the version has been checked.- That ordering is what makes the handle extensible, and what makes the capsule's name a one-time matter rather than a counter: fields added in a later ABI version are reached only through accessors added in that version, and a consumer that checked the version knows which ones exist, so additive growth never invalidates a dereference.
hurray_tensor_descriptorandhurray_tensor_buffersreturn borrowed pointers, owned by the tensor and valid until it is destroyed. A caller MUST NOT callhurray_descriptor_destroyorhurray_buffer_list_destroyon them, and MUST NOT destroy either half individually — the disciplinehurray_buffer_list_getalready follows.- Element
iof the borrowed list is buffer indexiof the descriptor's buffer table, so every buffer index appearing in a quantization, layout, or composite-member descriptor indexes it directly.
Teardown
hurray_tensor_destroyMUST release the descriptor, every buffer the list owns, and the owner reference — invokingowner_release(owner)exactly once — and MUST then write null through the caller's pointer, so the caller's variable is observably dead.- One call ends the whole tensor. There is no teardown order for a caller to get wrong and no second call to omit on an error path.
- A consumer MUST keep the handle alive for as long as it reads the buffers. Taking the halves out and destroying the carrier is use-after-free: the halves are borrows.
Descriptor Field Reads
Decoding a descriptor yields its fixed header — rank, element type tag, layout tag, byte offset, buffer table. Everything with internal structure behind that header is read separately: the layout payload, and the five optional sections (quantization, shard, statistics, extension type, composite member).
An implementation MUST expose every layout-specific field of the Tier 1 layouts
(0x01–0x09), because Compliance Level 1 requires a reader to correctly interpret
those fields and a consumer that cannot read them cannot address an element. The
optional sections are not required at any conformance level, but an implementation that
decodes one SHOULD expose its fields: a section a caller cannot read is a section the
producer wrote for nothing.
Fields are named by a HurrayDescriptorField identifier and read through five typed
accessors (ADR-039, ADR-040):
| Accessor | Reads |
|---|---|
hurray_descriptor_field_u64 | every unsigned or boolean scalar |
hurray_descriptor_field_i64 | signed scalars: per-tensor affine zero_point |
hurray_descriptor_field_f64 | real scalars: per-tensor affine scale, the float64 statistics |
hurray_descriptor_field_u64_array | tile_shape, morton_bits, mode_order, parent_shape, shard_offset |
hurray_descriptor_field_i64_array | strides, outer_strides, inner_strides |
Field Identifiers
HurrayDescriptorField MUST be a uint32 with named constants, and MUST NOT be a C
enum: a caller may pass any integer, and an out-of-range value materialised as a Rust
enum is undefined behaviour.
The identifier encoding is:
field_id = (range << 16) | (discriminant << 8) | field_index
| Range | Sub-object | Discriminant byte |
|---|---|---|
0x0001 | layout payload | layout tag |
0x0002 | quantization | scheme tag; 0x00 addresses the section itself |
0x0003 | statistics | 0x00 |
0x0004 | shard | 0x00 |
0x0005 | extension type | 0x00 |
0x0006 | composite member | 0x00 |
The embedded discriminant makes applicability a comparison rather than a table lookup,
and the layout range inherits the layout tag space's reserved and private ranges.
0x0000_0000 MUST NOT be a valid identifier, so that a zeroed variable is rejected
rather than read as the first field.
Quantization is the only sub-object with an inner sum type, so it is the only section
that uses the discriminant byte. 0x0002_0000 MUST read the quantization scheme tag
itself, which a caller MUST read before selecting any scheme-specific identifier. The
remaining sections are flat and carry 0x00 in that byte.
Allocation follows the anti-rebind rule in ADR-019: a retired identifier MUST NOT be reused for a different field.
Field Semantics
- Every scalar field in the format is unsigned or boolean but two, so almost all of
them widen losslessly into
uint64. A boolean MUST read as0or1, and a wire tag byte MUST read as its documented value. - A signed scalar MUST be read through the signed accessor, and a real scalar through
the real accessor. A
zero_pointis routinely negative, and an unsigned read would report it as a value near2^64. - Stride fields MUST be read through the signed array accessor: a negative stride reverses a dimension and a zero stride broadcasts one, both of which an unsigned read would misreport.
- Array accessors MUST use the capacity/length in-out protocol that
hurray_descriptor_shapeuses:*out_lencarries the caller's capacity in and the true length out, and a null or insufficient buffer returnsHURRAY_ERR_BUFFER_TOO_SMALLwith the true length written. - An absent optional field MUST return
HURRAY_ERR_FIELD_NOT_PRESENTand MUST NOT write to the output: absence is a state this format expresses through a cleared flag bit, not a value, and reporting it as a zero would be indistinguishable from data. This covers an absent section, an unset optional field, and a symmetric quantization scheme's zero-point buffer index. - A statistics field whose guard bit in
computed_maskis clear MUST returnHURRAY_ERR_FIELD_NOT_PRESENT. The statistics fields are physically present whatever the mask says, so an implementation that returned the stored bytes would let a caller read an uncomputed statistic by omitting the mask check the spec requires.0x0003_0000MUST read the raw mask, for a caller that branches on it directly. - A field identifier that does not belong to the descriptor's layout or quantization
scheme MUST return
HURRAY_ERR_FIELD_NOT_APPLICABLE. An identifier outside any allocated range, or passed to an accessor of the wrong type, MUST return the same code. - Every accessor takes a
level, which selects the tile to read in a nested tiled layout.levelMUST be0for every layout that does not nest and for every section field, and a non-zero value MUST be rejected withHURRAY_ERR_INDEX_OUT_OF_BOUNDSrather than ignored.
Streaming Over a Transport Vtable
A reader and a writer are blocking and take their bytes from callbacks the caller supplies at construction. The implementation MUST NOT open a path or own a file descriptor on the caller's behalf, and MUST NOT link an async runtime into the C library: a vtable composes with a socket, a pipe, a memory span, or a caller's own object, and file descriptors are not portable across the platforms this ABI targets.
typedef HurrayStatus (*HurrayTransportReadFn)(
void* ctx, uint8_t* dst, size_t len, size_t* out_filled);
typedef HurrayStatus (*HurrayTransportWriteFn)(
void* ctx, const uint8_t* src, size_t len, size_t* out_written);
typedef void (*HurrayTransportReleaseFn)(void* ctx);
typedef HurrayStatus (*HurrayTransportReadAtFn)(
void* ctx, uint64_t offset, uint8_t* dst, size_t len, size_t* out_filled);
typedef HurrayStatus (*HurrayTransportSizeFn)(void* ctx, uint64_t* out_size);
typedef struct HurrayTransport {
void* ctx;
HurrayTransportReadFn read; /* required by a stream reader */
HurrayTransportWriteFn write; /* required by any writer */
HurrayTransportReleaseFn release; /* optional */
HurrayTransportReadAtFn read_at; /* required by a file reader */
HurrayTransportSizeFn size; /* required by a file reader */
} HurrayTransport;
One struct serves both surfaces, and a caller populates only the callbacks its transport
has. read_at and size are appended rather than grouped beside read so that a caller
using positional initialisation gets null for them instead of a release callback where a
positional read is expected.
The struct is copied at construction; ctx MUST stay valid until the owning handle is
destroyed, at which point release is invoked exactly once.
Transport Rules
These are ABI surface, and none of them may be left to inference.
- Partial transfers are legal. A callback MUST report the number of bytes it moved. Fewer than requested is a short transfer: not an error, and not end of input.
- Zero means different things in each direction. A read reporting
0withHURRAY_OKis end of input. A write reporting0withHURRAY_OKis not completion — it means the sink will accept nothing more, and is reported asHURRAY_ERR_SINK_CLOSEDwhen an item is in flight. - EOF, error, and closed are three distinct statuses, never one.
- A failing callback MUST have moved no bytes and MUST write
0through its count out-parameter. A transport that partially succeeds and then fails MUST report the partial count withHURRAY_OKand surface the failure on the next call — what POSIXreadandwritealready do. This rule is what makes a failed call recoverable: the handle's stream position still matches the transport's, so the call MAY be retried and will resume rather than restart. - A range request is absolute.
read_atMUST read from the offset it is given and MUST NOT rely on, or be relied upon to advance, any cursor of its own; a short range read is re-requested at the offset that follows. Because the request carries its own position, a failedread_atis always retryable — re-issuing it reads the same bytes. sizeis read once, when a file reader is created. A container is located from its end, so its size is a precondition of opening it and never something a read discovers:read_atreporting0means the file is shorter than its own structure claims, which isHURRAY_ERR_UNEXPECTED_EOFand not an end-of-input signal.- No reentrancy. A callback MUST NOT call any Hurray function on the handle that invoked it.
- The context pointer belongs to the caller. The implementation MUST NOT free it;
releaseis the only teardown hook. - One handle, one thread at a time. The absence of an internal runtime makes this a rule rather than a threading model.
Reader and Writer Contracts
| Function | Contract |
|---|---|
hurray_reader_new(transport, options, out_reader) | transport->read MUST be non-null. options MAY be NULL for the documented defaults. |
hurray_reader_next_tensor(reader, out_tensor) | Returns HURRAY_OK with a new HurrayTensor*, HURRAY_END_OF_STREAM at a clean tensor boundary, HURRAY_ERR_UNEXPECTED_EOF if input ended inside a descriptor or buffer, or the read callback's own status. A composite head arrives as the buffer-less tensor it is on the wire. |
hurray_reader_next_item(reader, out_item) | The same bytes, assembled: returns a new HurrayItem* holding one tensor or one whole composite. HURRAY_END_OF_STREAM only at an item boundary — input that ends with a head still owed members is HURRAY_ERR_INVALID_STREAM, not a clean end. See Composites. |
hurray_reader_destroy(&reader) | Destroys the reader, releases the transport context, writes NULL through the argument. Tensors already produced are unaffected. |
hurray_writer_new(transport, options, out_writer) | transport->write MUST be non-null. options MAY be NULL. |
hurray_writer_write_tensor(writer, descriptor, payloads, count) | payloads MUST have one HurrayIoSlice per buffer in the descriptor's buffer table, in that order, each of the declared byte_size. The tensor is validated before any byte reaches the sink. A composite head is refused: a head alone is not a tensor. |
hurray_writer_write_composite(writer, nodes, count) | One composite, as the preorder node array of Composites. nodes[0] MUST be a head. The whole tree is validated before any byte reaches the sink. |
hurray_writer_destroy(&writer) | Destroys the writer and releases the transport context. There is nothing to flush. |
A writer whose sink failed after the first byte of a tensor MUST be poisoned: a truncated item is on the wire, a stream cannot be un-sent, and a fresh writer over the same connection is not a recovery. A writer whose sink failed before the first byte MUST stay usable.
Readers MUST allocate every buffer at the alignment its buffer handle declares, and the transport MUST write into that allocation directly: a consumer is entitled to rely on the declared alignment for SIMD loads, and no copy of a payload may happen after the transport writes it.
A read produces the handle of Tensor Handle: a HurrayTensor owning
its descriptor and its buffers, reporting this library's HURRAY_C_ABI_VERSION and
carrying no owner. hurray_tensor_descriptor and hurray_tensor_buffers hand back
borrowed handles, valid until hurray_tensor_destroy, which releases both halves in
one call so the teardown order is not the caller's problem.
Composites
A composite is a tensor or a composite head with members, and C has no sum type to receive that in. It therefore crosses this boundary as the sequence the format itself carries: a head followed by its members, in preorder, with every node declaring how many of the nodes after it are its direct members. Nesting is in that one column, and the array is structurally acyclic — a pointer tree crossing an ABI is not.
Reads produce an opaque HurrayItem; writes consume an array of HurrayCompositeNode.
Both use the same two kind tags, which are not wire values: the wire has no
tensor-or-composite tag, it has a composite layout. The tags exist so that walking an
item needs no descriptor decode, and because a head with member_count == 0 — which the
layout permits — would otherwise be indistinguishable from a tensor.
#define HURRAY_ITEM_TENSOR 0
#define HURRAY_ITEM_COMPOSITE 1
typedef struct HurrayItemNode { /* read: borrowed from the HurrayItem */
uint8_t kind;
const uint8_t* name; /* NULL on a stream; UTF-8, no NUL, in a file */
size_t name_len;
HurrayTensor* tensor; /* borrowed; a head's buffer list is empty */
uint32_t member_count;
} HurrayItemNode;
typedef struct HurrayCompositeNode { /* write: borrowed from the caller */
uint8_t kind;
const uint8_t* name; /* required in a file; ignored on a stream */
size_t name_len;
const HurrayDescriptor* descriptor;
const HurrayIoSlice* payloads; /* ignored for a head: it owns no data */
size_t payload_count;
uint32_t member_count;
} HurrayCompositeNode;
| Function | Contract |
|---|---|
hurray_item_kind(item, out_kind) | The kind of node 0, for a caller that only needs to know whether a composite arrived. |
hurray_item_node_count(item, out_count) | Total nodes, root and every nested member included. 1 for a plain tensor. |
hurray_item_node(item, index, out_node) | Copies node index out in preorder. Its pointers borrow from the item. HURRAY_ERR_INDEX_OUT_OF_BOUNDS past the end. |
hurray_item_destroy(&item) | Destroys the item, every tensor and name it owns, and writes NULL. |
Implementations MUST order the nodes so that a node's direct members begin at the next
index and each is followed immediately by its own subtree, and MUST set member_count from
the members actually present — a count that disagrees with its head is rejected by
validation, not reported here.
A node's tensor is borrowed: it MUST NOT be passed to hurray_tensor_destroy, which
hurray_item_destroy does for every node in one call.
A write MUST refuse, before any byte reaches the sink:
- a
countof0, or anodes[0]that is not a head —HURRAY_ERR_NOT_COMPOSITE, the caller asking for a composite where there is none; - member counts that do not describe the array, whether a head declares members the array
does not carry or nodes trail after the root's subtree completes —
HURRAY_ERR_INDEX_OUT_OF_BOUNDS, a count that does not fit what it indexes; - a tree that is not a valid composite at any level, member count, partition exact-cover
and overlay base-first ordering included —
HURRAY_ERR_INVALID_LAYOUT, the composite layout's contract with its members being what was broken.
A stream composite is one item: the failure contract above therefore applies to the whole group, and a sink that stops accepting bytes part-way through it poisons the writer like any other torn item.
File Containers Over a Transport Vtable
A HRRYFILE container is addressed, not consumed: its trailer sits at size - 40,
its footer index is located from there, and a tensor is read by name without the bytes
between being touched. A file reader therefore asks for absolute ranges through read_at
and learns the file's length through size, while a file writer emits one forward pass
through write alone and never seeks back.
Reader and Writer Contracts
| Function | Contract |
|---|---|
hurray_file_reader_new(transport, out_reader) | transport->read_at and transport->size MUST both be non-null. Reads the header, trailer, index and KV section. A failed open is terminal: there is no index to start from, so no handle is produced and the transport context is released. |
hurray_file_reader_tensor_count(reader, out_count) | Number of footer-index entries. Every tensor is individually addressable, composite heads and members included. |
hurray_file_reader_tensor_name(reader, index, out_name, out_len) | Borrows entry index's name as UTF-8 bytes, not NUL-terminated, valid until the reader is destroyed. HURRAY_ERR_INDEX_OUT_OF_BOUNDS past the end. |
hurray_file_reader_read_descriptor(reader, name, name_len, out_descriptor) | Decodes one descriptor and reads no buffer data. The descriptor handle is owned by the caller. |
hurray_file_reader_read_tensor(reader, name, name_len, out_tensor) | Reads a descriptor and every buffer, as an owned HurrayTensor*. HURRAY_ERR_NOT_FOUND if the name is not in the index. A composite head reads back as the buffer-less tensor it is in the file. |
hurray_file_reader_read_composite(reader, name, name_len, out_item) | Reads the composite headed by name — its members and theirs — as an owned HurrayItem* (Composites). HURRAY_ERR_NOT_FOUND for a name not in the index; HURRAY_ERR_NOT_COMPOSITE for a name whose descriptor is not a head, refused at descriptor time before any data region is read. |
hurray_file_reader_kv_count(reader, out_count) / hurray_file_reader_kv_entry(reader, index, out_entry) | The file-level KV section, in file order. An entry's pointers borrow from the reader. |
hurray_file_reader_destroy(&reader) | Destroys the reader, releases the transport context, writes NULL. Tensors already produced are unaffected; borrowed name and KV pointers are not. |
hurray_file_writer_new(transport, options, out_writer) | transport->write MUST be non-null. Writes the 64-byte header during the call. options MAY be NULL for 4096-byte data alignment and an index in write order. |
hurray_file_writer_write_tensor(writer, name, name_len, descriptor, payloads, count) | As the stream writer's, plus the name that becomes the footer-index entry: non-empty, at most 65 535 bytes, unique within the file. |
hurray_file_writer_write_composite(writer, nodes, count) | One composite, as the same preorder node array, written head first then each member in order. Every node's name is REQUIRED: a reader recovers membership from the head's member_count and the file order of the entries after it, so each tensor needs an entry to be found by. |
hurray_file_writer_finish(writer, kv, kv_count) | Writes the KV section, the footer index and the trailer. kv MAY be NULL with a count of 0. Until this returns HURRAY_OK the bytes are not a container. Calling it twice after it returned HURRAY_OK MUST fail; a footer that failed before its first byte MAY be retried. |
hurray_file_writer_destroy(&writer) | Destroys the writer and releases the transport context. Any flush or close belongs to the transport's release callback. |
The file writer's failure contract is the stream writer's. The rule stated above for
hurray_writer_write_tensor is normative for both surfaces: a write that fails after
the first byte of its item MUST poison the writer, and one that fails before it MUST
leave the writer usable, the item's name unconsumed, and the file completable. The status
distinguishes the two — HURRAY_ERR_SINK_CLOSED after, the callback's own status before.
hurray_file_writer_finish is an item under the same rule, which makes it the one retry
worth having here: the data is already on the sink and only the index is missing, so a
footer that failed before its first byte MAY be written again.
KV Metadata
One tagged value type serves reads and writes, so a KV section read from one file can be handed straight to another file's writer without being transcribed:
#define HURRAY_KV_STRING 0x01
#define HURRAY_KV_INT64 0x02
#define HURRAY_KV_UINT64 0x03
#define HURRAY_KV_FLOAT64 0x04
#define HURRAY_KV_BOOL 0x05
#define HURRAY_KV_BYTES 0x06
#define HURRAY_KV_ARRAY 0x07
typedef struct HurrayKvValue {
uint8_t kind;
bool bool_value;
int64_t i64_value;
uint64_t u64_value;
double f64_value;
const uint8_t* data; /* STRING (UTF-8, no NUL) or BYTES */
size_t data_len;
const struct HurrayKvValue* items; /* ARRAY */
size_t item_count;
} HurrayKvValue;
typedef struct HurrayKvEntry {
const uint8_t* key; /* UTF-8, non-empty, no NUL terminator */
size_t key_len;
HurrayKvValue value;
} HurrayKvEntry;
The kind values are the KV section's own wire tags, not a second numbering. Fields the
tag does not select MUST be zero on a read and are ignored on a write. An array's elements
MUST be scalars — the format forbids an array of arrays, and a nested one MUST be refused
with HURRAY_ERR_INVALID_FILE rather than flattened.
File Composites
The node array of Composites, with the names filled in. One difference from a stream matters and it is not cosmetic: a file composite is several items, one index entry per tensor, not one item.
So a group that begins and does not finish leaves a head with fewer members after it than
it declares, which file-format.md § Composite Tensors calls invalid, and no footer can
rescue it. Implementations MUST therefore poison the writer when a composite group begins
and does not complete — including when the defect is a member's name rather than a
transport failure, since the head is already on the sink by then. Only a failure on the
head itself, the first node, leaves the writer usable and the file completable.
Panic Safety
Rust panics MUST NOT propagate across the FFI boundary. Every extern "C" function
that calls Rust code MUST wrap the call in std::panic::catch_unwind. If a panic is
caught, the function MUST:
- Log or store the panic message (implementation-defined).
- Return a well-defined error code (e.g.,
HURRAY_ERR_INTERNAL_PANIC). - Leave no partially-constructed state visible to the caller.
Error Handling
All fallible FFI functions MUST return an error code of type HurrayStatus
(int32). The value 0 (HURRAY_OK) indicates success. All other values indicate
errors.
typedef int32_t HurrayStatus;
#define HURRAY_OK 0
#define HURRAY_ERR_INVALID_MAGIC -1
#define HURRAY_ERR_VERSION_MISMATCH -2
#define HURRAY_ERR_INVALID_LAYOUT -3
#define HURRAY_ERR_INVALID_TYPE -4
#define HURRAY_ERR_BUFFER_TOO_SMALL -5
#define HURRAY_ERR_NULL_POINTER -6
#define HURRAY_ERR_INTERNAL_PANIC -7
#define HURRAY_ERR_INVALID_SYNC_MODE -8
#define HURRAY_ERR_SYNC_MODE_MISMATCH -9
#define HURRAY_ERR_INTERNAL -10
#define HURRAY_ERR_INDEX_OUT_OF_BOUNDS -11
#define HURRAY_ERR_FIELD_NOT_PRESENT -12
#define HURRAY_ERR_FIELD_NOT_APPLICABLE -13
#define HURRAY_ERR_TRANSPORT -14
#define HURRAY_ERR_SINK_CLOSED -15
#define HURRAY_ERR_UNEXPECTED_EOF -16
#define HURRAY_ERR_CORE_UNUSABLE -17
#define HURRAY_ERR_LIMIT_EXCEEDED -18
#define HURRAY_ERR_INVALID_STREAM -19
#define HURRAY_ERR_NOT_FOUND -20
#define HURRAY_ERR_INVALID_FILE -21
#define HURRAY_ERR_NOT_COMPOSITE -22
#define HURRAY_END_OF_STREAM 1
Functions MUST return HURRAY_ERR_NULL_POINTER for any required pointer argument
that is NULL, without invoking undefined behaviour.
Positive status values report a normal outcome that is not plain success.
HURRAY_END_OF_STREAM is the only one defined: a stream ending is not a failure, so
consumers MUST test for errors with status < 0 rather than status != HURRAY_OK.
Buffer Release Callbacks
Buffer handles carry a release callback to support zero-copy buffer sharing with
non-Rust runtimes. When hurray-ffi wraps an externally-owned buffer, the caller
provides a release function and a context pointer:
typedef void (*HurrayReleaseCallback)(void* buffer, void* context);
HurrayStatus hurray_buffer_from_ptr(
void* data,
uint64_t byte_size,
uint32_t alignment,
uint8_t device_tag,
HurrayReleaseCallback release,
void* release_context,
HurrayBuffer** out_handle
);
The release callback MUST be called exactly once when the buffer's reference count reaches zero. The implementation MUST NOT call the release callback from a destructor that runs on a foreign thread without the caller's consent.
Thread Safety
- All handles MUST be safe to use from a single thread at a time (i.e.,
Sendbut notSyncin Rust terms). - Concurrent access to the same handle from multiple threads is undefined behaviour unless documented otherwise.
- Reference counting for shared buffer handles MUST be performed with atomic
operations (
std::sync::atomic).
Naming Conventions
All public symbols MUST be prefixed with hurray_. Type names use Hurray prefix
with PascalCase. Error codes use HURRAY_ERR_ prefix with SCREAMING_SNAKE_CASE.
Header Generation
A C header file (hurray.h) MUST be generated from the Rust source using cbindgen
as part of the build process. The generated header MUST be checked into the repository
and kept in sync with the Rust source. CI MUST fail if the generated header differs
from the committed one.
Buffer Handoff Synchronisation
The buffer protocol's sync_mode field (see docs/spec/buffer-protocol.md
§ Stream and Event Synchronisation) declares one of three producer-side
synchronisation mechanisms in the binary descriptor. The C ABI carries the
corresponding payload out of band; the discriminant itself is read from the
buffer handle's binary descriptor and is NOT duplicated in the ABI struct.
The ABI layer MUST cross-check the sync_mode declared in the descriptor
against the payload provided at handoff time. A mismatch is a producer-side
bug; the ABI MUST reject the handoff with HURRAY_ERR_SYNC_MODE_MISMATCH
before returning a buffer handle to the consumer.
Per-Mode Payloads
sync_mode = SYNC_PRODUCER_SYNCED (0x00)
No additional fields beyond the buffer pointer, byte size, alignment, device tag, release callback, and release context defined in Buffer Release Callbacks.
The producer MUST have issued a host-side wait on the device stream(s) that
wrote the buffer before calling the handoff function. For CPU buffers
(device_tag == 0x00), the producer MUST have issued a host memory fence if
concurrent host-side writes exist.
sync_mode = SYNC_EVENT (0x01)
The handoff struct carries an opaque event handle and an event-release callback in addition to the buffer fields:
| Field | Type | Description |
|---|---|---|
sync_handle | opaque pointer (void*) | Device-vendor-specific event handle (e.g., cudaEvent_t, hipEvent_t, MTLSharedEvent, VkSemaphore). Opaque to the ABI layer. |
sync_handle_device_tag | uint8 | Device tag identifying the event handle's driver context. MUST equal the buffer's device_tag. The ABI MUST reject the handoff if it does not. |
event_release_fn | void (*)(void* sync_handle, void* context) | Callback the consumer calls exactly once after issuing its stream-wait. Thread-safe. |
event_release_context | opaque pointer (void*) | Context pointer passed to event_release_fn. |
The producer MUST record the event on the writing stream(s) before calling the handoff function. The producer MUST NOT defer event recording past the handoff; doing so would allow the consumer to issue a stream-wait on an unrecorded event and deadlock.
The consumer MUST issue a device-stream-wait on sync_handle on every stream
that will access the buffer before enqueuing any work that touches the
buffer's bytes. The consumer MUST call event_release_fn(sync_handle, event_release_context) exactly once after all stream-waits have been issued
(typically immediately after handoff). event_release_fn MUST be safe to call
from any thread.
The event-release callback is separate from the buffer-release callback
defined in Buffer Release Callbacks: a consumer
in SYNC_EVENT mode makes two release calls per buffer, with independent
lifetimes.
sync_mode = SYNC_CONSUMER_STREAM (0x02)
At handoff request time, the consumer supplies an opaque stream handle to the producer:
| Field | Type | Description |
|---|---|---|
consumer_stream | opaque pointer (void*) | Device-vendor-specific stream handle (e.g., cudaStream_t, hipStream_t, id<MTLCommandQueue>). Opaque to the ABI layer. Supplied by the consumer at handoff request time. |
consumer_stream_device_tag | uint8 | Device tag identifying the stream handle's driver context. MUST equal the buffer's device_tag. The ABI MUST reject the handoff if it does not. |
The producer MUST issue a device-side ordering dependency from its writing
stream(s) onto consumer_stream before the handoff function returns a buffer
handle. The producer-side stream is not blocked; the ordering is established
on the device.
The consumer MAY access the buffer on consumer_stream after handoff returns,
but MUST NOT access the buffer on any other stream until it has issued an
inter-stream wait on that other stream.
SYNC_CONSUMER_STREAM does not introduce a second release callback: there is
no event handle whose lifetime must be managed.
ABI Cross-Check
For every buffer handed off through the C ABI, the implementation MUST:
- Read the
sync_modebyte at offset 13 of the buffer handle binary descriptor. - Verify that the payload provided by the producer at handoff time matches
the declared
sync_mode:SYNC_PRODUCER_SYNCEDMUST NOT carry async_handleorconsumer_stream.SYNC_EVENTMUST carry a non-NULLsync_handle, anevent_release_fn, and async_handle_device_tagequal to the buffer'sdevice_tag.SYNC_CONSUMER_STREAMMUST carry a non-NULLconsumer_streamand aconsumer_stream_device_tagequal to the buffer'sdevice_tag.
- Reject any reserved
sync_modevalue (0x03–0xFE) and the invalid value0xFFwithHURRAY_ERR_INVALID_SYNC_MODE. - Reject a mismatch between descriptor and payload with
HURRAY_ERR_SYNC_MODE_MISMATCH.
Backward Compatibility for Pre-Version-2 Consumers
A consumer compiled against C ABI version 1 that does not inspect sync_mode
will receive buffers whose sync_mode == SYNC_PRODUCER_SYNCED by default. This
is the safe fallback: the strongest synchronisation guarantee, no per-mode
payload required, and behaviour identical to the version-1 contract. A
producer that wishes to interoperate with version-1 consumers MUST set
sync_mode = SYNC_PRODUCER_SYNCED for every buffer it hands off and MUST
issue the corresponding host-side wait before handoff.