Move eterm emulation off the UI thread and add tabbed frontend

- introduce a per-terminal worker controller with ordered command queues,
    coalesced events, immutable snapshots, and deterministic shutdown
  - move PTY reads, parsing, history, selection, resize, reflow, and process
    status handling onto the terminal worker
  - publish terminal snapshots at the host refresh rate while preserving
    immediate idle, resize, interaction, and final-exit presentation
  - migrate TerminalDisplay, UITerminal, eterm, ecode, debugger, and ACP
    integrations away from direct mutable emulator access
  - synchronize scrollbar updates with acknowledged scroll commands
  - preserve title, clipboard, bell, prompt, color, restart, keep-alive,
    selection, input, and buffered process-exit behavior
  - add bounded synchronous selection requests and idempotent worker shutdown
  - make POSIX process launch safe after fork in multithreaded hosts and mark
    original PTY descriptors close-on-exec
  - add an UIApplication-based tabbed eterm with legacy CLI parity, tab
    lifecycle handling, close warnings, and default navigation shortcuts
  - add premake targets and deterministic controller, snapshot, lifecycle,
    concurrency, presentation, and exit-drain tests
This commit is contained in:
Martín Lucas Golini
2026-08-30 20:25:44 -03:00
parent 7c92b59e00
commit 8bcec85da0
21 changed files with 2274 additions and 402 deletions
+302 -13
View File
@@ -1,9 +1,13 @@
#include "utest.hpp"
#include <atomic>
#include <chrono>
#include <eterm/system/iprocess.hpp>
#include <eterm/terminal/ipseudoterminal.hpp>
#include <eterm/terminal/iterminaldisplay.hpp>
#include <eterm/terminal/terminalcontroller.hpp>
#include <eterm/terminal/terminalemulator.hpp>
#include <limits>
#include <thread>
using namespace eterm::Terminal;
using namespace eterm::System;
@@ -11,7 +15,11 @@ using namespace eterm::System;
class MockPty : public IPseudoTerminal {
public:
std::string mBuffer;
std::string mWrites;
bool mLoopWrites{ true };
size_t mMaxRead{ std::numeric_limits<size_t>::max() };
size_t mReadOffset{ 0 };
std::atomic<size_t> mBytesRead{ 0 };
int mCols = 80;
int mRows = 24;
int getNumColumns() const override { return mCols; }
@@ -23,30 +31,313 @@ class MockPty : public IPseudoTerminal {
}
bool isTTY() const override { return true; }
int write( const char* s, size_t n ) override {
mBuffer.append( s, n );
mWrites.append( s, n );
if ( mLoopWrites )
mBuffer.append( s, n );
return n;
}
int read( char* buf, size_t n, bool ) override {
if ( mBuffer.empty() )
if ( mReadOffset == mBuffer.size() )
return 0;
size_t toRead = std::min( { n, mBuffer.size(), mMaxRead } );
memcpy( buf, mBuffer.data(), toRead );
mBuffer.erase( 0, toRead );
size_t toRead = std::min( { n, mBuffer.size() - mReadOffset, mMaxRead } );
memcpy( buf, mBuffer.data() + mReadOffset, toRead );
mReadOffset += toRead;
mBytesRead.fetch_add( toRead, std::memory_order_relaxed );
return toRead;
}
};
class MockProcess : public IProcess {
public:
bool mExited{ false };
std::atomic<bool> mExited{ false };
void checkExitStatus() override {}
bool hasExited() const override { return mExited; }
bool hasExited() const override { return mExited.load(); }
int getExitCode() const override { return 0; }
void terminate() override {}
void waitForExit() override {}
int pid() override { return 123; }
};
static std::shared_ptr<const TerminalSnapshot>
waitForSnapshot( const std::shared_ptr<TerminalController>& controller,
const std::function<bool( const TerminalSnapshot& )>& predicate,
std::chrono::milliseconds timeout = std::chrono::milliseconds( 1000 ) ) {
const auto deadline = std::chrono::steady_clock::now() + timeout;
while ( std::chrono::steady_clock::now() < deadline ) {
auto snapshot = controller->snapshot();
if ( snapshot && predicate( *snapshot ) )
return snapshot;
std::this_thread::sleep_for( std::chrono::milliseconds( 1 ) );
}
return nullptr;
}
UTEST( eterm_controller, command_wakeup_and_snapshot_immutability ) {
auto pty = std::make_unique<MockPty>();
auto process = std::make_unique<MockProcess>();
auto controller = TerminalController::create( std::move( pty ), std::move( process ), 100 );
ASSERT_TRUE( controller != nullptr );
controller->writeRaw( "ABC" );
auto first = waitForSnapshot( controller, []( const TerminalSnapshot& snapshot ) {
return snapshot.cells.size() >= 3 && snapshot.cells[0].u == 'A' &&
snapshot.cells[1].u == 'B' && snapshot.cells[2].u == 'C';
} );
ASSERT_TRUE( first != nullptr );
const Uint64 firstGeneration = first->generation;
controller->writeRaw( "\rXYZ" );
auto second =
waitForSnapshot( controller, [firstGeneration]( const TerminalSnapshot& snapshot ) {
return snapshot.generation > firstGeneration && snapshot.cells[0].u == 'X';
} );
ASSERT_TRUE( second != nullptr );
EXPECT_EQ( static_cast<Rune>( 'A' ), first->cells[0].u );
EXPECT_TRUE( second->generation > first->generation );
}
UTEST( eterm_controller, skipped_snapshot_generation_requires_full_redraw ) {
TerminalSnapshot snapshot;
snapshot.generation = 42;
EXPECT_TRUE( snapshot.dirtyRowsFollow( 41 ) );
EXPECT_FALSE( snapshot.dirtyRowsFollow( 40 ) );
}
UTEST( eterm_controller, ordered_selection_request ) {
auto pty = std::make_unique<MockPty>();
pty->mBuffer = "ordered selection";
auto process = std::make_unique<MockProcess>();
auto controller = TerminalController::create( std::move( pty ), std::move( process ), 100 );
ASSERT_TRUE( waitForSnapshot( controller, []( const TerminalSnapshot& snapshot ) {
return !snapshot.cells.empty() && snapshot.cells[0].u == 'o';
} ) != nullptr );
controller->selectionStart( 0, 0, 0 );
controller->selectionExtend( 6, 0, SEL_REGULAR, false );
auto selection = controller->requestSelection();
ASSERT_TRUE( selection.has_value() );
EXPECT_STDSTREQ( "ordered", *selection );
}
UTEST( eterm_controller, loaded_command_latency_stays_bounded ) {
auto pty = std::make_unique<MockPty>();
pty->mBuffer.assign( 32 * 1024 * 1024, 'L' );
pty->mMaxRead = 64;
MockPty* ptyPtr = pty.get();
auto process = std::make_unique<MockProcess>();
auto controller = TerminalController::create( std::move( pty ), std::move( process ), 100 );
const auto readDeadline = std::chrono::steady_clock::now() + std::chrono::milliseconds( 100 );
while ( ptyPtr->mBytesRead.load( std::memory_order_relaxed ) == 0 &&
std::chrono::steady_clock::now() < readDeadline )
std::this_thread::yield();
ASSERT_TRUE( ptyPtr->mBytesRead.load( std::memory_order_relaxed ) > 0 );
const auto start = std::chrono::steady_clock::now();
auto selection = controller->requestSelection();
const auto latency = std::chrono::steady_clock::now() - start;
EXPECT_TRUE( selection.has_value() );
EXPECT_TRUE( latency < std::chrono::milliseconds( 50 ) );
}
UTEST( eterm_controller, resize_and_output_are_serialized ) {
auto pty = std::make_unique<MockPty>();
auto process = std::make_unique<MockProcess>();
auto controller = TerminalController::create( std::move( pty ), std::move( process ), 100 );
controller->resize( 40, 12 );
controller->writeRaw( "after resize" );
auto snapshot = waitForSnapshot( controller, []( const TerminalSnapshot& value ) {
return value.columns == 40 && value.rows == 12 && !value.cells.empty() &&
value.cells[0].u == 'a';
} );
ASSERT_TRUE( snapshot != nullptr );
EXPECT_EQ( static_cast<size_t>( 40 * 12 ), snapshot->cells.size() );
}
UTEST( eterm_controller, scroll_snapshots_acknowledge_the_latest_ordered_command ) {
auto pty = std::make_unique<MockPty>();
for ( int line = 0; line < 80; ++line )
pty->mBuffer += "history " + std::to_string( line ) + "\r\n";
auto process = std::make_unique<MockProcess>();
auto controller = TerminalController::create( std::move( pty ), std::move( process ), 100 );
ASSERT_TRUE( waitForSnapshot( controller, []( const TerminalSnapshot& snapshot ) {
return snapshot.historyLength >= 25;
} ) != nullptr );
const Uint64 firstCommand = controller->scrollTo( 10 );
const Uint64 secondCommand = controller->scrollTo( 25 );
EXPECT_EQ( firstCommand + 1, secondCommand );
auto acknowledged =
waitForSnapshot( controller, [secondCommand]( const TerminalSnapshot& snapshot ) {
return snapshot.lastAppliedScrollCommand == secondCommand;
} );
ASSERT_TRUE( acknowledged != nullptr );
EXPECT_EQ( 25, acknowledged->scrollPosition );
}
UTEST( eterm_controller, presentation_rate_is_applied_on_the_worker ) {
auto pty = std::make_unique<MockPty>();
auto process = std::make_unique<MockProcess>();
auto controller = TerminalController::create( std::move( pty ), std::move( process ), 100 );
controller->setPresentationRate( 120 );
ASSERT_TRUE( waitForSnapshot( controller, []( const TerminalSnapshot& snapshot ) {
return snapshot.presentationRate == 120;
} ) != nullptr );
}
UTEST( eterm_controller, focus_reporting_is_ordered_on_worker ) {
auto pty = std::make_unique<MockPty>();
pty->mBuffer = "\033[?1004h";
pty->mLoopWrites = false;
MockPty* ptyPtr = pty.get();
auto process = std::make_unique<MockProcess>();
auto controller = TerminalController::create( std::move( pty ), std::move( process ), 100 );
auto enabled = waitForSnapshot( controller, []( const TerminalSnapshot& snapshot ) {
return snapshot.windowMode & MODE_FOCUS;
} );
ASSERT_TRUE( enabled != nullptr );
controller->setFocus( false );
auto unfocused = waitForSnapshot( controller, [enabled]( const TerminalSnapshot& snapshot ) {
return snapshot.generation > enabled->generation && !( snapshot.windowMode & MODE_FOCUSED );
} );
ASSERT_TRUE( unfocused != nullptr );
ASSERT_TRUE( ptyPtr->mWrites.size() >= 3 );
EXPECT_STDSTREQ( "\033[O", ptyPtr->mWrites.substr( ptyPtr->mWrites.size() - 3 ) );
controller->setFocus( true );
ASSERT_TRUE( waitForSnapshot( controller, [unfocused]( const TerminalSnapshot& snapshot ) {
return snapshot.generation > unfocused->generation &&
snapshot.windowMode & MODE_FOCUSED;
} ) != nullptr );
ASSERT_TRUE( ptyPtr->mWrites.size() >= 3 );
EXPECT_STDSTREQ( "\033[I", ptyPtr->mWrites.substr( ptyPtr->mWrites.size() - 3 ) );
}
UTEST( eterm_controller, replaceable_events_coalesce_without_losing_ordered_events ) {
auto pty = std::make_unique<MockPty>();
auto process = std::make_unique<MockProcess>();
auto controller = TerminalController::create( std::move( pty ), std::move( process ), 100 );
controller->drainEvents();
controller->writeRaw( "\033]0;first\a\033]0;second\a" );
ASSERT_TRUE( waitForSnapshot( controller, []( const TerminalSnapshot& snapshot ) {
return snapshot.title == "second";
} ) != nullptr );
int titleEvents = 0;
std::string title;
for ( auto& event : controller->drainEvents() ) {
if ( event.type == TerminalController::EventType::Title ) {
++titleEvents;
title = std::move( event.data );
}
}
EXPECT_EQ( 1, titleEvents );
EXPECT_STDSTREQ( "second", title );
}
UTEST( eterm_controller, ordered_events_are_coalescing_barriers ) {
auto pty = std::make_unique<MockPty>();
auto process = std::make_unique<MockProcess>();
auto controller = TerminalController::create( std::move( pty ), std::move( process ), 100 );
controller->drainEvents();
controller->writeRaw( "\033]0;before\a\a\033]0;after\a" );
ASSERT_TRUE( waitForSnapshot( controller, []( const TerminalSnapshot& snapshot ) {
return snapshot.title == "after";
} ) != nullptr );
std::vector<TerminalController::EventType> semanticEvents;
for ( const auto& event : controller->drainEvents() ) {
if ( event.type == TerminalController::EventType::Title ||
event.type == TerminalController::EventType::Bell )
semanticEvents.emplace_back( event.type );
}
ASSERT_EQ( static_cast<size_t>( 3 ), semanticEvents.size() );
EXPECT_EQ( TerminalController::EventType::Title, semanticEvents[0] );
EXPECT_EQ( TerminalController::EventType::Bell, semanticEvents[1] );
EXPECT_EQ( TerminalController::EventType::Title, semanticEvents[2] );
}
UTEST( eterm_controller, reset_is_ordered_and_publishes_immediately ) {
auto pty = std::make_unique<MockPty>();
pty->mBuffer = "content";
auto process = std::make_unique<MockProcess>();
auto controller = TerminalController::create( std::move( pty ), std::move( process ), 100 );
auto populated = waitForSnapshot( controller, []( const TerminalSnapshot& snapshot ) {
return !snapshot.cells.empty() && snapshot.cells[0].u == 'c';
} );
ASSERT_TRUE( populated != nullptr );
controller->reset();
auto reset = waitForSnapshot( controller, [populated]( const TerminalSnapshot& snapshot ) {
return snapshot.generation > populated->generation && !snapshot.cells.empty() &&
snapshot.cells[0].u == ' ';
} );
ASSERT_TRUE( reset != nullptr );
}
UTEST( eterm_controller, process_exit_follows_buffered_output_and_final_snapshot ) {
auto pty = std::make_unique<MockPty>();
pty->mMaxRead = 1;
MockPty* ptyPtr = pty.get();
auto process = std::make_unique<MockProcess>();
MockProcess* processPtr = process.get();
auto controller = TerminalController::create( std::move( pty ), std::move( process ), 100 );
controller->setDataEventsEnabled( true );
controller->writeRaw( std::string( 3 * 1024, 'Q' ) );
const auto readDeadline = std::chrono::steady_clock::now() + std::chrono::milliseconds( 100 );
while ( ptyPtr->mBytesRead.load( std::memory_order_relaxed ) == 0 &&
std::chrono::steady_clock::now() < readDeadline )
std::this_thread::yield();
ASSERT_TRUE( ptyPtr->mBytesRead.load( std::memory_order_relaxed ) > 0 );
processPtr->mExited.store( true );
auto finalSnapshot = waitForSnapshot(
controller, []( const TerminalSnapshot& snapshot ) { return snapshot.processExited; } );
ASSERT_TRUE( finalSnapshot != nullptr );
EXPECT_EQ( 0, finalSnapshot->exitCode );
size_t bytesRead = 0;
bool sawExit = false;
for ( auto& event : controller->drainEvents() ) {
if ( event.type == TerminalController::EventType::Data )
bytesRead += event.data.size();
else if ( event.type == TerminalController::EventType::ProcessExit )
sawExit = true;
}
EXPECT_EQ( static_cast<size_t>( 3 * 1024 ), bytesRead );
EXPECT_TRUE( sawExit );
}
UTEST( eterm_controller, repeated_create_destroy_and_concurrent_workers ) {
for ( int iteration = 0; iteration < 16; ++iteration ) {
std::vector<std::shared_ptr<TerminalController>> controllers;
for ( int terminal = 0; terminal < 4; ++terminal ) {
auto pty = std::make_unique<MockPty>();
auto process = std::make_unique<MockProcess>();
auto controller =
TerminalController::create( std::move( pty ), std::move( process ), 100 );
controller->writeRaw( "worker" + std::to_string( terminal ) );
controllers.emplace_back( std::move( controller ) );
}
for ( const auto& controller : controllers ) {
EXPECT_TRUE( waitForSnapshot( controller, []( const TerminalSnapshot& snapshot ) {
return !snapshot.cells.empty() && snapshot.cells[0].u == 'w';
} ) != nullptr );
}
}
}
UTEST( eterm_controller, concurrent_shutdown_is_idempotent ) {
auto pty = std::make_unique<MockPty>();
auto process = std::make_unique<MockProcess>();
auto controller = TerminalController::create( std::move( pty ), std::move( process ), 100 );
std::vector<std::thread> shutdownThreads;
for ( int thread = 0; thread < 4; ++thread )
shutdownThreads.emplace_back( [controller] { controller->shutdown(); } );
for ( auto& thread : shutdownThreads )
thread.join();
controller->shutdown();
}
class MockDisplay : public ITerminalDisplay {
public:
int mDrawLines{ 0 };
@@ -120,14 +411,12 @@ UTEST( eterm, sustained_saturated_reads_present_periodically ) {
term->update();
display->mDrawLines = 0;
std::string output( 33 * 1024, 'A' );
std::string output( 1024 * 1024, 'A' );
term->write( output.data(), output.size() );
for ( int batch = 0; batch < 31; ++batch ) {
EXPECT_FALSE( term->update() );
EXPECT_EQ( 0, display->mDrawLines );
}
EXPECT_FALSE( term->update() );
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds( 100 );
while ( display->mDrawLines == 0 && std::chrono::steady_clock::now() < deadline )
term->update();
EXPECT_TRUE( display->mDrawLines > 0 );
}