Skip to content

bug(sandbox): a loopback listener keeps accepting connections after the workload closes it #4407

Open

Description

@jordanhubbard

Problem

In an OpenShell 0.1.2 sandbox, a TCP listener on 127.0.0.1 that the workload has closed keeps accepting connections. New connects to the port succeed and then sit unaccepted. A port that was only bound, or never used, refuses as expected. This looks like the network broker keeping its own copy of the listening socket it injected.

Reproduction

Inside a sandbox (openshell sandbox exec --name <sb> --no-tty -- python3 -):

import socket

def connects(p):
    c = socket.socket(); c.settimeout(1)
    try:
        c.connect(("127.0.0.1", p)); return True
    except OSError:
        return False
    finally:
        c.close()

s = socket.socket(); s.bind(("127.0.0.1", 0)); p = s.getsockname()[1]; s.close()
print("bound only, closed:", connects(p))         # False (expected)

s = socket.socket(); s.bind(("127.0.0.1", 0)); s.listen(); p = s.getsockname()[1]; s.close()
print("listened, closed:", connects(p))           # True  (expected False)
print("never used:", connects(59999))             # False (expected)

Observed effects

  • After a PostgreSQL server in the sandbox exited, /proc/net/tcp still showed its 127.0.0.1:5432 socket in LISTEN, owned by the sandbox uid, with a pending connection. A new postmaster on 5432 then failed: could not bind IPv4 address "127.0.0.1": Address already in use. No workload process held the socket.
  • Every later openshell sandbox exec child inherited that socket as an open descriptor (fd 19, socket:[…] matching the stale listener's inode). The exec child's parent is pid 1 (openshell-sandbox --bootstrap …), so the supervisor appears to hold the socket without O_CLOEXEC. /proc/1/fd isn't readable from the workload, so I couldn't confirm that directly.
  • Tests that check "a closed listener's port refuses connections" fail inside the sandbox.

Expected

Once the workload closes a listener, the port refuses connections and can be listened on again. Descriptors the supervisor holds aren't inherited by exec'd workload processes.

Environment

Activity

  1. self-assigned this
    on Oct 12, 2026
  2. johntmyers commented on Oct 12, 2026

    @johntmyers
    Collaborator

    📋 triage-agent

    Confirmed the core listener-lifetime bug on the running docker-dev gateway, using openshell sandbox exec in ocsf-network-demo. Sandbox and supervisor both report 0.1.4-dev.35+ge585b4917; workload uid 1000, Linux 7.0.12-linuxkit, aarch64. Python is absent in this image, so I used installed Perl with native socket, bind, listen, close, and connect calls and a bounded connection timeout, preserving the reported three cases:

    bound only, closed: False (Connection refused)
    listened port: 46279
    listened, closed: True
    never used: False (Connection refused)
    

    After close, /proc/net/tcp still showed port 46279 in LISTEN (inode 543994) with a queued connection. A separate listener closed before its exec process exited also remained in LISTEN in the next exec (port 37433, inode 544670). Rebinding the closed listener's port succeeded; I reclaimed the test listeners this way.

    The descriptor-inheritance observation was not reproduced in this test: the subsequent exec had zero descriptors matching inode 544670, despite the stale listener remaining visible. This does not exclude inheritance with other socket creation flags or on the reported 0.1.2 runtime. The exact Python/custom-image reproduction and PostgreSQL restart failure were not tested here.

    Current source supports the retained-descriptor explanation: listen_socket calls listen on retained_preconnect() and records Listening without releasing the broker-owned descriptor. Reclamation remains operation/pressure driven rather than following workload close immediately.

    The core behavior duplicates #4331, which already covers listener close, owner exit, misleading probes, and the successful-rebind workaround. #3526 concerns broker diagnostics rather than listener lifetime. No PR referencing #4331 or #4407 was found. This report provides first-hand context through the PostgreSQL failure and concrete expected behavior.

    Ready for maintainer review: decide whether to consolidate the core lifetime fix under #4331 and retain descriptor inheritance as a separately verified regression case. No acceptance or implementation decision made; existing assignment preserved.

  3. added
    duplicateThis issue or pull request already exists
    area:sandboxSandbox runtime and isolation work
    and removed
    state:triage-neededOpened without agent diagnostics and needs triage
    on Oct 12, 2026
  4. johntmyers commented on Oct 12, 2026

    @johntmyers
    Collaborator

    🏗️ build-plan

    Outcome and scope

    Fix #4407 so closing the last workload reference to a TCP listener, or exiting its owning process, removes the listener immediately. Subsequent connections must fail with ECONNREFUSED, and the port must be reusable without triggering broker garbage collection. Broker-owned descriptors must never leak into exec'd workloads, including when the workload deliberately requests a socket without SOCK_CLOEXEC.

    The listener-lifetime failure reproduces on docker-dev with runtime 0.1.4-dev.35+ge585b4917. Descriptor inheritance was not reproduced with the Perl fixture, so its regression must explicitly exercise socket creation without SOCK_CLOEXEC. #4331 reports the same listener-lifetime bug and should share the regression coverage. No active PR referencing either issue was found. #4407 remains assigned to johntmyers and ready for human review; this direct request authorizes planning only.

    Complexity: medium. Confidence: high in the retained-listener root cause; repeated operations and FD ownership need careful regression coverage.

    Design

    1. In crates/openshell-sandbox/src/network_broker.rs, keep the first listen operation mediated. After native listen succeeds and the local address is recorded, transition the registry entry to Listening and release its broker-owned descriptor before completing the notification. Keep lightweight metadata for subsequent mediation. Failed listen must retain the pre-connect descriptor and its previous state.
    2. Make repeated listen on an established listener execute natively, preserving backlog changes and notification restart behavior. Simply adding release_preconnect() is insufficient: the current handler always dereferences the retained descriptor. Audit every other consumer, especially bind_socket and connect_socket (repeated_connect_outcome currently treats Listening as pre-connect), so operations on a listener preserve appropriate kernel/error behavior without accidentally entering a connection/relay path or returning an artificial missing-descriptor error. If exact error handling requires descriptor access, use a validated, short-lived close-on-exec duplicate, never a permanently retained listener reference. Preserve the existing enforcement of mutable connect addresses and protected control endpoints.
    3. Always create the broker's source sockets with SOCK_CLOEXEC, including the EMFILE retry. Independently preserve the workload's requested close-on-exec flag in metadata and add_fd_and_send. This prevents a source descriptor created without close-on-exec from leaking through a concurrent workload fork/exec before it is released. Do not change intentional inheritance of a workload-owned non-CLOEXEC descriptor.
    4. In crates/openshell-sandbox/src/linux/socket_registry.rs, document and enforce that successful listening, like successful connection, relinquishes the retained source. Align commit_with_state's retention rules with that contract and retain bounded metadata collection. Keep native accept/accept4, loopback confinement, and denied socket options intact.

    Regression coverage

    • Linux broker integration tests through the production workload launcher: listener close and owner exit each remove LISTEN and cause immediate connection refusal before any rebinding or descriptor-pressure collection. Include bound-only and unused-port controls. Test IPv4 and IPv6 where supported.
    • Verify duplicate/fork ownership: closing one workload reference leaves the listener functional; closing the final reference removes it. Verify a live listener still accepts connections, exchanges data, and retains loopback confinement on accepted sockets.
    • Exercise repeated listen with a different backlog, implicit bind through listen, failed listen, and bind/connect attempts on listening sockets. Compare applicable errnos with Linux behavior; do not weaken network enforcement to preserve error behavior.
    • Registry tests: listening entries retain metadata without owning a source FD; failed transitions retain ownership; reclamation remains bounded.
    • FD tests with raw socket creation both with and without SOCK_CLOEXEC: verify the broker source always has FD_CLOEXEC, while the injected workload descriptor follows the requested flag. Start an unrelated exec while the source is still retained and assert that no descriptor with its socket inode leaks. Separately verify intentional inheritance from the owning workload still works.
    • Add a Docker-capable CLI E2E regression, preferably a focused e2e/rust/tests/listener_lifetime.rs fixture registered with the existing E2E harness. Use existing workload tools and ephemeral ports; check close, process exit, subsequent exec visibility, connection refusal, and rebind. Keep the fixture independent of PostgreSQL installation.

    Verification and documentation

    During implementation, run repository formatting and scoped lint/build checks, the Linux openshell-sandbox tests, existing native-accept/confinement coverage, and the relevant Docker E2E lane (mise run e2e:docker, scoped using the existing harness where appropriate). macOS-only compilation cannot validate Linux seccomp behavior: execute the kernel-facing tests in the supported Linux environment. Re-run the three-case reproducer on docker-dev with the newly built runtime and confirm all three report refusal after close. Do not claim success against the pre-fix running image.

    Document the descriptor ownership contract in crates/openshell-sandbox/README.md and update relevant Rust comments. No new CLI, configuration, middleware, or published-doc workflow is needed; this restores ordinary socket semantics inside the runtime. Review related contributor/public skills through the sync-agent-infra maintenance map during implementation and change them only if their instructions become inaccurate.

    Alternatives and risks

    Periodic collection or intercepting every close would introduce delayed cleanup or additional mediation complexity across dup/fork/exit. Releasing the reference after successful listen lets the kernel track the actual workload ownership. Removing listen mediation entirely would require a broader registry/state redesign. Middleware and gateway interceptors cannot repair runtime FD ownership.

    The main risks are repeated-listen regressions, changed errors for operations on a listener, and confusing broker CLOEXEC with workload CLOEXEC. Cover these explicitly. Maintainers may consolidate tracking under #4331; this plan does not close either issue or change acceptance/roadmap state.

    Revision 1 — plan only; implementation and test execution pending.

  5. johntmyers commented on Oct 12, 2026

    @johntmyers
    Collaborator

    🏗️ build-from-issue-agent

    Implemented in #4444 on fix/4407-listener-lifetime/johntmyers (signed-off commit 9520604c8). Successful listening now releases the broker reference, repeated listen remains native, bind/connect on a listener preserve Linux errors, and broker source sockets always use close-on-exec independently of workload flags.

    All four new Linux unit tests, the 38-test focused broker suite, both new Docker CLI E2E tests, scoped lint, and the full pre-commit hook pass. The lifetime E2E test fails on the original docker-dev runtime and passes with the branch-built musl runtime. Broader validation caveats (Docker's AF_ALG errno, existing musl test-only ABI assumptions, and an initial restart failure that passed on rerun) are recorded in the PR.

    The user's existing docker-dev sandbox remains healthy on its original runtime; temporary validation gateways and sandboxes were cleaned up. Updated the crate README. No issue acceptance or roadmap labels changed.

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

Metadata

Metadata

Assignees

Labels

area:sandboxSandbox runtime and isolation workduplicateThis issue or pull request already existsstate:acceptedA maintainer decided OpenShell should pursue this issuestate:review-readyReady for human review

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions