v0.4.3: Lock contention overhaul, StoragePool, mobile bottom nav, text scaling

Eliminate all conn_mgr lock holds during network I/O across 14 actor commands
and bi-stream handlers. PostFetch, TcpPunch, PullFromPeer, FetchEngagement,
ResolveAddress, AnchorProbe use brief locks for data gathering only. WormLookup,
ContentSearch, WormQuery use connection snapshots for lock-free cascade fan-out.
RelayIntroduce extracts forwarding data under brief lock, does I/O outside.
BlobRequest, PostFetchRequest, ManifestRefresh use Arc clones instead of conn_mgr
lock. ConnectionActor hoists shared Arcs (storage, blob_store, endpoint) for
lock-free access. ResolveAddress adds 5s per-query timeout (was unbounded).

Initial exchange failure now aborts mesh upgrade (was silently continuing with
broken connection). connect_to_peer/connect_to_anchor use consistent 15s timeout.
Rebalance connects outside the lock via pending_connects pattern.

StoragePool: 8 concurrent SQLite connections in WAL mode replace single
Mutex<Storage>. Reads run fully parallel; writes serialize at SQLite level only.
PRAGMA busy_timeout=5000 for graceful write contention.

Mobile bottom nav bar (<=768px) with icon tabs. Text sizes: XS/S/M/L/XL
(75%/100%/125%/150%/200%), default M. localStorage persistence for instant
restore. Toast repositioned above mobile nav.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-03-22 21:35:38 -04:00
parent f17535d61d
commit 43adbbdf7d
15 changed files with 1546 additions and 618 deletions

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,7 @@ use tokio::sync::Mutex;
use tracing::{debug, info}; use tracing::{debug, info};
use crate::blob::BlobStore; use crate::blob::BlobStore;
use crate::storage::Storage; use crate::storage::{Storage, StoragePool};
use crate::types::PostVisibility; use crate::types::PostVisibility;
/// Connection budget: 5 content slots, 15 redirect slots, 1 per IP. /// Connection budget: 5 content slots, 15 redirect slots, 1 per IP.
@ -104,7 +104,7 @@ impl HttpBudget {
/// Run the HTTP server on the given port. Blocks forever. /// Run the HTTP server on the given port. Blocks forever.
pub async fn run_http_server( pub async fn run_http_server(
port: u16, port: u16,
storage: Arc<Mutex<Storage>>, storage: Arc<StoragePool>,
blob_store: Arc<BlobStore>, blob_store: Arc<BlobStore>,
downstream_addrs: Arc<Mutex<HashMap<[u8; 32], Vec<SocketAddr>>>>, downstream_addrs: Arc<Mutex<HashMap<[u8; 32], Vec<SocketAddr>>>>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
@ -180,7 +180,7 @@ async fn handle_connection(
mut stream: TcpStream, mut stream: TcpStream,
_ip: IpAddr, _ip: IpAddr,
slot: SlotKind, slot: SlotKind,
storage: &Arc<Mutex<Storage>>, storage: &Arc<StoragePool>,
blob_store: &Arc<BlobStore>, blob_store: &Arc<BlobStore>,
downstream_addrs: &Arc<Mutex<HashMap<[u8; 32], Vec<SocketAddr>>>>, downstream_addrs: &Arc<Mutex<HashMap<[u8; 32], Vec<SocketAddr>>>>,
) { ) {
@ -281,12 +281,12 @@ fn validate_hex64(s: &str) -> Option<[u8; 32]> {
async fn serve_post( async fn serve_post(
stream: &mut TcpStream, stream: &mut TcpStream,
post_id: &[u8; 32], post_id: &[u8; 32],
storage: &Arc<Mutex<Storage>>, storage: &Arc<StoragePool>,
blob_store: &Arc<BlobStore>, blob_store: &Arc<BlobStore>,
) -> bool { ) -> bool {
// Look up post + visibility // Look up post + visibility
let result = { let result = {
let store = storage.lock().await; let store = storage.get().await;
store.get_post_with_visibility(post_id) store.get_post_with_visibility(post_id)
}; };
@ -301,7 +301,7 @@ async fn serve_post(
// Look up author name // Look up author name
let author_name = { let author_name = {
let store = storage.lock().await; let store = storage.get().await;
store store
.get_profile(&post.author) .get_profile(&post.author)
.ok() .ok()
@ -321,12 +321,12 @@ async fn serve_post(
async fn serve_blob( async fn serve_blob(
stream: &mut TcpStream, stream: &mut TcpStream,
blob_id: &[u8; 32], blob_id: &[u8; 32],
storage: &Arc<Mutex<Storage>>, storage: &Arc<StoragePool>,
blob_store: &Arc<BlobStore>, blob_store: &Arc<BlobStore>,
) -> bool { ) -> bool {
// Verify this blob belongs to a public post // Verify this blob belongs to a public post
let (mime_type, _post_id) = { let (mime_type, _post_id) = {
let store = storage.lock().await; let store = storage.get().await;
match find_public_blob_info(&store, blob_id) { match find_public_blob_info(&store, blob_id) {
Some(info) => info, Some(info) => info,
None => return false, // not found or not public — hard close None => return false, // not found or not public — hard close
@ -367,12 +367,12 @@ fn find_public_blob_info(store: &Storage, blob_id: &[u8; 32]) -> Option<(String,
async fn try_redirect( async fn try_redirect(
stream: &mut TcpStream, stream: &mut TcpStream,
post_id: &[u8; 32], post_id: &[u8; 32],
storage: &Arc<Mutex<Storage>>, storage: &Arc<StoragePool>,
_downstream_addrs: &Arc<Mutex<HashMap<[u8; 32], Vec<SocketAddr>>>>, _downstream_addrs: &Arc<Mutex<HashMap<[u8; 32], Vec<SocketAddr>>>>,
) -> bool { ) -> bool {
// Get downstream peers for this post // Get downstream peers for this post
let downstream_peers = { let downstream_peers = {
let store = storage.lock().await; let store = storage.get().await;
// Verify post exists and is public first // Verify post exists and is public first
match store.get_post_with_visibility(post_id) { match store.get_post_with_visibility(post_id) {
Ok(Some((_, PostVisibility::Public))) => {} Ok(Some((_, PostVisibility::Public))) => {}
@ -383,7 +383,7 @@ async fn try_redirect(
// Get addresses for downstream peers // Get addresses for downstream peers
let candidates: Vec<SocketAddr> = { let candidates: Vec<SocketAddr> = {
let store = storage.lock().await; let store = storage.get().await;
let mut addrs = Vec::new(); let mut addrs = Vec::new();
for peer_id in &downstream_peers { for peer_id in &downstream_peers {
if let Ok(Some(peer)) = store.get_peer_record(peer_id) { if let Ok(Some(peer)) = store.get_peer_record(peer_id) {

View file

@ -18,7 +18,7 @@ use crate::protocol::{
PullSyncRequestPayload, PullSyncResponsePayload, RefuseRedirectPayload, PullSyncRequestPayload, PullSyncResponsePayload, RefuseRedirectPayload,
SocialAddressUpdatePayload, SocialDisconnectNoticePayload, SyncPost, ALPN_V2, SocialAddressUpdatePayload, SocialDisconnectNoticePayload, SyncPost, ALPN_V2,
}; };
use crate::storage::Storage; use crate::storage::{Storage, StoragePool};
use crate::types::{ use crate::types::{
DeleteRecord, DeviceProfile, DeviceRole, NodeId, PeerSlotKind, PeerWithAddress, Post, PostId, DeleteRecord, DeviceProfile, DeviceRole, NodeId, PeerSlotKind, PeerWithAddress, Post, PostId,
PostVisibility, PublicProfile, SessionReachMethod, WormResult, PostVisibility, PublicProfile, SessionReachMethod, WormResult,
@ -27,7 +27,7 @@ use crate::types::{
/// The network layer: manages the iroh endpoint and mesh connections /// The network layer: manages the iroh endpoint and mesh connections
pub struct Network { pub struct Network {
endpoint: iroh::Endpoint, endpoint: iroh::Endpoint,
storage: Arc<Mutex<Storage>>, storage: Arc<StoragePool>,
our_node_id: NodeId, our_node_id: NodeId,
is_anchor: Arc<AtomicBool>, is_anchor: Arc<AtomicBool>,
conn_mgr: Arc<Mutex<ConnectionManager>>, conn_mgr: Arc<Mutex<ConnectionManager>>,
@ -79,7 +79,7 @@ pub(crate) fn is_publicly_routable(addr: &SocketAddr) -> bool {
impl Network { impl Network {
pub async fn new( pub async fn new(
secret_key: iroh::SecretKey, secret_key: iroh::SecretKey,
storage: Arc<Mutex<Storage>>, storage: Arc<StoragePool>,
bind_addr: Option<SocketAddr>, bind_addr: Option<SocketAddr>,
secret_seed: [u8; 32], secret_seed: [u8; 32],
blob_store: Arc<BlobStore>, blob_store: Arc<BlobStore>,
@ -469,7 +469,7 @@ impl Network {
// Store peer with their address // Store peer with their address
{ {
let storage = this.storage.lock().await; let storage = this.storage.get().await;
let _ = storage.upsert_peer(&remote_node_id, &[remote_sock], None); let _ = storage.upsert_peer(&remote_node_id, &[remote_sock], None);
} }
@ -512,7 +512,7 @@ impl Network {
async fn handle_incoming_connection( async fn handle_incoming_connection(
conn_mgr: Arc<Mutex<ConnectionManager>>, conn_mgr: Arc<Mutex<ConnectionManager>>,
conn_handle: ConnHandle, conn_handle: ConnHandle,
storage: Arc<Mutex<Storage>>, storage: Arc<StoragePool>,
conn: iroh::endpoint::Connection, conn: iroh::endpoint::Connection,
remote_node_id: NodeId, remote_node_id: NodeId,
remote_sock: SocketAddr, remote_sock: SocketAddr,
@ -608,7 +608,7 @@ impl Network {
/// Uses ConnHandle for all state access — no direct conn_mgr lock. /// Uses ConnHandle for all state access — no direct conn_mgr lock.
async fn try_mesh_upgrade( async fn try_mesh_upgrade(
conn_handle: &ConnHandle, conn_handle: &ConnHandle,
storage: &Arc<Mutex<Storage>>, storage: &Arc<StoragePool>,
conn: &iroh::endpoint::Connection, conn: &iroh::endpoint::Connection,
remote_node_id: NodeId, remote_node_id: NodeId,
remote_sock: SocketAddr, remote_sock: SocketAddr,
@ -638,7 +638,7 @@ impl Network {
} }
{ {
let s = storage.lock().await; let s = storage.get().await;
let _ = s.upsert_peer(&remote_node_id, &[remote_sock], None); let _ = s.upsert_peer(&remote_node_id, &[remote_sock], None);
let _ = s.add_mesh_peer(&remote_node_id, PeerSlotKind::Local, 0); let _ = s.add_mesh_peer(&remote_node_id, PeerSlotKind::Local, 0);
if s.has_social_route(&remote_node_id).unwrap_or(false) { if s.has_social_route(&remote_node_id).unwrap_or(false) {
@ -663,6 +663,7 @@ impl Network {
} }
Err(e) => { Err(e) => {
error!(peer = hex::encode(remote_node_id), error = ?e, "Initial exchange failed"); error!(peer = hex::encode(remote_node_id), error = ?e, "Initial exchange failed");
return false;
} }
} }
@ -692,13 +693,12 @@ impl Network {
// Store addresses so they're available during initial exchange // Store addresses so they're available during initial exchange
let addrs: Vec<std::net::SocketAddr> = addr.ip_addrs().copied().collect(); let addrs: Vec<std::net::SocketAddr> = addr.ip_addrs().copied().collect();
if !addrs.is_empty() { if !addrs.is_empty() {
let storage = self.storage.lock().await; let storage = self.storage.get().await;
let _ = storage.upsert_peer(&peer_id, &addrs, None); let _ = storage.upsert_peer(&peer_id, &addrs, None);
} }
// QUIC connect OUTSIDE the conn_mgr lock — this can take 60+ seconds // QUIC connect OUTSIDE the conn_mgr lock with 15s timeout
// on unreachable peers and must not block other tasks let conn = ConnectionManager::connect_to_unlocked(&self.endpoint, addr).await?;
let conn = self.endpoint.connect(addr, ALPN_V2).await?;
// Register the established connection // Register the established connection
self.conn_handle.register_connection(peer_id, conn.clone(), addrs, PeerSlotKind::Local).await; self.conn_handle.register_connection(peer_id, conn.clone(), addrs, PeerSlotKind::Local).await;
@ -734,7 +734,7 @@ impl Network {
let addrs: Vec<SocketAddr> = redir.a.iter() let addrs: Vec<SocketAddr> = redir.a.iter()
.filter_map(|a| a.parse::<SocketAddr>().ok()) .filter_map(|a| a.parse::<SocketAddr>().ok())
.collect(); .collect();
let s = self.storage.lock().await; let s = self.storage.get().await;
let _ = s.upsert_peer(&redir_id, &addrs, None); let _ = s.upsert_peer(&redir_id, &addrs, None);
drop(s); drop(s);
self.conn_handle.notify_growth(); self.conn_handle.notify_growth();
@ -837,7 +837,7 @@ impl Network {
// Build full state: all current N1 and N2 as "added", nothing removed // Build full state: all current N1 and N2 as "added", nothing removed
let all_n1 = self.conn_handle.connected_peers().await; let all_n1 = self.conn_handle.connected_peers().await;
let all_n2 = { let all_n2 = {
let storage = self.storage.lock().await; let storage = self.storage.get().await;
storage.build_n2_share().unwrap_or_default() storage.build_n2_share().unwrap_or_default()
}; };
@ -891,7 +891,7 @@ impl Network {
} }
PostVisibility::GroupEncrypted { group_id, .. } => { PostVisibility::GroupEncrypted { group_id, .. } => {
// Push to all group members // Push to all group members
match self.storage.lock().await.get_all_group_members() { match self.storage.get().await.get_all_group_members() {
Ok(map) => map.get(group_id).cloned().unwrap_or_default().into_iter().collect(), Ok(map) => map.get(group_id).cloned().unwrap_or_default().into_iter().collect(),
Err(_) => return 0, Err(_) => return 0,
} }
@ -1041,7 +1041,7 @@ impl Network {
manifest: &crate::types::CdnManifest, manifest: &crate::types::CdnManifest,
) -> usize { ) -> usize {
let downstream = { let downstream = {
let storage = self.storage.lock().await; let storage = self.storage.get().await;
storage.get_blob_downstream(cid).unwrap_or_default() storage.get_blob_downstream(cid).unwrap_or_default()
}; };
let payload = crate::protocol::ManifestPushPayload { let payload = crate::protocol::ManifestPushPayload {
@ -1156,7 +1156,7 @@ impl Network {
} }
let audience_members: Vec<NodeId> = { let audience_members: Vec<NodeId> = {
match self.storage.lock().await.list_audience_members() { match self.storage.get().await.list_audience_members() {
Ok(m) => m, Ok(m) => m,
Err(_) => return 0, Err(_) => return 0,
} }
@ -1318,7 +1318,7 @@ impl Network {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(e) if e.to_string().contains("mesh refused") => { Err(e) if e.to_string().contains("mesh refused") => {
// Anchor refused mesh — reconnect as session for registration // Anchor refused mesh — reconnect as session for registration
let conn = self.endpoint.connect(addr, ALPN_V2).await?; let conn = ConnectionManager::connect_to_unlocked(&self.endpoint, addr).await?;
self.conn_handle.add_session(peer_id, conn, crate::types::SessionReachMethod::Direct, None).await; self.conn_handle.add_session(peer_id, conn, crate::types::SessionReachMethod::Direct, None).await;
self.conn_handle.log_activity( self.conn_handle.log_activity(
ActivityLevel::Info, ActivityLevel::Info,
@ -1352,7 +1352,7 @@ impl Network {
Ok(ExchangeResult::Accepted) => { Ok(ExchangeResult::Accepted) => {
self.conn_handle.register_connection(peer_id, conn.clone(), vec![], PeerSlotKind::Local).await; self.conn_handle.register_connection(peer_id, conn.clone(), vec![], PeerSlotKind::Local).await;
{ {
let s = self.storage.lock().await; let s = self.storage.get().await;
let _ = s.add_mesh_peer(&peer_id, PeerSlotKind::Local, 0); let _ = s.add_mesh_peer(&peer_id, PeerSlotKind::Local, 0);
} }
@ -1464,7 +1464,7 @@ impl Network {
let addrs: Vec<SocketAddr> = redir.a.iter() let addrs: Vec<SocketAddr> = redir.a.iter()
.filter_map(|a| a.parse::<SocketAddr>().ok()) .filter_map(|a| a.parse::<SocketAddr>().ok())
.collect(); .collect();
let _ = self.storage.lock().await.upsert_peer(&redir_id, &addrs, None); let _ = self.storage.get().await.upsert_peer(&redir_id, &addrs, None);
} }
} }
} }
@ -1568,7 +1568,7 @@ impl Network {
} else { } else {
// Network resolution: get reporter connections, resolve outside lock // Network resolution: get reporter connections, resolve outside lock
let reporters_and_conns = { let reporters_and_conns = {
let storage = self.storage.lock().await; let storage = self.storage.get().await;
let n2 = storage.find_in_n2(&candidate_id).unwrap_or_default(); let n2 = storage.find_in_n2(&candidate_id).unwrap_or_default();
let n3 = storage.find_in_n3(&candidate_id).unwrap_or_default(); let n3 = storage.find_in_n3(&candidate_id).unwrap_or_default();
drop(storage); drop(storage);
@ -1660,7 +1660,7 @@ impl Network {
// Find N2 reporter(s) who told us about this peer — they can introduce us // Find N2 reporter(s) who told us about this peer — they can introduce us
let reporters = { let reporters = {
let storage = self.storage.lock().await; let storage = self.storage.get().await;
storage.find_in_n2(&candidate_id).unwrap_or_default() storage.find_in_n2(&candidate_id).unwrap_or_default()
}; };
@ -1724,7 +1724,7 @@ impl Network {
/// Send a uni-stream message to all audience members (persistent if available, ephemeral otherwise). /// Send a uni-stream message to all audience members (persistent if available, ephemeral otherwise).
async fn send_to_audience<T: Serialize>(&self, msg_type: MessageType, payload: &T) -> usize { async fn send_to_audience<T: Serialize>(&self, msg_type: MessageType, payload: &T) -> usize {
let audience: Vec<NodeId> = match self.storage.lock().await.list_audience_members() { let audience: Vec<NodeId> = match self.storage.get().await.list_audience_members() {
Ok(m) => m, Ok(m) => m,
Err(_) => return 0, Err(_) => return 0,
}; };
@ -1741,7 +1741,7 @@ impl Network {
pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result<PullStats> { pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result<PullStats> {
let conn = self.get_connection(peer_id).await?; let conn = self.get_connection(peer_id).await?;
let (our_follows, follows_sync) = { let (our_follows, follows_sync) = {
let storage = self.storage.lock().await; let storage = self.storage.get().await;
( (
storage.list_follows()?, storage.list_follows()?,
storage.get_follows_with_last_sync().unwrap_or_default(), storage.get_follows_with_last_sync().unwrap_or_default(),
@ -1768,7 +1768,7 @@ impl Network {
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
.as_millis() as u64; .as_millis() as u64;
let storage = self.storage.lock().await; let storage = self.storage.get().await;
let mut posts_received = 0; let mut posts_received = 0;
let mut vis_updates = 0; let mut vis_updates = 0;
for sp in &response.posts { for sp in &response.posts {
@ -1968,7 +1968,7 @@ impl Network {
Ok(Ok(result)) if result.accepted => { Ok(Ok(result)) if result.accepted => {
let our_profile = self.conn_handle.our_nat_profile().await; let our_profile = self.conn_handle.our_nat_profile().await;
let peer_profile = { let peer_profile = {
let s = self.storage.lock().await; let s = self.storage.get().await;
s.get_peer_nat_profile(peer_id) s.get_peer_nat_profile(peer_id)
}; };
if let Some(conn) = crate::connection::hole_punch_with_scanning(&self.endpoint, peer_id, &result.target_addresses, our_profile, peer_profile).await { if let Some(conn) = crate::connection::hole_punch_with_scanning(&self.endpoint, peer_id, &result.target_addresses, our_profile, peer_profile).await {
@ -2010,7 +2010,7 @@ impl Network {
pub async fn addr_from_storage(&self, peer_id: &NodeId) -> Option<iroh::EndpointAddr> { pub async fn addr_from_storage(&self, peer_id: &NodeId) -> Option<iroh::EndpointAddr> {
let endpoint_id = iroh::EndpointId::from_bytes(peer_id).ok()?; let endpoint_id = iroh::EndpointId::from_bytes(peer_id).ok()?;
let mut addr = iroh::EndpointAddr::from(endpoint_id); let mut addr = iroh::EndpointAddr::from(endpoint_id);
let storage = self.storage.lock().await; let storage = self.storage.get().await;
if let Ok(Some(rec)) = storage.get_peer_record(peer_id) { if let Ok(Some(rec)) = storage.get_peer_record(peer_id) {
for sock in &rec.addresses { for sock in &rec.addresses {
addr = addr.with_ip_addr(*sock); addr = addr.with_ip_addr(*sock);
@ -2158,7 +2158,7 @@ impl Network {
/// Check if a peer is a known anchor. /// Check if a peer is a known anchor.
pub async fn is_anchor_peer(&self, node_id: &NodeId) -> bool { pub async fn is_anchor_peer(&self, node_id: &NodeId) -> bool {
let storage = self.storage.lock().await; let storage = self.storage.get().await;
storage.is_peer_anchor(node_id).unwrap_or(false) storage.is_peer_anchor(node_id).unwrap_or(false)
} }
@ -2222,7 +2222,7 @@ impl Network {
let our_profile = self.conn_handle.our_nat_profile().await; let our_profile = self.conn_handle.our_nat_profile().await;
let peer_profile = { let peer_profile = {
let s = self.storage.lock().await; let s = self.storage.get().await;
s.get_peer_nat_profile(&target) s.get_peer_nat_profile(&target)
}; };
@ -2301,7 +2301,7 @@ impl Network {
exclude_peer: &crate::types::NodeId, exclude_peer: &crate::types::NodeId,
) -> usize { ) -> usize {
let downstream = { let downstream = {
let storage = self.storage.lock().await; let storage = self.storage.get().await;
storage.get_post_downstream(post_id).unwrap_or_default() storage.get_post_downstream(post_id).unwrap_or_default()
}; };
let mut sent = 0; let mut sent = 0;

File diff suppressed because it is too large Load diff

View file

@ -287,7 +287,7 @@ pub struct RefuseRedirectPayload {
} }
/// Worm lookup query (bi-stream) — searches for nodes, posts, or blobs /// Worm lookup query (bi-stream) — searches for nodes, posts, or blobs
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WormQueryPayload { pub struct WormQueryPayload {
pub worm_id: WormId, pub worm_id: WormId,
pub target: NodeId, pub target: NodeId,

View file

@ -30,6 +30,44 @@ pub struct Storage {
conn: Connection, conn: Connection,
} }
/// Pool of Storage connections for concurrent SQLite access in WAL mode.
/// Each connection is independently locked — readers don't block each other.
/// Uses tokio::sync::Mutex so guards are Send (safe across .await points).
pub struct StoragePool {
slots: Vec<tokio::sync::Mutex<Storage>>,
}
const STORAGE_POOL_SIZE: usize = 8;
impl StoragePool {
/// Create a pool of Storage connections to the same database.
pub fn open(path: impl AsRef<std::path::Path>) -> anyhow::Result<Self> {
let mut slots = Vec::with_capacity(STORAGE_POOL_SIZE);
// First connection does schema init + migration
let first = Storage::open(path.as_ref())?;
slots.push(tokio::sync::Mutex::new(first));
// Additional connections just open + WAL mode (schema already exists)
for _ in 1..STORAGE_POOL_SIZE {
let conn = Connection::open(path.as_ref())?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")?;
slots.push(tokio::sync::Mutex::new(Storage { conn }));
}
Ok(Self { slots })
}
/// Get an available Storage connection. Tries each slot with try_lock;
/// if all busy, awaits the first (rare under normal load).
pub async fn get(&self) -> tokio::sync::MutexGuard<'_, Storage> {
for slot in &self.slots {
if let Ok(guard) = slot.try_lock() {
return guard;
}
}
// All busy — await the first
self.slots[0].lock().await
}
}
/// Current schema version. Bump this when making schema or data changes /// Current schema version. Bump this when making schema or data changes
/// that require migration. Old databases with a lower version will be migrated. /// that require migration. Old databases with a lower version will be migrated.
/// If the gap is too large (major version mismatch), the DB is reset instead. /// If the gap is too large (major version mismatch), the DB is reset instead.

View file

@ -126,7 +126,7 @@ async fn serve_post(stream: &mut TcpStream, path: &str, node: &Arc<Node>, browse
// Single lock: gather holders, local post, AND author name if local // Single lock: gather holders, local post, AND author name if local
let (holders, local_post, local_author_name) = { let (holders, local_post, local_author_name) = {
let store = node.storage.lock().await; let store = node.storage.get().await;
let mut holders = Vec::new(); let mut holders = Vec::new();
if let Some(author) = author_id { if let Some(author) = author_id {
@ -190,7 +190,7 @@ async fn serve_post(stream: &mut TcpStream, path: &str, node: &Arc<Node>, browse
Ok(Ok(Some(sync_post))) => { Ok(Ok(Some(sync_post))) => {
// Single lock: store post AND get author name // Single lock: store post AND get author name
let author_name = { let author_name = {
let store = node.storage.lock().await; let store = node.storage.get().await;
let _ = store.store_post_with_visibility( let _ = store.store_post_with_visibility(
&sync_post.id, &sync_post.post, &sync_post.visibility, &sync_post.id, &sync_post.post, &sync_post.visibility,
); );
@ -230,7 +230,7 @@ async fn try_redirect(
use crate::types::NatMapping; use crate::types::NatMapping;
let post_hex = hex::encode(post_id); let post_hex = hex::encode(post_id);
let store = node.storage.lock().await; let store = node.storage.get().await;
// Classify holders into tiers // Classify holders into tiers
let mut direct_candidates: Vec<(NodeId, String)> = Vec::new(); // http_addr known let mut direct_candidates: Vec<(NodeId, String)> = Vec::new(); // http_addr known
@ -354,7 +354,7 @@ async fn serve_blob(stream: &mut TcpStream, path: &str, node: &Arc<Node>) {
// Check blobs table first, then scan post attachments (for posts stored via PostFetch // Check blobs table first, then scan post attachments (for posts stored via PostFetch
// which don't populate the blobs table). // which don't populate the blobs table).
let (mime_type, author_id) = { let (mime_type, author_id) = {
let store = node.storage.lock().await; let store = node.storage.get().await;
// Try blobs table first // Try blobs table first
if let Some(mime) = find_public_blob_mime(&store, &blob_id) { if let Some(mime) = find_public_blob_mime(&store, &blob_id) {
let author = store.get_blob_post_id(&blob_id).ok().flatten().and_then(|pid| { let author = store.get_blob_post_id(&blob_id).ok().flatten().and_then(|pid| {

View file

@ -1,6 +1,6 @@
[package] [package]
name = "itsgoin-desktop" name = "itsgoin-desktop"
version = "0.4.2" version = "0.4.3"
edition = "2021" edition = "2021"
[lib] [lib]

View file

@ -198,7 +198,7 @@ async fn post_to_dto(
// Resolve intent kind from storage // Resolve intent kind from storage
let intent_kind = { let intent_kind = {
let storage = node.storage.lock().await; let storage = node.storage.get().await;
match storage.get_post_intent(id) { match storage.get_post_intent(id) {
Ok(Some(intent)) => match intent { Ok(Some(intent)) => match intent {
VisibilityIntent::Public => "public".to_string(), VisibilityIntent::Public => "public".to_string(),
@ -237,14 +237,14 @@ async fn post_to_dto(
.collect(); .collect();
// Engagement data // Engagement data
let reaction_counts = { let reaction_counts = {
let storage = node.storage.lock().await; let storage = node.storage.get().await;
storage.get_reaction_counts(id, &node.node_id).unwrap_or_default() storage.get_reaction_counts(id, &node.node_id).unwrap_or_default()
.into_iter() .into_iter()
.map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me }) .map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me })
.collect() .collect()
}; };
let comment_count = { let comment_count = {
let storage = node.storage.lock().await; let storage = node.storage.get().await;
storage.get_comment_count(id).unwrap_or(0) storage.get_comment_count(id).unwrap_or(0)
}; };
@ -286,7 +286,7 @@ async fn decrypt_just_created(
} }
PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => { PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => {
let seed_info = { let seed_info = {
let storage = node.storage.lock().await; let storage = node.storage.get().await;
storage.get_all_group_seeds_map().ok() storage.get_all_group_seeds_map().ok()
.and_then(|map| map.get(&(*group_id, *epoch)).copied()) .and_then(|map| map.get(&(*group_id, *epoch)).copied())
}; };
@ -319,7 +319,7 @@ async fn get_node_info(state: State<'_, AppState>) -> Result<NodeInfoDto, String
// Prefer external address (UPnP, public IPv6, observed) over local bind address // Prefer external address (UPnP, public IPv6, observed) over local bind address
let external_addr = node.network.http_addr(); let external_addr = node.network.http_addr();
let observed_addr = if external_addr.is_none() { let observed_addr = if external_addr.is_none() {
let storage = node.storage.lock().await; let storage = node.storage.get().await;
storage.get_peer_record(&node.node_id).ok().flatten() storage.get_peer_record(&node.node_id).ok().flatten()
.and_then(|r| r.addresses.first().map(|a| a.to_string())) .and_then(|r| r.addresses.first().map(|a| a.to_string()))
} else { } else {
@ -475,7 +475,7 @@ async fn get_blob_path(
if let Some(ref pid_hex) = post_id_hex { if let Some(ref pid_hex) = post_id_hex {
if let Ok(pid_bytes) = hex::decode(pid_hex) { if let Ok(pid_bytes) = hex::decode(pid_hex) {
if let Ok(post_id) = <[u8; 32]>::try_from(pid_bytes.as_slice()) { if let Ok(post_id) = <[u8; 32]>::try_from(pid_bytes.as_slice()) {
let storage = node.storage.lock().await; let storage = node.storage.get().await;
if let Ok(Some((_post, vis))) = storage.get_post_with_visibility(&post_id) { if let Ok(Some((_post, vis))) = storage.get_post_with_visibility(&post_id) {
if !matches!(vis, PostVisibility::Public) { if !matches!(vis, PostVisibility::Public) {
return Ok(None); return Ok(None);
@ -524,7 +524,7 @@ async fn resolve_blob_data(
// Try fetching from network if post_id provided // Try fetching from network if post_id provided
if let Some(pid) = post_id { if let Some(pid) = post_id {
let post = { let post = {
let storage = node.storage.lock().await; let storage = node.storage.get().await;
storage.get_post(&pid).map_err(|e| e.to_string())? storage.get_post(&pid).map_err(|e| e.to_string())?
}; };
if let Some(post) = post { if let Some(post) = post {
@ -665,7 +665,7 @@ async fn connect_peer(
// Store peer with addresses // Store peer with addresses
let ip_addrs: Vec<_> = addr.ip_addrs().copied().collect(); let ip_addrs: Vec<_> = addr.ip_addrs().copied().collect();
{ {
let storage = node.storage.lock().await; let storage = node.storage.get().await;
if ip_addrs.is_empty() { if ip_addrs.is_empty() {
storage.add_peer(&nid).map_err(|e| e.to_string())?; storage.add_peer(&nid).map_err(|e| e.to_string())?;
} else { } else {
@ -720,7 +720,7 @@ async fn list_follows(state: State<'_, AppState>) -> Result<Vec<PeerDto>, String
_ => None, _ => None,
}; };
// Try to get peer record for address info // Try to get peer record for address info
let storage = node.storage.lock().await; let storage = node.storage.get().await;
let rec = storage.get_peer_record(nid).ok().flatten(); let rec = storage.get_peer_record(nid).ok().flatten();
drop(storage); drop(storage);
let is_online = node.network.is_connected(nid).await let is_online = node.network.is_connected(nid).await
@ -766,7 +766,7 @@ async fn list_peers(state: State<'_, AppState>) -> Result<Vec<PeerDto>, String>
.map(|(nid, _, _)| nid) .map(|(nid, _, _)| nid)
.collect(); .collect();
let (social_ids, n2_ids, n3_ids) = { let (social_ids, n2_ids, n3_ids) = {
let storage = node.storage.lock().await; let storage = node.storage.get().await;
let social: std::collections::HashSet<_> = storage let social: std::collections::HashSet<_> = storage
.list_social_routes() .list_social_routes()
.unwrap_or_default() .unwrap_or_default()
@ -994,7 +994,7 @@ async fn set_anchors(
#[tauri::command] #[tauri::command]
async fn list_anchor_peers(state: State<'_, AppState>) -> Result<Vec<PeerDto>, String> { async fn list_anchor_peers(state: State<'_, AppState>) -> Result<Vec<PeerDto>, String> {
let node = state.inner(); let node = state.inner();
let storage = node.storage.lock().await; let storage = node.storage.get().await;
let records = storage.list_anchor_peers().map_err(|e| e.to_string())?; let records = storage.list_anchor_peers().map_err(|e| e.to_string())?;
drop(storage); drop(storage);
let mut dtos = Vec::with_capacity(records.len()); let mut dtos = Vec::with_capacity(records.len());
@ -1026,7 +1026,7 @@ struct KnownAnchorDto {
#[tauri::command] #[tauri::command]
async fn list_known_anchors(state: State<'_, AppState>) -> Result<Vec<KnownAnchorDto>, String> { async fn list_known_anchors(state: State<'_, AppState>) -> Result<Vec<KnownAnchorDto>, String> {
let node = state.inner(); let node = state.inner();
let storage = node.storage.lock().await; let storage = node.storage.get().await;
let anchors = storage.list_known_anchors().map_err(|e| e.to_string())?; let anchors = storage.list_known_anchors().map_err(|e| e.to_string())?;
drop(storage); drop(storage);
let mut dtos = Vec::with_capacity(anchors.len()); let mut dtos = Vec::with_capacity(anchors.len());
@ -1444,7 +1444,7 @@ async fn get_badge_counts(
last_feed_view_ms: u64, last_feed_view_ms: u64,
) -> Result<BadgeCountsDto, String> { ) -> Result<BadgeCountsDto, String> {
let node = state.inner(); let node = state.inner();
let storage = node.storage.lock().await; let storage = node.storage.get().await;
// Feed badge: count non-DM posts from others newer than last_feed_view_ms // Feed badge: count non-DM posts from others newer than last_feed_view_ms
let feed_posts = storage.get_feed().map_err(|e| e.to_string())?; let feed_posts = storage.get_feed().map_err(|e| e.to_string())?;
@ -1588,7 +1588,7 @@ async fn get_network_summary(state: State<'_, AppState>) -> Result<NetworkSummar
} }
} }
let (n2, n3) = { let (n2, n3) = {
let storage = node.storage.lock().await; let storage = node.storage.get().await;
let n2 = storage.count_distinct_n2().unwrap_or(0); let n2 = storage.count_distinct_n2().unwrap_or(0);
let n3 = storage.count_distinct_n3().unwrap_or(0); let n3 = storage.count_distinct_n3().unwrap_or(0);
(n2, n3) (n2, n3)
@ -1664,7 +1664,7 @@ async fn request_referrals(state: State<'_, AppState>) -> Result<String, String>
// Try known_anchors table first (populated by anchor register cycle), // Try known_anchors table first (populated by anchor register cycle),
// fall back to anchor peers from the peers table (is_anchor = true) // fall back to anchor peers from the peers table (is_anchor = true)
let anchors: Vec<(NodeId, Vec<std::net::SocketAddr>)> = { let anchors: Vec<(NodeId, Vec<std::net::SocketAddr>)> = {
let storage = node.storage.lock().await; let storage = node.storage.get().await;
let known = storage.list_known_anchors().unwrap_or_default(); let known = storage.list_known_anchors().unwrap_or_default();
if !known.is_empty() { if !known.is_empty() {
known known
@ -2096,7 +2096,7 @@ pub fn run() {
// Start blob eviction cycle (every 5 min) // Start blob eviction cycle (every 5 min)
let cache_max_bytes: u64 = { let cache_max_bytes: u64 = {
let storage = n.storage.lock().await; let storage = n.storage.get().await;
storage.get_setting("cache_size_bytes") storage.get_setting("cache_size_bytes")
.ok() .ok()
.flatten() .flatten()

View file

@ -1,6 +1,6 @@
{ {
"productName": "itsgoin", "productName": "itsgoin",
"version": "0.4.2", "version": "0.4.3",
"identifier": "com.itsgoin.app", "identifier": "com.itsgoin.app",
"build": { "build": {
"frontendDist": "../../frontend", "frontendDist": "../../frontend",

View file

@ -3007,22 +3007,20 @@ $('#circle-profiles-toggle').addEventListener('click', () => {
// --- Notifications popover --- // --- Notifications popover ---
// Text size toggle // Text size toggle
const TEXT_SIZE_SCALES = { small: '100%', normal: '150%', large: '200%' }; const TEXT_SIZE_SCALES = { xsmall: '75%', small: '100%', normal: '125%', large: '150%', xlarge: '200%' };
// Apply text size immediately (default Normal = 150%) // Apply text size immediately from localStorage cache (no async wait)
document.documentElement.style.fontSize = '150%'; const _cachedTextSize = localStorage.getItem('text_size') || 'normal';
(async () => { document.documentElement.style.fontSize = TEXT_SIZE_SCALES[_cachedTextSize] || '125%';
const saved = await invoke('get_setting', { key: 'text_size' }).catch(() => null) || 'normal'; document.querySelectorAll('.text-size-opt').forEach(b => {
document.documentElement.style.fontSize = TEXT_SIZE_SCALES[saved] || '150%'; b.classList.toggle('active', b.dataset.size === _cachedTextSize);
document.querySelectorAll('.text-size-opt').forEach(b => { });
b.classList.toggle('active', b.dataset.size === saved);
});
})();
document.querySelectorAll('.text-size-opt').forEach(btn => { document.querySelectorAll('.text-size-opt').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const size = btn.dataset.size; const size = btn.dataset.size;
document.documentElement.style.fontSize = TEXT_SIZE_SCALES[size] || ''; document.documentElement.style.fontSize = TEXT_SIZE_SCALES[size] || '';
document.querySelectorAll('.text-size-opt').forEach(b => b.classList.remove('active')); document.querySelectorAll('.text-size-opt').forEach(b => b.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
localStorage.setItem('text_size', size);
await invoke('set_setting', { key: 'text_size', value: size }).catch(() => {}); await invoke('set_setting', { key: 'text_size', value: size }).catch(() => {});
toast('Text size updated'); toast('Text size updated');
}); });

View file

@ -30,11 +30,11 @@
<main> <main>
<nav id="tabs"> <nav id="tabs">
<button class="tab" data-tab="feed">Feed</button> <button class="tab" data-tab="feed"><span class="tab-icon">&#x1f4f0;</span><span class="tab-label">Feed</span></button>
<button class="tab" data-tab="myposts">My Posts</button> <button class="tab" data-tab="myposts"><span class="tab-icon">&#x270d;</span><span class="tab-label">My Posts</span></button>
<button class="tab" data-tab="people">People</button> <button class="tab" data-tab="people"><span class="tab-icon">&#x1f465;</span><span class="tab-label">People</span></button>
<button class="tab" data-tab="messages">Messages</button> <button class="tab" data-tab="messages"><span class="tab-icon">&#x1f4ac;</span><span class="tab-label">Messages</span></button>
<button class="tab" data-tab="settings">Settings</button> <button class="tab" data-tab="settings"><span class="tab-icon">&#x2699;</span><span class="tab-label">Settings</span></button>
</nav> </nav>
<!-- Welcome (shown on startup) --> <!-- Welcome (shown on startup) -->
@ -229,9 +229,11 @@
<div class="section-card"> <div class="section-card">
<h3>Text Size</h3> <h3>Text Size</h3>
<div id="text-size-btns" style="display:flex;gap:0.4rem;margin-top:0.3rem"> <div id="text-size-btns" style="display:flex;gap:0.4rem;margin-top:0.3rem">
<button class="notif-opt text-size-opt" data-size="small">Small</button> <button class="notif-opt text-size-opt" data-size="xsmall">XS</button>
<button class="notif-opt text-size-opt active" data-size="normal">Normal</button> <button class="notif-opt text-size-opt" data-size="small">S</button>
<button class="notif-opt text-size-opt" data-size="large">Large</button> <button class="notif-opt text-size-opt active" data-size="normal">M</button>
<button class="notif-opt text-size-opt" data-size="large">L</button>
<button class="notif-opt text-size-opt" data-size="xlarge">XL</button>
</div> </div>
</div> </div>

View file

@ -81,13 +81,25 @@ header h1 { font-size: clamp(1.4rem, 2.5vw, 2rem); color: #7fdbca; margin: 0; }
.compose-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; } .compose-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.compose-left { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; } .compose-left { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
/* Tabs */ /* Tabs — desktop (top bar) */
#tabs { display: flex; gap: 0; margin-bottom: 1rem; border-bottom: 1px solid #333; } #tabs { display: flex; gap: 0; margin-bottom: 1rem; border-bottom: 1px solid #333; }
.tab { background: none; border: none; color: #99a; padding: 0.5rem 0.6rem; cursor: pointer; border-bottom: 2px solid transparent; font-size: 0.82rem; transition: color 0.15s, border-color 0.15s; position: relative; flex: 1; text-align: center; white-space: nowrap; } .tab { background: none; border: none; color: #99a; padding: 0.5rem 0.6rem; cursor: pointer; border-bottom: 2px solid transparent; font-size: 0.82rem; transition: color 0.15s, border-color 0.15s; position: relative; flex: 1; text-align: center; white-space: nowrap; }
.tab:hover { color: #ccd; } .tab:hover { color: #ccd; }
.tab.active { color: #7fdbca; border-bottom-color: #7fdbca; } .tab.active { color: #7fdbca; border-bottom-color: #7fdbca; }
.tab-icon { display: none; }
.tab-badge { display: inline-flex; align-items: center; justify-content: center; background: #0f3460; color: #7fdbca; font-size: 0.6rem; min-width: 1.1rem; height: 1.1rem; border-radius: 0.55rem; padding: 0 0.3rem; margin-left: 0.25rem; font-family: system-ui, sans-serif; vertical-align: middle; } .tab-badge { display: inline-flex; align-items: center; justify-content: center; background: #0f3460; color: #7fdbca; font-size: 0.6rem; min-width: 1.1rem; height: 1.1rem; border-radius: 0.55rem; padding: 0 0.3rem; margin-left: 0.25rem; font-family: system-ui, sans-serif; vertical-align: middle; }
/* Tabs — mobile/tablet (bottom nav bar) */
@media (max-width: 768px) {
#tabs { position: fixed; bottom: 0; left: 0; right: 0; z-index: 900; background: #0a0a1a; border-bottom: none; border-top: 1px solid #333; margin-bottom: 0; padding: 0; padding-bottom: env(safe-area-inset-bottom, 0); }
.tab { flex-direction: column; align-items: center; padding: 0.4rem 0.2rem 0.3rem; border-bottom: none; border-top: 2px solid transparent; font-size: 0.6rem; gap: 0.15rem; display: flex; }
.tab.active { border-bottom: none; border-top-color: #7fdbca; }
.tab-icon { display: block; font-size: 1.2rem; line-height: 1; }
.tab-badge { position: absolute; top: 0.1rem; right: 0.2rem; margin-left: 0; font-size: 0.5rem; min-width: 0.9rem; height: 0.9rem; border-radius: 0.45rem; }
main { padding-bottom: 4rem; }
.toast { bottom: 4.5rem; }
}
/* Views / tab content transitions */ /* Views / tab content transitions */
.view { display: none; animation: viewFadeIn 0.2s ease-out; } .view { display: none; animation: viewFadeIn 0.2s ease-out; }
.view.active { display: block; } .view.active { display: block; }

View file

@ -44,7 +44,8 @@
<p>This is the canonical technical reference for ItsGoin. It describes the vision, the architecture, and the current state of every subsystem &mdash; with full implementation detail. This document is versioned; each update records what changed.</p> <p>This is the canonical technical reference for ItsGoin. It describes the vision, the architecture, and the current state of every subsystem &mdash; with full implementation detail. This document is versioned; each update records what changed.</p>
<div class="card" style="margin-top: 1rem;"> <div class="card" style="margin-top: 1rem;">
<strong style="font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;">Changelog</strong> <strong style="font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;">Changelog</strong>
<p style="margin-top: 0.5rem;"><strong>v0.4.2</strong> (2026-03-22): Welcome screen &mdash; startup shows &ldquo;How&rsquo;s it goin?&rdquo; with staggered counters (connections, posts, messages, reacts, comments) while backend bootstraps. Status ticker &mdash; header ticker for new posts, messages, reactions, comments, connection changes. Notification improvements &mdash; Tauri plugin &rarr; Web Notification &rarr; notify-rust fallback chain, Linux native notifications. Responsive text scaling &mdash; Small/Normal/Large (100%/150%/200%), persisted via settings. Diagnostics popover &mdash; diagnostics moved from inline section to overlay, connections on-demand, timers removed. Share details lightbox with QR code. Connect string prefers external address (UPnP/public IPv6/observed). Stale N1 fix &mdash; disconnected social routes excluded from N1 share. Replication handler fix &mdash; actively fetches posts + blobs from requester after accepting replication. Hole punch fix &mdash; target-side registers publicly routable remote address for relay introduction. Replication semaphore (3 concurrent max). Peer labels show truncated node ID.</p> <p style="margin-top: 0.5rem;"><strong>v0.4.3</strong> (2026-03-22): Lock contention overhaul &mdash; all conn_mgr lock holds during network I/O eliminated. PostFetch, TcpPunch, PullFromPeer, FetchEngagement, ResolveAddress, AnchorProbe, WormLookup, ContentSearch now use brief locks for data gathering only. Bi-stream handlers (BlobRequest, WormQuery, RelayIntroduce, PostFetchRequest, ManifestRefresh) fully lock-free for I/O. ConnectionActor hoists shared Arcs (storage, blob_store, endpoint) for lock-free access. ResolveAddress adds 5s per-query timeout (was unbounded). Worm cascade uses connection snapshots. Initial exchange failure now aborts mesh upgrade (was silently continuing). connect_to_peer/connect_to_anchor use 15s timeout. StoragePool &mdash; 8 concurrent SQLite connections in WAL mode replace single Mutex&lt;Storage&gt;. Reads run fully parallel; writes serialize only at SQLite level. Bottom nav bar for mobile/tablet (&le;768px) with icon tabs. Text sizes: XS 75%, S 100%, M 125% (default), L 150%, XL 200%. Text size persisted to localStorage for instant restore.</p>
<p><strong>v0.4.2</strong> (2026-03-22): Welcome screen &mdash; startup shows &ldquo;How&rsquo;s it goin?&rdquo; with staggered counters (connections, posts, messages, reacts, comments) while backend bootstraps. Status ticker &mdash; header ticker for new posts, messages, reactions, comments, connection changes. Notification improvements &mdash; Tauri plugin &rarr; Web Notification &rarr; notify-rust fallback chain, Linux native notifications. Responsive text scaling &mdash; Small/Normal/Large (100%/150%/200%), persisted via settings. Diagnostics popover &mdash; diagnostics moved from inline section to overlay, connections on-demand, timers removed. Share details lightbox with QR code. Connect string prefers external address (UPnP/public IPv6/observed). Stale N1 fix &mdash; disconnected social routes excluded from N1 share. Replication handler fix &mdash; actively fetches posts + blobs from requester after accepting replication. Hole punch fix &mdash; target-side registers publicly routable remote address for relay introduction. Replication semaphore (3 concurrent max). Peer labels show truncated node ID.</p>
<p><strong>v0.4.1</strong> (2026-03-21): Security hardening &mdash; reaction signatures (ed25519), comment signature verification on receipt, reaction removal authorization, BlobHeader author verification. Lock contention fixes &mdash; ManifestPush discovery (cm lock released during I/O), pull request handler (filter without lock), pull sender (split into brief locks), engagement checker (batch writes per chunk). Data cleanup &mdash; post deletion cleans downstream/upstream/seen tables.</p> <p><strong>v0.4.1</strong> (2026-03-21): Security hardening &mdash; reaction signatures (ed25519), comment signature verification on receipt, reaction removal authorization, BlobHeader author verification. Lock contention fixes &mdash; ManifestPush discovery (cm lock released during I/O), pull request handler (filter without lock), pull sender (split into brief locks), engagement checker (batch writes per chunk). Data cleanup &mdash; post deletion cleans downstream/upstream/seen tables.</p>
<p><strong>v0.4.0</strong> (2026-03-21): Protocol v4 &mdash; header-driven sync. ManifestPush as primary post notification. Slim PullSyncRequest (per-author timestamps, not full post ID list). Tiered engagement checks (5min/1hr/4hr/24hr by content age). Multi-upstream (3 max) with fallback chain. Auto-prefetch followed authors &lt;90d. Self Last Encounter per-author tracking. Encrypted-but-not-for-us CDN caching. Serial engagement polling. ~90% bandwidth reduction for established nodes.</p> <p><strong>v0.4.0</strong> (2026-03-21): Protocol v4 &mdash; header-driven sync. ManifestPush as primary post notification. Slim PullSyncRequest (per-author timestamps, not full post ID list). Tiered engagement checks (5min/1hr/4hr/24hr by content age). Multi-upstream (3 max) with fallback chain. Auto-prefetch followed authors &lt;90d. Self Last Encounter per-author tracking. Encrypted-but-not-for-us CDN caching. Serial engagement polling. ~90% bandwidth reduction for established nodes.</p>
<p><strong>v0.3.6</strong> (2026-03-20): Active CDN replication &mdash; all devices proactively replicate recent posts to peers (desktops &gt; anchors &gt; phones priority). ReplicationRequest/Response (0xE1/0xE2). Device roles (Intermittent/Available/Persistent) advertised in InitialExchange. Bandwidth budgets: replication (pull to cache) + delivery (serve requests), hourly auto-reset, phones 100MB/1GB, desktops 200MB/2GB, anchors 200MB/1GB. Cache management: 1GB default, configurable, eviction cycle activated with share-link priority boost. Engagement distribution fix &mdash; BlobHeader JSON rebuilt after diff ops. Tombstone system &mdash; deleted reactions/comments tombstoned, propagate via pull sync. Persistent notifications via seen_engagement/seen_messages tables. DOS hardening: fan-out cap (10), prefetch cap (20), downstream registration cap (50), delivery budget enforcement. Pull preference reordered: non-anchors first. Network indicator &mdash; header dot (black/red/yellow/green) + capability labels. Tab badges &mdash; contextual counts (new posts, engagement, online, unread). Message read tracking on open/close/send. Stats bar removed.</p> <p><strong>v0.3.6</strong> (2026-03-20): Active CDN replication &mdash; all devices proactively replicate recent posts to peers (desktops &gt; anchors &gt; phones priority). ReplicationRequest/Response (0xE1/0xE2). Device roles (Intermittent/Available/Persistent) advertised in InitialExchange. Bandwidth budgets: replication (pull to cache) + delivery (serve requests), hourly auto-reset, phones 100MB/1GB, desktops 200MB/2GB, anchors 200MB/1GB. Cache management: 1GB default, configurable, eviction cycle activated with share-link priority boost. Engagement distribution fix &mdash; BlobHeader JSON rebuilt after diff ops. Tombstone system &mdash; deleted reactions/comments tombstoned, propagate via pull sync. Persistent notifications via seen_engagement/seen_messages tables. DOS hardening: fan-out cap (10), prefetch cap (20), downstream registration cap (50), delivery budget enforcement. Pull preference reordered: non-anchors first. Network indicator &mdash; header dot (black/red/yellow/green) + capability labels. Tab badges &mdash; contextual counts (new posts, engagement, online, unread). Message read tracking on open/close/send. Stats bar removed.</p>

View file

@ -25,16 +25,16 @@
<section> <section>
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.25rem;">Download ItsGoin</h1> <h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.25rem;">Download ItsGoin</h1>
<p>Available for Android and Linux. Free and open source.</p> <p>Available for Android and Linux. Free and open source.</p>
<p style="color: var(--text-muted); font-size: 0.85rem;">Version 0.4.2 &mdash; March 22, 2026</p> <p style="color: var(--text-muted); font-size: 0.85rem;">Version 0.4.3 &mdash; March 22, 2026</p>
<div class="downloads"> <div class="downloads">
<a href="itsgoin-0.4.2.apk" class="download-btn btn-android"> <a href="itsgoin-0.4.3.apk" class="download-btn btn-android">
Android APK Android APK
<span class="sub">v0.4.2</span> <span class="sub">v0.4.3</span>
</a> </a>
<a href="itsgoin_0.4.2_amd64.AppImage" class="download-btn btn-linux"> <a href="itsgoin_0.4.3_amd64.AppImage" class="download-btn btn-linux">
Linux AppImage Linux AppImage
<span class="sub">v0.4.2</span> <span class="sub">v0.4.3</span>
</a> </a>
</div> </div>
</section> </section>
@ -46,7 +46,7 @@
<h3 style="color: var(--accent);">Android</h3> <h3 style="color: var(--accent);">Android</h3>
<ol class="steps"> <ol class="steps">
<li><strong>Download the APK</strong> &mdash; Tap the button above. Your browser may warn that this type of file can be harmful &mdash; tap <strong>Download anyway</strong>.</li> <li><strong>Download the APK</strong> &mdash; Tap the button above. Your browser may warn that this type of file can be harmful &mdash; tap <strong>Download anyway</strong>.</li>
<li><strong>Open the file</strong> &mdash; When the download finishes, tap the notification or find <code>itsgoin-0.4.2.apk</code> in your Downloads folder and tap it.</li> <li><strong>Open the file</strong> &mdash; When the download finishes, tap the notification or find <code>itsgoin-0.4.3.apk</code> in your Downloads folder and tap it.</li>
<li><strong>Allow installation</strong> &mdash; Android will ask you to allow installs from this source. Tap <strong>Settings</strong>, toggle <strong>"Allow from this source"</strong>, then go back and tap <strong>Install</strong>.</li> <li><strong>Allow installation</strong> &mdash; Android will ask you to allow installs from this source. Tap <strong>Settings</strong>, toggle <strong>"Allow from this source"</strong>, then go back and tap <strong>Install</strong>.</li>
<li><strong>Launch the app</strong> &mdash; Once installed, tap <strong>Open</strong> or find ItsGoin in your app drawer.</li> <li><strong>Launch the app</strong> &mdash; Once installed, tap <strong>Open</strong> or find ItsGoin in your app drawer.</li>
</ol> </ol>
@ -59,8 +59,8 @@
<h3 style="color: var(--green);">Linux (AppImage)</h3> <h3 style="color: var(--green);">Linux (AppImage)</h3>
<ol class="steps"> <ol class="steps">
<li><strong>Download the AppImage</strong> &mdash; Click the button above to download.</li> <li><strong>Download the AppImage</strong> &mdash; Click the button above to download.</li>
<li><strong>Make it executable</strong> &mdash; Open a terminal and run:<br><code>chmod +x itsgoin_0.4.2_amd64.AppImage</code></li> <li><strong>Make it executable</strong> &mdash; Open a terminal and run:<br><code>chmod +x itsgoin_0.4.3_amd64.AppImage</code></li>
<li><strong>Run it</strong> &mdash; Double-click the file, or from the terminal:<br><code>./itsgoin_0.4.2_amd64.AppImage</code></li> <li><strong>Run it</strong> &mdash; Double-click the file, or from the terminal:<br><code>./itsgoin_0.4.3_amd64.AppImage</code></li>
</ol> </ol>
<div class="note"> <div class="note">
<strong>Note:</strong> If it doesn't launch, you may need to install FUSE:<br><code>sudo apt install libfuse2</code> (Debian/Ubuntu) or <code>sudo dnf install fuse</code> (Fedora). <strong>Note:</strong> If it doesn't launch, you may need to install FUSE:<br><code>sudo apt install libfuse2</code> (Debian/Ubuntu) or <code>sudo dnf install fuse</code> (Fedora).
@ -71,6 +71,17 @@
<section> <section>
<h2>Changelog</h2> <h2>Changelog</h2>
<div class="changelog"> <div class="changelog">
<div class="changelog-date">v0.4.3 &mdash; March 22, 2026</div>
<ul>
<li><strong>Lock contention overhaul</strong> &mdash; All conn_mgr lock holds during network I/O eliminated across 14 handlers. Brief locks for data gathering only; all network operations run lock-free.</li>
<li><strong>StoragePool</strong> &mdash; 8 concurrent SQLite connections in WAL mode replace the single Mutex. Reads run fully parallel; writes serialize only at the SQLite level.</li>
<li><strong>Initial exchange fix</strong> &mdash; Failed initial exchanges now abort the mesh upgrade instead of silently continuing with a broken connection.</li>
<li><strong>Connect timeout</strong> &mdash; connect_to_peer and connect_to_anchor now use a consistent 15s timeout. ResolveAddress adds 5s per-query timeout (was unbounded).</li>
<li><strong>Worm cascade unlock</strong> &mdash; WormLookup, ContentSearch, and WormQuery use connection snapshots for lock-free fan-out.</li>
<li><strong>Bottom nav bar</strong> &mdash; Mobile/tablet (&le;768px) gets a fixed bottom navigation bar with icon tabs. Desktop keeps the top tab bar.</li>
<li><strong>Text size update</strong> &mdash; Five options: XS (75%), S (100%), M (125% default), L (150%), XL (200%). Persisted to localStorage for instant restore on startup.</li>
</ul>
<div class="changelog-date">v0.4.2 &mdash; March 22, 2026</div> <div class="changelog-date">v0.4.2 &mdash; March 22, 2026</div>
<ul> <ul>
<li><strong>Welcome screen</strong> &mdash; Startup shows &ldquo;How&rsquo;s it goin?&rdquo; with staggered counters (connections, posts, messages, reacts, comments) while the backend bootstraps. Replaces the blank-screen wait.</li> <li><strong>Welcome screen</strong> &mdash; Startup shows &ldquo;How&rsquo;s it goin?&rdquo; with staggered counters (connections, posts, messages, reacts, comments) while the backend bootstraps. Replaces the blank-screen wait.</li>