Skip to content

Commit b17ef0a

Browse files
committed
Propagate cancellation and order shutdown usage persistence
1 parent 75febb9 commit b17ef0a

8 files changed

Lines changed: 237 additions & 36 deletions

File tree

src/cli/commands/serve.ts

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export function registerServeFinalCleanups(
110110
shutdownMgr: Pick<ShutdownManager, "addCleanup">,
111111
cleanup: {
112112
drainWork: () => Promise<void>;
113+
persistUsage: () => Promise<void>;
113114
stopObservability: () => Promise<void>;
114115
closeDatabase: () => Promise<void>;
115116
},
@@ -120,6 +121,9 @@ export function registerServeFinalCleanups(
120121
workDrained = true;
121122
};
122123
shutdownMgr.addCleanup("workDrain", drainWork);
124+
shutdownMgr.addCleanup("persistUsage", async () => {
125+
if (workDrained) await cleanup.persistUsage();
126+
});
123127
shutdownMgr.addCleanup("observability", async () => {
124128
if (workDrained) await cleanup.stopObservability();
125129
});
@@ -289,23 +293,6 @@ export async function serveCommand(options: ServeOptions): Promise<void> {
289293
dashboardHandle?.close(),
290294
);
291295
shutdownMgr.addCleanup("httpServer", () => httpHandle?.close());
292-
shutdownMgr.addCleanup("persistUsage", async () => {
293-
try {
294-
if (
295-
graphDbAvailable &&
296-
startupReadiness.isWriteReady() &&
297-
tokenAccumulator.hasUsage
298-
) {
299-
await persistUsageSnapshot(tokenAccumulator.getSnapshot());
300-
}
301-
} catch (err) {
302-
// Non-critical — don't block shutdown.
303-
writeServeStderrLine(
304-
"[sdl-mcp] Failed to persist usage snapshot: " +
305-
(err instanceof Error ? err.message : String(err)),
306-
);
307-
}
308-
});
309296
shutdownMgr.addCleanup("watchers", async () => {
310297
for (const watcher of watchers) {
311298
try {
@@ -320,6 +307,23 @@ export async function serveCommand(options: ServeOptions): Promise<void> {
320307
shutdownMgr.addCleanup("graphIntegrityVerifier", stopGraphIntegrityVerifier);
321308
registerServeFinalCleanups(shutdownMgr, {
322309
drainWork: drainLadybugWork,
310+
persistUsage: async () => {
311+
try {
312+
if (
313+
graphDbAvailable &&
314+
startupReadiness.isWriteReady() &&
315+
tokenAccumulator.hasUsage
316+
) {
317+
await persistUsageSnapshot(tokenAccumulator.getSnapshot());
318+
}
319+
} catch (err) {
320+
// Non-critical — don't block shutdown.
321+
writeServeStderrLine(
322+
"[sdl-mcp] Failed to persist usage snapshot: " +
323+
(err instanceof Error ? err.message : String(err)),
324+
);
325+
}
326+
},
323327
stopObservability,
324328
closeDatabase: closeLadybugDb,
325329
});

src/mcp/dispatch-limiter.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ export async function runToolDispatch<T>(
171171
fn: () => Promise<T>,
172172
timeoutMs?: number,
173173
label = "tool-dispatch",
174+
signal?: AbortSignal,
174175
): Promise<T> {
175176
const deferredWork = label.startsWith("derived-refresh:")
176177
? undefined
@@ -195,6 +196,7 @@ export async function runToolDispatch<T>(
195196
}
196197
}),
197198
queueTimeoutMs,
199+
signal,
198200
);
199201
} catch (error) {
200202
if (error instanceof ConcurrencyQueueTimeoutError) {

src/mcp/tools/code.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,32 @@ type ResolvedGetHotPathRequest = Omit<
9494
"symbolId" | "symbolRef"
9595
> & { symbolId: string };
9696

97+
function awaitWithSignal<T>(
98+
promise: Promise<T>,
99+
signal?: AbortSignal,
100+
): Promise<T> {
101+
if (!signal) return promise;
102+
if (signal.aborted) return Promise.reject(signal.reason);
103+
104+
return new Promise<T>((resolve, reject) => {
105+
const onAbort = (): void => {
106+
signal.removeEventListener("abort", onAbort);
107+
reject(signal.reason);
108+
};
109+
signal.addEventListener("abort", onAbort, { once: true });
110+
void promise.then(
111+
(value) => {
112+
signal.removeEventListener("abort", onAbort);
113+
resolve(value);
114+
},
115+
(error: unknown) => {
116+
signal.removeEventListener("abort", onAbort);
117+
reject(error);
118+
},
119+
);
120+
});
121+
}
122+
97123
function buildSessionRef(key: string, etag?: string): { key: string; etag?: string } {
98124
const ref: { key: string; etag?: string } = { key };
99125
if (etag !== undefined) ref.etag = etag;
@@ -561,11 +587,21 @@ export async function handleCodeNeedWindow(
561587
request.repoId,
562588
);
563589
if (latestVersion) {
564-
const { slice } = await buildSlice({
565-
repoId: request.repoId,
566-
versionId: latestVersion.versionId,
567-
...request.sliceContext,
568-
});
590+
const sliceSignal = context?.signal
591+
? AbortSignal.any([context.signal, AbortSignal.timeout(30_000)])
592+
: undefined;
593+
sliceSignal?.throwIfAborted();
594+
// LadybugDB reads are not uniformly abort-aware; release the MCP
595+
// dispatch slot on cancellation while also passing the signal down.
596+
const { slice } = await awaitWithSignal(
597+
buildSlice({
598+
repoId: request.repoId,
599+
versionId: latestVersion.versionId,
600+
...request.sliceContext,
601+
signal: sliceSignal,
602+
}),
603+
sliceSignal,
604+
);
569605
sliceContext = slice;
570606
}
571607
}

src/server.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1298,7 +1298,12 @@ export class MCPServer {
12981298
const runDispatch = () =>
12991299
shouldBypassToolDispatch(toolName, parsedArgs)
13001300
? dispatchTool()
1301-
: runToolDispatch(dispatchTool, undefined, toolName);
1301+
: runToolDispatch(
1302+
dispatchTool,
1303+
undefined,
1304+
toolName,
1305+
toolContext.signal,
1306+
);
13021307
// Refresh admission must happen before the outer dispatch lease.
13031308
// This also covers workflows, whose refresh step executes inside
13041309
// the workflow's single outer lease rather than acquiring its own.

tests/unit/dispatch-limiter.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import assert from "node:assert";
2+
import { readFileSync } from "node:fs";
3+
import { join } from "node:path";
24
import { afterEach, beforeEach, describe, it } from "node:test";
35

46
import {
@@ -180,6 +182,56 @@ describe("tool dispatch limiter", () => {
180182
await first;
181183
});
182184

185+
it("removes queued dispatch work when the request is cancelled", async () => {
186+
configureToolDispatchLimiter({ maxConcurrency: 1, queueTimeoutMs: 1_000 });
187+
188+
let release: (() => void) | undefined;
189+
const blocker = new Promise<void>((resolve) => {
190+
release = resolve;
191+
});
192+
const first = runToolDispatch(async () => blocker, undefined, "first");
193+
const controller = new AbortController();
194+
const reason = new Error("client disconnected");
195+
const queued = runToolDispatch(
196+
async () => "second",
197+
undefined,
198+
"second",
199+
controller.signal,
200+
);
201+
202+
try {
203+
await delay(20);
204+
assert.strictEqual(getToolDispatchStats().queued, 1);
205+
206+
controller.abort(reason);
207+
const outcome = await Promise.race([
208+
queued.then(
209+
() => ({ kind: "resolved" as const }),
210+
(error: unknown) => ({ kind: "rejected" as const, error }),
211+
),
212+
delay(100).then(() => ({ kind: "pending" as const })),
213+
]);
214+
215+
assert.strictEqual(outcome.kind, "rejected");
216+
if (outcome.kind !== "rejected") return;
217+
assert.strictEqual(outcome.error, reason);
218+
assert.strictEqual(getToolDispatchStats().queued, 0);
219+
} finally {
220+
release?.();
221+
await first;
222+
await queued.catch(() => undefined);
223+
}
224+
});
225+
226+
it("forwards MCP request cancellation into the shared dispatch queue", () => {
227+
const source = readFileSync(join(process.cwd(), "src", "server.ts"), "utf8");
228+
229+
assert.match(
230+
source,
231+
/runToolDispatch\(\s*dispatchTool,\s*undefined,\s*toolName,\s*toolContext\.signal,\s*\)/,
232+
);
233+
});
234+
183235
it("lets derived-refresh deferred work finish before timing foreground dispatch out", async () => {
184236
configureToolDispatchLimiter({ maxConcurrency: 1, queueTimeoutMs: 30 });
185237

tests/unit/http-shutdown.test.ts

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -77,36 +77,54 @@ describe("HTTP shutdown wiring", () => {
7777
}
7878
});
7979

80-
it("registers HTTP server cleanup before final DB cleanup", () => {
80+
it("stops HTTP producers before draining work and persisting usage", () => {
8181
const source = readFileSync(
8282
join(process.cwd(), "src", "cli", "commands", "serve.ts"),
8383
"utf8",
8484
);
85-
const httpCleanupIndex = source.indexOf(
85+
const serveSource = source.slice(
86+
source.indexOf("export async function serveCommand"),
87+
);
88+
const httpCleanupIndex = serveSource.indexOf(
8689
'shutdownMgr.addCleanup("httpServer"',
8790
);
88-
const persistUsageIndex = source.indexOf(
91+
const earlyPersistUsageIndex = serveSource.indexOf(
8992
'shutdownMgr.addCleanup("persistUsage"',
9093
);
91-
const dbCleanupIndex = source.lastIndexOf(
94+
const watcherCleanupIndex = serveSource.indexOf(
95+
'shutdownMgr.addCleanup("watchers"',
96+
);
97+
const verifierCleanupIndex = serveSource.indexOf(
98+
'shutdownMgr.addCleanup("graphIntegrityVerifier"',
99+
);
100+
const finalCleanupIndex = serveSource.indexOf(
92101
"registerServeFinalCleanups(shutdownMgr",
93102
);
94-
const loggerCleanupIndex = source.indexOf(
103+
const loggerCleanupIndex = serveSource.indexOf(
95104
'shutdownMgr.addCleanup("logger"',
96105
);
97106

98107
assert.ok(httpCleanupIndex >= 0, "HTTP cleanup should be registered");
99-
assert.ok(
100-
persistUsageIndex >= 0,
101-
"usage persistence cleanup should be registered",
108+
assert.equal(
109+
earlyPersistUsageIndex,
110+
-1,
111+
"usage persistence must not run before producer shutdown and work drain",
102112
);
103-
assert.ok(dbCleanupIndex >= 0, "DB cleanup should be registered");
113+
assert.ok(watcherCleanupIndex >= 0, "watcher cleanup should be registered");
114+
assert.ok(verifierCleanupIndex >= 0, "verifier cleanup should be registered");
115+
assert.ok(finalCleanupIndex >= 0, "final cleanup should be registered");
104116
assert.ok(loggerCleanupIndex >= 0, "logger cleanup should be registered");
105117
assert.ok(
106-
httpCleanupIndex < persistUsageIndex &&
107-
persistUsageIndex < dbCleanupIndex &&
108-
dbCleanupIndex < loggerCleanupIndex,
109-
"HTTP transport cleanup must run before usage persistence and final DB/logger cleanup",
118+
httpCleanupIndex < watcherCleanupIndex &&
119+
watcherCleanupIndex < verifierCleanupIndex &&
120+
verifierCleanupIndex < finalCleanupIndex &&
121+
finalCleanupIndex < loggerCleanupIndex,
122+
"HTTP transport and producers must stop before final drain/usage/DB cleanup",
123+
);
124+
assert.match(
125+
serveSource,
126+
/registerServeFinalCleanups\(shutdownMgr,\s*\{[\s\S]*?persistUsage:\s*async\s*\(\)\s*=>/,
127+
"serve cleanup must persist usage through the post-drain final cleanup path",
110128
);
111129
});
112130

tests/unit/mcp-code-need-window-policy.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,15 @@ describe("code.needWindow policy remediation", () => {
120120
createdAt: now,
121121
});
122122

123+
await ladybugDb.createVersion(conn, {
124+
versionId: "version-test",
125+
repoId: "repo-test",
126+
createdAt: now,
127+
reason: "test",
128+
prevVersionHash: null,
129+
versionHash: "hash-version-test",
130+
});
131+
123132
await ladybugDb.upsertFile(conn, {
124133
fileId: "file-demo",
125134
repoId: "repo-test",
@@ -288,6 +297,46 @@ describe("code.needWindow policy remediation", () => {
288297
assert.equal(JSON.stringify(first), JSON.stringify(second));
289298
});
290299

300+
it("passes request cancellation into slice-backed code access", async () => {
301+
const originalAny = AbortSignal.any;
302+
const calls: AbortSignal[][] = [];
303+
AbortSignal.any = ((signals: AbortSignal[]) => {
304+
calls.push([...signals]);
305+
return originalAny(signals);
306+
}) as typeof AbortSignal.any;
307+
308+
const controller = new AbortController();
309+
const reason = new Error("client disconnected");
310+
try {
311+
const response = handleCodeNeedWindow(
312+
{
313+
repoId: "repo-test",
314+
symbolId: "sym-demo",
315+
reason: "inspect important flag handling",
316+
expectedLines: 20,
317+
maxTokens: 120,
318+
identifiersToFind: ["importantFlag"],
319+
sliceContext: {
320+
taskText: "inspect demoWindow",
321+
entrySymbols: ["sym-demo"],
322+
},
323+
},
324+
{
325+
sendNotification: async () => {},
326+
signal: controller.signal,
327+
},
328+
);
329+
queueMicrotask(() => controller.abort(reason));
330+
331+
await assert.rejects(response, (error: unknown) => error === reason);
332+
assert.equal(calls.length, 1);
333+
assert.strictEqual(calls[0]?.[0], controller.signal);
334+
assert.equal(calls[0]?.length, 2);
335+
} finally {
336+
AbortSignal.any = originalAny;
337+
}
338+
});
339+
291340
it("resolves stringified symbolRef targets for raw code windows", async () => {
292341
const response = await handleCodeNeedWindow({
293342
repoId: "repo-test",

0 commit comments

Comments
 (0)