async / await in Delphi: sgcWebSockets Without Event Handlers | eSeGeCe Blog

async / await in Delphi: sgcWebSockets Without Event Handlers

· Components
async / await in Delphi: sgcWebSockets Without Event Handlers

Until now, every asynchronous call in sgcWebSockets meant an event handler. You called Get on a background thread, or you set Active := True and waited for OnConnect. The result arrived somewhere else, in another method, so the code that started the operation and the code that consumed the answer lived in different places. Add a second request that depends on the first, plus error handling, plus a "please wait" panel that has to be hidden again, and you end up with a small state machine spread across your form: a couple of private fields to remember what you were doing, a flag to know whether the last call is still in flight, and a handler that has to figure out which of the three requests just came back.

sgcWebSockets 2026.7 adds an await-style API that removes most of that. An HTTP request, a WebSocket connect or an AI chat call now returns an object you can wait on, chain a callback to, or cancel, so the code reads top to bottom in the order it happens. Cancellation is not cosmetic either, it aborts the underlying operation instead of just ignoring the answer when it finally arrives.

Tasks and futures

The new unit is sgcBase_AsyncAwait. It introduces two interfaces. IsgcTask represents work that finishes but does not produce a value, and IsgcFuture<T> represents work that produces a value of type T. Both give you the same four things: Await to block until the work is done, ThenProc to get a callback when it succeeds, OnError to get a callback when it raises, and Cancel to stop it.

You can build your own with TsgcAsync.Run. The procedure or function you pass runs on a background thread, and the callbacks come back on the main thread, so it is safe to touch the UI inside ThenProc and OnError.

uses
  sgcBase_AsyncAwait;

begin
  // the anonymous method runs on a background thread
  TsgcAsync.Run<Integer>(
    function: Integer
    begin
      Result := ExpensiveCalculation;
    end)
  .ThenProc(
    procedure(const Value: Integer)
    begin
      // main thread, safe to update the UI
      Label1.Caption := IntToStr(Value);
    end)
  .OnError(
    procedure(E: Exception)
    begin
      // main thread as well
      Label1.Caption := 'Failed: ' + E.Message;
    end);
end;

The task keeps itself alive while it runs, so a fire-and-forget call like the one above is fine. If you want to inspect it later, keep the interface in a variable and read IsCompleted, IsCancelled or State, which is one of tsaPending, tsaRunning, tsaCompleted, tsaFailed or tsaCancelled. There is also TsgcAsync.Delay(aMilliseconds), which returns a task that completes after a timeout and can be cancelled before it fires, and TsgcAsync.RunOnMainThread, which pushes a piece of code back to the main thread from inside a background task.

Awaiting an HTTP request

The HTTP client exposes futures directly, so you do not have to wrap the call yourself. GetFuture and PostFuture return an IsgcFuture<string> that resolves to the response body.

uses
  sgcHTTP_Client, sgcBase_AsyncAwait;

var
  oHTTP: TsgcHTTPClient;
  oFuture: IsgcFuture<string>;
begin
  oHTTP := TsgcHTTPClient.Create(nil);

  // the request runs on a background thread, this line returns immediately
  oFuture := oHTTP.GetFuture('https://httpbin.org/get');

  oFuture.ThenProc(
    procedure(const Value: string)
    begin
      // main thread: Value is the response body
      Memo1.Lines.Text := Value;
    end)
  .OnError(
    procedure(E: Exception)
    begin
      ShowMessage('GET failed: ' + E.Message);
    end);
end;

If you are already on a worker thread, or you are writing a console application, you can simply block and read the value:

var
  vBody: string;
begin
  vBody := oHTTP.GetFuture('https://httpbin.org/get').Await;
  WriteLn(vBody);
end;

Await re-raises whatever the background call raised, wrapped in an EsgcAsyncException that carries the original class name and message, so a plain try ... except around it still works. If the future was cancelled, Await raises EsgcTaskCancelled instead.

One warning that matters more than any other in this post. Calling Await on the main thread blocks the main thread, which in a VCL or FMX application means a frozen window until the call returns. In a form, use ThenProc. Keep Await for console applications, services, and code that is already running on a background thread. The HTTP client itself is not thread-safe, so give each concurrent request its own instance rather than sharing one across tasks.

Connecting a WebSocket client

Connecting used to mean setting Active := True and waiting for OnConnect to tell you it worked, or for OnError to tell you it did not. ConnectTask gives you the same thing as a task.

uses
  sgcWebSocket, sgcBase_AsyncAwait;

var
  oTask: IsgcTask;
begin
  sgcWebSocketClient1.Host := 'echo.esegece.com';
  sgcWebSocketClient1.Port := 443;
  sgcWebSocketClient1.TLS  := True;

  oTask := sgcWebSocketClient1.ConnectTask(10000); // 10 second timeout

  oTask.ThenProc(
    procedure
    begin
      // main thread: the connection is established and ready to send
      sgcWebSocketClient1.WriteData('hello');
    end)
  .OnError(
    procedure(E: Exception)
    begin
      ShowMessage('Could not connect: ' + E.Message);
    end);
end;

The connection attempt runs on a background thread and the timeout is a real timeout. If it expires, the task fails instead of hanging. Cancelling the task disconnects the socket, which unblocks a connect that is still waiting on the network.

Waiting for a connection without awaiting: Connect and LastError

Not everything needs a task. Sometimes you just want a line of code that connects and tells you whether it worked, which is what most people expected Active := True to do in the first place. 2026.7 adds a blocking Connect that returns only when the connection is really established, not merely started.

var
  vError: string;
begin
  // returns True only when the WebSocket connection is fully established
  if sgcWebSocketClient1.Connect(10000) then
    sgcWebSocketClient1.WriteData('hello')
  else
    // find out why, without wiring an OnError handler
    ShowMessage('Connect failed: ' + sgcWebSocketClient1.LastError);

  // or get the reason back directly from the call
  if not sgcWebSocketClient1.Connect(vError, 10000) then
    ShowMessage('Connect failed: ' + vError);

  // and a Disconnect that waits until the socket is really closed
  sgcWebSocketClient1.Disconnect(5000);
end;

This closes a race that used to bite during quick reconnects. Because Connect only returns True once the connection is fully up, nothing runs against a half-ready connection any more, and Disconnect waits until the socket is really closed before returning, so a reconnect loop cannot overlap the previous connection. LastError lives on the component and holds the reason the last attempt failed, so you can find out why a connect failed without subscribing to any event. It is cleared automatically at the start of each connection attempt, and ClearLastError resets it by hand.

Note that Connect blocks the calling thread, exactly like Await does. Use it from a worker thread or a console application, and use ConnectTask from a form.

Awaiting an AI chat

The AI client follows the same pattern. ChatAsync sends the prompt on a background thread and returns an IsgcFuture<string> that resolves to the model reply, so a chat call in a form is now three lines and no event handler.

uses
  sgcAI_Chat, sgcBase_AsyncAwait;

var
  oFuture: IsgcFuture<string>;
begin
  oFuture := sgcAIChat1.ChatAsync('Summarize this text in one sentence.');

  oFuture.ThenProc(
    procedure(const Value: string)
    begin
      // main thread: Value is the model answer
      MemoAnswer.Lines.Text := Value;
      ButtonSend.Enabled := True;
    end)
  .OnError(
    procedure(E: Exception)
    begin
      MemoAnswer.Lines.Text := 'Error: ' + E.Message;
      ButtonSend.Enabled := True;
    end);

  ButtonSend.Enabled := False; // the UI stays responsive while it runs
end;

Cancelling that future aborts the request in flight, so a user who closes the dialog or presses Stop does not keep an LLM call running in the background and paying for tokens they will never read.

Chaining instead of blocking (ThenProc / OnError)

It is worth being explicit about which thread runs what, because that is where callback code usually goes wrong. The work you pass to TsgcAsync.Run, and the request behind GetFuture, ConnectTask or ChatAsync, all run on a background thread. The ThenProc and OnError callbacks are queued back to the main thread, so touching a form inside them is safe.

Only one of the two fires. ThenProc runs when the operation succeeds, OnError runs when it raises, and neither runs if the task was cancelled. That is enough for the sequential case: start the next call from inside the previous ThenProc and the chain reads in the order it executes. If you are already inside a background task and need to touch the UI partway through, use TsgcAsync.RunOnMainThread.

TsgcAsync.Run(
  procedure
  var
    oHTTP: TsgcHTTPClient;
    vBody: string;
  begin
    // background thread
    oHTTP := TsgcHTTPClient.Create(nil);
    try
      TsgcAsync.RunOnMainThread(
        procedure
        begin
          StatusBar1.SimpleText := 'Downloading...';
        end);

      vBody := oHTTP.Get('https://httpbin.org/get');

      TsgcAsync.RunOnMainThread(
        procedure
        begin
          // main thread again
          Memo1.Lines.Text := vBody;
          StatusBar1.SimpleText := 'Done';
        end);
    finally
      oHTTP.Free;
    end;
  end);

Cancellation that really cancels

Most "cancel" implementations set a flag and ignore the result when it eventually arrives. The socket stays open, the request keeps running, and the thread stays busy. In sgcWebSockets each asynchronous call registers a cancel action with the task, so Cancel reaches the operation underneath: cancelling an HTTP future aborts the request, cancelling a ConnectTask disconnects the socket, and cancelling a ChatAsync aborts the AI call. The blocked worker thread wakes up instead of sitting there until a timeout expires.

That makes a timeout easy to build out of a Delay task and a cancel, which is the pattern shipped as example 18 in the library:

var
  oRequest: IsgcFuture<string>;
  oTimeout: IsgcTask;
begin
  oRequest := oHTTP.GetFuture('https://httpbin.org/delay/10');
  oTimeout := TsgcAsync.Delay(3000);

  // if the request answers first, drop the timeout
  oRequest.ThenProc(
    procedure(const Value: string)
    begin
      oTimeout.Cancel;
      Memo1.Lines.Text := Value;
    end);

  // if the timeout fires first, abort the request for real
  oTimeout.ThenProc(
    procedure
    begin
      if not oRequest.IsCompleted then
      begin
        oRequest.Cancel;
        ShowMessage('Request timed out');
      end;
    end);
end;

A cancelled task fires neither ThenProc nor OnError. Its State becomes tsaCancelled, IsCancelled returns True, and anyone blocked in Await gets an EsgcTaskCancelled. If you build your own task with TsgcAsync.Run and it wraps something abortable, register your own abort with SetCancelProc and Cancel will call it.

Delphi version support

The async/await unit uses generics and anonymous methods, so it needs Delphi 2010 or newer. On Delphi 7, 2007 and 2009 the unit compiles to an empty unit and the async methods are not there, so GetFuture, ConnectTask and ChatAsync are simply not available. Everything else in the library still supports Delphi 7 exactly as before, including the event-based API these methods sit next to. Nothing was removed and no existing code changes behaviour, the new calls are additions.

The blocking Connect, Disconnect and LastError described above are not generic, so they are available on every supported Delphi version.

Worked examples for all of this ship with the source in sgcBase_AsyncAwait_Examples.pas, covering tasks, futures, delays, cancellation, polling the state, parallel HTTP requests and the timeout watchdog above.

Availability

Tasks and futures are available in sgcWebSockets 2026.7 from Delphi 2010 through Delphi 13 and on C++Builder, across Win32/Win64, Linux64, macOS, Android and iOS. The blocking Connect, Disconnect and LastError are available on Delphi 7 through 13.

Customers with an active subscription can download the new build from the customer area. Trial users can grab the updated installer at esegece.com/products/websockets/download.

Questions, feedback or help porting a callback-based form to tasks? Get in touch, you will get a reply from the people who wrote the code.