Configurable bind retries -- and the wait loops that never waited - #114
Merged
Conversation
… out FTPD retried a failed bind() exactly once, after 10 seconds, hardcoded. HTTPD has had BIND_TRIES and BIND_SLEEP in its Parmlib for as long, defaulting to 10 and 10. While a failed bind left FTPD sitting there idle the difference barely mattered; since #111 it ends the started task, so the retry policy decides how much transient trouble a start survives. BINDTRIES and BINDWAIT, both 1-100, both defaulting to 10, so the wait before giving up goes from 10 seconds to up to 100. Out-of-range values are clamped rather than refused: the nearest legal value is what a typo meant. The underscore HTTPD uses is dropped because no other FTPD keyword has one. FTPD057I reports both in the CONFIG dump. The more interesting half is what gets retried. The old retry ran only for EADDRINUSE, and that is not the failure most in need of patience. Starting before the stack has an address fails EADDRNOTAVAIL, and FTPD gave up on it immediately -- measured while testing #111, where binding an address the host does not have ended the start with errno 49 and not one retry. Meanwhile EADDRINUSE, the one case that was retried, is now largely handled by the stale port sweep (#109) before the retry loop is ever reached. Both are retryable now; everything else is still refused at once, because waiting does not fix a socket that could not be created. The wait runs in one second steps and checks FTPD_ACTIVE between them. Without that, a longer BINDTRIES would be worse for the operator than the short retry it replaces: terminate() gives the socket thread 10 seconds before reporting FTPD095W SOCKET THREAD DID NOT TERMINATE, and a thread asleep in one 100 second STIMER would sail past a /P. Abandoning a retry run for shutdown says so. Closes #113 Claude-Session: https://claude.ai/code/session_01A6q4hLaokSJBeifJDkNJtH
The wait loops in ftpd.c did not wait. Measured on MVS with an instrumented
build: main's loop for the listener coming up exited after ONE iteration with
listen_sock still -1, the socket thread alive and its termecb clear -- none of
its exit conditions true.
Two causes, both in the generated code.
`__asm__("STIMER WAIT,BINTVL==F'10'")` carried no clobber list, so cc370 was
free to keep a live value in a register the SVC destroys. It kept sock_task in
R15 across the STIMER; the next iteration tested garbage, found it non-zero,
read a "termecb" from it and left. libc370 writes the same macro as
`: : : "0", "1"` for exactly this reason (@@75send.c, @@75acce.c); R14 and R15
go on the list here because R15 is demonstrably destroyed. All five sites in
this file are fixed, not just the one that was caught.
listen_sock and flags are written by one TCB and polled by another, and were
not volatile, so the load was hoisted out of the loop -- the poller could never
have seen a change even with the registers intact.
This repairs a regression shipped in #111. With the wait collapsing to a
single 0.1s pass, any start whose bind did not succeed almost immediately was
declared FTPD056E and ended -- including the stale-port recovery from #109,
whose 2s settle and rebind take far longer than the wait was actually lasting.
The #111 tests passed because both cases they covered resolve inside that first
pass: an instant EADDRNOTAVAIL failure, and a bind that succeeds at once.
Claude-Session: https://claude.ai/code/session_01A6q4hLaokSJBeifJDkNJtH
A fixed 20 second cap looked generous against a hardcoded 10 second retry and became wrong the moment BINDTRIES and BINDWAIT could raise the socket thread's budget past it. Main hit the cap mid-retry, declared the start failed, and -- because giving up clears FTPD_ACTIVE -- aborted the retry it was waiting for. Measured on mvsdev at BINDTRIES=10 BINDWAIT=10: FTPD056E at 21 seconds, on the third of ten tries, followed by FTPD051I BIND RETRY ABANDONED from a socket thread that still had 70 seconds of patience left. The cap now follows the budget it is waiting on, plus slack for the sweep and the settle ahead of it. It stays a backstop against a socket thread that neither listens nor ends; both normal exits are still immediate. Claude-Session: https://claude.ai/code/session_01A6q4hLaokSJBeifJDkNJtH
A bind retry runs for up to BINDTRIES x BINDWAIT seconds and main spends all of it in the wait loop, which did not drain CIBs -- only the event loop after it does. So an operator's /P sat unread on the CIB queue until the retries gave up by themselves. Measured on mvsdev at 10x10s: /P did nothing and the STC ran the full 100 seconds, exactly the wait the operator was trying to cut short. Draining them here is also what gives the FTPD_ACTIVE check in bind_wait() any effect: nothing else could clear the flag while main sat in this loop. A start stopped this way announces neither READY nor FTPD056E and returns 0. The socket thread has already said it abandoned the retry, and a /P that worked is not a failed start. Claude-Session: https://claude.ai/code/session_01A6q4hLaokSJBeifJDkNJtH
This was referenced Aug 23, 2026
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 #113.
Four commits. The first is the feature that was asked for; the other three are a
latent defect it dragged into the light, and the consequences of fixing it.
Read the third commit first if you read only one. It repairs a regression
shipped in #111 that would break the stale-port recovery from #109 on
maintoday.
1. Configurable retries, and retrying the right thing
BINDTRIESandBINDWAIT, both 1–100, both defaulting to 10, so the waitbefore giving up goes from 10 seconds to up to 100. Out-of-range values are
clamped rather than refused. The underscore HTTPD uses (
BIND_TRIES,BIND_SLEEP) is dropped because no other FTPD keyword has one.FTPD057Ireports both in the CONFIG dump; sample member and
doc/installation.mdupdated.The more interesting half is what gets retried. The old retry ran only for
EADDRINUSE, which is not the failure most in need of patience — startingbefore the stack has an address fails
EADDRNOTAVAIL, and FTPD gave up on itat once. Meanwhile
EADDRINUSE, the one case that was retried, is now largelyhandled by the stale-port sweep (#109) before the retry loop is reached. Both
are retryable now; everything else is still refused immediately.
2. The wait loops did not wait
Measured with an instrumented build on MVS: main's loop waiting for the
listener exited after one iteration with
listen_sockstill -1, the socketthread alive and its
termecbclear — none of its exit conditions true.Two causes, both visible in
cc370 -S:__asm__("STIMER WAIT,BINTVL==F'10'")carried no clobber list, so thecompiler kept
sock_taskin R15 across the SVC. The next iterationtested garbage, found it non-zero, read a "termecb" from it and left. libc370
writes the same macro as
: : : "0", "1"for exactly this reason(
@@75send.c,@@75acce.c); R14/R15 go on the list here because R15 isdemonstrably destroyed. All five sites in the file are fixed.
listen_sockandflagsare written by one TCB and polled by another andwere not
volatile, so the load was hoisted out of the loop — the pollercould never have seen a change even with the registers intact.
This is a live regression on
main. With the wait collapsing to a single0.1s pass, any start whose bind did not succeed almost immediately was declared
FTPD056Eand ended — including #109's stale-port recovery, whose sweep, 2ssettle and rebind take far longer than the wait was actually lasting. The #111
tests passed because both cases they covered resolve inside that first pass: an
instant
EADDRNOTAVAILfailure, and a bind that succeeds at once.3. Then the cap, then the console
With the wait actually waiting, two more things followed:
main's fixed 20 second cap was wrong the momentBINDTRIES × BINDWAITcouldexceed it. Measured at 10×10s:
FTPD056Eat 21 seconds, on the third of tentries — and because giving up clears
FTPD_ACTIVE, main aborted the retry itwas waiting for. The cap now follows the configured budget plus slack.
And main spends that whole budget in the wait loop, which did not drain CIBs —
only the event loop after it does. So
/Psat unread until the retries gave upon their own: measured at 10×10s,
/Pdid nothing and the STC ran the full 100seconds. The wait loop now reads the console too, which is also what gives the
FTPD_ACTIVEcheck inbind_wait()any effect. A start stopped that wayannounces neither READY nor
FTPD056Eand returns 0 — a/Pthat worked is nota failed start.
Verified on MVS (mvsdev, MVS/CE, 2026-08-22)
Throwaway STC on port 2122; the live FTPD on 2121 and
FTPD.LINKLIBuntouched.Retries run, at the configured interval (
BINDTRIES=3 BINDWAIT=2,SRVBINDon an address the host does not have):errno 49 retried at all is new; the old code ended without a single retry.
The full budget is honoured (10×10s): ten tries at exactly ten second
intervals, 12.34.07 → 12.35.47, then the verdict. Before the cap fix this died
at 21 seconds.
The console answers during a retry run, past the old cap:
Two seconds from
/Pto ended, instead of the remaining ~70.#109 stale-port recovery, the case the regression would have broken — three
clients connected,
C FTPDT, leak confirmed (connect succeeds, no banner),restart:
Two seconds between the sweep and the listener — the gap main previously
skipped. Banner probe answered,
/Pended it with COND CODE 0000.make test-host78/78,tools/check-module-data.pyclean over 15 sources,src/ftpd.candsrc/ftpd#cfg.ccompiled by hand with-Wall -Werror(theproject
cflagscarry neither, so CI does not enforce them).Test STC proc and config data set deleted afterwards; port 2122 refuses
connections and the live FTPD on 2121 answers normally.
Worth a follow-up, not done here
terminate()tests the same posted bit as a raw0x40000000Urather thanECB_POSTED_BIT. And the bare-__asm__pattern fixed here exists in otherFTPD sources and across the ecosystem — this PR only covers
src/ftpd.c.