Added 'connected devices' to external lib
CI / check (push) Successful in 1m59s

This commit is contained in:
ab
2026-09-10 17:03:53 +03:00
parent 95bcc9bac4
commit b191c10ba4
10 changed files with 780 additions and 44 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "music-dht"
version = "0.4.1"
version = "0.5.0"
description = "Distributed music library search: a Kademlia-style DHT on top of federation-net"
readme = "README.md"
documentation = "https://docs.rs/music-dht"
+9 -1
View File
@@ -183,7 +183,15 @@ to know that the integration exists.
Protocol v1 uses `furumi/sync/1`; v2 uses `furumi/sync/2`. During migration,
new clients should accept both ALPNs and only send `ListenRecorded` when
`supports_listen_history(peer.protocol_version)` returns true. Likes,
playlists, membership and playback coordination remain compatible with v1.
playlists and membership remain compatible with v1. Profile version 3 adds
versioned personal playback coordination over the existing JSON sync stream;
legacy unfenced playback messages are ignored by upgraded clients.
`music_dht::playback` provides the shared pure ownership engine and generic
snapshot/command envelopes. Configure startup and failover with `Config`, feed
actual output reports, and persist `Checkpoint` before applying decisions.
`Config::passive()` is intended for always-running gateways. See the workspace
`PLAYBACK_PROTOCOL.md` for invariants, configuration and migration details.
## Verification
+8 -38
View File
@@ -27,7 +27,7 @@ pub const SYNC_ALPN_V2: &[u8] = b"furumi/sync/2";
pub const SYNC_ALPN: &[u8] = SYNC_ALPN_V2;
/// Version of the personal-device sync wire protocol.
pub const DEVICE_SYNC_PROTOCOL_VERSION: u16 = 2;
pub const DEVICE_SYNC_PROTOCOL_VERSION: u16 = 3;
/// Minimum actual listening time retained for an interrupted listen.
pub const MIN_RECORDED_LISTEN_MS: i64 = 5_000;
@@ -327,43 +327,10 @@ pub struct PlaybackStateWire {
pub repeat: PlaybackRepeat,
}
/// Playback status broadcast by a device during sync.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlaybackSnapshot {
/// Device id.
pub device_id: String,
/// Device name.
pub device_name: String,
/// Whether this device considers itself active.
pub active: bool,
/// Update timestamp in unix milliseconds.
pub updated_at_ms: i64,
/// Playback state.
pub state: PlaybackStateWire,
}
/// Playback command delivered through the sync op log.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PlaybackCommand {
/// Replace target state. `seek` means the target should also seek audio.
SetState {
/// New state.
state: PlaybackStateWire,
/// Whether to seek the audio sink to `state.position_secs`.
#[serde(default)]
seek: bool,
},
/// Another device became active.
ActiveChanged {
/// New active device id.
active_device_id: String,
/// New active device name.
active_device_name: String,
/// Transferred playback state.
state: PlaybackStateWire,
},
}
/// Playback snapshot with the common coordination envelope.
pub type PlaybackSnapshot = crate::playback::Snapshot<PlaybackStateWire>;
/// Shared command vocabulary with the portable music queue DTO.
pub type PlaybackCommand = crate::playback::Command<PlaybackStateWire>;
/// One operation in the device-sync log.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -461,6 +428,9 @@ pub enum SyncOpPayload {
target_device_id: String,
/// Command.
command: PlaybackCommand,
/// Ownership and ordering fence. Legacy commands cannot change v3 playback.
#[serde(default)]
authority: Option<crate::playback::CommandStamp>,
},
/// Append one immutable listening-history event.
///
+2
View File
@@ -137,3 +137,5 @@ pub use federation_net::{
NetworkHealthSnapshot, NetworkHealthState, NetworkId, PeerTicket, RecvStream, RendezvousConfig,
SecretKey, SendStream, StreamAcceptor,
};
pub mod playback;
+624
View File
@@ -0,0 +1,624 @@
//! Transport-independent personal playback coordination.
//!
//! This is an eventually consistent register, not a distributed lock. During a
//! partition multiple outputs may play. Once announcements are delivered, all
//! participants adopt the greatest claim and only its owner may keep playing.
//! Applications authenticate membership, persist [`DurableState`] before acting,
//! feed monotonic time and actual output heartbeats, and execute ownership changes.
//! Relaying an announcement must never manufacture an output heartbeat.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// Version of the additive playback coordination envelope.
pub const VERSION: u16 = 1;
/// Maximum command senders retained within one ownership term.
pub const MAX_COMMAND_SENDERS: usize = 128;
/// Local choices; these do not weaken conflict resolution or command fencing.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
/// Claim idle playback once after discovery at application startup.
pub claim_on_startup: bool,
/// Become the output when its owner stops producing heartbeats.
pub automatic_failover: bool,
/// A newly started output may take over a paused owner.
pub take_paused_on_startup: bool,
/// Wait for initial peer discovery before considering a startup claim.
pub discovery_ms: u64,
/// Missing output heartbeats, not server connectivity, trigger failover.
pub owner_timeout_ms: u64,
}
impl Default for Config {
fn default() -> Self {
Self {
claim_on_startup: true,
automatic_failover: true,
take_paused_on_startup: true,
discovery_ms: 3_000,
owner_timeout_ms: 120_000,
}
}
}
impl Config {
/// Shared candidate policy, also used when a gateway selects a browser.
/// `owner` is `(actually_playing, milliseconds_since_fresh_output_report)`.
/// Startup intent is a one-shot event, never inferred from reconnection.
pub fn should_claim(&self, startup: bool, owner: Option<(bool, u64)>) -> bool {
let vacant = owner.is_none();
let expired = owner.is_some_and(|(_, age)| age >= self.owner_timeout_ms.max(1));
let idle = owner.is_some_and(|(playing, _)| !playing);
(startup && (vacant || expired || (self.take_paused_on_startup && idle)))
|| (self.automatic_failover && (vacant || expired))
}
/// An always-running gateway only transfers ownership on user intent.
pub fn passive() -> Self {
Self {
claim_on_startup: false,
automatic_failover: false,
..Self::default()
}
}
}
/// A Lamport term with a deterministic tie-breaker. No wall-clock ordering.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Claim {
/// Greater than every term the issuer has observed.
pub counter: u64,
/// Authenticated device issuing the claim.
pub issuer: String,
/// Device allowed to render personal audio in this term.
pub owner: String,
}
impl Claim {
fn valid(&self) -> bool {
self.counter > 0
&& [&self.issuer, &self.owner]
.into_iter()
.all(|id| !id.is_empty() && id.len() <= 256)
}
}
/// Persist atomically before publishing a new claim or applying a command.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct DurableState {
/// Winning claim, retained even after the owner becomes unavailable.
pub claim: Option<Claim>,
/// Last accepted command sequence per sender in the winning term.
pub commands: BTreeMap<String, u64>,
/// Locally authored command sequence; never reset on ownership changes.
pub command_sequence: u64,
/// Locally authored output heartbeat; retained across process restarts.
pub heartbeat_sequence: u64,
}
/// Gossip carried beside a playback snapshot, including by controllers.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Announcement {
/// Envelope version; unknown versions are ignored.
pub version: u16,
/// Greatest observed ownership claim.
pub claim: Option<Claim>,
/// Advances only when the sender's actual output reports activity.
pub heartbeat: u64,
/// Sender can currently render audio; a server alone cannot.
pub available: bool,
/// Sender is actually playing unpaused audio.
pub playing: bool,
}
/// Commands are scoped to an ownership term and ordered per sender.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandStamp {
/// Coordination envelope version.
pub version: u16,
/// Ownership term in which the command is valid.
pub claim: Claim,
/// Increasing sequence of the authenticated operation origin.
pub sequence: u64,
}
/// Application checkpoint scoped to a trusted-device group.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Checkpoint {
/// Application-defined membership scope. Do not reuse state across groups.
pub scope: String,
/// Durable coordinator state in that scope.
pub state: DurableState,
}
/// Shared snapshot envelope; applications supply their portable queue DTO.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Snapshot<S> {
/// Authenticated sender, checked against the transport by the adapter.
pub device_id: String,
/// Display name only; never an identity or ordering key.
pub device_name: String,
/// Compatibility/UI projection. Ownership comes only from coordination.
pub active: bool,
/// Position extrapolation timestamp, never used for claim ordering.
pub updated_at_ms: i64,
/// Output state or the controller's cached presentation state.
pub state: S,
/// Absent on older clients and on the independently scoped Jam protocol.
#[serde(default)]
pub coordination: Option<Announcement>,
}
/// Shared personal/Jam command vocabulary. Personal commands additionally
/// require [`CommandStamp`]; Jam keeps its own capability and authority scope.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Command<S> {
/// Update the current owner's output state.
SetState {
/// Queue and transport state.
state: S,
/// Also seek the actual audio output.
#[serde(default)]
seek: bool,
},
/// Explicit transfer with state for the newly selected output.
ActiveChanged {
/// Selected output identity.
active_device_id: String,
/// Selected output display name.
active_device_name: String,
/// State to render after transfer.
state: S,
},
}
/// Pure engine. All times are monotonic milliseconds supplied by the adapter.
#[derive(Debug, Clone)]
pub struct Engine {
id: String,
config: Config,
durable: DurableState,
started_at: u64,
startup_pending: bool,
owner_seen_at: u64,
owner_heartbeat: u64,
owner_playing: bool,
available: bool,
playing: bool,
output_report: u64,
heartbeat_sequence: u64,
}
impl Engine {
/// Restore durable state, but never restore persisted liveness timestamps.
pub fn new(id: String, config: Config, durable: DurableState, now: u64) -> Self {
let heartbeat_sequence = durable.heartbeat_sequence;
let owner_playing = durable.claim.is_some();
Self {
id,
startup_pending: config.claim_on_startup,
config,
durable,
started_at: now,
owner_seen_at: now,
owner_heartbeat: 0,
owner_playing,
available: false,
playing: false,
output_report: 0,
heartbeat_sequence,
}
}
/// Durable checkpoint. Save it before executing decisions.
pub fn durable(&self) -> &DurableState {
&self.durable
}
/// Winning output, whether currently reachable or not.
pub fn owner(&self) -> Option<&str> {
self.durable
.claim
.as_ref()
.map(|claim| claim.owner.as_str())
}
/// Whether this device currently owns personal playback.
pub fn is_owner(&self) -> bool {
self.owner() == Some(self.id.as_str())
}
/// Update local capabilities without interpreting connectivity as playback.
pub fn set_output(&mut self, available: bool, playing: bool) {
if self.available && !available {
self.advance_heartbeat();
}
self.available = available;
self.playing = available && playing;
}
/// Feed one fresh report from the actual local output, even if paused.
pub fn heartbeat(&mut self, now: u64) {
if self.available {
self.advance_heartbeat();
if self.is_owner() {
self.owner_seen_at = now;
self.owner_playing = self.playing;
}
}
}
fn advance_heartbeat(&mut self) {
self.heartbeat_sequence = self.heartbeat_sequence.saturating_add(1);
// Reserve a range before publishing. Restart skips unused values.
if self.heartbeat_sequence > self.durable.heartbeat_sequence {
self.durable.heartbeat_sequence = self.heartbeat_sequence.saturating_add(1024);
}
}
/// Merge gossip received from an authenticated trusted device.
/// Returns false for unsupported or malformed envelopes.
pub fn observe(&mut self, sender: &str, message: &Announcement, now: u64) -> bool {
if message.version != VERSION || message.claim.as_ref().is_some_and(|c| !c.valid()) {
return false;
}
if let Some(claim) = &message.claim {
self.merge(claim, now);
}
if self.owner() == Some(sender)
&& self.durable.claim == message.claim
&& message.heartbeat > self.owner_heartbeat
{
self.owner_heartbeat = message.heartbeat;
self.owner_playing = message.available && message.playing;
if message.available {
self.owner_seen_at = now;
}
}
true
}
fn merge(&mut self, claim: &Claim, now: u64) {
if self
.durable
.claim
.as_ref()
.is_none_or(|current| claim > current)
{
self.durable.claim = Some(claim.clone());
self.durable.commands.clear();
self.owner_seen_at = now;
self.owner_heartbeat = 0;
// A transferred owner gets a full grace period before failover.
self.owner_playing = true;
}
}
/// Apply explicit user intent. Target eligibility is checked by the adapter.
/// Returns false rather than wrapping an exhausted Lamport counter.
pub fn transfer(&mut self, target: &str, now: u64) -> bool {
if target.is_empty() || target.len() > 256 {
return false;
}
let Some(counter) = self
.durable
.claim
.as_ref()
.map_or(Some(1), |c| c.counter.checked_add(1))
else {
return false;
};
let claim = Claim {
counter,
issuer: self.id.clone(),
owner: target.into(),
};
self.merge(&claim, now);
self.startup_pending = false;
true
}
/// A real browser/application startup, never a server timer or reconnect.
pub fn request_startup(&mut self) {
self.startup_pending = true;
}
/// Feed a monotonically increasing output-report identifier. Polling the
/// same cached report cannot renew owner liveness.
pub fn output_report(&mut self, report: u64, now: u64) {
if report > self.output_report {
self.output_report = report;
self.heartbeat(now);
}
}
/// Fence an effect again immediately before executing it in an event loop.
pub fn command_is_current(&self, sender: &str, stamp: &CommandStamp) -> bool {
self.durable.claim.as_ref() == Some(&stamp.claim)
&& self.durable.commands.get(sender) == Some(&stamp.sequence)
}
/// Whether an owner snapshot is at least as recent as accepted liveness.
pub fn announcement_is_current(&self, sender: &str, message: &Announcement) -> bool {
self.owner() == Some(sender)
&& self.durable.claim == message.claim
&& message.heartbeat >= self.owner_heartbeat
}
/// Evaluate startup/failover. A losing node cannot immediately reclaim:
/// the winning term always receives an entire heartbeat grace period.
pub fn tick(&mut self, now: u64) -> bool {
if now.saturating_sub(self.started_at) < self.config.discovery_ms {
return false;
}
// A restored or relayed owner has unknown activity until it reports.
// Keep startup intent pending through discovery, rather than treating
// an unknown playing state as an idle output.
if self.startup_pending
&& self.owner().is_some()
&& !self.is_owner()
&& self.owner_heartbeat == 0
&& now.saturating_sub(self.owner_seen_at) < self.config.owner_timeout_ms.max(1)
{
return false;
}
let startup = self.startup_pending;
self.startup_pending = false;
let eligible = self.available && !self.is_owner();
let owner = self
.owner()
.map(|_| (self.owner_playing, now.saturating_sub(self.owner_seen_at)));
if eligible && self.config.should_claim(startup, owner) {
return self.transfer(&self.id.clone(), now);
}
false
}
/// Current gossip. Controllers repeat the claim, never the owner's heartbeat.
pub fn announcement(&self) -> Announcement {
Announcement {
version: VERSION,
claim: self.durable.claim.clone(),
heartbeat: self.heartbeat_sequence,
available: self.available,
playing: self.playing,
}
}
/// Stamp a command with the current ownership term.
pub fn stamp(&mut self) -> Option<CommandStamp> {
let claim = self.durable.claim.clone()?;
self.durable.command_sequence = self.durable.command_sequence.checked_add(1)?;
Some(CommandStamp {
version: VERSION,
claim,
sequence: self.durable.command_sequence,
})
}
/// Merge the referenced term and fence a command. Commands may carry a
/// previously issued claim ahead of its gossip (transport can reorder).
/// Creating claims remains exclusive to transfer/startup/failover.
/// Sender identity comes from the authenticated operation log.
pub fn accept_command(
&mut self,
sender: &str,
stamp: &CommandStamp,
handoff: bool,
now: u64,
) -> bool {
if stamp.version != VERSION
|| !stamp.claim.valid()
|| sender.is_empty()
|| sender.len() > 256
|| stamp.sequence == 0
{
return false;
}
if handoff && stamp.claim.issuer != sender {
return false;
}
self.merge(&stamp.claim, now);
if self.durable.claim.as_ref() != Some(&stamp.claim) {
return false;
}
if !self.durable.commands.contains_key(sender)
&& self.durable.commands.len() >= MAX_COMMAND_SENDERS
{
return false;
}
let previous = self.durable.commands.entry(sender.to_string()).or_default();
if stamp.sequence <= *previous {
return false;
}
*previous = stamp.sequence;
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn node(id: &str, config: Config) -> Engine {
let mut e = Engine::new(id.into(), config, DurableState::default(), 0);
e.set_output(true, false);
e
}
#[test]
fn partition_converges_without_reclaim_loop() {
let mut a = node("a", Config::default());
let mut b = node("b", Config::default());
assert!(a.tick(3_000));
assert!(b.tick(3_000));
a.set_output(true, true);
b.set_output(true, true);
a.heartbeat(3_001);
b.heartbeat(3_001);
let aa = a.announcement();
let bb = b.announcement();
a.observe("b", &bb, 3_002);
b.observe("a", &aa, 3_002);
assert_eq!(a.owner(), b.owner());
assert!(!a.is_owner());
assert!(b.is_owner());
assert!(!a.tick(3_003));
a.observe("a", &aa, 3_004);
assert_eq!(a.owner(), Some("b"));
}
#[test]
fn startup_respects_playing_but_can_take_paused() {
for playing in [true, false] {
let mut owner = node("a", Config::default());
owner.tick(3_000);
owner.set_output(true, playing);
owner.heartbeat(3_001);
let mut newcomer = node("b", Config::default());
newcomer.observe("a", &owner.announcement(), 2_000);
assert_eq!(newcomer.tick(3_000), !playing);
}
}
#[test]
fn passive_gateway_never_claims_without_intent() {
let mut web = node("web", Config::passive());
assert!(!web.tick(3_000));
assert!(!web.tick(1_000_000));
assert!(web.transfer("web", 1_000_001));
assert!(web.is_owner());
}
#[test]
fn duplicates_and_relays_do_not_extend_liveness() {
let mut a = node("a", Config::default());
a.tick(3_000);
a.set_output(true, true);
a.heartbeat(3_001);
let mut b = node("b", Config::default());
b.observe("a", &a.announcement(), 3_002);
b.tick(3_003);
b.observe("a", &a.announcement(), 100_000);
b.observe("relay", &a.announcement(), 122_000);
assert!(b.tick(123_003));
}
#[test]
fn commands_are_fenced_ordered_and_survive_restore() {
let mut a = node("a", Config::default());
a.tick(3_000);
let old = a.stamp().unwrap();
assert!(a.accept_command("a", &old, false, 3_001));
let mut restored = Engine::new("a".into(), Config::default(), a.durable().clone(), 4_000);
assert!(!restored.accept_command("a", &old, false, 4_001));
restored.transfer("b", 4_002);
assert!(!restored.accept_command("a", &old, false, 4_003));
let forged = restored.stamp().unwrap();
assert!(!restored.accept_command("spoof", &forged, true, 4_004));
}
#[test]
fn three_outputs_converge_in_every_delivery_order() {
for order in [
[0, 1, 2],
[0, 2, 1],
[1, 0, 2],
[1, 2, 0],
[2, 0, 1],
[2, 1, 0],
] {
let mut nodes = [
node("a", Config::default()),
node("b", Config::default()),
node("c", Config::default()),
];
for node in &mut nodes {
node.tick(3_000);
node.set_output(true, true);
node.heartbeat(3_001);
}
let messages = nodes.each_ref().map(Engine::announcement);
for node in &mut nodes {
for index in order {
node.observe(["a", "b", "c"][index], &messages[index], 4_000);
}
assert_eq!(node.owner(), Some("c"));
assert!(!node.tick(4_001));
}
assert_eq!(nodes.iter().filter(|node| node.is_owner()).count(), 1);
}
}
#[test]
fn cached_browser_reports_do_not_keep_a_suspended_output_alive() {
let mut web = node("web", Config::passive());
web.transfer("web", 0);
web.set_output(true, true);
web.output_report(1, 1);
let mut tui = node("tui", Config::default());
tui.observe("web", &web.announcement(), 2);
tui.tick(3_000);
for now in [30_000, 60_000, 90_000, 120_003] {
web.output_report(1, now);
tui.observe("web", &web.announcement(), now);
}
assert!(tui.tick(120_003));
web.observe("tui", &tui.announcement(), 120_004);
assert!(!web.is_owner());
}
#[test]
fn heartbeat_reservation_survives_restart() {
let mut a = node("a", Config::default());
a.tick(3_000);
a.heartbeat(3_001);
let before = a.announcement();
let mut restored = Engine::new("a".into(), Config::default(), a.durable().clone(), 0);
restored.set_output(true, false);
restored.heartbeat(1);
assert!(restored.announcement().heartbeat > before.heartbeat);
}
#[test]
fn command_can_arrive_before_handoff_and_older_state_cannot_overwrite_it() {
let mut a = node("a", Config::default());
a.transfer("b", 1);
let handoff = a.stamp().unwrap();
let play = a.stamp().unwrap();
let mut b = node("b", Config::default());
assert!(b.accept_command("a", &play, false, 2));
assert!(b.is_owner());
assert!(!b.accept_command("a", &handoff, true, 3));
assert!(b.command_is_current("a", &play));
let mut incompatible = a.stamp().unwrap();
incompatible.version += 1;
assert!(!b.accept_command("a", &incompatible, false, 4));
}
#[test]
fn disabled_automatic_policy_and_unavailable_outputs_never_claim() {
let mut disabled = node(
"a",
Config {
claim_on_startup: false,
automatic_failover: false,
..Config::default()
},
);
assert!(!disabled.tick(1_000_000));
let mut unavailable = node("b", Config::default());
unavailable.set_output(false, false);
assert!(!unavailable.tick(1_000_000));
}
#[test]
fn command_sender_memory_is_bounded() {
let mut a = node("a", Config::default());
a.tick(3_000);
let stamp = a.stamp().unwrap();
for index in 0..MAX_COMMAND_SENDERS {
assert!(a.accept_command(&format!("sender{index}"), &stamp, false, 3_001));
}
assert!(!a.accept_command("overflow", &stamp, false, 3_002));
assert_eq!(a.durable().commands.len(), MAX_COMMAND_SENDERS);
}
}