Vom Magazinthema zur Projektpraxis
Passende Leistungs- und Technikseiten zum Beitrag
„Mein Delphi WebSocket Client verbindet – aber nach ein paar Stunden kommen keine Events mehr. Und beim Beenden hängt der Prozess manchmal.“ Diese Leserfrage taucht in Echtbetriebsszenarien häufiger auf als „Wie baue ich einen WebSocket?“. Denn WebSockets sind langlebige, zustandsbehaftete Verbindungen; damit kippen Fehlerbilder von „ein Request ist fehlgeschlagen“ zu „der ganze Client ist in einem undefinierten Zustand“.
Konstruierte, aber realistische Situation: Sie modernisieren eine gewachsene VCL-Anwendung, die Statusänderungen aus einer Server-Komponente live anzeigen soll (z. B. Maschinenmeldungen, Benutzeraktionen, Portal-Events). Im Testnetz läuft alles. Im Kundennetz hängt davor ein Reverse Proxy (typisch: TLS-Termination, Session-Affinity), Clients hängen teils in VPNs, teils hinter NAT. Nach Feierabend ist wenig Verkehr, am Morgen fehlt der Live-Status. Und beim Rollout eines Updates bleibt ein Client-Prozess stehen, weil ein Receive-Loop noch blockiert.
Dieser Refresh ist deshalb kein „Hello World“, sondern ein betriebsorientierter Source-Schnipsel: ein Wrapper um System.Net.WebSockets (Delphi RTL; WebSocket-Client-API mit TClientWebSocket), der Start/Stop, Reconnect-Backoff, Heartbeat und Debug-Hooks sauber kapselt. Nebenbei räumen wir eine verbreitete Fehlannahme auf: Nicht der WebSocket an sich ist instabil – instabil ist oft die Umgebung (Proxies, Idle-Timeouts, DNS/TLS, Threading).
Delphi WebSocket Client: Was im Betrieb wirklich entscheidet
Begriffe kurz eingeordnet, weil sie im Code zentral sind:
- Backoff: eine Wartezeit, die nach Fehlern kontrolliert wächst (z. B. 0,5s → 1s → 2s …). Ziel: Server und Netzwerk nicht „hammern“, aber zügig wieder online sein.
- Heartbeat: regelmäßige, kleine Nachrichten auf Anwendungsebene, um Idle-Timeouts zu vermeiden und „lebt noch“ zu signalisieren (wenn Ping/Pong nicht zuverlässig verfügbar ist).
- TThread.Queue vs. Synchronize: Queue plant Code im Mainthread ein, blockiert den Worker nicht; Synchronize blockiert und ist im Shutdown-Pfad ein häufiger Deadlock-Auslöser.
- Fragmentierung: WebSocket-Text kann über mehrere Frames kommen. „Ein Receive = ein JSON“ ist eine Annahme, die im Fehlerfall weh tut.
Für die API-Grundlagen lohnt ein Blick in die Delphi-Doku zur RTL-Einheit System.Net.WebSockets, um die Zustände und Message-Kinds korrekt zu interpretieren (Embarcadero DocWiki: System.Net.WebSockets). Und für das Threading-Verhalten ist die Unterscheidung Queue/Synchronize entscheidend, weil Sie damit Shutdown-Deadlocks vermeiden (Embarcadero DocWiki: TThread.Queue).
Source-Schnipsel: Wrapper mit Stop-Signal, Backoff und Fragment-Reassembly
Der Code ist bewusst so geschnitten, dass er in VCL/FMX und auch in Service-Prozessen funktioniert. Der Kern ist ein Worker-Thread; Stop erfolgt über TEvent (Abbruchsignal), nicht über „Thread kill“.
unit Net.WebSocketClientEx;
interface
uses
System.SysUtils,
System.Classes,
System.SyncObjs,
System.Net.WebSockets;
type
TWsLogLevel = (llDebug, llInfo, llWarn, llError);
TWsLogEvent = reference to procedure(Level: TWsLogLevel; const Msg: string);
TWsTextEvent = reference to procedure(const Text: string);
TWsStateEvent = reference to procedure(const State: string);
TDelphiWebSocketClient = class
private
FUrl: string;
FOnLog: TWsLogEvent;
FOnText: TWsTextEvent;
FOnState: TWsStateEvent;
FStopEvent: TEvent;
FWorker: TThread;
FMinBackoffMs: Integer;
FMaxBackoffMs: Integer;
FHeartbeatSec: Integer;
FMaxMessageBytes: Integer;
procedure Log(Level: TWsLogLevel; const Msg: string);
procedure State(const S: string);
procedure Run;
function NextBackoffMs(const Prev: Integer): Integer;
function NowUtcStr: string;
public
constructor Create(const AUrl: string);
destructor Destroy; override;
procedure Start;
procedure Stop(TimeoutMs: Cardinal = 5000);
property OnLog: TWsLogEvent read FOnLog write FOnLog;
property OnText: TWsTextEvent read FOnText write FOnText;
property OnState: TWsStateEvent read FOnState write FOnState;
property MinBackoffMs: Integer read FMinBackoffMs write FMinBackoffMs;
property MaxBackoffMs: Integer read FMaxBackoffMs write FMaxBackoffMs;
property HeartbeatSec: Integer read FHeartbeatSec write FHeartbeatSec;
property MaxMessageBytes: Integer read FMaxMessageBytes write FMaxMessageBytes;
end;
implementation
uses
System.NetEncoding;
constructor TDelphiWebSocketClient.Create(const AUrl: string);
begin
inherited Create;
FUrl := AUrl;
FStopEvent := TEvent.Create(nil, True, False, '');
FMinBackoffMs := 500;
FMaxBackoffMs := 15000;
FHeartbeatSec := 20; // App-Heartbeat gegen Idle-Timeouts
FMaxMessageBytes := 2*1024*1024; // Schutz gegen Payload-Explosion
end;
destructor TDelphiWebSocketClient.Destroy;
begin
Stop;
FStopEvent.Free;
inherited;
end;
procedure TDelphiWebSocketClient.Start;
begin
if Assigned(FWorker) then Exit;
FStopEvent.ResetEvent;
FWorker := TThread.CreateAnonymousThread(
procedure
begin
Run;
end);
FWorker.FreeOnTerminate := False;
FWorker.Start;
end;
procedure TDelphiWebSocketClient.Stop(TimeoutMs: Cardinal);
var
W: TThread;
begin
FStopEvent.SetEvent;
W := FWorker;
if Assigned(W) then
begin
if W.WaitFor(TimeoutMs) = wrTimeout then
Log(llWarn, 'Stop: Worker reagiert nicht innerhalb Timeout (Connect/Receive könnte blockieren)');
FreeAndNil(FWorker);
end;
end;
procedure TDelphiWebSocketClient.Log(Level: TWsLogLevel; const Msg: string);
begin
if Assigned(FOnLog) then
TThread.Queue(nil,
procedure
begin
FOnLog(Level, NowUtcStr + ' ' + Msg);
end);
end;
procedure TDelphiWebSocketClient.State(const S: string);
begin
if Assigned(FOnState) then
TThread.Queue(nil,
procedure
begin
FOnState(S);
end);
end;
function TDelphiWebSocketClient.NowUtcStr: string;
begin
Result := FormatDateTime('yyyy-mm-dd"T"hh:nn:ss.zzz"Z"', TTimeZone.Local.ToUniversalTime(Now));
end;
function TDelphiWebSocketClient.NextBackoffMs(const Prev: Integer): Integer;
var
N: Integer;
begin
if Prev <= 0 then Exit(FMinBackoffMs);
N := Prev * 2;
if N < FMinBackoffMs then N := FMinBackoffMs;
if N > FMaxBackoffMs then N := FMaxBackoffMs;
Result := N;
end;
procedure TDelphiWebSocketClient.Run;
var
WS: TClientWebSocket;
Backoff: Integer;
LastHeartbeat: UInt64;
Buf: TBytes;
Received: TWebSocketReceiveResult;
SB: TStringBuilder;
AssembledBytes: Integer;
function StopRequested: Boolean;
begin
Result := (FStopEvent.WaitFor(0) = wrSignaled);
end;
procedure SafeClose;
begin
try
if WS.State = TWebSocketState.Open then
WS.Close(TWebSocketCloseStatus.NormalClosure, 'client shutdown');
except
on E: Exception do
Log(llDebug, 'Close: ' + E.ClassName + ': ' + E.Message);
end;
end;
procedure DispatchText(const S: string);
begin
if Assigned(FOnText) then
TThread.Queue(nil,
procedure
begin
FOnText(S);
end);
end;
begin
Backoff := 0;
LastHeartbeat := 0;
while not StopRequested do
begin
WS := TClientWebSocket.Create;
try
State('connecting');
Log(llInfo, 'Connect: ' + FUrl);
try
// Connect ist synchron und kann bei DNS/TLS/Proxy blockieren.
// Deshalb: immer im Worker, nie im Mainthread.
WS.Connect(FUrl);
except
on E: Exception do
begin
State('connect_failed');
Log(llWarn, 'Connect failed: ' + E.ClassName + ': ' + E.Message);
Backoff := NextBackoffMs(Backoff);
if FStopEvent.WaitFor(Backoff) = wrSignaled then Break;
Continue;
end;
end;
State('open');
Backoff := 0;
SetLength(Buf, 16 * 1024);
SB := TStringBuilder.Create;
AssembledBytes := 0;
try
while (WS.State = TWebSocketState.Open) and (not StopRequested) do
begin
// App-Heartbeat: bewusst als Text, weil Ping/Pong nicht überall gleich gut nutzbar ist.
if (FHeartbeatSec > 0) then
begin
if (LastHeartbeat = 0) or
(TThread.GetTickCount64 - LastHeartbeat >= UInt64(FHeartbeatSec) * 1000) then
begin
try
WS.Send('{"type":"ping"}');
LastHeartbeat := TThread.GetTickCount64;
Log(llDebug, 'Heartbeat sent');
except
on E: Exception do
begin
Log(llWarn, 'Heartbeat send failed: ' + E.Message);
Break;
end;
end;
end;
end;
try
Received := WS.Receive(Buf);
except
on E: Exception do
begin
Log(llWarn, 'Receive failed: ' + E.ClassName + ': ' + E.Message);
Break;
end;
end;
case Received.Kind of
TWebSocketMessageKind.Text:
begin
SB.Append(TEncoding.UTF8.GetString(Buf, 0, Received.BytesReceived));
Inc(AssembledBytes, Received.BytesReceived);
// Schutz gegen „unendliche“ Nachrichten (z. B. Serverbug, Protokollbruch)
if (FMaxMessageBytes > 0) and (AssembledBytes > FMaxMessageBytes) then
begin
Log(llWarn, 'Message too large; closing connection');
Break;
end;
if Received.EndOfMessage then
begin
DispatchText(SB.ToString);
SB.Clear;
AssembledBytes := 0;
end;
end;
TWebSocketMessageKind.Binary:
begin
// In vielen Integrationen ist Text/JSON der Standard.
// Wenn Binary gebraucht wird: separates Event + eigener Assembler + Limits.
Log(llDebug, 'Binary frame: ' + Received.BytesReceived.ToString + ' bytes');
end;
TWebSocketMessageKind.Close:
begin
Log(llInfo, 'Server requested close');
Break;
end;
end;
TThread.Sleep(1);
end;
finally
SB.Free;
end;
SafeClose;
State('closed');
finally
WS.Free;
end;
if StopRequested then Break;
Backoff := NextBackoffMs(Backoff);
Log(llInfo, 'Reconnect in ' + Backoff.ToString + ' ms');
if FStopEvent.WaitFor(Backoff) = wrSignaled then Break;
end;
State('stopped');
Log(llInfo, 'Worker stopped');
end;
end.
Warum dieser Aufbau sich in Legacy- und Mischarchitekturen bewährt
Ungewöhnlich (und bewusst) sind zwei Punkte: Erstens wird der Heartbeat als Anwendungsnachricht modelliert. Das ist nicht „schön“, aber robust, wenn Sie nicht sicher sind, ob Ihre Delphi-Version Ping/Pong sauber exponiert oder ob Proxies damit korrekt umgehen. Zweitens ist MaxMessageBytes ein Sicherheitsgurt: In Business-Software sieht man durchaus Fälle, in denen eine Serverkomponente auf einmal riesige Payloads pusht (Bug oder Fehlkonfiguration). Ohne Limit frisst Ihr Client RAM und hängt dann scheinbar „zufällig“.
Shutdown, Deadlocks und die Queue/Synchronize-Falle
Wenn ein Client beim Stoppen hängt, sind meist zwei Ursachen im Spiel: (1) ein blockierender Connect/Receive im Netzwerk-Stack und (2) ein Callback, der via Synchronize in den Mainthread will, während der Mainthread gerade auf das Ende des Workers wartet. Das ist ein klassischer Deadlock-Ring.
Hier ist der Wrapper strikt: Ereignisse gehen über TThread.Queue, der Worker blockiert nicht auf UI-Ausführung. Das löst nicht jedes Problem (ein blockierender Connect kann trotzdem länger hängen), aber es verhindert die selbstgebauten Deadlocks.
Reconnect-Strategie: Backoff ist kein „Nice to have“
Ein WebSocket, der im Fehlerfall im 200ms-Takt reconnectet, ist im Kundennetz eine Einladung zu Nebeneffekten: Proxy-Rate-Limits, Log-Fluten, unnötige TLS-Handshakes und schwer interpretierbare Fehlerbilder. Backoff macht das Verhalten berechenbar.
Heartbeat, Reverse Proxy und TLS: wo WebSockets „plötzlich“ sterben
Idle-Timeouts sind der häufigste Grund für „morgens keine Updates mehr“. Reverse Proxies oder Load Balancer schließen gern Verbindungen, wenn längere Zeit keine Daten fließen. TCP-Keepalive ist oft zu träge konfiguriert, und Sie haben als Applikation nicht immer die Hoheit über OS-Settings.
Der Heartbeat ist deshalb weniger Protokollspielerei als Betriebswerkzeug: Sie erzeugen definierten Traffic, und Sie können serverseitig ausbleibende Heartbeats als Signal für „Client weg“ nutzen. Wichtig: Heartbeat-Frequenz und Payload klein halten; im Zweifel ist alle 20–60 Sekunden ein pragmatischer Bereich, abhängig von Infrastruktur und Kostenmodell.
Vergleich: RTL-WebSocket vs. Alternativen (wann ein Wechsel lohnt)
| Kriterium | System.Net.WebSockets (RTL) | Alternativer Stack (z. B. ICS) |
|---|---|---|
| Integration/Deployment | Keine Zusatzlibs, „im Projekt drin“ | Zusätzliche Abhängigkeit, dafür mehr Stellschrauben |
| Threading-Modell | Erfordert saubere Kapselung (Worker), Connect/Receive oft synchron | Je nach Library mehr Kontrolle über asynchrone APIs/Timeouts |
| Ping/Pong, Timeouts | Version/Plattform-abhängig; oft App-Heartbeat nötig | Häufig granularer konfigurierbar |
| Legacy-Delphi | Je nach Version eingeschränkt oder nicht verfügbar | Oft bessere Abdeckung älterer Toolchains |
Genau eine redaktionelle Einschätzung (begründet)
Unsere Einschätzung: Für die meisten Modernisierungs- und Integrationsszenarien in individueller Unternehmenssoftware lohnt sich zuerst die Disziplin in Architektur und Betrieb (Stop-Signal, Queue, Limits, Backoff, Logging) – erst danach die Diskussion „welche Library“. Der Grund ist simpel: Die häufigsten Ausfälle entstehen nicht durch fehlende Features, sondern durch fehlende Betriebs-Mechanik (Idle-Timeout, Shutdown-Deadlock, unlimitierte Payloads, fehlende Tracebarkeit). Ein Stack-Wechsel kann nötig werden, aber er ersetzt diese Mechanik nicht.
Risiken und Einsatzgrenzen
- Keine harte Cancellation: Wenn Connect/Receive in Ihrer Umgebung sehr lange blockieren kann, brauchen Sie zusätzliche Architekturmaßnahmen (z. B. Prozess-Überwachung, getrennte Worker-Instanz, „fail closed“ beim Update).
- UI-Überlast: Wenn OnText zu schwer wird, hilft Queue/Backpressure (TThreadedQueue) und ein separater Consumer-Worker.
- Protokoll-Disziplin: Heartbeat und Message-Formate müssen mit dem Server abgestimmt sein (z. B. JSON envelope, Versionierung).
- Security/TLS: Zertifikatswechsel, TLS-Termination und Proxy-Regeln sind Betriebsrealität. Ohne Logging (Statewechsel, Exceptions, Close) debuggen Sie im Blindflug.
Fazit: Der Delphi WebSocket Client ist ein Betriebsbaustein
Wenn ein WebSocket nur „verbinden und lesen“ soll, ist er schnell gebaut. Wenn er über Nacht stabil laufen, Updates überleben und bei sporadischen Netzwerkproblemen deterministisch reagieren muss, wird er zu einem Baustein mit klaren Regeln: Stop-Signal statt Gewalt, Queue statt Synchronize, Fragment-Reassembly, Heartbeat gegen Idle-Timeouts, Backoff gegen Reconnect-Stürme und Limits gegen Payload-Fehler.
Wenn Sie so einen Client in eine bestehende Architektur einpassen (z. B. klare Schichten, Service-/UI-Trennung) oder echte Disconnect-Phänomene im Kundennetz reproduzierbar machen müssen, ist eine strukturierte Analyse oft schneller als Trial-and-Error. Kontakt aufnehmen und den konkreten Fall mit Net-Base besprechen.
Für dieses Thema sind auch Heartbeat Ping/Pong wichtig. Der Beitrag ordnet diese Aspekte verständlich ein und zeigt, worauf es im Alltag ankommt.
Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.
nächster Schritt
Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.
Wir unterstuetzen nicht nur bei Einzelfragen, sondern auch dann, wenn aus Source-Schnipseln, Legacy-Themen oder Portalideen ein belastbares Unternehmensprojekt werden soll.
- Bestand, Zielbild und technische Risiken werden zusammen bewertet.
- REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
- Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.