Skip to content

client: actually terminate the tunnel-encap Envoy subprocess#1561

Merged
eric846 merged 1 commit into
mainfrom
encap-subprocess-termination
Jul 9, 2026
Merged

client: actually terminate the tunnel-encap Envoy subprocess#1561
eric846 merged 1 commit into
mainfrom
encap-subprocess-termination

Conversation

@oschaaf

@oschaaf oschaaf commented Jul 9, 2026

Copy link
Copy Markdown
Member

Found during an adversarial review of the shutdown-sequence fixes (a -Wshadow class of bug).

EncapsulationSubProcessRunner::RunWithSubprocess() declared a local pid_t pid_ = fork(); shadowing the class member, so the member stayed -1 and TerminateEncapSubProcess() always returned early without signaling the child: the encap Envoy subprocess was never terminated or reaped on shutdown or cancellation, and was orphaned on every tunnel-mode run.

Fix: assign the fork() result to the member. The shadowing bug was also masking a second defect: RunWithSubprocess() ended by calling TerminateEncapSubProcess() right after nighthawk_fn() returns — but nighthawk_fn() only starts execution, so with the member populated this would have torn down the tunnel at the start of the load test. That call is removed; termination is owned by the existing call sites that now actually work: ProcessImpl::shutdown(), requestExecutionCancellation(), and the runner's destructor as a last resort.

Testing: TestWithEncapsulation (forks a real encap Envoy) behaves identically to main on the machine used for verification, including an environment-specific IPv4 flake that reproduces at the same rate (1-in-3) on unmodified main.

Open design question for review: ProcessImpl::shutdown() terminates the encap subprocess before draining the workers, so tunnel-mode drains will now see connection resets rather than graceful completions. Arguably fine for shutdown, but moving termination after the worker joins would preserve graceful drains; left untouched here to keep this PR minimal.

Update (asan CI failure on the first revision): test_remote_execution.py failed under asan — the first execution succeeded, then the service stopped answering. Root cause analysis: RunWithSubprocess() forks on every execution (pre-existing, tunnel or not), and the child exited via exit(0) — full C++ exit whose atexit/sanitizer hooks can deadlock in a fork of the multithreaded gRPC service (only the forking thread survives a fork). Before this PR nobody waitpid()ed, so a wedged child leaked invisibly; with reaping in place it wedged the service's shutdown. Two hardening changes: the child now exits via _exit(0), and TerminateEncapSubProcess() reaps with a bounded wait (SIGTERM, ~15s WNOHANG polling, then SIGKILL) so a stuck child can never hang the caller.

@oschaaf oschaaf force-pushed the encap-subprocess-termination branch 2 times, most recently from 733ad0d to 2d49aeb Compare July 9, 2026 14:15
EncapsulationSubProcessRunner::RunWithSubprocess() declared a local
pid_t pid_ that shadowed the class member, so the member stayed -1 and
TerminateEncapSubProcess() always returned early without signaling the
child: the encap Envoy subprocess was never terminated or reaped on
shutdown or cancellation, and was orphaned on every tunnel-mode run.

Assign the fork() result to the member instead. The shadowing bug was
also masking a second defect: RunWithSubprocess() ended by calling
TerminateEncapSubProcess() right after nighthawk_fn() returns - but
nighthawk_fn() only starts execution, so with the member populated this
would have torn down the tunnel at the start of the load test. Remove
that call; termination is owned by the existing call sites that now
work: ProcessImpl::shutdown(), requestExecutionCancellation(), and the
runner's destructor as a last resort.

Two hardening changes that reaping made necessary: the forked child now
exits via _exit() instead of exit(), because running atexit/sanitizer
hooks in a fork of a multithreaded parent (e.g. the gRPC service) can
deadlock on locks owned by threads that do not exist after fork - and a
wedged child would now wedge the parent's waitpid() too. And
TerminateEncapSubProcess() reaps with a bounded wait (SIGTERM, ~15s of
WNOHANG polling, then SIGKILL) instead of an unbounded waitpid().

Signed-off-by: Otto van der Schaaf <oschaaf@we-amp.com>
@oschaaf oschaaf force-pushed the encap-subprocess-termination branch from 2d49aeb to 8c78a23 Compare July 9, 2026 14:17
@eric846 eric846 merged commit d955a10 into main Jul 9, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants