All files / src/middleware response-write-timeout.ts

97.29% Statements 36/37
77.27% Branches 17/22
100% Functions 7/7
97.14% Lines 34/35

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125                                        18x 18x 18x     18x     3x         10x 1x                             18x 83x   12x 1x 1x             11x       11x           11x     11x 10x       10x           6x 4x   3x 3x   3x 3x 3x               3x           11x     11x 5x 5x 5x         11x 11x   11x      
/**
 * Response Write Timeout Middleware
 *
 * Detects and kills zombie connections where res.write()/res.end() stalls
 * because the downstream TCP peer (Cloudflare/Envoy/client) stopped reading.
 *
 * Problem: When a client disconnects mid-flight (laptop sleep, network change),
 * the TCP connection may remain half-open. Node.js writes the HTTP response into
 * the TCP send buffer, which fills up. TCP retransmits for ~125s before RST.
 * During this time, the request appears stuck with no error.
 *
 * Solution: After response headers are sent, start a timer. If the response
 * doesn't finish (all data flushed to OS) within the timeout, destroy the socket.
 * SSE streams are excluded — they have their own heartbeat-based dead connection
 * detection (see startSseHeartbeat in server.ts).
 *
 * @see https://github.com/structured-world/gitlab-mcp/issues/391
 */
 
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { RESPONSE_WRITE_TIMEOUT_MS } from '../config';
import { logWarn } from '../logger';
 
/** Validates mcp-session-id header: string, string[], or undefined → string | undefined */
const mcpSessionIdSchema = z
  .union([z.string(), z.array(z.string())])
  .optional()
  .transform((value) => (Array.isArray(value) ? value[0] : value));
 
/** Normalize Content-Type header to lowercase string for comparison.
 *  Handles string, string[] (Node.js allows both), and undefined. */
function normalizeContentType(value: string | number | string[] | undefined): string {
  if (typeof value === 'string') return value.toLowerCase();
  Eif (Array.isArray(value)) return value.join(',').toLowerCase();
  return '';
}
 
/**
 * Express middleware that destroys sockets when response writes stall.
 *
 * Intercepts writeHead() to start a timer when response headers are sent.
 * If the response `finish` event doesn't fire within RESPONSE_WRITE_TIMEOUT_MS,
 * the socket is destroyed to free resources.
 *
 * Skips:
 * - SSE responses (Content-Type: text/event-stream) — handled by heartbeat
 * - When RESPONSE_WRITE_TIMEOUT_MS is 0 (disabled)
 */
export function responseWriteTimeoutMiddleware() {
  return (req: Request, res: Response, next: NextFunction): void => {
    // Disabled when timeout is 0
    if (RESPONSE_WRITE_TIMEOUT_MS <= 0) {
      next();
      return;
    }
 
    let writeTimer: ReturnType<typeof setTimeout> | undefined;
 
    // Intercept writeHead to detect when response writing begins.
    // This pattern is used by established Express middleware (morgan, compression, on-headers).
    const originalWriteHead = res.writeHead.bind(res) as (
      ...a: Parameters<typeof res.writeHead>
    ) => ReturnType<typeof res.writeHead>;
 
    (res as unknown as Record<string, unknown>).writeHead = (
      ...args: Parameters<typeof res.writeHead>
    ): Response => {
      // Call originalWriteHead FIRST so headers passed as arguments are applied
      // (e.g., Content-Type: text/event-stream via writeHead(200, headers)).
      // Only then can res.getHeader() return the final effective Content-Type.
      const result = originalWriteHead(...args);
 
      // Start timer only once, and only for non-SSE responses
      if (!writeTimer) {
        const isSSE = normalizeContentType(res.getHeader('content-type')).includes(
          'text/event-stream',
        );
 
        if (!isSSE) {
          // Timer starts at writeHead, not at first write(). This is intentional:
          // MCP responses are small JSON payloads that flush in milliseconds.
          // A 10s window from headers-sent is generous for any non-SSE response
          // in this server. Wrapping res.write/res.end would add complexity
          // without benefit for the MCP use case.
          writeTimer = setTimeout(() => {
            if (!res.writableFinished && !res.destroyed) {
              // Mark response so close handler can use 'write_timeout' reason
              res.locals = res.locals ?? {};
              res.locals.writeTimedOut = true;
 
              const parsedSessionId = mcpSessionIdSchema.safeParse(req.headers['mcp-session-id']);
              const sessionId = parsedSessionId.success ? parsedSessionId.data : undefined;
              logWarn('Response write timeout — destroying zombie connection', {
                method: req.method,
                path: req.path,
                timeoutMs: RESPONSE_WRITE_TIMEOUT_MS,
                sessionId,
                reason: 'write_timeout',
              });
 
              res.destroy();
            }
          }, RESPONSE_WRITE_TIMEOUT_MS);
        }
      }
 
      return result;
    };
 
    const cleanup = () => {
      Eif (writeTimer) {
        clearTimeout(writeTimer);
        writeTimer = undefined;
      }
    };
 
    // Clear timer when response completes normally or connection closes
    res.on('finish', cleanup);
    res.on('close', cleanup);
 
    next();
  };
}