Repository navigation
Egress gateway leaves port/pid files after SIGINT to a background uv run process group #17
Description
What's broken
On Linux, sending SIGINT to a Bash-backgrounded uv run ... egress-gateway process group can leave explicitly requested port/pid files behind, even though the gateway exits with status 0 and its workers and listener are released.
This report is limited to the supplied background-launch reproduction. Real terminal Ctrl+C tests passed; it is not a claim that ordinary interactive Ctrl+C always fails.
Which layer
tools/egress-gateway/src/egress_gateway/cli.py, at its integration with uv and proxy.py shutdown. The native proxy command reproduces the same result, so this is not specific to the gateway wrapper.
How to reproduce
At the time of testing, upstream main (27268f28a253978137e0cfa17bc19ef6e8897c28) still has the separate startup failure. These tests use that base plus the already-reviewed startup patch; the four resulting files match commit 44ca53aa6e9610af5ed9bb5d707a996dd481ba15. No signal-handling fix was applied.
Use that runnable source and install its locked environment:
git clone https://github.com/MakerFlyFly/magpie.git
cd magpie
git checkout --detach 44ca53aa6e9610af5ed9bb5d707a996dd481ba15
uv sync --frozen --project tools/egress-gatewaySave the following as repro.sh, then run bash repro.sh from the repository root. It makes a denied request to confirm that the proxy is serving before delivering SIGINT. The script was also checked with bash repro.sh module and bash repro.sh proxy.
Standalone Bash reproduction
#!/bin/bash
# SPDX-License-Identifier: Apache-2.0
# Run with: bash repro.sh [console|module|proxy], from the repository root.
set -u
project=tools/egress-gateway
state=$(mktemp -d)
case ${1:-console} in
console) command=(egress-gateway) ;;
module) command=(python -m egress_gateway.cli) ;;
proxy) command=(proxy --hostname 127.0.0.1 --plugins egress_gateway.allowlist.EgressAllowlistPlugin) ;;
*) exit 2 ;;
esac
setsid uv run --frozen --project "$project" "${command[@]}" \
--port 0 --port-file "$state/port" --pid-file "$state/pid" \
--log-level DEBUG >"$state/log" 2>&1 &
leader=$!
for _ in $(seq 1 120); do
[ -s "$state/port" ] && [ -s "$state/pid" ] && break
sleep 0.25
done
gateway=$(cat "$state/pid")
pgid=$(ps -o pgid= -p "$leader" | tr -d ' ')
[ "$pgid" = "$leader" ] || exit 1
python - "$state/port" <<'PY'
import http.client, pathlib, sys, time
port = int(pathlib.Path(sys.argv[1]).read_text())
deadline = time.monotonic() + 20
while True:
connection = http.client.HTTPConnection("127.0.0.1", port, timeout=1)
try:
connection.request("GET", "http://example.invalid/readiness")
response = connection.getresponse()
if response.status == 403 and b"host not on allowlist" in response.read():
break
except (OSError, http.client.HTTPException):
pass
finally:
connection.close()
if time.monotonic() >= deadline:
raise SystemExit("No successful proxy response")
time.sleep(0.1)
PY
[ "$?" = 0 ] || { kill -TERM "$gateway"; wait "$leader"; exit 1; }
sleep 1.5
kill -INT -- "-$pgid"
wait "$leader"
rc=$?
printf 'exit=%s handler_calls=%s port_file=%s pid_file=%s\n' "$rc" \
"$(grep -c "pid:$gateway .*Received signal 2" "$state/log")" \
"$([ -e "$state/port" ] && echo left || echo removed)" \
"$([ -e "$state/pid" ] && echo left || echo removed)"Observed with each entrypoint:
exit=0 handler_calls=2 port_file=left pid_file=left
Expected vs actual
Expected: a normal shutdown removes the requested state files, as direct execution and single-process signal delivery do in the controls.
Actual: the affected runs report success while retaining both files. The successful-response background reproductions were 3/3 for each of the console entrypoint, module entrypoint, and native proxy command. Runs without prior proxy traffic also reproduced 3/3 for each.
Separate diagnostic runs found the first SIGINT in sleep_loop and the second inside Proxy.shutdown() while AcceptorPool.shutdown() waits in multiprocessing.Process.join(). The second handler raises SystemExit(0) before normal shutdown reaches file deletion. Those instrumented runs are separate from the unchanged-handler reproduction above.
Surface area (optional)
| Launch and signal delivery | Result |
|---|---|
| Bash background + setsid; uv console/module/native proxy; SIGINT to group | Both files retained, 6/6 per entrypoint across the two traffic conditions |
| Same Bash launch; SIGINT only to uv or only to gateway | Both files removed, 3/3 for each target |
| Bash background; direct gateway or direct native proxy; group SIGINT | Both files removed, 3/3 per command |
Python start_new_session launcher, non-TTY |
Both files removed, 84/84 |
| Real PTY, Ctrl+C byte, foreground process group verified | Both files removed, 36/36 |
All 150 matrix/control runs exited 0 and released workers and listeners. The exact reason the Bash-background and Python-launcher cases differ has not been established. The demonstrated impact is stale explicitly requested state files; no higher-impact process-management consequence has been verified. Default invocations do not request these files.
Environment (optional)
- Linux containers on WSL2 kernel 6.6.87.2; Docker Engine 29.8.1.
- CPython 3.12.12; uv 0.11.14; locked proxy.py 2.4.10.
- Image:
ghcr.io/astral-sh/uv@sha256:85d4cb1afa769a7338e095b927bee941cf5ec92266c7424b3f6c0f2748567248. - Linux-native Git checkout, preserving all 490 tracked symlinks and file modes. Default concurrency and a one-worker control were tested separately.
Prepared with assistance from Codex (GPT-6). This fork issue is for contributor review before any upstream submission.
Activity