Skip to content

Commit d7ab361

Browse files
authored
Report POLLHUP/POLLERR readiness for pipe and socket hangups (emscripten-core#27298)
Makes poll/epoll surface the hangup and error conditions that Linux does when the far end of a pipe or a connecting socket goes away. Without these edges a reader (or writer) blocked in poll/epoll (e.g. mio's `is_write_closed()`/`is_error()`) never wakes and hangs. Pipe (`libpipefs.js`): on Linux a `pipe(2)` signals both directions of hangup, which PIPEFS never modelled — `poll()` returned readiness only while unread bytes remained and `close()` just decremented a shared refcount without waking anyone. * Once every write end is closed, poll on the read end reports `POLLHUP|POLLIN` (read returns EOF). We track open write ends via a `writerCount` (incremented in `dup`); when the last one closes we set `writeClosed`, `notifyListeners(POLLHUP | POLLRDNORM | POLLIN)` on the read end's wait-queue, and OR `POLLHUP|POLLIN` into `poll()`. * Symmetrically, once every read end is closed, poll on the write end reports `POLLERR` (a further write would get `EPIPE`), while staying writable per Linux. Mirrored via `readerCount`/`readClosed` and a `pipe.writeNode` wait-queue. The old `refcnt` field is dropped — it always equalled `readerCount + writerCount`, so buckets are freed once both reach 0. Socket (`libsockfs_node.js`): a refused connect resolves with `sock.error = ECONNREFUSED`, but `poll()` only reported `POLLOUT`, so `is_write_closed()` and `is_error()` were both false under NODERAWSOCKETS. We now report `POLLOUT | POLLERR | POLLHUP` on a pending error, matching Linux's connect-failure readiness. `POLLOUT|POLLERR` satisfies epoll's `is_write_closed()` mapping. The `SO_ERROR` read-and-clear semantics are untouched. Before (pipe read end after writer close): `poll()` returned `0` once the buffer drained. After: `poll()` returns `POLLHUP | POLLIN`. Before (refused connect): `poll()` revents `0x11` (`POLLIN|POLLHUP` from the CLOSED transition, no `POLLERR`). After: `0x1d` (`POLLIN|POLLOUT|POLLERR|POLLHUP`). Test coverage: * `test/core/test_pipe_pollhup.c` — writes, closes the writer, and asserts `POLLHUP|POLLIN` both while data is buffered and after draining to EOF; also closes the reader and asserts the write end reports `POLLOUT|POLLERR`. * Extends `test/sockets/test_tcp_refused.c` to `poll()` for `POLLERR|POLLHUP` *before* reading (and thereby clearing) `SO_ERROR`. _Made with AI assistance under my review_
1 parent b5499ef commit d7ab361

6 files changed

Lines changed: 146 additions & 15 deletions

File tree

src/lib/libpipefs.js

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,15 @@ addToLibrary({
1717
createPipe() {
1818
var pipe = {
1919
buckets: [],
20-
// refcnt 2 because pipe has a read end and a write end. We need to be
21-
// able to read from the read end after write end is closed.
22-
refcnt : 2,
20+
// Open write ends. When it drops to 0 the reader sees EOF and poll must
21+
// report POLLHUP (Linux semantics). Buckets are freed once both counts
22+
// reach 0.
23+
writerCount: 1,
24+
writeClosed: false,
25+
// Open read ends. When it drops to 0 the writer sees POLLERR (a further
26+
// write would get EPIPE).
27+
readerCount: 1,
28+
readClosed: false,
2329
timestamp: new Date(),
2430
};
2531

@@ -36,8 +42,10 @@ addToLibrary({
3642

3743
rNode.pipe = pipe;
3844
wNode.pipe = pipe;
39-
// The read end's node carries the poll wait-queue; writes wake it.
45+
// The read end's node carries the reader poll wait-queue (writes wake it);
46+
// the write end's node carries the writer wait-queue (read-end close wakes it).
4047
pipe.readNode = rNode;
48+
pipe.writeNode = wNode;
4149

4250
var readableStream = FS.createStream({
4351
path: rName,
@@ -86,17 +94,35 @@ addToLibrary({
8694
var pipe = stream.node.pipe;
8795

8896
if ((stream.flags & {{{ cDefs.O_ACCMODE }}}) === {{{ cDefs.O_WRONLY }}}) {
89-
return ({{{ cDefs.POLLWRNORM }}} | {{{ cDefs.POLLOUT }}});
97+
// Linux keeps the write end writable (the write itself fails with
98+
// EPIPE) while also signalling POLLERR once every read end is closed.
99+
var mask = {{{ cDefs.POLLWRNORM }}} | {{{ cDefs.POLLOUT }}};
100+
if (pipe.readClosed) {
101+
mask |= {{{ cDefs.POLLERR }}};
102+
}
103+
return mask;
90104
}
105+
var mask = 0;
91106
for (var bucket of pipe.buckets) {
92107
if (bucket.offset - bucket.roffset > 0) {
93-
return ({{{ cDefs.POLLRDNORM }}} | {{{ cDefs.POLLIN }}});
108+
mask = {{{ cDefs.POLLRDNORM }}} | {{{ cDefs.POLLIN }}};
109+
break;
94110
}
95111
}
96-
return 0;
112+
// With every write end closed the read end is at EOF: readable (read
113+
// returns 0) and hung up.
114+
if (pipe.writeClosed) {
115+
mask |= {{{ cDefs.POLLHUP }}} | {{{ cDefs.POLLIN }}};
116+
}
117+
return mask;
97118
},
98119
dup(stream) {
99-
stream.node.pipe.refcnt++;
120+
var pipe = stream.node.pipe;
121+
if ((stream.flags & {{{ cDefs.O_ACCMODE }}}) === {{{ cDefs.O_WRONLY }}}) {
122+
pipe.writerCount++;
123+
} else {
124+
pipe.readerCount++;
125+
}
100126
},
101127
ioctl(stream, request, argp) {
102128
if (request == {{{ cDefs.FIONREAD }}}) {
@@ -251,8 +277,20 @@ addToLibrary({
251277
},
252278
close(stream) {
253279
var pipe = stream.node.pipe;
254-
pipe.refcnt--;
255-
if (pipe.refcnt === 0) {
280+
// When the last write end closes, wake any poll/epoll waiter on the read
281+
// end with POLLHUP so a reader blocked on the writer dropping unblocks.
282+
if ((stream.flags & {{{ cDefs.O_ACCMODE }}}) === {{{ cDefs.O_WRONLY }}}) {
283+
if (--pipe.writerCount === 0) {
284+
pipe.writeClosed = true;
285+
pipe.readNode.notifyListeners({{{ cDefs.POLLHUP }}} | {{{ cDefs.POLLRDNORM }}} | {{{ cDefs.POLLIN }}});
286+
}
287+
} else if (--pipe.readerCount === 0) {
288+
// Mirror: when the last read end closes, wake any poll/epoll waiter on
289+
// the write end with POLLERR (a further write would get EPIPE).
290+
pipe.readClosed = true;
291+
pipe.writeNode.notifyListeners({{{ cDefs.POLLERR }}} | {{{ cDefs.POLLWRNORM }}} | {{{ cDefs.POLLOUT }}});
292+
}
293+
if (pipe.readerCount === 0 && pipe.writerCount === 0) {
256294
pipe.buckets = null;
257295
}
258296
}

src/lib/libsockfs_node.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -336,8 +336,10 @@ var NodeSockFSLibrary = {
336336
mask |= ({{{ cDefs.POLLRDNORM }}} | {{{ cDefs.POLLIN }}});
337337
}
338338
if (sock.error) {
339-
// Mark writable on error so SO_ERROR can be read.
340-
mask |= {{{ cDefs.POLLOUT }}};
339+
// A pending socket error (e.g. a refused connect) is Linux's
340+
// POLLERR|POLLHUP, plus writable so SO_ERROR can be read. POLLOUT|POLLERR
341+
// also satisfies epoll's is_write_closed() mapping.
342+
mask |= {{{ cDefs.POLLOUT }}} | {{{ cDefs.POLLERR }}} | {{{ cDefs.POLLHUP }}};
341343
} else if (sock.connection && sock.state === {{{ SOCK_STATE_CONNECTED }}} && !sock.writeBlocked) {
342344
mask |= {{{ cDefs.POLLOUT }}};
343345
}

test/codesize/test_codesize_hello_dylink_all.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
2-
"a.out.js": 268425,
2+
"a.out.js": 268621,
33
"a.out.nodebug.wasm": 587978,
4-
"total": 856403,
4+
"total": 856599,
55
"sent": [
66
"IMG_Init",
77
"IMG_Load",

test/core/test_pipe_pollhup.c

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright 2025 The Emscripten Authors. All rights reserved.
3+
* Emscripten is available under two separate licenses, the MIT license and the
4+
* University of Illinois/NCSA Open Source License. Both these licenses can be
5+
* found in the LICENSE file.
6+
*/
7+
8+
// Once every write end of a pipe is closed, poll on the read end must report
9+
// POLLHUP (and POLLIN, since read returns EOF), matching Linux. Symmetrically,
10+
// once every read end is closed, poll on the write end must report POLLERR
11+
// (while staying writable, the write itself failing with EPIPE).
12+
13+
#include <poll.h>
14+
#include <assert.h>
15+
#include <stdio.h>
16+
#include <unistd.h>
17+
#include <string.h>
18+
19+
int poll_events(int fd, short events) {
20+
struct pollfd pfd = {fd, events, 0};
21+
assert(poll(&pfd, 1, 0) >= 0);
22+
return pfd.revents;
23+
}
24+
25+
int poll_read(int fd) {
26+
return poll_events(fd, POLLIN);
27+
}
28+
29+
int poll_write(int fd) {
30+
return poll_events(fd, POLLOUT);
31+
}
32+
33+
int main() {
34+
const char *t = "test\n";
35+
int p[2];
36+
37+
assert(pipe(p) == 0);
38+
39+
// Nothing written yet, writer still open: not readable, no hangup.
40+
assert(poll_read(p[0]) == 0);
41+
42+
// Both ends open: write end is writable, no error.
43+
int wevents = poll_write(p[1]);
44+
assert(wevents & POLLOUT);
45+
assert(!(wevents & POLLERR));
46+
47+
write(p[1], t, strlen(t));
48+
49+
// Data pending, writer still open: readable, no hangup.
50+
int revents = poll_read(p[0]);
51+
assert(revents & POLLIN);
52+
assert(!(revents & POLLHUP));
53+
54+
// Close the write end. Data is still buffered, so POLLIN and POLLHUP.
55+
close(p[1]);
56+
revents = poll_read(p[0]);
57+
assert(revents & POLLIN);
58+
assert(revents & POLLHUP);
59+
60+
// Drain the buffer. Read end at EOF with writer gone: POLLIN and POLLHUP.
61+
char buf[16];
62+
assert(read(p[0], buf, sizeof(buf)) == (ssize_t)strlen(t));
63+
revents = poll_read(p[0]);
64+
assert(revents & POLLIN);
65+
assert(revents & POLLHUP);
66+
67+
close(p[0]);
68+
69+
// Mirror case: close the read end and poll the write end for POLLERR.
70+
assert(pipe(p) == 0);
71+
close(p[0]);
72+
wevents = poll_write(p[1]);
73+
// Still writable per Linux, but now signalling an error.
74+
assert(wevents & POLLOUT);
75+
assert(wevents & POLLERR);
76+
close(p[1]);
77+
78+
printf("done\n");
79+
return 0;
80+
}

test/sockets/test_tcp_refused.c

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
#include <errno.h>
1414
#include <fcntl.h>
1515
#include <netinet/in.h>
16+
#include <poll.h>
1617
#include <stdio.h>
1718
#include <stdlib.h>
1819
#include <string.h>
@@ -45,10 +46,17 @@ void main_loop(void) {
4546
select(64, NULL, &fdw, NULL, &tv);
4647

4748
if (FD_ISSET(fd, &fdw)) {
49+
// Poll before reading SO_ERROR (which clears the pending error). A refused
50+
// connect is Linux POLLERR|POLLHUP (plus writable).
51+
struct pollfd pfd = {fd, POLLIN | POLLOUT, 0};
52+
assert(poll(&pfd, 1, 0) == 1);
53+
printf("poll revents 0x%x\n", pfd.revents);
54+
assert(pfd.revents & POLLERR);
55+
assert(pfd.revents & POLLHUP);
56+
4857
int err = 0;
4958
socklen_t l = sizeof(err);
5059
getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &l);
51-
if (err == 0) return; // not resolved yet
5260
printf("connect resolved with errno %d (%s)\n", err, strerror(err));
5361
assert(err == ECONNREFUSED && "expected ECONNREFUSED");
5462
test_success();

test/test_core.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9735,6 +9735,9 @@ def test_pipe_select(self, args):
97359735
self.require_pthreads()
97369736
self.do_runf('core/test_pipe_select.c', cflags=args)
97379737

9738+
def test_pipe_pollhup(self):
9739+
self.do_runf('core/test_pipe_pollhup.c', 'done\n')
9740+
97389741
@also_without_bigint
97399742
def test_jslib_i64_params(self):
97409743
# Tests the defineI64Param and receiveI64ParamAsI53 helpers that are

0 commit comments

Comments
 (0)