Skip to content

Notice when a control-connection write fails - #105

Merged
mgrossmann merged 1 commit into
mainfrom
issue-104-ctrl-send-return
Aug 18, 2026
Merged

Notice when a control-connection write fails#105
mgrossmann merged 1 commit into
mainfrom
issue-104-ctrl-send-return

Conversation

@mgrossmann

Copy link
Copy Markdown
Contributor

Fixes #104

Approach: one sink, not 200 call sites

ftpd_session_reply() alone has ~197 callers across eight files. Rewriting them would be a large diff with real regression risk and no structural guarantee — the next unchecked call reintroduces the problem. Instead, the four places that actually write to the control socket (ftpd_session_reply(), both writes in ftpd_session_reply_multi(), and cmd_feat()) now funnel through a new ftpd_session_send(), which:

  • completes short writes,
  • logs a failure once per session (a dead control connection otherwise produces one message per queued reply),
  • sets ctrl_dead and moves the session to SESS_CLOSING.

Callers may keep ignoring the return value and still terminate correctly — the command loop ends on its next iteration. ftpd_session_reply() and ftpd_session_reply_multi() now return int; that is source-compatible, since C permits discarding a return value, and the build confirms no call site needed touching.

The trace line moved behind the success check: a reply that never reached the client no longer appears in the trace as if it had.

Why ctrl_dead is separate from the session state

A handler that assigns its own state after a failed reply would clear SESS_CLOSING again. That is not hypothetical — the 220 greeting did exactly that (ftpd_session_reply(...) followed unconditionally by sess->state = SESS_AUTH_USER;). The greeting now only advances the state when the write succeeded, and the command loop tests the flag as well, so the guarantee does not depend on all ~200 handlers behaving.

Deliberately not done here

No retry, and no interpretation of transport-level return codes. The control connection is a blocking socket — FIONBIO is set only on the listening socket, for the accept poll (src/ftpd.c:418) — so waiting out a temporarily full send buffer belongs in the C library, not here. Anything negative that reaches FTPD means the peer is gone.

That split is the coordination agreed in mvslovers/libc370#120: per socket type, exactly one layer waits. libc370#120 should land before this is deployed. Until it does, a patched Hercules can return a "would block" code straight through to FTPD, and this change would then close a session on a transiently full buffer. The window is small (control replies are under 512 bytes) and only exists on a system with a patched emulator and an unpatched libc370.

Also fixed

ftpd_data_send() (src/ftpd#dat.c) logged errno for every non-positive return, though errno only carries a diagnosis when send() itself failed. For any other value it printed a leftover from an earlier call that reads like a real cause. It now logs the actual return code and the byte counts, and only reports errno when it means something.

Verification

  • make clean (cc370, -Wall -Werror), make test-host 72 assertions pass (TSTADR 29, TSTDSN 43).
  • Not yet exercised against a live client. The observable behaviour to check on MVS: kill an FTP client mid-session (so the control connection is gone but the server still has replies to write) and confirm a single control write failed ... closing session in the log, the session ending promptly, and the worker returning to the pool.

Every write to the control connection discarded the send() return
value. A reply that never reached the client was not logged, not
retried and not noticed -- the session carried on until some later
recv() happened to fail. The trace line was written unconditionally
too, so the log claimed a reply had gone out when it had not, which is
misleading precisely when reconstructing a failed session.

Rather than touch ~200 call sites, the four places that write to the
control socket now funnel through one ftpd_session_send(): it completes
short writes, reports a failure once per session, sets ctrl_dead and
moves the session to SESS_CLOSING. Callers may keep ignoring the return
value and still terminate correctly, because the command loop ends on
its next iteration.

ctrl_dead is tracked separately from the session state on purpose. A
handler that assigns its own state after a failed reply would otherwise
clear SESS_CLOSING again -- the 220 greeting did exactly that, so it now
only advances the state when the write succeeded, and the loop tests the
flag as well.

Deliberately no retry and no interpretation of transport-level return
codes here. The control connection is a blocking socket (FIONBIO is set
only on the listener), so waiting out a full send buffer belongs in the
C library; anything negative reaching FTPD means the peer is gone. See
mvslovers/libc370#120, which has to land before this behaves correctly
on an emulator whose SEND can report "would block".

Also stops ftpd_data_send() from logging errno for a non-negative
failure return, where it only prints a leftover value from an earlier
call.

Fixes #104
@mgrossmann

Copy link
Copy Markdown
Contributor Author

Live auf MVS getestet (mvsdev, Build AA1D2FC = HEAD dieses Branches, per IEBCOPY in FTPD.V1R0M0.LINKLIB aktiviert und STC neu gestartet).

Der neue Pfad feuert — und genau einmal pro Session:

[INFO ] ftpd_session_send: control write failed after 0 of 88 bytes, socket 3, rc=-1, errno=54 -- closing session

Drei Läufe, drei Meldungen. Das ist der eigentliche Punkt: Jeder Lauf hatte 36 Kommandos vorab gepipelined, deren Antworten alle in den toten Socket geschrieben worden wären. Ohne den ctrl_dead-Riegel stünden dort 36 Meldungen pro Session statt einer. errno=54 (ECONNRESET) ist eine echte Diagnose, nicht der Altwert, den der alte Code gedruckt hätte.

Testaufbau: einloggen, 36 Kommandos (FEAT/SYST/PWD im Wechsel) auf einmal absetzen, ohne eine Antwort zu lesen, dann die Verbindung mit SO_LINGER 0 hart abreißen. Der Server arbeitet die Kommandos seriell ab und schreibt ab dem RST in einen toten Socket.

Nebenbei bestätigt das die Analyse aus #104: Ein erster Versuch mit 0,2 s Pause vor dem Abriss erzeugte keine Fehlermeldung — die kleine Antwort ging noch in den Sendepuffer, bevor das RST ankam. Der Fehlerfall braucht wirklich einen Schreibversuch nach dem Abriss.

Keine Regression, keine Leichen:

  • Vollständige FTP-Sitzung vor und nach den Abbrüchen fehlerfrei: 220-Begrüßung, USER/PASS, das mehrzeilige FEAT (die gepatchte Stelle in cmd.c, beide Sende-Aufrufe), SYST/PWD/TYPE über reply(), PASV + LIST mit 1700 Bytes Nutzdaten, QUIT.
  • F FTPD,SESSIONS nach allen Abbrüchen: FTPD015I ACTIVE SESSIONS: 0 / 10, FTPD011I SESSIONS: 0 ACTIVE, 9 TOTAL — jede abgebrochene Sitzung wurde abgeräumt.
  • P FTPD beendete sauber (FTPD098IFTPD099I in 2 s).

Hinweis für Nachtestende: ftpd_log() schreibt per printf() nach STDOUT, nicht per WTO. Die Meldungen stehen also nicht im Konsolenlog, sondern im SYSOUT der STC — und das wird erst bei Schrittende in den Spool geschrieben. Zum Nachlesen muss die STC gestoppt werden (oder man liest den Spool eines bereits beendeten Laufs).

Unverändert gilt: Merge/Deployment erst nach mvslovers/libc370#120, siehe PR-Beschreibung.

@mgrossmann

Copy link
Copy Markdown
Contributor Author

Korrektur zu meinem vorigen Kommentar: die Warnung „erst nach libc370#120 deployen" ist gegenstandslos — #120 ist längst drin.

libc370 2e61b7b ("send() honours the X'75' retry code -2 on a bounded budget") liegt im cc370-Sysroot, und da libc370 statisch gebunden wird, steckt der Fix bereits in der getesteten Load: Die STC meldet beim Start FTPD005I LIBC370 1.0.3-DEV (2E61B7B) — exakt dieser Commit.

Der Live-Test lief damit nicht in einem Zwischenzustand, sondern in der vollständigen Zielkonfiguration: gepatchtes Hercules (SEND non-blocking), libc370 mit -2-Behandlung, ftpd mit Rückgabewert-Prüfung. Alle drei Schichten der in libc370#120 vereinbarten Arbeitsteilung sind aktiv.

Zur Vollständigkeit, was der Test damit abgedeckt hat und was nicht:

  • Abgedeckt: der harte Fehlerfall. errno=54 (ECONNRESET) ist ein echter Socket-Fehler, der libc370 unverändert durchreicht — ftpd erkennt ihn und schließt die Sitzung genau einmal.
  • Nicht abgedeckt: der Voll-Puffer-Fall (-2 → libc370 wartet bis zu 10 s → Erfolg oder -1/EWOULDBLOCK). Auf der Steuerverbindung ist der kaum zu provozieren, weil Antworten unter 512 Bytes bleiben. Der natürliche Ort dafür ist die Datenverbindung mit einem großen RETR an einen Client, der nicht liest — das ist das FTP-Gegenstück zum httpd-Stall-Test.

Aus meiner Sicht steht dem Merge damit nichts mehr im Weg.

@mgrossmann
mgrossmann merged commit d07edf1 into main Aug 18, 2026
1 check passed
@mgrossmann
mgrossmann deleted the issue-104-ctrl-send-return branch August 18, 2026 12:41
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.

Control-socket sends discard their return value: a failed reply is lost silently

1 participant