microsoft / microsoft/vscode-remote-release
Orphaned `node -e` proxy processes accumulate inside Dev Container when `docker exec` dies during reconnection
@chrmarti is already working on this.
Since Mar 28, 2026.
- Dominant language
- Dockerfile
- Stars
- 4.2k
- Forks
- 469
- Avg merge
- 1d 1h
- Merged PRs (30d)
- 1
Description
Environment
- VS Code Version: 1.97+ (observed across multiple versions)
- Local OS Version: macOS / Windows / Linux
- Remote OS Version: Linux (Dev Container)
- Remote Extension/Connection Type: Dev Containers (ms-vscode-remote.remote-containers)
Summary
When two VS Code windows are connected to the same Dev Container running with network_mode: host (Docker host networking), orphaned node -e proxy processes accumulate inside the container. The accumulation begins within seconds of the second window connecting. Over time, the orphaned proxies saturate CPU and cause window activations to take 30-60 seconds.
The container shares the host's network stack, so the proxy's 127.0.0.1 TCP connection to the VS Code server goes through the host's loopback interface rather than an isolated container network.
The proxy script has no mechanism to detect that its docker exec parent has died, because the container runtime (containerd-shim) inherits and holds open all three pipe file descriptors (stdin, stdout, stderr) for the lifetime of the exec session.
Related: #5094
Steps to Reproduce
-
Configure the Dev Container with
network_mode: hostindocker-compose.yml:services: app: network_mode: host -
Open the Dev Container with one VS Code window. Verify proxy count is stable:
ps aux | grep "node -e" | grep -v grep | wc -lExpected: 2-5 proxies.
-
Open a second VS Code window to the same container.
-
Watch proxy count:
watch -n2 'ps aux | grep "node -e" | grep -v grep | wc -l'The count begins climbing within seconds.
-
Verify proxies are orphaned (PPID 0):
ps aux | grep "node -e" | grep -v grep | awk '{print $2}' | \ xargs -I{} ps -p {} -o pid,ppid 2>/dev/null | grep " 0$" -
VS Code server log shows repeated disconnect/reconnect cycles:
tail -50 ~/.vscode-server/data/logs/$(ls -t ~/.vscode-server/data/logs/ | head -1)/remoteagent.log[ManagementConnection] The client has disconnected, will wait for reconnection 3h... [ManagementConnection] Another client has connected, will shorten the wait for reconnection 5m... [ManagementConnection] The client has reconnected.
Expected Behavior
When docker exec dies on the host side, the node -e proxy process inside the container should detect it has become stale and exit. Proxy count should remain stable with two windows connected.
Actual Behavior
Proxy processes accumulate indefinitely. Each orphaned proxy consumes CPU. Observed:
- 100+ proxy processes, all with PPID 0
- Load average 50+ on a 10-core machine
- Window activation takes 30-60 seconds
Why Proxies Don't Exit
The proxy script spawned by docker exec follows this pattern:
const net = require('net');
process.stdin.pause();
const client = net.createConnection({ host: '127.0.0.1', port: PORT }, () => {
client.pipe(process.stdout);
process.stdin.pipe(client);
});
process.stdin.on('close', () => process.exit(0));
When docker exec dies on the host, one might expect the stdin pipe to deliver EOF and the
stdout pipe to return EPIPE. Neither happens, because containerd-shim inherits all pipe
file descriptors and holds them open for the lifetime of the exec session:
| fd | Direction | containerd-shim holds | Effect |
|---|---|---|---|
| 0 (stdin) | host -> container | Write end open, never writes | No EOF delivered |
| 1 (stdout) | container -> host | Read end open, drains buffer | No EPIPE on write |
| 2 (stderr) | container -> host | Read end open, drains buffer | No EPIPE on write |
Confirmed via strace: orphaned proxies continuously read(18, ...) from the TCP socket
and write(1, ...) to stdout — and the writes succeed (return byte count, not EPIPE). The
TCP connection stays alive because the VS Code server keeps sending protocol data to all
connected proxies.
This means process.stdin.pause() / .resume(), process.stdout.on('error'), and
process.stdin.on('close') are all ineffective — the pipes never break.
Why process.stdin.resume() alone doesn't fix it
Adding process.stdin.resume() inside the connection callback (as previously proposed) does
not help because containerd-shim holds the write end of stdin open. With the write end open,
the kernel treats the pipe as healthy — no EOF, no POLLHUP. The resume() call re-arms the
epoll watcher, but epoll never returns a readable event because there is no data and no
hangup.
Proposed Fix
The distinguishing signal between active and stale proxies is stdin data flow:
- Active proxies receive IDE data (handshake, protocol messages, keepalives) within
seconds of TCP connection. Verified viastrace: active proxies show continuous
read(0, ...)syscalls returning 1-2KB of data. - Stale proxies never receive any stdin data. Verified via
FIONREADioctl: stdin
buffer is always 0 bytes. Containerd-shim holds the pipe open but never writes.
This holds for all connection types — management, extension host, and terminal connections
all receive initial IDE data (handshake, resize events, configuration) within seconds.
Add a stdin data watchdog to the proxy script:
process.stdout.on('error', () => process.exit(0));
process.stdout.on('close', () => process.exit(0));
const net = require('net');
const fs = require('fs');
process.stdin.pause();
let stdinReceivedData = false;
let connectionTime = 0;
process.stdin.on('data', () => { stdinReceivedData = true; });
const client = net.createConnection({ host: '127.0.0.1', port: PORT }, () => {
connectionTime = Date.now();
process.stdin.resume();
console.error('Connection established');
client.pipe(process.stdout);
process.stdin.pipe(client);
});
// Watchdog: exit if no stdin data arrives within 30s of TCP connection.
// Active proxies always receive IDE handshake data immediately.
// Stale proxies never receive stdin data (containerd-shim holds pipe
// open but never writes).
setInterval(() => {
if (connectionTime > 0 && !stdinReceivedData && Date.now() - connectionTime > 30000) {
console.error('No stdin data after 30s, exiting as stale');
process.exit(0);
}
}, 10000);
client.on('close', function (hadError) {
console.error(hadError ? 'Remote close with error' : 'Remote close');
process.exit(hadError ? 1 : 0);
});
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`);
});
The stdout handlers and stdin close handler are kept as belt-and-suspenders for environments
where containerd-shim does close pipe fds (non-Docker runtimes, future Docker versions).
Tested Workaround
The fix can be applied locally by running patch-devcontainers-proxy.sh, which patches the
proxy script in the extension bundle. See tunnel-patch.md for details. The script is
idempotent and safe to re-run after extension updates.
After patching and reloading, stale proxies exit within 30-40 seconds of spawning. Proxy
count stabilizes at 2-5 with two windows connected.
Does this issue occur locally?
No. This is specific to Dev Containers where the proxy mechanism uses docker exec.
Does this issue occur with all extensions disabled?
Not applicable — the bug is in the Dev Containers extension's proxy script.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.