This first foundation provides a synchronous, caller-driven connection API over LibMoQ's public service tier. It includes checked configuration, verified connection setup, wait/state/terminal observations, negotiated wire version, wake/interrupt, and explicit lifetime management, plus a synchronous media receiver (track events, owned media objects, a drain observation and an example receive loop) and a synchronous media sender: configuration and owned attachment, declaring and removing tracks, writing media objects, ending a track, an asynchronous completion request, statistics, subscriber-demand queries, a bounded wait for the write level, and an example publish loop. It is not a full Python media binding: asyncio, real-media acceptance, CMAF fragments and a decoder are not part of it, and neither is a graceful finish or a drain. Python is not finished by this landing. This Python foundation lands first; the Android bindings will be integrated above its commit.
Normal, GIL-enabled CPython 3.12 is the first tested floor. The private extension
targets cp312-abi3; that ABI target alone does not qualify every later Python
runtime or LibMoQ SDK. No guarantees are made for PyPy, subinterpreters,
free-threaded CPython, or Windows in this foundation.
This is an external-SDK source build. Install a compatible LibMoQ service SDK
and its selected backend dependencies first. CMake must be able to resolve
find_package(libmoq CONFIG REQUIRED COMPONENTS service) and moq::service
from that installation, for example through CMAKE_PREFIX_PATH. Build the
Python package with the packaging files in this directory using CPython 3.12
or later. The installed native libraries must also be available to the runtime
loader. Backends are not bundled, and this landing makes no PyPI publication,
self-contained wheel, or general platform support claim.
From the repository root, with the service SDK already installed:
python3.12 -m venv bindings/python/.venv
bindings/python/.venv/bin/python -m pip install ./bindings/python \
-C cmake.define.CMAKE_PREFIX_PATH=/path/to/installed-sdkSelect the SDK and backend deliberately; this command does not download native transport dependencies. A shared SDK also needs the normal platform loader configuration, independently of CMake's package search path.
moq5.build_info() returns a fresh dictionary containing compiled_version,
runtime_version, python_abi, and test_backend. The version strings identify
the compiled and runtime LibMoQ VERSION values; the other fields identify the
Python ABI target and use of the test fixture. This is not full build provenance
or capability metadata and does not enumerate backends or native dependencies.
These VERSION values are separate from a connection's negotiated MoQ wire
version. A test-backend build is not real transport acceptance.
LibMoQ is licensed under Apache-2.0; see LICENSE.txt.
The Python workflow defines Linux/macOS checks with static and shared service SDKs built without transport adapters, SDK and binding warnings as errors, and Debug native-bridge ownership/configuration tests using a fake C service provider. Those tests do not qualify live handshakes or TLS. The workflow also installs the wheel, sdist, and a plain source archive into separate temporary venvs and runs the installed smoke against the real SDK outside the source tree. This workflow is not a hosted-CI acceptance claim until those jobs run. It does not publish artifacts or qualify media transport.
For an already installed artifact, run the smoke from outside the checkout:
/path/to/fresh-venv/bin/python -I -W error /path/to/installed_smoke.py \
--artifact /path/to/moq5-0.1.0.dev0-cp312-abi3-linux_x86_64.whl \
--expected-package-version 0.1.0.dev0 --expected-sdk-version 0.1.0Use the exact local archive passed to pip for --artifact; the smoke checks
its recorded SHA-256, distribution version, installed module paths and hashes,
ABI tag, typed marker, and absence of the private fixture. SDK VERSION strings
must match the explicit expectation. Linux needs readelf; macOS needs otool
for the installed module's loader-path check. A shared SDK may need a
test-process LD_LIBRARY_PATH (Linux) or DYLD_LIBRARY_PATH (macOS) pointing to
its installed library directory; the workflow does not bake that path into
the artifacts.
The default smoke expects an SDK without adapters and checks real C INVAL
for an invalid URL and UNSUPPORTED for valid QUIC and WebTransport URLs. When
using an external SDK with adapters, pass --skip-backendless-checks to omit
only the valid-URL attempts. This does not claim transport acceptance. Terminal
raw-bit and type checks exercise Python values; a backend-free refusal cannot
produce a live endpoint terminal snapshot.
The installed-package example observes establishment with a bounded overall deadline and then closes:
python bindings/python/examples/connect.py moqt://relay.example:4433 \
--ca-file /path/to/trusted-ca.pemhttps://relay.example/moq selects WebTransport with the default protocol
selection. The example accepts --timeout-us for its overall observation
budget; this is separate from the transport-only handshake configuration.
It reports terminal reasons and raw detail codes without treating failures as
clean EOF.
from moq5 import Endpoint, EndpointConfig, WaitResult
config = EndpointConfig(url="moqt://relay.example:4433", ca_file="trusted-ca.pem")
with Endpoint.connect(config) as endpoint:
result = endpoint.wait(timeout_us=1_000_000)
print(endpoint.state, endpoint.negotiated_version)
if result is WaitResult.CLOSED:
print(endpoint.terminal)Endpoint.connect(config) invokes native connect immediately and returns after
creation, while the transport handshake and MoQ establishment continue
asynchronously. It does not wait for readiness. Direct Endpoint() construction
is refused. Applications import only moq5; native handles stay private.
EndpointConfig is a frozen, keyword-only dataclass. url is required; the
defaults are Protocol.AUTO, Backend.AUTO, an empty version offer,
WtProfile.BACKEND_DEFAULT, and handshake_timeout_us=0. Optional sni,
ca_file, and wt_path default to None; None and empty strings both select
the native default. Text must be a built-in str, must encode as UTF-8, and
must not contain NUL. URL syntax remains the C service's authority.
versions accepts a tuple or list of integers and stores a checked immutable
tuple in preference order. An empty tuple means AUTO. Each offered version
must be in 1..2**32-1; Version.DRAFT_16 and Version.DRAFT_18 are named
values, not a promise of compiled support. Unknown nonzero offers reach native
validation without being silently downgraded. Protocol, backend, and WT profile
fields accept their enum members or known integer values. Their numbers, all
named wire versions, states, terminal reasons, and wait results come from C
constants exposed by the compiled bridge.
Native offer-count and support limits still govern validation: the Python
layer checks each offer's range and type, and the native service refuses an
offer list it cannot carry (INVAL) or a version it does not support, before
any transport is selected.
handshake_timeout_us accepts 0..2**63-1, where zero leaves the backend's
default. A nonzero value bounds only the QUIC/TLS handshake, not WebTransport
CONNECT or MoQ SETUP, and an unsupported backend rejects it. wait(timeout_us)
takes 0..2**63-1 integer microseconds; zero polls. Both durations use the
finite INT64_MAX ceiling to avoid overflow in native deadline additions;
there is no infinite-wait sentinel. The example's --timeout-us uses the same
range for its observation budget. Numeric fields reject bools, floats,
coercible non-integers, negative durations, and overflow. Wrong types raise
TypeError; out-of-range values and unknown configuration enums raise
ValueError before native effects.
| Native wait result | Python result | Meaning |
|---|---|---|
MOQ_OK (0) |
WaitResult.WOKEN |
Coalesced activity; re-read state |
MOQ_DONE (1) |
WaitResult.TIMED_OUT |
This wait's timeout elapsed |
MOQ_ERR_INTERRUPTED (-13) |
WaitResult.INTERRUPTED |
Sticky interrupt is set |
MOQ_ERR_CLOSED (-4) |
WaitResult.CLOSED |
Connection is terminal; inspect reason |
A public wait is one monotonic deadline served by native wait requests of
at most 250 ms each, and every valid call makes at least one native
observation: an already-expired budget is served by one zero poll, so a
closed, foreign-thread or fork-inherited handle is always refused and an
already-present WOKEN/INTERRUPTED/CLOSED or native failure is never hidden
behind a timeout. A native wait is not interruptible by a signal; Python
signal handlers run on the main thread only between native calls, so a
pending KeyboardInterrupt there is honoured within one slice, without
anyone waking or interrupting the endpoint. The 250 ms figure caps each native
wait request; it is not a wall-clock or arbitrary-thread delivery guarantee.
A signal pending when a native wait returns takes precedence over that wait's
result. A timed-out slice neither shortens nor restarts the budget, and the
final slice is the exact remainder, rounded up.
Compare enum members explicitly: WOKEN has integer value zero and is false
in a truth test. A timed-out wait does not itself make the connection terminal.
Unexpected native failures raise MoqError with the exact signed .code and
.operation preserved, and retain the native exception as their __cause__.
Errors never become EOF or a successful wait.
endpoint.state returns an EndpointState for known values, otherwise the
unchanged integer. endpoint.terminal returns an immutable
Terminal(reason, detail_code) snapshot; known reasons are TerminalReason
members and unknown reasons remain integers. Only TerminalReason.CLEAN
establishes a clean close; NONE and unknown reasons are not evidence of
success. detail_code retains every unsigned 64-bit raw bit, including
2**64-1; transport failures can have detail zero. Some backends encode signed
native errors in these bits. endpoint.negotiated_version is an integer, zero
before establishment, and preserves unknown future version numbers.
TLS chain and server-name verification stay enabled. There is no insecure
verification option or Python ssl.SSLContext mapping. Native backend defaults
remain authoritative: a picoquic mbedTLS-only SDK requires an explicit PEM
ca_file, while backends without CA-file or distinct-SNI support reject those
configurations. Explicit protocol selection overrides a recognized URL scheme;
it does not make an otherwise invalid URL valid. Unsupported backend, version,
profile, or TLS choices fail explicitly through the native service.
Receiver.attach(endpoint, ReceiverConfig(...)) attaches a media receiver to
a live endpoint on the calling thread and retains that endpoint until
Receiver.close(): the endpoint's own close() is refused by the service
(MoqError, WRONG_STATE) while a receiver is attached. ReceiverConfig
requires an OverflowPolicy; namespace parts and the catalog track name are
binary and copied byte-exact. wait(), stats(), terminal, the track
commands and poll_track() require the owner thread; only closed may be
read elsewhere. To cancel a blocked receiver wait from another thread use
receiver.endpoint.set_interrupted(True) or wake().
poll_track() returns one TrackEvent, or PollOutcome.EMPTY (nothing
now) or PollOutcome.CLOSED (the event queue is empty AND the receiver is
terminal -- not an aggregate end of stream). It never consults the interrupt
latch. TrackEvent.track is an opaque Track whose identity is the native
handle: the same handle yields the same Track while any reference exists,
and two tracks with identical descriptions are still distinct.
CATALOG_READY carries no track or description.
TrackEvent.description and Track.description are owned Python copies:
every span and array is bytes, nothing borrows native memory, and a copy a
caller retains is never mutated and survives close(). The description is
read through the service's sized current-description copy under the receiver
mutex at poll time, so description.vod (VodState.is_live,
track_duration_ms) is the track's CURRENT live/VOD state, not the state at
the moment the event was queued. Optional values the catalog did not carry,
or that the native stamped prefixes (event, description, nested info and
init) do not cover, are None, never zero; unknown enum values are kept as
integers. TrackEvent.largest / expires_ms are UPDATE_OK observations,
parse_drop a coalesced PARSE_DROP snapshot.
A native poll consumes the event. If the bridge cannot convert it,
EventLost (a BindingError) names the validated kind and the failing
stage and carries no address; nothing about the track or its cached
description changes, and the next poll proceeds. An allocation failure is
the bare MemoryError. MoqError is raised only for a signed result the
service actually returned.
poll_object() dequeues one MediaObject, or PollOutcome.INTERRUPTED
while the endpoint's sticky latch is set (checked before the queue and
before terminal), PollOutcome.CLOSED when the object queue is empty AND
the receiver is terminal, or PollOutcome.EMPTY. It makes exactly one
native poll and never waits; the owner-thread, same-process and liveness
checks run before anything is consumed. Track events and objects are two
queues: drain poll_track() before poll_object() on each wakeup.
A successful native dequeue transfers the object's buffers to the binding.
The bridge copies every byte and sample record into Python-owned values
(bytes, a tuple of CmafSample), releases the transferred buffers exactly
once on the polling thread, and only then returns. Nothing borrows native
memory: a MediaObject survives Receiver.close() and Endpoint.close(),
may be read from any thread, and is immutable (dataclasses.replace copies
it field for field). RAW media is payload with fragment empty; CMAF media
is the whole fragment with the mdat slice at mdat_offset / mdat_len
and samples in the track's timescale ticks (payload empty). Widths and
signs are the C ones: unsigned 64-bit timestamps, a signed
composition_offset_us, unsigned 32-bit sample fields with a signed
composition_offset; capture_time_us is None when absent.
config_generation is carried as reported.
status decides is_status_only, never byte length: a zero-length NORMAL
object is media with payload == b""; END_OF_GROUP / END_OF_TRACK carry no
media. Unknown status and packaging values are preserved as integers
and their bytes are not interpreted; that is a contract for future values,
not a claim that the current native parser accepts them.
MediaObject.track is the same cached Track a track event yields for that
handle, whichever arrives first, and is described from the track's CURRENT
state at poll time (not the state when the object was queued). A new
Track, or a replaced Track.description, is published only after the
whole object converted.
A consumed object the bridge cannot convert raises ObjectLost (a
BindingError): stage names the failing field or bound (payload,
fragment, samples, bounds:mdat_offset, bounds:mdat_len, status,
struct_size, track, track.description), and presentation_time_us,
status, packaging are carried only when they were validated before that
stage. Its buffers were released exactly once; no address is carried; the
cache and any prior description are untouched; the next poll proceeds. An
allocation failure during conversion is the bare MemoryError -- the object
was consumed and no per-object metadata is promised. MoqError is raised
only for a signed result the service actually returned.
Receiver.drained() is an observation over the two streams this binding
exposes: it is true only when the most recent poll_track() AND the most
recent poll_object() each returned PollOutcome.CLOSED. It starts false,
never polls, waits, clears the interrupt latch, or infers anything from
terminal state, stats or queue counts. Any newer non-CLOSED outcome on a
stream (an item, EMPTY, INTERRUPTED, a native error, or a consumed-item
conversion failure) withdraws that stream's evidence while the other
stream's is kept. It requires the owner thread, process and a live
receiver; a refused caller changes nothing. Close is not drain. The native
SAP and media-timeline queues are not exposed here and are not covered.
examples/receive_loop.py is a handler-driven loop over this surface: it
drains every track event and every media object through the same handlers,
subscribes cmaf/loc tracks as they are added, blocks on wait() only when
nothing is queued, and distinguishes transport drain, a requested
interruption, and finite broadcast completion. Completion is the loop's own
policy over what the facade observes (MSF-01 section 11.3): every wanted
track reached TRACK_ENDED (which includes rejection) and either every wanted
track's description became non-live WITH a track duration, or the catalog
latched isComplete with every wanted track removed. It is not proof of the
publisher's wire sequence. A finite receiver campaign against our own relay on
loopback has been run, with an independent C publisher and declared synthetic
RAW/LOC payloads; acceptance against real media has not been run.
The package requires a LibMoQ service SDK that declares
moq_media_receiver_track_desc_copy; building against an older SDK fails
at compile time rather than falling back to the borrowed descriptor.
Sender.attach(endpoint, SenderConfig(...)) attaches a media sender to a live
endpoint and retains it, exactly as the receiver does: sender.endpoint is ep,
and the endpoint's own close() is refused by the service (MoqError,
WRONG_STATE) while a sender is attached. Sender() is refused; attachment is
the only constructor. Every fallible Python step of the attachment completes
before the native call, so a successful native attachment is never stranded by
a later Python failure, and a refusal retains nothing.
SenderConfig is frozen and keyword-only. It copies its namespace parts and
catalog track name, keeps the native field widths, and leaves every unset field
at the service default. Backpressure is the closed set of the four native
policies; UNSET is refused. SenderConfig.live() and SenderConfig.lossless()
are presets over the same fields, and CATALOG_REFRESH_DISABLED is the native
sentinel that turns periodic catalog refresh off.
sender.ready means the namespace was accepted and the catalog published, so
the publish path can accept media. It does NOT mean anyone subscribed: demand
is a separate observation, exposed by subscriptions(), has_subscriber() and
has_media_subscriber() and described below. sender.terminal copies the
sender's closed/fatal/fatal_code plus the endpoint's terminal snapshot; fatal
includes an endpoint failure and fatal_code falls back to the endpoint's code
when the sender itself is not fatal. A native terminal is not closure:
sender.closed reports only that this owner released the sender.
Sender.close() is DESTRUCTIVE and idempotent. It detaches immediately and
discards whatever the service had queued. It is not a flush and it does not
complete a broadcast. Ownership follows the endpoint rules below: the attaching
thread owns every operation except closed, and a handle inherited across
fork is refused rather than used.
Tracks are described in the next section and writing media objects in the one after. There is no graceful finish or drain. A finite publishing run has been performed on loopback against our own relay -- eight declared synthetic RAW/LOC objects, observed exactly by a real receiver, with that track's terminal event seen -- which is receipt evidence for that run, not a delivery, flush or completion guarantee.
Sender.add_track(SendTrackConfig(...)) declares a track and returns a
SendTrack; Sender.remove_track(track) removes it. Both preserve the
service's signed result codes. The one special case is a retryable add: while a
just-removed name, or a generated <name>.sap or <name>.timeline sibling, is
still held by a teardown that has not finished, the service returns
WOULD_BLOCK and the binding raises TrackNameBusy, which is a MoqError.
Retry later.
SendTrackConfig is frozen and keyword-only. It requires name,
media_type, packaging, codec and bitrate, and accepts timescale,
init_data, role, lang, is_live, width, height, framerate_millis,
samplerate, channel_config and track_duration_ms. Every span is bytes and
is kept byte-exact, embedded NULs included; nothing is sniffed or parsed. A
timescale of 0 selects the service default. MSF-01 requires a codec and a
maximum bitrate for audio and video, and a sample rate and channel
configuration for audio; a live track must not declare a duration. Those rules
are mirrored here, so a malformed track is refused before the service sees it.
The NATIVE sender owns its tracks until it is destroyed. Dropping a SendTrack
removes nothing and ends nothing: there is no per-track native destructor and
no implicit removal on collection. A live track keeps its sender, and through
it the endpoint, alive. A removed track keeps a valid but inert handle until
the sender is destroyed, so its name, sender and removed stay readable.
removed reports removals made through this binding -- this track's own
remove_track, and an accepted request_complete(), which the service
answers by marking every track the sender then had removed.
Removing twice is refused with WRONG_STATE. Sender.close() remains
destructive and is not a flush or a completion barrier.
SendTrack is returned by add_track and cannot be constructed directly. Its
identity is its own: two tracks are distinct keys, and hash and equality do not
change when a track is removed or when its sender is closed. There is no track
registry.
Track CONFIGURATION fields that this binding does not expose are sent absent
by SendTrackConfig: the generated SAP and media timelines,
content-protection reference ids, the CMSF maximum SAP starting types and the
alternate group.
Sender METHODS not in the binding yet -- a different thing from an unexposed field -- are a graceful finish and a drain. Writing objects, ending a track, requesting completion, statistics and the demand queries are described in the sections below.
Sender.write(track, SendObject(...)) -> WriteOutcome submits one object, in
decode order, with exactly one synchronous native call per invocation.
SendObject is frozen, slotted and keyword-only. payload is required and is
immutable bytes; b"" is a VALUE and is passed as a present zero-length
buffer, while omitting it is an error. properties is the CMAF property block,
passed through byte-exact: None is ABSENCE and b"" is a present empty
block, and the two are never collapsed -- the service requires properties to be
absent on a RAW track, where it owns the LOC block itself. is_sync,
starts_group and ends_group are strict bools. decode_time_us is advisory
in v0, because per track write order IS decode order. presentation_time_us
drives the generated timelines. capture_time_us and sap_type are optional:
None is absence and an explicit value is a declaration, so sap_type=SapType.NONE
declares "not a SAP", which is not the same as declaring nothing.
capture_time_us is the LOC Capture Timestamp (LOC-01 2.3.1.1) -- wall-clock
microseconds since the Unix epoch, emitted verbatim; its encodable range
follows the negotiated codec and is enforced by the service, not clamped here.
Spans are bytes only. bytearray, memoryview, str and int are each
refused, so nothing mutable is held across the call. Nothing is parsed,
sniffed or generated: no codec inference, no second LOC writer.
SapType offers NONE, TYPE_1, TYPE_2 and TYPE_3. The native internal
sentinel UNKNOWN is deliberately absent, because the service rejects it.
WriteOutcome carries the native codes: ACCEPTED (0), WOULD_BLOCK (-8),
INTERRUPTED (-13) and CLOSED (-4). ACCEPTED means accepted by the
service, not delivered: under a drop policy an accepted object may still be
evicted, and there is no receipt in this surface. WOULD_BLOCK is backpressure
-- a full queue under RETURN_WOULD_BLOCK, an expired BLOCK_TIMEOUT, a missing
sync anchor, or a GOP larger than the bound under a drop policy. INTERRUPTED
means the endpoint's interrupt latch is set; CLOSED means terminal. None of the
three took ownership of the object, and none is retried for you. Any other
native result is a MoqError keeping its signed code and the operation
"write".
Ownership is transfer-on-success. The binding copies each span into a refcounted buffer before the call; on ACCEPTED those buffers belong to the service, and on any other result they are released before a Python result or error is built. A failure to build the result cannot strand a buffer.
Signals. The GIL is released only around the native call. A signal
delivered while it is in flight reaches the caller after ownership has been
settled, so it can never strand a buffer -- but it does NOT certify that the
object was refused. If the service accepted the object, the transfer stands.
Do not retry blindly on KeyboardInterrupt: a second write is a NEW object.
stats() reports aggregate counters, not a per-write acknowledgment, so it
cannot tell you whether THAT object was accepted.
Guards are the same as everywhere else: the owning thread only, handles
inherited across fork refused, a closed sender refused, and a track that
belongs to another sender -- or a receiver Track -- refused before any native
effect. close() stays destructive: it discards whatever was queued.
Sender.end_track(track) -> WriteOutcome requests that one track be ended,
with exactly one synchronous native call per invocation.
ACCEPTED means the service accepted the REQUEST. After this track's queued
objects drain it emits a reliable END_OF_TRACK, which receivers see as a
track-ended event. It does NOT mean the terminal has been emitted, that prior
media was flushed, or that anything was delivered. WOULD_BLOCK means the send
queue was momentarily full and commits nothing -- retry it yourself, because
the binding never retries for you. INTERRUPTED and CLOSED commit nothing
either. Any other native result is a MoqError keeping its signed code and
the operation "end_track"; a REMOVED track is refused that way, since
removal is not ending.
Idempotence belongs to the SERVICE: this binding keeps no ended state, so a repeat is simply a second call, and the service answers OK without queuing a second terminal. Because it checks its interrupt and terminal conditions BEFORE that idempotent no-op, a repeat after the sender terminalizes is CLOSED, not success -- do not read "idempotent" as "always succeeds".
Ending removes nothing, destroys nothing and closes nothing. The track keeps
its handle, name and owner; removed stays False; other tracks are untouched;
and the broadcast and endpoint stay up. A later write on an ended track is
refused by the SERVICE with WRONG_STATE, not by a Python latch. This is not a
broadcast completion -- that is request_complete(), below -- and not a
graceful finish, which does not exist.
A signal delivered while the call is in flight reaches the caller after the native result is settled, so it does not undo a request the service already accepted.
Sender.request_complete() -> None asks the service to terminate the
broadcast permanently (MSF-01 section 11.3). It is a REQUEST: a successful
return means the service ACCEPTED it, and nothing more.
What happens synchronously is one thing only -- the service marks every track
this sender has as removed, so SendTrack.removed answers True immediately
afterwards. Everything a receiver would see happens later, on the service's
own network thread: each active track is ended reliably (END_OF_TRACK), and
then a terminal INDEPENDENT catalog generation carrying isComplete and an
empty track list is published to active subscribers and retained, so a later
joiner still learns the broadcast completed. A successful request is NOT
emitted, flushed, acknowledged or received completion.
The queued media of those removed tracks is discarded by the service as it tears them down, and counted there -- so statistics read around this call may move, because the service's own thread is running. An unchanged snapshot is not a promise, and there is no queue-empty completion predicate anywhere in this binding.
Completion is legal only once the broadcast is ready; the service answers a pre-ready request with WRONG_STATE. Idempotence belongs to the SERVICE, and this binding keeps no completed state, so a repeat is simply a second call that the service answers. Because it checks its interrupt latch and terminal condition BEFORE that idempotent no-op, a repeat after either is a refusal, not a courtesy success.
Every non-OK native result is a MoqError keeping its signed code and the
operation "request_complete". There is no retryable outcome here and
nothing is retried. A signal delivered while the call is in flight reaches
the caller after the native result is settled: it does not undo a request the
service already accepted, and the tracks still report the removal.
After an accepted request the SERVICE refuses add_track, write,
end_track and remove_track with WRONG_STATE; the handles stay valid but
inert, keeping their name and owner. The namespace and the endpoint stay up:
this is not close(). close() and leaving a with block remain
DESTRUCTIVE -- they detach and discard whatever was still queued -- and this
binding offers no flush, drain or graceful finish to pair with them.
examples/publish_loop.py is a caller-driven finite submission loop over the
sender surface:
record = publish(sender, plan, now_us, deadline_us,
WriteOutcome, WaitResult, MoqError, note_dropped,
wait_us=250_000, require_demand=False)
plan is a FINITE ordered sequence of (track, SendObject) pairs. The loop
holds the exact object it was given until that submission is ACCEPTED,
advances only then, and never submits an accepted object again; a
WOULD_BLOCK is retried after a wait, and INTERRUPTED, CLOSED or any other
error ends the attempt rather than looping. A KeyboardInterrupt on the way
out of an accepted write is ambiguous and is never resubmitted.
The CALLER owns the endpoint, the sender and the tracks on both sides of the
call. The loop never closes or destroys an owner, never adds or removes a
track, never requests completion, never drains, and never starts a thread or
a process -- close() discards queued work and an accepted completion
request removes every track, so either would turn accepted work into lost
work. Those remain your decision, after the record has been read.
deadline_us, read through the injected now_us, is a COOPERATIVE
submission budget: it bounds how long the loop keeps attempting and clips
every wait to min(wait_us, remaining). It cannot cancel a native call
already in progress or your own callback, so a BLOCK_TIMEOUT sender can spend
its whole block timeout inside ONE write, past the remaining budget. Use
RETURN_WOULD_BLOCK for a bounded run.
note_dropped receives the increment between OBSERVATIONS whenever the named
drop counters advance, and the final observation reports its remainder once;
the record's dropped aggregate and its stats come from that same final
snapshot. Accepted is not delivered, and no lossless guarantee follows from a
policy or from zero observed drops. require_demand is opt-in and finite,
and has_media_subscriber() is the service's AGGREGATE question, so an
unrelated subscribed track satisfies it; readiness is never queried, because
writing pre-ready is legal and bounded by the pre-ready caps.
The record carries stop ("submitted", "deadline", "interrupted" or
"closed"), submitted, attempts, pending, would_block, ended,
ends_refused, interrupted, dropped, terminal and stats. It is a
SUBMISSION report: stop == "submitted" means every planned object was
accepted and every end requested (accepted, or refused with the one named
track-local WRONG_STATE). It is not emission, flush, acknowledgment or
receipt; it says nothing about END_OF_TRACK reaching a peer or a catalog
being published; and it is not a completion predicate. The finite loopback run
described above used this example; it establishes what that run observed, not a
general guarantee.
Sender.wait(timeout_us) -> WaitResult blocks until this sender can accept
another write, the endpoint wakes, or the timeout elapses. It is sliced
exactly like Endpoint.wait and Receiver.wait, against ONE monotonic
deadline: bounded native slices, the remainder rounded up, and an exhausted or
zero budget still making exactly one observation. timeout_us is an integer
(not a bool) from 0 to INT64_MAX.
The service's priority applies unchanged, and this binding does not reorder
it: the interrupt latch wins, then terminal state, then the write level --
because write() refuses the first two, so reporting "write now" there would
be a lie.
WOKEN is advisory. It means "try another write now". It is also what an
endpoint wake reports even when the level does not hold, so it is NOT
subscriber demand, NOT a promise that the next write will be accepted, and not
completion, drain or flush -- another thread may fill the queue first, a
single object may exceed the byte budget, and a drop-policy sender still
reports per-object outcomes. TIMED_OUT means the budget elapsed with the
level not holding, INTERRUPTED means the endpoint's latch is set, and
CLOSED means the sender or endpoint is terminal. Any other native result is
a MoqError keeping its signed code and the operation "wait".
Waiting has no side effects: it does not clear the interrupt latch, write, end or remove a track, close anything, or change the drop policy. A live sender that is merely TERMINAL still answers with CLOSED; a closed owner is refused before the service is entered. The GIL is released only around the native call, and a signal delivered during it reaches the caller afterwards.
Three independent point-in-time queries:
Sender.subscriptions(track) -> int
Sender.has_subscriber(track) -> bool
Sender.has_media_subscriber() -> bool
subscriptions is the service's mirrored count for that exact track, as an
ordinary nonnegative int covering the whole native range -- nothing is
narrowed or clamped. has_subscriber is the service's own count > 0, asked
of the service rather than recomputed here. has_media_subscriber is the
"should I encode?" question: ONE native observation over app-visible media
tracks, never a sum over Python wrappers.
The internal catalog track, the generated SAP and media-timeline tracks and
removed tracks are excluded from has_media_subscriber by the service, so a
catalog-only subscription does not make it true. A generated track's exact
handle, if you hold one, still reports its own count.
Demand and readiness are separate: demand without ready, and ready without
demand, are both ordinary, and neither gates the other.
A REMOVED track reports what the service reports -- its mirrored count until teardown has drained it, then zero. The binding applies no removed latch and forces no immediate zero.
Nothing here is cached, and a count is not a promise about a later write: demand can change between the query and the call, and the write result is always the service's own answer. These are not events or callbacks, there is no subscription registry, and no query has any lifecycle effect.
Guards are the same as everywhere else: the owning thread only, handles
inherited across fork refused, a closed sender refused, and a track owned by
another sender -- or a receiver Track -- refused before any native effect. A
live sender that is merely TERMINAL still answers; the native getters return
zero for a foreign handle, but this API refuses one rather than answering.
Sender.stats() -> SenderStats copies a snapshot. Each call takes a NEW
native snapshot: nothing is cached, computed in Python, reset or repaired, and
no native pointer is retained. A live sender answers even when it is terminal
-- terminal is not closed -- while a closed owner is refused.
SenderStats is a frozen, slotted value with ten counters plus a signed
last_error: objects_written, objects_sent, objects_queued,
bytes_queued, objects_dropped, groups_dropped, keyframes_dropped,
groups_abandoned, backpressure_stalls, last_error, and the appended
sap_records_evicted.
sap_records_evicted is None when the service's stamped prefix does not
cover that WHOLE field -- absence, never a fabricated zero. A stamped zero is
a present zero, and the two are not equal.
What the numbers mean, as the service defines them:
objects_written,objects_sent,objects_queued,objects_droppedandbytes_queuedare ONE population: media accepted bywrite()and the bytes it holds. The private END_OF_TRACK markerend_track()queues is not media and appears in none of them. Soobjects_queued == 0means no media is pending -- it is not a completion barrier, and a terminal may still be in flight.objects_sentmeans handed to the session, not delivered to a receiver.groups_droppedcounts distinct (track, group) values that lost at least one queued object;groups_abandonedcounts open wire subgroups driven through the RESET lifecycle. They are different facts, and one partially emitted group can contribute to both.last_erroris the last non-OKwrite()return, 0 for none. It is DATA: a recorded historical failure is reported here, never raised.
Within that population the service maintains
objects_written == objects_sent + objects_queued + objects_dropped. The
binding reports it; it does not check, recompute or repair it.
A native failure keeps its signed code and the operation "stats". A
malformed successful output -- a stamp outside the frozen v0 floor and the
capacity offered -- is a BindingError, not a native result.
The thread calling connect owns ordinary endpoint operations, including
context entry and close. The binding adds no worker thread; native transport
execution stays in the service tier. Native waits release the GIL and native
callbacks do not invoke application Python.
Only wake(), set_interrupted(exact_bool), and the closed property may be
called from another thread. Native lifetime checks still apply. Wake requests
a coalesced service cycle; it does not clear the sticky interrupt latch.
set_interrupted(True) makes waits report INTERRUPTED until
set_interrupted(False) clears it. Interruption does not close the connection.
Inherited native handles are rejected after fork; create new endpoints in
the child.
endpoint.closed means the native resource has been released, not that the
connection has reached EndpointState.CLOSED. Read state, terminal, and version
before explicit close, and keep copied terminal snapshots as needed. close()
stops, joins, and releases native resources synchronously and is idempotent.
It provides no graceful media completion or drain guarantee.
Use with Endpoint.connect(config) as endpoint: for deterministic cleanup.
Normal exit propagates any body exception after closing. If cleanup alone
fails, its exception propagates. If both the body and cleanup fail, an
ExceptionGroup contains the body exception first and cleanup failure second;
it is a BaseExceptionGroup when necessary to retain KeyboardInterrupt or
another BaseException. Both existing exception causes remain intact. A
forgotten explicit close triggers the native capsule's stop/join fallback and
ResourceWarning; finalization is not the normal lifetime contract.