Skip to content

Commit e4a6201

Browse files
.
1 parent 28b59e8 commit e4a6201

File tree

9 files changed

+81
-18
lines changed

9 files changed

+81
-18
lines changed

.forge/FORGE_PR_DESCRIPTION.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
## Summary
2+
Fix a panic on `/new` caused by SQLite lock contention when `ForgeRepo` created a second connection pool pointing at the same `.forge.db` file while the previous pool was still alive.
3+
4+
## Context
5+
Every time a user ran `/new`, `ForgeRepo::new` created a brand-new `DatabasePool` via `DatabasePool::try_from(...).unwrap()`. The old pool — still held alive by `tokio::spawn`'d hydration tasks — would hold open connections to the same SQLite file. r2d2's pool builder validates connections at construction time with a 5-second timeout; after 5 retries the error bubbled back to the `.unwrap()`, crashing the process with:
6+
7+
```
8+
ERROR: called `Result::unwrap()` on an `Err` value: Failed to create connection pool: timed out waiting for connection
9+
```
10+
11+
## Changes
12+
- **`forge_repo`**: `ForgeRepo::new` now accepts an `Arc<DatabasePool>` instead of constructing its own. The `DatabasePool` and `PoolConfig` types are re-exported from the crate root so callers don't need to reach into internal modules.
13+
- **`forge_api`**: `ForgeAPI::init` creates the pool once and passes it to `ForgeRepo::new`. The return type changes from `Self` to `Result<Self>` so a pool failure produces a clean error message instead of a panic.
14+
- **`forge_main`**: `UI`'s factory closure type changes from `Fn(ForgeConfig) -> A` to `Fn(ForgeConfig) -> Result<A>` to accommodate the fallible `ForgeAPI::init`. `on_new` propagates the error via `?` instead of unwrapping.
15+
16+
### Key Implementation Details
17+
The pool is now created once per `forge` process — inside `ForgeAPI::init` using `ConfigReader::base_path().join(".forge.db")`, which is the canonical path already used by `Environment::database_path()`. On each `/new`, a new `ForgeRepo` is built pointing at the same `Arc<DatabasePool>`, so the underlying SQLite file is only ever opened once and there is no contention window.
18+
19+
## Testing
20+
```bash
21+
# Build
22+
cargo build -p forge_repo -p forge_api -p forge_main
23+
24+
# Manually verify: start forge, run /new multiple times, confirm no crash
25+
forge
26+
/new
27+
/new
28+
/new
29+
```
30+
31+
## Links
32+
- Root cause: `crates/forge_repo/src/forge_repo.rs` — the `.unwrap()` on pool construction that caused the panic

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/forge_api/src/forge_api.rs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use forge_app::{
1313
use forge_config::ForgeConfig;
1414
use forge_domain::{Agent, ConsoleWriter, *};
1515
use forge_infra::ForgeInfra;
16-
use forge_repo::ForgeRepo;
16+
use forge_repo::{DatabasePool, ForgeRepo};
1717
use forge_services::ForgeServices;
1818
use forge_stream::MpscStream;
1919
use futures::stream::BoxStream;
@@ -42,17 +42,26 @@ impl<A, F> ForgeAPI<A, F> {
4242
}
4343

4444
impl ForgeAPI<ForgeServices<ForgeRepo<ForgeInfra>>, ForgeRepo<ForgeInfra>> {
45-
/// Creates a fully-initialized [`ForgeAPI`] from a pre-read configuration.
45+
/// Creates a fully-initialized [`ForgeAPI`] from a pre-read configuration
46+
/// and a shared database pool.
47+
///
48+
/// The pool is created once in `main` and passed in here so that a single
49+
/// SQLite connection pool is reused across `/new` conversation resets,
50+
/// eliminating the lock contention that previously caused a panic.
4651
///
4752
/// # Arguments
4853
/// * `cwd` - The working directory path for environment and file resolution
4954
/// * `config` - Pre-read application configuration (from startup)
50-
/// * `services_url` - Pre-validated URL for the gRPC workspace server
51-
pub fn init(cwd: PathBuf, config: ForgeConfig) -> Self {
55+
/// * `db_pool` - Shared database pool created once at process startup
56+
///
57+
/// # Errors
58+
///
59+
/// Returns an error if the API cannot be initialised.
60+
pub fn init(cwd: PathBuf, config: ForgeConfig, db_pool: Arc<DatabasePool>) -> Result<Self> {
5261
let infra = Arc::new(ForgeInfra::new(cwd, config));
53-
let repo = Arc::new(ForgeRepo::new(infra.clone()));
62+
let repo = Arc::new(ForgeRepo::new(infra.clone(), db_pool));
5463
let app = Arc::new(ForgeServices::new(repo.clone()));
55-
ForgeAPI::new(app, repo)
64+
Ok(ForgeAPI::new(app, repo))
5665
}
5766

5867
pub async fn get_skills_internal(&self) -> Result<Vec<Skill>> {

crates/forge_main/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ forge_display.workspace = true
2323
forge_tracker.workspace = true
2424

2525
forge_spinner.workspace = true
26+
forge_repo.workspace = true
2627
forge_select.workspace = true
2728

2829
merge.workspace = true

crates/forge_main/src/main.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::io::Read;
22
use std::panic;
33
use std::path::PathBuf;
4+
use std::sync::Arc;
45

56
use anyhow::{Context, Result};
67
use clap::Parser;
@@ -120,8 +121,21 @@ async fn run() -> Result<()> {
120121
(_, _) => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
121122
};
122123

124+
// Create the database pool once for the entire process lifetime. The same
125+
// Arc<DatabasePool> is cloned into every ForgeAPI created on /new, so the
126+
// SQLite file is opened exactly once and there is no contention window.
127+
//
128+
// Note: ConfigReader::base_path().join(".forge.db") is identical to what
129+
// Environment::database_path() returns — both derive from the same base path.
130+
let db_pool = Arc::new(
131+
forge_repo::DatabasePool::try_from(forge_repo::PoolConfig::new(
132+
forge_config::ConfigReader::base_path().join(".forge.db"),
133+
))
134+
.context("Failed to open Forge database")?,
135+
);
136+
123137
let mut ui = UI::init(cli, config, move |config| {
124-
ForgeAPI::init(cwd.clone(), config)
138+
ForgeAPI::init(cwd.clone(), config, db_pool.clone())
125139
})?;
126140
ui.run().await;
127141

crates/forge_main/src/ui.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,10 @@ fn format_mcp_headers(server: &forge_domain::McpServerConfig) -> Option<String>
101101
}
102102
}
103103

104-
pub struct UI<A: ConsoleWriter, F: Fn(ForgeConfig) -> A> {
104+
pub struct UI<A: ConsoleWriter, F: Fn(ForgeConfig) -> anyhow::Result<A>> {
105105
markdown: MarkdownFormat,
106106
state: UIState,
107-
api: Arc<F::Output>,
107+
api: Arc<A>,
108108
new_api: Arc<F>,
109109
console: Console,
110110
command: Arc<ForgeCommandManager>,
@@ -115,7 +115,7 @@ pub struct UI<A: ConsoleWriter, F: Fn(ForgeConfig) -> A> {
115115
_guard: forge_tracker::Guard,
116116
}
117117

118-
impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI<A, F> {
118+
impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> anyhow::Result<A> + Send + Sync> UI<A, F> {
119119
/// Writes a line to the console output
120120
/// Takes anything that implements ToString trait
121121
fn writeln<T: ToString>(&mut self, content: T) -> anyhow::Result<()> {
@@ -161,7 +161,7 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
161161
async fn on_new(&mut self) -> Result<()> {
162162
let config = forge_config::ForgeConfig::read().unwrap_or_default();
163163
self.config = config.clone();
164-
self.api = Arc::new((self.new_api)(config));
164+
self.api = Arc::new((self.new_api)(config)?);
165165
self.init_state(false).await?;
166166

167167
// Set agent if provided via CLI
@@ -216,7 +216,7 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
216216
/// from `forge config set` are reflected in new conversations
217217
pub fn init(cli: Cli, config: ForgeConfig, f: F) -> Result<Self> {
218218
// Parse CLI arguments first to get flags
219-
let api = Arc::new(f(config.clone()));
219+
let api = Arc::new(f(config.clone())?);
220220
let env = api.environment();
221221
let command = Arc::new(ForgeCommandManager::default());
222222
let spinner = SharedSpinner::new(SpinnerManager::new(api.clone()));

crates/forge_repo/src/database/pool.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
#![allow(dead_code)]
21
use std::path::PathBuf;
32
use std::time::Duration;
43

crates/forge_repo/src/forge_repo.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ use url::Url;
2626
use crate::agent::ForgeAgentRepository;
2727
use crate::context_engine::ForgeContextEngineRepository;
2828
use crate::conversation::ConversationRepositoryImpl;
29-
use crate::database::{DatabasePool, PoolConfig};
29+
use crate::database::DatabasePool;
3030
use crate::fs_snap::ForgeFileSnapshotService;
3131
use crate::fuzzy_search::ForgeFuzzySearchRepository;
3232
use crate::provider::{ForgeChatRepository, ForgeProviderRepository};
@@ -60,13 +60,17 @@ impl<
6060
+ HttpInfra,
6161
> ForgeRepo<F>
6262
{
63-
pub fn new(infra: Arc<F>) -> Self {
63+
/// Creates a new [`ForgeRepo`] with the provided infrastructure and a
64+
/// shared database pool.
65+
///
66+
/// The pool is created once at application startup and reused across
67+
/// `/new` conversation resets so that a fresh `ForgeRepo` never races
68+
/// with the previous instance for the same SQLite file.
69+
pub fn new(infra: Arc<F>, db_pool: Arc<DatabasePool>) -> Self {
6470
let env = infra.get_environment();
6571
let file_snapshot_service = Arc::new(ForgeFileSnapshotService::new(env.clone()));
66-
let db_pool =
67-
Arc::new(DatabasePool::try_from(PoolConfig::new(env.database_path())).unwrap());
6872
let conversation_repository = Arc::new(ConversationRepositoryImpl::new(
69-
db_pool.clone(),
73+
db_pool,
7074
env.workspace_hash(),
7175
));
7276

crates/forge_repo/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,8 @@ mod proto_generated {
1414
tonic::include_proto!("forge.v1");
1515
}
1616

17+
// Expose the database pool types so callers (e.g. forge_api) can construct
18+
// and share a pool without depending on internal module paths.
19+
pub use database::{DatabasePool, PoolConfig};
1720
// Only expose forge_repo container
1821
pub use forge_repo::*;

0 commit comments

Comments
 (0)