Data safety (VFS): - Copies write a temporary sibling and replace the destination only once it is complete (as writeAll() already did): a failed overwrite kept nothing of the old destination, and copying onto a hard link to the source truncated the source itself. A destination symlink is written through, and one that resolves to the source fails as the same file. - Moving a symlink to another file system is refused: the fallback copy followed it and then removed the copied entries through the link, emptying its target. - Saves keep the exact mode of the file they replace: the temporary was created with it, but filtered through the umask. Deadlocks and blocking: - writeAll( createParents ) waited on makeDirs() queued on its own orchestration pool: one thread deadlocked at once, two with two concurrent saves. Orchestration jobs now create parents directly. - Copies and moves run on their own pool (Config::transferThreads), so long transfers no longer delay saves, reads and recursive removals. - Local watches are registered and removed on the I/O pool: efsw and the directory checks touch the filesystem, which blocked the UI thread on slow mounts. SSH security: - "@revoked" known_hosts keys are rejected; they were skipped as unknown, so StrictHostKeyChecking accept-new trusted them. - Sessions are shared only between configurations with the same security policy (known hosts, host key checking, algorithms, authentication settings), not just the same destination: a strict alias reused a session a permissive one had accepted. - A configured algorithm restriction that permits nothing this libssh2 supports fails the connection instead of using libssh2's defaults. SSH liveness: - Operations submitted to a failed or closed connection fail with ConnectionLost instead of staying pending forever. - ssh-agent requests run on a worker thread (SSHAgent, Unix-domain sockets), with libssh2_userauth_publickey() and a sign callback that waits without blocking: an agent waiting for a hardware key touch no longer stalls every other session. Windows keeps libssh2's agent. - Connecting no longer holds a helper thread while the user answers a host key or password prompt: the setup continues from the session's ready task, one ProxyJump hop at a time. Jump hosts only need to authenticate, not to offer SFTP. - Idle connections close after SFTPProviderConfig::idleTimeout. The old check compared use_count() with 1 and never passed: connections now track their users (open handles, pumps, jump children). - A remote watch agent that never answers falls back to polling after 5 seconds; the directory listing waited for it indefinitely. Core: - CancellationSource::cancel() keeps the shared state alive while its callbacks run: a callback destroying the source freed it mid-cancel (heap use after free under AddressSanitizer). - MainThreadExecutor::shutdown() detaches the executor from its dispatcher node and drops queued and later functions. - String::icompare(): case-insensitive comparison without allocations. VFSDirectoryModel (UI thread): - Names compare with String::icompare() instead of lowercased copies, which made every sort, merge and insertion allocate (about 2x slower when sorting). - Sorting by type asks the type provider once per entry instead of twice per comparison. - The case-insensitive plain text filter keeps a search table instead of lowercasing every name on each row rebuild. - Watch events update the visible rows in place instead of rebuilding them from every entry per event. Drag and drop: - The X11 drag source showed the pending badge over the source window for every application, which broke the X11 drag and drop integration tests in CI. DragOffer::sourceHandlesDrops now asks for it; eefm sets it, since it completes those drops as its own drag. eefm: - Shutdown destroyed the main thread executor before the VFS and the thread pool while continuations still held it: closing during a transfer could dispatch through freed memory. Transfers are cancelled, the VFS is destroyed, the executor is detached, and only then are the scene, its pool and the executor destroyed. - Remote files opened locally were downloaded to /tmp/eefm/<scheme>/<host>/<name>: files with the same name from other folders, users or ports overwrote each other, in a directory shared by every user. They now go to a per-file folder (a hash of the complete URI) in the user's cache. Every fix comes with a regression test.
11 KiB
Virtual File System (eepp-vfs)
Introduction
eepp-vfs is an optional static module that gives applications one asynchronous, URI-based API
for local files, packed resources and remote servers:
| Scheme | Provider | Notes |
|---|---|---|
file:// |
LocalProvider |
Native filesystem (POSIX and Win32). |
res:// |
ResourceProvider |
Read-only view of every open Pack (System::PackFileIndex). |
sftp:// |
SFTPProvider |
Built by default (--without-vfs-sftp disables it), libssh2 based, honors ~/.ssh/config. |
mem:// |
MemoryProvider |
In-memory filesystem, mainly for tests. |
The module builds on the async core, generic primitives that live in eepp core and are useful on their own:
| Header | Type |
|---|---|
<eepp/core/result.hpp> |
Result<T, E>, Ok(), Err() |
<eepp/core/task.hpp> |
Task<T>, TaskSource<T>, makeReadyTask() |
<eepp/core/cancellationtoken.hpp> |
CancellationSource, CancellationToken, CancellationRegistration |
<eepp/core/executor.hpp> |
Executor, InlineExecutor (ThreadPool is an Executor and has submit()) |
<eepp/scene/mainthreadexecutor.hpp> |
MainThreadExecutor: runs continuations on the UI thread |
<eepp/core/uri.hpp> |
URI (formerly Network::URI, which remains an alias) |
Link the module with eepp_module_vfs_add() in Premake and include <eepp/vfs.hpp>. The UI model
lives in <eepp/vfs/ui/vfsdirectorymodel.hpp> and is not part of the aggregate header.
Tasks, results and threads
Every VFS operation returns VFSTask<T>, an alias of Task<Result<T, VFSError>>:
auto& vfs = EE::VFS::instance();
MainThreadExecutor ui( sceneNode );
vfs.stat( URI( "sftp://makyo/home/black/notes.txt" ) )
.thenOn( ui, []( VFSResult<FileInfo>&& info ) {
if ( info )
label->setText( info.value().name );
else
label->setText( info.error().toString() );
} );
Rules:
- VFS methods never block and may be called from any thread.
- Tasks resolve on VFS threads.
then()runs where the task resolves;thenOn( executor, ... )always goes through the executor. Touch UI or model state only fromthenOn( uiExecutor, ... ). - A
Taskhas one consumer: continuations consume it (std::move( task ).then( ... )). A continuation returning another task is flattened. - If a producer is destroyed without resolving, its task chain becomes
Abandonedinstead of hanging. That is an infrastructure bug (reported byTaskDiagnostics), never a domain outcome. - Cancellation is cooperative: pass
CancellationTokens; cancelled operations complete withVFSErrorCode::Cancelled. Task::wait()/get()block. They exist for worker-thread adapters (for exampleIOStreamVFS) and tests; never use them on the UI thread.
Errors are typed (VFSErrorCode): callers branch on codes such as NotFound,
PermissionDenied, ChangedExternally or HostKeyRejected, never on errno or libssh2 values.
Files and directories
// Incremental listing: batches with an explicit end-of-directory flag.
vfs.openDirectory( uri ); // VFSTask<DirectoryHandle>; then handle.read( 256 ) per batch
vfs.listAll( uri ); // convenience: the whole directory
vfs.readAll( uri ); // VFSTask<std::vector<Uint8>>
vfs.writeAll( uri, data ); // atomic by default, returns the new FileInfo
vfs.makeDirs( uri );
vfs.remove( uri, RemoveOptions{ true } ); // recursive
vfs.rename( from, to, RenameOptions{ true } );
vfs.canonicalize( uri ); // resolves symlinks/aliases; URIs are never canonicalized implicitly
FileHandle uses explicit offsets (read( offset, span ), write( offset, span )); FileCursor
adds a sequential position. IOStreamVFS exposes a VFS file as a blocking System::IOStream for
existing decoders running on worker threads.
FileInfo is a plain value. Unknown metadata stays unknown (std::optional accessors). It does
not store its own URI, because listings hold many entries and the caller always knows the URI it
listed: use info.uriIn( directory ) for a listed entry (VFSDirectoryModel::entryURI( row ) in
the model). FileInfo::revision is an opaque token used for conflict detection:
WriteOptions options;
options.expectedRevision = lastKnown.revision; // from a previous stat/writeAll
vfs.writeAll( uri, text, options ); // fails with ChangedExternally if modified
Atomic writes go to a sibling temporary file, are flushed and renamed over the destination; a
symlinked destination is written through to its target. Providers report what they can do through
capabilities( uri ) (for example AtomicReplace, Fsync, Permissions).
Copy and move
Recursive and cross-provider transfers are orchestrated by the VFS, not by providers:
Transfer transfer = vfs.copy( URI( "sftp://makyo/srv/www" ), URI::fromFSPath( "/tmp/www" ) );
auto progress = transfer.onProgress( []( const TransferProgress& p ) { ... }, &uiExecutor );
transfer.completion().thenOn( uiExecutor, []( VFSResult<TransferResult>&& r ) { ... } );
transfer.cancel();
Moves use a native rename when both sides are on the same provider/authority and fall back to copy plus removal of the entries that were completely copied; a source is never removed after a partial transfer.
File copies are written to a temporary sibling before publication. Fail and Skip also apply
when a destination appears while the copy is running. The Overwrite policy requires
AtomicReplace; otherwise the transfer returns Unsupported and preserves the old contents.
Creating a file with Fail or Skip requires Rename. Moving a symlink through the copy fallback
is unsupported; native same-filesystem renames still move the link itself.
Watching
vfs.watch( uri, WatchOptions{ /*recursive*/ true } )
.thenOn( ui, [this]( VFSResult<WatchHandle>&& watch ) {
mWatch = std::move( watch ).value();
mWatch.onEvents( ui, [this]( WatchEventBatch&& batch ) { ... } );
} );
Events are relative to the watched root and arrive in coalesced batches through the executor you
choose. RescanRequired means continuity was lost (missed native events, reconnection, overflow):
re-list the directory. WatchHandle::status() reports Active, Polling, Reconnecting or
Failed, so a broken remote watch never looks healthy.
Backends:
file://: efsw native watching; directories efsw reports as remote (NFS, SMB) use efsw's generic polling watcher.sftp://:efsw-agent --stdioruns on the server through an exec channel of the same SSH connection (one agent per connection, many watches). When the agent dies or the connection drops, watches reportReconnecting, re-register and emitRescanRequired. If the agent is not installed they fall back to polling over SFTP. SFTP itself never depends on the agent.- Anything else: polling through the VFS (
PollingWatchBackend).
To establish a consistent view, watch first, list second, then apply the events that arrived during
the listing; VFSDirectoryModel does exactly that.
VFSDirectoryModel
A UI::Models::Model for one directory of any scheme:
auto model = VFSDirectoryModel::New( uiExecutor );
tableView->setModel( model );
model->setURI( URI( "sftp://makyo/home/black" ) );
It enumerates incrementally (the first rows appear after the first batch), discards results of
previous navigations, stays live through watches (row inserts/deletes keep the selection), and
exposes loadState(), error() and watchStatus().
Columns are Name, Size, Modified, Type, Permissions, Owner and Group; cells carry a per-column CSS
class (vfs_size, ...). Applications customize the text with setTypeProvider(),
setTimeFormatter() and setItemCountFormatter(). With Config::countDirectoryItems (or
setCountDirectoryItems()), directories are counted in the background and the Size column shows
"N items". setNameFilter( pattern, mode, caseSensitive ) filters rows by name with unanchored
PlainText, Glob or RegEx patterns, like Dolphin's filter bar.
SFTP
SFTP is built by default (disable it with --without-vfs-sftp, Premake 4 or 5; it is never built
for emscripten). This compiles the vendored libssh2
(src/thirdparty/libssh2) against eepp's mbedTLS, or OpenSSL when --with-openssl is used.
sftp://host/path resolves host through OpenSSH configuration exactly like ssh host would:
~/.ssh/config and the system ssh_config, Host/Match (all client criteria), Include,
first-value-wins, HostName, Port, User, IdentityFile, IdentityAgent, IdentitiesOnly,
ProxyJump, ProxyCommand, UserKnownHostsFile, StrictHostKeyChecking, HostKeyAlias,
algorithm lists, hostname canonicalization and the final re-parse pass. A user or port in the URI
overrides the configuration. Passwords are rejected in URIs. SSHConfigEvaluator is usable on its
own and is tested against ssh -G.
Ed25519 identity files and host keys and the curve25519-sha256 key exchange are supported with
both crypto backends. mbedTLS does not implement them, so the vendored libssh2 gets them from
Monocypher (src/thirdparty/monocypher, src/thirdparty/libssh2/src/mbedtls_ed25519.c).
Host keys follow StrictHostKeyChecking. Unless HostKeyAlgorithms is configured, the host key
algorithms that known_hosts already has keys for are preferred, as OpenSSH does. Changed keys are always refused. For unknown keys with
ask, the provider asks an application SSHHostKeyVerifier; secrets (passwords, passphrases,
keyboard-interactive answers) come from an SSHAuthenticationHandler. Both return tasks, so the
SSH I/O thread never blocks while the user answers. Accepted keys are appended atomically to the
user's known_hosts file.
Authentication order: ssh-agent, identity files (asking for passphrases), keyboard-interactive,
password, filtered by PreferredAuthentications and the *Authentication options.
Connections are pooled per effective configuration and shared by every operation and watch. All
sessions are driven by one non-blocking I/O thread. posix-rename@openssh.com (atomic replace),
fsync@openssh.com and statvfs@openssh.com are detected per server.
efsw-agent
efsw-agent is part of efsw (src/agent/efsw-agent.cpp, protocol in
include/efsw/agent_protocol.hpp). It has no eepp dependency and only reports changes. Install it
on the server's PATH, or set SFTPProviderConfig::watchAgentCommand. The eepp build produces
bin/efsw-agent for the build host.
Known limitations
- FIDO security-key identity files (
ssh-keygen -t ed25519-sk/ecdsa-sk) are not supported with either crypto backend: signing needs the hardware token (libfido2), which libssh2 leaves to the application. Such keys can be used through ssh-agent, which signs on the token's behalf (untested). Match localnetworkis not implemented on Windows.- Jump hosts use their own
ssh_configentries, but their ownProxyJump/ProxyCommandare ignored (the chain comes from the destination). - Operations on one SSH connection are serialized; a long transfer delays metadata requests on the same connection.
- The efsw-agent is not deployed automatically.
Resources (res://)
System::PackFileIndex (formerly System::VirtualFileSystem, still available as a deprecated
alias) indexes the files of every open Pack. res:///fonts/a.ttf maps to the pack path
fonts/a.ttf; directories are implicit. The provider is read-only and extracts files to memory
when they are opened.