Skip to content

DevContainer Port Forwarding terminates prematurely #11707

Description

@MMM-J
  • VSCode Version: Version: 1.125.0 (Universal)
  • Local OS Version: macOS 26.0
  • Remote OS Version: Debian GNU/Linux 13 (trixie)
  • Remote Extension/Connection Type: Dev Containers (local via podman + lima)
  • Logs: Chrome: "net::ERR_INCOMPLETE_CHUNKED_ENCODING", curl: "curl: (18) transfer closed with outstanding read data remaining"

Steps to Reproduce:

HTTP requests served with "Transfer-Encoding: chunked" are randomly not transmitted in full from container to host (because the bridge terminates prematurely).

Below I'm attaching a nodeJs server and client script to reproduce.

  1. run node server.js inside the devcontainer.
  2. run node client.js outside the devcontainer on the host with port forwarding in vscode enabled.
  3. The client will encounter a varying number of ECONNRESET errors (for me between 25 and 40 in 200 requests)

Does this issue occur when you try this locally?: With local containers: Yes, Locally without containers: No, With client + server both inside the container: No
Does this issue occur when you try this locally and all extensions are disabled?: Did not try - I successfully fixed the bug by fixing the compiled ms-vscode-remote.remote-containers extension on my machine.

server.js

const http = require('http');
const { Readable } = require('stream');

const PORT = parseInt(process.env.PORT || '3030', 10);
const CHUNK_SIZE = parseInt(process.env.CHUNK_SIZE || '16384', 10);
const TOTAL_BYTES = parseInt(process.env.TOTAL_BYTES || String(8 * 1024 * 1024), 10);
const SENTINEL = '__END_OF_STREAM__';
const EXPECTED_BODY_BYTES = TOTAL_BYTES + Buffer.byteLength(SENTINEL);
const CHUNK = Buffer.alloc(CHUNK_SIZE, 0x41 /* 'A' */);

// Generator-backed body for chunked transfer
function* body() {
	for (let sent = 0; sent < TOTAL_BYTES; sent += CHUNK_SIZE) {
		yield sent + CHUNK_SIZE <= TOTAL_BYTES ? CHUNK : CHUNK.subarray(0, TOTAL_BYTES - sent);
	}
	yield Buffer.from(SENTINEL);
}

http
.createServer((req, res) => {
	req.resume(); // drain any request body
	res.writeHead(200, {
		'Content-Type': 'application/octet-stream',
		'Cache-Control': 'no-store',
		'Transfer-Encoding': 'chunked',
		'X-Expected-Body-Bytes': String(EXPECTED_BODY_BYTES),
		// Close after each response so every request exercises the bridge's
		// connection-close handling — where the truncation occurs.
		'Connection': 'close',
	});
	Readable.from(body()).pipe(res);
})
.listen(PORT, '0.0.0.0', () => {
	console.log(
		`well-behaved chunked server listening on 0.0.0.0:${PORT} | ` +
			`total=${TOTAL_BYTES} bytes | chunk=${CHUNK_SIZE} | expectedBody=${EXPECTED_BODY_BYTES}`
	);
});

client.js

const http = require('http');

const HOST = process.env.HOST || '127.0.0.1';
const PORT = parseInt(process.env.PORT || '3030', 10);
const COUNT = parseInt(process.env.COUNT || '200', 10);
const SENTINEL = '__END_OF_STREAM__';
const SENTINEL_LEN = Buffer.byteLength(SENTINEL);

let failures = 0;
let truncations = 0;
let errors = 0;

function makeRequest(i) {
	return new Promise((resolve) => {
		const req = http.get(
			{ host: HOST, port: PORT, path: '/', headers: { Connection: 'close' } },
			(res) => {
				const expected = parseInt(res.headers['x-expected-body-bytes'] || '0', 10);
				const chunks = [];
				let aborted = false;

				res.on('data', (c) => chunks.push(c));

				// Node emits 'aborted' / an error with code ERR_INCOMPLETE_CHUNKED_ENCODING
				// when a chunked response is closed before the terminating chunk arrives.
				res.on('aborted', () => {
					aborted = true;
				});
				res.on('error', (err) => {
					aborted = true;
					errors++;
					failures++;
					console.error(`[${i}] RES ERROR ${err.code || err.message}`);
				});

				res.on('end', () => {
					const body = Buffer.concat(chunks);
					const tail = body.subarray(Math.max(0, body.length - SENTINEL_LEN)).toString('latin1');
					const sentinelOk = tail === SENTINEL;
					const lengthOk = body.length === expected;

					if (aborted || !sentinelOk || !lengthOk) {
						failures++;
						truncations++;
						console.error(
							`[${i}] TRUNCATED got=${body.length} expected=${expected} ` +
								`sentinelOk=${sentinelOk} aborted=${aborted}`
						);
					}
					resolve();
				});

				res.on('close', () => {
					if (aborted && chunks.length >= 0) {
						// 'end' may not fire on a hard abort; ensure we still resolve.
						resolve();
					}
				});
			}
		);

		req.on('error', (err) => {
			errors++;
			failures++;
			console.error(`[${i}] REQ ERROR ${err.code || err.message}`);
			resolve();
		});
	});
}

async function main() {
	console.log(`Requesting http://${HOST}:${PORT}/ x${COUNT} ...`);
	const start = Date.now();
	for (let i = 0; i < COUNT; i++) {
		await makeRequest(i);
	}
	const secs = ((Date.now() - start) / 1000).toFixed(1);
	console.log('-----------------------------------------------');
	console.log(`Done in ${secs}s | requests=${COUNT}`);
	console.log(`failures=${failures} (truncations=${truncations}, errors=${errors})`);
	if (failures > 0) {
		console.log('REPRODUCED: at least one response was truncated / prematurely closed.');
		process.exitCode = 1;
	} else {
		console.log('No truncation observed in this run. Try increasing COUNT or TOTAL_BYTES.');
	}
}

main();

devcontainer.json

{
	"name": "chunked-forwarding-repro",
	"image": "mcr.microsoft.com/devcontainers/javascript-node:20",
	"forwardPorts": [3030],
	"portsAttributes": {
		"3030": {
			"label": "chunked-server",
			"onAutoForward": "silent"
		}
	},
	"postCreateCommand": "node --version"
}

The bug is in the inline script that the extension runs in the container.
Faulty code in the devcontainers extension:

const net = require('net');
const fs = require('fs');
process.stdin.pause();
const client = net.createConnection({ host: '127.0.0.1', port: ${o} }, () => {
	console.error('Connection established');
	client.pipe(process.stdout);
	process.stdin.pipe(client);
});
client.on('close', function (hadError) {
	console.error(hadError ? 'Remote close with error' : 'Remote close');
	process.exit(hadError ? 1 : 0); // <<<- This does not wait for the pipe to drain
});
client.on('error', function (err) {
	process.stderr.write(err && (err.stack || err.message) || String(err));
});
process.stdin.on('close', function (hadError) {
	console.error(hadError ? 'Remote stdin close with error' : 'Remote stdin close');
	process.exit(hadError ? 1 : 0);
});
process.on('uncaughtException', function (err) {
	fs.writeSync(process.stderr.fd, \`Uncaught Exception: \${String(err && (err.stack || err.message) || err)}\\n\`);
});

Verified fix:

const net = require('net');
const fs = require('fs');
process.stdin.pause();
const client = net.createConnection({ host: '127.0.0.1', port: ${o} }, () => {
	console.error('Connection established');
	client.pipe(process.stdout);
	process.stdin.pipe(client);
});
client.on('close', function (hadError) {
	console.error(hadError ? 'Remote close with error' : 'Remote close');
	// Wait for drainage
	if (!process.stdout.writableEnded && !process.stdout.destroyed) {
		process.stdout.end();
	}
	if (process.stdout.writableFinished) {
		process.exit(hadError ? 1 : 0);
	}
	Promise.race([
		new Promise(resolve => process.stdout.once('finish', resolve)),
		new Promise((_, reject) => process.stdout.once('error', reject)),
	]).then(() => process.exit(hadError ? 1 : 0),() => process.exit(1));
});
client.on('error', function (err) {
	process.stderr.write(err && (err.stack || err.message) || String(err));
});
process.stdin.on('close', function (hadError) {
	console.error(hadError ? 'Remote stdin close with error' : 'Remote stdin close');
	process.exit(hadError ? 1 : 0);
});
process.on('uncaughtException', function (err) {
	fs.writeSync(process.stderr.fd, \`Uncaught Exception: \${String(err && (err.stack || err.message) || err)}\\n\`);
});

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions