Sweep every stale socket on the port, and only when the bind says so - #110
Merged
Conversation
The sweep in socket_thread() stopped at the first match. An FTPD that ended without closing its sockets leaves behind the listener AND every connection it had accepted -- an accepted socket's local address is the listen port too, so getsockname() matches it -- and each one keeps the port occupied. Closing one was almost never enough. It also ran unconditionally in front of every bind, even one that would have succeeded, where all it could do was close a socket belonging to something alive on that port. Now it runs only after bind() actually failed with EADDRINUSE: every match is closed, one FTPD053I each, then a 2 second settle and one immediate rebind, with the 10 second retry kept as the fallback rather than the first resort. A start that recovers this way reports no bind failure at all. FTPD051E moved behind the sweep, so the console shows the FTPD053I lines and FTPD054I -- an E-message in front of a successful start only alarms. errno is captured immediately after each bind(), because getsockname() and closesocket() in the sweep sit between the failure and the message that reports it, and either clobbers it. close_stale_port() takes the caller's own descriptor and skips it. That matters now in a way it did not before: the sweep used to run before socket() had been called, and it now runs with a live socket the caller is about to bind. A failed bind() may still leave that descriptor in the table carrying a local port, and closing it would leave the caller binding a closed socket. The bound address is compared too, not just the port. SRVBIND can name one interface (#76), and a leftover socket on a different one cannot be what made our bind fail -- it is not ours to close. INADDR_ANY collides in both directions, which ftpd_adr_conflicts() in ftpd#adr.c states once so the rule is testable: TSTADR covers ANY over ANY, ANY against one interface either way, and two different interfaces. The comment above the sweep claimed the socket fd table is per address space and that a new STC inherits fds from a prior run of the same jobname. Neither is true. The X'75' table lives in Hercules and is global to the emulator, which is exactly why a dead STC's leftovers are reachable here at all -- and why the sweep has to be bounded. What bounds it is the pair of guards around it: the startup ENQ FTPD.PORT.nnnnn rules out another FTPD on this port, and EADDRINUSE rules out a port that was not actually blocked. It stays indiscriminate about ownership beyond that, because the table exposes no owner: a live listener on this port that is not an FTPD does lose its socket. Same defect and same shape as mvslovers/httpd#224. Closes #109 Claude-Session: https://claude.ai/code/session_01A6q4hLaokSJBeifJDkNJtH
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #109.
The stale-port sweep in
socket_thread()stopped at the first match and ran infront of every bind, whether or not the bind would have succeeded. Both are
fixed; the misleading comment above it is replaced with what the socket table
actually is.
What changed
Bind first, sweep only on
EADDRINUSE. The sweep is a recovery step for aport that is demonstrably blocked, not something a healthy start walks through.
It used to run before every bind, where the only thing it could do was close a
socket belonging to something alive on that port.
Close every match. A dead FTPD leaves the listener and every connection it
had accepted -- an accepted socket's local address is the listen port too, so
getsockname()matches it -- and each one holds the port. OneFTPD053Ipersocket, plus a summary line from two upwards, then one 2 second settle (not per
socket) and an immediate rebind. The 10 second retry stays as the fallback.
No bind failure reported on a start that recovers.
FTPD051Emoved behindthe sweep, so a successful recovery shows the
FTPD053Ilines andFTPD054Iandnothing alarming in front of them.
errnois captured immediately after eachbind(), sincegetsockname()andclosesocket()in the sweep sit between thefailure and the message reporting it.
The caller's own descriptor is skipped. This used to run before
socket()had been called; it now runs with a live socket the caller is about to bind, and
a failed
bind()may still leave that descriptor in the table carrying a localport.
The bound address is compared, not just the port.
SRVBINDcan name oneinterface (#76), and a leftover socket on a different one cannot be what made our
bind fail.
INADDR_ANYcollides in both directions. The rule is stated once inftpd_adr_conflicts()(ftpd#adr.c) so it can be tested.The comment. It claimed the fd table is per address space and that a new STC
inherits fds from a prior run of the same jobname. Neither is true: the X'75'
table lives in Hercules, global to the emulator. That is why a dead STC's
leftovers are reachable at all, and why the sweep has to be bounded. What bounds
it is the ENQ
FTPD.PORT.nnnnn(no second FTPD on this port) plusEADDRINUSE(the port really is blocked). Beyond that it stays indiscriminate, because the
table exposes no owner -- a live listener on this port that is not an FTPD does
lose its socket. That boundary is now written into the code rather than implied.
Relation to HTTPD
Same defect, and deliberately the same shape as the fix mvslovers/httpd#224 just
merged (
mvslovers/httpd@2f2c41c): bind first, sweep onEADDRINUSEonly, closeall, skip own descriptor, 2 second settle, existing retry as fallback,
errnocaptured early.
Two FTPD-only additions. The address comparison has no HTTPD counterpart --
do_bind()there bindsINADDR_ANYunconditionally, so there is nothing tocompare. And the summary line, which HTTPD does not have.
htons()here versus HTTPD's barein->sin_port == portis not a difference:libc370 defines
htons/ntohlas(x)on big-endian S/370.Verification
Host:
makeclean,make test-host78/78 (TSTADR 35, six of them new forftpd_adr_conflicts()). Note the projectcflagsinproject.tomlcarryneither
-Wallnor-Werror, contrary to the root CLAUDE.md -- CI does notenforce them. Both changed sources were compiled by hand with
-Wall -Werror,clean.
Run on MVS (mvsdev, MVS/CE, 2026-08-22.) Throwaway STC on port 2122 from the
mbt staging library, so the live FTPD on 2121 and
FTPD.LINKLIBwere untouched.Same scenario twice, once against each build: start, connect 3 clients
(
FTPD015I ACTIVE SESSIONS: 3 / 10),C FTPDT, restart.The leak was confirmed independently before each restart -- address space gone
from
D A,L, yet a TCP connect to 2122 still succeeded with no banner (listeneralive in Hercules, nothing in MVS accepting) and all three accepted connections
still
ESTABLISHEDin hostnetstat. Four stale sockets, not one.With the fix (
179cbac):Under a second, no
FTPD051E, noFTPD051I EADDRINUSE, RETRYING IN 10S.Socket 4 is the listener on
0.0.0.0(SRVBIND=ANY); 3, 5 and 6 are the acceptedconnections on the interface address -- so
getsockname()does fillsin_addrfor another address space's sockets. A first start on a free port swept nothing.
Without the fix (
c63024d, the currentFTPD.LINKLIB), identical scenario:It closed one socket -- an accepted connection, not the listener -- then
break,and never listened at all. Note the first match is not the listener, which is
what makes stopping at it useless.
Restarting the fixed build afterwards swept exactly the three sockets the old
one had left (
4,5,6) and came up./Pthen left 2122 refusingconnections, so a clean shutdown closes the listener properly.
Not exercised on MVS:
ftpd_adr_conflicts()only ran withwant == 0(SRVBIND=ANY), where everything on the port collides. The specific-interface
branch -- skipping a socket bound to a different address -- is covered by TSTADR
only.
Test STC proc and config data set were deleted afterwards; port 2122 refuses
connections and the live FTPD on 2121 answers normally.