Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions deltachat-rpc-client/tests/test_multitransport.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ def test_transport_sync_new_as_primary(acfactory, log) -> None:
log.section("ac1 changes the primary transport")
ac1.set_config("configured_addr", transport2["addr"])

ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
assert ac1_clone.get_config("configured_addr") == transport2["addr"]

Expand Down
22 changes: 13 additions & 9 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -785,16 +785,19 @@ impl Context {
(addr,),
)?;

// Update the timestamp for the primary transport
// so it becomes the first in `get_all_self_addrs()` list
// and the list of relays distributed in the public key.
// This ensures that messages will be sent
// to the primary relay by the contacts
// and will be fetched in background_fetch()
// which only fetches from the primary transport.
// `is_published=1`: an unpublished primary would be missing
// from the relay list in the public key, so contacts would
// never send to it.
//
// The timestamp must strictly increase because
// other devices ignore the row update otherwise,
// and contacts only adopt the re-signed key
// if its signature timestamp increases.
transaction
.execute(
"UPDATE transports SET add_timestamp=?, is_published=1 WHERE addr=?",
"UPDATE transports

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So my suggestion is to remove this block (with large comment about get_all_self_addrs) completely, and only update the configured_addrs and still send the sync message, but with unchanged addresses. And then expect only a single event on the other side. This does not depend on #8501 and we are anyway going to only add up to 3 relays with automatic relay management.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I think we need to keep is_published=1 for promoting an unpublished transport
  2. even then, test_is_published_flag fails in the final check_addrs without increasing timestamp. As far as i see merge_openpgp_certificates depends on timestamps increasing, or it will keep the stored cert with signature timestamp being the max of transport/removed_transports timestamps.

sidenote: i think all transport manipulation should be done in transport.rs and tested there, and other sites only use it. This would make it easier i think to get a complete picture and certainty that it handles all cases. But that's clearly outside this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fwiw, i also pushed a revised and shortened comment block.

SET add_timestamp=MAX(?, add_timestamp+1), is_published=1
WHERE addr=?",
(time(), addr),
)
.context(
Expand All @@ -811,8 +814,9 @@ impl Context {
Ok(())
})
.await?;
send_sync_transports(self).await?;
// Invalidate the cache so the sync message cannot read a stale primary address.
self.sql.uncache_raw_config("configured_addr").await;
send_sync_transports(self).await?;
}
}
_ => {
Expand Down
10 changes: 10 additions & 0 deletions src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::collections::{BTreeMap, HashMap};
use std::ffi::OsString;
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, OnceLock, Weak};
use std::time::Duration;

Expand Down Expand Up @@ -258,6 +259,14 @@ pub struct InnerContext {
/// This causes [`Context::wait_next_msgs`] to wake up.
pub(crate) new_msgs_notify: Notify,

/// Whether IO should be restarted after the current fetch cycle completed.
///
/// Set when a fetched transport sync message modified the transports.
/// Restarting from within the inbox loop would cancel it,
/// losing the remaining processing of the sync message
/// which is already stored and is never fetched again.
pub(crate) restart_io_after_fetch: AtomicBool,

/// Server ID response if ID capability is supported
/// and the server returned non-NIL on the inbox connection.
/// <https://datatracker.ietf.org/doc/html/rfc2971>
Expand Down Expand Up @@ -486,6 +495,7 @@ impl Context {
ratelimit: RwLock::new(Ratelimit::new(Duration::new(3, 0), 3.0)), // Allow at least 1 message every second + a burst of 3.
quota: RwLock::new(BTreeMap::new()),
new_msgs_notify,
restart_io_after_fetch: AtomicBool::new(false),
server_id: RwLock::new(None),
metadata: RwLock::new(BTreeMap::new()),
creation_time: tools::Time::now(),
Expand Down
14 changes: 14 additions & 0 deletions src/scheduler.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
use std::cmp;
use std::future::Future;
use std::num::NonZeroUsize;
use std::pin::Pin;
use std::sync::atomic::Ordering;

use anyhow::{Context as _, Error, Result, bail};
use async_channel::{self as channel, Receiver, Sender};
Expand Down Expand Up @@ -407,6 +410,12 @@ async fn inbox_loop(
.await;
}

/// Same as `context.restart_io_if_running()`, but `Box::pin`ed and with a `+ Send` bound
/// to break the async type cycle with the IMAP loop it restarts.
fn restart_io_if_running_boxed(context: Context) -> Pin<Box<dyn Future<Output = ()> + Send>> {
Box::pin(async move { context.restart_io_if_running().await })
}

async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session) -> Result<Session> {
let transport_id = session.transport_id();

Expand Down Expand Up @@ -491,6 +500,11 @@ async fn fetch_idle(ctx: &Context, connection: &mut Imap, mut session: Session)
.await
.context("download_msgs")?;

if ctx.restart_io_after_fetch.swap(false, Ordering::Relaxed) {
// Stopping IO from within the inbox loop would cancel it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Stopping IO from within the inbox loop would cancel it.
// Restarting IO cancels the IMAP loop.
// Therefore, we only restart when we're anyways about to go IDLE.

task::spawn(restart_io_if_running_boxed(ctx.clone()));
}
Comment on lines +503 to +506

@Hocuri Hocuri Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TL;DR: This won't solve the flakiness completely, it can just make it smaller (even though I didn't understand yet why restarting IO causes this problem at all). I have ideas for a proper solution, but they are bigger changes. If the fix here noticeably improves test flakiness, then it might be fine for now as a partial solution.


The underlying problem was that stopping IO was triggered immediately during
receiving sync messages, potentially canceling the processing of the sync message,
effectively de-syncing the device's view on transports.

I'm afraid that we still have this problem, it might just happen less frequently (I didn't test whether it does). If one transport sets restart_io_after_fetch, and a second transport enters idle shortly afterwards, then the second transport will see that restart_io_after_fetch is set, and restart io, even the first transport isn't done with processing the message yet. A partial solution to this would be to make restart_io_after_fetch per-transport, but that's not trivial because sync_transports() doesn't have access to Imap or Session, and we would still have the problem that restarting IO at a bad time might cause problems.

I didn't really understand yet why restarting IO causes problems at all, because if the message isn't completely processed, then it's not written into the database, so that it should be downloaded again. restart_io_if_running_boxed() is called after applying the sync. But this might just be an incomplete understanding of the complex interactions of restarting and cancelling, and also we do handle some things (unrelated to transport sync) after writing the message into the database, so that I totally see how restarting at a bad timing can be a problem.

The complete solution to protect against stops or restarts at a bad timing would be to prevent cancellation during receive_imf(). There are two possibilities for this:

  • In the IMAP loop: Rather than cancelling and dropping the future (by using cancelled() and race(fut)), periodically call is_cancelled(), and return if it returns true.
  • Make SQL calls non-async by using non-async Mutexes and Rwlocks structs (i.e. the ones from std rather than tokio). We're already using block_in_place() to wrap the calls to sqlite, we would just need to expand its scope to also wrap any calls to lock(), read(), write(). I'm not sure if that would actually work, but in this way, the whole of receive_imf()` would become non-async. Then, it couldn't be cancelled in the middle but would just run to completion when the future is dropped.

Then again, a partial solution is better than no solution, and if this change removes the apparent test flakiness, then it might still be an improvement, even though the test is probably just less flaky rather than being actually stable.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the IMAP loop: Rather than cancelling and dropping the future (by using cancelled() and race(fut)), periodically call is_cancelled(), and return if it returns true.

Even if we stop racing against cancelled(), scheduler may still cancel the inbox loop with a timeout if shutting down takes too long, and we want to eventually shutdown the inbox loop e.g. if the connection is very slow and the inbox loop is stuck downloading the message for hours.

Ideally most async code should be cancellation-safe with rare exceptions marked as such. If cancelling IMAP loop at the wrong moment results in some message never being downloaded, it is a bug somewhere down the IMAP loop code, e.g. if we record somewhere that the message is downloaded before running receive_imf to completion.

@Hocuri Hocuri Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is a bug somewhere down the IMAP loop code, e.g. if we record somewhere that the message is downloaded before running receive_imf to completion.

I mean, we do: We call INSERT INTO msgs towards the end of add_parts(), and there are quite some things we do afterwards (non-exhaustive list):

  • unarchive the chat if needed
  • promote the chat if needed
  • apply pending reactions to the message
  • update the contact's last_seen
  • save attached locations
  • Execute sync items
  • receive webxdc status updates
  • apply changes to the user avatar and status

@Hocuri Hocuri Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we decide to make receive_imf() non-async (see my last comment for how this could be done), then it would be possible to put the whole of receive_imf() into one SQL transaction by using a thread-local transaction variable, and using it in Sql::call() if it's set. Today we can't do that because we can't hold a transaction over an await point. I'm not sure whether this actually works and is a good idea, though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TL;DR: This won't solve the flakiness completely, it can just make it smaller (even though I didn't understand yet why restarting IO causes this problem at all).

here is the problem with main as i see it:

  1. a sync message is in add_parts() persistently assigned to trash before execute_sync_items executes.
  2. cancellation within that function is triggered immediately and occurs at any subsequent async point
  3. the message gets refetched but not processed because it exists as RFC724_mid

This leads to the flakyness of one or two TransportsModified events that i observed. If you insert a sleep after execute_sync_items you can more reliably cause the problem. It's some kind of direct self-sabotage.

Regular messages also don't get re-processed after add_parts() but the most important work regarding sync messages happens later. Therefore, UID_NEXT not progressing (because job is canceled) doesn't help.

You are right that Transport A and B concurrently fetching might cancel each other's message receiving mid-flight, But in the current code path, the only place that triggers concurrent cancellation from within receive_imf is kind of guaranteed to cause troubles every Nth time with N not being very large. Corrupting sync processing especially when multi-device&multi-relay already have their own flakyness problems without it, is .... i think worthwhile to avoid as much as we can. By comparison, Transport A randomly stopping something in Transport B is not good, but usually not as critical. Regular text messages are after add_parts() there and user-visible, even if all post-processing fails: you might loose a reaction, or an MDN or an avatar or webxdc update or so. But as far as i see, none of them are as serious as somewhat predictably corrupting multi-relay/device state.


connection.connectivity.set_idle(ctx);

ctx.emit_event(EventType::ImapInboxIdle);
Expand Down
12 changes: 4 additions & 8 deletions src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
//! and configured list of connection candidates.

use std::fmt;
use std::pin::Pin;
use std::sync::atomic::Ordering;

use anyhow::{Context as _, Result, bail, format_err};
use deltachat_contact_tools::{EmailAddress, addr_normalize};
Expand Down Expand Up @@ -666,18 +666,14 @@ pub(crate) async fn sync_transports(

if modified {
context.self_public_key.lock().await.take();
tokio::task::spawn(restart_io_if_running_boxed(context.clone()));
context
.restart_io_after_fetch
.store(true, Ordering::Relaxed);
context.emit_event(EventType::TransportsModified);
}
Ok(())
}

/// Same as `context.restart_io_if_running()`, but `Box::pin`ed and with a `+ Send` bound,
/// so that it can be called recursively.
fn restart_io_if_running_boxed(context: Context) -> Pin<Box<dyn Future<Output = ()> + Send>> {
Box::pin(async move { context.restart_io_if_running().await })
}

/// Adds transport entry to the `transports` table with empty configuration.
pub(crate) async fn add_pseudo_transport(context: &Context, addr: &str) -> Result<()> {
context.sql
Expand Down
110 changes: 96 additions & 14 deletions src/transport/transport_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,19 @@ fn dummy_configured_login_param(addr: &str) -> ConfiguredLoginParam {
}
}

async fn add_dummy_transport(t: &TestContext, addr: &str) -> Result<()> {
dummy_configured_login_param(addr)
.save_to_transports_table(
t,
&EnteredLoginParam {
addr: addr.to_string(),
..Default::default()
},
time(),
)
.await
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_is_published_flag() -> Result<()> {
let mut tcm = TestContextManager::new();
Expand All @@ -138,16 +151,7 @@ async fn test_is_published_flag() -> Result<()> {
)
.await;

dummy_configured_login_param("alice@otherprovider.com")
.save_to_transports_table(
alice,
&EnteredLoginParam {
addr: "alice@otherprovider.com".to_string(),
..Default::default()
},
time(),
)
.await?;
add_dummy_transport(alice, "alice@otherprovider.com").await?;
send_sync_transports(alice).await?;
sync_and_check_recipients(alice, alice2, "alice@otherprovider.com alice@example.org").await;

Expand Down Expand Up @@ -195,10 +199,7 @@ async fn test_is_published_flag() -> Result<()> {

SystemTime::shift(Duration::from_secs(2));

alice
.set_config(Config::ConfiguredAddr, Some("alice@otherprovider.com"))
.await?;
sync_and_check_recipients(alice, alice2, "alice@example.org alice@otherprovider.com").await;
promote_transport_and_check_success(alice, alice2, "alice@otherprovider.com").await?;

check_addrs(
alice,
Expand All @@ -215,6 +216,87 @@ async fn test_is_published_flag() -> Result<()> {
Ok(())
}

/// Tests that changing the primary transport propagates to other devices
/// even if the promoted transport was added within the same second.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_promote_transport_same_second() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let alice2 = &tcm.alice().await;
for a in [alice, alice2] {
a.set_config_bool(Config::SyncMsgs, true).await?;
a.set_config_bool(Config::BccSelf, true).await?;
}

add_dummy_transport(alice, "alice@otherprovider.com").await?;
send_sync_transports(alice).await?;
sync_and_check_recipients(alice, alice2, "alice@otherprovider.com alice@example.org").await;

promote_transport_and_check_success(alice, alice2, "alice@otherprovider.com").await
}

/// Tests that `sync_transports()` requests an IO restart
/// if and only if it modified anything.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sync_transports_requests_io_restart() -> Result<()> {
let alice = &TestContext::new_alice().await;

let data = TransportData {
configured: dummy_configured_login_param("alice@otherprovider.com").into(),
entered: EnteredLoginParam {
addr: "alice@otherprovider.com".to_string(),
..Default::default()
},
timestamp: time(),
is_published: true,
};
let data = std::slice::from_ref(&data);
sync_transports(alice, data, &[]).await?;
assert!(alice.restart_io_after_fetch.swap(false, Ordering::Relaxed));

// Applying the same data again modifies nothing.
sync_transports(alice, data, &[]).await?;
assert!(!alice.restart_io_after_fetch.load(Ordering::Relaxed));

Ok(())
}

/// Promotes `addr` to primary on `alice` and checks the change syncs to `alice2`.
async fn promote_transport_and_check_success(
alice: &TestContext,
alice2: &TestContext,
addr: &str,
) -> Result<()> {
let old_timestamp = add_timestamp(alice2, addr).await;
alice.set_config(Config::ConfiguredAddr, Some(addr)).await?;
assert!(add_timestamp(alice, addr).await > old_timestamp);

alice.send_sync_msg().await?.unwrap();
let sync_msg = alice.pop_sent_msg().await;
assert_eq!(sync_msg.recipients, format!("alice@example.org {addr}"));
// Other devices switch their primary transport
// based on the From address of the sync message.
assert!(sync_msg.payload.contains(&format!("From: <{addr}>")));
alice2.recv_msg_trash(&sync_msg).await;

// add_timestamp must monotonically increase because
// other devices ignore the change otherwise.
assert!(add_timestamp(alice2, addr).await > old_timestamp);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SQL is now changed to increase add_timestamp at least by 1, and the test is checking that add_timestamp is increased.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have checked out the branch, split the first commit into the test changes and the fix, commented out two add_timestamp asserts and the test passes. So ConfiguredAddr is already synchronized even without the changes to add_timestamp.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SQL is now changed to increase add_timestamp at least by 1, and the test is checking that add_timestamp is increased.

add_timestamp only increases by 1 if the clock hasn't advanced beyond the current transport timestamps.
the same-second test is meant to test that.

assert_eq!(
alice2.get_config(Config::ConfiguredAddr).await?.as_deref(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this already working without increasing add_timestamp? Here is the code that updates the primary transport, it only checks if the transport exists:

core/src/receive_imf.rs

Lines 802 to 830 in 020e477

let transport_changed = context
.sql
.transaction(|transaction| {
let transport_exists = transaction.query_row(
"SELECT COUNT(*) FROM transports WHERE addr=?",
(from_addr,),
|row| {
let count: i64 = row.get(0)?;
Ok(count > 0)
},
)?;
let transport_changed = if transport_exists {
transaction.execute(
"
UPDATE config SET value=? WHERE keyname='configured_addr' AND value!=?1
",
(from_addr,),
)? > 0
} else {
warn!(
context,
"Received sync message from unknown address {from_addr:?}."
);
false
};
Ok(transport_changed)
})
.await?;

Some(addr)
);
Ok(())
}

async fn add_timestamp(t: &TestContext, addr: &str) -> i64 {
t.sql
.query_get_value("SELECT add_timestamp FROM transports WHERE addr=?", (addr,))
.await
.unwrap()
.unwrap()
}

struct Addresses {
primary: &'static str,
secondary_published: &'static [&'static str],
Expand Down