Running an Application with Event Loops
A file monitor needs to prepare its input, read the file periodically, and stop when its work is done. This page shows how to connect those steps to an application’s main event loop. You will first let the application run the loop for you, then see how to enter it explicitly when you need setup and cleanup around it.
Both examples are compiled demos: one prints file sizes, the other prints modification times. If callbacks and timers are unfamiliar, Events in Erbsland Core introduces them using the same file-size monitor.
The Application’s Main Event Loop
For a small monitor, Application already provides the surrounding lifecycle.
Its run() initializes the application, parses the command line, and calls
its default main, which enters the event loop.
The C++ entry point therefore stays small:
event/FileSizeMonitor/main.cpp
/// This simple main method is the entry point of the application.
auto main(const int argc, char *argv[]) -> int {
auto app = el::Application{argc, argv};
app.enableTerminal();
app.info().setApplicationName("File Size Monitor"_el);
app.options()->addSet(createOptions());
return app.run();
}
createOptions() registers the file argument, an optional timeout, and a function to handle the parsed values.
That handler prepares the file information and queues the monitor’s initialize() function using
invoke().
Queuing the function does not call it immediately: it runs when the main loop processes that work.
We will look at the option handler after following the monitor itself.
Timer Setup and Repeated Work
Once initialization reaches the loop, the monitor prints the current size and starts a repeating timer.
The timer and path information live in appData so they survive the setup function’s return.
event/FileSizeMonitor/main.cpp
struct AppData {
el::EventTimerPtr pollTimer;
el::PathInfo pathInfo;
} appData;
void initialize() {
el::stdOut()->printLine("Monitoring size of file: "_el, appData.pathInfo.resolvedPath());
poll();
appData.pollTimer = el::application().events()->createTimer(poll);
appData.pollTimer->startFixedDelay(el::Seconds{1});
}
void poll() {
appData.pathInfo.reload();
if (!appData.pathInfo.exists()) {
el::stdOut()->printLine("File not found"_el);
} else {
el::stdOut()->printLine("Size: "_el, appData.pathInfo.fileSize(), " bytes"_el);
}
}
$ event/file_size_monitor sample-text.txt --timeout 2
Monitoring size of file: sample-text.txt
Size: 187 bytes
Size: 187 bytes
The first poll() is an ordinary function call made during setup.
Later calls come from the timer, on the same main thread.
startFixedDelay() leaves a one-second delay after each timer
callback finishes before the next becomes due.
The sample output shows a short run; the readings depend on the file and how long you let the monitor run.
Keeping appData.pollTimer is essential: releasing its last owning pointer cancels future timer callbacks.
The demo’s application-wide state lasts through app.run().
For a monitor owned by a shorter-lived object, store the timer in that object and ensure its callbacks cannot outlive
the state they access; Keeping Callbacks and Their Owners Alive explains those designs.
Configuration Before Dispatch
The monitor needs a path before its first reading. An option set’s post-parsing callback is a convenient place to turn command-line values into that configuration. It also schedules the optional quit request, so the demo has a finite run by default.
These two functions complete the setup used by the entry point:
event/FileSizeMonitor/main.cpp
auto createOptions() -> el::OptionSetPtr {
auto optionSet = el::OptionSet::create();
optionSet->addOption("file"_el).setRequired().setHelp("The path to the file to monitor."_el);
optionSet->addOption({"-t"_el, "--timeout"_el, "timeout"_el})
.setType(el::OptionType::Integer)
.setHelp("The timeout in seconds. Zero for no timeout."_el)
.setDefaultValue(5);
optionSet->setPostParsingFn(handleOptions);
return optionSet;
}
void handleOptions(const el::OptionValuesPtr &values) {
try {
auto path = el::Path::fromNativeOrThrow(values->getText("file"_el));
path = path.resolveOrThrow(el::PathResolveMode::Physical);
appData.pathInfo = path.info(el::PathInfoPart::Type);
} catch (const el::ParseError &) {
throw el::ApplicationError{"The path has an invalid format"_el, std::current_exception()};
} catch (const el::PathError &) {
throw el::ApplicationError{
"The file at the given path does not exist or is no regular file"_el, std::current_exception()};
}
const auto timeout = el::Seconds{values->getInteger("timeout"_el)};
if (timeout.isNegative()) {
throw el::ApplicationError{"The timeout must be a non-negative integer."_el};
}
el::application().events()->invoke(initialize);
if (timeout.isPositive()) {
el::application().events()->invokeAfter(timeout, []() -> void { el::application().quit(); });
}
}
setPostParsingFn(handleOptions) registers the handler; it does not run it during option registration.
When app.run() has parsed the arguments, handleOptions() resolves the path, prepares pathInfo, and rejects a
negative timeout before queuing any work.
Path parsing and resolution failures are translated into application errors with the original cause attached.
The invokeAfter() call queues one delayed action without requiring a
stored timer handle.
A positive timeout ends the run after the requested delay; zero leaves the monitor running until another quit request.
For example, you can run file_size_monitor sample-text.txt --timeout 0 and stop it with Ctrl+C.
The call to enableTerminal() in the entry point enables the application’s terminal integration.
Application Startup and Shutdown
There are two useful views of this run. First, the application follows a procedural lifecycle on the main thread. The event-processing phase occupies the middle of that lifecycle and lasts until the loop exits:
Inside the event-processing phase, the loop dispatches one callback at a time. The queued setup runs once, timer callbacks provide later readings, and the timeout callback requests shutdown. Each callback returns control to the loop:
The arrows back to dispatch are returns from callbacks, not calls that your code must make. If nothing is ready, the loop waits. These are two views of the same main thread; the timer does not introduce another thread.
Calling quit() requests shutdown; it does not terminate the process
inside the callback.
The callback returns, the loop exits, and app.run() completes the application lifecycle.
There is no separate thread for this monitor: configuration, setup, and readings all run on the main thread.
The loop makes it possible to wait between readings while remaining available for other events.
Errors also have a path out of the monitor. A setup error or an exception from a timer callback reaches the application’s error handling. Resources that need unconditional release should have an owning object or a non-throwing cleanup hook, as in the next example. The Queue Capacity and Error Policies reference describes error policies for loops you configure yourself.
Initialization and Cleanup Hooks
Sometimes the event-processing phase is one step in a larger sequence.
You may want to open resources first and continue with final work after the loop stops.
Deriving from Application and overriding its main() gives you that sequence.
Your override explicitly calls runEventLoop() where event
processing should begin.
The file-time monitor uses this arrangement. It keeps its path information and timer as members, validates the input, and starts the timer before entering the loop. Timers can be prepared before dispatch starts; other event sources may require setup from inside their owning loop, as shown in Connecting to Event Sources.
event/FileTimeMonitor/FileTimeMonitor.hpp
/// Initialize resources, enter the main event loop, and release resources when it returns.
/// `PathInfo` reloads filesystem timestamps for each timer callback.
/// @notest{Verified by running the file_time_monitor demo.}
class FileTimeMonitorApp final : public el::Application {
public:
using Application::Application;
protected: // implement Application
[[nodiscard]] auto main() -> el::ExitCode override {
_path = el::Path::fromNativeOrThrow(optionValues()->getText("file"_el))
.resolveOrThrow(el::PathResolveMode::Physical);
_pathInfo = _path.info(el::PathInfoPart::Type);
if (!_pathInfo.isRegularFile()) {
throw el::ApplicationError{"The path must name a regular file."_el};
}
const auto timeout = el::Seconds{optionValues()->getInteger("timeout"_el)};
if (timeout.isNegative()) {
throw el::ApplicationError{"The timeout must be non-negative."_el};
}
if (timeout.isPositive()) {
events()->invokeAfter(timeout, []() -> void { el::application().quit(); });
}
_pollTimer = events()->createTimer([this]() -> void { poll(); });
_pollTimer->startFixedDelay(el::Seconds{1});
el::stdOut()->printLine("Monitoring file: "_el, _path);
// Enter the event loop after setup; execution resumes here when it exits.
const auto exitCode = runEventLoop();
_pollTimer->stop();
el::stdOut()->printLine("Monitoring stopped."_el);
return exitCode;
}
void cleanup() noexcept override { _pollTimer.reset(); }
private:
void poll() {
_pathInfo.reload(el::PathInfoPart::Times);
el::stdOut()->printLine("Last modification: "_el, _pathInfo.lastModified().toString());
}
private:
el::EventTimerPtr _pollTimer; ///< Retains the repeating timer.
el::Path _path; ///< File being monitored.
el::PathInfo _pathInfo; ///< Reloadable filesystem information.
};
$ event/file_time_monitor sample-text.txt --timeout 2
Monitoring file: sample-text.txt
Last modification: 2026-11-06T10:32:10.986459848Z
Monitoring stopped.
The callback captures this to reach the monitor’s members.
The application object remains alive throughout app.run(), and its timer is released during cleanup, so those
members remain available while callbacks execute.
The entry point constructs FileTimeMonitorApp in place of Application and calls app.run() in the same way.
The timestamp in the captured output is from an example run.
runEventLoop() stays inside the overridden main() until the loop exits.
On the normal path, execution then stops the timer, prints the final message, and returns the loop’s exit code.
Overriding main() replaces the default behavior, so forgetting the explicit loop call would leave the scheduled
callbacks unprocessed.
The cleanup() hook handles release even if setup or a callback throws
and bypasses the statements after runEventLoop().
Keep that hook non-throwing and let resource-owning members handle ordinary destruction.
This is the main reason to distinguish work that follows a successful event-processing phase from cleanup needed on
every exit path.
Worker Event Threads
Both monitor designs use one loop on the main thread. That is enough while each callback finishes promptly. If a callback instead hashes a large file, the monitor cannot process other callbacks until hashing returns. An additional event thread gives that work its own loop and lets the main loop remain responsive.
A batch application can use the same idea while keeping a procedural main: send files to workers, wait for their results, then write the report. Processing Work on Event Threads develops this design with the file-hasher demo. It explains why a main function that waits without running its loop needs another place to receive completion callbacks.
Application Parts and Their Event Loops
A long-lived service may need its own event sources, startup, and asynchronous shutdown.
An ApplicationPart gives those responsibilities a dedicated event thread
whose lifecycle the application manages.
Using Event Loops in Application Parts follows a file server from configuration through request callbacks to graceful shutdown. It explains which work belongs on the part’s thread and how that loop stays active while the server finishes closing.
Complete demo sources
event/FileSizeMonitor/main.cpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include <DemoTerminal.hpp>
#include <erbsland/all_core.hpp>
#include <erbsland/all_err.hpp>
#include <erbsland/all_event.hpp>
#include <erbsland/all_options.hpp>
#include <erbsland/all_path.hpp>
#include <erbsland/all_stream.hpp>
#include <erbsland/all_text.hpp>
#include <erbsland/all_time.hpp>
#include <erbsland/Application.hpp>
#include <erbsland/ApplicationError.hpp>
#include <erbsland/EventTimer.hpp>
#include <erbsland/OptionSet.hpp>
#include <erbsland/OptionType.hpp>
#include <erbsland/OptionValues.hpp>
#include <erbsland/ParseError.hpp>
#include <erbsland/Path.hpp>
#include <erbsland/PathError.hpp>
#include <erbsland/PathInfo.hpp>
#include <erbsland/PathResolveMode.hpp>
namespace demo {
using namespace el::text::literals;
struct AppData {
el::EventTimerPtr pollTimer;
el::PathInfo pathInfo;
} appData;
/// This method is called in configured intervals to display the file size.
void poll() {
appData.pathInfo.reload();
if (!appData.pathInfo.exists()) {
el::stdOut()->printLine("File not found"_el);
} else {
el::stdOut()->printLine("Size: "_el, appData.pathInfo.fileSize(), " bytes"_el);
}
}
/// In an event-based application, you initialize your event handlers and let the event loop run.
void initialize() {
el::stdOut()->printLine("Monitoring size of file: "_el, appData.pathInfo.resolvedPath());
poll();
appData.pollTimer = el::application().events()->createTimer(poll);
appData.pollTimer->startFixedDelay(el::Seconds{1});
}
/// This method is automatically called after the options are parsed.
void handleOptions(const el::OptionValuesPtr &values) {
try {
auto path = el::Path::fromNativeOrThrow(values->getText("file"_el));
path = path.resolveOrThrow(el::PathResolveMode::Physical);
appData.pathInfo = path.info(el::PathInfoPart::Type);
} catch (const el::ParseError &) {
throw el::ApplicationError{"The path has an invalid format"_el, std::current_exception()};
} catch (const el::PathError &) {
throw el::ApplicationError{
"The file at the given path does not exist or is no regular file"_el, std::current_exception()};
}
const auto timeout = el::Seconds{values->getInteger("timeout"_el)};
if (timeout.isNegative()) {
throw el::ApplicationError{"The timeout must be a non-negative integer."_el};
}
el::application().events()->invoke(initialize);
if (timeout.isPositive()) {
el::application().events()->invokeAfter(timeout, []() -> void { el::application().quit(); });
}
}
/// Here you create the command line options.
auto createOptions() -> el::OptionSetPtr {
auto optionSet = el::OptionSet::create();
optionSet->addOption("file"_el).setRequired().setHelp("The path to the file to monitor."_el);
optionSet->addOption({"-t"_el, "--timeout"_el, "timeout"_el})
.setType(el::OptionType::Integer)
.setHelp("The timeout in seconds. Zero for no timeout."_el)
.setDefaultValue(5);
optionSet->setPostParsingFn(handleOptions);
return optionSet;
}
/// This simple main method is the entry point of the application.
auto main(const int argc, char *argv[]) -> int {
auto app = el::Application{argc, argv};
demo::DemoTerminal::enable(app);
app.info().setApplicationName("File Size Monitor"_el);
app.options()->addSet(createOptions());
return app.run();
}
}
auto main(const int argc, char *argv[]) -> int {
return demo::main(argc, argv);
}
event/FileTimeMonitor/FileTimeMonitor.hpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <DemoTerminal.hpp>
#include <erbsland/all_core.hpp>
#include <erbsland/all_err.hpp>
#include <erbsland/all_event.hpp>
#include <erbsland/all_options.hpp>
#include <erbsland/all_path.hpp>
#include <erbsland/all_stream.hpp>
#include <erbsland/all_text.hpp>
#include <erbsland/all_time.hpp>
#include <erbsland/Application.hpp>
#include <erbsland/ApplicationError.hpp>
#include <erbsland/EventTimer.hpp>
#include <erbsland/ExitCode.hpp>
#include <erbsland/Options.hpp>
#include <erbsland/OptionType.hpp>
#include <erbsland/OptionValues.hpp>
#include <erbsland/Path.hpp>
#include <erbsland/PathInfo.hpp>
#include <erbsland/PathResolveMode.hpp>
namespace demo {
using namespace el::text::literals;
/// Initialize resources, enter the main event loop, and release resources when it returns.
/// `PathInfo` reloads filesystem timestamps for each timer callback.
/// @notest{Verified by running the file_time_monitor demo.}
class FileTimeMonitorApp final : public el::Application {
public:
using Application::Application;
protected: // implement Application
void initialize() override {
DemoTerminal::enable(*this);
info().setApplicationName("File Time Monitor"_el);
}
void registerCommandLineOptions(const el::OptionsPtr &options) override {
options->addOption("file"_el).setRequired().setHelp("The path to the file to monitor"_el);
options->addOption({"-t"_el, "--timeout"_el, "timeout"_el})
.setType(el::OptionType::Integer)
.setHelp("The timeout in seconds. Zero for no timeout."_el)
.setDefaultValue(5);
}
[[nodiscard]] auto main() -> el::ExitCode override {
_path = el::Path::fromNativeOrThrow(optionValues()->getText("file"_el))
.resolveOrThrow(el::PathResolveMode::Physical);
_pathInfo = _path.info(el::PathInfoPart::Type);
if (!_pathInfo.isRegularFile()) {
throw el::ApplicationError{"The path must name a regular file."_el};
}
const auto timeout = el::Seconds{optionValues()->getInteger("timeout"_el)};
if (timeout.isNegative()) {
throw el::ApplicationError{"The timeout must be non-negative."_el};
}
if (timeout.isPositive()) {
events()->invokeAfter(timeout, []() -> void { el::application().quit(); });
}
_pollTimer = events()->createTimer([this]() -> void { poll(); });
_pollTimer->startFixedDelay(el::Seconds{1});
el::stdOut()->printLine("Monitoring file: "_el, _path);
// Enter the event loop after setup; execution resumes here when it exits.
const auto exitCode = runEventLoop();
_pollTimer->stop();
el::stdOut()->printLine("Monitoring stopped."_el);
return exitCode;
}
void cleanup() noexcept override { _pollTimer.reset(); }
private:
void poll() {
_pathInfo.reload(el::PathInfoPart::Times);
el::stdOut()->printLine("Last modification: "_el, _pathInfo.lastModified().toString());
}
private:
el::EventTimerPtr _pollTimer; ///< Retains the repeating timer.
el::Path _path; ///< File being monitored.
el::PathInfo _pathInfo; ///< Reloadable filesystem information.
};
}