Fix connected devices
This commit is contained in:
@@ -190,6 +190,13 @@ must propagate and converge, not an ephemeral server-side session flag.
|
|||||||
|
|
||||||
## Playback across devices
|
## Playback across devices
|
||||||
|
|
||||||
|
A persisted device identity has exactly one live coordinator. The TUI holds
|
||||||
|
an OS file lock beside its device-sync database for the entire runtime; two
|
||||||
|
installations using the same user data directory cannot open it concurrently.
|
||||||
|
Network polling is independent per trusted peer, with one in-flight exchange
|
||||||
|
per peer and a bounded deadline. A stalled peer must not delay the next poll
|
||||||
|
of a healthy output. Poll tasks are cancelled when federation shuts down.
|
||||||
|
|
||||||
Playback has one logical state but remains physically local to the device
|
Playback has one logical state but remains physically local to the device
|
||||||
producing audio.
|
producing audio.
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,15 @@ controllers when another device is playing. Missing output reports trigger
|
|||||||
automatic failover; concurrent claims converge to one owner. The web gateway
|
automatic failover; concurrent claims converge to one owner. The web gateway
|
||||||
uses a passive server profile and reports actual browser activity.
|
uses a passive server profile and reports actual browser activity.
|
||||||
|
|
||||||
|
Only one TUI process may use a device identity. Installed and locally built
|
||||||
|
binaries use the same user data directory: close the installed player before
|
||||||
|
starting a development build. An OS file lock prevents duplicate coordinators
|
||||||
|
and is released automatically on exit or a crash. Older builds do not take
|
||||||
|
this lock, so close those explicitly when upgrading.
|
||||||
|
|
||||||
|
Connected-device polling runs independently per peer, so an offline or stalled
|
||||||
|
device does not postpone updates to live players.
|
||||||
|
|
||||||
The existing device menu handles manual transfers. Advanced policy can be set
|
The existing device menu handles manual transfers. Advanced policy can be set
|
||||||
in `settings.toml` without changing the UI:
|
in `settings.toml` without changing the UI:
|
||||||
|
|
||||||
@@ -220,6 +229,21 @@ output ownership. frid's `PLAYBACK_PROTOCOL.md` describes the protocol, adapter
|
|||||||
contract and rollout. Local Cargo patches are only for development; publish
|
contract and rollout. Local Cargo patches are only for development; publish
|
||||||
frid and bump the client dependency versions before releasing these changes.
|
frid and bump the client dependency versions before releasing these changes.
|
||||||
|
|
||||||
|
Run the local protocol checks from the TUI repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test localhost_devices_exchange_state_and_handoff
|
||||||
|
python scripts/test_device_interop.py ../furumusic
|
||||||
|
```
|
||||||
|
|
||||||
|
The first test uses real iroh streams, isolated SQLite databases, ownership
|
||||||
|
handoff, and a stalled peer. The second builds both player test binaries and
|
||||||
|
exchanges their actual JSON messages over `127.0.0.1`: queue metadata,
|
||||||
|
bidirectional handoff, pause/seek and duplicate command fencing. It exercises
|
||||||
|
the TUI command adapter and web player hub, but does not start a browser or
|
||||||
|
PostgreSQL and does not cover web database migrations. Both tests use temporary
|
||||||
|
identities and data; no running player or user library is used.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Furumi is released under the
|
Furumi is released under the
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Build and run the real TUI and web protocol adapters against each other.
|
||||||
|
|
||||||
|
Usage: python scripts/test_device_interop.py [path/to/furumusic]
|
||||||
|
No user databases, accounts, audio outputs, or external servers are used.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
def test_binary(repo):
|
||||||
|
result = subprocess.run(
|
||||||
|
["cargo", "test", "--locked", "--no-run", "--message-format=json"],
|
||||||
|
cwd=repo, text=True, encoding="utf-8", stdout=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
result.check_returncode()
|
||||||
|
binaries = []
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
event = json.loads(line)
|
||||||
|
if event.get("reason") == "compiler-artifact" and event.get("profile", {}).get("test") and event.get("executable"):
|
||||||
|
binaries.append(event["executable"])
|
||||||
|
if len(binaries) != 1:
|
||||||
|
raise RuntimeError(f"Expected one player test binary in {repo}, found {binaries}")
|
||||||
|
return binaries[0]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
tui = Path(__file__).resolve().parents[1]
|
||||||
|
web = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else tui.parent / "furumusic"
|
||||||
|
tui_bin, web_bin = test_binary(tui), test_binary(web)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="furumi-interop-") as directory:
|
||||||
|
env = dict(os.environ, FURUMI_INTEROP_DIR=directory)
|
||||||
|
processes = []
|
||||||
|
try:
|
||||||
|
for binary, test in [(web_bin, "federation::devices::interop_tests::localhost_tui_peer"),
|
||||||
|
(tui_bin, "devices::interop_tests::localhost_web_peer")]:
|
||||||
|
listing = subprocess.check_output([binary, "--list"], text=True, encoding="utf-8")
|
||||||
|
if f"{test}: test" not in listing:
|
||||||
|
raise RuntimeError(f"Required test {test} is missing from {binary}")
|
||||||
|
processes.append(subprocess.Popen(
|
||||||
|
[binary, "--ignored", "--exact", test, "--nocapture"], env=env,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
|
text=True, encoding="utf-8",
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
))
|
||||||
|
codes = []
|
||||||
|
for process in processes:
|
||||||
|
output, _ = process.communicate(timeout=45)
|
||||||
|
print(output, end="")
|
||||||
|
codes.append(process.returncode)
|
||||||
|
if any(codes):
|
||||||
|
raise RuntimeError(f"Cross-player test failed: exit codes {codes}")
|
||||||
|
finally:
|
||||||
|
for process in processes:
|
||||||
|
if process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
print("PASS: TUI <-> WEB state, bidirectional handoff, pause/seek, duplicate fencing")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+62
-6
@@ -82,6 +82,10 @@ pub struct DeviceSyncStatus {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct DeviceSync {
|
pub struct DeviceSync {
|
||||||
|
// A device identity has exactly one coordinator, including across binaries
|
||||||
|
// launched from different installation directories. The OS releases this
|
||||||
|
// lock on crashes; the file itself is deliberately never removed.
|
||||||
|
_identity_lock: Option<Arc<std::fs::File>>,
|
||||||
conn: Arc<std::sync::Mutex<Connection>>,
|
conn: Arc<std::sync::Mutex<Connection>>,
|
||||||
library: Arc<Library>,
|
library: Arc<Library>,
|
||||||
event_tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<AppEvent>>>>,
|
event_tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<AppEvent>>>>,
|
||||||
@@ -599,16 +603,33 @@ fn default_db_path() -> PathBuf {
|
|||||||
.unwrap_or_else(|| PathBuf::from("devices").join("sync.sqlite3"))
|
.unwrap_or_else(|| PathBuf::from("devices").join("sync.sqlite3"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn acquire_identity_lock(database: &std::path::Path) -> Result<std::fs::File> {
|
||||||
|
let path = database.with_extension("lock");
|
||||||
|
let file = std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(&path)
|
||||||
|
.with_context(|| format!("opening device identity lock {}", path.display()))?;
|
||||||
|
file.try_lock().with_context(|| format!(
|
||||||
|
"Cannot acquire device identity lock {}. Another Furumi instance may already be using this device. Close it before launching another binary; two players must not share one device identity.", path.display()
|
||||||
|
))?;
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
|
||||||
impl DeviceSync {
|
impl DeviceSync {
|
||||||
pub fn new(library: Arc<Library>) -> Result<Arc<Self>> {
|
pub fn new(library: Arc<Library>) -> Result<Arc<Self>> {
|
||||||
let path = default_db_path();
|
let path = default_db_path();
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
let identity_lock = acquire_identity_lock(&path)?;
|
||||||
let conn =
|
let conn =
|
||||||
Connection::open(&path).with_context(|| format!("opening {}", path.display()))?;
|
Connection::open(&path).with_context(|| format!("opening {}", path.display()))?;
|
||||||
init_schema(&conn)?;
|
init_schema(&conn)?;
|
||||||
let sync = Arc::new(Self {
|
let sync = Arc::new(Self {
|
||||||
|
_identity_lock: Some(Arc::new(identity_lock)),
|
||||||
conn: Arc::new(std::sync::Mutex::new(conn)),
|
conn: Arc::new(std::sync::Mutex::new(conn)),
|
||||||
library,
|
library,
|
||||||
event_tx: Arc::new(std::sync::Mutex::new(None)),
|
event_tx: Arc::new(std::sync::Mutex::new(None)),
|
||||||
@@ -2829,13 +2850,45 @@ pub async fn sync_loop(
|
|||||||
transport_stats: Arc<crate::federation::TransportStats>,
|
transport_stats: Arc<crate::federation::TransportStats>,
|
||||||
) {
|
) {
|
||||||
let mut interval = tokio::time::interval(SYNC_INTERVAL);
|
let mut interval = tokio::time::interval(SYNC_INTERVAL);
|
||||||
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
// Independent polls: an offline device must not hold up live outputs.
|
||||||
|
// JoinSet aborts outstanding IO when federation stops or restarts.
|
||||||
|
let mut polls = tokio::task::JoinSet::new();
|
||||||
|
let mut active = std::collections::HashMap::new();
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
tokio::select! {
|
||||||
if let Err(err) = sync
|
_ = interval.tick() => {
|
||||||
.sync_once(Arc::clone(&service), Arc::clone(&transport_stats))
|
match sync.active_remote_devices() {
|
||||||
.await
|
Ok(devices) => for device in devices {
|
||||||
{
|
if device.endpoint_ticket.trim().is_empty()
|
||||||
tracing::debug!("personal sync tick failed: {err:#}");
|
|| active.values().any(|id| id == &device.device_id) { continue; }
|
||||||
|
let device_id = device.device_id.clone();
|
||||||
|
let sync = Arc::clone(&sync);
|
||||||
|
let service = Arc::clone(&service);
|
||||||
|
let stats = Arc::clone(&transport_stats);
|
||||||
|
let handle = polls.spawn(async move {
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), sync.sync_device(service, &device, stats))
|
||||||
|
.await.context("device sync exchange timed out")?
|
||||||
|
});
|
||||||
|
active.insert(handle.id(), device_id);
|
||||||
|
},
|
||||||
|
Err(err) => { let _ = sync.set_last_error(Some(format!("{err:#}"))); }
|
||||||
|
}
|
||||||
|
if let Err(err) = sync.gc_tombstones() {
|
||||||
|
tracing::debug!("personal sync cleanup failed: {err:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(completed) = polls.join_next_with_id(), if !polls.is_empty() => {
|
||||||
|
let (task, result) = match completed {
|
||||||
|
Ok((task, result)) => (task, result),
|
||||||
|
Err(error) => (error.id(), Err(anyhow::Error::from(error))),
|
||||||
|
};
|
||||||
|
if let Some(device_id) = active.remove(&task)
|
||||||
|
&& let Err(err) = result {
|
||||||
|
tracing::debug!(device = %device_id, "device sync failed: {err:#}");
|
||||||
|
let _ = sync.set_last_error(Some(format!("{}: {err:#}", short_id(&device_id))));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(tx) = lock(&sync.event_tx).as_ref() {
|
if let Some(tx) = lock(&sync.event_tx).as_ref() {
|
||||||
let _ = tx.send(AppEvent::DeviceSyncStatus(sync.status()));
|
let _ = tx.send(AppEvent::DeviceSyncStatus(sync.status()));
|
||||||
@@ -3758,6 +3811,9 @@ fn base64url_decode(value: &str) -> Result<Vec<u8>> {
|
|||||||
#[path = "devices/tests.rs"]
|
#[path = "devices/tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod interop_tests;
|
||||||
|
|
||||||
fn playback_clock() -> u64 {
|
fn playback_clock() -> u64 {
|
||||||
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
||||||
START
|
START
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//! Cross-binary wire test. Run with scripts/test_device_interop.py; the other
|
||||||
|
//! endpoint is compiled from furumusic's real protocol types and player hub.
|
||||||
|
use super::*;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "run scripts/test_device_interop.py to start both player test binaries"]
|
||||||
|
async fn localhost_web_peer() {
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
|
let dir = PathBuf::from(std::env::var("FURUMI_INTEROP_DIR").expect("interop runner"));
|
||||||
|
let address = loop {
|
||||||
|
if let Ok(address) = std::fs::read_to_string(dir.join("web-address")) {
|
||||||
|
break address;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
};
|
||||||
|
let mut stream = tokio::net::TcpStream::connect(address.trim())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
init_schema(&conn).unwrap();
|
||||||
|
let sync = DeviceSync {
|
||||||
|
_identity_lock: None,
|
||||||
|
conn: Arc::new(std::sync::Mutex::new(conn)),
|
||||||
|
library: Arc::new(Library::open(&dir.join("interop-library.sqlite3")).unwrap()),
|
||||||
|
event_tx: Default::default(),
|
||||||
|
playback: Default::default(),
|
||||||
|
};
|
||||||
|
let id = sync.ensure_identity().unwrap();
|
||||||
|
let (tx, mut events) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
sync.set_event_tx(tx);
|
||||||
|
sync.claim_playback(&id.device_id).unwrap();
|
||||||
|
sync.playback_tick(true, true).unwrap();
|
||||||
|
let mut state: PlaybackStateWire = serde_json::from_value(serde_json::json!({
|
||||||
|
"queue": [{ "id": 7, "title": "Interop track", "duration_seconds": 123.5,
|
||||||
|
"release_id": 1, "release_title": "Interop album", "artist_names": ["Interop artist"],
|
||||||
|
"content_id": "b3:0000000000000000000000000000000000000000000000000000000000000000" }],
|
||||||
|
"queue_pos": 0, "playing": true, "paused": false,
|
||||||
|
"position_secs": 42.5, "volume": 73, "shuffle": true, "repeat": "all"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
for phase in 0..3 {
|
||||||
|
sync.publish_playback(PlaybackSnapshot {
|
||||||
|
device_id: id.device_id.clone(),
|
||||||
|
device_name: "TUI interop".into(),
|
||||||
|
active: true,
|
||||||
|
updated_at_ms: now_ms(),
|
||||||
|
state: state.clone(),
|
||||||
|
coordination: None,
|
||||||
|
});
|
||||||
|
let hello = WireMessage::Hello {
|
||||||
|
group_id: id.group_id.clone(),
|
||||||
|
profile: sync.own_profile("").unwrap(),
|
||||||
|
devices: vec![],
|
||||||
|
vector: BTreeMap::new(),
|
||||||
|
ops: vec![],
|
||||||
|
snapshot: SyncSnapshot::default(),
|
||||||
|
playback: sync.local_playback_snapshot(),
|
||||||
|
};
|
||||||
|
let mut bytes = serde_json::to_vec(&hello).unwrap();
|
||||||
|
bytes.push(b'\n');
|
||||||
|
stream.write_all(&bytes).await.unwrap();
|
||||||
|
let response: WireMessage =
|
||||||
|
serde_json::from_slice(&read_line(&mut stream).await.unwrap()).unwrap();
|
||||||
|
let WireMessage::SyncResponse {
|
||||||
|
accepted: true,
|
||||||
|
playback: Some(snapshot),
|
||||||
|
ops,
|
||||||
|
..
|
||||||
|
} = response
|
||||||
|
else {
|
||||||
|
panic!("expected web response")
|
||||||
|
};
|
||||||
|
let web_id = snapshot.device_id.clone();
|
||||||
|
sync.apply_playback_snapshot(&web_id, snapshot).unwrap();
|
||||||
|
assert_eq!(ops.len(), 1);
|
||||||
|
let op = &ops[0];
|
||||||
|
// Production command adapter including durable fencing/deduplication.
|
||||||
|
sync.apply_op(op).unwrap();
|
||||||
|
sync.apply_op(op).unwrap();
|
||||||
|
let mut commands = Vec::new();
|
||||||
|
while let Ok(event) = events.try_recv() {
|
||||||
|
if let AppEvent::PlaybackCommand {
|
||||||
|
command,
|
||||||
|
authority,
|
||||||
|
origin,
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
assert!(sync.playback_command_is_current(&origin, &authority));
|
||||||
|
commands.push(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(commands.len(), 1, "one event even after duplicate delivery");
|
||||||
|
match commands.pop().unwrap() {
|
||||||
|
PlaybackCommand::ActiveChanged {
|
||||||
|
active_device_id,
|
||||||
|
state: next,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(
|
||||||
|
active_device_id,
|
||||||
|
if phase == 0 {
|
||||||
|
web_id.clone()
|
||||||
|
} else {
|
||||||
|
id.device_id.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(next.position_secs, 42.5);
|
||||||
|
assert_eq!(next.queue, state.queue);
|
||||||
|
state = next;
|
||||||
|
}
|
||||||
|
PlaybackCommand::SetState { state: next, seek } => {
|
||||||
|
assert_eq!(phase, 2);
|
||||||
|
assert!(seek);
|
||||||
|
assert!(next.paused);
|
||||||
|
assert_eq!(next.position_secs, 87.0);
|
||||||
|
state = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
sync.playback_tick(true, false).unwrap(),
|
||||||
|
Some(if phase == 0 {
|
||||||
|
web_id
|
||||||
|
} else {
|
||||||
|
id.device_id.clone()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
stream.write_all(b"ok\n").await.unwrap();
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("web/TUI exchange timed out");
|
||||||
|
}
|
||||||
@@ -1,5 +1,223 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Exercises the production stream handlers and SQLite adapter, not just the
|
||||||
|
/// ownership reducer. Each peer has a fresh identity and an isolated library.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn localhost_devices_exchange_state_and_handoff() {
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
|
let dirs = [tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()];
|
||||||
|
let network = NetworkId::from_name(&format!("device-test-{}", random_hex(16)));
|
||||||
|
let mut peers = Vec::new();
|
||||||
|
let mut servers = Vec::new();
|
||||||
|
let mut receivers = Vec::new();
|
||||||
|
for dir in &dirs {
|
||||||
|
std::fs::create_dir_all(dir.path().join("network")).unwrap();
|
||||||
|
let config = music_dht::MusicDhtConfig::builder()
|
||||||
|
.data_dir(dir.path().join("network"))
|
||||||
|
.network_id(network)
|
||||||
|
.stream_protocol(SYNC_ALPN)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let (service, events) = MusicDhtService::start(config).await.unwrap();
|
||||||
|
let service = Arc::new(service);
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
init_schema(&conn).unwrap();
|
||||||
|
let sync = Arc::new(DeviceSync {
|
||||||
|
_identity_lock: Some(Arc::new(
|
||||||
|
acquire_identity_lock(&dir.path().join("sync.sqlite3")).unwrap(),
|
||||||
|
)),
|
||||||
|
conn: Arc::new(std::sync::Mutex::new(conn)),
|
||||||
|
library: Arc::new(Library::open(&dir.path().join("library.sqlite3")).unwrap()),
|
||||||
|
event_tx: Default::default(),
|
||||||
|
playback: Default::default(),
|
||||||
|
});
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
sync.set_event_tx(tx);
|
||||||
|
let stats = Arc::new(crate::federation::TransportStats::default());
|
||||||
|
servers.push(tokio::spawn(serve_peers(
|
||||||
|
service.stream_acceptor(SYNC_ALPN).unwrap(),
|
||||||
|
sync.clone(),
|
||||||
|
service.clone(),
|
||||||
|
stats.clone(),
|
||||||
|
)));
|
||||||
|
receivers.push(rx);
|
||||||
|
peers.push((sync, service, stats, events));
|
||||||
|
}
|
||||||
|
let (a, service_a, stats_a, _) = &peers[0];
|
||||||
|
let (b, service_b, stats_b, _) = &peers[1];
|
||||||
|
b.ensure_identity().unwrap();
|
||||||
|
b.set_group_id(&a.ensure_identity().unwrap().group_id)
|
||||||
|
.unwrap();
|
||||||
|
let profile_a = a
|
||||||
|
.own_profile(&service_a.ticket().await.unwrap().to_string())
|
||||||
|
.unwrap();
|
||||||
|
let profile_b = b
|
||||||
|
.own_profile(&service_b.ticket().await.unwrap().to_string())
|
||||||
|
.unwrap();
|
||||||
|
a.apply_device_profile(&profile_b, true).unwrap();
|
||||||
|
b.apply_device_profile(&profile_a, true).unwrap();
|
||||||
|
a.claim_playback(&profile_a.device_id).unwrap();
|
||||||
|
a.playback_tick(true, true).unwrap();
|
||||||
|
let mut state = empty_playback_state();
|
||||||
|
state.playing = true;
|
||||||
|
state.position_secs = 42.5;
|
||||||
|
a.publish_playback(PlaybackSnapshot {
|
||||||
|
device_id: profile_a.device_id.clone(),
|
||||||
|
device_name: "TUI".into(),
|
||||||
|
active: true,
|
||||||
|
updated_at_ms: now_ms(),
|
||||||
|
state: state.clone(),
|
||||||
|
coordination: None,
|
||||||
|
});
|
||||||
|
a.sync_device(
|
||||||
|
service_a.clone(),
|
||||||
|
&a.active_remote_devices().unwrap()[0],
|
||||||
|
stats_a.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
b.with_playback_engine(|e| e.owner().map(str::to_owned))
|
||||||
|
.unwrap(),
|
||||||
|
Some(profile_a.device_id.clone())
|
||||||
|
);
|
||||||
|
assert_eq!(lock(&b.playback).remote[&profile_a.device_id].state, state);
|
||||||
|
assert!(
|
||||||
|
b.status()
|
||||||
|
.devices
|
||||||
|
.iter()
|
||||||
|
.any(|d| d.device_id == profile_a.device_id && d.last_seen_ms.is_some())
|
||||||
|
);
|
||||||
|
|
||||||
|
a.record_playback_command(
|
||||||
|
&profile_b.device_id,
|
||||||
|
PlaybackCommand::ActiveChanged {
|
||||||
|
active_device_id: profile_b.device_id.clone(),
|
||||||
|
active_device_name: "Second player".into(),
|
||||||
|
state: state.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
a.sync_device(
|
||||||
|
service_a.clone(),
|
||||||
|
&a.active_remote_devices().unwrap()[0],
|
||||||
|
stats_a.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut transferred = false;
|
||||||
|
while let Ok(event) = receivers[1].try_recv() {
|
||||||
|
if let AppEvent::PlaybackCommand {
|
||||||
|
command:
|
||||||
|
PlaybackCommand::ActiveChanged {
|
||||||
|
state: received, ..
|
||||||
|
},
|
||||||
|
authority,
|
||||||
|
origin,
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
assert_eq!(received, state);
|
||||||
|
assert!(b.playback_command_is_current(&origin, &authority));
|
||||||
|
transferred = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(transferred, "handoff must reach the player's event loop");
|
||||||
|
assert_eq!(
|
||||||
|
b.playback_tick(true, true).unwrap(),
|
||||||
|
Some(profile_b.device_id.clone())
|
||||||
|
);
|
||||||
|
b.publish_playback(PlaybackSnapshot {
|
||||||
|
device_id: profile_b.device_id.clone(),
|
||||||
|
device_name: "Second player".into(),
|
||||||
|
active: true,
|
||||||
|
updated_at_ms: now_ms(),
|
||||||
|
state,
|
||||||
|
coordination: None,
|
||||||
|
});
|
||||||
|
b.sync_device(
|
||||||
|
service_b.clone(),
|
||||||
|
&b.active_remote_devices().unwrap()[0],
|
||||||
|
stats_b.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
a.playback_tick(true, false).unwrap(),
|
||||||
|
Some(profile_b.device_id)
|
||||||
|
);
|
||||||
|
// A paired peer that accepts a connection but never answers must not
|
||||||
|
// serialize or stop subsequent polls to the responsive peer.
|
||||||
|
let silent_dir = tempfile::tempdir().unwrap();
|
||||||
|
let (silent, _events) = MusicDhtService::start(
|
||||||
|
music_dht::MusicDhtConfig::builder()
|
||||||
|
.data_dir(silent_dir.path())
|
||||||
|
.network_id(network)
|
||||||
|
.stream_protocol(SYNC_ALPN)
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let _silent_acceptor = silent.stream_acceptor(SYNC_ALPN).unwrap();
|
||||||
|
let mut silent_profile = profile_a.clone();
|
||||||
|
silent_profile.device_id = "silent-peer".into();
|
||||||
|
silent_profile.endpoint_id = silent.endpoint_id().to_string();
|
||||||
|
silent_profile.endpoint_ticket = silent.ticket().await.unwrap().to_string();
|
||||||
|
a.apply_device_profile(&silent_profile, true).unwrap();
|
||||||
|
lock(&a.conn)
|
||||||
|
.execute(
|
||||||
|
"UPDATE sync_devices SET last_seen_ms = ?1 WHERE device_id = 'silent-peer'",
|
||||||
|
[now_ms() + 1_000],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
a.active_remote_devices().unwrap()[0].device_id,
|
||||||
|
"silent-peer"
|
||||||
|
);
|
||||||
|
a.claim_playback(&profile_a.device_id).unwrap();
|
||||||
|
let poller = tokio::spawn(sync_loop(a.clone(), service_a.clone(), stats_a.clone()));
|
||||||
|
for position in [99.0, 100.0] {
|
||||||
|
a.playback_tick(true, true).unwrap();
|
||||||
|
let mut next = empty_playback_state();
|
||||||
|
next.playing = true;
|
||||||
|
next.position_secs = position;
|
||||||
|
a.publish_playback(PlaybackSnapshot {
|
||||||
|
device_id: profile_a.device_id.clone(),
|
||||||
|
device_name: "TUI".into(),
|
||||||
|
active: true,
|
||||||
|
updated_at_ms: now_ms(),
|
||||||
|
state: next,
|
||||||
|
coordination: None,
|
||||||
|
});
|
||||||
|
tokio::time::timeout(Duration::from_secs(6), async {
|
||||||
|
loop {
|
||||||
|
if lock(&b.playback)
|
||||||
|
.remote
|
||||||
|
.get(&profile_a.device_id)
|
||||||
|
.is_some_and(|snapshot| snapshot.state.position_secs == position)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("a stalled peer must not delay live playback polls");
|
||||||
|
}
|
||||||
|
poller.abort();
|
||||||
|
let _ = poller.await;
|
||||||
|
silent.shutdown().await.unwrap();
|
||||||
|
for server in servers {
|
||||||
|
server.abort();
|
||||||
|
}
|
||||||
|
for (_, service, _, _) in &peers {
|
||||||
|
service.shutdown().await.unwrap();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("localhost sync must complete within 30 seconds");
|
||||||
|
}
|
||||||
|
|
||||||
fn empty_playback_state() -> PlaybackStateWire {
|
fn empty_playback_state() -> PlaybackStateWire {
|
||||||
PlaybackStateWire {
|
PlaybackStateWire {
|
||||||
queue: vec![],
|
queue: vec![],
|
||||||
@@ -119,6 +337,7 @@ fn test_sync() -> DeviceSync {
|
|||||||
unique
|
unique
|
||||||
));
|
));
|
||||||
let sync = DeviceSync {
|
let sync = DeviceSync {
|
||||||
|
_identity_lock: None,
|
||||||
conn: Arc::new(std::sync::Mutex::new(conn)),
|
conn: Arc::new(std::sync::Mutex::new(conn)),
|
||||||
library: Arc::new(Library::open(&library_path).unwrap()),
|
library: Arc::new(Library::open(&library_path).unwrap()),
|
||||||
event_tx: Arc::new(std::sync::Mutex::new(None)),
|
event_tx: Arc::new(std::sync::Mutex::new(None)),
|
||||||
@@ -128,6 +347,38 @@ fn test_sync() -> DeviceSync {
|
|||||||
sync
|
sync
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_identity_has_one_coordinator_across_installation_paths() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("sync.sqlite3");
|
||||||
|
let first = acquire_identity_lock(&path).unwrap();
|
||||||
|
assert!(acquire_identity_lock(&path).is_err());
|
||||||
|
let child = std::process::Command::new(std::env::current_exe().unwrap())
|
||||||
|
.args([
|
||||||
|
"--exact",
|
||||||
|
"devices::tests::identity_lock_child_process",
|
||||||
|
"--nocapture",
|
||||||
|
])
|
||||||
|
.env("FURUMI_IDENTITY_LOCK_TEST_PATH", &path)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
child.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&child.stderr)
|
||||||
|
);
|
||||||
|
drop(first);
|
||||||
|
// Reopening a leftover lock file after shutdown must succeed.
|
||||||
|
assert!(acquire_identity_lock(&path).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identity_lock_child_process() {
|
||||||
|
if let Some(path) = std::env::var_os("FURUMI_IDENTITY_LOCK_TEST_PATH") {
|
||||||
|
assert!(acquire_identity_lock(std::path::Path::new(&path)).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn device_revoked(sync: &DeviceSync, device_id: &str) -> bool {
|
fn device_revoked(sync: &DeviceSync, device_id: &str) -> bool {
|
||||||
let conn = lock(&sync.conn);
|
let conn = lock(&sync.conn);
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
|
|||||||
Reference in New Issue
Block a user