--- id: "bug-648" status: "fixed" status_updated: "2026-09-24" status_note: "" component: "lib" severity: "unknown" timestamp: "2026-06-17T17:30:00Z" last_seen: "2026-09-19T09:00:00Z" occurrences: 1 files: ["src/lib/netio.c"] tags: ["libxce", "netio", "ssl", "SSL_read", "EOF", "close_notify", "openssl-3.x", "SSL_ERROR_ZERO_RETURN", "SSL_ERROR_SSL", "silent-failure", "hang"] related: ["bug-646", "bug-610"] legacy: true --- # bug-648: Peer-close/EOF on an SSL connection was swallowed: io_fsm_do() set fsm->state=FSM_STATE_ERROR but… ## Description Peer-close/EOF on an SSL connection was swallowed: io_fsm_do() set fsm->state=FSM_STATE_ERROR but returned rc=0 on a clean EOF, so handle_input() returned >=0, handle_error/handle_disconnect never ran, and a blocked cbuf_get() reply-waiter was not woken on the first close event. ## Environment _Not recorded (migrated from buglog.json)._ ## Reproduction _Not recorded (migrated from buglog.json)._ ## Root cause io_fsm_do() computed rc = -errno when a closed/reset connection was detected. By OpenSSL 3.x contract (verified against docs + a live probe on OpenSSL 3.6.0), a peer close makes SSL_read() return 0 and is reported via SSL_get_error(), NOT via the return value or errno: clean close_notify -> SSL_ERROR_ZERO_RETURN (errno 0); unexpected EOF (FIN, no close_notify) -> SSL_ERROR_SSL since OpenSSL 3.0 (pre-3.0 it was SSL_ERROR_SYSCALL with errno 0 -- documented); RST -> SSL_ERROR_SYSCALL errno=ECONNRESET. errno is only meaningful for SSL_ERROR_SYSCALL; for SSL_ERROR_SSL the error queue is authoritative and any errno (we observed EPIPE=32) is a leaked side effect of OpenSSL's internal alert-write (write() on a broken connection returns EPIPE; read() never does). So -errno is 0 for the documented EOF cases -> the close looked like success. NOTE: on this 3.6 build a process-death FIN surfaced as SSL_ERROR_SSL/errno=32, which the OLD code propagated -- so this swallow is NOT necessarily the cause of the observed controller-kill permanent hang (that still implicates the reconnect/SSL-handshake path); it is a real, separate, errno-dependent non-determinism. ## Fix Extracted pure io_classify_io_error(ssl_err, ecode, io_rc): returns 0 only for WANT_READ/WANT_WRITE and EAGAIN/EWOULDBLOCK syscall; otherwise returns a NEGATIVE error ((ecode!=0)?-ecode:-ECONNRESET) so a detected close NEVER returns 0 regardless of the unreliable errno. io_fsm_do() now sets FSM_STATE_ERROR iff the classifier returns <0. TDD: test_netio_close_propagation.c asserts ZERO_RETURN/errno0 and SSL_ERROR_SSL/EOF map to <0 (verified red with old -ecode, green with fix). libxce suite 168/0; controller_unit_test links clean. ## Verification _Not recorded (migrated from buglog.json)._ ## References - Related: bug-646, bug-610