Skip to content

Commit 272bdf6

Browse files
pmorris-devclaude
andcommitted
feat: add configurable timeout for interruptible transactions
begin_interruptible_transaction holds the exclusive writer until the frontend commits or rolls back. If the frontend never does, the writer is locked indefinitely. Add a configurable idle timeout (default 5 min) with lazy cleanup: expired transactions are evicted on the next insert or rejected on the next remove, auto-rolling back via Drop. Convert ActiveInterruptibleTransactions from a tuple struct to named fields with a timeout Duration, and add a created_at Instant to each ActiveInterruptibleTransaction. Expose Builder::transaction_timeout() in the plugin for configuration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e58189d commit 272bdf6

5 files changed

Lines changed: 206 additions & 12 deletions

File tree

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,41 @@ Working Tauri demo apps are in the [`examples/`](examples) directory:
948948
See the [toolkit crate README](crates/sqlx-sqlite-toolkit/README.md#examples)
949949
for setup instructions.
950950

951+
## Security Considerations
952+
953+
### Cross-Window Shared State
954+
955+
Database instances are shared across all webviews/windows within the same Tauri
956+
application. A database loaded in one window is accessible from any other window
957+
without calling `load()` again. Writes from one window are immediately visible
958+
to reads in another, and closing a database affects all windows.
959+
960+
### Resource Limits
961+
962+
The plugin enforces several resource limits to prevent denial-of-service from
963+
untrusted or buggy frontend code:
964+
965+
* **Database count**: Maximum 50 concurrently loaded databases (configurable
966+
via `Builder::max_databases()`)
967+
* **Interruptible transaction timeout**: Transactions that exceed the
968+
default (5 minutes) are automatically rolled back on the next access
969+
attempt (configurable via `Builder::transaction_timeout()`)
970+
* **Observer channel capacity**: Capped at 10,000 (default 256)
971+
* **Observed tables**: Maximum 100 tables per `observe()` call
972+
* **Subscriptions**: Maximum 100 active subscriptions per database
973+
974+
### Unbounded Result Sets
975+
976+
`fetchAll()` returns the entire result set in a single response with no built-in
977+
size limit. For large or unbounded queries, prefer `fetchPage()` with keyset
978+
pagination to keep memory usage bounded on both the Rust and TypeScript sides.
979+
980+
### Path Validation
981+
982+
Database paths are validated to prevent directory traversal. Absolute paths,
983+
`..` segments, and null bytes are rejected. All paths are resolved relative to
984+
the app config directory.
985+
951986
## Development
952987

953988
This project follows

crates/sqlx-sqlite-toolkit/src/error.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ pub enum Error {
4545
#[error("invalid transaction token")]
4646
InvalidTransactionToken,
4747

48+
/// Transaction timed out (exceeded the configured timeout).
49+
#[error("transaction timed out for database: {0}")]
50+
TransactionTimedOut(String),
51+
4852
/// Error from the observer (change notifications).
4953
#[cfg(feature = "observer")]
5054
#[error(transparent)]
@@ -115,6 +119,7 @@ impl Error {
115119
Error::TransactionAlreadyActive(_) => "TRANSACTION_ALREADY_ACTIVE".to_string(),
116120
Error::NoActiveTransaction(_) => "NO_ACTIVE_TRANSACTION".to_string(),
117121
Error::InvalidTransactionToken => "INVALID_TRANSACTION_TOKEN".to_string(),
122+
Error::TransactionTimedOut(_) => "TRANSACTION_TIMED_OUT".to_string(),
118123
#[cfg(feature = "observer")]
119124
Error::Observer(_) => "OBSERVER_ERROR".to_string(),
120125
Error::Io(_) => "IO_ERROR".to_string(),
@@ -194,6 +199,13 @@ mod tests {
194199
assert_eq!(err.error_code(), "IO_ERROR");
195200
}
196201

202+
#[test]
203+
fn test_error_code_transaction_timed_out() {
204+
let err = Error::TransactionTimedOut("test.db".into());
205+
assert_eq!(err.error_code(), "TRANSACTION_TIMED_OUT");
206+
assert!(err.to_string().contains("test.db"));
207+
}
208+
197209
#[test]
198210
fn test_error_code_other() {
199211
let err = Error::Other("something went wrong".into());

crates/sqlx-sqlite-toolkit/src/transactions.rs

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
33
use std::collections::HashMap;
44
use std::sync::Arc;
5+
use std::time::{Duration, Instant};
56

67
use indexmap::IndexMap;
78
use serde::Deserialize;
@@ -10,7 +11,7 @@ use sqlx::{Column, Row};
1011
use sqlx_sqlite_conn_mgr::{AttachedWriteGuard, WriteGuard};
1112
use tokio::sync::{Mutex, RwLock};
1213
use tokio::task::AbortHandle;
13-
use tracing::debug;
14+
use tracing::{debug, warn};
1415

1516
#[cfg(feature = "observer")]
1617
use sqlx_sqlite_observer::ObservableWriteGuard;
@@ -97,6 +98,7 @@ pub struct ActiveInterruptibleTransaction {
9798
db_path: String,
9899
transaction_id: String,
99100
writer: Option<TransactionWriter>,
101+
created_at: Instant,
100102
}
101103

102104
impl ActiveInterruptibleTransaction {
@@ -105,6 +107,7 @@ impl ActiveInterruptibleTransaction {
105107
db_path,
106108
transaction_id,
107109
writer: Some(writer),
110+
created_at: Instant::now(),
108111
}
109112
}
110113

@@ -241,34 +244,71 @@ impl Drop for ActiveInterruptibleTransaction {
241244
}
242245
}
243246

247+
/// Default transaction timeout (5 minutes).
248+
const DEFAULT_TRANSACTION_TIMEOUT: Duration = Duration::from_secs(300);
249+
244250
/// Global state tracking all active interruptible transactions.
245251
///
246-
/// Enforces one interruptible transaction per database path.
252+
/// Enforces one interruptible transaction per database path and applies a configurable
253+
/// timeout. Expired transactions are cleaned up lazily on the next `insert()` or
254+
/// `remove()` call — no background task is needed.
255+
///
247256
/// Uses `Mutex` rather than `RwLock` because all operations require write access,
248257
/// and `Mutex<T>` only requires `T: Send` (not `T: Sync`) — avoiding an
249258
/// `unsafe impl Sync` that would otherwise be needed due to non-`Sync` inner
250259
/// types (`PoolConnection`, raw pointers in observer guards).
251-
#[derive(Clone, Default)]
252-
pub struct ActiveInterruptibleTransactions(
253-
Arc<Mutex<HashMap<String, ActiveInterruptibleTransaction>>>,
254-
);
260+
#[derive(Clone)]
261+
pub struct ActiveInterruptibleTransactions {
262+
inner: Arc<Mutex<HashMap<String, ActiveInterruptibleTransaction>>>,
263+
timeout: Duration,
264+
}
265+
266+
impl Default for ActiveInterruptibleTransactions {
267+
fn default() -> Self {
268+
Self::new(DEFAULT_TRANSACTION_TIMEOUT)
269+
}
270+
}
255271

256272
impl ActiveInterruptibleTransactions {
273+
/// Create a new instance with the given transaction timeout.
274+
pub fn new(timeout: Duration) -> Self {
275+
Self {
276+
inner: Arc::new(Mutex::new(HashMap::new())),
277+
timeout,
278+
}
279+
}
280+
257281
pub async fn insert(&self, db_path: String, tx: ActiveInterruptibleTransaction) -> Result<()> {
258282
use std::collections::hash_map::Entry;
259-
let mut txs = self.0.lock().await;
283+
let mut txs = self.inner.lock().await;
260284

261285
match txs.entry(db_path.clone()) {
262286
Entry::Vacant(e) => {
263287
e.insert(tx);
264288
Ok(())
265289
}
266-
Entry::Occupied(_) => Err(Error::TransactionAlreadyActive(db_path)),
290+
Entry::Occupied(mut e) => {
291+
// If the existing transaction has expired, drop it (auto-rollback) and
292+
// replace with the new one.
293+
if e.get().created_at.elapsed() >= self.timeout {
294+
warn!(
295+
"Evicting expired transaction for db: {} (age: {:?}, timeout: {:?})",
296+
db_path,
297+
e.get().created_at.elapsed(),
298+
self.timeout,
299+
);
300+
// Drop the expired transaction (auto-rollback) before inserting the new one
301+
let _expired = e.insert(tx);
302+
Ok(())
303+
} else {
304+
Err(Error::TransactionAlreadyActive(db_path))
305+
}
306+
}
267307
}
268308
}
269309

270310
pub async fn abort_all(&self) {
271-
let mut txs = self.0.lock().await;
311+
let mut txs = self.inner.lock().await;
272312
debug!("Aborting {} active interruptible transaction(s)", txs.len());
273313

274314
for db_path in txs.keys() {
@@ -283,13 +323,17 @@ impl ActiveInterruptibleTransactions {
283323
txs.clear();
284324
}
285325

286-
/// Remove and return transaction for commit/rollback
326+
/// Remove and return transaction for commit/rollback.
327+
///
328+
/// Returns `Err(Error::TransactionTimedOut)` if the transaction has exceeded the
329+
/// configured timeout. The expired transaction is dropped (auto-rolled-back) in
330+
/// that case.
287331
pub async fn remove(
288332
&self,
289333
db_path: &str,
290334
token_id: &str,
291335
) -> Result<ActiveInterruptibleTransaction> {
292-
let mut txs = self.0.lock().await;
336+
let mut txs = self.inner.lock().await;
293337

294338
// Validate token before removal
295339
let tx = txs
@@ -300,6 +344,19 @@ impl ActiveInterruptibleTransactions {
300344
return Err(Error::InvalidTransactionToken);
301345
}
302346

347+
// Check if the transaction has expired
348+
if tx.created_at.elapsed() >= self.timeout {
349+
warn!(
350+
"Transaction timed out for db: {} (age: {:?}, timeout: {:?})",
351+
db_path,
352+
tx.created_at.elapsed(),
353+
self.timeout,
354+
);
355+
// Drop the expired transaction (auto-rollback via Drop)
356+
txs.remove(db_path);
357+
return Err(Error::TransactionTimedOut(db_path.to_string()));
358+
}
359+
303360
// Safe unwrap: we just confirmed the key exists above
304361
Ok(txs.remove(db_path).unwrap())
305362
}

crates/sqlx-sqlite-toolkit/tests/transaction_state_tests.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,80 @@ async fn test_insert_after_abort_all_succeeds() {
202202
state.insert("reuse-key".into(), tx2).await.unwrap();
203203
}
204204

205+
// ============================================================================
206+
// ActiveInterruptibleTransactions timeout tests
207+
// ============================================================================
208+
209+
#[tokio::test]
210+
async fn test_expired_transaction_evicted_on_insert() {
211+
let (db1, _temp1) = create_test_db("expire1.db").await;
212+
let (db2, _temp2) = create_test_db("expire2.db").await;
213+
214+
for db in [&db1, &db2] {
215+
db.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)".into(), vec![])
216+
.await
217+
.unwrap();
218+
}
219+
220+
// Use a 1ms timeout so the first transaction expires immediately
221+
let state = ActiveInterruptibleTransactions::new(std::time::Duration::from_millis(1));
222+
223+
let tx1 = begin_transaction(&db1, "shared-key").await;
224+
state.insert("shared-key".into(), tx1).await.unwrap();
225+
226+
// Sleep to ensure the transaction expires
227+
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
228+
229+
// Second insert should succeed because the expired transaction is evicted
230+
let tx2 = begin_transaction(&db2, "shared-key").await;
231+
state.insert("shared-key".into(), tx2).await.unwrap();
232+
}
233+
234+
#[tokio::test]
235+
async fn test_remove_expired_transaction_returns_timed_out() {
236+
let (db, _temp) = create_test_db("timeout.db").await;
237+
238+
db.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)".into(), vec![])
239+
.await
240+
.unwrap();
241+
242+
let state = ActiveInterruptibleTransactions::new(std::time::Duration::from_millis(1));
243+
244+
let tx = begin_transaction(&db, "timeout.db").await;
245+
let tx_id = tx.transaction_id().to_string();
246+
247+
state.insert("timeout.db".into(), tx).await.unwrap();
248+
249+
// Sleep to ensure the transaction expires
250+
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
251+
252+
let err = expect_err(state.remove("timeout.db", &tx_id).await);
253+
assert_eq!(err.error_code(), "TRANSACTION_TIMED_OUT");
254+
}
255+
256+
#[tokio::test]
257+
async fn test_non_expired_transaction_not_evicted() {
258+
let (db1, _temp1) = create_test_db("live1.db").await;
259+
let (db2, _temp2) = create_test_db("live2.db").await;
260+
261+
for db in [&db1, &db2] {
262+
db.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)".into(), vec![])
263+
.await
264+
.unwrap();
265+
}
266+
267+
// Use a long timeout so the first transaction does NOT expire
268+
let state = ActiveInterruptibleTransactions::new(std::time::Duration::from_secs(300));
269+
270+
let tx1 = begin_transaction(&db1, "shared-key").await;
271+
state.insert("shared-key".into(), tx1).await.unwrap();
272+
273+
// Second insert should still fail because the first transaction is alive
274+
let tx2 = begin_transaction(&db2, "shared-key").await;
275+
let err = state.insert("shared-key".into(), tx2).await.unwrap_err();
276+
assert_eq!(err.error_code(), "TRANSACTION_ALREADY_ACTIVE");
277+
}
278+
205279
// ============================================================================
206280
// ActiveRegularTransactions tests
207281
// ============================================================================

src/lib.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,13 +134,16 @@ pub struct MigrationEvent {
134134
pub struct Builder {
135135
/// Migrations registered per database path
136136
migrations: HashMap<String, Arc<Migrator>>,
137+
/// Timeout for interruptible transactions. Defaults to 5 minutes.
138+
transaction_timeout: Option<std::time::Duration>,
137139
}
138140

139141
impl Builder {
140142
/// Create a new builder instance.
141143
pub fn new() -> Self {
142144
Self {
143145
migrations: HashMap::new(),
146+
transaction_timeout: None,
144147
}
145148
}
146149

@@ -170,9 +173,19 @@ impl Builder {
170173
self
171174
}
172175

176+
/// Set the timeout for interruptible transactions.
177+
///
178+
/// If an interruptible transaction exceeds this duration, it will be automatically
179+
/// rolled back on the next access attempt. Defaults to 5 minutes.
180+
pub fn transaction_timeout(mut self, timeout: std::time::Duration) -> Self {
181+
self.transaction_timeout = Some(timeout);
182+
self
183+
}
184+
173185
/// Build the plugin with command registration and state management.
174186
pub fn build<R: Runtime>(self) -> tauri::plugin::TauriPlugin<R> {
175187
let migrations = Arc::new(self.migrations);
188+
let transaction_timeout = self.transaction_timeout;
176189

177190
PluginBuilder::<R>::new("sqlite")
178191
.invoke_handler(tauri::generate_handler![
@@ -197,7 +210,10 @@ impl Builder {
197210
.setup(move |app, _api| {
198211
app.manage(DbInstances::default());
199212
app.manage(MigrationStates::default());
200-
app.manage(ActiveInterruptibleTransactions::default());
213+
app.manage(match transaction_timeout {
214+
Some(timeout) => ActiveInterruptibleTransactions::new(timeout),
215+
None => ActiveInterruptibleTransactions::default(),
216+
});
201217
app.manage(ActiveRegularTransactions::default());
202218
app.manage(subscriptions::ActiveSubscriptions::default());
203219

0 commit comments

Comments
 (0)