Skip to content

BUG: Unix Socket pending connections receive EOF instead of ECONNRESET when listener closes #12576

@tanyifeng

Description

@tanyifeng

Description

When a Unix socket listener is closed while there are pending connections (connections that have been established but not yet accepted), gVisor returns EOF (0 bytes read) instead of ECONNRESET error, which differs from Linux kernel behavior.

Expected behavior (Linux kernel):

  • read() returns ECONNRESET error (errno 104)
  • getsockopt(SO_ERROR) returns 104 (ECONNRESET)
  • epoll reports EPOLLHUP | EPOLLERR events

Observed behavior (gVisor):

  • read() returns 0 (EOF)
  • getsockopt(SO_ERROR) returns 0 (no error)
  • epoll reports only EPOLLHUP (missing EPOLLERR)

This causes issues with async frameworks like tokio that rely on these semantics to detect connection failures.

Related issue: tokio-rs/tokio#3879

Analysis

The issue is in pkg/sentry/socket/unix/transport/connectioned.go. When a listener closes:

  1. Linux kernel behavior: Sends RST to pending connections, setting SO_ERROR to ECONNRESET and triggering EPOLLERR
  2. gVisor behavior: Calls Close() on pending connections which sends FIN (graceful close), resulting in EOF

Relevant code

In connectionedEndpoint.Close():

if acceptedChan != nil {
    for n := range acceptedChan {
        n.Close(ctx)  // This does graceful close (EOF), not reset (ECONNRESET)
    }
}

Potential fix

When closing pending connections, gVisor should:

  1. Set SO_ERROR to ECONNRESET on the client endpoint
  2. Notify EventErr so epoll reports EPOLLERR
  3. Make read() return ECONNRESET (first call) following Linux's read-once-and-clear semantics

Steps to reproduce

Option 1: Using tokio test suite

# Create Dockerfile
cat > Dockerfile << 'EOF'
FROM rust:1.84-bookworm
WORKDIR /testbed
RUN git clone --depth 1 https://github.com/tokio-rs/tokio.git . && \
    cargo test --test uds_stream --no-run || true
EOF

# Build image
docker build -t tokio-uds-test .

# Test with runc (passes)
docker run --rm --runtime=runc tokio-uds-test cargo test --test uds_stream epollhup

# Test with gVisor (fails)
docker run --rm --runtime=runsc tokio-uds-test cargo test --test uds_stream epollhup

Expected output (runc):

running 1 test
test epollhup ... ok

Actual output (gVisor):

running 1 test
test epollhup ... FAILED

---- epollhup stdout ----
thread 'epollhup' panicked at tokio/tests/uds_stream.rs:412:29:
called `Result::unwrap_err()` on an `Ok` value: PollEvented { io: Some(UnixStream { ... }) }

Option 2: Minimal C reproduction

cat > /tmp/test_uds.c << 'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <sys/epoll.h>
#include <errno.h>

#define SOCK_PATH "/tmp/test_uds.sock"

int main() {
    unlink(SOCK_PATH);
    
    // Create listener
    int listener = socket(AF_UNIX, SOCK_STREAM, 0);
    struct sockaddr_un addr = {.sun_family = AF_UNIX};
    strcpy(addr.sun_path, SOCK_PATH);
    bind(listener, (struct sockaddr*)&addr, sizeof(addr));
    listen(listener, 5);
    
    // Create client and connect
    int client = socket(AF_UNIX, SOCK_STREAM, 0);
    connect(client, (struct sockaddr*)&addr, sizeof(addr));
    
    // Close listener while connection is pending (not accepted)
    close(listener);
    
    // Check epoll events
    int epfd = epoll_create1(0);
    struct epoll_event ev = {.events = EPOLLIN|EPOLLOUT|EPOLLERR|EPOLLHUP, .data.fd = client};
    epoll_ctl(epfd, EPOLL_CTL_ADD, client, &ev);
    struct epoll_event events[1];
    epoll_wait(epfd, events, 1, 100);
    
    printf("epoll events: 0x%x\n", events[0].events);
    printf("  EPOLLIN:  %s\n", (events[0].events & EPOLLIN)  ? "yes" : "no");
    printf("  EPOLLOUT: %s\n", (events[0].events & EPOLLOUT) ? "yes" : "no");
    printf("  EPOLLERR: %s\n", (events[0].events & EPOLLERR) ? "yes" : "no");  // Missing in gVisor!
    printf("  EPOLLHUP: %s\n", (events[0].events & EPOLLHUP) ? "yes" : "no");
    
    // First read - should return ECONNRESET error
    char buf[10];
    ssize_t n = read(client, buf, sizeof(buf));
    if (n < 0) {
        printf("read #1: error %d (%s)\n", errno, errno == 104 ? "ECONNRESET - correct" : strerror(errno));
    } else {
        printf("read #1: %zd bytes (EOF) - WRONG, should be ECONNRESET\n", n);
    }
    
    // Second read - should return EOF (error already consumed by first read)
    n = read(client, buf, sizeof(buf));
    if (n < 0) {
        printf("read #2: error %d (%s)\n", errno, strerror(errno));
    } else if (n == 0) {
        printf("read #2: EOF - correct (error consumed)\n");
    } else {
        printf("read #2: %zd bytes - unexpected\n", n);
    }
    
    close(client);
    close(epfd);
    unlink(SOCK_PATH);
    return 0;
}
EOF

# Compile
gcc -o /tmp/test_uds /tmp/test_uds.c

# Run in container
docker run --rm -v /tmp/test_uds:/test_uds --runtime=runc ubuntu:22.04 /test_uds
docker run --rm -v /tmp/test_uds:/test_uds --runtime=runsc ubuntu:22.04 /test_uds

runc output (correct):

epoll events: 0x1d
  EPOLLIN:  yes
  EPOLLOUT: yes
  EPOLLERR: yes
  EPOLLHUP: yes
read #1: error 104 (ECONNRESET - correct)
read #2: EOF - correct (error consumed)

gVisor output (incorrect):

epoll events: 0x15
  EPOLLIN:  yes
  EPOLLOUT: yes
  EPOLLERR: no    <-- Missing!
  EPOLLHUP: yes
read #1: 0 bytes (EOF) - WRONG, should be ECONNRESET
read #2: EOF - correct (error consumed)

runsc version

runsc version release-20260126.0
spec: 1.1.0-rc.1

docker version (if using docker)

Docker version 26.1.3, build b72abbb

uname

Linux 6.8.0-52-generic #53-Ubuntu SMP PREEMPT_DYNAMIC x86_64 GNU/Linux

kubectl (if using Kubernetes)

repo state (if built from source)

No response

runsc debug logs (if available)

Metadata

Metadata

Assignees

No one assigned

    Labels

    type: bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions