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 => {