Simplifying Event Code with Bindings
You already know how invoke queues a call and how captured values survive until dispatch.
Returning results from a worker often repeats the same two lambdas: one receives the result, and another calls its
recipient on the right loop.
An event binding packages that return path into a reusable callback.
This page develops that return path in two checksum applications, then separates required calculation results from
optional display updates in a pi calculator.
From Nested Callbacks to a Bound Continuation
The checksum applications scan files on an ordinary worker thread and return a file list to their main-loop workflow.
ChecksumPackerInvoke spells out the return path with invoke:
event/ChecksumPackerInvoke/src/Workflow.hpp
auto events = el::currentEvents();
auto workflow = shared_from_this();
_scanner.scan([events, workflow](FileWithDigestList fileList, std::exception_ptr error) -> void {
events->invoke([workflow, fileList = std::move(fileList), error = std::move(error)]() mutable -> void {
workflow->onScanned(std::move(fileList), std::move(error));
});
});
The outer callback receives the worker’s result.
It moves the file list into the inner callback, which retains the workflow and waits in main’s queue.
ChecksumPacker expresses that same continuation with bindEvent:
event/ChecksumPacker/src/Workflow.hpp
_scanner.scan(el::bindEvent(shared_from_this(), &Workflow::onScanned));
The returned callable accepts the arguments for Workflow::onScanned.
Calling it queues that method; it never calls the method synchronously, even when called on the receiving loop.
The member binding retains the shared workflow for as long as the binding or a queued delivery needs it.
Each call owns its argument storage: lvalues are copied, and rvalues are moved.
The scanner passes its file list with std::move, so the queued delivery owns those files until main receives them.
Stored arguments are delivered as rvalues, allowing the continuation to move the list onward to the packer.
Pointers, views, and explicit reference wrappers still borrow their referents; wrapping them does not extend their
lifetime.
Choosing the Receiving Loop
Workflow::start runs on main, so the binding without an explicit target captures main through currentEvents when
it is created.
Later calls from the scan worker return to that captured target.
Creating the same binding on a worker loop would capture the worker instead.
When you already have the intended EventsPtr, pass it explicitly:
auto onScanned = el::bindEvent(events, shared_from_this(), &Workflow::onScanned);
This form also works outside a running loop. Capturing a target keeps its object alive; it does not keep dispatch running. The receiving application still has to run its loop until required results have been handled.
Passing Results to a Bound Method
The workers keep ordinary callback interfaces. They need no knowledge of the receiving loop or event bindings:
event/ChecksumPacker/src/Scanner.hpp
/// The callback if the scan is done.
/// On success, nullptr is passed to indicate that no failures occurred.
/// On failure, a std::exception_ptr containing an exception is passed to indicate why the scan failed.
using OnDone = std::function<void(FileWithDigestList, std::exception_ptr)>;
void scan(OnDone onDone) {
_workerThread = std::thread{[onDone = std::move(onDone), this]() -> void { scanInThread(onDone); }};
}
event/ChecksumPacker/src/Packer.hpp
/// The callback if the packing is done.
/// On success, nullptr is passed to indicate that no failures occurred.
/// On failure, a std::exception_ptr containing an exception is passed to indicate why packing failed.
using OnDone = std::function<void(std::exception_ptr)>;
void pack(FileWithDigestList fileList, OnDone onDone) {
_workerThread = std::thread{[fileList = std::move(fileList), onDone = std::move(onDone), this]() -> void {
packInThread(fileList, onDone);
}};
}
A binding fits these std::function parameters through their normal constructors.
The pack callback uses the same transformation with just an error argument.
First, the explicit return path in ChecksumPackerInvoke:
event/ChecksumPackerInvoke/src/Workflow.hpp
auto events = el::currentEvents();
auto workflow = shared_from_this();
_packer.pack(std::move(fileList), [events, workflow](std::exception_ptr error) -> void {
events->invoke(
[workflow, error = std::move(error)]() mutable -> void { workflow->onPacked(std::move(error)); });
});
Then the bound continuation in ChecksumPacker:
event/ChecksumPacker/src/Workflow.hpp
_packer.pack(std::move(fileList), el::bindEvent(shared_from_this(), &Workflow::onPacked));
Both applications have the same state transitions, error reporting, archive contents, and worker shutdown ordering. Building Workflows Across Callbacks follows those operation decisions without repeating the transport mechanics.
Binding a Callable with Additional Context
A method binding passes result arguments directly to a method. A bound lambda can also retain context that belongs to the request. TextPreview creates its reply on main before submitting the worker job:
event/TextPreview/TextPreviewApp.cpp
void TextPreviewApp::requestPreview(el::String text) {
const auto requestId = ++_requestId;
// Bind the reply on main before sending work to another loop.
auto reply =
el::bindEvent([this, requestId](el::String preview) -> void { receivePreview(requestId, std::move(preview)); });
_worker->events()->invoke([reply = std::move(reply), text = std::move(text)]() -> void {
// Worker uses only captured input; main owns every application-state update.
reply(el::toUppercase(text));
});
}
The bound handler keeps the request identifier; the worker supplies only the computed preview.
invoke remains useful for the immediate worker submission, while the bound reply makes the return path reusable.
The application outlives the worker, so its borrowed this remains valid throughout the exchange.
Communicating Between Event Loops develops the obsolete-result checks and failure paths.
Copies of a binding share one handler, including its mutable state and move-only captures.
Each call nevertheless creates an independent argument delivery.
That distinction matters when a callback is copied into std::function or retained by several producers.
Retaining an Operation While Its Display Disappears
The pi calculator continuously calculates decimal digits on an ordinary thread.
Its main loop stores every accepted batch in chunked history and displays a scrollable window into that prefix.
Press S to stop calculation permanently and browse the retained digits, or Q / Escape to quit.
Arrow keys, PageUp/PageDown, Home/End scroll; F follows the newest digits again.
--timeout N stops and exits after N seconds, including while browsing; zero means unlimited.
--digits N calculates exactly N fractional digits and exits.
PiCalculatorApp inherits Application::main() to run the main loop and propagate callback failures.
Option processing queues setup before that loop starts dispatching:
event/PiCalculator/src/PiCalculatorApp.cpp
// Start the application as soon the main loop has started.
events()->invoke([this]() -> void { begin(); });
begin() therefore creates its implicit-target bindings while main is the current loop.
The application owns required operation state separately from its optional view:
event/PiCalculator/src/PiCalculatorApp.hpp
std::shared_ptr<DigitHistory> _history; ///< Required data, independent of presentation.
std::shared_ptr<PiSession> _session; ///< Retained until the calculation thread has joined.
std::shared_ptr<PiView> _view; ///< Optional recipient, released when the user quits.
PiSession binds batch and final callbacks strongly to itself on main.
The worker’s interfaces remain ordinary std::function callbacks:
event/PiCalculator/src/PiWorker.hpp
/// Receive one owned batch of fractional digits on the calculation thread.
using OnDigits = std::function<void(el::String)>;
/// Receive exactly one final notification; nullptr means ordinary completion or cancellation.
using OnFinished = std::function<void(std::exception_ptr)>;
The session supplies both receiving methods:
event/PiCalculator/src/PiSession.cpp
void PiSession::start(const std::optional<el::CpCount> limit) {
if (_started) {
throw el::LogicFailure{"A pi calculation session starts once."_el};
}
// Required results retain the session and always return to its owner loop.
_worker.start(
limit,
el::bindEvent(_events, shared_from_this(), &PiSession::onDigits),
el::bindEvent(_events, shared_from_this(), &PiSession::onFinished));
_started = true;
}
After main stores each batch, the session requests the next one. At most one batch is outstanding, so calculation cannot accumulate a backlog while main is busy. Batches contain at most 32 fractional digits; a nonempty partial batch is flushed after roughly 20 ms of calculation. Optional display notification does not control demand or completion.
The application supplies that notification through bindWeakEvent:
event/PiCalculator/src/PiCalculatorApp.cpp
// Optional presentation must not keep the view alive after Q or timeout releases it.
updated = el::bindWeakEvent(std::weak_ptr<PiView>{_view}, &PiView::onUpdated);
The weak member binding checks its recipient on main during dispatch. If the view has disappeared, the queued delivery makes no call. Once a weak recipient is locked successfully, it stays alive through that method call. The session holds no strong path back to the view, and losing that view cannot discard a required calculation result.
The arithmetic is isolated in PiDigitGenerator, using growing exact BigInteger values and the streaming
algorithm from section 5 of Gibbons’s corrected paper.
History retains the entire computed prefix, so memory use grows as calculation continues.
Plain mode provides deterministic output without interactive screen controls:
event/PiCalculator/src/PiCalculatorApp.cpp
void PiCalculatorApp::onCalculationFinished(std::exception_ptr error) {
if (error) {
std::rethrow_exception(error);
}
if (_plain) {
printNewDigits();
terminal()->printLine();
}
// S leaves the stopped view available even with --digits. Only normal finite completion exits automatically.
const auto finiteCompletion = _limit.has_value() && !_session->isStopping();
if (_exitRequested || finiteCompletion || _plain) {
quit();
}
}
$ event/pi_calculator --plain --digits 100
3.1415926535897932384626433832795028841971693993751058209749445923078164062862089986280348
253421170679
An interactive capture after S shows the retained prefix ready for browsing. The number of completed digits depends on when you stop:
Pi = 3. │ 255 fractional digits │ Stopped - browse the retained digits
1 | 1415926535 8979323846 2643383279 5028841971 6939937510
51 | 5820974944 5923078164 0628620899 8628034825 3421170679
101 | 8214808651 3282306647 0938446095 5058223172 5359408128
151 | 4811174502 8410270193 8521105559 6446229489 5493038196
201 | 4428810975 6659334461 2847564823 3786783165 2712019091
251 | 45648
301 |
351 |
401 |
451 |
501 |
551 |
601 |
651 |
701 |
751 |
[S] Stop [Q/Esc] Quit [↑/↓/PgUp/PgDown/Home/End] Scroll [F] Follow (follow)
Finishing Work After the User Quits
The quit path releases the optional view first, requests cancellation, and keeps main dispatching:
event/PiCalculator/src/PiCalculatorApp.cpp
void PiCalculatorApp::requestExit() {
_exitRequested = true;
// Release optional UI immediately, but keep main open for strongly bound required completion.
_view.reset();
if (_session->isFinished()) {
quit();
} else {
_session->requestStop();
}
}
The worker checks cancellation between arithmetic advancements, reports final completion through its strong binding, and is joined by the session on main. Only then does the application quit its event loop and release the session. One large-integer operation remains non-interruptible; cancellation does not promise a fixed shutdown latency. Cancelling Work and Shutting Down explains this completion sequence and its exception cleanup in detail.
Weak delivery is suitable for optional presentation, while required completion needs an owner that stays alive. Destroying a binding does not cancel its queued deliveries, and handler destruction has no receiving-thread guarantee. For complete ownership, threading, argument-transfer, and failure contracts, see Bound Callbacks.
Complete demo sources
event/ChecksumPackerInvoke/src/Workflow.hpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include "Packer.hpp"
#include "Scanner.hpp"
#include "State.hpp"
#include "Workflow_fwd.hpp"
#include <erbsland/CurrentEvents.hpp>
#include <erbsland/Events.hpp>
#include <erbsland/LogStream.hpp>
#include <erbsland/Path.hpp>
#include <exception>
#include <functional>
#include <memory>
#include <utility>
namespace demo {
using namespace el::text::literals;
/// Coordinate scanning and packing while keeping all state transitions on the main event loop.
/// Workers return captured results through events; the application owns the workflow until both workers join.
class Workflow final : public std::enable_shared_from_this<Workflow> {
public:
/// The callback if the workflow is done.
/// On success, nullptr is passed to indicate that no failures occurred.
/// On failure, a std::exception_ptr containing an exception is passed to indicate why the workflow failed.
using OnDone = std::function<void(std::exception_ptr)>;
public:
/// Create a new workflow from a source directory.
/// @param sourceDirectory Path to the source directory.
/// @param zipPath The path for the final ZIP file.
/// @param logStream The log stream to write output to.
/// @param onDone Called on the main event loop after success or failure.
explicit Workflow(el::Path sourceDirectory, el::Path zipPath, el::LogStreamPtr logStream, OnDone onDone) :
_logStream{std::move(logStream)},
_onDone{std::move(onDone)},
_state{_logStream},
_scanner{sourceDirectory, _logStream},
_packer{std::move(sourceDirectory), std::move(zipPath), _logStream} {}
public: // events
/// Start the workflow for this working set.
void start() {
_logStream->trace("Starting workflow"_el);
_state.requireAndSet(State::Initialized, State::Scanning);
auto events = el::currentEvents();
auto workflow = shared_from_this();
_scanner.scan([events, workflow](FileWithDigestList fileList, std::exception_ptr error) -> void {
events->invoke([workflow, fileList = std::move(fileList), error = std::move(error)]() mutable -> void {
workflow->onScanned(std::move(fileList), std::move(error));
});
});
}
/// Test if the workflow is done.
[[nodiscard]] auto isDone() const noexcept -> bool { return _state.is(State::Done); }
/// Shutdown the workflow.
void shutdown() {
_scanner.shutdown();
_packer.shutdown();
}
private:
/// Receive the scan result on the main loop, joining the worker before starting packing.
void onScanned(FileWithDigestList fileList, std::exception_ptr error) {
_scanner.shutdown();
if (error != nullptr) {
_state.requireAndSet(State::Scanning, State::Failed);
_onDone(std::move(error));
return;
}
_state.requireAndSet(State::Scanning, State::Packing);
auto events = el::currentEvents();
auto workflow = shared_from_this();
_packer.pack(std::move(fileList), [events, workflow](std::exception_ptr error) -> void {
events->invoke(
[workflow, error = std::move(error)]() mutable -> void { workflow->onPacked(std::move(error)); });
});
}
/// Receive one packing result and notify the application after the worker has joined.
void onPacked(std::exception_ptr error) {
_packer.shutdown();
if (error != nullptr) {
_state.requireAndSet(State::Packing, State::Failed);
} else {
_state.requireAndSet(State::Packing, State::Done);
}
_onDone(std::move(error));
}
private:
// readonly setup data:
const el::LogStreamPtr _logStream; ///< The log stream to write output to during the operation.
const OnDone _onDone; ///< Callback for when the workflow is done.
// owned by this working set and its events:
State _state; ///< The state of the working set.
// the workflow components
Scanner _scanner; ///< The scanner logic.
Packer _packer; ///< The packer logic.
};
}
event/ChecksumPacker/src/Workflow.hpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include "Packer.hpp"
#include "Scanner.hpp"
#include "State.hpp"
#include "Workflow_fwd.hpp"
#include <erbsland/BindEvent.hpp>
#include <erbsland/LogStream.hpp>
#include <erbsland/Path.hpp>
#include <exception>
#include <functional>
#include <memory>
#include <utility>
namespace demo {
using namespace el::text::literals;
/// Coordinate scanning and packing while keeping all state transitions on the main event loop.
/// Workers return captured results through events; the application owns the workflow until both workers join.
class Workflow final : public std::enable_shared_from_this<Workflow> {
public:
/// The callback if the workflow is done.
/// On success, nullptr is passed to indicate that no failures occurred.
/// On failure, a std::exception_ptr containing an exception is passed to indicate why the workflow failed.
using OnDone = std::function<void(std::exception_ptr)>;
public:
/// Create a new workflow from a source directory.
/// @param sourceDirectory Path to the source directory.
/// @param zipPath The path for the final ZIP file.
/// @param logStream The log stream to write output to.
/// @param onDone Called on the main event loop after success or failure.
explicit Workflow(el::Path sourceDirectory, el::Path zipPath, el::LogStreamPtr logStream, OnDone onDone) :
_logStream{std::move(logStream)},
_onDone{std::move(onDone)},
_state{_logStream},
_scanner{sourceDirectory, _logStream},
_packer{std::move(sourceDirectory), std::move(zipPath), _logStream} {}
public: // events
/// Start the workflow for this working set.
void start() {
_logStream->trace("Starting workflow"_el);
_state.requireAndSet(State::Initialized, State::Scanning);
_scanner.scan(el::bindEvent(shared_from_this(), &Workflow::onScanned));
}
/// Test if the workflow is done.
[[nodiscard]] auto isDone() const noexcept -> bool { return _state.is(State::Done); }
/// Shutdown the workflow.
void shutdown() {
_scanner.shutdown();
_packer.shutdown();
}
private:
/// Receive the scan result on the main loop, joining the worker before starting packing.
void onScanned(FileWithDigestList fileList, std::exception_ptr error) {
_scanner.shutdown();
if (error != nullptr) {
_state.requireAndSet(State::Scanning, State::Failed);
_onDone(std::move(error));
return;
}
_state.requireAndSet(State::Scanning, State::Packing);
_packer.pack(std::move(fileList), el::bindEvent(shared_from_this(), &Workflow::onPacked));
}
/// Receive one packing result and notify the application after the worker has joined.
void onPacked(std::exception_ptr error) {
_packer.shutdown();
if (error != nullptr) {
_state.requireAndSet(State::Packing, State::Failed);
} else {
_state.requireAndSet(State::Packing, State::Done);
}
_onDone(std::move(error));
}
private:
// readonly setup data:
const el::LogStreamPtr _logStream; ///< The log stream to write output to during the operation.
const OnDone _onDone; ///< Callback for when the workflow is done.
// owned by this working set and its events:
State _state; ///< The state of the working set.
// the workflow components
Scanner _scanner; ///< The scanner logic.
Packer _packer; ///< The packer logic.
};
}
event/ChecksumPacker/src/Scanner.hpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include "Constants.hpp"
#include "FileWithDigest.hpp"
#include <erbsland/all_path.hpp>
#include <erbsland/ApplicationError.hpp>
#include <erbsland/ByteCount.hpp>
#include <erbsland/ByteInputStream.hpp>
#include <erbsland/HashAlgorithm.hpp>
#include <erbsland/Hasher.hpp>
#include <erbsland/LogStream.hpp>
#include <erbsland/Path.hpp>
#include <erbsland/PathContent.hpp>
#include <erbsland/PathType.hpp>
#include <erbsland/PathWalkOptions.hpp>
#include <erbsland/PathWalkStatus.hpp>
#include <erbsland/RuntimeError.hpp>
#include <erbsland/StringList.hpp>
#include <erbsland/SymlinkMode.hpp>
#include <atomic>
#include <exception>
#include <functional>
#include <thread>
#include <utility>
namespace demo {
using namespace el::text::literals;
/// Scan regular files and calculate SHA3-256 checksums on a worker thread.
/// The owner cancels and joins the worker before releasing the scanner.
class Scanner final {
public:
/// The callback if the scan is done.
/// On success, nullptr is passed to indicate that no failures occurred.
/// On failure, a std::exception_ptr containing an exception is passed to indicate why the scan failed.
using OnDone = std::function<void(FileWithDigestList, std::exception_ptr)>;
public:
/// Create a scanner.
/// @param sourceDirectory The directory to scan recursively.
/// @param logStream The stream for worker diagnostics.
Scanner(el::Path sourceDirectory, el::LogStreamPtr logStream) :
_sourceDirectory{std::move(sourceDirectory)}, _logStream{std::move(logStream)} {}
public: // worker lifecycle
/// Start the scan operation in a background thread.
/// @param onDone Callback to invoke when the scanning is finished (either successfully or with an error).
void scan(OnDone onDone) {
_workerThread = std::thread{[onDone = std::move(onDone), this]() -> void { scanInThread(onDone); }};
}
/// Shutdown the workflow.
void shutdown() {
_shutdown = true;
if (_workerThread.joinable()) {
_workerThread.join();
}
}
private:
/// Scan all files and create their checksums.
/// @param onDone Callback to invoke when the scanning is finished (either successfully or with an error).
void scanInThread(const OnDone &onDone) {
// This function runs in its own thread.
std::exception_ptr error;
try {
_logStream->trace("Start scanning"_el);
// Skip symlinks so a package stays within its selected source tree.
auto options = el::PathWalkOptions{}
.setTypes(el::PathTypes{el::PathType::RegularFile})
.setSymlinkMode(el::SymlinkMode::Skip);
_sourceDirectory.walker().walkOrThrow(
[this](const el::Path &path) -> el::PathWalkStatus { return scanOneFile(path); }, options);
_logStream->trace("Scanning done"_el);
} catch (const el::RuntimeError &) {
error = std::current_exception();
}
if (!_shutdown) {
onDone(_entries, std::move(error));
}
}
/// Read a single file and calculate its checksum.
auto scanOneFile(const el::Path &path) -> el::PathWalkStatus {
const static auto cErrorTitle = "Failed to read a file and calculate its checksum"_el;
const auto relativePath = path.toRelativeOrThrow(_sourceDirectory);
_logStream->trace("Calculating checksum for file: "_el, relativePath);
if (_shutdown) {
return el::PathWalkStatus::Stop;
}
try {
if (relativePath.name() == cChecksumFileName) {
throw el::ApplicationError{
cErrorTitle,
"The filename conflicts with the reserved 'checksum.txt' name."_el,
};
}
// prepare the hash algorithm.
el::Hasher hasher{el::HashAlgorithm::Sha3_256};
// open the file for reading, using default options.
auto inputStream = path.content().openByteInputStream();
while (true) {
if (_shutdown) {
return el::PathWalkStatus::Stop;
}
// try to read a good amount of bytes for each iteration.
auto readResult = inputStream->read(el::ByteCount{0x10000U});
if (readResult.isTimeout()) {
throw el::ApplicationError{"Timed out reading a file."_el}.withSourcePath(path.toString());
}
if (readResult.isFinished()) {
break;
}
hasher.update(readResult.takeData());
}
inputStream->close();
auto digest = hasher.finalize();
_entries.append(FileWithDigest{path, digest});
return el::PathWalkStatus::Continue;
} catch (const el::ApplicationError &error) {
throw error.withSourcePath(path.toString());
} catch (const el::RuntimeError &) {
throw el::ApplicationError{
cErrorTitle,
"There was an unexpected error while reading a file."_el,
std::current_exception(),
}
.withSourcePath(path.toString());
}
}
private:
// read-only setup attributes:
const el::Path _sourceDirectory; ///< The source directory that is being worked on.
const el::LogStreamPtr _logStream; ///< The log stream to write output to during the operation.
// variables owned by this part and worker thread:
std::thread _workerThread; ///< The worker thread for scanning and zipping files.
std::atomic<bool> _shutdown{false}; ///< If a shutdown has been requested.
FileWithDigestList _entries; ///< A list of found files and their checksums.
};
}
event/ChecksumPacker/src/Packer.hpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include "Constants.hpp"
#include "FileWithDigest.hpp"
#include <erbsland/all_compression_zip.hpp>
#include <erbsland/all_path.hpp>
#include <erbsland/AnyStringBuilder.hpp>
#include <erbsland/ApplicationError.hpp>
#include <erbsland/LogStream.hpp>
#include <erbsland/Path.hpp>
#include <erbsland/RuntimeError.hpp>
#include <erbsland/StringEncoder.hpp>
#include <erbsland/StringEncoding.hpp>
#include <erbsland/StringFormat.hpp>
#include <erbsland/zip/ArchiveWriter.hpp>
#include <atomic>
#include <exception>
#include <functional>
#include <thread>
#include <utility>
namespace demo {
using namespace el::text::literals;
/// Verify scanned file contents and publish a ZIP with a SHA3-256 manifest.
/// Each file is buffered within the demo limit so its checksum describes the exact bytes archived.
class Packer final {
public:
/// The callback if the packing is done.
/// On success, nullptr is passed to indicate that no failures occurred.
/// On failure, a std::exception_ptr containing an exception is passed to indicate why packing failed.
using OnDone = std::function<void(std::exception_ptr)>;
public:
/// Create a new packer instance.
/// @param sourceDirectory The source directory, to get the relative paths for the zip entries.
/// @param zipPath The path of the new zip file.
/// @param logStream The logging stream to output messages to.
Packer(el::Path sourceDirectory, el::Path zipPath, el::LogStreamPtr logStream) :
_sourceDirectory{std::move(sourceDirectory)}, _zipPath{std::move(zipPath)}, _logStream{std::move(logStream)} {}
public: // worker lifecycle
/// Pack a list of files into a given ZIP file, adding `checksum.txt` to each ZIP.
/// This runs in a separate thread.
/// @param fileList The list of files and their checksums.
/// @param onDone Callback if the packing is done.
void pack(FileWithDigestList fileList, OnDone onDone) {
_workerThread = std::thread{[fileList = std::move(fileList), onDone = std::move(onDone), this]() -> void {
packInThread(fileList, onDone);
}};
}
/// Shutdown the workflow.
void shutdown() {
_shutdown = true;
if (_workerThread.joinable()) {
_workerThread.join();
}
}
private:
/// Run packing in a separate thread.
/// @param fileList The list of files and their checksums.
/// @param onDone Callback if the packing is done.
void packInThread(const FileWithDigestList &fileList, const OnDone &onDone) {
if (_shutdown) {
return;
}
const auto mainfestLineFormat = el::StringFormat{"{:bytes:} {}\n"};
std::exception_ptr error;
try {
_logStream->trace("Start packing files"_el);
auto archiveWriter = el::zip::ArchiveWriter::create(_zipPath);
auto manifest = el::AnyStringBuilder::u8();
for (const auto &entry : fileList) {
if (_shutdown) {
return;
}
archiveWriter->addFile(entry.filePath);
mainfestLineFormat.appendTo(manifest, entry.digest, entry.filePath.toRelative(_sourceDirectory));
}
archiveWriter->addData(
el::StringEncoder{manifest.takeString()}.encode(el::StringEncoding::Utf8), el::Path{cChecksumFileName});
if (_shutdown) {
return;
}
archiveWriter->finalize();
_logStream->trace("Packing finished"_el);
} catch (const el::RuntimeError &) {
error = std::current_exception();
}
if (!_shutdown) {
onDone(std::move(error));
}
}
private:
// read-only setup attributes:
const el::Path _sourceDirectory; ///< The source directory that is being worked on.
const el::Path _zipPath; ///< The path of the zip file.
const el::LogStreamPtr _logStream; ///< The logging stream to output messages to.
// variables owned by this part and worker thread:
std::thread _workerThread; ///< The worker thread for scanning and zipping files.
std::atomic<bool> _shutdown{false}; ///< If a shutdown has been requested.
};
}
event/TextPreview/TextPreviewApp.cpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include "TextPreviewApp.hpp"
#include <erbsland/all_stream.hpp>
#include <erbsland/all_time.hpp>
#include <erbsland/ApplicationError.hpp>
#include <erbsland/Char.hpp>
#include <erbsland/EventLoopErrorAction.hpp>
#include <erbsland/EventLoopErrorPolicy.hpp>
#include <erbsland/EventLoopOptions.hpp>
#include <erbsland/ExitCode.hpp>
#include <erbsland/LogicFailure.hpp>
#include <erbsland/Options.hpp>
#include <erbsland/OptionType.hpp>
#include <erbsland/OptionValues.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>
#include <erbsland/StringTransformations.hpp>
#include <exception>
#include <utility>
namespace demo {
using namespace el::text::literals;
void TextPreviewApp::initialize() {
info().setApplicationName("Text Preview"_el);
}
void TextPreviewApp::registerCommandLineOptions(const el::OptionsPtr &options) {
options->addOption({"--scenario"_el, "scenario"_el})
.setType(el::OptionType::Text)
.setDefaultValue("replacement"_el)
.setHelp("Scenario: single, replacement, failure, or missing."_el);
}
auto TextPreviewApp::main() -> el::ExitCode {
_scenario = optionValues()->getText("scenario"_el);
if (_scenario != "single"_el && _scenario != "replacement"_el && _scenario != "failure"_el &&
_scenario != "missing"_el) {
throw el::ApplicationError{"Unknown preview scenario."_el};
}
_worker = createEventThread(el::EventLoopOptions{}.setErrorPolicy(el::EventLoopErrorPolicy::FailApplication));
_worker->start();
eventLoop().setErrorHandler(
[](std::exception_ptr) -> el::EventLoopErrorAction { return el::EventLoopErrorAction::Stop; });
events()->invoke([this]() -> void { begin(); });
const auto exitCode = runEventLoop();
if (const auto error = eventLoop().takeError()) {
std::rethrow_exception(error);
}
// Missing completion must not look like a successful application exit.
auto expectedReplies = 1;
auto expectedPreview = "FIRST DRAFT"_el;
if (_scenario == "replacement"_el) {
expectedReplies = 2;
expectedPreview = "REVISED DRAFT"_el;
}
if (_received != expectedReplies || _accepted != 1 || _preview != expectedPreview) {
throw el::ApplicationError{"The preview exchange did not complete correctly."_el};
}
return exitCode;
}
void TextPreviewApp::begin() {
_deadline = events()->createTimer(
[]() -> void { throw el::ApplicationError{"Timed out waiting for preview replies."_el}; });
_deadline->startOnce(el::Seconds{1});
if (_scenario == "failure"_el) {
_worker->events()->invoke([]() -> void { throw el::ApplicationError{"Preview worker failed."_el}; });
return;
}
if (_scenario == "missing"_el) {
return;
}
// Submit initial input, then optionally replace it before main can dispatch a reply.
requestPreview("First draft"_el);
if (_scenario == "replacement"_el) {
requestPreview("Revised draft"_el);
}
}
void TextPreviewApp::requestPreview(el::String text) {
const auto requestId = ++_requestId;
// Bind the reply on main before sending work to another loop.
auto reply =
el::bindEvent([this, requestId](el::String preview) -> void { receivePreview(requestId, std::move(preview)); });
_worker->events()->invoke([reply = std::move(reply), text = std::move(text)]() -> void {
// Worker uses only captured input; main owns every application-state update.
reply(el::toUppercase(text));
});
}
void TextPreviewApp::receivePreview(const std::uint64_t requestId, el::String preview) {
if (el::currentEvents() != events()) {
throw el::LogicFailure{"Preview delivered to the wrong loop."_el};
}
++_received;
if (requestId == _requestId) {
_preview = std::move(preview);
++_accepted;
el::io::printLine("Accepted preview: "_el, _preview);
} else {
el::io::printLine("Ignored obsolete preview."_el);
}
if (_received == static_cast<int>(_requestId)) {
_deadline->stop();
quit();
}
}
void TextPreviewApp::cleanup() noexcept {
_deadline.reset();
}
}
event/PiCalculator/src/PiCalculatorApp.cpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include "PiCalculatorApp.hpp"
#include <DemoTerminal.hpp>
#include <erbsland/ApplicationError.hpp>
#include <erbsland/BindEvent.hpp>
#include <erbsland/CpCount.hpp>
#include <erbsland/CpIndex.hpp>
#include <erbsland/cterm/Input.hpp>
#include <erbsland/CurrentEvents.hpp>
#include <erbsland/EventLoop.hpp>
#include <erbsland/OptionError.hpp>
#include <erbsland/Options.hpp>
#include <erbsland/OptionType.hpp>
#include <erbsland/OptionValues.hpp>
#include <erbsland/TimeAmounts.hpp>
#include <exception>
#include <utility>
namespace demo {
using namespace el::text::literals;
void PiCalculatorApp::initialize() {
DemoTerminal::enable(*this);
info().setApplicationName("Pi Calculator"_el);
}
void PiCalculatorApp::registerCommandLineOptions(const el::OptionsPtr &options) {
options->setHelpDescription("Calculate exact decimal digits of pi while browsing a live terminal display."_el);
options->addOption({"--digits"_el, "digits"_el})
.setType(el::OptionType::Integer)
.setHelp("Calculate this many fractional digits and exit; omitted means unlimited."_el);
options->addOption({"--timeout"_el, "timeout"_el})
.setType(el::OptionType::Integer)
.setDefaultValue(0)
.setHelp("Stop and exit after this many seconds; zero means unlimited."_el);
options->addOption({"--plain"_el, "plain"_el})
.setHelp(
"Stream digits without screen controls. Interactive keys: S stop, Q/Esc quit, F follow, arrows scroll."_el);
}
void PiCalculatorApp::handleCommandLineOptions(const el::OptionValuesPtr &values) {
const auto timeout = values->getInteger("timeout"_el);
if (timeout < 0) {
throw el::OptionError{
"The timeout must be non-negative."_el,
values->value("timeout"_el),
};
}
_timeout = el::Seconds{timeout};
if (values->value("digits"_el)) {
const auto digits = values->getInteger("digits"_el);
if (!std::in_range<std::size_t>(digits)) {
throw el::OptionError{
"The fractional digit count must be a non-negative size."_el,
values->value("digits"_el),
};
}
_limit = el::CpCount::fromSizeTOrThrow(static_cast<std::size_t>(digits));
}
_plain = values->getFlag("plain"_el) || !terminal()->isInteractive();
// Start the application as soon the main loop has started.
events()->invoke([this]() -> void { begin(); });
}
void PiCalculatorApp::begin() {
_history = std::make_shared<DigitHistory>();
PiSession::OnUpdated updated;
if (_plain) {
terminal()->print("3."_el);
updated = [this]() -> void { printNewDigits(); };
} else {
terminal()->input().setMode(el::cterm::Input::Mode::Key);
_view = std::make_shared<PiView>(terminal(), _history);
// Optional presentation must not keep the view alive after Q or timeout releases it.
updated = el::bindWeakEvent(std::weak_ptr<PiView>{_view}, &PiView::onUpdated);
}
_session = std::make_shared<PiSession>(
el::currentEvents(), _history, std::move(updated), [this](std::exception_ptr error) -> void {
onCalculationFinished(std::move(error));
});
if (_view) {
_view->setSession(_session);
}
_session->start(_limit);
_refresh = events()->createTimer([this]() -> void { tick(); });
_refresh->startFixedDelay(el::Milliseconds{33});
if (_timeout.isPositive()) {
_deadline = events()->createTimer([this]() -> void { requestExit(); });
_deadline->startOnce(_timeout);
}
}
void PiCalculatorApp::tick() {
if (!_view) {
return;
}
const auto action = _view->pollKeys();
if (action == PiView::Action::Quit) {
requestExit();
return;
}
if (action == PiView::Action::Stop) {
_session->requestStop();
}
_view->render();
}
void PiCalculatorApp::printNewDigits() {
const auto count = _history->count();
terminal()->print(_history->range({el::CpIndex::end(_printed), el::CpCount::infinite()}));
terminal()->flush();
_printed = count;
}
void PiCalculatorApp::requestExit() {
_exitRequested = true;
// Release optional UI immediately, but keep main open for strongly bound required completion.
_view.reset();
if (_session->isFinished()) {
quit();
} else {
_session->requestStop();
}
}
void PiCalculatorApp::onCalculationFinished(std::exception_ptr error) {
if (error) {
std::rethrow_exception(error);
}
if (_plain) {
printNewDigits();
terminal()->printLine();
}
// S leaves the stopped view available even with --digits. Only normal finite completion exits automatically.
const auto finiteCompletion = _limit.has_value() && !_session->isStopping();
if (_exitRequested || finiteCompletion || _plain) {
quit();
}
}
void PiCalculatorApp::cleanup() noexcept {
_view.reset();
if (_refresh) {
_refresh->stop();
_refresh.reset();
}
if (_deadline) {
_deadline->stop();
_deadline.reset();
}
if (_session) {
_session->shutdown();
_session.reset();
}
}
}
event/PiCalculator/src/PiCalculatorApp.hpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include "DigitHistory.hpp"
#include "PiSession.hpp"
#include "PiView.hpp"
#include <erbsland/Application.hpp>
#include <erbsland/CpCount.hpp>
#include <erbsland/cterm/TerminalSession.hpp>
#include <erbsland/EventTimer.hpp>
#include <erbsland/Options.hpp>
#include <erbsland/OptionValues.hpp>
#include <erbsland/TimeDelta.hpp>
#include <cstddef>
#include <memory>
#include <optional>
namespace demo {
/// Run a cancellable pi calculation while main handles keyboard input, scrolling, and timeout.
/// Required callbacks retain the session; optional presentation uses a weak event binding.
/// @notest{Application scenarios and terminal restoration verified through command-line and PTY checks.}
class PiCalculatorApp final : public el::Application {
public:
using Application::Application;
protected: // implement Application
void initialize() override;
void registerCommandLineOptions(const el::OptionsPtr &options) override;
void handleCommandLineOptions(const el::OptionValuesPtr &values) override;
void cleanup() noexcept override;
private:
/// Construct operation state and bind delivery while main is the current loop.
void begin();
/// Poll input without waiting and redraw from main-loop state.
void tick();
/// Stream the accepted prefix not yet written in plain mode.
void printNewDigits();
/// Release optional presentation and wait for required cancellation completion.
void requestExit();
/// Exit after joining, or retain the stopped view for browsing.
void onCalculationFinished(std::exception_ptr error);
private:
std::shared_ptr<DigitHistory> _history; ///< Required data, independent of presentation.
std::shared_ptr<PiSession> _session; ///< Retained until the calculation thread has joined.
std::shared_ptr<PiView> _view; ///< Optional recipient, released when the user quits.
el::EventTimerPtr _refresh; ///< Main-loop input and redraw timer.
el::EventTimerPtr _deadline; ///< Exit deadline, independent of stop-and-browse.
std::optional<el::CpCount> _limit; ///< Optional finite fractional-digit count.
el::TimeDelta _timeout; ///< Zero means no application deadline.
el::CpCount _printed{0}; ///< Plain-output prefix already written.
bool _plain{false}; ///< Stream digits without full-screen controls.
bool _exitRequested{false}; ///< Exit waits for required completion.
};
}
event/PiCalculator/src/PiWorker.hpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include "PiDigitGenerator.hpp"
#include <erbsland/Char.hpp>
#include <erbsland/CpCount.hpp>
#include <erbsland/String.hpp>
#include <atomic>
#include <condition_variable>
#include <cstddef>
#include <exception>
#include <functional>
#include <mutex>
#include <optional>
#include <thread>
namespace demo {
/// Calculate one requested batch at a time on an ordinary thread, with cooperative cancellation.
/// The owner retains this worker until join; callbacks are independent of the event and terminal APIs.
/// @tested{PiWorkerTest PiSessionTest}
class PiWorker final {
public:
/// Receive one owned batch of fractional digits on the calculation thread.
using OnDigits = std::function<void(el::String)>;
/// Receive exactly one final notification; nullptr means ordinary completion or cancellation.
using OnFinished = std::function<void(std::exception_ptr)>;
/// Advance the arithmetic once, optionally producing a digit; used to inject failures in tests.
using Advance = std::function<std::optional<el::Char>()>;
public:
/// Create a worker using the exact spigot algorithm, or an injected advancement function.
/// @param advance An alternate calculation step; empty selects PiDigitGenerator.
explicit PiWorker(Advance advance = {});
/// Request cancellation and join as a cleanup fallback; never destroy this worker on its own thread.
~PiWorker();
// defaults
PiWorker(const PiWorker &) = delete;
PiWorker(PiWorker &&) = delete;
auto operator=(const PiWorker &) -> PiWorker & = delete;
auto operator=(PiWorker &&) -> PiWorker & = delete;
public: // lifecycle
/// Start once with demand for the first batch.
/// @param limit Optional number of fractional digits, excluding the integer part.
/// @param onDigits Called for each batch, before final completion.
/// @param onFinished Called when no further batches will be produced.
void start(std::optional<el::CpCount> limit, OnDigits onDigits, OnFinished onFinished);
/// Request on the owner thread one next batch after receiving the preceding batch; never accumulate requests.
/// The worker must have started, and no other request may be pending.
void requestNextBatch();
/// Wake a waiting worker or stop arithmetic at its next cancellation point; safe from any thread.
void requestStop() noexcept;
/// Join a started worker from its owner thread; does not depend on main-loop dispatch.
void join();
private:
/// Report calculation failure or cancellation through one required final callback.
void run(std::optional<el::CpCount> limit, const OnDigits &onDigits, const OnFinished &onFinished);
/// Consume one request or stop without performing arithmetic while idle.
[[nodiscard]] auto waitForDemand() -> bool;
/// Advance arithmetic between cancellation checks and return bounded batches.
void calculate(std::optional<el::CpCount> limit, const OnDigits &onDigits);
private:
PiDigitGenerator _generator; ///< Arithmetic state, touched only by the worker.
Advance _advance; ///< Optional injected arithmetic step.
std::thread _thread; ///< Thread joined before owner destruction.
std::atomic<bool> _stop{false}; ///< Cancellation independent of event-loop progress.
std::mutex _mutex; ///< Protects demand and wakeup ordering.
std::condition_variable _demand; ///< Sleeps between requested batches.
bool _requested{false}; ///< At most one next batch request.
bool _started{false}; ///< Owner-thread one-shot lifecycle flag.
};
}
event/PiCalculator/src/PiSession.cpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include "PiSession.hpp"
#include <erbsland/BindEvent.hpp>
#include <erbsland/CpCount.hpp>
#include <erbsland/Events.hpp>
#include <erbsland/LogicFailure.hpp>
#include <erbsland/ParameterFailure.hpp>
#include <erbsland/String.hpp>
#include <utility>
namespace demo {
using namespace el::text::literals;
PiSession::PiSession(
el::EventsPtr events,
std::shared_ptr<DigitHistory> history,
OnUpdated onUpdated,
OnFinished onFinished,
PiWorker::Advance advance) :
_events{std::move(events)},
_history{std::move(history)},
_onUpdated{std::move(onUpdated)},
_onFinished{std::move(onFinished)},
_worker{std::move(advance)} {
if (!_events || !_history || !_onFinished) {
throw el::ParameterFailure{"A pi session needs a target, history, and completion callback."_el, "session"_el};
}
}
void PiSession::start(const std::optional<el::CpCount> limit) {
if (_started) {
throw el::LogicFailure{"A pi calculation session starts once."_el};
}
// Required results retain the session and always return to its owner loop.
_worker.start(
limit,
el::bindEvent(_events, shared_from_this(), &PiSession::onDigits),
el::bindEvent(_events, shared_from_this(), &PiSession::onFinished));
_started = true;
}
void PiSession::onDigits(el::String digits) {
_history->append(std::move(digits));
if (_onUpdated) {
_onUpdated();
}
// Main accepts the result before asking for more. Optional display delivery never releases a worker slot.
if (!_stopping) {
_worker.requestNextBatch();
}
}
void PiSession::requestStop() {
if (_finished || _stopping) {
return;
}
_stopping = true;
_worker.requestStop();
if (_onUpdated) {
_onUpdated();
}
}
void PiSession::onFinished(std::exception_ptr error) {
_worker.join();
_finished = true;
if (_onUpdated) {
_onUpdated();
}
// Retire target-retaining callbacks once the operation is terminal, including on exceptional shutdown.
_onUpdated = {};
_events.reset();
if (auto completion = std::move(_onFinished)) {
completion(std::move(error));
}
}
void PiSession::shutdown() {
// Exception cleanup can run after dispatch has ended; joining must not depend on a pending main-loop callback.
_stopping = true;
_worker.requestStop();
_worker.join();
// A stopped loop may still own callbacks retaining this session. Break the return ownership path.
_onUpdated = {};
_onFinished = {};
_events.reset();
}
}