ModernSyntax

1.0.0Open SourceCRA-ready

Changelog

All notable changes to ModernSyntax are documented here.

The format follows Keep a Changelog and the project aims at Semantic Versioning.

This file starts at v1.2.0; for anything older, read the git history.


Unreleased

⚠️ BREAKING — TAsync runs its routine once, and the task owns its state

Fixes #13, #14, #15, #16 and #17.

TAsync now keeps its state in a reference-counted object. The record is a handle; copies share the same task. The worker task holds the routine and a reference to the state through a link that lets go of both explicitly - after the routine ran, or when Cancel drops a routine that has not run - so nothing a routine captures (not even its own TAsync, as in the CheckCanceled pattern) keeps a reference cycle alive, and a fire-and-forget task keeps its state and AError until it ends.

  • One execution per TAsync (#15): Await after Run/NoAwait waits for the task that is already running, and a second Await returns the same outcome. Before, every Await started a new task, so Run + Await executed the routine twice. Who breaks: code that called Await repeatedly on the same TAsync to run the routine again; create a new Async(...) instead.
  • No lock across the wait (#13): Status, Cancel, GetId and CheckCanceled called from another thread return immediately during an Await, so a cooperative routine can now be cancelled while it is being awaited. Await on a cancelled task returns an error future instead of raising.
  • Continuation on the awaiting thread (#14): Await(AContinue) runs the continuation inline, on the thread that called Await, before returning; its exception is the error of the future. On the main thread this is what already happened; on any other thread the continuation used to be queued to the main thread and never ran in a console app or a service.
  • Fire-and-forget failures are observable (#16): when the routine raises, Status reports TTaskStatus.Exception (it used to be Completed) and a later Await returns the error. Run(AError)/NoAwait(AError) still deliver the handler through TThread.Queue.
  • Cancellation: Cancel before the routine runs (not started, or still queued) drops the routine, which never runs; Status reports Canceled and Await returns an error future.
  • Orphans are safe: with a finite deadline the orphan task no longer points at the record, so Async(...).Await(50) on a temporary is no longer a dangling-pointer hazard. Note that the RTL thread pool waits for running tasks at process exit, so a long orphan delays the exit.
  • A TAsync that was never created (Default(TAsync)) returns an error future instead of an access violation.
  • AError: the Exception passed to the handler is now freed after it returns.
  • New: AwaitAll and AwaitAny (#17): start several tasks and wait for all of them under one shared deadline (results in order), or for the first one to finish - completed, failed or cancelled. Like Await, an expired deadline does not cancel the tasks.

⚠️ BREAKING — Await(timeout) no longer reports success on a blown deadline

Merged in #7.

TAsync.Await discarded the Boolean returned by ITask.Wait(ATimeout) at all four await sites, so an expired deadline fell through to Result.SetOk(...). A blown deadline was reported as SUCCESS, and the TFunc overload even published an LValue the task might never have produced. Now an expired deadline yields IsErr with a message that says TIMEOUT and quotes the deadline in ms; the continuation does not run and nothing is published.

Who breaks: anyone who called Await(timeout) with a finite deadline and treated the result as "fire and forget with a grace period" — i.e. code that read IsOk after the deadline and carried on. That code used to see IsOk = True for work that had not finished; it now sees IsErr = True. Any branch keyed on IsOk inverts.

Who does not break: Await and Await(INFINITE) — the parameter default — are provably unchanged. _AwaitTimedOut only reports a timeout when ATimeout <> INFINITE, so the no-deadline path is byte-for-byte the old behaviour. This is the path every pre-existing caller who never passed a deadline is on.

Migration:

// before - "success" could mean "still running"
LFuture := LAsync.Await(500);
if LFuture.IsOk then
  ...

// after - decide explicitly what a blown deadline means
LFuture := LAsync.Await(500);
if LFuture.IsOk then
  ...                        // really finished
else if IsAwaitTimeout(LFuture) then
  ...                        // deadline blown, task still running
else
  ...                        // the task itself failed: LFuture.Err

To keep the old "no deadline" semantics, drop the argument: LAsync.Await.

Added

  • ModernSyntax.Async.IsAwaitTimeout(const AFuture: TFuture): Boolean — the supported way to tell a blown Await deadline from a task failure. Replaces Pos('TIMEOUT', LFuture.Err), which breaks on a reworded message and misfires on task messages that merely contain the word. The check is anchored at the start of the message.

Changed

  • The Await timeout message is now a frozen contract, declared as a const instead of a resourcestring so that translation tooling cannot silently patch it. Its ASCII prefix (Async await TIMEOUT:) must not be reworded or localized; the tail after the prefix may be improved. Rationale, including why TFuture did not get a discriminator field, is documented above the constant in Source/ModernSyntax.Async.pas.
  • TAsync.Await <remarks> now document that a timeout does not cancel the task and that the orphan keeps dereferencing @Self, so with a finite deadline the TAsync must be held in a named variable rather than awaited as a temporary (Async(...).Await(50)).
  • TResultPair.Dispose <remarks> now state that idempotence is per instance, not per object: two copies of the record hold the same pointer, so two copies with one Dispose each still free twice.
  • Corrected the "why there is no class operator Finalize" note in Source/ModernSyntax.ResultPair.pas: it cited ModernSyntax.inc, a Delphi 2010 floor and an FPC/Lazarus target, none of which hold. The unit does not include the .inc, no unit in the repository consumes any symbol it defines, the declared floor is Delphi XE (README.md:3, README.md:33), and the .inc's Lazarus block is dead code guarded by a typo (FCP). The decision itself is unchanged and still correct: managed records need 10.4+, which is above the XE floor.

Fixed

  • Restored the accented characters in two test files that were mangled to U+FFFD by an encoding change in #7: Test Delphi/EclbrResultPair/UTestMS.ResultPair.pas and Test Delphi/EclbrSystem/UTestMMS.Threading.pas. Both are back to the repository's CP1252, no-BOM convention for accented Pascal sources.

Documented (not fixed)

  • KNOWN BUG markers on TResultPair._ReturnSuccess / _ReturnFailure, tracked by #8: the Return chain discards the transformation (Result := Self is taken before the loop mutates Self), does not compose across steps, and leaves a dangling pointer (_Set*Value adopts a pointer that the following LResult.Dispose frees). For TResultPair<class, class> #7 changed how it shows up: it used to abort loudly with 'Value is nil.', it is now a silent use-after-free. The fix is an ownership refactor of the chain and belongs in its own PR.

1.2.0 - 2026-06-20

Fixed

  • dcclinux64 E2076: TResultPair<>.Failure / .Success were being called as class methods; changed to .New.Failure / .New.Success in ModernSyntax.Match and ModernSyntax.Option.

Added

  • Cross-platform build verified on Win32, Win64 and Linux64 (dcclinux64): POSIX setenv/unsetenv shim for TDotEnv, stderr fallback for OutputDebugString, AtomicIncrement in place of InterlockedIncrement64, and {$IFDEF MSWINDOWS} guards on bare uses Windows clauses.