Skip to content

Commit bc906da

Browse files
fix: atomic session slots, partial-index cleanup, watcher serialization
- server.py: the MAX_ACTIVE_BOTS check ran in offer() while the increment happened inside the detached bot task, several awaits later, so concurrent offers all passed the guard together. Capacity is now reserved atomically under a lock before the offer is handled; ownership transfers to the bot task once it is created, and offer() releases the slot on every path where it was not. run_interview_bot wraps its whole body so even the early readiness raise releases. Measured with 10 simultaneous offers: peak active went 10 -> 2, with 8 x 503 and no leaked slots. - indexer.ts: a rebuild that threw after the stale delete left the documents it had already upserted in the index, while the caller dropped the persisted cache — the only record of their ids — stranding them in search results. Extracted discardPartialIndex(), now used by the cancel path and the catch. If that cleanup itself fails it clears discardedPreviousIndex so the cache is kept and a later rebuild can retry, rather than losing the ids entirely. - indexer.ts: the `indexing` guard only stopped watcher work from starting, so an upsert/remove already awaiting a file read could write during a rebuild's cleanup. Added a generation counter bumped per rebuild and re-checked after every await before any write, plus in-flight op tracking that rebuild drains before it mutates anything. - .env.example: MAX_ACTIVE_BOTS now precedes SESSION_HANDSHAKE_TIMEOUT_SECS. Verified: 10-way offer burst and the reserve-then-fail path; rebuild throwing mid-scan with cleanup succeeding and failing; and a watcher write racing a rebuild with and without the generation guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent caf0519 commit bc906da

3 files changed

Lines changed: 193 additions & 59 deletions

File tree

apps/moss-interview-coach/backend/.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ PIPER_VOICE=en_US-lessac-medium
2121

2222
# Grader subprocess
2323
GRADE_SUBPROCESS_TIMEOUT_SECS=60
24+
# Concurrent interviews. Each loads its own STT/TTS and shares one Ollama.
25+
MAX_ACTIVE_BOTS=2
2426
# Ends a session whose client never finishes the WebRTC/RTVI handshake, so a
2527
# dropped offer cannot hold Whisper/Piper/Ollama open.
2628
SESSION_HANDSHAKE_TIMEOUT_SECS=45
27-
# Concurrent interviews. Each loads its own STT/TTS and shares one Ollama.
28-
MAX_ACTIVE_BOTS=2
2929

3030
# Backend
3131
# Loopback by default: /api/offer is unauthenticated, and each call spins up

apps/moss-interview-coach/backend/server.py

Lines changed: 84 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,27 @@ def build_system_prompt(track_id: str) -> str:
109109
moss_indexes_ready: dict[str, bool] = {tid: False for tid in INTERVIEW_TRACKS}
110110
moss_ready = False
111111
active_bots = 0
112+
# Guards active_bots. The capacity check and the increment have to be one
113+
# critical section: offer() runs concurrently, and the bot task that used to do
114+
# the incrementing is spawned several awaits later, so checking there let any
115+
# number of simultaneous offers pass the limit together.
116+
active_bots_lock = asyncio.Lock()
117+
118+
119+
async def reserve_bot_slot() -> bool:
120+
"""Take a slot if one is free. Caller must release exactly once."""
121+
global active_bots
122+
async with active_bots_lock:
123+
if active_bots >= MAX_ACTIVE_BOTS:
124+
return False
125+
active_bots += 1
126+
return True
127+
128+
129+
async def release_bot_slot() -> None:
130+
global active_bots
131+
async with active_bots_lock:
132+
active_bots = max(0, active_bots - 1)
112133

113134

114135
class ActiveSession:
@@ -744,7 +765,18 @@ async def run_interview_bot(
744765
webrtc_connection: SmallWebRTCConnection,
745766
track_id: str = DEFAULT_TRACK_ID,
746767
) -> None:
747-
global active_bots
768+
# The slot is reserved by offer() before this task is created, so every exit
769+
# path here — including the readiness check below — must release it.
770+
try:
771+
await _run_interview_bot(webrtc_connection, track_id)
772+
finally:
773+
await release_bot_slot()
774+
775+
776+
async def _run_interview_bot(
777+
webrtc_connection: SmallWebRTCConnection,
778+
track_id: str = DEFAULT_TRACK_ID,
779+
) -> None:
748780
track_id = normalize_track_id(track_id)
749781
if moss_client is None or not moss_indexes_ready.get(track_id):
750782
raise RuntimeError(
@@ -756,7 +788,6 @@ async def run_interview_bot(
756788
index_name = track["index_name"]
757789
system_prompt = build_system_prompt(track_id)
758790

759-
active_bots += 1
760791
session: ActiveSession | None = None
761792
try:
762793
transport = SmallWebRTCTransport(
@@ -931,7 +962,6 @@ async def _handshake_watchdog(current: ActiveSession) -> None:
931962
finally:
932963
if session is not None:
933964
active_sessions.discard(session)
934-
active_bots = max(0, active_bots - 1)
935965

936966

937967
async def ensure_moss_loaded() -> None:
@@ -1127,53 +1157,65 @@ async def offer(request: Request) -> dict[str, Any]:
11271157
status_code=503,
11281158
detail=f"Grader worker missing at {GRADER_WORKER_PATH.name}.",
11291159
)
1130-
# Refuse before spawning: every session loads Whisper/Piper and competes for
1160+
# Reserve before spawning: every session loads Whisper/Piper and competes for
11311161
# the same local Ollama, so unbounded offers would degrade the live ones.
1132-
if active_bots >= MAX_ACTIVE_BOTS:
1162+
# Taken here rather than inside the bot task — that runs several awaits
1163+
# later, so concurrent offers would all pass a mere check and overshoot.
1164+
if not await reserve_bot_slot():
11331165
raise HTTPException(
11341166
status_code=503,
11351167
detail=(
1136-
f"At capacity: {active_bots} interview(s) already running "
1168+
f"At capacity: {MAX_ACTIVE_BOTS} interview(s) already running "
11371169
f"(MAX_ACTIVE_BOTS={MAX_ACTIVE_BOTS}). Try again shortly."
11381170
),
11391171
)
1140-
1141-
body = await _json_object_body(request)
1142-
1143-
async def webrtc_connection_callback(connection: SmallWebRTCConnection) -> None:
1144-
# Detached deliberately, not a Starlette background task. Those are
1145-
# awaited as part of the request lifecycle, and uvicorn waits for
1146-
# outstanding request tasks *before* running lifespan shutdown — with
1147-
# timeout_graceful_shutdown defaulting to None, that wait is unbounded.
1148-
# An interview attached to the request would therefore hang Ctrl-C
1149-
# forever and the lifespan cleanup below would never get to cancel it.
1150-
task = asyncio.create_task(
1151-
run_interview_bot(connection, track_id),
1152-
name=f"moss-interview-{track_id}",
1153-
)
1154-
bot_tasks.add(task)
1155-
task.add_done_callback(_on_bot_task_done)
1156-
1172+
# Ownership of the reserved slot moves to the bot task once it is created;
1173+
# until then this request must hand it back on every failure path.
1174+
slot_handed_over = False
11571175
try:
1158-
answer = await small_webrtc_handler.handle_web_request(
1159-
request=SmallWebRTCRequest.from_dict(body),
1160-
webrtc_connection_callback=webrtc_connection_callback,
1161-
)
1162-
except HTTPException:
1163-
# Already carries an intended status; do not flatten it to a 500.
1164-
raise
1165-
except (KeyError, TypeError, ValueError) as exc:
1166-
# A malformed offer is the caller's mistake, not a server fault.
1167-
logger.warning(f"Malformed WebRTC offer: {exc}")
1168-
raise HTTPException(
1169-
status_code=422, detail=f"Malformed WebRTC offer: {exc}"
1170-
) from exc
1171-
except Exception as exc: # noqa: BLE001
1172-
logger.exception("Failed to handle WebRTC offer")
1173-
raise HTTPException(
1174-
status_code=500,
1175-
detail="Failed to handle WebRTC offer",
1176-
) from exc
1176+
body = await _json_object_body(request)
1177+
1178+
async def webrtc_connection_callback(connection: SmallWebRTCConnection) -> None:
1179+
# Detached deliberately, not a Starlette background task. Those are
1180+
# awaited as part of the request lifecycle, and uvicorn waits for
1181+
# outstanding request tasks *before* running lifespan shutdown — with
1182+
# timeout_graceful_shutdown defaulting to None, that wait is unbounded.
1183+
# An interview attached to the request would therefore hang Ctrl-C
1184+
# forever and the lifespan cleanup below would never get to cancel it.
1185+
nonlocal slot_handed_over
1186+
task = asyncio.create_task(
1187+
run_interview_bot(connection, track_id),
1188+
name=f"moss-interview-{track_id}",
1189+
)
1190+
# From here run_interview_bot's finally releases the slot, so this
1191+
# request must not — even if handle_web_request fails afterwards.
1192+
slot_handed_over = True
1193+
bot_tasks.add(task)
1194+
task.add_done_callback(_on_bot_task_done)
1195+
1196+
try:
1197+
answer = await small_webrtc_handler.handle_web_request(
1198+
request=SmallWebRTCRequest.from_dict(body),
1199+
webrtc_connection_callback=webrtc_connection_callback,
1200+
)
1201+
except HTTPException:
1202+
# Already carries an intended status; do not flatten it to a 500.
1203+
raise
1204+
except (KeyError, TypeError, ValueError) as exc:
1205+
# A malformed offer is the caller's mistake, not a server fault.
1206+
logger.warning(f"Malformed WebRTC offer: {exc}")
1207+
raise HTTPException(
1208+
status_code=422, detail=f"Malformed WebRTC offer: {exc}"
1209+
) from exc
1210+
except Exception as exc: # noqa: BLE001
1211+
logger.exception("Failed to handle WebRTC offer")
1212+
raise HTTPException(
1213+
status_code=500,
1214+
detail="Failed to handle WebRTC offer",
1215+
) from exc
1216+
finally:
1217+
if not slot_handed_over:
1218+
await release_bot_slot()
11771219

11781220
return answer
11791221

apps/moss-vscode/src/indexer/indexer.ts

Lines changed: 107 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export class CodebaseIndexer {
2626
private watchingEnabled = false;
2727
/** True once rebuild() has begun destroying the previous index. */
2828
private discardedPreviousIndex = false;
29+
/** Bumped by each rebuild so watcher work started earlier can bail out. */
30+
private generation = 0;
31+
/** Watcher operations already past their `indexing` guard. */
32+
private watcherOps = new Set<Promise<void>>();
2933
private onPersist: (() => void) | undefined;
3034

3135
setPersistHandler(handler: (() => void) | undefined): void {
@@ -110,8 +114,12 @@ export class CodebaseIndexer {
110114
}
111115
this.indexing = true;
112116
this.discardedPreviousIndex = false;
117+
// Invalidate watcher work already in flight, then wait for it to settle so
118+
// no late write lands between our delete and our rewrite.
119+
this.generation += 1;
113120

114121
try {
122+
await this.drainWatcherOps();
115123
const files = await scanWorkspaceFiles(token);
116124
this.setStatus({ state: "indexing", processed: 0, total: files.length });
117125

@@ -216,16 +224,7 @@ export class CodebaseIndexer {
216224
// snapshot is already gone (stale docs were deleted above), so discard
217225
// the partial index rather than presenting it as ready. The user re-runs
218226
// indexing from the "unindexed" state.
219-
const partialIds: string[] = [];
220-
for (const [rel, count] of this.pathChunkCounts) {
221-
for (let i = 0; i < count; i++) {
222-
partialIds.push(`${rel}#chunk-${i}`);
223-
}
224-
}
225-
if (partialIds.length) {
226-
await this.deleteInBatches(partialIds);
227-
}
228-
this.pathChunkCounts.clear();
227+
await this.discardPartialIndex();
229228
this.watchingEnabled = false;
230229
this.setStatus({ state: "unindexed" });
231230
return;
@@ -247,6 +246,14 @@ export class CodebaseIndexer {
247246
});
248247
} catch (err) {
249248
const message = err instanceof Error ? err.message : String(err);
249+
// If the throw landed after the stale delete, documents from this run may
250+
// already be in the index while the persisted cache — soon to be dropped
251+
// by the caller — is the only record of their ids. Remove them now so
252+
// nothing is stranded answering searches for files that no longer exist.
253+
if (this.discardedPreviousIndex) {
254+
await this.discardPartialIndex();
255+
}
256+
this.watchingEnabled = false;
250257
this.setStatus({ state: "error", message });
251258
throw err;
252259
} finally {
@@ -263,17 +270,55 @@ export class CodebaseIndexer {
263270
}
264271
}
265272

273+
/**
274+
* Register a watcher operation so a rebuild can wait for it to finish.
275+
*
276+
* The `indexing` guard only stops watcher work from *starting*; an operation
277+
* already awaiting a file read would otherwise run its writes concurrently
278+
* with a rebuild's cleanup.
279+
*/
280+
private trackWatcherOp(run: () => Promise<void>): Promise<void> {
281+
const op = run();
282+
this.watcherOps.add(op);
283+
void op.catch(() => undefined).finally(() => this.watcherOps.delete(op));
284+
return op;
285+
}
286+
287+
/** Let watcher work that predates this rebuild settle before mutating. */
288+
private async drainWatcherOps(): Promise<void> {
289+
while (this.watcherOps.size) {
290+
const inflight = Array.from(this.watcherOps);
291+
await Promise.allSettled(inflight);
292+
for (const op of inflight) {
293+
this.watcherOps.delete(op);
294+
}
295+
}
296+
}
297+
266298
async upsertFile(uri: vscode.Uri): Promise<void> {
267299
if (!this.session || !this.watchingEnabled || this.indexing) {
268300
return;
269301
}
302+
return this.trackWatcherOp(() => this.applyUpsert(uri, this.session!));
303+
}
304+
305+
private async applyUpsert(uri: vscode.Uri, session: LocalMossSession): Promise<void> {
306+
// A rebuild starting mid-operation invalidates everything below: its
307+
// pathChunkCounts are being rewritten and its documents deleted, so writing
308+
// here would resurrect ids the rebuild has already accounted for.
309+
const generation = this.generation;
310+
const stale = () => generation !== this.generation;
311+
270312
const relativePath = toWorkspaceRelative(uri);
271313
if (isExcludedFromIndex(relativePath)) {
272314
return;
273315
}
274316
const file = await readFileForIndex(uri);
317+
if (stale()) {
318+
return;
319+
}
275320
if (!file) {
276-
await this.removeFile(uri);
321+
await this.applyRemove(uri, session);
277322
return;
278323
}
279324

@@ -287,14 +332,25 @@ export class CodebaseIndexer {
287332
toDelete.push(`${file.relativePath}#chunk-${i}`);
288333
}
289334
if (toDelete.length) {
290-
await this.session.deleteDocs(toDelete);
335+
await session.deleteDocs(toDelete);
336+
if (stale()) {
337+
return;
338+
}
291339
}
292340
}
293341

294342
if (chunks.length) {
295-
await this.session.addDocs(chunks, { upsert: true });
343+
await session.addDocs(chunks, { upsert: true });
344+
// Re-checked after the write: if a rebuild took over we must not record
345+
// these ids, and it will delete them as part of its own cleanup.
346+
if (stale()) {
347+
return;
348+
}
296349
this.pathChunkCounts.set(file.relativePath, next);
297350
} else {
351+
if (stale()) {
352+
return;
353+
}
298354
this.pathChunkCounts.delete(file.relativePath);
299355
}
300356

@@ -306,16 +362,26 @@ export class CodebaseIndexer {
306362
if (!this.session || !this.watchingEnabled || this.indexing) {
307363
return;
308364
}
365+
return this.trackWatcherOp(() => this.applyRemove(uri, this.session!));
366+
}
367+
368+
private async applyRemove(uri: vscode.Uri, session: LocalMossSession): Promise<void> {
369+
const generation = this.generation;
370+
const stale = () => generation !== this.generation;
371+
309372
const relativePath = toWorkspaceRelative(uri);
310373
const count = this.pathChunkCounts.get(relativePath) ?? 0;
311374
if (!count) {
312375
// Best-effort: try deleting a reasonable number of chunks
313376
const guessIds = Array.from({ length: 64 }, (_, i) => `${relativePath}#chunk-${i}`);
314-
await this.session.deleteDocs(guessIds).catch(() => undefined);
377+
await session.deleteDocs(guessIds).catch(() => undefined);
315378
return;
316379
}
317380
const ids = Array.from({ length: count }, (_, i) => `${relativePath}#chunk-${i}`);
318-
await this.session.deleteDocs(ids);
381+
await session.deleteDocs(ids);
382+
if (stale()) {
383+
return;
384+
}
319385
this.pathChunkCounts.delete(relativePath);
320386
this.refreshReadyStatus();
321387
this.requestPersist();
@@ -397,6 +463,32 @@ export class CodebaseIndexer {
397463
});
398464
}
399465

466+
/**
467+
* Remove every document this rebuild added and forget their ids.
468+
*
469+
* If the delete fails, `discardedPreviousIndex` is cleared so the caller
470+
* keeps the persisted cache: the ids stay recorded there for a later rebuild
471+
* to retry, rather than leaving documents in the index that nothing knows
472+
* how to remove.
473+
*/
474+
private async discardPartialIndex(): Promise<void> {
475+
const partialIds: string[] = [];
476+
for (const [rel, count] of this.pathChunkCounts) {
477+
for (let i = 0; i < count; i++) {
478+
partialIds.push(`${rel}#chunk-${i}`);
479+
}
480+
}
481+
if (partialIds.length) {
482+
try {
483+
await this.deleteInBatches(partialIds);
484+
} catch {
485+
this.discardedPreviousIndex = false;
486+
return;
487+
}
488+
}
489+
this.pathChunkCounts.clear();
490+
}
491+
400492
private async deleteInBatches(ids: string[]): Promise<void> {
401493
if (!this.session) {
402494
return;

0 commit comments

Comments
 (0)