You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/08/05 19:13:18 UTC

(superset) 01/01: fix(dev): stop the webpack dev-proxy from hanging when a compressed response is interrupted mid-stream

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch fix/dev-proxy-decompress-hang
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 41eaa9d8455e61fdf6306685104a827df484b4ed
Author: Claude Code <no...@anthropic.com>
AuthorDate: Wed Aug 5 12:12:44 2026 -0700

    fix(dev): stop the webpack dev-proxy from hanging when a compressed response is interrupted mid-stream
    
    processHTML() pipes a proxied backend response through zlib/simple-zstd
    with plain .pipe(), which does not forward the source's errors or
    premature close to the destination. When the backend connection drops
    mid-response -- most commonly the Flask dev server's reloader
    restarting on a file save -- the decompression stream (and, for zstd,
    its child process) is left waiting on input that will never arrive.
    end/error never fire, so response.end() is never called and the
    request hangs indefinitely instead of failing fast, eventually
    exhausting the browser's per-origin connection pool.
    
    Confirmed this is not specific to simple-zstd's v2 API: the same hang
    reproduces with gzip on the pre-existing .pipe()-based code, so simply
    reverting the simple-zstd bump (#42804) would not have fixed it.
    
    Replace the manual .pipe() + event-listener wiring with
    stream/promises' pipeline(), which destroys every stream in the chain
    and rejects as soon as any one of them errors or closes prematurely --
    letting the existing onProxyRes .catch() surface a fast, clear error
    instead of a silent hang.
    
    Added tools/webpack.proxy-config.test.js (previously no coverage
    existed for this file), which fails against the pre-fix code (the
    whole test process hangs) and passes with the fix.
---
 .../tools/webpack.proxy-config.test.js             | 207 +++++++++++++++++++++
 superset-frontend/webpack.proxy-config.js          |  45 +++--
 2 files changed, 233 insertions(+), 19 deletions(-)

diff --git a/superset-frontend/tools/webpack.proxy-config.test.js b/superset-frontend/tools/webpack.proxy-config.test.js
new file mode 100644
index 00000000000..c8e6de12441
--- /dev/null
+++ b/superset-frontend/tools/webpack.proxy-config.test.js
@@ -0,0 +1,207 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+const http = require('http');
+const zlib = require('zlib');
+const { compressBuffer } = require('simple-zstd');
+const { createProxyMiddleware } = require('http-proxy-middleware');
+
+// yargs ships ESM-only and jest's default transform doesn't cover
+// node_modules; webpack.proxy-config.js only uses it to parse a `--env`
+// CLI flag we don't exercise here (the target port is set via
+// process.env.supersetPort below), so stub it out rather than teaching
+// the whole suite's transformIgnorePatterns about it.
+jest.mock('yargs', () => jest.fn(() => ({ parse: () => ({}) })));
+jest.mock('yargs/helpers', () => ({ hideBin: argv => argv }));
+
+const HANG_GUARD_MS = 2000;
+
+/**
+ * Wires the real dev proxy config to a real HTTP server, exactly the way
+ * webpack-dev-server does (`devServer.proxy: [() => proxyConfig]`), and
+ * points it at a caller-supplied backend. Both servers are ephemeral
+ * (port 0) so tests can run in parallel.
+ */
+async function startProxy(backendPort) {
+  const previousPort = process.env.supersetPort;
+  // webpack.proxy-config.js resolves its target port from process.env at
+  // require()-time, so the module must be (re-)required after this is set.
+  process.env.supersetPort = String(backendPort);
+  jest.resetModules();
+  // eslint-disable-next-line global-require
+  const getProxyConfig = require('../webpack.proxy-config');
+  process.env.supersetPort = previousPort;
+
+  const proxyMiddleware = createProxyMiddleware(getProxyConfig(undefined));
+  const server = http.createServer((req, res) => proxyMiddleware(req, res));
+  await new Promise(resolve => server.listen(0, resolve));
+  return server;
+}
+
+async function startBackend(handler) {
+  const server = http.createServer(handler);
+  await new Promise(resolve => server.listen(0, resolve));
+  return server;
+}
+
+function get(port) {
+  return new Promise((resolve, reject) => {
+    const req = http.get(
+      { hostname: 'localhost', port, path: '/dashboard/list/' },
+      res => {
+        const chunks = [];
+        res.on('data', chunk => chunks.push(chunk));
+        res.on('end', () =>
+          resolve({
+            statusCode: res.statusCode,
+            body: Buffer.concat(chunks).toString(),
+          }),
+        );
+        res.on('error', reject);
+      },
+    );
+    req.on('error', reject);
+  });
+}
+
+async function closeAll(...servers) {
+  await Promise.all(
+    servers.map(server => new Promise(resolve => server.close(resolve))),
+  );
+}
+
+describe('webpack.proxy-config zstd/gzip HTML decompression', () => {
+  test('decompresses a complete zstd-encoded HTML response and injects the [DEV] title', async () => {
+    const html =
+      '<html><head><title>Superset</title></head><body>hi</body></html>';
+    const backend = await startBackend(async (req, res) => {
+      const compressed = await compressBuffer(Buffer.from(html), 3);
+      res.writeHead(200, {
+        'content-type': 'text/html; charset=utf-8',
+        'content-encoding': 'zstd',
+      });
+      res.end(compressed);
+    });
+    const proxy = await startProxy(backend.address().port);
+
+    try {
+      const { statusCode, body } = await get(proxy.address().port);
+      expect(statusCode).toBe(200);
+      expect(body).toContain('[DEV] Superset');
+      expect(body).toContain('<body>hi</body>');
+    } finally {
+      await closeAll(proxy, backend);
+    }
+  });
+
+  test(
+    'fails fast instead of hanging when the backend connection drops mid-response (zstd)',
+    async () => {
+      const html = `<html><head><title>Superset</title></head><body>${'x'.repeat(20000)}</body></html>`;
+      const backend = await startBackend(async (req, res) => {
+        const compressed = await compressBuffer(Buffer.from(html), 3);
+        res.writeHead(200, {
+          'content-type': 'text/html; charset=utf-8',
+          'content-encoding': 'zstd',
+        });
+        // Simulate the backend dying mid-response -- e.g. the Flask dev
+        // server's reloader restarting on a file save -- by writing only
+        // half the compressed body and then hard-destroying the socket.
+        // The short delay lets the proxy fully receive the response headers
+        // first, so this exercises the body-stream-level failure inside
+        // processHTML rather than a connection-level error that
+        // http-proxy-middleware's own error handler would intercept first.
+        res.write(compressed.subarray(0, Math.floor(compressed.length / 2)));
+        setTimeout(() => res.socket.destroy(), 20);
+      });
+      const proxy = await startProxy(backend.address().port);
+
+      try {
+        const hangGuard = new Promise((_resolve, reject) => {
+          setTimeout(
+            () =>
+              reject(
+                new Error(
+                  'request never resolved -- the client-facing response hung ' +
+                    'instead of the proxy propagating the backend disconnect',
+                ),
+              ),
+            HANG_GUARD_MS,
+          );
+        });
+
+        // Headers (including the 200 the backend sent before dying) are
+        // already flushed before the drop is detected, so the response
+        // completes with the original status; what matters is that it
+        // completes at all, promptly, with the error surfaced in the body
+        // instead of the connection hanging indefinitely.
+        const { body } = await Promise.race([
+          get(proxy.address().port),
+          hangGuard,
+        ]);
+        expect(body).toContain('Error requesting');
+      } finally {
+        await closeAll(proxy, backend);
+      }
+    },
+    HANG_GUARD_MS + 1000,
+  );
+
+  test(
+    'fails fast instead of hanging when the backend connection drops mid-response (gzip)',
+    async () => {
+      // The hang is a generic pipe()-doesn't-propagate-errors bug in
+      // processHTML, not specific to any one decoder -- pin it for gzip too.
+      const html = `<html><head><title>Superset</title></head><body>${'x'.repeat(20000)}</body></html>`;
+      const compressed = zlib.gzipSync(Buffer.from(html));
+      const backend = await startBackend((req, res) => {
+        res.writeHead(200, {
+          'content-type': 'text/html; charset=utf-8',
+          'content-encoding': 'gzip',
+        });
+        res.write(compressed.subarray(0, Math.floor(compressed.length / 2)));
+        setTimeout(() => res.socket.destroy(), 20);
+      });
+      const proxy = await startProxy(backend.address().port);
+
+      try {
+        const hangGuard = new Promise((_resolve, reject) => {
+          setTimeout(
+            () =>
+              reject(
+                new Error(
+                  'request never resolved -- the client-facing response hung ' +
+                    'instead of the proxy propagating the backend disconnect',
+                ),
+              ),
+            HANG_GUARD_MS,
+          );
+        });
+
+        const { body } = await Promise.race([
+          get(proxy.address().port),
+          hangGuard,
+        ]);
+        expect(body).toContain('Error requesting');
+      } finally {
+        await closeAll(proxy, backend);
+      }
+    },
+    HANG_GUARD_MS + 1000,
+  );
+});
diff --git a/superset-frontend/webpack.proxy-config.js b/superset-frontend/webpack.proxy-config.js
index 8fb51bca3e3..a91a75b27e3 100644
--- a/superset-frontend/webpack.proxy-config.js
+++ b/superset-frontend/webpack.proxy-config.js
@@ -17,6 +17,8 @@
  * under the License.
  */
 const zlib = require('zlib');
+const { Writable } = require('stream');
+const { pipeline } = require('stream/promises');
 const { decompress: zstdDecompress } = require('simple-zstd');
 
 const yargs = require('yargs');
@@ -118,9 +120,7 @@ function copyHeaders(originalResponse, response) {
  * local webpack-dev-server build.
  */
 async function processHTML(proxyResponse, response) {
-  let body = Buffer.from([]);
-  let originalResponse = proxyResponse;
-  const responseEncoding = originalResponse.headers['content-encoding'];
+  const responseEncoding = proxyResponse.headers['content-encoding'];
 
   // decode GZIP response
   let uncompress;
@@ -133,23 +133,30 @@ async function processHTML(proxyResponse, response) {
   } else if (responseEncoding === 'zstd') {
     uncompress = await zstdDecompress();
   }
-  if (uncompress) {
-    originalResponse.pipe(uncompress);
-    originalResponse = uncompress;
-  }
 
-  originalResponse
-    .on('data', data => {
-      body = Buffer.concat([body, data]);
-    })
-    .on('error', error => {
-      // eslint-disable-next-line no-console
-      console.error(error);
-      response.end(`Error fetching proxied request: ${error.message}`);
-    })
-    .on('end', () => {
-      response.end(toDevHTML(body.toString()));
-    });
+  const chunks = [];
+  const collector = new Writable({
+    write(chunk, encoding, callback) {
+      chunks.push(chunk);
+      callback();
+    },
+  });
+
+  // `pipeline` (unlike `.pipe()`) destroys every stream in the chain -- and
+  // rejects -- as soon as any one of them errors or closes prematurely. A
+  // proxied backend connection dying mid-response (e.g. the Flask dev
+  // server's reloader restarting on a file save) is exactly that case:
+  // plain `.pipe()` never forwards the upstream error/close to `uncompress`,
+  // so `uncompress` (and, for `zstd`, the child process backing it) sits
+  // waiting for input that will never arrive, `end`/`error` never fire, and
+  // the client-facing response hangs forever instead of failing fast.
+  await pipeline(
+    ...(uncompress
+      ? [proxyResponse, uncompress, collector]
+      : [proxyResponse, collector]),
+  );
+
+  response.end(toDevHTML(Buffer.concat(chunks).toString()));
 }
 
 module.exports = newManifest => {