Skip to content

kal_net_close closes the descriptor before retiring the handle, so a file opened at the same moment gets a stale handle and its I/O goes to descriptor 0 #53

Closed

Description

@yspbwx2010

With openkal-llvm-runtime 0.20.1 (openkal-musl 0.29.2 over openkal-linux 0.26.0, x86_64-linux-musl), closing a connected TCP socket on one thread while another thread opens a regular file sometimes gives the new file a handle that is already stale. Reads on that file then return 0 bytes, and writes on it go to descriptor 0.

kal_net_close closes the descriptor and only then retires the handle (net.cpp L247-L252). kal_net_close_listener (L254-L259) and kal_datagram_close (datagram.cpp L138-L143) do the same, while kal_fs_close_file retires first and closes second (fs.cpp L600-L603). Between the close and the retire, another thread's openat can be given the same descriptor number. pack gives the new handle the current generation of that number, and the retire then increments it (handle.h L20-L49), so from that point unpack refuses the new file's handle.

openkal-musl takes the file's stream under its descriptor table lock, after the open (okm_syscall.c L397-L418). A socket is closed and retired under the same lock (okm_fd.c L309-L316), so a file opened in the window always has a stale handle by the time its stream is taken. kal_fs_stream turns a handle that does not unpack into stream 0 (fs.cpp L609-L612), and from then on every read and write on the new C descriptor reaches descriptor 0. kal_fs_close_file skips a handle that does not unpack, so the file's own descriptor is never closed.

The order itself is old; openkal-linux 0.16.1 already has it. For regular files it started to matter with openkal-musl 0.26.0, which opens the file with the table lock released (the comment at okm_syscall.c L397); openkal-musl 0.20.1 held the lock across the open, so a file open and a socket close could not interleave. Sockets were already exposed before that: kal_net_accept and kal_net_connect run outside the lock (okm_net.c L403, L467, the same in 0.20.1), so a connection accepted or connected in the window also gets a stale handle, and kal_net_stream turns that into stream 0 as well (net.cpp L205-L212).

What I saw, on a 4-vCPU x86_64 VM, with a small program in which one thread accepts loopback TCP connections, sends a few bytes on each and closes it, another thread connects and closes its end, and a third opens, reads and closes an 89-byte file:

  • With descriptor 0 on /dev/null, about 150 reads per second returned 0, and fstat and lseek on those descriptors failed. With a listener but no connections there were none in about 950,000 reads.
  • With descriptor 0 opened on a writable file, records that a fourth thread appended to another file ended up in the descriptor 0 file, and so did bytes the accepting thread sent on accepted sockets (the accept case above).
  • Each stale file leaks its descriptor. With a limit of 1024 the process ran out within about 5 seconds, after which open failed with EIO rather than EMFILE.
  • With descriptor 0 on a pipe, the read blocked instead of returning.

A socket that is created and closed without connecting does not trigger it: in openkal-musl, socket makes no kernel descriptor, so its close never reaches kal_net_close.

The loops, reduced:

/* thread A, repeated: accept a connection on a loopback listener, send a few bytes, close it */
/* thread B, repeated: connect a TCP socket to that listener, close it */
/* thread C, repeated: */
int fd = open("data.txt", O_RDONLY);
ssize_t n = read(fd, buf, sizeof buf);   /* sometimes 0, although data.txt is not empty */
close(fd);

I can attach the full program if that helps.

Retiring before closing in the three socket close functions, as kal_fs_close_file does, should close the window for files and for accepted and connected sockets alike. The generation array is also read and incremented without atomics, which remains a data race between threads after the reordering. Separately, it may be worth having kal_fs_stream and kal_net_stream refuse a handle that does not unpack instead of mapping it to stream 0: stream 0 is the standard input, so a mistake there goes unnoticed.

Activity

  1. speak-agent commented on Nov 12, 2026

    @speak-agent
    Contributor

    Confirmed against main and reproduced: the three closes of openkal.net and openkal.datagram closed the descriptor and retired the handle afterwards, as you describe.

    Fixed in openkal-linux 0.26.1 (#54):

    • Every release is one function that retires and then closes; the two closes of openkal.fs call it too.
    • Your second point, the generation: it is advanced by a compare-and-exchange from the value the handle carries and read through the atomic operations. One release of a handle closes; a second release of the same word, from any thread, closes nothing.
    • Your third point, stream 0: kal_fs_stream and kal_net_stream answer a handle that names no live descriptor with a word no descriptor has, and a transfer upon it reports kal_err_invalid.
    • tests/a_released_handle_is_stale_before_its_number_is_free.cpp is your scenario (one thread accepts and closes, one connects and closes, one opens, reads and closes a file, for three seconds). Without the change it finds 94 files of 3.4 million whose read was not the file; with it, none.

    The same order was in openkal-macos and openkal-windows; they are fixed in 0.23.1 and in 0.21.1 / 0.22.1.

    Not changed: open failing with EIO and not EMFILE once the process has no descriptor left. openkal's conditions are a closed set and none of them names the exhaustion of handles, so the kernel's EMFILE is reported as kal_err_io; the exhaustion in your run was the leak of the stale files, which is gone.

    Above the C library this reaches a program through openkal-musl 0.29.4, which names these versions, and through openkal-platform 0.27.1, the version group. mcpp selects that platform from 2026.11.12.1, released today (xlings install mcpp@2026.11.12.1); with an earlier mcpp, [target.<selector>] platform = "openkal-platform@0.27.1" selects it.

    Thank you for the report; the analysis of where the window opened for regular files (the table lock released across the open since openkal-musl 0.26.0) was exact.

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