just-bash-mcp / src /runtime /resourceStore.ts
victor's picture
victor HF Staff
Initial deploy of just-bash MCP server
548a458 verified
// Per-session resource buffers for large stdout/stderr from bash_exec.
// Implements ring buffer over last N execs, byte caps per session and global.
export interface ResourceBuffer {
sessionId: string;
execId: string;
stream: "stdout" | "stderr";
bytes: Buffer;
complete: boolean; // true when not truncated below capturedOutputBytesExec cap
createdAt: number;
expiresAt: number;
}
export class ResourceStore {
private byKey = new Map<string, ResourceBuffer>(); // key: sessionId/execId/stream
private bySession = new Map<string, string[]>(); // sessionId -> [execId in insertion order]
private totalBytes = 0;
constructor(
private readonly maxExecsRetainedSession: number,
private readonly maxResourceBytesSession: number,
private readonly maxResourceBytesGlobal: number,
) {}
private key(sessionId: string, execId: string, stream: "stdout" | "stderr") {
return `${sessionId}::${execId}::${stream}`;
}
store(
sessionId: string,
execId: string,
stream: "stdout" | "stderr",
text: string,
complete: boolean,
ttlMs: number,
): boolean {
const bytes = Buffer.from(text, "utf8");
if (bytes.length === 0) return false;
if (bytes.length > this.maxResourceBytesGlobal) return false;
this.evictForCapacity(sessionId, bytes.length);
const buf: ResourceBuffer = {
sessionId,
execId,
stream,
bytes,
complete,
createdAt: Date.now(),
expiresAt: Date.now() + ttlMs,
};
this.byKey.set(this.key(sessionId, execId, stream), buf);
const list = this.bySession.get(sessionId) ?? [];
if (!list.includes(execId)) list.push(execId);
this.bySession.set(sessionId, list);
this.totalBytes += bytes.length;
return true;
}
get(
sessionId: string,
execId: string,
stream: "stdout" | "stderr",
): ResourceBuffer | null {
const b = this.byKey.get(this.key(sessionId, execId, stream));
if (!b) return null;
if (b.expiresAt < Date.now()) {
this.delete(sessionId, execId, stream);
return null;
}
return b;
}
delete(sessionId: string, execId: string, stream: "stdout" | "stderr") {
const k = this.key(sessionId, execId, stream);
const b = this.byKey.get(k);
if (b) {
this.totalBytes -= b.bytes.length;
this.byKey.delete(k);
}
}
deleteSession(sessionId: string) {
const execs = this.bySession.get(sessionId) ?? [];
for (const eid of execs) {
for (const s of ["stdout", "stderr"] as const) {
this.delete(sessionId, eid, s);
}
}
this.bySession.delete(sessionId);
}
totalBytesUsed() {
return this.totalBytes;
}
sessionBytesUsed(sessionId: string): number {
let total = 0;
const execs = this.bySession.get(sessionId) ?? [];
for (const eid of execs) {
for (const s of ["stdout", "stderr"] as const) {
const b = this.byKey.get(this.key(sessionId, eid, s));
if (b) total += b.bytes.length;
}
}
return total;
}
// Evict oldest exec resources for session until we fit incoming bytes within caps.
private evictForCapacity(sessionId: string, incomingBytes: number) {
// Per-session: if exec count limit would be exceeded, drop oldest. (Counts exec ids.)
const execs = this.bySession.get(sessionId) ?? [];
while (execs.length >= this.maxExecsRetainedSession) {
const oldest = execs.shift();
if (!oldest) break;
for (const s of ["stdout", "stderr"] as const) {
this.delete(sessionId, oldest, s);
}
}
// Per-session byte cap
while (this.sessionBytesUsed(sessionId) + incomingBytes > this.maxResourceBytesSession) {
const oldest = execs.shift();
if (!oldest) break;
for (const s of ["stdout", "stderr"] as const) {
this.delete(sessionId, oldest, s);
}
}
// Global byte cap: drop oldest entries across all sessions.
while (this.totalBytes + incomingBytes > this.maxResourceBytesGlobal) {
const allKeys = Array.from(this.byKey.values()).sort(
(a, b) => a.createdAt - b.createdAt,
);
const oldest = allKeys[0];
if (!oldest) break;
this.delete(oldest.sessionId, oldest.execId, oldest.stream);
// also prune from per-session list
const list = this.bySession.get(oldest.sessionId);
if (list) {
const idx = list.indexOf(oldest.execId);
const stillHas =
this.byKey.has(this.key(oldest.sessionId, oldest.execId, "stdout")) ||
this.byKey.has(this.key(oldest.sessionId, oldest.execId, "stderr"));
if (!stillHas && idx >= 0) list.splice(idx, 1);
}
}
this.bySession.set(sessionId, execs);
}
sweepExpired() {
const now = Date.now();
for (const [, b] of this.byKey) {
if (b.expiresAt < now) {
this.delete(b.sessionId, b.execId, b.stream);
}
}
}
}