@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 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
- return mod.handleTool(toolName, args, extra.signal);
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
- setTimeout(() => process.exit(0), 2000);
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 keep polling until deadline
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
- * so no need to track whether this actually reached a still-running job.
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
- void this.cancelJob(taskId);
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: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
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> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xmaxma/claude-gateway",
3
- "version": "1.7.1",
3
+ "version": "1.7.2",
4
4
  "description": "Multi-agent gateway for Claude",
5
5
  "repository": {
6
6
  "type": "git",