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

@ -7,6 +7,7 @@
WXCONFIG = ../../build-wasm/wxwidgets-universal/wx-config
TOOLS_ROOT = ../../wxwidgets/build/wasm
KICAD_ROOT = ../../kicad
CXX = em++
WX_CXXFLAGS := $(shell $(WXCONFIG) --cxxflags)
@ -93,6 +94,14 @@ LDFLAGS_PTHREAD = $(DEBUG_LDFLAGS) $(BASE_LDFLAGS) -pthread \
-sPTHREAD_POOL_SIZE_STRICT=0 \
$(WX_LDFLAGS_NOGL)
# Coroutine harness flags - mirror KiCad's fiber-related runtime needs
COROUTINE_BASE_LDFLAGS = -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
-s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
-sASYNCIFY=1 \
-sASYNCIFY_STACK_SIZE=65536 \
-sASYNCIFY_IMPORTS=['startModal','js_writeTextToClipboard','js_readTextFromClipboard','js_clipboardHasText','js_clearClipboard','js_enumerateFonts','emscripten_fiber_swap']
LDFLAGS_COROUTINE = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) $(WX_LDFLAGS_NOGL)
JS = $(TOOLS_ROOT)/wx.js
HTML = $(TOOLS_ROOT)/template.html
@ -147,7 +156,9 @@ all: minimal_test.html \
$(S)/earlysize/earlysize_test.html \
$(S)/threadpool/threadpool_test.html \
$(S)/logerror/logerror_test.html \
$(S)/retinascale/retinascale_test.html
$(S)/retinascale/retinascale_test.html \
$(S)/coroutine/coroutine_test.html \
$(S)/coroutine-nested/nested_test.html
# Main test app (uses GL)
minimal_test.o: minimal_test.cpp
@ -450,6 +461,26 @@ $(S)/logerror/logerror_test.o: $(S)/logerror/logerror_test.cpp
$(S)/logerror/logerror_test.html: $(S)/logerror/logerror_test.o $(WX_CORE_LIB)
$(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@
# Coroutine stress harness - mirrors KiCad coroutine semantics against real libcontext
$(S)/coroutine/coroutine_test.o: $(S)/coroutine/coroutine_test.cpp $(S)/coroutine/kicad_coroutine_harness.h
$(CXX) -c $(CXXFLAGS) -I$(KICAD_ROOT)/thirdparty/libcontext $< -o $@
$(S)/coroutine/libcontext.o: $(KICAD_ROOT)/thirdparty/libcontext/libcontext.cpp $(KICAD_ROOT)/thirdparty/libcontext/libcontext.h
$(CXX) -c $(CXXFLAGS) -I$(KICAD_ROOT)/thirdparty/libcontext $< -o $@
$(S)/coroutine/coroutine_test.html: $(S)/coroutine/coroutine_test.o $(S)/coroutine/libcontext.o $(WX_CORE_LIB)
$(CXX) $^ $(LDFLAGS_COROUTINE) --pre-js $(JS) --shell-file $(HTML) -o $@
../../scripts/common/inject-dyncall-shims.sh $(basename $@).js
# Nested coroutine+modal interaction harness - reproduces Asyncify rewind corruption
# when fiber swaps happen inside a wxDialog::ShowModal event loop (Issue #9153).
$(S)/coroutine-nested/nested_test.o: $(S)/coroutine-nested/nested_test.cpp $(S)/coroutine/kicad_coroutine_harness.h
$(CXX) -c $(CXXFLAGS) -I$(KICAD_ROOT)/thirdparty/libcontext -I$(S)/coroutine $< -o $@
$(S)/coroutine-nested/nested_test.html: $(S)/coroutine-nested/nested_test.o $(S)/coroutine/libcontext.o $(WX_CORE_LIB)
$(CXX) $^ $(LDFLAGS_COROUTINE) --pre-js $(JS) --shell-file $(HTML) -o $@
../../scripts/common/inject-dyncall-shims.sh $(basename $@).js
# Convenience targets
menu: $(S)/menu/menu_test.html
clipboard: $(S)/clipboard/clipboard_test.html
@ -502,9 +533,11 @@ $(S)/retinascale/retinascale_test.html: $(S)/retinascale/retinascale_test.o $(WX
$(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@
retinascale: $(S)/retinascale/retinascale_test.html
coroutine: $(S)/coroutine/coroutine_test.html
coroutine-nested: $(S)/coroutine-nested/nested_test.html
clean:
rm -f minimal_test.o minimal_test.html minimal_test.js minimal_test.wasm
rm -f $(S)/*/*.o $(S)/*/*.html $(S)/*/*.js $(S)/*/*.wasm
.PHONY: all clean menu clipboard filedialog layout aui toolbar grid dialog timer tree dataview htmlwin stc print dnd propgrid pickers collapsible listctrl infobar dataviewvirtual auinotebook wizard gridedit calendar gridrenderers printpreview bitmapbuttons specialized validators ownerdrawn popup xml wasmedge fontenum textdecor bitmask regions maximize earlysize threadpool logerror retinascale
.PHONY: all clean menu clipboard filedialog layout aui toolbar grid dialog timer tree dataview htmlwin stc print dnd propgrid pickers collapsible listctrl infobar dataviewvirtual auinotebook wizard gridedit calendar gridrenderers printpreview bitmapbuttons specialized validators ownerdrawn popup xml wasmedge fontenum textdecor bitmask regions maximize earlysize threadpool logerror retinascale coroutine coroutine-nested

View file

@ -0,0 +1,753 @@
#include "wx/wx.h"
#include "wx/textctrl.h"
#include "wx/timer.h"
#include "wx/dialog.h"
#include "kicad_coroutine_harness.h"
#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h>
#endif
#include <array>
#include <functional>
#include <memory>
#include <numeric>
#include <sstream>
#include <string>
#include <vector>
using coroutine_test::TestCoroutine;
namespace
{
constexpr int ID_SCENARIO_TIMER = wxID_HIGHEST + 550;
constexpr int ID_MODAL_CLOSE_TIMER = wxID_HIGHEST + 551;
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();
}
void LogLine( const std::string& aLine )
{
#ifdef __EMSCRIPTEN__
EM_ASM( { console.log( UTF8ToString( $0 ) ); }, aLine.c_str() );
#else
std::printf( "%s\n", aLine.c_str() );
#endif
}
void LogAsyncifyState( const char* aTag )
{
#ifdef __EMSCRIPTEN__
EM_ASM( {
try {
var tag = UTF8ToString( $0 );
var state = ( typeof Asyncify !== 'undefined' ) ? Asyncify.state : 'N/A';
var stackLen = ( typeof Asyncify !== 'undefined' && Asyncify.exportCallStack )
? Asyncify.exportCallStack.length : 'N/A';
var currData = ( typeof Asyncify !== 'undefined' && Asyncify.currData )
? Asyncify.currData : 'null';
var tableLen = ( typeof wasmTable !== 'undefined' && wasmTable )
? wasmTable.length : 'N/A';
console.log( '[COROUTINE_TEST] ASYNCIFY ' + tag +
' state=' + state +
' stackLen=' + stackLen +
' currData=' + currData +
' tableLen=' + tableLen );
} catch (e) {
console.log( '[COROUTINE_TEST] ASYNCIFY ' + UTF8ToString( $0 ) + ' error=' + e );
}
}, aTag );
#else
(void) aTag;
#endif
}
} // namespace
/**
* AutoClosingDialog - a wxDialog that closes itself after a delay.
* Used to simulate user interaction in automated tests.
*/
class AutoClosingDialog : public wxDialog
{
public:
AutoClosingDialog( wxWindow* aParent, const wxString& aTag, int aDelayMs ) :
wxDialog( aParent, wxID_ANY, aTag, wxDefaultPosition, wxSize( 300, 150 ) ),
m_tag( aTag.ToStdString() ),
m_delayMs( aDelayMs ),
m_timer( this, ID_MODAL_CLOSE_TIMER ),
m_externalClose( false )
{
Bind( wxEVT_SHOW, &AutoClosingDialog::OnShow, this );
Bind( wxEVT_TIMER, &AutoClosingDialog::OnTimer, this, ID_MODAL_CLOSE_TIMER );
}
// If set, the dialog will not self-close; an external caller must call EndModalExternal.
void UseExternalClose() { m_externalClose = true; }
void EndModalExternal( int aCode )
{
LogLine( "[COROUTINE_TEST] MODAL-END-EXT " + m_tag );
EndModal( aCode );
}
private:
void OnShow( wxShowEvent& aEvent )
{
if( aEvent.IsShown() )
{
LogLine( "[COROUTINE_TEST] MODAL-SHOW " + m_tag );
LogAsyncifyState( ( "modal-shown-" + m_tag ).c_str() );
if( !m_externalClose )
m_timer.StartOnce( m_delayMs );
}
aEvent.Skip();
}
void OnTimer( wxTimerEvent& aEvent )
{
(void) aEvent;
LogLine( "[COROUTINE_TEST] MODAL-END-AUTO " + m_tag );
EndModal( wxID_OK );
}
std::string m_tag;
int m_delayMs;
wxTimer m_timer;
bool m_externalClose;
};
class NestedTestFrame : public wxFrame
{
public:
NestedTestFrame() :
wxFrame( nullptr, wxID_ANY, "Nested Coroutine+Modal Test",
wxDefaultPosition, wxSize( 1000, 760 ) ),
m_scenarioTimer( this, ID_SCENARIO_TIMER )
{
wxPanel* panel = new wxPanel( this );
wxBoxSizer* sizer = new wxBoxSizer( wxVERTICAL );
wxStaticText* description = new wxStaticText(
panel,
wxID_ANY,
"Tests the interaction between wxDialog::ShowModal (EM_ASYNC_JS / startModal) and\n"
"libcontext fibers (emscripten_fiber_swap). Reproduces nested Asyncify crashes.\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 nested coroutine+modal 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( "Nested coroutine+modal test harness starting" );
Bind( wxEVT_TIMER, &NestedTestFrame::OnScenarioTimer, this, ID_SCENARIO_TIMER );
CallAfter( [this]() { RunSuite(); } );
}
private:
void Log( const wxString& aMessage )
{
if( m_log )
{
m_log->AppendText( aMessage );
m_log->AppendText( "\n" );
}
LogLine( aMessage.ToStdString() );
}
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 ) );
m_results.push_back( 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( "Nested 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 ) );
}
// --- Case 1: baseline_modal_alone ---
// Proves that the modal mechanism works in isolation (EM_ASYNC_JS/startModal).
// If this fails, the test infrastructure is broken.
void RunCase_BaselineModalAlone()
{
const std::string caseName = "baseline_modal_alone";
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
CaseContext ctx;
LogAsyncifyState( "A-pre-modal" );
{
AutoClosingDialog dlg( this, "baselineA", 50 );
int result = dlg.ShowModal();
ctx.Expect( result == wxID_OK, "modal should return wxID_OK" );
}
LogAsyncifyState( "A-post-modal" );
FinalizeCase( caseName, std::move( ctx ) );
}
// --- Case 2: baseline_fiber_alone ---
// Proves that TestCoroutine works without any modal involvement.
void RunCase_BaselineFiberAlone()
{
const std::string caseName = "baseline_fiber_alone";
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
CaseContext ctx;
LogAsyncifyState( "B-pre-fiber" );
TestCoroutine coroutine( []( TestCoroutine& self ) {
self.Yield( 42 );
} );
bool running = coroutine.Call( 1 );
ctx.Expect( running, "fiber should yield on first call" );
ctx.Expect( coroutine.LastReturnValue() == 42, "yield value should be 42" );
running = coroutine.Resume( 2 );
ctx.Expect( !running, "fiber should finish on resume" );
LogAsyncifyState( "B-post-fiber" );
FinalizeCase( caseName, std::move( ctx ) );
}
// --- Case 3: fiber_create_run_destroy_inside_modal (THE TARGET REPRODUCER) ---
// A fiber is created, run to completion, and destroyed during a modal's event loop.
// In the broken state, the modal's rewind after EndModal crashes with index out of bounds.
void StartCase_FiberInsideModal()
{
const std::string caseName = "fiber_create_run_destroy_inside_modal";
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
m_currentCaseName = caseName;
m_currentCtx = std::make_unique<CaseContext>();
LogAsyncifyState( "S3-pre-modal" );
auto dlg = std::make_unique<AutoClosingDialog>( this, "S3", 0 );
dlg->UseExternalClose();
// Arm a scenario timer that will fire INSIDE the modal's event loop
m_pendingScenario = [this]() { RunScenario3FiberWork(); };
m_scenarioTimer.StartOnce( 30 );
// Show the modal (this blocks under EM_ASYNC_JS)
m_activeDialog = dlg.get();
int result = dlg->ShowModal();
m_activeDialog = nullptr;
LogAsyncifyState( "S3-post-modal" );
m_currentCtx->Expect( result == wxID_OK,
"modal should return wxID_OK (actual: " + std::to_string( result ) + ")" );
FinalizeCase( caseName, std::move( *m_currentCtx ) );
m_currentCtx.reset();
// Chain to next scenario
CallAfter( [this]() { StartCase_FiberMultiSwapInsideModal(); } );
}
void RunScenario3FiberWork()
{
LogAsyncifyState( "S3-timer-enter" );
{
TestCoroutine co( []( TestCoroutine& self ) {
self.Yield( 100 );
} );
bool running = co.Call( 1 );
m_currentCtx->Expect( running, "S3: fiber should yield on first call" );
m_currentCtx->Expect( co.LastReturnValue() == 100, "S3: yield value should be 100" );
LogAsyncifyState( "S3-after-call" );
running = co.Resume( 2 );
m_currentCtx->Expect( !running, "S3: fiber should finish on resume" );
LogAsyncifyState( "S3-after-resume" );
}
// Fiber destroyed here
LogAsyncifyState( "S3-after-destroy" );
if( m_activeDialog )
m_activeDialog->EndModalExternal( wxID_OK );
}
// --- Case 4: fiber_multi_swap_inside_modal ---
// Multiple fiber yield/resume cycles inside a modal. Tests if the bug needs >=2 swaps.
void StartCase_FiberMultiSwapInsideModal()
{
const std::string caseName = "fiber_multi_swap_inside_modal";
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
m_currentCaseName = caseName;
m_currentCtx = std::make_unique<CaseContext>();
LogAsyncifyState( "S4-pre-modal" );
auto dlg = std::make_unique<AutoClosingDialog>( this, "S4", 0 );
dlg->UseExternalClose();
m_pendingScenario = [this]() { RunScenario4MultiSwap(); };
m_scenarioTimer.StartOnce( 30 );
m_activeDialog = dlg.get();
int result = dlg->ShowModal();
m_activeDialog = nullptr;
LogAsyncifyState( "S4-post-modal" );
m_currentCtx->Expect( result == wxID_OK, "S4: modal should return wxID_OK" );
FinalizeCase( caseName, std::move( *m_currentCtx ) );
m_currentCtx.reset();
CallAfter( [this]() { StartCase_FiberYieldAcrossModalClose(); } );
}
void RunScenario4MultiSwap()
{
LogAsyncifyState( "S4-timer-enter" );
{
TestCoroutine co( []( TestCoroutine& self ) {
self.Yield( 1 );
self.Yield( 2 );
self.Yield( 3 );
} );
bool running = co.Call( 10 );
m_currentCtx->Expect( running, "S4: first yield" );
m_currentCtx->Expect( co.LastReturnValue() == 1, "S4: yield value 1" );
running = co.Resume( 20 );
m_currentCtx->Expect( running, "S4: second yield" );
m_currentCtx->Expect( co.LastReturnValue() == 2, "S4: yield value 2" );
running = co.Resume( 30 );
m_currentCtx->Expect( running, "S4: third yield" );
m_currentCtx->Expect( co.LastReturnValue() == 3, "S4: yield value 3" );
running = co.Resume( 40 );
m_currentCtx->Expect( !running, "S4: fiber should finish" );
}
LogAsyncifyState( "S4-after-fiber" );
if( m_activeDialog )
m_activeDialog->EndModalExternal( wxID_OK );
}
// --- Case 5: fiber_yield_across_modal_close ---
// Fiber is Call()'d and yields, then modal closes WITHOUT resuming the fiber.
// After modal, we resume the still-suspended fiber. Tests dormant fiber buffer impact.
void StartCase_FiberYieldAcrossModalClose()
{
const std::string caseName = "fiber_yield_across_modal_close";
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
m_currentCaseName = caseName;
m_currentCtx = std::make_unique<CaseContext>();
LogAsyncifyState( "S5-pre-modal" );
m_s5Fiber = std::make_unique<TestCoroutine>( []( TestCoroutine& self ) {
self.Yield( 501 );
self.Yield( 502 );
} );
auto dlg = std::make_unique<AutoClosingDialog>( this, "S5", 0 );
dlg->UseExternalClose();
m_pendingScenario = [this]() { RunScenario5Yield(); };
m_scenarioTimer.StartOnce( 30 );
m_activeDialog = dlg.get();
int result = dlg->ShowModal();
m_activeDialog = nullptr;
LogAsyncifyState( "S5-post-modal" );
// After modal, resume the fiber
if( m_s5Fiber && m_s5Fiber->Running() )
{
bool running = m_s5Fiber->Resume( 99 );
m_currentCtx->Expect( running, "S5: fiber should yield again after modal close" );
running = m_s5Fiber->Resume( 100 );
m_currentCtx->Expect( !running, "S5: fiber should finish after second resume" );
}
m_s5Fiber.reset();
m_currentCtx->Expect( result == wxID_OK, "S5: modal should return wxID_OK" );
FinalizeCase( caseName, std::move( *m_currentCtx ) );
m_currentCtx.reset();
CallAfter( [this]() { StartCase_FiberDeepYieldLoop(); } );
}
void RunScenario5Yield()
{
LogAsyncifyState( "S5-timer-enter" );
bool running = m_s5Fiber->Call( 1 );
m_currentCtx->Expect( running, "S5: fiber should yield in modal" );
m_currentCtx->Expect( m_s5Fiber->LastReturnValue() == 501, "S5: yield 501" );
LogAsyncifyState( "S5-fiber-yielded" );
// Do NOT resume; leave the fiber suspended across the modal close.
if( m_activeDialog )
m_activeDialog->EndModalExternal( wxID_OK );
}
// --- Case 6: fiber_deep_yield_loop_inside_modal ---
// Deep recursive stack with many yields inside a modal. Stresses asyncify buffers.
void StartCase_FiberDeepYieldLoop()
{
const std::string caseName = "fiber_deep_yield_loop_inside_modal";
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
m_currentCaseName = caseName;
m_currentCtx = std::make_unique<CaseContext>();
LogAsyncifyState( "S6-pre-modal" );
auto dlg = std::make_unique<AutoClosingDialog>( this, "S6", 0 );
dlg->UseExternalClose();
m_pendingScenario = [this]() { RunScenario6DeepYield(); };
m_scenarioTimer.StartOnce( 30 );
m_activeDialog = dlg.get();
int result = dlg->ShowModal();
m_activeDialog = nullptr;
LogAsyncifyState( "S6-post-modal" );
m_currentCtx->Expect( result == wxID_OK, "S6: modal should return wxID_OK" );
FinalizeCase( caseName, std::move( *m_currentCtx ) );
m_currentCtx.reset();
CallAfter( [this]() { StartCase_ModalFiberModalSequence(); } );
}
void RunScenario6DeepYield()
{
LogAsyncifyState( "S6-timer-enter" );
{
TestCoroutine co( [ctx = m_currentCtx.get()]( TestCoroutine& self ) {
std::function<void( int )> dive = [&]( int depth ) {
std::array<int, 8> locals {};
for( std::size_t i = 0; i < locals.size(); ++i )
locals[i] = depth * 10 + static_cast<int>( i );
int expected = std::accumulate( locals.begin(), locals.end(), 0 );
if( depth == 0 )
{
self.Yield( 600 );
ctx->Expect( std::accumulate( locals.begin(), locals.end(), 0 ) == expected,
"S6: deepest frame locals survive resume" );
return;
}
dive( depth - 1 );
ctx->Expect( std::accumulate( locals.begin(), locals.end(), 0 ) == expected,
"S6: frame locals survive at depth " + std::to_string( depth ) );
};
dive( 4 );
} );
bool running = co.Call( 1 );
m_currentCtx->Expect( running, "S6: deep fiber should yield" );
m_currentCtx->Expect( co.LastReturnValue() == 600, "S6: deep yield value" );
running = co.Resume( 2 );
m_currentCtx->Expect( !running, "S6: deep fiber should finish" );
}
LogAsyncifyState( "S6-after-fiber" );
if( m_activeDialog )
m_activeDialog->EndModalExternal( wxID_OK );
}
// --- Case 7: modal_fiber_modal_sequence ---
// Modal A -> fiber work between -> Modal B. Tests state leak across modal boundaries.
void StartCase_ModalFiberModalSequence()
{
const std::string caseName = "modal_fiber_modal_sequence";
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
CaseContext ctx;
LogAsyncifyState( "S7-pre-modal-A" );
// Modal A (auto-close)
{
AutoClosingDialog dlgA( this, "S7A", 50 );
int resultA = dlgA.ShowModal();
ctx.Expect( resultA == wxID_OK, "S7: modal A should return wxID_OK" );
}
LogAsyncifyState( "S7-post-modal-A" );
// Fiber work between modals
{
TestCoroutine co( []( TestCoroutine& self ) {
self.Yield( 700 );
} );
bool running = co.Call( 1 );
ctx.Expect( running, "S7: inter-modal fiber should yield" );
running = co.Resume( 2 );
ctx.Expect( !running, "S7: inter-modal fiber should finish" );
}
LogAsyncifyState( "S7-mid" );
// Modal B (auto-close)
{
AutoClosingDialog dlgB( this, "S7B", 50 );
int resultB = dlgB.ShowModal();
ctx.Expect( resultB == wxID_OK, "S7: modal B should return wxID_OK" );
}
LogAsyncifyState( "S7-post-modal-B" );
FinalizeCase( "modal_fiber_modal_sequence", std::move( ctx ) );
CallAfter( [this]() { StartCase_NestedFibersInsideModal(); } );
}
// --- Case 8: nested_fibers_inside_modal ---
// Parent fiber calls child fiber (FROM_ROUTINE) inside modal.
void StartCase_NestedFibersInsideModal()
{
const std::string caseName = "nested_fibers_inside_modal";
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
m_currentCaseName = caseName;
m_currentCtx = std::make_unique<CaseContext>();
LogAsyncifyState( "S8-pre-modal" );
auto dlg = std::make_unique<AutoClosingDialog>( this, "S8", 0 );
dlg->UseExternalClose();
m_pendingScenario = [this]() { RunScenario8NestedFibers(); };
m_scenarioTimer.StartOnce( 30 );
m_activeDialog = dlg.get();
int result = dlg->ShowModal();
m_activeDialog = nullptr;
LogAsyncifyState( "S8-post-modal" );
m_currentCtx->Expect( result == wxID_OK, "S8: modal should return wxID_OK" );
FinalizeCase( caseName, std::move( *m_currentCtx ) );
m_currentCtx.reset();
// Done with all cases
CallAfter( [this]() { FinalizeSuite(); } );
}
void RunScenario8NestedFibers()
{
LogAsyncifyState( "S8-timer-enter" );
{
auto ctx = m_currentCtx.get();
std::vector<std::string> sequence;
TestCoroutine child( [&sequence]( TestCoroutine& self ) {
sequence.push_back( "child-start" );
self.Yield( 801 );
sequence.push_back( "child-end" );
} );
TestCoroutine parent( [&]( TestCoroutine& self ) {
sequence.push_back( "parent-start" );
bool childRunning = child.Call( self, 100 );
ctx->Expect( childRunning, "S8: child should yield to parent" );
ctx->Expect( child.LastReturnValue() == 801, "S8: child yield value" );
sequence.push_back( "parent-after-child-yield" );
childRunning = child.Resume( self, 200 );
ctx->Expect( !childRunning, "S8: child should finish on resume" );
sequence.push_back( "parent-end" );
} );
bool running = parent.Call( 1 );
ctx->Expect( !running, "S8: parent should complete" );
const std::vector<std::string> expected = {
"parent-start", "child-start", "parent-after-child-yield", "child-end", "parent-end"
};
ctx->Expect( sequence == expected,
"S8: unexpected sequence: " + JoinVector( sequence ) );
}
LogAsyncifyState( "S8-after-fiber" );
if( m_activeDialog )
m_activeDialog->EndModalExternal( wxID_OK );
}
// --- Scenario timer handler (runs inside modal event loops) ---
void OnScenarioTimer( wxTimerEvent& aEvent )
{
if( aEvent.GetId() != ID_SCENARIO_TIMER )
return;
if( m_pendingScenario )
{
auto scenario = std::move( m_pendingScenario );
m_pendingScenario = nullptr;
scenario();
}
}
// --- RunSuite: kicks off the synchronous cases, then chains async ones ---
void RunSuite()
{
m_results.clear();
// Synchronous baselines first
RunCase_BaselineModalAlone();
RunCase_BaselineFiberAlone();
// Chain async modal+fiber scenarios 3..8
CallAfter( [this]() { StartCase_FiberInsideModal(); } );
}
private:
std::vector<CaseResult> m_results;
wxTimer m_scenarioTimer;
std::function<void()> m_pendingScenario;
std::string m_currentCaseName;
std::unique_ptr<CaseContext> m_currentCtx;
AutoClosingDialog* m_activeDialog = nullptr;
std::unique_ptr<TestCoroutine> m_s5Fiber;
wxStaticText* m_summary = nullptr;
wxTextCtrl* m_log = nullptr;
};
class NestedTestApp : public wxApp
{
public:
bool OnInit() override
{
NestedTestFrame* frame = new NestedTestFrame();
frame->Show();
return true;
}
};
wxIMPLEMENT_APP( NestedTestApp );

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