fix: shared reader locks, resolved lock paths, install-only sync lock

Third Codex (gpt-6.1-sol, xhigh) pass over 1e55138, each item verified
before fixing:

- readers now hold the ownership lock shared for the life of their
  connection, so a snapshot swap (exclusive) never happens under an
  open SQLite connection whose WAL cleanup could hit a later import.
- the lock file name uses the resolved database path, so a symlinked
  path shares the real database's lock; sync also installs at the
  resolved path, keeping a symlinked path a symlink (it used to be
  overwritten with a regular file).
- sync downloads without the lock and takes it only to recheck and
  install; snapshot downloads have connect (15 s) and total (10 min)
  timeouts. idx-mcp drops its in-process RwLock: idx-core's lock covers
  it across processes.
- XLSX parsing caps rows at 1,000,000 and checks a shared string's size
  before copying it.
- the temp snapshot's -wal/-shm files are removed instead of left in the
  data directory.

Remaining, by design: `changes` loads all rows of two releases from the
local database before filtering; its size is bounded by the snapshot.

CLI goldens unchanged. Live A/B vs 0.2.4: 16 passed; discover hits the
IDX 403 on both builds (exit 4 vs 1, intended).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hermes 2026-10-14 22:39:09 +00:00
commit 5e0831d8fe
4 changed files with 133 additions and 51 deletions

View file

@ -15,7 +15,7 @@ use rmcp::model::{CallToolResult, Implementation, ServerCapabilities, ServerConf
use rmcp::{ErrorData, ServerHandler, tool, tool_handler, tool_router};
use serde::Serialize;
use serde_json::{Map, Value, json};
use tokio::sync::{RwLock, Semaphore};
use tokio::sync::Semaphore;
use crate::params::{
AnalysisKind, AnalyzeParams, ChangesParams, CompanyParams, CompanySection, EntityParams,
@ -36,9 +36,6 @@ const MAX_CHANGES_LIMIT: usize = 2000;
/// Tool calls running at once; the rest wait their turn.
static CALL_SLOTS: Semaphore = Semaphore::const_new(4);
/// `ownership_sync` replaces the database file: it runs alone, while
/// ownership reads may run together.
static OWNERSHIP: RwLock<()> = RwLock::const_new(());
const INSTRUCTIONS: &str = "Indonesian stock market (IDX) data. Market and financial \
tools fetch live data (cached on disk) and wrap it as {data, source, fetched_at, stale}; \
@ -92,7 +89,6 @@ impl IdxServer {
} else {
"run `idx ownership sync`, or restart idx-mcp with --allow-sync"
};
let _read = OWNERSHIP.read().await;
blocking(move || {
if !investor.status()?.populated {
return Err(IdxError::NoMarketData(format!(
@ -323,7 +319,6 @@ KSEI release dates."
)]
async fn ownership_status(&self) -> ToolResult {
let investor = Investor::new(&self.config);
let _read = OWNERSHIP.read().await;
blocking(move || investor.status()).await
}
@ -334,7 +329,7 @@ the local database."
async fn ownership_sync(&self, params: Parameters<SyncParams>) -> ToolResult {
let SyncParams { force } = params.0;
let investor = Investor::new(&self.config);
let _write = OWNERSHIP.write().await;
// idx-core's database lock keeps the swap away from open readers.
blocking(move || investor.sync(None, force)).await
}
}