Skip to content

Configurable bind retries -- and the wait loops that never waited - #114

Merged
mgrossmann merged 4 commits into
mainfrom
issue-113-bind-retries
Aug 22, 2026
Merged

Configurable bind retries -- and the wait loops that never waited#114
mgrossmann merged 4 commits into
mainfrom
issue-113-bind-retries

Conversation

@mgrossmann

Copy link
Copy Markdown
Contributor

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 main
today.

1. Configurable retries, and retrying the right thing

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 underscore HTTPD uses (BIND_TRIES,
BIND_SLEEP) is dropped because no other FTPD keyword has one. FTPD057I
reports both in the CONFIG dump; sample member and doc/installation.md updated.

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 — starting
before the stack has an address fails EADDRNOTAVAIL, and FTPD gave up on it
at once. Meanwhile EADDRINUSE, the one case that was retried, is now largely
handled 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_sock still -1, the socket
thread alive and its termecb clear — none of its exit conditions true.

Two causes, both visible in cc370 -S:

  • __asm__("STIMER WAIT,BINTVL==F'10'") carried no clobber list, so the
    compiler kept sock_task in R15 across the SVC. 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/R15 go on the list here because R15 is
    demonstrably destroyed. All five sites in the file are fixed.
  • 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 is a live regression on main. 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 #109's stale-port recovery, whose sweep, 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.

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 moment BINDTRIES × BINDWAIT could
exceed it. Measured at 10×10s: FTPD056E at 21 seconds, on the third of ten
tries — and because giving up clears FTPD_ACTIVE, main aborted the retry it
was 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 /P sat unread until the retries gave up
on their own: measured at 10×10s, /P did nothing and the STC ran the full 100
seconds. The wait loop now reads the console too, which is also what gives the
FTPD_ACTIVE check in bind_wait() any effect. A start stopped that way
announces neither READY nor FTPD056E and returns 0 — a /P that worked is not
a failed start.

Verified on MVS (mvsdev, MVS/CE, 2026-08-22)

Throwaway STC on port 2122; the live FTPD on 2121 and FTPD.LINKLIB untouched.

Retries run, at the configured interval (BINDTRIES=3 BINDWAIT=2,
SRVBIND on an address the host does not have):

12.31.47 FTPD051E BIND() FAILED ON 10.99.99.99 PORT 2122, ERRNO=49
12.31.47 FTPD051I RETRYING BIND IN 2S (1 OF 3)
12.31.49 FTPD051I RETRYING BIND IN 2S (2 OF 3)
12.31.51 FTPD051I RETRYING BIND IN 2S (3 OF 3)
12.31.53 FTPD051E BIND() STILL FAILING AFTER 3 TRIES, ERRNO=49
12.31.53 FTPD056E FTPD IS NOT LISTENING ON PORT 2122, THIS INSTANCE ENDS
         IEF142I FTPDT FTPDT - STEP WAS EXECUTED - COND CODE 0008

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:

12.37.17 FTPD057I   BINDTRIES=10 BINDWAIT=10        (F FTPDT,CONFIG at +23s)
12.37.19 FTPD098I FTPD SHUTTING DOWN                (P FTPDT)
12.37.19 FTPD051I BIND RETRY ABANDONED, FTPD IS STOPPING
12.37.21 IEF404I FTPDT - ENDED

Two seconds from /P to 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:

12.38.14 FTPD053I CLOSING STALE SOCKET 3 ON 0.0.0.0 PORT 2122
12.38.14 FTPD053I CLOSING STALE SOCKET 5 ON 192.168.0.233 PORT 2122
12.38.14 FTPD053I CLOSING STALE SOCKET 6 ON 192.168.0.233 PORT 2122
12.38.14 FTPD053I CLOSING STALE SOCKET 7 ON 192.168.0.233 PORT 2122
12.38.14 FTPD053I 4 STALE SOCKETS CLOSED ON PORT 2122
12.38.16 FTPD054I LISTENING ON ANY PORT 2122
12.38.16 FTPD001I FTPD 1.0.1-DEV READY
         IEF142I FTPDT FTPDT - STEP WAS EXECUTED - COND CODE 0000

Two seconds between the sweep and the listener — the gap main previously
skipped. Banner probe answered, /P ended it with COND CODE 0000.

make test-host 78/78, tools/check-module-data.py clean over 15 sources,
src/ftpd.c and src/ftpd#cfg.c compiled by hand with -Wall -Werror (the
project cflags carry 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 raw 0x40000000U rather than
ECB_POSTED_BIT. And the bare-__asm__ pattern fixed here exists in other
FTPD sources and across the ecosystem — this PR only covers src/ftpd.c.

… 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
@mgrossmann
mgrossmann merged commit c4d64cd into main Aug 22, 2026
2 checks passed
@mgrossmann
mgrossmann deleted the issue-113-bind-retries branch August 22, 2026 18:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bind retries are one hardcoded attempt; HTTPD makes them configurable

1 participant