design-b D1b/c: context harness + the bounded-and-measured memory gate
Doc 20 §6 D1, second slice: the dedicated test app D1 calls for, plus the doc 20 §7 risk-1 gate. tests/apps/standalone/sched-context/ drives the primitives with no wx linked (a failure can only be the contexts layer) and assertions ON — unlike the races harness, an emscripten "cannot start an async operation" state here IS the bug, not a tolerated state. Nine scenarios, each pinning one invariant; the load-bearing ones: - parked_does_not_block: a parked context does not stop others running. This is doc 19's freeze made unrepresentable — there a parked activity held a global interlock and the UI died; here "parked" simply means "not runnable", and three workers run past it before it resumes. - one_transition_in_flight: a context calling drain() gets a no-op, with a second ready context queued so a buggy nested drain would actually run something and be caught. - async_wake: a real macrotask hop (setTimeout → mark_ready → drain), the shape every production bridge has. - deep_park_sizing: parks 64 live frames deep to measure what a park actually costs. The memory gate asserts the ceiling (peak live contexts, peak bytes), that nothing leaked (live=0, bytes=0, created==finished), that nothing was left mid-transition, and that refusals occurred (zero would mean the illegal-operation scenarios stopped provoking). Measured here: ~34 B/frame, 2200 B for a 64-frame park, 1 MB peak for 4 concurrent contexts. Recorded with the caveat that the harness's frames carry three locals each, so this is a FLOOR — real bridges save far more per frame (libcontext runs 512 K after a 64 K buffer silently overflowed). The apparatus and its units are validated; the sizing DECISION needs deep-park numbers from real bridges at D3/D4. playwright.config: asyncify-firefox now matches every spec in ./asyncify instead of only asyncify-races* — a new harness there is covered by construction rather than by remembering to widen the pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEHGiiXMShNXbBr7gSJ7iz
This commit is contained in:
parent
98731cd30e
commit
7dbc9fb36e
4 changed files with 716 additions and 3 deletions
|
|
@ -111,6 +111,18 @@ LDFLAGS_COROUTINE = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) $(WX_LDFLAGS_NOGL
|
|||
# forbids the very multi-parked-sleep states the harness exists to exercise.
|
||||
LDFLAGS_RACES = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) -sASSERTIONS=0 $(WX_LDFLAGS_NOGL)
|
||||
|
||||
# Scheduler-context harness (docs/features/async/20 D1). Deliberately does NOT
|
||||
# link wx: the contexts layer is pure emscripten fibers + Asyncify, and keeping
|
||||
# wx out means a failure here can only be the primitives. Assertions stay ON —
|
||||
# unlike the races harness this must NOT tolerate a "cannot start an async
|
||||
# operation" state; hitting one is the bug.
|
||||
LDFLAGS_SCHED_CTX = $(DEBUG_LDFLAGS) $(EH_FLAGS) -sALLOW_MEMORY_GROWTH \
|
||||
-sERROR_ON_UNDEFINED_SYMBOLS=0 \
|
||||
-s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
|
||||
-sASYNCIFY=1 \
|
||||
-sASYNCIFY_STACK_SIZE=65536 \
|
||||
-sASYNCIFY_IMPORTS=['emscripten_fiber_swap']
|
||||
|
||||
# LDFLAGS for the raytracer thread-deadlock repro. Same pthread + pool config as
|
||||
# LDFLAGS_PTHREAD, but built on COROUTINE_BASE_LDFLAGS for the 65536 ASYNCIFY
|
||||
# stack KiCad actually ships (emscripten_sleep needs Asyncify to yield).
|
||||
|
|
@ -199,6 +211,7 @@ all: minimal_test.html \
|
|||
$(S)/coroutine/coroutine_test.html \
|
||||
$(S)/coroutine-nested/nested_test.html \
|
||||
$(S)/asyncify-races/races_test.html \
|
||||
$(S)/sched-context/sched_context_test.html \
|
||||
$(S)/coroutine-pthread/coroutine_test_wxpt.html \
|
||||
$(S)/coroutine-pthread/embind_repro.html \
|
||||
$(S)/coroutine-pthread/gl_repro.html \
|
||||
|
|
@ -744,6 +757,20 @@ $(S)/coroutine-nested/nested_test.html: $(S)/coroutine-nested/nested_test.o $(S)
|
|||
../../scripts/common/inject-dyncall-shims.sh $(basename $@).js
|
||||
|
||||
# Asyncify race-condition red-green harness (tests/asyncify/). Three variants of
|
||||
# Scheduler-context harness (docs/features/async/20 D1, 21). Pure fibers +
|
||||
# Asyncify, no wx: the contexts layer under test is wx-independent.
|
||||
SCHED_CTX_DIR = ../../wasm/sched
|
||||
|
||||
$(S)/sched-context/sched_context_test.o: $(S)/sched-context/sched_context_test.cpp $(SCHED_CTX_DIR)/context.h
|
||||
$(CXX) -c $(CXXFLAGS) -I$(SCHED_CTX_DIR) $< -o $@
|
||||
|
||||
$(S)/sched-context/context.o: $(SCHED_CTX_DIR)/context.cpp $(SCHED_CTX_DIR)/context.h
|
||||
$(CXX) -c $(CXXFLAGS) -I$(SCHED_CTX_DIR) $< -o $@
|
||||
|
||||
$(S)/sched-context/sched_context_test.html: $(S)/sched-context/sched_context_test.o $(S)/sched-context/context.o
|
||||
$(CXX) $^ $(LDFLAGS_SCHED_CTX) --shell-file $(HTML) -o $@
|
||||
../../scripts/common/inject-dyncall-shims.sh $(basename $@).js
|
||||
|
||||
# (The trampoline-heal / handleSleep ablation variants — races_test_noheal /
|
||||
# races_test_nosleepfix — were retired at doc 20 D-1 together with the legacy
|
||||
# runtime they pinned.)
|
||||
|
|
@ -826,6 +853,7 @@ retinascale: $(S)/retinascale/retinascale_test.html
|
|||
coroutine: $(S)/coroutine/coroutine_test.html
|
||||
coroutine-nested: $(S)/coroutine-nested/nested_test.html
|
||||
asyncify-races: $(S)/asyncify-races/races_test.html
|
||||
sched-context: $(S)/sched-context/sched_context_test.html
|
||||
|
||||
# Only delete generated app files (*_test*, *_repro*) — a bare $(S)/*/*.js would
|
||||
# also delete checked-in sources like coroutine-pthread/worker_dom_stub.js.
|
||||
|
|
|
|||
497
tests/apps/standalone/sched-context/sched_context_test.cpp
Normal file
497
tests/apps/standalone/sched-context/sched_context_test.cpp
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
/*
|
||||
* Scheduler-context harness — Design B D1 gate
|
||||
* (pcbjam docs/features/async/20-design-b-core-plan.md §6 D1).
|
||||
*
|
||||
* Exercises wasm/sched/context.{h,cpp} — create / yield_park / mark_ready /
|
||||
* drain — against the invariants that make a resume something the registry
|
||||
* KNOWS rather than something a guard guesses (doc 19's disease):
|
||||
*
|
||||
* 1. a context runs only when the scheduler drains it;
|
||||
* 2. a park yields the whole context; its result arrives on resume;
|
||||
* 3. the registry is truth (status transitions, illegal ops refused);
|
||||
* 4. nothing parks "in place" — yield_park off a context is refused;
|
||||
* 5. a PARKED context does not block others (doc 20 §5: "parked ones do
|
||||
* not count") — the property that makes doc 19's freeze unrepresentable;
|
||||
* 6. FIFO readiness, no starvation;
|
||||
* 7. at most one transition in flight (drain is not re-entrant);
|
||||
* 8. a real async wake (JS timeout → mark_ready → drain) resumes correctly;
|
||||
* 9. memory is bounded and MEASURED (peak bytes, asyncify high-water) —
|
||||
* the doc 20 risk-1 gate.
|
||||
*
|
||||
* Output contract (parsed by tests/asyncify/sched-context.spec.ts):
|
||||
* [SCHED_CTX] PASS <name> per scenario
|
||||
* [SCHED_CTX] FAIL <name>: <detail>
|
||||
* [SCHED_CTX] STATS <json> the memory/registry snapshot
|
||||
* [SCHED_CTX] SUMMARY total=N passed=N failed=N
|
||||
*/
|
||||
|
||||
#include "context.h"
|
||||
|
||||
#include <emscripten/emscripten.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace pcbjam_sched;
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
int g_total = 0;
|
||||
int g_passed = 0;
|
||||
|
||||
void log_line( const std::string& aLine )
|
||||
{
|
||||
std::printf( "%s\n", aLine.c_str() );
|
||||
std::fflush( stdout );
|
||||
}
|
||||
|
||||
void report( const char* aName, bool aOk, const std::string& aDetail = "" )
|
||||
{
|
||||
++g_total;
|
||||
|
||||
if( aOk )
|
||||
{
|
||||
++g_passed;
|
||||
log_line( std::string( "[SCHED_CTX] PASS " ) + aName );
|
||||
}
|
||||
else
|
||||
{
|
||||
log_line( std::string( "[SCHED_CTX] FAIL " ) + aName + ": " + aDetail );
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scenario 1: a created context runs its body — but only on drain()
|
||||
// ---------------------------------------------------------------------------
|
||||
int g_ran = 0;
|
||||
|
||||
void body_sets_flag( void* )
|
||||
{
|
||||
g_ran = 1;
|
||||
}
|
||||
|
||||
void Scenario_CreateRunsOnDrain()
|
||||
{
|
||||
g_ran = 0;
|
||||
const ContextId id = create( body_sets_flag, nullptr, "runs-on-drain" );
|
||||
|
||||
if( !id )
|
||||
return report( "create_runs_on_drain", false, "create returned 0" );
|
||||
|
||||
// Creation alone must NOT run it: the scheduler decides when.
|
||||
if( g_ran != 0 )
|
||||
return report( "create_runs_on_drain", false, "body ran before drain()" );
|
||||
|
||||
const ContextId ran = drain();
|
||||
|
||||
if( ran != id )
|
||||
return report( "create_runs_on_drain", false, "drain() returned a different id" );
|
||||
|
||||
if( g_ran != 1 )
|
||||
return report( "create_runs_on_drain", false, "body did not run" );
|
||||
|
||||
if( status_of( id ) != Status::Finished )
|
||||
return report( "create_runs_on_drain", false,
|
||||
std::string( "status after return: " ) + status_name( status_of( id ) ) );
|
||||
|
||||
destroy( id );
|
||||
report( "create_runs_on_drain", true );
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scenario 2: park yields the whole context; the result arrives on resume
|
||||
// ---------------------------------------------------------------------------
|
||||
int g_park_result = 0;
|
||||
int g_park_stage = 0;
|
||||
|
||||
void body_parks_once( void* )
|
||||
{
|
||||
g_park_stage = 1;
|
||||
g_park_result = yield_park( "test-park" );
|
||||
g_park_stage = 2;
|
||||
}
|
||||
|
||||
void Scenario_ParkAndResume()
|
||||
{
|
||||
g_park_stage = 0;
|
||||
g_park_result = 0;
|
||||
const ContextId id = create( body_parks_once, nullptr, "park-once" );
|
||||
|
||||
drain(); // runs until the park
|
||||
|
||||
if( g_park_stage != 1 )
|
||||
return report( "park_and_resume", false, "body did not reach the park" );
|
||||
|
||||
if( status_of( id ) != Status::Parked )
|
||||
return report( "park_and_resume", false,
|
||||
std::string( "status: " ) + status_name( status_of( id ) ) );
|
||||
|
||||
// Draining again must do NOTHING: a parked context is not runnable until
|
||||
// it is marked ready. (If this resumed it, "parked" would be a guess.)
|
||||
if( drain() != 0 )
|
||||
return report( "park_and_resume", false, "drain() resumed a parked context" );
|
||||
|
||||
if( !mark_ready( id, 4242 ) )
|
||||
return report( "park_and_resume", false, "mark_ready failed" );
|
||||
|
||||
if( status_of( id ) != Status::Ready )
|
||||
return report( "park_and_resume", false, "status is not ready after mark_ready" );
|
||||
|
||||
drain();
|
||||
|
||||
if( g_park_stage != 2 )
|
||||
return report( "park_and_resume", false, "body did not resume" );
|
||||
|
||||
if( g_park_result != 4242 )
|
||||
return report( "park_and_resume", false,
|
||||
"wrong result: " + std::to_string( g_park_result ) );
|
||||
|
||||
destroy( id );
|
||||
report( "park_and_resume", true );
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scenario 3: nothing parks in place — yield_park off a context is refused
|
||||
// ---------------------------------------------------------------------------
|
||||
void Scenario_NoParkInPlace()
|
||||
{
|
||||
// Called from the scheduler stack (this battery runs there).
|
||||
const int rc = yield_park( "illegal" );
|
||||
|
||||
if( rc != -1 )
|
||||
return report( "no_park_in_place", false, "yield_park did not refuse" );
|
||||
|
||||
if( current() != 0 )
|
||||
return report( "no_park_in_place", false, "current() is not the scheduler" );
|
||||
|
||||
report( "no_park_in_place", true );
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scenario 4: a parked context does NOT block other contexts
|
||||
// This is doc 19's freeze, made unrepresentable: there, a parked activity
|
||||
// held a global interlock and everything else stopped. Here, a parked
|
||||
// context is simply not runnable while every other context keeps running.
|
||||
// ---------------------------------------------------------------------------
|
||||
int g_blocker_stage = 0;
|
||||
int g_worker_runs = 0;
|
||||
|
||||
void body_blocker( void* )
|
||||
{
|
||||
g_blocker_stage = 1;
|
||||
yield_park( "long-park" );
|
||||
g_blocker_stage = 2;
|
||||
}
|
||||
|
||||
void body_worker( void* )
|
||||
{
|
||||
++g_worker_runs;
|
||||
}
|
||||
|
||||
void Scenario_ParkedDoesNotBlock()
|
||||
{
|
||||
g_blocker_stage = 0;
|
||||
g_worker_runs = 0;
|
||||
|
||||
const ContextId blocker = create( body_blocker, nullptr, "blocker" );
|
||||
drain(); // blocker parks, and stays parked for the rest of this scenario
|
||||
|
||||
if( status_of( blocker ) != Status::Parked )
|
||||
return report( "parked_does_not_block", false, "blocker is not parked" );
|
||||
|
||||
// Three unrelated contexts must run to completion while it is parked.
|
||||
std::vector<ContextId> workers;
|
||||
|
||||
for( int i = 0; i < 3; ++i )
|
||||
workers.push_back( create( body_worker, nullptr, "worker" ) );
|
||||
|
||||
for( int i = 0; i < 3; ++i )
|
||||
drain();
|
||||
|
||||
if( g_worker_runs != 3 )
|
||||
return report( "parked_does_not_block", false,
|
||||
"workers run: " + std::to_string( g_worker_runs ) + " (expected 3)" );
|
||||
|
||||
if( g_blocker_stage != 1 )
|
||||
return report( "parked_does_not_block", false, "blocker resumed unexpectedly" );
|
||||
|
||||
// And the blocker still resumes correctly afterwards.
|
||||
mark_ready( blocker, 0 );
|
||||
drain();
|
||||
|
||||
if( g_blocker_stage != 2 )
|
||||
return report( "parked_does_not_block", false, "blocker did not resume at the end" );
|
||||
|
||||
for( ContextId w : workers )
|
||||
destroy( w );
|
||||
|
||||
destroy( blocker );
|
||||
report( "parked_does_not_block", true );
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scenario 5: FIFO readiness — no starvation
|
||||
// ---------------------------------------------------------------------------
|
||||
std::vector<int> g_order;
|
||||
|
||||
void body_records( void* aArg )
|
||||
{
|
||||
g_order.push_back( static_cast<int>( reinterpret_cast<intptr_t>( aArg ) ) );
|
||||
}
|
||||
|
||||
void Scenario_FifoOrder()
|
||||
{
|
||||
g_order.clear();
|
||||
std::vector<ContextId> ids;
|
||||
|
||||
for( int i = 1; i <= 4; ++i )
|
||||
ids.push_back( create( body_records, reinterpret_cast<void*>( (intptr_t) i ), "fifo" ) );
|
||||
|
||||
for( size_t i = 0; i < ids.size(); ++i )
|
||||
drain();
|
||||
|
||||
if( g_order.size() != 4 )
|
||||
return report( "fifo_order", false, "ran " + std::to_string( g_order.size() ) );
|
||||
|
||||
for( int i = 0; i < 4; ++i )
|
||||
{
|
||||
if( g_order[i] != i + 1 )
|
||||
return report( "fifo_order", false, "out of order at " + std::to_string( i ) );
|
||||
}
|
||||
|
||||
for( ContextId id : ids )
|
||||
destroy( id );
|
||||
|
||||
report( "fifo_order", true );
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scenario 6: one transition in flight — drain() is not re-entrant
|
||||
// ---------------------------------------------------------------------------
|
||||
int g_reentrant_drain_rc = -99;
|
||||
bool g_saw_transition = false;
|
||||
|
||||
void body_calls_drain( void* )
|
||||
{
|
||||
g_saw_transition = transition_in_flight();
|
||||
// A context calling drain() would turn the star into a cycle; the guard
|
||||
// must make it a no-op rather than a nested swap.
|
||||
g_reentrant_drain_rc = static_cast<int>( drain() );
|
||||
}
|
||||
|
||||
void Scenario_OneTransitionInFlight()
|
||||
{
|
||||
g_reentrant_drain_rc = -99;
|
||||
g_saw_transition = false;
|
||||
|
||||
// A second, ready context exists — so a buggy re-entrant drain would have
|
||||
// something to run and the failure would be observable, not vacuous.
|
||||
const ContextId victim = create( body_worker, nullptr, "victim" );
|
||||
const ContextId id = create( body_calls_drain, nullptr, "reentrant" );
|
||||
|
||||
drain(); // runs victim (FIFO)
|
||||
drain(); // runs the re-entrant body
|
||||
|
||||
if( !g_saw_transition )
|
||||
return report( "one_transition_in_flight", false,
|
||||
"transition_in_flight() was false inside a running context" );
|
||||
|
||||
if( g_reentrant_drain_rc != 0 )
|
||||
return report( "one_transition_in_flight", false,
|
||||
"nested drain() ran a context: " + std::to_string( g_reentrant_drain_rc ) );
|
||||
|
||||
destroy( victim );
|
||||
destroy( id );
|
||||
report( "one_transition_in_flight", true );
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scenario 7: registry refusals — illegal operations are recorded, not obeyed
|
||||
// ---------------------------------------------------------------------------
|
||||
void Scenario_RegistryRefusals()
|
||||
{
|
||||
if( mark_ready( 999999, 0 ) )
|
||||
return report( "registry_refusals", false, "mark_ready on an unknown id succeeded" );
|
||||
|
||||
const ContextId id = create( body_parks_once, nullptr, "refusals" );
|
||||
drain(); // parks
|
||||
|
||||
// Destroying a parked context would strand its stack.
|
||||
if( destroy( id ) )
|
||||
return report( "registry_refusals", false, "destroy() freed a parked context" );
|
||||
|
||||
// Marking a ready context ready again must not double-queue it.
|
||||
mark_ready( id, 1 );
|
||||
|
||||
if( mark_ready( id, 1 ) )
|
||||
return report( "registry_refusals", false, "mark_ready twice succeeded" );
|
||||
|
||||
drain();
|
||||
destroy( id );
|
||||
report( "registry_refusals", true );
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scenario 8: DEEP park — how does asyncify buffer use scale with depth?
|
||||
//
|
||||
// This is the doc 20 risk-1 measurement that matters. A shallow park saves
|
||||
// almost nothing (~100 B), which would tempt a tiny buffer; libcontext's
|
||||
// 512 K exists precisely because a DEEP park (a collab apply suspending
|
||||
// inside commit.Push → connectivity → font work) overflowed 64 K and
|
||||
// silently corrupted the rewind state. So park at a known depth with live
|
||||
// locals and report the cost, giving a per-frame number a future buffer
|
||||
// size can be derived FROM rather than inherited.
|
||||
// ---------------------------------------------------------------------------
|
||||
constexpr int DEEP_PARK_FRAMES = 64;
|
||||
|
||||
int g_deep_result = 0;
|
||||
int g_deep_reached = 0;
|
||||
|
||||
// noinline + volatile locals: the point is to keep frames genuinely live
|
||||
// across the park so the unwind has something real to save.
|
||||
__attribute__( ( noinline ) ) int deep_recurse( int aDepth )
|
||||
{
|
||||
volatile int a = aDepth * 3;
|
||||
volatile int b = aDepth ^ 0x5a5a;
|
||||
volatile double c = aDepth * 1.5;
|
||||
|
||||
if( aDepth <= 0 )
|
||||
{
|
||||
g_deep_reached = 1;
|
||||
return yield_park( "deep-park" );
|
||||
}
|
||||
|
||||
const int inner = deep_recurse( aDepth - 1 );
|
||||
return inner + static_cast<int>( a ) - static_cast<int>( a ) + static_cast<int>( b )
|
||||
- static_cast<int>( b ) + static_cast<int>( c ) - static_cast<int>( c );
|
||||
}
|
||||
|
||||
void body_deep_park( void* )
|
||||
{
|
||||
g_deep_result = deep_recurse( DEEP_PARK_FRAMES );
|
||||
}
|
||||
|
||||
void Scenario_DeepPark()
|
||||
{
|
||||
g_deep_result = 0;
|
||||
g_deep_reached = 0;
|
||||
|
||||
const ContextId id = create( body_deep_park, nullptr, "deep-park" );
|
||||
drain();
|
||||
|
||||
if( !g_deep_reached )
|
||||
return report( "deep_park_sizing", false, "never reached the deep park" );
|
||||
|
||||
if( status_of( id ) != Status::Parked )
|
||||
return report( "deep_park_sizing", false, "not parked at depth" );
|
||||
|
||||
mark_ready( id, 31337 );
|
||||
drain();
|
||||
|
||||
if( g_deep_result != 31337 )
|
||||
return report( "deep_park_sizing", false,
|
||||
"wrong result through " + std::to_string( DEEP_PARK_FRAMES )
|
||||
+ " frames: " + std::to_string( g_deep_result ) );
|
||||
|
||||
if( status_of( id ) != Status::Finished )
|
||||
return report( "deep_park_sizing", false, "did not finish after the deep resume" );
|
||||
|
||||
// The sizing evidence: bytes saved for a known frame count.
|
||||
log_line( "[SCHED_CTX] DEEPPARK frames=" + std::to_string( DEEP_PARK_FRAMES )
|
||||
+ " registry=" + registry_json() );
|
||||
|
||||
destroy( id );
|
||||
report( "deep_park_sizing", true );
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scenario 9: a REAL async wake — JS timeout resolves the park
|
||||
// The park's wake crosses a JS turn, which is the shape every production
|
||||
// bridge has (doc 21 D4): the context must resume from a fresh task, and
|
||||
// the wake must never rewind inline.
|
||||
// ---------------------------------------------------------------------------
|
||||
ContextId g_async_ctx = 0;
|
||||
int g_async_result = 0;
|
||||
int g_async_stage = 0;
|
||||
|
||||
void body_async_park( void* )
|
||||
{
|
||||
g_async_stage = 1;
|
||||
g_async_result = yield_park( "js-promise" );
|
||||
g_async_stage = 2;
|
||||
}
|
||||
|
||||
void finish_battery()
|
||||
{
|
||||
log_line( std::string( "[SCHED_CTX] STATS " ) + stats_json() );
|
||||
log_line( std::string( "[SCHED_CTX] REGISTRY " ) + registry_json() );
|
||||
log_line( "[SCHED_CTX] SUMMARY total=" + std::to_string( g_total )
|
||||
+ " passed=" + std::to_string( g_passed )
|
||||
+ " failed=" + std::to_string( g_total - g_passed ) );
|
||||
}
|
||||
|
||||
extern "C" EMSCRIPTEN_KEEPALIVE void sched_ctx_async_wake( int aResult )
|
||||
{
|
||||
if( !mark_ready( g_async_ctx, aResult ) )
|
||||
{
|
||||
report( "async_wake", false, "mark_ready from JS failed" );
|
||||
finish_battery();
|
||||
return;
|
||||
}
|
||||
|
||||
// Resume from THIS fresh JS task — never inline in the resolver.
|
||||
drain();
|
||||
|
||||
if( g_async_stage != 2 )
|
||||
report( "async_wake", false, "context did not resume" );
|
||||
else if( g_async_result != aResult )
|
||||
report( "async_wake", false, "wrong result: " + std::to_string( g_async_result ) );
|
||||
else if( status_of( g_async_ctx ) != Status::Finished )
|
||||
report( "async_wake", false, "context did not finish" );
|
||||
else
|
||||
report( "async_wake", true );
|
||||
|
||||
destroy( g_async_ctx );
|
||||
finish_battery();
|
||||
}
|
||||
|
||||
void Scenario_AsyncWake()
|
||||
{
|
||||
g_async_stage = 0;
|
||||
g_async_result = 0;
|
||||
g_async_ctx = create( body_async_park, nullptr, "async-wake" );
|
||||
drain(); // parks awaiting the JS wake
|
||||
|
||||
if( g_async_stage != 1 || status_of( g_async_ctx ) != Status::Parked )
|
||||
{
|
||||
report( "async_wake", false, "context did not park" );
|
||||
finish_battery();
|
||||
return;
|
||||
}
|
||||
|
||||
// A real macrotask hop, exactly like a bridge promise settling.
|
||||
EM_ASM( { setTimeout( function() { Module["_sched_ctx_async_wake"]( 777 ); }, 20 ); } );
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
int main()
|
||||
{
|
||||
log_line( "[SCHED_CTX] battery start" );
|
||||
|
||||
Scenario_CreateRunsOnDrain();
|
||||
Scenario_ParkAndResume();
|
||||
Scenario_NoParkInPlace();
|
||||
Scenario_ParkedDoesNotBlock();
|
||||
Scenario_FifoOrder();
|
||||
Scenario_OneTransitionInFlight();
|
||||
Scenario_RegistryRefusals();
|
||||
Scenario_DeepPark();
|
||||
|
||||
// Async last: it emits STATS + SUMMARY when its wake lands.
|
||||
Scenario_AsyncWake();
|
||||
return 0;
|
||||
}
|
||||
186
tests/asyncify/sched-context.spec.ts
Normal file
186
tests/asyncify/sched-context.spec.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { test, expect, tryLoadApp } from '../e2e/utils/fixtures';
|
||||
|
||||
/**
|
||||
* Design B D1 gate — scheduler contexts (docs/features/async/20 §6 D1, 21).
|
||||
*
|
||||
* Drives tests/apps/standalone/sched-context/sched_context_test.cpp, which
|
||||
* exercises wasm/sched/context.{h,cpp}: create / yield_park / mark_ready /
|
||||
* drain. The point of the layer is that a resume is something the REGISTRY
|
||||
* knows rather than something a guard guesses (doc 19's disease), so the
|
||||
* battery asserts the invariants, and this spec additionally gates the thing
|
||||
* doc 20 §7 risk 1 demands: contexts must be bounded and MEASURED.
|
||||
*
|
||||
* No production path runs on contexts yet — that starts at D2.
|
||||
*/
|
||||
|
||||
const SCENARIOS = [
|
||||
'create_runs_on_drain',
|
||||
'park_and_resume',
|
||||
'no_park_in_place',
|
||||
'parked_does_not_block',
|
||||
'fifo_order',
|
||||
'one_transition_in_flight',
|
||||
'registry_refusals',
|
||||
'deep_park_sizing',
|
||||
'async_wake',
|
||||
];
|
||||
|
||||
/** Frame count the deep-park scenario recurses to before parking (must match
|
||||
* DEEP_PARK_FRAMES in the harness) — the divisor for the per-frame cost. */
|
||||
const DEEP_PARK_FRAMES = 64;
|
||||
|
||||
/** Memory ceiling for the battery (doc 20 risk 1). The worst scenario holds
|
||||
* ~6 live contexts; 16 leaves headroom without letting a leak hide. */
|
||||
const MAX_PEAK_LIVE = 16;
|
||||
/** Peak bytes the battery may charge to contexts. At 256 KB per context
|
||||
* (128 KB C stack + 128 KB asyncify buffer) 16 contexts = 4 MB. */
|
||||
const MAX_PEAK_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
type Stats = {
|
||||
live: number;
|
||||
peakLive: number;
|
||||
created: number;
|
||||
finished: number;
|
||||
transitions: number;
|
||||
refusals: number;
|
||||
running: number;
|
||||
transitionInFlight: boolean;
|
||||
readyQueued: number;
|
||||
bytes: number;
|
||||
peakBytes: number;
|
||||
perContextBytes: number;
|
||||
cStackBytes: number;
|
||||
asyncifyBytes: number;
|
||||
asyncifyHighWater: number;
|
||||
};
|
||||
|
||||
function findLine(logs: string[], marker: string): string | undefined {
|
||||
return logs.find((l) => l.includes(`[SCHED_CTX] ${marker}`));
|
||||
}
|
||||
|
||||
function parseTagged<T>(logs: string[], marker: string): T | null {
|
||||
const line = findLine(logs, marker);
|
||||
if (!line) return null;
|
||||
const json = line.slice(line.indexOf(`[SCHED_CTX] ${marker}`) + `[SCHED_CTX] ${marker}`.length);
|
||||
return JSON.parse(json.trim()) as T;
|
||||
}
|
||||
|
||||
test.describe('Design B D1 — scheduler contexts', () => {
|
||||
test('battery: every context invariant holds', async ({ page, testLogger }) => {
|
||||
await page.goto('/standalone/sched-context/sched_context_test.html');
|
||||
expect(await tryLoadApp(page, 30000), 'sched-context harness should load').toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(() => findLine(testLogger.consoleLogs, 'SUMMARY') ?? null, {
|
||||
timeout: 30000,
|
||||
message: 'battery should emit SUMMARY (a missing one means a wedge — e.g. a context that never resumed)',
|
||||
})
|
||||
.not.toBeNull();
|
||||
|
||||
const summary = findLine(testLogger.consoleLogs, 'SUMMARY')!;
|
||||
const m = summary.match(/total=(\d+)\s+passed=(\d+)\s+failed=(\d+)/);
|
||||
expect(m, `unparseable summary: ${summary}`).not.toBeNull();
|
||||
const [total, passed, failed] = [Number(m![1]), Number(m![2]), Number(m![3])];
|
||||
|
||||
const fails = testLogger.consoleLogs.filter((l) => l.includes('[SCHED_CTX] FAIL '));
|
||||
expect(fails, `failures: ${fails.join(' || ')}`).toHaveLength(0);
|
||||
expect(failed).toBe(0);
|
||||
expect(total, 'every scenario ran').toBe(SCENARIOS.length);
|
||||
expect(passed).toBe(SCENARIOS.length);
|
||||
|
||||
for (const name of SCENARIOS) {
|
||||
expect
|
||||
.soft(
|
||||
testLogger.consoleLogs.some((l) => l.includes(`[SCHED_CTX] PASS ${name}`)),
|
||||
`scenario ${name} should PASS`,
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('memory: contexts are bounded and measured (doc 20 risk 1)', async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
await page.goto('/standalone/sched-context/sched_context_test.html');
|
||||
expect(await tryLoadApp(page, 30000)).toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(() => findLine(testLogger.consoleLogs, 'STATS') ?? null, { timeout: 30000 })
|
||||
.not.toBeNull();
|
||||
|
||||
const stats = parseTagged<Stats>(testLogger.consoleLogs, 'STATS')!;
|
||||
console.log(`[TEST] context stats: ${JSON.stringify(stats)}`);
|
||||
|
||||
// --- the ceiling: a leak must fail here, not in a user's tab ------------
|
||||
expect(stats.peakLive, 'peak live contexts within the ceiling').toBeLessThanOrEqual(
|
||||
MAX_PEAK_LIVE,
|
||||
);
|
||||
expect(stats.peakBytes, 'peak context bytes within the budget').toBeLessThanOrEqual(
|
||||
MAX_PEAK_BYTES,
|
||||
);
|
||||
|
||||
// --- no leaks: every context the battery created was destroyed ----------
|
||||
expect(stats.live, 'no context left live after the battery').toBe(0);
|
||||
expect(stats.bytes, 'no context bytes left charged').toBe(0);
|
||||
expect(stats.finished, 'every created context finished').toBe(stats.created);
|
||||
|
||||
// --- the layer did real work (guards against a vacuous pass) -----------
|
||||
expect(stats.created, 'the battery created contexts').toBeGreaterThan(5);
|
||||
expect(stats.transitions, 'the scheduler performed swaps').toBeGreaterThan(
|
||||
stats.created,
|
||||
);
|
||||
|
||||
// --- the invariant: nothing left mid-transition ------------------------
|
||||
expect(stats.transitionInFlight, 'no transition left in flight').toBe(false);
|
||||
expect(stats.running, 'no context left running').toBe(0);
|
||||
expect(stats.readyQueued, 'ready queue drained').toBe(0);
|
||||
|
||||
// --- refusals are EXPECTED here: three scenarios provoke them ----------
|
||||
// (yield_park off a context, destroy while parked, mark_ready twice /
|
||||
// unknown id). Zero would mean those scenarios stopped provoking.
|
||||
expect(stats.refusals, 'illegal operations were refused and counted').toBeGreaterThan(0);
|
||||
|
||||
// --- sizing evidence (doc 20 risk 1: derive, don't inherit) ------------
|
||||
// The high-water mark is what a future buffer size must be justified by.
|
||||
// Assert it is both real (>0 — the parks did save frames) and comfortably
|
||||
// inside the buffer, so the number in the log is trustworthy.
|
||||
expect(stats.asyncifyHighWater, 'asyncify use was measured').toBeGreaterThan(0);
|
||||
expect(
|
||||
stats.asyncifyHighWater,
|
||||
'asyncify high-water inside the per-context buffer',
|
||||
).toBeLessThan(stats.asyncifyBytes);
|
||||
// Per-frame cost from the deep-park scenario: the number a future buffer
|
||||
// size must be derived from. The high-water is dominated by that park
|
||||
// (every other scenario parks 1-2 frames deep).
|
||||
//
|
||||
// CAVEAT, do not skip when quoting this number: the harness's frames carry
|
||||
// three locals each, so this is a FLOOR for per-frame cost, not a
|
||||
// production estimate. Real park sites (a lib fetch inside commit.Push →
|
||||
// connectivity → font work) save far more per frame — that is why
|
||||
// libcontext runs a 512 K buffer after a 64 K one silently overflowed.
|
||||
// Treat this as "the measurement apparatus works and these are its units";
|
||||
// the sizing decision needs deep-park numbers from real bridges at D3/D4.
|
||||
const perFrame = stats.asyncifyHighWater / DEEP_PARK_FRAMES;
|
||||
console.log(
|
||||
`[TEST] asyncify sizing: high-water ${stats.asyncifyHighWater}B of ` +
|
||||
`${stats.asyncifyBytes}B buffer ` +
|
||||
`(${((stats.asyncifyHighWater / stats.asyncifyBytes) * 100).toFixed(1)}%); ` +
|
||||
`~${perFrame.toFixed(0)}B per frame over ${DEEP_PARK_FRAMES} frames ` +
|
||||
`→ the ${stats.asyncifyBytes}B buffer holds ~${Math.floor(
|
||||
stats.asyncifyBytes / Math.max(perFrame, 1),
|
||||
)} frames`,
|
||||
);
|
||||
|
||||
// The deep park must actually dominate — otherwise the per-frame number
|
||||
// above is noise from a shallow park and cannot justify any size.
|
||||
expect(
|
||||
stats.asyncifyHighWater,
|
||||
'the deep park (64 frames) drove the high-water mark',
|
||||
).toBeGreaterThan(1024);
|
||||
|
||||
// No buffer-pressure beacon should have fired (>75% use).
|
||||
const pressure = testLogger.consoleLogs.filter((l) => l.includes('BUFFER-PRESSURE'));
|
||||
expect(pressure, `buffer pressure: ${pressure.join(' || ')}`).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -184,11 +184,13 @@ export default defineConfig({
|
|||
},
|
||||
},
|
||||
{
|
||||
// Asyncify race-condition red-green harness. One heavy WASM app at a
|
||||
// time within the (single) spec file.
|
||||
// Asyncify-layer harnesses: the race-condition red-green battery and
|
||||
// the Design-B scheduler-context gate. One heavy WASM app at a time.
|
||||
// Matches EVERY spec in ./asyncify — a new harness here is covered by
|
||||
// construction rather than by remembering to widen this pattern.
|
||||
name: 'asyncify-firefox',
|
||||
testDir: './asyncify',
|
||||
testMatch: /asyncify-races.*\.spec\.ts$/,
|
||||
testMatch: /\.spec\.ts$/,
|
||||
fullyParallel: false,
|
||||
timeout: 120000,
|
||||
use: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue