Using Event Loops in Application Parts
A service often needs to keep resources alive, respond to incoming work, and finish outstanding operations before it stops. An application part gives that service its own event loop and connects its lifetime to the rest of the application. You can keep its event sources and mutable state together on one thread.
The framework runs the part’s initialization, callbacks, stopping hook, and cleanup on its dedicated thread. Keeping mutable service state on that thread lets callbacks work with it in sequence, without a mutex between those callbacks. Other threads communicate with the service by posting work to its target; an ordinary C++ method call does not move to the part’s thread automatically.
This page explains that execution model, when event sources can be created, and how a part keeps dispatching until its asynchronous shutdown is complete. The file-server demo provides concrete examples of the same mechanics you would use in another long-lived service. It builds on Running an Application with Event Loops and focuses on the event behavior of a part. Building an Application from Parts covers application composition, while Designing Application Parts explains service interfaces and dependencies.
Registration and Automatic Startup
registerPart() adds a part type before the application runs.
During startup, the framework constructs the part and prepares its command-line options.
After successful parsing, the default application main starts automatic parts and runs the main loop for coordination.
Each started part gets a separate event-loop thread; its automaticStart() decision and dependencies determine
whether it starts during this initial phase.
The framework retains the part while its lifecycle is active and runs its loop.
Inside the part, you implement lifecycle hooks rather than start another event thread or call runEventLoop().
The file-server application registers one such part to publish a local directory over HTTP:
/// Register an application part and let the default main manage its event thread and shutdown.
auto main(const int argc, char *argv[]) -> int {
auto app = el::Application{argc, argv};
app.info().setApplicationName("File Server"_el);
app.enableServiceLifecycle();
app.registerPart<ServerPart>();
return app.run();
}
$ event/file_server --port 0 --timeout 1 root
Browse http://127.0.0.1:51380/
The call to enableServiceLifecycle() connects this application’s shutdown to its platform service lifecycle.
The captured run uses an available local port and quits after one second; its port number varies between runs.
For an interactive run, start file_server --port 17680 doc/topics/event and visit http://127.0.0.1:17680/ in
your browser.
The server binds to loopback, and its page links to files beneath the selected directory.
Configuration and Event-Owned Resources
In an application-managed part, command-line hooks run on the application thread before the part thread starts.
Use them to validate input and store configuration values.
The event target is not available before startup, so defer event-source creation to initialize().
A constructor or command-line hook cannot provide that execution context.
Once callbacks start, configuration and runtime resources belong to different phases of the same service. Configuration prepared before thread startup can be read by the part; mutable runtime state should be accessed only on its loop unless you add explicit synchronization.
The file server’s parseCommandLine() validates its root directory, port, and optional timeout.
Those settings and the server pointer are members of ServerPart:
event/FileServer/ServerPart.hpp
el::Path _root; ///< Configured, physically resolved content root.
el::Port _port; ///< Configured loopback listening port.
el::TimeDelta _timeout; ///< Optional demo run duration.
el::HttpServerPtr _server; ///< Event source owned by this part's loop.
The configuration becomes fixed before the event thread begins using it.
The _server member will keep the event source alive after setup returns.
A temporary local server pointer in the setup function would not provide that lifetime.
Initialization and Asynchronous Readiness
initialize() runs on the part’s thread with its event loop bound as the current loop.
events() returns the target for that loop.
Use this context to create event sources, retain their owning pointers in members, connect handlers, and start their
operations.
Timers and invocations scheduled through this target also run on the part’s loop.
After initialize() and running() return, the framework reports the part as running and dispatches queued work.
That lifecycle state does not mean that asynchronous operations started during initialization are already ready.
Use each source’s readiness notification before depending on its result; do not block initialization waiting for a
callback that requires this loop to dispatch.
This excerpt keeps the file server’s lifecycle handlers visible and omits its request handler.
The server is created through the part’s target, and onListening() announces actual network readiness:
event/FileServer/ServerPart.hpp
void initialize() override {
// The network source belongs to this part's event loop.
_server = events()->get<el::Network>().createHttpServer();
_server->addStaticContentHandler(el::HttpStaticFileHandler::create(_root, "/files"_el));
_server->events()
.onListening([this]() -> void {
el::io::printLine("Browse http://"_el, _server->localEndpoint()->toString(), "/"_el);
if (_timeout.isPositive()) {
events()->invokeAfter(_timeout, []() -> void { el::application().quit(); });
}
})
.onError([](const el::NetworkErrorContext &error) -> void { throw el::NetworkError{error}; })
.onFinal([this]() -> void {
if (state() == el::ApplicationPartState::Stopping) {
completeStopping();
}
});
// Bind to loopback so the demo exposes files only on the local machine.
_server->start(el::IpEndpoint{el::IpAddress::fromStringOrThrow("127.0.0.1"_el), _port});
}
start() begins the asynchronous listen operation and returns.
The stored server owns its handlers; the final handler participates in the shutdown described below.
HTTP routing and response construction are covered in Using HTTP Servers.
Once listening begins, this demo schedules its optional timeout through the part’s target.
That timeout callback also runs on the part thread and requests application-wide shutdown.
invokeAfter() retains the queued callback without a stored timer handle; a repeating timer created with
createTimer() would need an owning member, just as in Scheduling Work and Timers.
Callback Execution and State Ownership
Callbacks from sources created on the part’s loop execute one at a time on the part thread.
Lifecycle hooks that access runtime resources use that same thread.
If all mutations stay there, no two callbacks can change the part’s state concurrently, and those members need no
application mutex.
This is a rule for how your service uses its state, not automatic protection applied to every member of an
ApplicationPart.
In the file server, the request, listening, error, and final handlers follow that rule. Each can access the configuration and server without competing with another callback on the same loop.
The handlers capture this.
That is safe here because the framework retains the part through shutdown, its loop finishes before cleanup, and cleanup
releases the server that owns those handlers.
The callbacks do not keep the part alive through a shared-pointer cycle.
Keeping Callbacks and Their Owners Alive explains how this changes when a callback recipient has an independent lifetime.
Cross-Thread Service Calls
The main thread and other parts still run concurrently.
An ordinary call to a method on ServerPart runs on the caller’s thread; owning an event loop does not redirect C++
method calls.
A service interface that changes event-owned state should queue that operation through the part’s target with
invoke(), carrying values and ownership sufficient for deferred execution.
Its caller must also coordinate with the service’s startup and shutdown.
Designing Application Parts covers the service-interface boundary, and
Processing Work on Event Threads demonstrates value transfer between loops.
Keep callbacks short enough for the service to remain responsive. In this demo, directory-index creation is synchronous, so it delays other callbacks on the part while it runs. For expensive processing, delegate work and deliver the completed result back to the owning loop.
Asynchronous Shutdown
The part manager calls stopping() on the part’s thread when
shutdown begins.
The default implementation completes stopping immediately.
Override it when owned resources need further event processing to finish, such as a connection draining pending output.
Begin that close operation and return so the loop can dispatch its completion callback.
Call completeStopping() when no required asynchronous
shutdown work remains.
It requests termination of the part’s loop; cleanup follows after dispatch ends.
For multiple sources, track all outstanding close operations and complete stopping only when all have finished.
If no asynchronous work remains, complete stopping directly from the hook.
In the file-server demo, the timeout requests quit(), and the
application stops its parts before closing the remaining event system.
The server may still have committed responses to deliver, so stopping() begins closing it:
event/FileServer/ServerPart.hpp
void stopping() override {
// Allow committed responses to drain before ending this part's event loop.
if (_server == nullptr || _server->state() == el::NetworkSourceState::Closed ||
_server->state() == el::NetworkSourceState::Failed) {
completeStopping();
} else {
_server->close();
}
}
On the asynchronous close path, returning from this hook does not complete the part’s shutdown.
The loop continues dispatching while the server closes.
Once its final notification arrives, the onFinal() handler installed during initialization checks that the part is
stopping and calls completeStopping().
That is the signal that this part no longer needs event processing.
If the server is already closed, failed, or absent, there is nothing left to drain, and stopping() completes
immediately instead.
The default implementation of stopping() also completes immediately; this override deliberately keeps the loop alive
for the server’s asynchronous close.
Blocking in stopping() until a final callback arrives would prevent that callback from running on the same thread.
Calling completeStopping() before the server finishes would remove the event processing it still needs.
The begin-close, return, complete-later sequence avoids both problems.
Cleanup and Failure Paths
After the loop exits, cleanup() runs on the same part thread.
It releases resources without starting new asynchronous work:
event/FileServer/ServerPart.hpp
void cleanup() noexcept override {
if (_server != nullptr) {
_server->abort();
_server.reset();
}
}
On a normal shutdown, the server has already finished closing.
abort() also provides a fallback when an error prevents that normal sequence from completing.
Resetting _server releases the source and its captured handlers.
The null check handles initialization that failed before a server was stored.
A final source callback is not a substitute for cleanup.
Here, onError() throws a NetworkError so a server failure reaches the part manager; another callback or
lifecycle hook can throw as well.
The default manager policy stops the graph, and cleanup must remain safe after partial initialization or failed work.
Keep it non-throwing and use resource-owning members for unconditional release.
The manager also limits graceful shutdown; the default timeout is 30 seconds per part. After a timeout it requests loop termination and records a failure, but it cannot interrupt a callback that never returns. This makes prompt returns from callbacks and lifecycle hooks part of the shutdown design. Designing Application Parts covers dependencies, failure handling, and lifecycle configuration beyond this single-service example. Cancelling Work and Shutting Down explains the general shutdown sequence, including queue draining, operation cancellation, and thread joins outside the part framework.
Complete demo sources
event/FileServer/main.cpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include "ServerPart.hpp"
#include <erbsland/Application.hpp>
namespace demo {
/// Register an application part and let the default main manage its event thread and shutdown.
auto main(const int argc, char *argv[]) -> int {
auto app = el::Application{argc, argv};
app.info().setApplicationName("File Server"_el);
app.enableServiceLifecycle();
app.registerPart<ServerPart>();
return app.run();
}
}
auto main(const int argc, char *argv[]) -> int {
return demo::main(argc, argv);
}
event/FileServer/ServerPart.hpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <erbsland/all_core.hpp>
#include <erbsland/all_event.hpp>
#include <erbsland/all_mem.hpp>
#include <erbsland/all_network.hpp>
#include <erbsland/all_network_http.hpp>
#include <erbsland/all_network_http_server.hpp>
#include <erbsland/all_network_source.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/ApplicationPart.hpp>
#include <erbsland/ApplicationPartIdentifier.hpp>
#include <erbsland/ApplicationPartState.hpp>
#include <erbsland/ByteBlock.hpp>
#include <erbsland/HttpFieldType.hpp>
#include <erbsland/HttpHeaders.hpp>
#include <erbsland/HttpMethod.hpp>
#include <erbsland/HttpMethodType.hpp>
#include <erbsland/HttpResponseHead.hpp>
#include <erbsland/HttpServer.hpp>
#include <erbsland/HttpServerRequest.hpp>
#include <erbsland/HttpServerSession.hpp>
#include <erbsland/HttpStaticFileHandler.hpp>
#include <erbsland/HttpStatus.hpp>
#include <erbsland/HttpVersion.hpp>
#include <erbsland/IpAddress.hpp>
#include <erbsland/IpEndpoint.hpp>
#include <erbsland/Network.hpp>
#include <erbsland/NetworkError.hpp>
#include <erbsland/NetworkErrorContext.hpp>
#include <erbsland/NetworkSourceState.hpp>
#include <erbsland/Options.hpp>
#include <erbsland/OptionValues.hpp>
#include <erbsland/Path.hpp>
#include <erbsland/Port.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>
#include <erbsland/StringBomMode.hpp>
#include <erbsland/StringEncoder.hpp>
#include <erbsland/StringEncoding.hpp>
#include <erbsland/TimeDelta.hpp>
#include <memory>
namespace demo {
using namespace el::text::literals;
/// Own an HTTP server on an application part's dedicated event thread.
/// Command-line hooks configure the part before startup; lifecycle hooks create and close its event sources.
/// @notest{Verified by running the file_server demo.}
class ServerPart final : public el::ApplicationPart {
public: // registration
/// Get the stable identifier used to register this part.
[[nodiscard]] static auto partIdentifier() -> el::ApplicationPartIdentifierPtr {
static const auto identifier = el::ApplicationPartIdentifier::create("dev.erbsland.demo.file-server"_el);
return identifier;
}
/// Construct this part when the application prepares its registered parts.
/// @return The new server part.
[[nodiscard]] static auto create() -> std::shared_ptr<ServerPart> { return std::make_shared<ServerPart>(); }
public: // implement ApplicationPart
void registerCommandLineOptions(const el::OptionsPtr &options) override;
void parseCommandLine(const el::OptionValuesPtr &values) override;
protected: // implement ApplicationPart
void initialize() override {
// The network source belongs to this part's event loop.
_server = events()->get<el::Network>().createHttpServer();
_server->addStaticContentHandler(el::HttpStaticFileHandler::create(_root, "/files"_el));
_server->events()
.onRequest(
el::HttpMethod{el::HttpMethodType::Get},
"/"_el,
[this](el::HttpServerSessionPtr, el::HttpServerRequestPtr request, el::ByteBlock) -> void {
auto headers = el::HttpHeaders{};
headers.setField(el::HttpFieldType::ContentType, "text/html; charset=utf-8"_el);
const auto body = createIndex();
request->sendResponse(
el::HttpResponseHead{el::HttpVersion::Http11, el::HttpStatus::Ok, "OK"_el, headers},
el::StringEncoder{body}.encode(el::StringEncoding::Utf8, el::StringBomMode::Reject));
})
.onListening([this]() -> void {
el::io::printLine("Browse http://"_el, _server->localEndpoint()->toString(), "/"_el);
if (_timeout.isPositive()) {
events()->invokeAfter(_timeout, []() -> void { el::application().quit(); });
}
})
.onError([](const el::NetworkErrorContext &error) -> void { throw el::NetworkError{error}; })
.onFinal([this]() -> void {
if (state() == el::ApplicationPartState::Stopping) {
completeStopping();
}
});
// Bind to loopback so the demo exposes files only on the local machine.
_server->start(el::IpEndpoint{el::IpAddress::fromStringOrThrow("127.0.0.1"_el), _port});
}
void stopping() override {
// Allow committed responses to drain before ending this part's event loop.
if (_server == nullptr || _server->state() == el::NetworkSourceState::Closed ||
_server->state() == el::NetworkSourceState::Failed) {
completeStopping();
} else {
_server->close();
}
}
void cleanup() noexcept override {
if (_server != nullptr) {
_server->abort();
_server.reset();
}
}
private:
/// Build the HTML listing for the configured content root.
/// @return The HTML document with links to regular files.
auto createIndex() const -> el::String;
private:
el::Path _root; ///< Configured, physically resolved content root.
el::Port _port; ///< Configured loopback listening port.
el::TimeDelta _timeout; ///< Optional demo run duration.
el::HttpServerPtr _server; ///< Event source owned by this part's loop.
};
}