Skip to content

envd port forwarder never re-forwards a port when the same process re-listens on it (stale ports map entry; PR #3514 was closed unmerged) #3796

Open

Description

@ii-au

Summary

If a process listening on 127.0.0.1:<port> closes that listener and then
listens again from the same PID, the envd port forwarder kills the socat
but never starts a new one. The port then stays unreachable through the
sandbox proxy (The sandbox is running but port is not open, HTTP 502) for as
long as that process lives. This only happens when at least one port scan
runs while the port is closed (gap > ~1s).

This is exactly the root cause described in #3514 ("re-forward a sandbox port
that drops out of a single scan"). That PR was closed without merging on
2026-08-04, and current main (envd 0.10.2,
packages/envd/internal/port/forward.go) still has the behaviour. We couldn't
find an open issue tracking it, so we're opening one to ask whether #3514 was
superseded or can be revived.

Root cause (current main)

In Forwarder.StartForwarding, entries are keyed by "<pid>-<port>". Entries
missing from a scan go through stopPortForwarding, which kills the socat
process group and clears socat/socatPid, but the entry is never removed
from f.ports
. When the same PID listens on the port again, the key is found
(portOk == true), the state flips back to FORWARD, and
startPortForwarding is never called.

If the listener comes back with a new PID, a new key is created and a
socat is spawned. So the problem is specific to same-PID re-listens, such as
in-process server restarts, hot reloads, and frameworks that rebind a socket.

Reproduction (official E2B, default code-interpreter template, envd 0.10.0)

Inside a sandbox:

# same_pid_relisten.py
import socket, subprocess, time
PORT = 3000
def listen():
    s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(("127.0.0.1", PORT)); s.listen(); return s
def forwarded():
    out = subprocess.run(["sudo", "-n", "ss", "-ltnp"], capture_output=True, text=True).stdout
    return any(f"169.254.0.21:{PORT} " in l for l in out.splitlines())
s = listen(); time.sleep(3); print("1. listening, forwarder:", forwarded())
s.close(); time.sleep(3); print("2. closed for 3s, forwarder:", forwarded())
s = listen(); time.sleep(5); print("3. same pid listening again, forwarder:", forwarded())

Output:

1. listening, forwarder: True
2. closed for 3s, forwarder: False
3. same pid listening again, forwarder: False   <-- expected True

Additional runs (Node HTTP server, same process re-listening):

Case Result
Same PID, 3s gap, IPv4 only not re-forwarded (2/2)
Same PID, 3s gap, dual-stack 127.0.0.1 + ::1 not re-forwarded (1/1)
Same PID, 100ms gap (no scan in between) still forwarded
New PID on the same port, fast or slow restart re-forwarded (6/6)

While the stuck process kept listening, 127.0.0.1:<port> returned 200 inside
the sandbox, but 169.254.0.21:<port> refused connections and the public host
returned 502 The sandbox is running but port is not open.

Real-world impact

We hit this in our development environment, in sandboxes running a Node gateway. Without a
supervisor, that gateway does an in-process restart (same PID) on SIGUSR1.
After one such restart, the gateway's public URL returned 502 permanently,
while the service was healthy on loopback, until the process was replaced.

Expected

A port that is listening on localhost is forwarded again after it reappears,
whatever PID owns it. The fix in #3514 (delete stopped entries from the map,
and run the stop pass before starting new forwards so a fresh socat doesn't
race a dying one for the bind address) looks right to us.

Environment

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions