@0xmaxma/claude-gateway 1.7.1 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/server.ts +30 -3
- package/mcp/tools/image/module.ts +32 -8
- package/package.json +1 -1
package/mcp/server.ts
CHANGED
|
@@ -118,19 +118,46 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
|
|
|
118
118
|
// it by requestId) — the CLI sends that when a user Stop/Ctrl-C interrupts an
|
|
119
119
|
// in-flight tool call. Threaded through so a long-running module (image
|
|
120
120
|
// generation's poll loop) can react instead of running to its full timeout.
|
|
121
|
-
|
|
121
|
+
// Also combine with shutdownController.signal: the CLI sending that
|
|
122
|
+
// notification is only a "SHOULD" in the MCP spec, not a "MUST", and in
|
|
123
|
+
// practice a Stop that lets the CLI process exit cleanly (rather than
|
|
124
|
+
// staying alive to keep chatting) closes this server's stdin without ever
|
|
125
|
+
// sending notifications/cancelled — extra.signal would then never fire, and
|
|
126
|
+
// an in-flight image generation would poll to its full timeout instead of
|
|
127
|
+
// cancelling. stdin closing is a reliable, protocol-independent signal that
|
|
128
|
+
// the turn is over either way, so it backstops the notification.
|
|
129
|
+
const combinedSignal = AbortSignal.any([extra.signal, shutdownController.signal]);
|
|
130
|
+
return mod.handleTool(toolName, args, combinedSignal);
|
|
122
131
|
});
|
|
123
132
|
|
|
124
133
|
// Connect MCP transport
|
|
125
134
|
await mcp.connect(new StdioServerTransport());
|
|
126
135
|
|
|
127
|
-
// Graceful shutdown
|
|
136
|
+
// Graceful shutdown — used by stdin-close, SIGINT, and SIGTERM paths.
|
|
137
|
+
// Awaits any in-flight image cancel (E3) before exiting so the provider
|
|
138
|
+
// actually stops generating when the user presses Stop, rather than the
|
|
139
|
+
// process dying mid-request and the cancel call never reaching the server.
|
|
140
|
+
const imageModuleRef = modules.find((m) => m.id === 'image') as { drainCancel?: () => Promise<void> } | undefined;
|
|
128
141
|
let shuttingDown = false;
|
|
129
142
|
function shutdown(): void {
|
|
130
143
|
if (shuttingDown) return;
|
|
131
144
|
shuttingDown = true;
|
|
132
145
|
shutdownController.abort();
|
|
133
|
-
|
|
146
|
+
// Hard cap: never let shutdown block on the drain. cancelJob() is bounded by
|
|
147
|
+
// its own AbortSignal.timeout (up to 30s), which is far longer than a
|
|
148
|
+
// supervisor's SIGTERM→SIGKILL grace period. Force-exit after a short window
|
|
149
|
+
// so a slow/hung provider cancel can't keep the process alive and risk SIGKILL
|
|
150
|
+
// mid-drain. Whichever fires first wins; unref() so the timer itself never
|
|
151
|
+
// holds the loop open.
|
|
152
|
+
const forceExit = setTimeout(() => process.exit(0), 2000);
|
|
153
|
+
forceExit.unref();
|
|
154
|
+
// Give the event loop one tick so the poll loop's sleep() onAbort listener
|
|
155
|
+
// fires and cancelledResult() sets activeCancelPromise before we try to drain it.
|
|
156
|
+
setImmediate(async () => {
|
|
157
|
+
try { await imageModuleRef?.drainCancel?.(); } catch { /* non-fatal */ }
|
|
158
|
+
clearTimeout(forceExit);
|
|
159
|
+
process.exit(0);
|
|
160
|
+
});
|
|
134
161
|
}
|
|
135
162
|
process.stdin.on('end', shutdown);
|
|
136
163
|
process.stdin.on('close', shutdown);
|
|
@@ -382,9 +382,12 @@ export class ImageModule implements ToolModule {
|
|
|
382
382
|
if (signal?.aborted) return this.cancelledResult(taskId);
|
|
383
383
|
await sleep(DEFAULT_POLL_INTERVAL_MS, signal);
|
|
384
384
|
if (signal?.aborted) return this.cancelledResult(taskId);
|
|
385
|
-
const polled = await this.fetchJob(taskId);
|
|
385
|
+
const polled = await this.fetchJob(taskId, signal);
|
|
386
386
|
if (polled.__transportError) {
|
|
387
|
-
// transient transport error
|
|
387
|
+
// transient transport error (or abort signal fired mid-fetch) — check
|
|
388
|
+
// signal before continuing so a Stop-triggered abort isn't swallowed
|
|
389
|
+
// as a retryable error and the cancel fires on the next loop iteration.
|
|
390
|
+
if (signal?.aborted) return this.cancelledResult(taskId);
|
|
388
391
|
continue;
|
|
389
392
|
}
|
|
390
393
|
if (polled.httpError) return this.mapHttpError(polled.httpError.status, polled.httpError.body);
|
|
@@ -539,8 +542,9 @@ export class ImageModule implements ToolModule {
|
|
|
539
542
|
/**
|
|
540
543
|
* Best-effort E3 cancel — fires and never throws, so a failed cancel call can
|
|
541
544
|
* never break the caller's own (already-cancelled) tool response. The api-side
|
|
542
|
-
* cancel is itself idempotent/no-op-safe on an already-terminal or unknown task
|
|
543
|
-
*
|
|
545
|
+
* cancel is itself idempotent/no-op-safe on an already-terminal or unknown task.
|
|
546
|
+
* cancelledResult() tracks the returned promise (activeCancelPromise) so a
|
|
547
|
+
* process-exiting shutdown can await it via drainCancel() instead of racing it.
|
|
544
548
|
*/
|
|
545
549
|
private async cancelJob(taskId: string): Promise<void> {
|
|
546
550
|
try {
|
|
@@ -554,9 +558,23 @@ export class ImageModule implements ToolModule {
|
|
|
554
558
|
}
|
|
555
559
|
}
|
|
556
560
|
|
|
561
|
+
// Tracks the in-flight cancel call so drainCancel() can await it before the
|
|
562
|
+
// server process exits — a Stop that lets the CLI process die cleanly closes
|
|
563
|
+
// this server's stdin, which fires process.exit() shortly after; without
|
|
564
|
+
// this, that exit can race the fire-and-forget cancelJob() fetch and kill it
|
|
565
|
+
// before the request ever reaches the provider.
|
|
566
|
+
private activeCancelPromise: Promise<void> | null = null;
|
|
567
|
+
|
|
568
|
+
/** Wait for any in-flight E3 cancel to complete. Called by the shutdown handler. */
|
|
569
|
+
async drainCancel(): Promise<void> {
|
|
570
|
+
if (this.activeCancelPromise) await this.activeCancelPromise;
|
|
571
|
+
}
|
|
572
|
+
|
|
557
573
|
/** Tool result for a Stop-triggered cancellation, firing the E3 cancel first. */
|
|
558
574
|
private cancelledResult(taskId: string): McpToolResult {
|
|
559
|
-
|
|
575
|
+
this.activeCancelPromise = this.cancelJob(taskId).finally(() => {
|
|
576
|
+
this.activeCancelPromise = null;
|
|
577
|
+
});
|
|
560
578
|
return {
|
|
561
579
|
content: [{
|
|
562
580
|
type: 'text',
|
|
@@ -567,13 +585,19 @@ export class ImageModule implements ToolModule {
|
|
|
567
585
|
}
|
|
568
586
|
|
|
569
587
|
/** Fetch a job (E2), classifying transport vs HTTP errors so the poller can retry transient ones. */
|
|
570
|
-
private async fetchJob(taskId: string): Promise<{ job?: JobResponse; httpError?: { status: number; body: string }; __transportError?: unknown }> {
|
|
588
|
+
private async fetchJob(taskId: string, signal?: AbortSignal): Promise<{ job?: JobResponse; httpError?: { status: number; body: string }; __transportError?: unknown }> {
|
|
571
589
|
let res: Response;
|
|
572
590
|
try {
|
|
591
|
+
// Combine with the caller's cancel signal (not just the request timeout) so
|
|
592
|
+
// a Stop that lands mid-fetch aborts the request immediately instead of
|
|
593
|
+
// waiting out REQUEST_TIMEOUT_MS before the poll loop even gets to check.
|
|
594
|
+
const fetchSignal = signal
|
|
595
|
+
? AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal])
|
|
596
|
+
: AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
573
597
|
res = await fetch(`${this.baseUrl()}/v1/images/jobs/${encodeURIComponent(taskId)}`, {
|
|
574
598
|
method: 'GET',
|
|
575
599
|
headers: this.headers(),
|
|
576
|
-
signal:
|
|
600
|
+
signal: fetchSignal,
|
|
577
601
|
});
|
|
578
602
|
} catch (err) {
|
|
579
603
|
return { __transportError: err };
|
|
@@ -764,7 +788,7 @@ export class ImageModule implements ToolModule {
|
|
|
764
788
|
// signal.aborted itself right after, so this only needs to shorten the wait.
|
|
765
789
|
// The abort listener is removed when the timer fires normally: sleep() is called
|
|
766
790
|
// once per poll iteration against the SAME long-lived signal (up to ~75 times for
|
|
767
|
-
// the default 150s/2s budget), so leaving { once:true } listeners around on the
|
|
791
|
+
// the default 150s/2s budget), so leaving { once: true } listeners around on the
|
|
768
792
|
// non-abort path would pile them onto that one signal and trip Node's
|
|
769
793
|
// MaxListenersExceededWarning.
|
|
770
794
|
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|