From 52751a2922823174e8ae90c7e1169e0c012de488 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 19 Oct 2026 16:04:09 +0200 Subject: [PATCH 1/8] fix: arbitrate every activation against exclusive activities --- README.md | 30 +++++++++++++++++++----------- connection.md | 28 ++++++++++++++++++---------- pairing.md | 2 +- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 91c2de0..7ce5299 100644 --- a/README.md +++ b/README.md @@ -182,24 +182,32 @@ Clients MUST NOT manually connect to servers while advertising `_sendspin._tcp`. #### Multiple servers (server-initiated) -A client holds at most one admitted connection at a time, except where noted below, classified by the highest-ranked activity in its declared [`activities`](#server--client-serveractivate); from highest to lowest: +A client admits connections by the [`activities`](#server--client-serveractivate) they declare: + +- At most one admitted connection has `'playback'` in its `activities`, and at most one has `'pairing'`. One connection MAY have both. +- A connection with empty `activities` is admitted only while no other connection is admitted. + +A client MAY also admit only one connection at a time. + +Clients MUST persistently store the `server_id` of the server that most recently held an admitted connection while `'playback'` was among its `activities` (the "last-playback server"). + +When a new server connects, the client lets the handshake complete before applying admission; the new connection is provisional until its first [`server/activate`](#server--client-serveractivate) declares its activities. The client arbitrates that activation and every later one, and MUST NOT arbitrate an activation that is not [admissible](#server--client-serveractivate). + +An activation conflicts with another admitted connection when both admitted, with the activating connection holding the activities the activation declares, would break one of the rules above. On a client that admits only one connection at a time, an activation on a provisional connection always conflicts with the admitted connection. The client compares the activating connection with each connection it conflicts with, classifying each by the highest-ranked activity in its `activities`, and the activating connection by those the activation declares, from highest to lowest: - `'playback'` - `'pairing'` -A connection with empty `activities` ranks lowest. - -Clients MUST persistently store the `server_id` of the server that most recently held the admitted connection while `'playback'` was among its `activities` (the "last-playback server"). +A connection with empty `activities` ranks lowest. The activating connection wins when it ranks higher or equal, and loses when it ranks lower. Two exceptions: -When a new server connects, the client lets the handshake complete before applying admission; the new connection is provisional until its first [`server/activate`](#server--client-serveractivate) declares its priority. The client MUST NOT apply the priority rules to an activation that is not [admissible](#server--client-serveractivate). The incoming connection's priority is compared to the current connection's: higher or equal is accepted, lower is rejected. Three exceptions: +- A connection with a [pairing attempt](#entering-and-leaving-pairing) in progress is not displaced: the activating connection loses against it. +- When both connections have empty `activities`, the activating connection wins only if its `server_id` matches the last-playback server (and the other's does not). -- A [pairing attempt](#entering-and-leaving-pairing) is not displaced by an incoming `'playback'` or `'pairing'` connection. -- When both the current holder and the incoming connection have empty `activities`, the incoming is admitted only if its `server_id` matches the last-playback server (and the existing one's does not); otherwise the existing is kept. -- A client MAY admit one incoming `'pairing'` connection alongside the admitted `'playback'` connection, holding both. A client that does not admit both connections rejects the incoming with [`pair/abort`](#client--server-pairabort) reason `concurrent_attempt` and closes the connection. While both are held, further incoming connections are arbitrated against the `'playback'` holder. When a later `server/activate` drops `'pairing'` from that connection's `activities`, the client arbitrates it against the `'playback'` holder as if it were incoming. +The client applies an activation that conflicts with no connection or wins against every connection it conflicts with, admitting its connection if provisional, and displaces those connections. Otherwise it rejects the activating connection. -Subsequent `server/activate` updates do not otherwise trigger arbitration, even when a connection escalates its activities. A provisional connection that has not sent `server/activate` within 30 seconds is dropped. Clients MAY cap how many provisional connections they hold at once, rejecting further incoming connections as if they were lower priority. +A provisional connection that has not sent `server/activate` within 30 seconds is dropped. Clients MAY cap how many provisional connections they hold at once, rejecting further incoming connections as if they were lower priority. -A displaced connection receives [`client/goodbye`](#client--server-clientgoodbye) reason `'another_server'` (or [`pair/abort`](#client--server-pairabort) reason `concurrent_attempt` if it is a pairing handshake). A rejected incoming receives [`client/goodbye`](#client--server-clientgoodbye) reason `'concurrent_attempt'` (or [`pair/abort`](#client--server-pairabort) reason `concurrent_attempt` for pairings). The client then closes the connection. +A displaced connection receives [`client/goodbye`](#client--server-clientgoodbye) reason `'another_server'` (or [`pair/abort`](#client--server-pairabort) reason `concurrent_attempt` if it is a pairing handshake). A rejected connection receives [`client/goodbye`](#client--server-clientgoodbye) reason `'concurrent_attempt'` (or [`pair/abort`](#client--server-pairabort) reason `concurrent_attempt` if the rejected activation declares `'pairing'`). The client then closes the connection. ### Client Initiated Connections @@ -1204,7 +1212,7 @@ Aborts a pairing attempt, started or not. With reason `concurrent_attempt` the s - `reason`: string - one of: - `attempt_timeout` (client) - the pairing attempt did not complete within the [attempt timeout](#entering-and-leaving-pairing) - - `concurrent_attempt` (client) - another pairing attempt is already in progress with this client, or the client does not admit a pairing connection alongside its current admitted connection (see [Multiple servers](#multiple-servers-server-initiated)) + - `concurrent_attempt` (client) - another pairing attempt is already in progress with this client, or the client rejected or displaced this pairing (see [Multiple servers](#multiple-servers-server-initiated)) - `method_not_supported` (client) - the server's activity set and `pairing.method` are not a permitted combination for the matched PSK, or `pairing.method` names a method the client does not currently offer, or `pairing.format` names an emission format the client does not currently offer - `pairing_code_mismatch` (client or server) - PAKE key-confirmation failed - `user_cancelled` (client or server) - operator aborted the pairing through a local UI diff --git a/connection.md b/connection.md index b150ad5..143c7c7 100644 --- a/connection.md +++ b/connection.md @@ -22,24 +22,32 @@ Clients MUST NOT manually connect to servers while advertising `_sendspin._tcp`. #### Multiple servers (server-initiated) -A client holds at most one admitted connection at a time, except where noted below, classified by the highest-ranked activity in its declared [`activities`](messaging.md#server--client-serveractivate); from highest to lowest: +A client admits connections by the [`activities`](messaging.md#server--client-serveractivate) they declare: + +- At most one admitted connection has `'playback'` in its `activities`, and at most one has `'pairing'`. One connection MAY have both. +- A connection with empty `activities` is admitted only while no other connection is admitted. + +A client MAY also admit only one connection at a time. + +Clients MUST persistently store the `server_id` of the server that most recently held an admitted connection while `'playback'` was among its `activities` (the "last-playback server"). + +When a new server connects, the client lets the handshake complete before applying admission; the new connection is provisional until its first [`server/activate`](messaging.md#server--client-serveractivate) declares its activities. The client arbitrates that activation and every later one, and MUST NOT arbitrate an activation that is not [admissible](messaging.md#server--client-serveractivate). + +An activation conflicts with another admitted connection when both admitted, with the activating connection holding the activities the activation declares, would break one of the rules above. On a client that admits only one connection at a time, an activation on a provisional connection always conflicts with the admitted connection. The client compares the activating connection with each connection it conflicts with, classifying each by the highest-ranked activity in its `activities`, and the activating connection by those the activation declares, from highest to lowest: - `'playback'` - `'pairing'` -A connection with empty `activities` ranks lowest. - -Clients MUST persistently store the `server_id` of the server that most recently held the admitted connection while `'playback'` was among its `activities` (the "last-playback server"). +A connection with empty `activities` ranks lowest. The activating connection wins when it ranks higher or equal, and loses when it ranks lower. Two exceptions: -When a new server connects, the client lets the handshake complete before applying admission; the new connection is provisional until its first [`server/activate`](messaging.md#server--client-serveractivate) declares its priority. The client MUST NOT apply the priority rules to an activation that is not [admissible](messaging.md#server--client-serveractivate). The incoming connection's priority is compared to the current connection's: higher or equal is accepted, lower is rejected. Three exceptions: +- A connection with a [pairing attempt](pairing.md#entering-and-leaving-pairing) in progress is not displaced: the activating connection loses against it. +- When both connections have empty `activities`, the activating connection wins only if its `server_id` matches the last-playback server (and the other's does not). -- A [pairing attempt](pairing.md#entering-and-leaving-pairing) is not displaced by an incoming `'playback'` or `'pairing'` connection. -- When both the current holder and the incoming connection have empty `activities`, the incoming is admitted only if its `server_id` matches the last-playback server (and the existing one's does not); otherwise the existing is kept. -- A client MAY admit one incoming `'pairing'` connection alongside the admitted `'playback'` connection, holding both. A client that does not admit both connections rejects the incoming with [`pair/abort`](pairing.md#client--server-pairabort) reason `concurrent_attempt` and closes the connection. While both are held, further incoming connections are arbitrated against the `'playback'` holder. When a later `server/activate` drops `'pairing'` from that connection's `activities`, the client arbitrates it against the `'playback'` holder as if it were incoming. +The client applies an activation that conflicts with no connection or wins against every connection it conflicts with, admitting its connection if provisional, and displaces those connections. Otherwise it rejects the activating connection. -Subsequent `server/activate` updates do not otherwise trigger arbitration, even when a connection escalates its activities. A provisional connection that has not sent `server/activate` within 30 seconds is dropped. Clients MAY cap how many provisional connections they hold at once, rejecting further incoming connections as if they were lower priority. +A provisional connection that has not sent `server/activate` within 30 seconds is dropped. Clients MAY cap how many provisional connections they hold at once, rejecting further incoming connections as if they were lower priority. -A displaced connection receives [`client/goodbye`](messaging.md#client--server-clientgoodbye) reason `'another_server'` (or [`pair/abort`](pairing.md#client--server-pairabort) reason `concurrent_attempt` if it is a pairing handshake). A rejected incoming receives [`client/goodbye`](messaging.md#client--server-clientgoodbye) reason `'concurrent_attempt'` (or [`pair/abort`](pairing.md#client--server-pairabort) reason `concurrent_attempt` for pairings). The client then closes the connection. +A displaced connection receives [`client/goodbye`](messaging.md#client--server-clientgoodbye) reason `'another_server'` (or [`pair/abort`](pairing.md#client--server-pairabort) reason `concurrent_attempt` if it is a pairing handshake). A rejected connection receives [`client/goodbye`](messaging.md#client--server-clientgoodbye) reason `'concurrent_attempt'` (or [`pair/abort`](pairing.md#client--server-pairabort) reason `concurrent_attempt` if the rejected activation declares `'pairing'`). The client then closes the connection. ### Client Initiated Connections diff --git a/pairing.md b/pairing.md index c8c8df9..2cfeccf 100644 --- a/pairing.md +++ b/pairing.md @@ -402,7 +402,7 @@ Aborts a pairing attempt, started or not. With reason `concurrent_attempt` the s - `reason`: string - one of: - `attempt_timeout` (client) - the pairing attempt did not complete within the [attempt timeout](#entering-and-leaving-pairing) - - `concurrent_attempt` (client) - another pairing attempt is already in progress with this client, or the client does not admit a pairing connection alongside its current admitted connection (see [Multiple servers](connection.md#multiple-servers-server-initiated)) + - `concurrent_attempt` (client) - another pairing attempt is already in progress with this client, or the client rejected or displaced this pairing (see [Multiple servers](connection.md#multiple-servers-server-initiated)) - `method_not_supported` (client) - the server's activity set and `pairing.method` are not a permitted combination for the matched PSK, or `pairing.method` names a method the client does not currently offer, or `pairing.format` names an emission format the client does not currently offer - `pairing_code_mismatch` (client or server) - PAKE key-confirmation failed - `user_cancelled` (client or server) - operator aborted the pairing through a local UI From c949b9cf80f239bac00bb8e5b8efd16189bd3398 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 19 Oct 2026 16:04:09 +0200 Subject: [PATCH 2/8] fix: require `'playback'` for player and visualizer streams --- README.md | 4 ++-- messaging.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7ce5299..5e383ec 100644 --- a/README.md +++ b/README.md @@ -590,7 +590,7 @@ Servers normally activate the client's [preferred](#priority-and-activation) ver Role removal includes explicit removal from `active_roles`, implicit removal when the connection is no longer playback-capable, and replacement of an active role version. -Before a `server/activate` removes a server-to-client stream role (`player`, `artwork`, `visualizer`, or an application-specific role with such a stream), the server MUST send [`stream/end`](#server--client-streamend) for that role if its stream is active. If the first activation after a [re-handshake](#re-handshake) will remove such a role, the server MUST send any required `stream/end` before starting the re-handshake. +Before a `server/activate` removes a server-to-client stream role (`player`, `artwork`, `visualizer`, or an application-specific role with such a stream), the server MUST send [`stream/end`](#server--client-streamend) for that role if its stream is active. Before a `server/activate` drops `'playback'` from `activities`, the server MUST send `stream/end` for the `player` and `visualizer` streams if they are active. If the first activation after a [re-handshake](#re-handshake) will remove such a role or drop `'playback'`, the server MUST send any required `stream/end` before starting the re-handshake. When applying a `server/activate`, the client MUST: @@ -724,7 +724,7 @@ Starts a stream for one or more roles. If sent for a role that already has an ac [Application-specific roles](#application-specific-roles) MAY also include objects in this message (keys starting with `_`). -The server MUST NOT send `stream/start` unless the latest [`client/state`](#client--server-clientstate) it has received reports `available: true`. +The server MUST NOT send `stream/start` unless the latest [`client/state`](#client--server-clientstate) it has received reports `available: true`, and MUST NOT include the `player` or `visualizer` object unless `'playback'` is in the connection's `activities`. Each role's stream configuration is derived from what the client reports about itself: the role's support object in [`client/hello`](#client--server-clienthello) for constant capabilities, and the role's [`client/state`](#client--server-clientstate) object for the stream-configuration fields the client may change during the connection (each role may define both). After a role that defines a `client/state` object is added or re-added to `active_roles`, the server MUST wait for the [`client/state`](#client--server-clientstate) update the activation requires before starting that role's stream, so it does not start from stale state. When a `client/state` changes a role's stream-configuration fields while a stream is active for that role, the server re-derives the stream configuration and, if it changed, sends a `stream/start` with the new configuration. When no stream is active for the role, the server MUST NOT start one in response; the updated state applies to the next stream it starts for that role. diff --git a/messaging.md b/messaging.md index a864cea..095cd2d 100644 --- a/messaging.md +++ b/messaging.md @@ -264,7 +264,7 @@ Servers normally activate the client's [preferred](README.md#priority-and-activa Role removal includes explicit removal from `active_roles`, implicit removal when the connection is no longer playback-capable, and replacement of an active role version. -Before a `server/activate` removes a server-to-client stream role (`player`, `artwork`, `visualizer`, or an application-specific role with such a stream), the server MUST send [`stream/end`](#server--client-streamend) for that role if its stream is active. If the first activation after a [re-handshake](connection.md#re-handshake) will remove such a role, the server MUST send any required `stream/end` before starting the re-handshake. +Before a `server/activate` removes a server-to-client stream role (`player`, `artwork`, `visualizer`, or an application-specific role with such a stream), the server MUST send [`stream/end`](#server--client-streamend) for that role if its stream is active. Before a `server/activate` drops `'playback'` from `activities`, the server MUST send `stream/end` for the `player` and `visualizer` streams if they are active. If the first activation after a [re-handshake](connection.md#re-handshake) will remove such a role or drop `'playback'`, the server MUST send any required `stream/end` before starting the re-handshake. When applying a `server/activate`, the client MUST: @@ -398,7 +398,7 @@ Starts a stream for one or more roles. If sent for a role that already has an ac [Application-specific roles](README.md#application-specific-roles) MAY also include objects in this message (keys starting with `_`). -The server MUST NOT send `stream/start` unless the latest [`client/state`](#client--server-clientstate) it has received reports `available: true`. +The server MUST NOT send `stream/start` unless the latest [`client/state`](#client--server-clientstate) it has received reports `available: true`, and MUST NOT include the `player` or `visualizer` object unless `'playback'` is in the connection's `activities`. Each role's stream configuration is derived from what the client reports about itself: the role's support object in [`client/hello`](#client--server-clienthello) for constant capabilities, and the role's [`client/state`](#client--server-clientstate) object for the stream-configuration fields the client may change during the connection (each role may define both). After a role that defines a `client/state` object is added or re-added to `active_roles`, the server MUST wait for the [`client/state`](#client--server-clientstate) update the activation requires before starting that role's stream, so it does not start from stale state. When a `client/state` changes a role's stream-configuration fields while a stream is active for that role, the server re-derives the stream configuration and, if it changed, sends a `stream/start` with the new configuration. When no stream is active for the role, the server MUST NOT start one in response; the updated state applies to the next stream it starts for that role. From f93b111ac52b665335e1ac080931907454dd961a Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 19 Oct 2026 16:14:23 +0200 Subject: [PATCH 3/8] fix: say a rejected connection may already be admitted --- README.md | 2 +- connection.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5e383ec..f1d982e 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ The client applies an activation that conflicts with no connection or wins again A provisional connection that has not sent `server/activate` within 30 seconds is dropped. Clients MAY cap how many provisional connections they hold at once, rejecting further incoming connections as if they were lower priority. -A displaced connection receives [`client/goodbye`](#client--server-clientgoodbye) reason `'another_server'` (or [`pair/abort`](#client--server-pairabort) reason `concurrent_attempt` if it is a pairing handshake). A rejected connection receives [`client/goodbye`](#client--server-clientgoodbye) reason `'concurrent_attempt'` (or [`pair/abort`](#client--server-pairabort) reason `concurrent_attempt` if the rejected activation declares `'pairing'`). The client then closes the connection. +A displaced connection receives [`client/goodbye`](#client--server-clientgoodbye) reason `'another_server'` (or [`pair/abort`](#client--server-pairabort) reason `concurrent_attempt` if it is a pairing handshake). A rejected connection, provisional or admitted, receives [`client/goodbye`](#client--server-clientgoodbye) reason `'concurrent_attempt'` (or [`pair/abort`](#client--server-pairabort) reason `concurrent_attempt` if the rejected activation declares `'pairing'`). The client then closes the connection. ### Client Initiated Connections diff --git a/connection.md b/connection.md index 143c7c7..1939f6c 100644 --- a/connection.md +++ b/connection.md @@ -47,7 +47,7 @@ The client applies an activation that conflicts with no connection or wins again A provisional connection that has not sent `server/activate` within 30 seconds is dropped. Clients MAY cap how many provisional connections they hold at once, rejecting further incoming connections as if they were lower priority. -A displaced connection receives [`client/goodbye`](messaging.md#client--server-clientgoodbye) reason `'another_server'` (or [`pair/abort`](pairing.md#client--server-pairabort) reason `concurrent_attempt` if it is a pairing handshake). A rejected connection receives [`client/goodbye`](messaging.md#client--server-clientgoodbye) reason `'concurrent_attempt'` (or [`pair/abort`](pairing.md#client--server-pairabort) reason `concurrent_attempt` if the rejected activation declares `'pairing'`). The client then closes the connection. +A displaced connection receives [`client/goodbye`](messaging.md#client--server-clientgoodbye) reason `'another_server'` (or [`pair/abort`](pairing.md#client--server-pairabort) reason `concurrent_attempt` if it is a pairing handshake). A rejected connection, provisional or admitted, receives [`client/goodbye`](messaging.md#client--server-clientgoodbye) reason `'concurrent_attempt'` (or [`pair/abort`](pairing.md#client--server-pairabort) reason `concurrent_attempt` if the rejected activation declares `'pairing'`). The client then closes the connection. ### Client Initiated Connections From 950497ee7b6bd2867e51f125bc3ee2911e8557d7 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 20 Oct 2026 09:22:34 +0200 Subject: [PATCH 4/8] fix: require `'playback'` for the source `start` command --- README.md | 6 +++--- roles/source/v1.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f1d982e..f026f2c 100644 --- a/README.md +++ b/README.md @@ -1462,7 +1462,7 @@ The `source` object in [`server/command`](#server--client-servercommand) has thi A `start` received while a start authorization is pending or the input stream is open has no effect: it MUST NOT restart the stream or authorize a later reopening. A `stop` received with no pending start authorization and no open input stream is ignored. -The server MUST NOT send `start` unless the source role is active, it has received the source object in the [`client/state`](#client--server-clientstate) update required by that activation, and the latest `client/state` it received reports `available: true`. +The server MUST NOT send `start` unless the source role is active, `'playback'` is in the connection's [`activities`](#server--client-serveractivate), it has received the source object in the [`client/state`](#client--server-clientstate) update required by that activation, and the latest `client/state` it received reports `available: true`. A client MUST ignore `start` received while it is unavailable. Becoming available again does not resume streaming without a new `start`. @@ -1476,7 +1476,7 @@ Streaming state is per-connection: a previously sent `start` does not survive re A source that supports line sensing reports `signal` in [`client/state`](#client--server-clientstate). The server MAY use it as a hint for when to send `command: "start"` or `command: "stop"`, but the decision is server policy. -When the client receives a [`server/activate`](#server--client-serveractivate) removing `source` from `active_roles`, it MUST clear any pending start authorization, stop sending chunks, and send `client-stream/end` if its input stream is open. After reactivation, the client MUST NOT resume streaming until it receives a new `start`. +When the client receives a [`server/activate`](#server--client-serveractivate) removing `source` from `active_roles` or dropping `'playback'` from `activities`, it MUST clear any pending start authorization, stop sending chunks, and send `client-stream/end` if its input stream is open. After reactivation, the client MUST NOT resume streaming until it receives a new `start`. A source that becomes unavailable MUST clear any pending start authorization. If its input stream is open, it MUST stop sending source audio chunks and send `client-stream/end` before reporting [`available: false`](#external-source-handling) in `client/state`. The server treats the transition as an implicit `stop`. @@ -1511,7 +1511,7 @@ The client ends the current input stream. After sending this message, the client Receiving a source audio message without an open input stream, or while the latest [`client/state`](#client--server-clientstate) received by the server reports `available: false`, is a protocol error: the server MUST close the connection. The client's stream lifecycle messages, audio chunks, and availability updates are delivered in send order on the same connection, so network delay cannot move earlier chunks past `client-stream/end` or an availability update. -Clients MUST send `client-stream/start` before the first audio chunk. Sending `command: "stop"` or removing the source role does not itself close an input stream at the server. After either action, the server MUST tolerate otherwise valid in-flight responses to a previously sent `start`, including `client-stream/start` and audio chunks, until it receives `client-stream/end`. It MAY discard audio payloads but MUST process `client-stream/start` and `client-stream/end` in received order. +Clients MUST send `client-stream/start` before the first audio chunk. Sending `command: "stop"`, removing the source role, or dropping `'playback'` does not itself close an input stream at the server. After any of these, the server MUST tolerate otherwise valid in-flight responses to a previously sent `start`, including `client-stream/start` and audio chunks, until it receives `client-stream/end`. It MAY discard audio payloads but MUST process `client-stream/start` and `client-stream/end` in received order. - Byte 0: message type `12` (uint8) - Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when the first sample was captured diff --git a/roles/source/v1.md b/roles/source/v1.md index cc3829d..5eda0f6 100644 --- a/roles/source/v1.md +++ b/roles/source/v1.md @@ -54,7 +54,7 @@ The `source` object in [`server/command`](../../messaging.md#server--client-serv A `start` received while a start authorization is pending or the input stream is open has no effect: it MUST NOT restart the stream or authorize a later reopening. A `stop` received with no pending start authorization and no open input stream is ignored. -The server MUST NOT send `start` unless the source role is active, it has received the source object in the [`client/state`](../../messaging.md#client--server-clientstate) update required by that activation, and the latest `client/state` it received reports `available: true`. +The server MUST NOT send `start` unless the source role is active, `'playback'` is in the connection's [`activities`](../../messaging.md#server--client-serveractivate), it has received the source object in the [`client/state`](../../messaging.md#client--server-clientstate) update required by that activation, and the latest `client/state` it received reports `available: true`. A client MUST ignore `start` received while it is unavailable. Becoming available again does not resume streaming without a new `start`. @@ -68,7 +68,7 @@ Streaming state is per-connection: a previously sent `start` does not survive re A source that supports line sensing reports `signal` in [`client/state`](../../messaging.md#client--server-clientstate). The server MAY use it as a hint for when to send `command: "start"` or `command: "stop"`, but the decision is server policy. -When the client receives a [`server/activate`](../../messaging.md#server--client-serveractivate) removing `source` from `active_roles`, it MUST clear any pending start authorization, stop sending chunks, and send `client-stream/end` if its input stream is open. After reactivation, the client MUST NOT resume streaming until it receives a new `start`. +When the client receives a [`server/activate`](../../messaging.md#server--client-serveractivate) removing `source` from `active_roles` or dropping `'playback'` from `activities`, it MUST clear any pending start authorization, stop sending chunks, and send `client-stream/end` if its input stream is open. After reactivation, the client MUST NOT resume streaming until it receives a new `start`. A source that becomes unavailable MUST clear any pending start authorization. If its input stream is open, it MUST stop sending source audio chunks and send `client-stream/end` before reporting [`available: false`](../../messaging.md#external-source-handling) in `client/state`. The server treats the transition as an implicit `stop`. @@ -103,7 +103,7 @@ The client ends the current input stream. After sending this message, the client Receiving a source audio message without an open input stream, or while the latest [`client/state`](../../messaging.md#client--server-clientstate) received by the server reports `available: false`, is a protocol error: the server MUST close the connection. The client's stream lifecycle messages, audio chunks, and availability updates are delivered in send order on the same connection, so network delay cannot move earlier chunks past `client-stream/end` or an availability update. -Clients MUST send `client-stream/start` before the first audio chunk. Sending `command: "stop"` or removing the source role does not itself close an input stream at the server. After either action, the server MUST tolerate otherwise valid in-flight responses to a previously sent `start`, including `client-stream/start` and audio chunks, until it receives `client-stream/end`. It MAY discard audio payloads but MUST process `client-stream/start` and `client-stream/end` in received order. +Clients MUST send `client-stream/start` before the first audio chunk. Sending `command: "stop"`, removing the source role, or dropping `'playback'` does not itself close an input stream at the server. After any of these, the server MUST tolerate otherwise valid in-flight responses to a previously sent `start`, including `client-stream/start` and audio chunks, until it receives `client-stream/end`. It MAY discard audio payloads but MUST process `client-stream/start` and `client-stream/end` in received order. - Byte 0: message type `12` (uint8) - Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when the first sample was captured From cca951530437bc008962e3e99f58ba2ad7115d60 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 20 Oct 2026 09:42:55 +0200 Subject: [PATCH 5/8] fix: say when a server declares and drops `'playback'` --- README.md | 2 +- messaging.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f026f2c..fd4489a 100644 --- a/README.md +++ b/README.md @@ -584,7 +584,7 @@ When `'pairing'` is in `activities`, `pairing.method` MUST be `'pairing_psk'` if **Worked example (`pairing_required`).** A Sentinel-keyed connection to a client with unpaired access disabled receives `activities: ['playback']` and `active_roles: ['player@v1']`. Under a hypothetical `unpaired_access: enabled`, `['playback']` would be an allowed set for the Sentinel PSK and the connection would be playback-capable, so the activation would be admissible: the client closes with `'pairing_required'`. -Servers SHOULD declare the minimal set of activities that reflects the connection's current purpose, and drop an activity as soon as that purpose ends. Admission between competing connections is decided by the highest-ranked declared activity (see [Multiple servers](#multiple-servers-server-initiated)), so keeping an unused activity declared would degrade multi-server cooperation. +Servers SHOULD declare the minimal set of activities that reflects the connection's current purpose, and drop an activity as soon as that purpose ends. Admission between competing connections is decided by the highest-ranked declared activity (see [Multiple servers](#multiple-servers-server-initiated)), so keeping an unused activity declared would degrade multi-server cooperation. A server SHOULD declare `'playback'` while the client's group is playing or while it wants the client's `source` to stream, whatever roles are active, and drop it once neither is the case, not at a track change or while the client moves to another playing group. Servers normally activate the client's [preferred](#priority-and-activation) version of each role, but MAY omit a role at their discretion (e.g., based on whether the session is paired, deployment context, or operator policy). Checking `active_roles` is therefore required to determine what the client may actually use on this session. diff --git a/messaging.md b/messaging.md index 095cd2d..f511192 100644 --- a/messaging.md +++ b/messaging.md @@ -258,7 +258,7 @@ When `'pairing'` is in `activities`, `pairing.method` MUST be `'pairing_psk'` if **Worked example (`pairing_required`).** A Sentinel-keyed connection to a client with unpaired access disabled receives `activities: ['playback']` and `active_roles: ['player@v1']`. Under a hypothetical `unpaired_access: enabled`, `['playback']` would be an allowed set for the Sentinel PSK and the connection would be playback-capable, so the activation would be admissible: the client closes with `'pairing_required'`. -Servers SHOULD declare the minimal set of activities that reflects the connection's current purpose, and drop an activity as soon as that purpose ends. Admission between competing connections is decided by the highest-ranked declared activity (see [Multiple servers](connection.md#multiple-servers-server-initiated)), so keeping an unused activity declared would degrade multi-server cooperation. +Servers SHOULD declare the minimal set of activities that reflects the connection's current purpose, and drop an activity as soon as that purpose ends. Admission between competing connections is decided by the highest-ranked declared activity (see [Multiple servers](connection.md#multiple-servers-server-initiated)), so keeping an unused activity declared would degrade multi-server cooperation. A server SHOULD declare `'playback'` while the client's group is playing or while it wants the client's `source` to stream, whatever roles are active, and drop it once neither is the case, not at a track change or while the client moves to another playing group. Servers normally activate the client's [preferred](README.md#priority-and-activation) version of each role, but MAY omit a role at their discretion (e.g., based on whether the session is paired, deployment context, or operator policy). Checking `active_roles` is therefore required to determine what the client may actually use on this session. From 328cf4d41acd1204b8edec74f25efa9d5dfc0082 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 20 Oct 2026 09:43:05 +0200 Subject: [PATCH 6/8] fix: keep one admitted connection as the default --- README.md | 4 +--- connection.md | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fd4489a..0bae48f 100644 --- a/README.md +++ b/README.md @@ -182,13 +182,11 @@ Clients MUST NOT manually connect to servers while advertising `_sendspin._tcp`. #### Multiple servers (server-initiated) -A client admits connections by the [`activities`](#server--client-serveractivate) they declare: +A client admits one connection at a time. It MAY admit more, as long as the [`activities`](#server--client-serveractivate) the connections declare follow these rules: - At most one admitted connection has `'playback'` in its `activities`, and at most one has `'pairing'`. One connection MAY have both. - A connection with empty `activities` is admitted only while no other connection is admitted. -A client MAY also admit only one connection at a time. - Clients MUST persistently store the `server_id` of the server that most recently held an admitted connection while `'playback'` was among its `activities` (the "last-playback server"). When a new server connects, the client lets the handshake complete before applying admission; the new connection is provisional until its first [`server/activate`](#server--client-serveractivate) declares its activities. The client arbitrates that activation and every later one, and MUST NOT arbitrate an activation that is not [admissible](#server--client-serveractivate). diff --git a/connection.md b/connection.md index 1939f6c..9d99380 100644 --- a/connection.md +++ b/connection.md @@ -22,13 +22,11 @@ Clients MUST NOT manually connect to servers while advertising `_sendspin._tcp`. #### Multiple servers (server-initiated) -A client admits connections by the [`activities`](messaging.md#server--client-serveractivate) they declare: +A client admits one connection at a time. It MAY admit more, as long as the [`activities`](messaging.md#server--client-serveractivate) the connections declare follow these rules: - At most one admitted connection has `'playback'` in its `activities`, and at most one has `'pairing'`. One connection MAY have both. - A connection with empty `activities` is admitted only while no other connection is admitted. -A client MAY also admit only one connection at a time. - Clients MUST persistently store the `server_id` of the server that most recently held an admitted connection while `'playback'` was among its `activities` (the "last-playback server"). When a new server connects, the client lets the handshake complete before applying admission; the new connection is provisional until its first [`server/activate`](messaging.md#server--client-serveractivate) declares its activities. The client arbitrates that activation and every later one, and MUST NOT arbitrate an activation that is not [admissible](messaging.md#server--client-serveractivate). From bff3b8a2feba59e9801c4a18dd8ede4a16a6e04a Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 21 Oct 2026 11:50:48 +0200 Subject: [PATCH 7/8] fix: ignore player commands without `'playback'` while another plays --- README.md | 2 ++ roles/player/v1.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/README.md b/README.md index 0bae48f..d3e9c26 100644 --- a/README.md +++ b/README.md @@ -1297,6 +1297,8 @@ Request the player to perform an action, e.g., change volume or mute state. The server MUST NOT send a player command to a client before that client has sent its initial [`client/state`](#client--server-clientstate). +While another admitted connection has `'playback'` in its [`activities`](#server--client-serveractivate), the client MUST ignore player commands from a connection without `'playback'`. + ### Server → Client: `stream/start` player object The `player` object in [`stream/start`](#server--client-streamstart) has this structure: diff --git a/roles/player/v1.md b/roles/player/v1.md index 384a831..1118995 100644 --- a/roles/player/v1.md +++ b/roles/player/v1.md @@ -80,6 +80,8 @@ Request the player to perform an action, e.g., change volume or mute state. The server MUST NOT send a player command to a client before that client has sent its initial [`client/state`](../../messaging.md#client--server-clientstate). +While another admitted connection has `'playback'` in its [`activities`](../../messaging.md#server--client-serveractivate), the client MUST ignore player commands from a connection without `'playback'`. + ### Server → Client: `stream/start` player object The `player` object in [`stream/start`](../../messaging.md#server--client-streamstart) has this structure: From e08fa968108bbe7f1530b42d9e61ddce79e42c21 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 21 Oct 2026 12:03:32 +0200 Subject: [PATCH 8/8] fix: ignore audio streams and source `start` without `'playback'` --- README.md | 4 +++- messaging.md | 2 ++ roles/source/v1.md | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d3e9c26..ab936ac 100644 --- a/README.md +++ b/README.md @@ -724,6 +724,8 @@ Starts a stream for one or more roles. If sent for a role that already has an ac The server MUST NOT send `stream/start` unless the latest [`client/state`](#client--server-clientstate) it has received reports `available: true`, and MUST NOT include the `player` or `visualizer` object unless `'playback'` is in the connection's `activities`. +On a connection without `'playback'`, the client MUST ignore the `player` and `visualizer` objects in `stream/start` and discard player audio and visualization data. + Each role's stream configuration is derived from what the client reports about itself: the role's support object in [`client/hello`](#client--server-clienthello) for constant capabilities, and the role's [`client/state`](#client--server-clientstate) object for the stream-configuration fields the client may change during the connection (each role may define both). After a role that defines a `client/state` object is added or re-added to `active_roles`, the server MUST wait for the [`client/state`](#client--server-clientstate) update the activation requires before starting that role's stream, so it does not start from stale state. When a `client/state` changes a role's stream-configuration fields while a stream is active for that role, the server re-derives the stream configuration and, if it changed, sends a `stream/start` with the new configuration. When no stream is active for the role, the server MUST NOT start one in response; the updated state applies to the next stream it starts for that role. Clients may change their stream-configuration fields to adapt to changing network conditions, CPU constraints, or display requirements. The server maintains separate encoding for each client, allowing heterogeneous device capabilities within the same group. @@ -1464,7 +1466,7 @@ A `start` received while a start authorization is pending or the input stream is The server MUST NOT send `start` unless the source role is active, `'playback'` is in the connection's [`activities`](#server--client-serveractivate), it has received the source object in the [`client/state`](#client--server-clientstate) update required by that activation, and the latest `client/state` it received reports `available: true`. -A client MUST ignore `start` received while it is unavailable. Becoming available again does not resume streaming without a new `start`. +A client MUST ignore `start` received while it is unavailable or while `'playback'` is not in the connection's `activities`. Becoming available again does not resume streaming without a new `start`. #### Default streaming behavior diff --git a/messaging.md b/messaging.md index f511192..3f53440 100644 --- a/messaging.md +++ b/messaging.md @@ -400,6 +400,8 @@ Starts a stream for one or more roles. If sent for a role that already has an ac The server MUST NOT send `stream/start` unless the latest [`client/state`](#client--server-clientstate) it has received reports `available: true`, and MUST NOT include the `player` or `visualizer` object unless `'playback'` is in the connection's `activities`. +On a connection without `'playback'`, the client MUST ignore the `player` and `visualizer` objects in `stream/start` and discard player audio and visualization data. + Each role's stream configuration is derived from what the client reports about itself: the role's support object in [`client/hello`](#client--server-clienthello) for constant capabilities, and the role's [`client/state`](#client--server-clientstate) object for the stream-configuration fields the client may change during the connection (each role may define both). After a role that defines a `client/state` object is added or re-added to `active_roles`, the server MUST wait for the [`client/state`](#client--server-clientstate) update the activation requires before starting that role's stream, so it does not start from stale state. When a `client/state` changes a role's stream-configuration fields while a stream is active for that role, the server re-derives the stream configuration and, if it changed, sends a `stream/start` with the new configuration. When no stream is active for the role, the server MUST NOT start one in response; the updated state applies to the next stream it starts for that role. Clients may change their stream-configuration fields to adapt to changing network conditions, CPU constraints, or display requirements. The server maintains separate encoding for each client, allowing heterogeneous device capabilities within the same group. diff --git a/roles/source/v1.md b/roles/source/v1.md index 5eda0f6..46c2c26 100644 --- a/roles/source/v1.md +++ b/roles/source/v1.md @@ -56,7 +56,7 @@ A `start` received while a start authorization is pending or the input stream is The server MUST NOT send `start` unless the source role is active, `'playback'` is in the connection's [`activities`](../../messaging.md#server--client-serveractivate), it has received the source object in the [`client/state`](../../messaging.md#client--server-clientstate) update required by that activation, and the latest `client/state` it received reports `available: true`. -A client MUST ignore `start` received while it is unavailable. Becoming available again does not resume streaming without a new `start`. +A client MUST ignore `start` received while it is unavailable or while `'playback'` is not in the connection's `activities`. Becoming available again does not resume streaming without a new `start`. #### Default streaming behavior