How Session List Caching Avoids File Re-Scanning in pi-web
The pi-web codebase eliminates redundant directory scans by storing session lists in a global cache with a 30-second TTL and a generation-based invalidation system that forces fresh scans only after mutations.
Every time the UI requests the list of Pi sessions, the system risks triggering expensive file system operations and Git process spawns. To prevent this, agegr/pi-web implements a robust session list caching layer in lib/session-reader.ts that minimizes I/O while ensuring data consistency across concurrent requests.
Generation Counter for Cache Invalidation
The caching system relies on a global generation counter to track data freshness. Each time a session is created, deleted, or modified, the invalidateSessionListCache() function increments globalThis.__piSessionListGeneration. This counter acts as a logical clock; any cached result bearing an older generation number is automatically treated as stale.
// lib/session-reader.ts (line 99-101)
export function invalidateSessionListCache() {
globalThis.__piSessionListGeneration++;
}
RPC handlers in lib/rpc-manager.ts invoke this function immediately after mutation operations (such as forking or deleting sessions), ensuring the next read operation triggers a fresh scan rather than returning outdated data.
Time-Based TTL Check
Before attempting a new scan, listAllSessions() validates the existing cache entry against a time-to-live threshold. The cached result stored in globalThis.__piSessionListCache includes a timestamp recorded at creation. If the elapsed time is less than the default 30-second TTL, the function returns the cached array of SessionInfo objects immediately, bypassing the file system entirely.
// lib/session-reader.ts (line 52-56)
const cache = globalThis.__piSessionListCache;
if (cache && (Date.now() - cache.timestamp) < 30000) {
return cache.sessions;
}
Coalescing In-Flight Requests
When the cache is missing or expired, the system must execute loadAllSessions() to scan the session directory. To prevent thundering-herd problems during high-frequency access, concurrent callers receive the same shared promise stored in globalThis.__piSessionListPromise. This promise is bound to the current generation number, ensuring that only requests from the same logical generation share the asynchronous work.
// lib/session-reader.ts (line 58-62)
if (!globalThis.__piSessionListPromise ||
globalThis.__piSessionListGeneration !== currentGen) {
globalThis.__piSessionListPromise = loadAllSessions(currentGen);
}
return globalThis.__piSessionListPromise;
Populating the Cache and Path Registry
During a fresh scan, loadAllSessions() queries the Pi SDK via SessionManager.listAll() and enriches each entry with metadata including project roots and work-tree branches. While iterating through results, the function also populates a path-to-id cache by calling cacheSessionPath(), which registers each session's file path for fast subsequent lookups.
// lib/session-reader.ts (line 17-31)
async function loadAllSessions(expectedGen: number) {
const sessions = await SessionManager.listAll();
const enriched = sessions.map(s => ({
...s,
projectRoot: resolveProjectRoot(s),
branch: getWorktreeBranch(s)
}));
enriched.forEach(s => cacheSessionPath(s.id, s.path));
return enriched;
}
Cache Guarding After Mutation
Race conditions between mutations and in-flight scans are prevented through generation checking. When the scan promise resolves, the then handler verifies that the generation counter hasn't changed since the scan started. If globalThis.__piSessionListGeneration no longer matches the expected value, the result is discarded rather than written to the cache, preventing stale data from overwriting a newer valid cache.
// lib/session-reader.ts (line 65-70)
.then(sessions => {
if (globalThis.__piSessionListGeneration === expectedGen) {
globalThis.__piSessionListCache = {
sessions,
timestamp: Date.now(),
generation: expectedGen
};
}
return sessions;
})
Promise Cleanup
Once the scan completes—whether successfully or with an error—the temporary promise reference is cleared from globalThis.__piSessionListPromise. This cleanup allows subsequent requests to initiate fresh scans if the cache remains invalid, preventing memory leaks and ensuring error recovery.
// lib/session-reader.ts (line 72-77)
.finally(() => {
if (globalThis.__piSessionListGeneration === expectedGen) {
globalThis.__piSessionListPromise = undefined;
}
});
Practical Usage Examples
Fetching the Session List in UI Components
React hooks and API routes import listAllSessions() to retrieve cached data instantly when the TTL is valid.
import { listAllSessions } from "@/lib/session-reader";
export async function getSessionList() {
// Returns cached data if within the 30-second window
const sessions = await listAllSessions();
return sessions;
}
Invalidating Cache After Mutations
After performing destructive operations, explicitly invalidate the cache to force a fresh scan on the next request.
import { invalidateSessionListCache } from "@/lib/session-reader";
export async function deleteSession(id: string) {
await someDeleteRpc(id);
// Forces fresh scan on next listAllSessions() call
invalidateSessionListCache();
}
Resolving Session Paths
The path-to-id cache enables fast resolution of session file paths without repeated lookups.
import { resolveSessionPath } from "@/lib/session-reader";
const path = await resolveSessionPath("c3f9e2a1-...");
Summary
- Dual-layer invalidation: The system combines a 30-second TTL with a generation counter to balance performance and consistency.
- Request coalescing: Concurrent calls share a single promise during scans, eliminating redundant Git processes and file system operations.
- Race condition safety: Generation checking prevents stale scan results from overwriting fresh cache entries when mutations occur mid-flight.
- Path caching: Secondary caching of session paths via
cacheSessionPath()accelerates individual session lookups. - Global state management: All cache state resides in
globalThisproperties (__piSessionListCache,__piSessionListGeneration,__piSessionListPromise), making the mechanism persistent across module reloads during development.
Frequently Asked Questions
How long does the session list cache remain valid?
The default time-to-live is 30 seconds. After this period expires, the next call to listAllSessions() triggers a fresh scan of the session directory. Additionally, any mutation operation that calls invalidateSessionListCache() immediately invalidates the cache regardless of age.
What prevents stale data from appearing after a session is deleted?
The generation counter mechanism ensures consistency. When invalidateSessionListCache() increments globalThis.__piSessionListGeneration, all existing cache entries are logically expired. Even if an in-flight scan completes after the deletion, the generation mismatch prevents that stale result from being stored in the cache.
How does the system handle multiple simultaneous requests for the session list?
The system implements request coalescing through globalThis.__piSessionListPromise. When the cache is expired and a scan is already in progress, subsequent callers receive the same pending promise rather than spawning additional scans. This shared promise is tied to a specific generation number, ensuring isolation between different cache epochs.
Where is the cache data actually stored?
All caching metadata resides in global properties attached to globalThis: __piSessionListCache stores the result array and timestamp, __piSessionListGeneration tracks the logical version, and __piSessionListPromise holds the shared in-flight promise. This approach persists data across hot module reloads and prevents memory leaks in serverless environments.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →