Files
eepp/docs/articles/async_core.md
T
Martín Lucas Golini 88675a7c84 feat(vfs): add async URI-based VFS module with SFTP and the eefm file manager
Async core (EE namespace, documented in docs/articles/async_core.md):
- Add Result<T, E>, Task<T>/TaskSource<T> with continuations and
  abandonment, Executor, CancellationToken and SmallUniqueFunction
- Make ThreadPool an Executor with submit() returning a Task; work items
  are move-only
- Add Scene::MainThreadExecutor to resume continuations on the UI thread
- Move URI to EE core (eepp/core/uri.hpp) with filesystem helpers
  (fromFSPath, getFSPath, getParent); EE::Network::URI remains an alias
- Add System::SHA1 (streaming, modeled on MD5)
- Rename the pack path index to System::PackFileIndex; VirtualFileSystem
  is kept as a deprecated alias

eepp-vfs module (src/modules/vfs, documented in docs/articles/vfs.md):
- URI-addressed VirtualFileSystem with pluggable providers: file://,
  mem://, res:// (packs) and sftp://
- Async file and directory handles, metadata, space info, capabilities
  and IOStream adapters
- Copy/move transfers with progress, cancellation and conflict options
- Change watching through efsw locally, efsw-agent over SSH for sftp://,
  and a polling fallback
- VFSDirectoryModel for table views: sorting, name filtering (plain,
  glob, regex), type and item-count columns, per-column CSS classes and
  lifetime-safe executor delivery

SFTP:
- SSH sessions over libssh2 with ssh_config parsing (Host/Match,
  ProxyJump, ProxyCommand), known_hosts verification, ssh-agent, key and
  keyboard-interactive authentication, and connection pooling
- Built by default; opt out with --without-vfs-sftp
- Vendor libssh2 1.11.1 with the mbedTLS backend, and add Ed25519 host
  and user keys to it through Monocypher 4.0.2 (mbedTLS has no Ed25519)

eefm (src/tools/eefm), a Dolphin-like file manager on eepp-vfs:
- Split panes, each with its own tabs, location bar, history and
  filter bar
- Places panel with Places, Remote (saved locations and network mounts)
  and Devices (with usage), editable, hideable and reorderable by drag
- Details view with drag and drop, clipboard interoperable with other
  file managers, rename, new folder/file, trash (freedesktop, macOS,
  Windows) and properties
- Open and Open With from shared-mime-info and desktop entries
- Persisted window, panes, tabs, columns, sort and places

UI and window fixes:
- Keep table column max widths across model updates and give the main
  column the remaining width
- Count left margins when laying out push button icon and text
- Add Clipboard::setMimeData, getDataString and getTextMimeTypes
- Report Zip::close/addFile failures and free archives correctly

Tests and tooling:
- Unit tests for the async core, VFS providers, transfers, watching,
  the directory model, ssh_config, SFTP (local sshd), the efsw agent
  protocol, SHA1 and URI
- Add a VFS benchmark
- Update efsw with efsw-agent and its wire protocol
2026-10-13 14:58:11 -03:00

8.7 KiB

Async core

Introduction

eepp's async core is a small set of primitives for work that finishes later: file and network I/O, background computation, anything a UI must not block on. They live in eepp core (no module needed) and are designed to be used together:

Header Types Role
<eepp/core/result.hpp> Result<T, E>, Ok(), Err() A value or a typed error, no exceptions.
<eepp/core/task.hpp> Task<T>, TaskSource<T>, makeReadyTask(), TaskDiagnostics A value that becomes available later, with continuations.
<eepp/core/executor.hpp> Executor, InlineExecutor Where continuations and jobs run.
<eepp/system/threadpool.hpp> ThreadPool (an Executor, plus submit()) Background threads that return tasks.
<eepp/scene/mainthreadexecutor.hpp> MainThreadExecutor Runs continuations on the UI thread.
<eepp/core/cancellationtoken.hpp> CancellationSource, CancellationToken, CancellationRegistration Cooperative cancellation.
<eepp/core/small_unique_function.hpp> SmallUniqueFunction<Sig, N> Move-only callable with inline storage (the executor work item).

The pieces fit as one pipeline:

producer (thread pool, I/O thread)          consumer (usually the UI)
TaskSource<Result<T, E>>  --resolve-->  Task<Result<T, E>>  --thenOn( executor, fn )-->  fn( Result )
        ^                                                                      |
        +------------------- CancellationToken <---- CancellationSource -------+

The virtual file system is the main user: every VFS operation returns VFSTask<T>, an alias of Task<Result<T, VFSError>>.

Result

Result<T, E> holds either a value or an error. Construct it from Ok( value ) / Err( error ) (Ok() for Result<void, E>):

Result<int, std::string> parsePort( std::string_view text ) {
	int port = 0;
	if ( !String::fromString( port, std::string( text ) ) || port <= 0 || port > 65535 )
		return Err( std::string( "invalid port" ) );
	return Ok( port );
}

auto port = parsePort( "8080" );
if ( port )
	use( port.value() );
else
	log( port.error() );
  • hasValue(), hasError() and explicit operator bool test the state; value() and error() assert in debug builds when used on the wrong state.
  • Rvalue combinators consume the result: valueOr(), map(), mapError(), andThen(), orElse().
  • Errors are values. Do not use exceptions or sentinel values for expected failures.

Task and TaskSource

A TaskSource<T> is the producer side; its task() is the single consumer handle:

TaskSource<int> source;
Task<int> task = source.task();
std::thread( [source = std::move( source )]() mutable { source.resolve( 42 ); } ).detach();

Consumers attach one continuation chain. Continuations consume the task, so it is always written as std::move( task ).then( ... ) (a temporary needs no std::move):

Method Runs fn
then( fn ) On the thread that resolves the task, or immediately if it is already ready.
thenOn( executor, fn ) Always through executor, even when already ready.
onComplete( fn ) On the completing thread with the terminal task (Ready or Abandoned), producing nothing.
onCompleteOn( executor, fn ) Same, through executor.

fn may return a value, void or another Task<U>; nested tasks are flattened, so chains stay Task<U> instead of Task<Task<U>>:

vfs.stat( uri )
	.then( [&vfs, uri]( VFSResult<FileInfo>&& info ) -> VFSTask<std::vector<Uint8>> {
		if ( !info || !info.value().isRegularFile() )
			return makeReadyTask(
				VFSResult<std::vector<Uint8>>( Err( VFSError( VFSErrorCode::NotFound, uri ) ) ) );
		return vfs.readAll( uri );
	} )
	.thenOn( uiExecutor, []( VFSResult<std::vector<Uint8>>&& data ) { /* UI thread */ } );

Abandonment

If the last TaskSource is destroyed without resolving, the task becomes Abandoned: its continuations do not run and the abandonment propagates down the chain. This is an infrastructure bug (a producer was lost), not a domain outcome, so it is reported through TaskDiagnostics (debug builds print a diagnostic; tools and tests can install a handler with TaskDiagnostics::setHandler()). Domain outcomes, including cancellation, belong in the value: use Task<Result<T, E>>.

TaskDiagnostics also reports ResolvedTwice and TaskRetrievedTwice.

Blocking

wait(), waitFor() and get() block the calling thread (get() returns std::optional<T>, empty when abandoned). They exist for worker-thread adapters (for example the VFS IOStreamVFS) and tests. Never block the UI thread, and never block a thread the producer needs to make progress (for example a pool waiting on itself).

Ready tasks

makeReadyTask( value ) (or Task<T>::ready( ... )) creates an already-resolved task, useful for early returns and synchronous fast paths. then() on a ready task runs immediately; thenOn() still goes through its executor.

Executors

An Executor accepts Executor::Function work items (SmallUniqueFunction<void(), 64>) and runs each exactly once, unless the executor is being destroyed, in which case pending items may be destroyed without running.

  • InlineExecutor::instance(): runs immediately on the calling thread.

  • ThreadPool: background workers. run() queues a function; submit( fn ) returns a Task with fn's result:

    auto pool = ThreadPool::createShared( 4 );
    pool->submit( [] { return countLines( path ); } )   // Task<size_t>
        .thenOn( uiExecutor, []( size_t lines ) { showLineCount( lines ); } );
    
  • Scene::MainThreadExecutor( sceneNode ): queues functions for the UI thread and drains them in submission order during the scene update. Execution is always deferred, even when execute() is called on the main thread. Destroying it drops queued functions.

Use thenOn( uiExecutor, ... ) for anything that touches widgets or models. then() runs where the task resolves, which for I/O is a worker or I/O thread.

Executor lifetime

thenOn() keeps a plain reference to its executor until the continuation is dispatched. The executor must outlive every task chain dispatched through it. Objects that start operations and can be destroyed before those operations finish (for example a model that is closed while a listing is in flight) should not hand out the executor directly: route continuations through an executor they own and close on destruction. VFSDirectoryModel does this; once closed, its gate runs late continuations inline, where they find the model gone and return.

SmallUniqueFunction

SmallUniqueFunction<R( Args... ), N> is a move-only std::function alternative that stores callables up to N bytes inline and falls back to the heap for larger ones. Executor work items use 64 bytes, enough for a continuation capturing a callable, a task source and a task state without allocating.

Cancellation

Cancellation is cooperative:

CancellationSource cancel;
auto listing = vfs.listAll( uri, {}, cancel.token() );
// later, e.g. the user navigated away:
cancel.cancel();   // listing completes with VFSErrorCode::Cancelled
  • CancellationSource (move-only) requests cancellation; token() hands out cheap copyable CancellationTokens. Destroying the source does not cancel.
  • Long operations check token.isCancellationRequested() at safe points.
  • token.onCancel( callback ) registers a callback that runs synchronously on the thread calling cancel() (immediately if already cancelled). Keep the returned CancellationRegistration alive while the callback may run; destroying it unregisters the callback and waits for a callback already running on another thread. A callback may destroy its own registration.
  • A default-constructed token can never be cancelled.

Cancelled operations complete normally with an error value (for VFS, VFSErrorCode::Cancelled), never by abandoning their task.

Rules of thumb

  • Return Task<Result<T, E>> from fallible async APIs; never block inside them.
  • Consume each task once; keep the returned task if you need to chain further.
  • Touch UI state only from continuations dispatched to the UI executor.
  • Capture std::weak_ptrs (or a generation counter) in continuations of objects that may die or move on before the operation completes, and ignore stale results.
  • Make sure executors outlive the chains dispatched through them (see above).
  • Treat Abandoned and TaskDiagnostics reports as bugs to fix, not states to handle.

See also

  • Virtual file system: the VFS API built on these primitives.
  • include/eepp/core/task.hpp and the other headers above for the full API.
  • src/tests/unit_tests/async_core_tests.cpp for executable examples.