wip: nested-asyncify fix, wxAuiToolBar registration, tests, research docs

Main-repo side of a multi-part WIP covering the KiCad WASM tool-selection
and nested-Asyncify work. Submodule commits are in kicad@f6e9239aaa
(libcontext hygiene) and wxwidgets@bb80f91e8b (auibar registration +
dialog diagnostics).

## scripts/common/inject-dyncall-shims.sh

Wrap Asyncify.handleSleep / allocateData to save-and-restore Asyncify.currData
around each EM_ASYNC_JS sleep. This fixes the nested Asyncify collision where
a fiber swap that fired during a modal's event loop clobbered currData, and
the modal's later doRewind used the fiber's buffer and hit "RuntimeError:
index out of bounds". Root cause documented as Emscripten Issue #9153
(wontfix upstream).

Diagnostic-rewind logging (forcedBottomOfCallStack, callStack traces) is
retained to help future debugging of Asyncify state corruption.

## tests/

- tests/playwright-kicad.config.ts: add `channel: 'chrome'` for the
  chromium project so --project=chromium --headed uses system Chrome
  (real GPU) instead of SwiftShader on ARM Mac. Also switch trace to
  retain-on-failure + screenshot on-failure for easier E2E debugging.
- tests/kicad/pcbnew.spec.ts: replace `tool.checked` assertions with a
  label-suffix check (`[checked]`) since our auibar registration encodes
  checked state in the label (no schema change to the registry).
- tests/apps/Makefile.wasm: add `coroutine-nested` build target + include
  it in the all: list.
- tests/apps/standalone/coroutine/: kicad_coroutine_harness.h + test app
  reproducing KiCad COROUTINE semantics against real libcontext.
- tests/apps/standalone/coroutine-nested/: nested_test.cpp reproduces the
  EM_ASYNC_JS-modal + fiber-swap nesting bug in isolation. 8 scenarios
  from baseline_modal_alone through nested_fibers_inside_modal.
- tests/e2e/coroutine.spec.ts + coroutine-nested.spec.ts: Playwright specs
  that load the standalone apps and assert all case cases pass via
  [COROUTINE_TEST] SUMMARY log parsing.

## research/ and features/browser-tools/

Three background docs capturing the investigation trajectory:

- features/browser-tools/0001-kicad-wasm-tool-activation-investigation.md
  Early investigation: why tools don't activate; initial dynCall-empty-
  callback hypothesis.
- features/browser-tools/0002-wasm-coroutine-deep-dive.md
  Deep dive on Asyncify internals, fiber API, QEMU's coroutine-wasm
  reference implementation.
- features/browser-tools/0003-wxauitoolbar-registration-fix.md
  The narrow fix: why wxAuiToolBar needs a registration block, where to
  add it, what the fallback plan is.
- research/threading_1.md: corrected root-cause analysis after reading
  runtime logs — nested-Asyncify currData collision, Emscripten #9153.
- research/threading_2.md: extended research on alternative approaches
  (JSPI/WasmFX/state-machines) and why they don't help here.

## Submodule pointer updates

kicad: f6e9239aaa (wip: libcontext WASM hygiene cleanup)
wxwidgets: bb80f91e8b (wip: wxAuiToolBar element-registry registration +
            dialog diagnostics)

## Open threads not yet in scope

- Firefox/Chrome divergent behavior: "indirect call signature mismatch"
  traps in Firefox vs renderer crash in system Chrome (tracked in
  plans/peaceful-hugging-pnueli.md and the research docs).
- E2E pixel-diff for Draw Lines fails because the test's diff region does
  not cover where the line is actually drawn; tool activation works, the
  line is visible in test-results/pcbnew-draw-lines-02-after-drawing.png.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Viktor Vaczi 2026-04-21 13:58:16 +02:00
commit 9a04217788
17 changed files with 5190 additions and 46 deletions

View file

@ -0,0 +1,975 @@
#include "wx/wx.h"
#include "wx/textctrl.h"
#include "wx/timer.h"
#include "kicad_coroutine_harness.h"
#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h>
#endif
#include <array>
#include <functional>
#include <numeric>
#include <sstream>
#include <string>
#include <vector>
using coroutine_test::TestCoroutine;
namespace
{
constexpr int ID_ASYNC_CASE_TIMER = wxID_HIGHEST + 450;
struct CaseContext
{
bool passed = true;
std::vector<std::string> failures;
void Expect( bool aCondition, const std::string& aMessage )
{
if( !aCondition )
{
passed = false;
failures.push_back( aMessage );
}
}
};
struct CaseResult
{
std::string name;
bool passed = true;
std::string detail;
};
std::string JoinFailures( const std::vector<std::string>& aFailures )
{
std::ostringstream oss;
for( std::size_t i = 0; i < aFailures.size(); ++i )
{
if( i > 0 )
oss << " | ";
oss << aFailures[i];
}
return oss.str();
}
template <typename T>
std::string JoinVector( const std::vector<T>& aValues )
{
std::ostringstream oss;
for( std::size_t i = 0; i < aValues.size(); ++i )
{
if( i > 0 )
oss << ",";
oss << aValues[i];
}
return oss.str();
}
} // namespace
class CoroutineTestFrame : public wxFrame
{
public:
CoroutineTestFrame() :
wxFrame( nullptr, wxID_ANY, "Coroutine Stress Test",
wxDefaultPosition, wxSize( 1000, 760 ) ),
m_asyncCaseTimer( this, ID_ASYNC_CASE_TIMER )
{
wxPanel* panel = new wxPanel( this );
wxBoxSizer* sizer = new wxBoxSizer( wxVERTICAL );
wxStaticText* description = new wxStaticText(
panel,
wxID_ANY,
"Stress-tests KiCad-style coroutine semantics on top of the real libcontext WASM port.\n"
"The suite runs automatically on startup and reports PASS/FAIL per scenario."
);
sizer->Add( description, 0, wxEXPAND | wxALL, 8 );
m_summary = new wxStaticText( panel, wxID_ANY, "Running coroutine stress suite..." );
sizer->Add( m_summary, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 8 );
m_log = new wxTextCtrl(
panel,
wxID_ANY,
"",
wxDefaultPosition,
wxDefaultSize,
wxTE_MULTILINE | wxTE_READONLY
);
m_log->SetFont( wxFontInfo( 10 ).Family( wxFONTFAMILY_TELETYPE ) );
sizer->Add( m_log, 1, wxEXPAND | wxALL, 8 );
panel->SetSizer( sizer );
CreateStatusBar();
SetStatusText( "Coroutine test harness starting" );
Bind( wxEVT_TIMER, &CoroutineTestFrame::OnAsyncCaseTimer, this, ID_ASYNC_CASE_TIMER );
CallAfter( [this]() { RunSuite(); } );
}
private:
struct AsyncWaitLoopCaseState
{
struct Event
{
std::string name;
};
CaseContext ctx;
std::vector<std::string> sequence;
bool pendingWait = false;
bool shutdown = false;
Event wakeupEvent;
std::unique_ptr<TestCoroutine> tool;
int phase = 0;
};
struct AsyncNestedResumeCaseState
{
struct Event
{
std::string name;
};
CaseContext ctx;
std::vector<std::string> sequence;
bool selectionPendingWait = false;
bool selectionShutdown = false;
Event selectionWakeupEvent;
std::unique_ptr<TestCoroutine> selection;
std::unique_ptr<TestCoroutine> control;
int phase = 0;
};
void Log( const wxString& aMessage )
{
if( m_log )
{
m_log->AppendText( aMessage );
m_log->AppendText( "\n" );
}
std::string utf8 = aMessage.ToStdString();
#ifdef __EMSCRIPTEN__
EM_ASM( {
console.log( UTF8ToString( $0 ) );
}, utf8.c_str() );
#else
printf( "%s\n", utf8.c_str() );
#endif
}
CaseResult RunCase( const std::string& aName, const std::function<void( CaseContext& )>& aFn )
{
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", aName ) );
CaseContext ctx;
aFn( ctx );
CaseResult result;
result.name = aName;
result.passed = ctx.passed;
result.detail = JoinFailures( ctx.failures );
if( result.passed )
{
Log( wxString::Format( "[COROUTINE_TEST] PASS %s", aName ) );
}
else
{
Log( wxString::Format( "[COROUTINE_TEST] FAIL %s :: %s", aName, result.detail ) );
}
return result;
}
void FinishCase( CaseResult aResult )
{
m_results.push_back( std::move( aResult ) );
if( m_pendingAsyncCases == 0 )
FinalizeSuite();
}
void FinalizeCase( const std::string& aName, CaseContext&& aCtx )
{
CaseResult result;
result.name = aName;
result.passed = aCtx.passed;
result.detail = JoinFailures( aCtx.failures );
if( result.passed )
Log( wxString::Format( "[COROUTINE_TEST] PASS %s", aName ) );
else
Log( wxString::Format( "[COROUTINE_TEST] FAIL %s :: %s", aName, result.detail ) );
FinishCase( std::move( result ) );
}
void FinalizeSuite()
{
int passed = 0;
for( const CaseResult& result : m_results )
{
if( result.passed )
++passed;
}
int failed = static_cast<int>( m_results.size() ) - passed;
wxString summary = wxString::Format( "Coroutine suite complete: %d passed, %d failed, %zu total",
passed, failed, m_results.size() );
m_summary->SetLabel( summary );
SetStatusText( summary );
Log( wxString::Format( "[COROUTINE_TEST] SUMMARY total=%zu passed=%d failed=%d",
m_results.size(), passed, failed ) );
}
void StartAsyncWaitLoopCase()
{
constexpr const char* caseName = "async_wait_loop_stays_suspended";
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
m_pendingAsyncCases = 1;
m_asyncCaseName = caseName;
m_asyncState = std::make_unique<AsyncWaitLoopCaseState>();
AsyncWaitLoopCaseState* state = m_asyncState.get();
state->tool = std::make_unique<TestCoroutine>( [state]( TestCoroutine& self ) {
while( true )
{
state->ctx.Expect( !state->pendingWait, "tool should not enter Wait twice in a row" );
state->pendingWait = true;
state->sequence.push_back( "wait-enter" );
self.Yield( 700 );
state->sequence.push_back( "wait-return" );
if( state->shutdown )
break;
state->ctx.Expect( !state->wakeupEvent.name.empty(),
"wakeup event should be populated before resume" );
state->sequence.push_back( "event:" + state->wakeupEvent.name );
}
state->sequence.push_back( "tool-end" );
} );
bool running = state->tool->Call( 1 );
state->ctx.Expect( running, "tool wait loop should yield on initial call" );
state->ctx.Expect( state->tool->LastReturnValue() == 700,
"initial wait yield should reach the root" );
state->ctx.Expect( state->pendingWait, "tool should be pending wait after initial yield" );
const std::vector<std::string> expectedBeforeDispatch = {
"wait-enter"
};
state->ctx.Expect( state->sequence == expectedBeforeDispatch,
"unexpected async sequence before dispatch: "
+ JoinVector( state->sequence ) );
state->phase = 1;
m_asyncCaseTimer.StartOnce( 10 );
}
void CompleteAsyncWaitLoopCase()
{
if( !m_asyncState )
return;
RecordAsyncCase( m_asyncCaseName, std::move( m_asyncState->ctx ) );
m_asyncState.reset();
StartAsyncNestedResumeCase();
}
void RecordAsyncCase( const std::string& aName, CaseContext&& aCtx )
{
CaseResult result;
result.name = aName;
result.passed = aCtx.passed;
result.detail = JoinFailures( aCtx.failures );
if( result.passed )
Log( wxString::Format( "[COROUTINE_TEST] PASS %s", aName ) );
else
Log( wxString::Format( "[COROUTINE_TEST] FAIL %s :: %s", aName, result.detail ) );
m_results.push_back( std::move( result ) );
}
void StartAsyncNestedResumeCase()
{
constexpr const char* caseName = "async_nested_resume_from_child_tool";
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
m_asyncCaseName = caseName;
m_nestedAsyncState = std::make_unique<AsyncNestedResumeCaseState>();
AsyncNestedResumeCaseState* state = m_nestedAsyncState.get();
state->selection = std::make_unique<TestCoroutine>( [state]( TestCoroutine& self ) {
while( true )
{
state->ctx.Expect( !state->selectionPendingWait,
"selection should not enter Wait twice in a row" );
state->selectionPendingWait = true;
state->sequence.push_back( "selection:wait-enter" );
self.Yield( 700 );
state->sequence.push_back( "selection:wait-return" );
if( state->selectionShutdown )
break;
state->ctx.Expect( !state->selectionWakeupEvent.name.empty(),
"selection wakeup event should be populated before nested resume" );
state->sequence.push_back( "selection:event:" + state->selectionWakeupEvent.name );
}
state->sequence.push_back( "selection:tool-end" );
} );
state->control = std::make_unique<TestCoroutine>( [state]( TestCoroutine& self ) {
(void) self;
state->sequence.push_back( "control:start" );
state->ctx.Expect( state->selectionPendingWait,
"selection should be pending when child tool resumes it" );
state->selectionPendingWait = false;
state->selectionWakeupEvent = { "metricUnits" };
bool selectionRunning = state->selection->Resume( self, 2 );
state->ctx.Expect( selectionRunning,
"selection should yield back to child tool after nested resume" );
state->ctx.Expect( state->selection->LastReturnValue() == 700,
"nested selection yield should reach the child tool" );
state->ctx.Expect( state->selectionPendingWait,
"selection should be waiting again after nested resume" );
state->sequence.push_back( "control:after-selection" );
} );
bool selectionRunning = state->selection->Call( 1 );
state->ctx.Expect( selectionRunning, "selection should yield on initial call" );
state->ctx.Expect( state->selection->LastReturnValue() == 700,
"initial selection yield should reach the root" );
state->ctx.Expect( state->selectionPendingWait,
"selection should be pending wait after initial yield" );
const std::vector<std::string> expectedBeforeNestedResume = {
"selection:wait-enter"
};
state->ctx.Expect( state->sequence == expectedBeforeNestedResume,
"unexpected nested sequence before child tool dispatch: "
+ JoinVector( state->sequence ) );
state->phase = 1;
m_asyncCaseTimer.StartOnce( 10 );
}
void CompleteAsyncNestedResumeCase()
{
if( !m_nestedAsyncState )
return;
m_pendingAsyncCases = 0;
FinalizeCase( m_asyncCaseName, std::move( m_nestedAsyncState->ctx ) );
m_nestedAsyncState.reset();
}
void OnAsyncCaseTimer( wxTimerEvent& aEvent )
{
if( aEvent.GetId() != ID_ASYNC_CASE_TIMER )
return;
if( m_asyncState )
{
AsyncWaitLoopCaseState* state = m_asyncState.get();
if( state->phase == 1 )
{
state->ctx.Expect( state->pendingWait,
"tool should still be pending when the browser callback resumes it" );
state->pendingWait = false;
state->wakeupEvent = { "metricUnits" };
bool running = state->tool->Resume( 2 );
state->ctx.Expect( running, "tool should yield again after the first callback resume" );
state->ctx.Expect( state->tool->LastReturnValue() == 700,
"second wait yield should reach the root" );
state->ctx.Expect( state->pendingWait,
"tool should be pending wait again after second yield" );
state->ctx.Expect( state->tool->Running(),
"tool should still be suspended after second yield" );
const std::vector<std::string> expectedAfterFirstResume = {
"wait-enter",
"wait-return",
"event:metricUnits",
"wait-enter"
};
state->ctx.Expect( state->sequence == expectedAfterFirstResume,
"unexpected wait-loop sequence after first callback resume: "
+ JoinVector( state->sequence ) );
state->phase = 2;
m_asyncCaseTimer.StartOnce( 10 );
return;
}
if( state->phase == 2 )
{
const std::vector<std::string> expectedStillSuspended = {
"wait-enter",
"wait-return",
"event:metricUnits",
"wait-enter"
};
state->ctx.Expect( state->sequence == expectedStillSuspended,
"tool should remain suspended until the next explicit dispatch: "
+ JoinVector( state->sequence ) );
state->ctx.Expect( state->pendingWait,
"tool should still be pending wait before the next dispatch" );
state->ctx.Expect( state->tool->Running(),
"tool should still be running before the next dispatch" );
state->phase = 3;
m_asyncCaseTimer.StartOnce( 10 );
return;
}
if( state->phase == 3 )
{
state->ctx.Expect( state->pendingWait,
"tool should still be pending before the shutdown dispatch" );
state->pendingWait = false;
state->shutdown = true;
state->wakeupEvent = { "shutdown" };
bool running = state->tool->Resume( 3 );
state->ctx.Expect( !running, "tool should finish after the explicit shutdown dispatch" );
const std::vector<std::string> expectedAfterFinalResume = {
"wait-enter",
"wait-return",
"event:metricUnits",
"wait-enter",
"wait-return",
"tool-end"
};
state->ctx.Expect( state->sequence == expectedAfterFinalResume,
"unexpected wait-loop sequence after shutdown dispatch: "
+ JoinVector( state->sequence ) );
CompleteAsyncWaitLoopCase();
return;
}
}
if( !m_nestedAsyncState )
return;
AsyncNestedResumeCaseState* state = m_nestedAsyncState.get();
if( state->phase == 1 )
{
bool controlRunning = state->control->Call( 11 );
state->ctx.Expect( !controlRunning,
"child tool should finish after resuming selection once" );
const std::vector<std::string> expectedAfterNestedResume = {
"selection:wait-enter",
"control:start",
"selection:wait-return",
"selection:event:metricUnits",
"selection:wait-enter",
"control:after-selection"
};
state->ctx.Expect( state->sequence == expectedAfterNestedResume,
"unexpected nested sequence after child tool resume: "
+ JoinVector( state->sequence ) );
state->ctx.Expect( state->selectionPendingWait,
"selection should be waiting again after the child tool finishes" );
state->ctx.Expect( state->selection->Running(),
"selection should still be suspended after nested resume" );
state->phase = 2;
m_asyncCaseTimer.StartOnce( 10 );
return;
}
if( state->phase == 2 )
{
const std::vector<std::string> expectedStillSuspended = {
"selection:wait-enter",
"control:start",
"selection:wait-return",
"selection:event:metricUnits",
"selection:wait-enter",
"control:after-selection"
};
state->ctx.Expect( state->sequence == expectedStillSuspended,
"selection should remain suspended after the child tool returns: "
+ JoinVector( state->sequence ) );
state->ctx.Expect( state->selectionPendingWait,
"selection should still be pending before the shutdown dispatch" );
state->ctx.Expect( state->selection->Running(),
"selection should still be running before the shutdown dispatch" );
state->selectionPendingWait = false;
state->selectionShutdown = true;
state->selectionWakeupEvent = { "shutdown" };
bool selectionRunning = state->selection->Resume( 3 );
state->ctx.Expect( !selectionRunning,
"selection should finish after the explicit shutdown dispatch" );
const std::vector<std::string> expectedAfterFinalResume = {
"selection:wait-enter",
"control:start",
"selection:wait-return",
"selection:event:metricUnits",
"selection:wait-enter",
"control:after-selection",
"selection:wait-return",
"selection:tool-end"
};
state->ctx.Expect( state->sequence == expectedAfterFinalResume,
"unexpected nested sequence after final selection shutdown: "
+ JoinVector( state->sequence ) );
CompleteAsyncNestedResumeCase();
}
}
void RunSuite()
{
m_results.clear();
m_results.push_back( RunCase( "first_entry_runs_once", [this]( CaseContext& ctx ) {
TestCoroutine coroutine( [&]( TestCoroutine& self ) {
ctx.Expect( self.EntryCount() == 1, "entry count should be 1 on first entry" );
self.Yield( 11 );
ctx.Expect( self.EntryCount() == 1, "entry count should still be 1 after resume" );
} );
bool running = coroutine.Call( 1 );
ctx.Expect( running, "coroutine should yield on first call" );
ctx.Expect( coroutine.EntryCount() == 1, "entry count should be 1 after call" );
ctx.Expect( coroutine.LastReturnValue() == 11, "yield value should be 11" );
running = coroutine.Resume( 2 );
ctx.Expect( !running, "coroutine should finish after resume" );
ctx.Expect( coroutine.EntryCount() == 1, "entry count should remain 1" );
} ) );
m_results.push_back( RunCase( "yield_resume_preserves_state", [this]( CaseContext& ctx ) {
int entryRuns = 0;
int afterResume = 0;
intptr_t resumedValue = -1;
bool localPreserved = false;
TestCoroutine coroutine( [&]( TestCoroutine& self ) {
++entryRuns;
int localGuard = 41;
self.Yield( 111 );
++afterResume;
resumedValue = self.CurrentValue();
localPreserved = ( localGuard == 41 );
} );
bool running = coroutine.Call( 7 );
ctx.Expect( running, "coroutine should yield on initial call" );
ctx.Expect( entryRuns == 1, "entry should run once" );
ctx.Expect( coroutine.LastReturnValue() == 111, "yield should reach caller" );
running = coroutine.Resume( 222 );
ctx.Expect( !running, "coroutine should finish after resume" );
ctx.Expect( afterResume == 1, "post-resume code should run once" );
ctx.Expect( resumedValue == 222, "resume value should reach coroutine" );
ctx.Expect( localPreserved, "stack-local state should survive yield/resume" );
} ) );
m_results.push_back( RunCase( "deep_stack_preserved_across_yield", [this]( CaseContext& ctx ) {
TestCoroutine coroutine( [&]( TestCoroutine& self ) {
std::function<void( int )> dive = [&]( int depth ) {
std::array<int, 16> locals {};
for( std::size_t i = 0; i < locals.size(); ++i )
locals[i] = depth * 100 + static_cast<int>( i );
int expected = std::accumulate( locals.begin(), locals.end(), 0 );
if( depth == 0 )
{
self.Yield( 500 );
ctx.Expect( std::accumulate( locals.begin(), locals.end(), 0 ) == expected,
"deepest frame locals should survive resume" );
return;
}
dive( depth - 1 );
ctx.Expect( std::accumulate( locals.begin(), locals.end(), 0 ) == expected,
"frame locals should survive unwind/rewind at depth " + std::to_string( depth ) );
};
dive( 6 );
} );
bool running = coroutine.Call( 3 );
ctx.Expect( running, "deep stack coroutine should yield" );
ctx.Expect( coroutine.LastReturnValue() == 500, "deep stack yield value should propagate" );
running = coroutine.Resume( 4 );
ctx.Expect( !running, "deep stack coroutine should finish after resume" );
} ) );
m_results.push_back( RunCase( "nested_coroutine_call_and_resume", [this]( CaseContext& ctx ) {
std::vector<std::string> sequence;
intptr_t childResumeValue = 0;
TestCoroutine child( [&]( TestCoroutine& self ) {
sequence.push_back( "child-start" );
self.Yield( 33 );
childResumeValue = self.CurrentValue();
sequence.push_back( "child-end" );
} );
TestCoroutine parent( [&]( TestCoroutine& self ) {
sequence.push_back( "parent-start" );
bool childRunning = child.Call( self, 10 );
ctx.Expect( childRunning, "child should yield to parent" );
ctx.Expect( child.LastReturnValue() == 33, "child yield value should reach parent" );
sequence.push_back( "after-child-yield" );
childRunning = child.Resume( self, 44 );
ctx.Expect( !childRunning, "child should finish after resume" );
ctx.Expect( childResumeValue == 44, "resume value should reach child" );
sequence.push_back( "after-child-finish" );
self.Yield( 55 );
sequence.push_back( "parent-end" );
} );
bool running = parent.Call( 1 );
ctx.Expect( running, "parent should yield to root" );
ctx.Expect( parent.LastReturnValue() == 55, "parent yield should reach root" );
const std::vector<std::string> expectedBeforeResume = {
"parent-start",
"child-start",
"after-child-yield",
"child-end",
"after-child-finish"
};
ctx.Expect( sequence == expectedBeforeResume,
"unexpected nested sequence before parent resume: " + JoinVector( sequence ) );
running = parent.Resume( 2 );
ctx.Expect( !running, "parent should finish after resume" );
const std::vector<std::string> expectedAfterResume = {
"parent-start",
"child-start",
"after-child-yield",
"child-end",
"after-child-finish",
"parent-end"
};
ctx.Expect( sequence == expectedAfterResume,
"unexpected nested sequence after parent resume: " + JoinVector( sequence ) );
} ) );
m_results.push_back( RunCase( "nested_parent_yield_preserves_suspend", [this]( CaseContext& ctx ) {
std::vector<std::string> sequence;
intptr_t childResumeValue = 0;
intptr_t childFinalValue = 0;
TestCoroutine child( [&]( TestCoroutine& self ) {
sequence.push_back( "child-start" );
self.Yield( 301 );
childResumeValue = self.CurrentValue();
sequence.push_back( "child-after-parent-resume" );
self.Yield( 302 );
childFinalValue = self.CurrentValue();
sequence.push_back( "child-end" );
} );
TestCoroutine parent( [&]( TestCoroutine& self ) {
sequence.push_back( "parent-start" );
bool childRunning = child.Call( self, 111 );
ctx.Expect( childRunning, "child should yield to parent before parent yields to root" );
ctx.Expect( child.LastReturnValue() == 301, "child first yield should reach parent" );
sequence.push_back( "parent-after-child-yield" );
self.Yield( 401 );
sequence.push_back( "parent-after-root-resume" );
ctx.Expect( child.Running(), "child should still be suspended when parent resumes from root" );
childRunning = child.Resume( self, 222 );
ctx.Expect( childRunning, "child should yield a second time after parent resumes" );
ctx.Expect( child.LastReturnValue() == 302, "child second yield should reach parent" );
sequence.push_back( "parent-after-child-second-yield" );
self.Yield( 402 );
sequence.push_back( "parent-final-resume" );
childRunning = child.Resume( self, 333 );
ctx.Expect( !childRunning, "child should finish on final resume" );
sequence.push_back( "parent-end" );
} );
bool running = parent.Call( 1 );
ctx.Expect( running, "parent should yield to root after child yields to parent" );
ctx.Expect( parent.LastReturnValue() == 401, "parent first yield should reach root" );
ctx.Expect( child.Running(), "child should remain suspended after parent yields to root" );
const std::vector<std::string> expectedBeforeResume = {
"parent-start",
"child-start",
"parent-after-child-yield"
};
ctx.Expect( sequence == expectedBeforeResume,
"parent should remain suspended after yielding to root: " + JoinVector( sequence ) );
running = parent.Resume( 2 );
ctx.Expect( running, "parent should yield a second time after explicit root resume" );
ctx.Expect( parent.LastReturnValue() == 402, "parent second yield should reach root" );
ctx.Expect( childResumeValue == 222, "child should observe the value from the parent resume" );
const std::vector<std::string> expectedAfterFirstResume = {
"parent-start",
"child-start",
"parent-after-child-yield",
"parent-after-root-resume",
"child-after-parent-resume",
"parent-after-child-second-yield"
};
ctx.Expect( sequence == expectedAfterFirstResume,
"unexpected sequence after first explicit parent resume: " + JoinVector( sequence ) );
running = parent.Resume( 3 );
ctx.Expect( !running, "parent should finish after the final explicit root resume" );
ctx.Expect( childFinalValue == 333, "child should observe the final resume value" );
const std::vector<std::string> expectedAfterFinalResume = {
"parent-start",
"child-start",
"parent-after-child-yield",
"parent-after-root-resume",
"child-after-parent-resume",
"parent-after-child-second-yield",
"parent-final-resume",
"child-end",
"parent-end"
};
ctx.Expect( sequence == expectedAfterFinalResume,
"unexpected sequence after final parent resume: " + JoinVector( sequence ) );
} ) );
m_results.push_back( RunCase( "root_bounce_continue_after_root", [this]( CaseContext& ctx ) {
std::vector<std::string> events;
int rootRuns = 0;
intptr_t afterRootValue = 0;
TestCoroutine coroutine( [&]( TestCoroutine& self ) {
events.push_back( "before-root" );
self.RunMainStack( [&]() {
++rootRuns;
events.push_back( "on-root" );
}, 77 );
afterRootValue = self.CurrentValue();
events.push_back( "after-root" );
} );
bool running = coroutine.Call( 5 );
ctx.Expect( !running, "root bounce case should finish in one root call" );
ctx.Expect( rootRuns == 1, "root callback should run exactly once" );
ctx.Expect( afterRootValue == 77, "resume from root bounce should keep value" );
const std::vector<std::string> expected = { "before-root", "on-root", "after-root" };
ctx.Expect( events == expected, "unexpected root bounce order: " + JoinVector( events ) );
} ) );
m_results.push_back( RunCase( "completion_returns_control_without_exit", [this]( CaseContext& ctx ) {
int entryRuns = 0;
TestCoroutine coroutine( [&]( TestCoroutine& self ) {
++entryRuns;
(void) self;
} );
bool running = coroutine.Call( 9 );
ctx.Expect( !running, "completed coroutine should return false from Call" );
ctx.Expect( entryRuns == 1, "completion case should run exactly once" );
} ) );
m_results.push_back( RunCase( "resume_after_finish_does_not_reenter", [this]( CaseContext& ctx ) {
int entryRuns = 0;
TestCoroutine coroutine( [&]( TestCoroutine& self ) {
++entryRuns;
(void) self;
} );
bool running = coroutine.Call( 0 );
ctx.Expect( !running, "coroutine should finish immediately" );
running = coroutine.Resume( 123 );
ctx.Expect( !running, "resume on finished coroutine should stay false" );
ctx.Expect( entryRuns == 1, "finished coroutine must not re-enter" );
} ) );
m_results.push_back( RunCase( "interleaving_multiple_coroutines", [this]( CaseContext& ctx ) {
std::vector<std::string> sequence;
TestCoroutine a( [&]( TestCoroutine& self ) {
sequence.push_back( "a1" );
self.Yield( 1 );
sequence.push_back( "a2" );
self.Yield( 2 );
sequence.push_back( "a3" );
} );
TestCoroutine b( [&]( TestCoroutine& self ) {
sequence.push_back( "b1" );
self.Yield( 10 );
sequence.push_back( "b2" );
} );
bool runningA = a.Call( 1 );
bool runningB = b.Call( 2 );
ctx.Expect( runningA, "coroutine A should yield on first call" );
ctx.Expect( runningB, "coroutine B should yield on first call" );
ctx.Expect( a.LastReturnValue() == 1, "A first yield should be 1" );
ctx.Expect( b.LastReturnValue() == 10, "B first yield should be 10" );
runningA = a.Resume( 3 );
runningB = b.Resume( 4 );
ctx.Expect( runningA, "coroutine A should yield on second resume" );
ctx.Expect( !runningB, "coroutine B should finish on resume" );
ctx.Expect( a.LastReturnValue() == 2, "A second yield should be 2" );
runningA = a.Resume( 5 );
ctx.Expect( !runningA, "coroutine A should finish on final resume" );
const std::vector<std::string> expected = { "a1", "b1", "a2", "b2", "a3" };
ctx.Expect( sequence == expected, "unexpected interleave order: " + JoinVector( sequence ) );
} ) );
m_results.push_back( RunCase( "stress_many_round_trips", [this]( CaseContext& ctx ) {
constexpr int rounds = 96;
int total = 0;
int iterations = 0;
TestCoroutine coroutine( [&]( TestCoroutine& self ) {
for( int i = 0; i < rounds; ++i )
{
total += static_cast<int>( self.CurrentValue() );
++iterations;
if( i + 1 < rounds )
self.Yield( i + 1 );
}
} );
bool running = coroutine.Call( 1 );
ctx.Expect( running, "stress coroutine should yield on first iteration" );
ctx.Expect( coroutine.LastReturnValue() == 1, "first stress yield should be 1" );
for( int value = 2; value <= rounds; ++value )
{
running = coroutine.Resume( value );
if( value < rounds )
{
ctx.Expect( running, "stress coroutine should still be running at value " + std::to_string( value ) );
ctx.Expect( coroutine.LastReturnValue() == value,
"stress yield mismatch at value " + std::to_string( value ) );
}
else
{
ctx.Expect( !running, "stress coroutine should finish on final resume" );
}
}
int expectedTotal = rounds * ( rounds + 1 ) / 2;
ctx.Expect( iterations == rounds, "stress coroutine should run all iterations" );
ctx.Expect( total == expectedTotal,
"stress accumulated total mismatch, got " + std::to_string( total ) +
" expected " + std::to_string( expectedTotal ) );
} ) );
m_results.push_back( RunCase( "transfer_values_round_trip", [this]( CaseContext& ctx ) {
std::vector<intptr_t> observed;
TestCoroutine coroutine( [&]( TestCoroutine& self ) {
observed.push_back( self.CurrentValue() );
self.Yield( 31 );
observed.push_back( self.CurrentValue() );
self.Yield( 63 );
observed.push_back( self.CurrentValue() );
} );
bool running = coroutine.Call( 17 );
ctx.Expect( running, "transfer test should yield on first call" );
ctx.Expect( coroutine.LastReturnValue() == 31, "first transfer yield should be 31" );
running = coroutine.Resume( 47 );
ctx.Expect( running, "transfer test should yield on second step" );
ctx.Expect( coroutine.LastReturnValue() == 63, "second transfer yield should be 63" );
running = coroutine.Resume( 79 );
ctx.Expect( !running, "transfer test should finish on final resume" );
const std::vector<intptr_t> expected = { 17, 47, 79 };
ctx.Expect( observed == expected, "unexpected transfer sequence: " + JoinVector( observed ) );
} ) );
m_pendingAsyncCases = 1;
StartAsyncWaitLoopCase();
}
private:
std::vector<CaseResult> m_results;
wxTimer m_asyncCaseTimer;
std::unique_ptr<AsyncWaitLoopCaseState> m_asyncState;
std::unique_ptr<AsyncNestedResumeCaseState> m_nestedAsyncState;
std::string m_asyncCaseName;
int m_pendingAsyncCases = 0;
wxStaticText* m_summary = nullptr;
wxTextCtrl* m_log = nullptr;
};
class CoroutineTestApp : public wxApp
{
public:
bool OnInit() override
{
CoroutineTestFrame* frame = new CoroutineTestFrame();
frame->Show();
return true;
}
};
wxIMPLEMENT_APP( CoroutineTestApp );

View file

@ -0,0 +1,252 @@
#pragma once
#include <libcontext.h>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
#include <utility>
namespace coroutine_test
{
class TestCoroutine
{
public:
enum class InvocationType
{
FromRoot,
FromRoutine,
ContinueAfterRoot
};
struct Invocation;
private:
struct Context
{
libcontext::fcontext_t ctx = nullptr;
};
class CallContext
{
public:
void SetMainStack( Context* aStack )
{
m_mainStackContext = aStack;
}
Invocation* RunMainStack( TestCoroutine* aCoroutine, std::function<void()> aFunc,
intptr_t aValue )
{
m_mainStackFunction = std::move( aFunc );
Invocation args{ InvocationType::ContinueAfterRoot, aCoroutine, this, aValue };
return reinterpret_cast<Invocation*>(
libcontext::jump_fcontext( &( aCoroutine->m_callee.ctx ), m_mainStackContext->ctx,
reinterpret_cast<intptr_t>( &args ) ) );
}
Invocation* Continue( Invocation* aArgs )
{
while( aArgs && aArgs->type == InvocationType::ContinueAfterRoot )
{
m_mainStackFunction();
aArgs->type = InvocationType::FromRoot;
aArgs = aArgs->destination->doResume( aArgs );
}
return aArgs;
}
private:
Context* m_mainStackContext = nullptr;
std::function<void()> m_mainStackFunction;
};
public:
struct Invocation
{
InvocationType type;
TestCoroutine* destination;
CallContext* context;
intptr_t value;
};
using EntryFn = std::function<void( TestCoroutine& )>;
explicit TestCoroutine( EntryFn aEntry, std::size_t aStackSize = 256 * 1024 ) :
m_stackSize( aStackSize ),
m_entry( std::move( aEntry ) )
{
}
~TestCoroutine()
{
if( m_caller.ctx )
libcontext::release_fcontext( m_caller.ctx );
if( m_callee.ctx )
libcontext::release_fcontext( m_callee.ctx );
}
bool Call( intptr_t aValue = 0 )
{
if( m_callee.ctx || !m_entry )
return false;
CallContext ctx;
Invocation args{ InvocationType::FromRoot, this, &ctx, aValue };
Invocation* ret = ctx.Continue( doCall( &args ) );
m_lastReturnValue = ret ? ret->value : 0;
return Running();
}
bool Call( const TestCoroutine& aCoroutine, intptr_t aValue )
{
if( m_callee.ctx || !m_entry )
return false;
Invocation args{ InvocationType::FromRoutine, this, aCoroutine.m_callContext, aValue };
Invocation* ret = doCall( &args );
m_lastReturnValue = ret ? ret->value : 0;
return Running();
}
bool Resume( intptr_t aValue = 0 )
{
if( !m_running )
return false;
CallContext ctx;
Invocation args{ InvocationType::FromRoot, this, &ctx, aValue };
Invocation* ret = ctx.Continue( doResume( &args ) );
m_lastReturnValue = ret ? ret->value : 0;
return Running();
}
bool Resume( const TestCoroutine& aCoroutine, intptr_t aValue )
{
if( !m_running )
return false;
Invocation args{ InvocationType::FromRoutine, this, aCoroutine.m_callContext, aValue };
Invocation* ret = doResume( &args );
m_lastReturnValue = ret ? ret->value : 0;
return Running();
}
void Yield( intptr_t aValue = 0 )
{
jumpOut( InvocationType::FromRoutine, aValue );
}
void RunMainStack( std::function<void()> aFunc, intptr_t aValue = 0 )
{
if( !m_callContext )
return;
Invocation* ret = m_callContext->RunMainStack( this, std::move( aFunc ), aValue );
updateIncomingInvocation( ret );
}
bool Running() const
{
return m_running;
}
intptr_t CurrentValue() const
{
return m_currentInvocation ? m_currentInvocation->value : 0;
}
intptr_t LastReturnValue() const
{
return m_lastReturnValue;
}
std::size_t EntryCount() const
{
return m_entryCount;
}
private:
static void callerStub( intptr_t aData )
{
Invocation& args = *reinterpret_cast<Invocation*>( aData );
TestCoroutine* coroutine = args.destination;
coroutine->m_callContext = args.context;
coroutine->m_currentInvocation = &args;
coroutine->m_entryCount += 1;
if( args.type == InvocationType::FromRoot )
coroutine->m_callContext->SetMainStack( &coroutine->m_caller );
coroutine->m_entry( *coroutine );
coroutine->m_running = false;
coroutine->jumpOut( InvocationType::FromRoutine, 0 );
}
Invocation* doCall( Invocation* aInvocation )
{
m_stack = std::make_unique<char[]>( m_stackSize );
void* stackTop = m_stack.get() + m_stackSize;
m_callee.ctx = libcontext::make_fcontext( stackTop, m_stackSize, callerStub );
m_running = true;
return jumpIn( aInvocation );
}
Invocation* doResume( Invocation* aInvocation )
{
return jumpIn( aInvocation );
}
Invocation* jumpIn( Invocation* aInvocation )
{
m_currentInvocation = aInvocation;
return reinterpret_cast<Invocation*>(
libcontext::jump_fcontext( &( m_caller.ctx ), m_callee.ctx,
reinterpret_cast<intptr_t>( aInvocation ) ) );
}
void jumpOut( InvocationType aType, intptr_t aValue )
{
Invocation args{ aType, nullptr, nullptr, aValue };
Invocation* ret = reinterpret_cast<Invocation*>(
libcontext::jump_fcontext( &( m_callee.ctx ), m_caller.ctx,
reinterpret_cast<intptr_t>( &args ) ) );
updateIncomingInvocation( ret );
}
void updateIncomingInvocation( Invocation* aInvocation )
{
m_currentInvocation = aInvocation;
if( !aInvocation )
return;
m_callContext = aInvocation->context;
if( aInvocation->type == InvocationType::FromRoot && m_callContext )
m_callContext->SetMainStack( &m_caller );
}
private:
std::size_t m_stackSize;
EntryFn m_entry;
bool m_running = false;
std::unique_ptr<char[]> m_stack;
Context m_caller;
Context m_callee;
CallContext* m_callContext = nullptr;
Invocation* m_currentInvocation = nullptr;
intptr_t m_lastReturnValue = 0;
std::size_t m_entryCount = 0;
};
} // namespace coroutine_test