PipeWire core, objects, and native protocol

Explain PipeWire objects, client messages and permissions.

14 min readChapter 138 of 168Intermediate → advanced
In this chapter

Before you start#

Read ALSA userspace and D-Bus messages. PipeWire 1.2.0 is the historical source baseline here; it is not a claim about the installed server or a compatibility matrix. The practice constructs a private model and never connects to a PipeWire server.

Learning goals#

Describe a context, core connection, registry, proxy and server resource; follow discovery and negotiation into a conditional node/port/link graph. Distinguish a synchronization reply from media completion and track objects and descriptors through connection failure.

Source walkthrough#

Client discovery and graph objects
Client discovery and graph objectsClient context: Connects and creates local proxies → Core and resources: Dispatch versioned requests and events (connect and bind). Registry: Advertises permitted globals → Nodes, ports and links: Form a graph after negotiation (discover and bind)connect and binddiscover and bindClient contextConnects and creates localproxiesRegistryAdvertises permitted globalsCore and resourcesDispatch versioned requestsand eventsNodes, ports and linksForm a graph afternegotiation
Client contextConnects and creates local proxies
Core and resourcesDispatch versioned requests and events
RegistryAdvertises permitted globals
Nodes, ports and linksForm a graph after negotiation
Read the text version
  • Client context: Connects and creates local proxies → Core and resources: Dispatch versioned requests and events (connect and bind)
  • Registry: Advertises permitted globals → Nodes, ports and links: Form a graph after negotiation (discover and bind)
Conceptual model. Control-plane relationships, not a captured server graph.

PipeWire is a framework for exchanging and processing multimedia data. A daemon normally hosts a graph and accepts clients, while client libraries connect to a core and communicate about objects. Nodes can represent processing endpoints, ports expose directional connection points, and links connect compatible ports. Devices can describe hardware or other backing resources; not every node corresponds to a physical device. Modules and SPA factories supply implementations and services. A session manager supplies policy about which devices and links to use; the core graph alone does not decide every desktop routing preference.

A pw_context holds local framework state associated with a loop and its configuration. A pw_core represents a connection to a PipeWire core; a client uses proxies to send requests and receive events about remote objects. These userspace objects are not the same thing as the daemon's server-side resources. Each side has its own lifetime, and a local proxy can remain allocated briefly while the remote object is already gone. The PipeWire 1.2.0 core implementation and proxy implementation provide the concrete paths in this chapter.

The main/control loop handles protocol messages, object events, configuration and callbacks according to the application's chosen loop integration. A data loop can execute time-sensitive graph work in another thread. An application must follow the selected API's thread and lock rules: a callback's thread is part of its contract, and a pointer to a proxy or listener cannot be retained after its owner destroys it. A thread-loop wrapper may provide locking primitives, but a generic context is not automatically a lock around all application data. Keep control-plane mutation and real-time data processing coordinated without blocking the real-time path.

Connecting and failure cleanup

An application creates a loop and context, chooses properties, and requests a connection. A core connection can fail because allocation, socket connection, authentication or server setup fails; no daemon is guaranteed to be present. If it succeeds, the caller registers listeners and makes requests through the connection. Some later failures arrive asynchronously after a successful local connect call, so the application must handle disconnect and error events as well as the initial return. Stop using remote proxies when their connection is gone and release local listeners, buffers and context resources in a defined order.

From PipeWire 1.2.0 src/pipewire/core.c, pw_context_connect, MIT SPDX license in the file, with earlier allocation and success return omitted:

c
error_free:
	pw_core_disconnect(core);
	errno = -res;
	return NULL;

The error label disconnects the partially created core. The next line translates the negative internal result into a positive errno, and the last returns a null pointer. The surrounding code reaches this label when the protocol-client connection fails after core allocation; it also returns null if initial core creation failed. A caller must not attach listeners to that null result. A later server disconnect is a separate event and requires cleanup of resources that were successfully established before the failure.

The connect failure path
  1. core_new

    Allocates the core object and its local state before any protocol connection is attempted.

  2. pw_protocol_client_connect

    Establishes the protocol connection; a negative result takes the error path.

  3. error_free

    Disconnects the partly created core with pw_core_disconnect.

  4. errno and NULL

    Stores the error as errno and returns NULL; the caller must not use the result.

Source map. A source path model of pw_context_connect in PipeWire 1.2.0 (src/pipewire/core.c), not a captured connection; the success path is omitted.

A connection made from an existing fd is a separate API with its own ownership contract. An fd may represent a Unix socket or a capability supplied by another service; it is not a global server identifier. If an interface takes ownership of an fd, the caller must not close or reuse it as though it still owns an independent reference. If it borrows or duplicates an fd, the caller follows that documented contract instead. When data or protocol messages carry descriptors, each receiver obtains a descriptor referring to a kernel object; the numeric values can differ between processes and can be reused after close. A connection teardown must close or transfer each owned fd exactly once.

Registry and object identity

A registry advertises globals that a client is permitted to see. An event includes an object ID, interface type, version and properties. The client can bind a global to create a local proxy at a supported interface version, then register listeners for its information and events. Permissions are checked by the server and can limit visibility or operations. A global ID is meaningful in the relevant server connection and object lifetime; it is not a persistent hardware identity. Removal invalidates assumptions about that object, and a later object can receive a reused numeric ID. The native protocol generation mechanism guards asynchronous lookups against stale IDs, but that generation is internal and not exposed through the client API. For identity across object lifetimes, PipeWire documents object.serial as unique and not reused; names and other descriptive properties may still change or be duplicated. See the native protocol registry-generation description and object property definitions.

A device can expose one or more nodes; a node can have input and output ports. A link connects an output port to an input port subject to policy, permissions and compatible parameters. Parameters describe formats and other capabilities; an application must wait for the relevant events and handle negotiation failure. A discovered node is not necessarily ready, active, connected to hardware, or allowed for every client. A link object can exist before data flows, and a server error or removed port can invalidate the intended connection. Destruction of one object can require releasing dependent listeners and references.

A local proxy is a handle to a remote resource and has its own reference and destruction state. From PipeWire 1.2.0 src/pipewire/proxy.c, pw_proxy_destroy, MIT file license; surrounding server-removal and listener code is omitted:

c
	assert(!proxy->destroyed);
	proxy->destroyed = true;

The assertion checks that the proxy has not already been destroyed, and the assignment marks it destroyed. The subsequent source conditionally sends a destroy request if the server has not already removed it, manages the map and zombie state, emits destruction, and drops a reference. A client must not call destroy twice on the same stale pointer or assume the remote side remains usable until the local allocation disappears. Destruction and reference release are connected but separate steps, particularly when the server's removal races with local teardown.

Native messages and typed parameters

PipeWire's native protocol commonly uses a local Unix socket. Messages carry object identifiers, operation information, sequence and payload according to the protocol implementation; file descriptors can be transferred as ancillary data. Shared memory can be represented by memfd-backed or other memory objects, allowing data to be mapped rather than copied into every control message. These are implementation-specific wire and ownership contracts, not D-Bus messages, GVariant values or JSON documents. A client should use the library's generated or defined protocol methods and check negotiated interface versions instead of inventing a raw message format.

A SPA POD is a typed, structured representation used for parameters and values in PipeWire's Simple Plugin API. A format parameter can describe media type, sample format, rate, channels and channel positions, often with choices or ranges. A client offers or enumerates capabilities and the graph negotiates a compatible result; the chosen concrete format is not necessarily the first value the application requested. A POD's declared type, size, alignment and nesting must be validated before a consumer interprets it. A malformed or unsupported parameter can produce an error or failed negotiation. An application must retain or copy parameter data for the lifetime required by the API rather than keeping a pointer to a temporary builder buffer indefinitely.

A memfd is a file descriptor for an anonymous memory-backed file, not a promise that its entire mapping contains valid media. Mapping length, offset, buffer metadata and synchronization define which bytes are accessible and ready. A received fd must be closed when its owned reference is no longer needed, while mappings and other duplicated descriptors have independent lifetimes. DMA-BUF and ordinary shared-memory paths have different requirements. An invalid fd, mapping failure, unsupported data type or insufficient permissions should unwind the associated proxy, mapping and callback state without leaving a worker using unmapped memory.

A synthetic format negotiation can be written without pretending to encode a real POD byte stream. Suppose an output advertises audio/raw, signed 16-bit interleaved samples, two channels and a rate choice of 44,100 or 48,000 frames per second. An input accepts the same format and channels but only 48,000. The compatible concrete result is 48,000 if the relevant parameter filters and policy select that intersection. If the input instead accepts only a format the output cannot provide and no adapter is available, there is no such direct match. The rate is an integer property within a typed parameter; it is not a property of an unrelated D-Bus message. This example uses invented capabilities and does not claim that a server negotiated them.

The pinned SPA POD header represents a basic POD header with a body size and type. Those fields tell a parser what follows; they do not by themselves prove a nested object is valid or compatible. An implementation must check type, size, alignment and bounds before reading nested values. A malformed claimed size that extends beyond its containing message must not be treated as a valid format simply because its first few fields look plausible. The exact builder and parser APIs own their own temporary memory and error rules.

In spa/include/spa/pod/pod.h, PipeWire 1.2.0, MIT SPDX (surrounding code omitted):

c
struct spa_pod {
	uint32_t size;		/* size of the body */
	uint32_t type;		/* a basic id of enum spa_type */
};

The header declares exactly two uint32_t fields: the body size and a basic type identifier from enum spa_type. A parser uses them to decide how far to read and what kind of value follows. They do not prove that nested values are well formed, so the bounds and type checks described above still apply.

The eight-byte POD header

Synthetic teaching data, not a device capture.

Select a field to connect its position, bytes, and meaning.

8 bytes in file order. Offsets start at zero.

Selected: body size. uint32_t size: the body is 40 bytes in this invented example..

Read the byte table
OffsetFieldHex bytesInterpretation
0body size28 00 00 00uint32_t size: the body is 40 bytes in this invented example.
4type0F 00 00 00uint32_t type: SPA_TYPE_Object is enum value 15 in the 1.2.0 type header.
Synthetic teaching data. Field layout from the PipeWire 1.2.0 header on a little-endian host; the byte values are an invented object POD (body size 40, type SPA_TYPE_Object), not captured from a server.

Sync, replies and actual media

pw_core_sync provides a protocol ordering mechanism: a later done event for the specified sequence indicates that the server has processed the relevant preceding requests on that connection according to the API's semantics. The done event echoes both the sync request object ID and sequence. This is a protocol round trip, not a media-completion signal; see the 1.2 core API and native protocol Core::Sync/Done definitions. It does not prove that an audio sample was rendered, that a node reached a desired physical state, or that unrelated clients have finished work. A client can use the reply to know that initial registry events preceding the sync have been handled, while still reacting to later additions and removals.

In src/pipewire/core.c, PipeWire 1.2.0, MIT SPDX (surrounding code omitted):

c
static void core_event_done(void *data, uint32_t id, int seq)
{
	struct pw_core *this = data;
	struct pw_proxy *proxy;

	pw_log_trace("%p: object %u done %d", this, id, seq);

	proxy = pw_map_lookup(&this->objects, id);
	if (proxy)
		pw_proxy_emit_done(proxy, seq);
}

The handler finds the local proxy by the event's object ID and emits its done signal with the echoed sequence. If the lookup finds nothing, nothing is emitted. The excerpt shows only this local dispatch; it does not show how the server orders earlier requests, so it says nothing about whether media moved.

A sync request returns through the local core
A sync request returns through the local core1. Client code to Local core: pw_proxy_sync(proxy, seq) calls pw_core_sync. 2. Local core to Server core: sync request with proxy id and seq. 3. Server core to Server core: process earlier requests in order. 4. Server core to Local core (return): done(id, seq) event. 5. Local core to Local core: core_event_done finds proxy by id. 6. Local core to Client code (return): pw_proxy_emit_done(proxy, seq).Client codeLocal coreServer corepw_proxy_sync(proxy,seq) calls pw_core_syncsync request with proxyid and seqprocess earlierrequests inorderdone(id, seq) eventcore_event_donefinds proxy byidpw_proxy_emit_done(proxy, seq)
  1. Client codeLocal core

    pw_proxy_sync(proxy, seq) calls pw_core_sync

  2. Local coreServer core

    sync request with proxy id and seq

  3. Server coreServer core

    process earlier requests in order

  4. Server coreLocal core

    Return. done(id, seq) event

  5. Local coreLocal core

    core_event_done finds proxy by id

  6. Local coreClient code

    Return. pw_proxy_emit_done(proxy, seq)

Time runs downward. Dashed arrows show returns.

Read the text version
  1. Client code → Local core: pw_proxy_sync(proxy, seq) calls pw_core_sync
  2. Local core → Server core: sync request with proxy id and seq
  3. Server core → Server core: process earlier requests in order
  4. Server core → Local core (return): done(id, seq) event
  5. Local core → Local core: core_event_done finds proxy by id
  6. Local core → Client code (return): pw_proxy_emit_done(proxy, seq)
Source map. A source-based ordering model of pw_proxy_sync and core_event_done in PipeWire 1.2.0, not a captured trace; done shows protocol ordering, not media completion.

A normal discovery sequence can be modeled as follows. The client connects and creates a registry proxy. It receives global 17 describing an audio node and global 18 describing one of its ports, binds supported versions, and records separate proxy objects. It requests a sync with sequence 12. On done for 12 it can complete its initial enumeration, but it must still handle later removal of 17 or 18. A policy component may create or select a compatible peer and link. Format negotiation can produce a concrete rate and channel layout; a node can then move through its applicable paused and active states. These IDs and values are synthetic, not a capture from a server.

Now suppose a proposed format contains an unsupported channel count or an invalid POD. Negotiation can fail and the link may never carry media. The client reports the error, releases temporary parameter storage and proxies it owns, and does not interpret a done event as successful playback. If the connection drops while a reply is pending, the client cancels or resolves its own pending operation and releases its local listener state. A later new connection can use the same integer ID for an unrelated object; it must not revive the old proxy or attach a queued callback to the new object by number alone.

Permissions, properties and modules

Registry properties are metadata used for discovery and policy, not an automatic authorization grant. A node name can help identify an object but may be duplicated, changed or controlled by the publishing component. Permission bits govern what a client can see and do under the server's policy. A client denied access to a global must not infer that the underlying device does not exist; it may simply lack visibility. Likewise a visible node does not imply permission to create every desired link.

A module can load an implementation, register factories or extend the context. A SPA factory can produce a node or other interface from specified parameters. Loading and configuration are deployment decisions, not things an ordinary audio client should change just to make an exercise work. A module's failure can leave a requested factory unavailable, and a client should report that condition rather than assume it can manufacture a device. Session policy can choose a default target or create links, but that choice can change when hardware, permissions or configuration changes.

The native protocol has both control and data concerns. Requests and events establish objects and exchange parameters; graph buffers carry media under negotiated memory and scheduling rules. Sending a control request does not transfer ownership of every referenced application's memory. A buffer can be returned later, or a stream can be destroyed before it is ever used, so callback and cleanup design must cover both paths. The next chapter examines these buffer cycles separately.

Practice#

This model uses only Python values. It does not connect to PipeWire, create SPA POD bytes, or read a host graph:

sh
python3 - <<'PY'
objects = {17: 'node', 18: 'port'}
pending_sync = 12
print('known:', sorted(objects.items()))
print('done sequence:', pending_sync)
del objects[17]
print('after removal:', sorted(objects.items()))
assert 17 not in objects
PY

Expected output lists two synthetic IDs, sequence 12, then only port 18. The remaining port is deliberately a counterexample to naive cleanup: a real application must inspect dependencies and handle removal events rather than assume this toy dictionary represents a valid graph. As a source-only alternative, use the supplied 17/18/12 sequence and write a table of connection, proxy, global and sync lifetimes. Include an unsupported-format error and identify the cleanup owner. This is not a daemon or device test.

Knowledge check#

  1. What distinguishes a local proxy from a server resource?
  2. What does pw_context_connect do on its protocol connection failure path?
  3. Does a registry global ID permanently identify a hardware device?
  4. What does a core sync done event fail to prove about audio?
  5. Why is a SPA format parameter not a D-Bus message?

Answers#

  1. The proxy is a local representation used for requests and events; the server resource has a separate lifetime and can disappear first.
  2. The shown path disconnects the partially created core, sets errno from the error, and returns null.
  3. No. Its scope is the server and object lifetime, and an ID can be reused after removal or reconnection.
  4. It does not prove that media reached hardware or was heard. It is an ordering/correlation mechanism for protocol processing.
  5. A SPA POD is PipeWire's typed parameter representation, with its own encoding and validation; it is not the D-Bus wire protocol or GVariant.

Checkpoint#

Submit the synthetic registry sequence, a connection-failure cleanup list and an unsupported-format path. Pass when IDs are scoped to object lifetime, done is not playback proof, and owned fds and proxies have release owners. If not, reread the core and proxy source paths and repeat the table. This is practice, not certification.

Sources#

Read PipeWire core connection, proxy lifecycle, context implementation, and SPA buffer structures. The example does not connect to a server.

A little further beneath the surface.

Check your understanding with the checkpoint. Marking a chapter as read saves a bookmark, not a proficiency assessment.