design-b D2: attempted, reverted, resequenced behind D3
Bumps wxwidgets d220aae5 (D2a: sched_context.h moved into wx's port, header-only so evtloop.cpp can see it) and 5ee60a81 (D1 fix: 16-align context stacks — EM_ASM's arg buffer lives on the running stack and the glue asserts buf % 16 == 0, so misaligned contexts trapped in readEmAsmArgs; std::vector<char> only gives malloc's 8-byte alignment). The dispatch switch itself is NOT landed. Running the tick's ProcessEvents on a context took the battery from 363 green to 388/7, six of them the coroutine-nested harness wedging at fiber_create_run_destroy_inside_modal via aliased-wake-live -> fiber-resume-refused — doc 19's mechanism. A quasi-modal opened from a tick handler suspends the dispatch context INSIDE the still-in-place wait, putting one more Asyncify layer under every libcontext fiber. Pooling contexts (8 burned in 30 ms) and falling back to entry-stack dispatch both failed to avoid it, because the layer exists as soon as the context is suspended. That is doc 20's own risk 2 arriving on schedule, so the plan is corrected rather than the symptom patched: D3 (waits become context yields) must come first, after which the dispatch context is released at its tick boundary instead of suspended and the failure class is structurally absent. Verified the revert: the nested battery is green again at this baseline (5 passed / 1 failed, the 1 being the environment-sensitive modal:125 that also fails without any of this). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEHGiiXMShNXbBr7gSJ7iz
This commit is contained in:
parent
41f3f35919
commit
5a3dd44b23
4 changed files with 54 additions and 645 deletions
|
|
@ -1,6 +1,7 @@
|
|||
# 20 — Design B core: parkable activities as scheduler contexts
|
||||
|
||||
> **Status: IN PROGRESS — D-1, D0, D1 DONE (2026-08-05, work log §10).** The remaining core of
|
||||
> **Status: IN PROGRESS — D-1, D0, D1 DONE; D2 attempted and resequenced behind D3
|
||||
> (work log §10). Phase order is now D3 → D2 → D4 → D5? → D6.** The remaining core of
|
||||
> Design B ([`06`](06-design-b-fiber-first-runtime.md) B1+B2), scoped against what S0–S6
|
||||
> actually built ([`17`](17-mailbox-scheduler-plan.md)) and motivated by the
|
||||
> stranded-fiber hang ([`19`](19-quasimodal-fiber-strand.md)). Supersedes doc 12's
|
||||
|
|
@ -117,13 +118,25 @@ Two consequences worth stating plainly:
|
|||
libcontext: create context, swap-out to the scheduler, resume by swap-in, registry as
|
||||
truth, one transition in flight. Exercised only by a dedicated test app — **no
|
||||
production path switched yet**. Gate: new unit/app tests + full battery unchanged.
|
||||
- **D2 · Dispatch contexts (1–2 wk).** The tick resumes a dispatch context instead of
|
||||
running handlers on the entry stack; scheduler policy enforces one runnable dispatch
|
||||
context. The interlock stays, demoted to a **tripwire** asserting it never disagrees with
|
||||
the scheduler. Gate: full net + zero tripwire divergence across the fuzz suites.
|
||||
- **D3 · Waits on contexts (1 wk).** `wxWasmYieldUntil` becomes a context yield. **The
|
||||
doc-19 red spec goes green here** — modal/nested/popup no longer park a tool fiber's body.
|
||||
- **D3 · Waits on contexts (1 wk) — NOW BEFORE D2, see §10's D2 attempt.**
|
||||
`wxWasmYieldUntil` becomes a context yield. **The doc-19 red spec goes green here** —
|
||||
modal/nested/popup no longer park a tool fiber's body.
|
||||
Gate: red→green + battery + kicad suite.
|
||||
- **D2 · Dispatch contexts (1–2 wk) — resequenced AFTER D3.** The tick resumes a dispatch
|
||||
context instead of running handlers on the entry stack; scheduler policy enforces one
|
||||
runnable dispatch context. The interlock stays, demoted to a **tripwire** asserting it
|
||||
never disagrees with the scheduler. Gate: full net + zero tripwire divergence across the
|
||||
fuzz suites.
|
||||
|
||||
**Why the swap (measured 2026-08-06):** dispatch-on-a-context is only safe once waits
|
||||
yield instead of parking in place. While `wxWasmYieldUntil` is still an in-place park, a
|
||||
quasi-modal opened from a tick handler suspends the dispatch context *inside* the wait,
|
||||
which adds an Asyncify layer under every libcontext fiber — and the coroutine-nested
|
||||
battery dies on it (`aliased-wake-live` → `fiber-resume-refused` in
|
||||
`fiber_create_run_destroy_inside_modal`). That is risk 2 ("partial migration is worse
|
||||
than none") arriving on schedule. After D3 the wait releases the context at its tick
|
||||
boundary, so the dispatch context is never suspended in place and the extra layer never
|
||||
exists.
|
||||
- **D4 · Bridges on contexts (1–2 wk).** One helper (`wasm_await_promise`-style) routes
|
||||
lib/3D/clipboard/font/nanosleep/stub parks through the same yield. After this **no
|
||||
`handleSleep` park happens inside a fiber at all** — the quarantine guard is demoted to a
|
||||
|
|
@ -274,6 +287,36 @@ the apparatus works and what its units are; the sizing DECISION needs deep-park
|
|||
from real bridges, so it belongs to D3/D4, not here. A >75% buffer use beacons
|
||||
`BUFFER-PRESSURE`, because that overflow corrupts silently rather than crashing.
|
||||
|
||||
Next: **D2** — the tick resumes a dispatch context instead of running handlers on the entry
|
||||
stack; scheduler policy enforces one runnable dispatch context; the interlock stays but is
|
||||
demoted to a tripwire asserting it never disagrees with the scheduler.
|
||||
### D2 — attempted, REVERTED, and resequenced behind D3 (2026-08-06)
|
||||
|
||||
Landed from the attempt: **D2a** (wx `d220aae5`) moved the layer into wx's port as a
|
||||
header (`wx/wasm/private/sched_context.h`) so evtloop.cpp can see it — adding a `.cpp` to
|
||||
wx means bakefile regeneration. And a real **D1 bug fix** (wx `5ee60a81`): context stacks
|
||||
must be 16-byte aligned or every `EM_ASM` on a context traps in `readEmAsmArgs`
|
||||
(`assert(buf % 16 == 0)` — the arg buffer lives on the running stack).
|
||||
`std::vector<char>::data()` only has malloc's 8-byte alignment, so contexts were
|
||||
misaligned about half the time. D1's harness passed by allocation luck; wx's dispatch
|
||||
found it instantly with 107 failures.
|
||||
|
||||
**Reverted: the dispatch switch itself.** With the tick's `ProcessEvents` on a context the
|
||||
battery went 363 → 388/7. Six of the seven were the coroutine-nested battery wedging at
|
||||
`fiber_create_run_destroy_inside_modal`, preceded by `aliased-wake-live` and killed by
|
||||
`fiber-resume-refused` — doc 19's exact mechanism. Cause: a quasi-modal opened from a tick
|
||||
handler suspends the dispatch context *inside* the still-in-place wait, so every
|
||||
libcontext fiber below now sits under one more Asyncify layer.
|
||||
|
||||
Two designs were tried and both are dead ends while waits park in place:
|
||||
1. *Grow a pool* (one context per suspended one) — the nested harness burned all 8 in
|
||||
30 ms, because a context suspended in a modal is consumed for that modal's lifetime.
|
||||
Nesting depth would size the pool: a leak wearing a cap.
|
||||
2. *Fall back to entry-stack dispatch while suspended* — restores pre-D2 semantics for the
|
||||
modal case and did fix the races `nested_quasi_modal_pump_error`, but the extra layer
|
||||
is already there by the time the fallback is reached, so the nested battery still died.
|
||||
|
||||
**Conclusion (plan correction):** D2 is not landable before D3. Sequence is now
|
||||
D3 → D2 → D4 → D5? → D6. Do not retry dispatch contexts until `wxWasmYieldUntil` yields
|
||||
its context; then the dispatch context is released at its tick boundary rather than
|
||||
suspended, and this whole failure class is structurally absent.
|
||||
|
||||
Next: **D3** — `wxWasmYieldUntil` becomes a context yield; the doc-19 red spec
|
||||
(`tests/kicad/quasimodal-strand.spec.ts`) goes green and its `test.fail()` comes off.
|
||||
|
|
|
|||
|
|
@ -1,508 +0,0 @@
|
|||
/*
|
||||
* Scheduler contexts — implementation. See context.h for the contract and why
|
||||
* this is a star topology rather than libcontext's symmetric swap.
|
||||
*/
|
||||
#include "context.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
|
||||
#include <emscripten/emscripten.h>
|
||||
#include <emscripten/fiber.h>
|
||||
#include <emscripten/threading.h>
|
||||
|
||||
namespace pcbjam_sched
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Sizes are DELIBERATELY not libcontext's 512K asyncify buffer: doc 20 risk 1
|
||||
// requires them re-derived from measurement, and this layer measures its own
|
||||
// high-water use (see asyncify_used()). Start at a quarter of libcontext's and
|
||||
// let the gate tell us whether that is generous or tight — an inherited
|
||||
// constant nobody can justify is exactly what the risk warns about.
|
||||
//
|
||||
// The C stack is separate from the asyncify buffer: the former holds live
|
||||
// frames while running, the latter holds their locals while parked.
|
||||
constexpr size_t DEFAULT_C_STACK_BYTES = 128 * 1024;
|
||||
constexpr size_t DEFAULT_ASYNCIFY_BYTES = 128 * 1024;
|
||||
|
||||
// A hard ceiling turns "contexts leaked until the tab died" into a loud,
|
||||
// early failure with a registry dump. Deliberately low for D1: nothing in
|
||||
// production runs on contexts yet, and the test app's worst battery uses ~8.
|
||||
constexpr size_t MAX_LIVE_CONTEXTS = 64;
|
||||
|
||||
struct Context
|
||||
{
|
||||
ContextId id = 0;
|
||||
const char* label = "";
|
||||
const char* park_reason = "";
|
||||
Status status = Status::Fresh;
|
||||
int result = 0;
|
||||
|
||||
emscripten_fiber_t fiber {};
|
||||
std::vector<char> c_stack;
|
||||
std::vector<char> asyncify_stack;
|
||||
|
||||
void ( *entry )( void* ) = nullptr;
|
||||
void* arg = nullptr;
|
||||
|
||||
uint32_t parks = 0;
|
||||
uint32_t resumes = 0;
|
||||
size_t asyncify_high_water = 0;
|
||||
};
|
||||
|
||||
struct Registry
|
||||
{
|
||||
std::map<ContextId, Context*> contexts;
|
||||
std::vector<ContextId> ready_fifo; // FIFO: no starvation (doc 13 §1.5 inv. 8)
|
||||
|
||||
ContextId next_id = 1;
|
||||
ContextId running = 0; // 0 = the scheduler stack is running
|
||||
bool transition = false; // at most one swap in flight
|
||||
bool scheduler_initialized = false;
|
||||
emscripten_fiber_t scheduler_fiber {};
|
||||
std::vector<char> scheduler_asyncify_stack;
|
||||
|
||||
// Counters (stats_json)
|
||||
uint32_t created = 0;
|
||||
uint32_t finished = 0;
|
||||
uint32_t transitions = 0;
|
||||
uint32_t refusals = 0;
|
||||
size_t live = 0;
|
||||
size_t peak_live = 0;
|
||||
size_t bytes = 0;
|
||||
size_t peak_bytes = 0;
|
||||
size_t asyncify_high_water = 0;
|
||||
};
|
||||
|
||||
Registry& reg()
|
||||
{
|
||||
static Registry s_registry;
|
||||
return s_registry;
|
||||
}
|
||||
|
||||
void beacon( const char* aWhat, const char* aDetail, unsigned aId )
|
||||
{
|
||||
std::printf( "[sched-ctx] %s id=%u %s\n", aWhat, aId, aDetail ? aDetail : "" );
|
||||
std::fflush( stdout );
|
||||
}
|
||||
|
||||
bool on_main_thread()
|
||||
{
|
||||
// Contexts are a main-thread concept by construction (doc 21 §2). A worker
|
||||
// has its own Asyncify state; a context swapped there would corrupt both.
|
||||
return emscripten_is_main_runtime_thread() != 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes of asyncify buffer this context has consumed at its deepest park.
|
||||
*
|
||||
* asyncify_data_t is {stack_ptr, stack_limit, rewind_id}; the unwind fills
|
||||
* from the buffer's base upward, so used = stack_ptr - base. This is the same
|
||||
* quantity the shim's `rem=` telemetry reports from the other direction
|
||||
* (limit - ptr), and it is what doc 20 risk 1 wants sizing derived from.
|
||||
*/
|
||||
size_t asyncify_used( const Context& aCtx )
|
||||
{
|
||||
const char* base = aCtx.asyncify_stack.data();
|
||||
const char* ptr = static_cast<const char*>( aCtx.fiber.asyncify_data.stack_ptr );
|
||||
|
||||
if( !base || !ptr || ptr < base )
|
||||
return 0;
|
||||
|
||||
const size_t used = static_cast<size_t>( ptr - base );
|
||||
return used > aCtx.asyncify_stack.size() ? aCtx.asyncify_stack.size() : used;
|
||||
}
|
||||
|
||||
void note_asyncify_use( Context& aCtx )
|
||||
{
|
||||
const size_t used = asyncify_used( aCtx );
|
||||
|
||||
if( used > aCtx.asyncify_high_water )
|
||||
aCtx.asyncify_high_water = used;
|
||||
|
||||
if( used > reg().asyncify_high_water )
|
||||
reg().asyncify_high_water = used;
|
||||
|
||||
// Overflow here is silent corruption (libcontext's 512K comment documents
|
||||
// exactly that failure), so shout well before the edge rather than after.
|
||||
if( used * 4 > aCtx.asyncify_stack.size() * 3 )
|
||||
{
|
||||
char detail[128];
|
||||
std::snprintf( detail, sizeof( detail ),
|
||||
"asyncify buffer >75%% used (%zu/%zu) - raise the size",
|
||||
used, aCtx.asyncify_stack.size() );
|
||||
beacon( "BUFFER-PRESSURE", detail, aCtx.id );
|
||||
}
|
||||
}
|
||||
|
||||
Context* find( ContextId aId )
|
||||
{
|
||||
auto it = reg().contexts.find( aId );
|
||||
return it == reg().contexts.end() ? nullptr : it->second;
|
||||
}
|
||||
|
||||
void ensure_scheduler_context()
|
||||
{
|
||||
Registry& r = reg();
|
||||
|
||||
if( r.scheduler_initialized )
|
||||
return;
|
||||
|
||||
// The scheduler runs on whatever stack called drain() first — a fresh JS
|
||||
// task entry. Contexts swap back INTO this fiber, which is what makes the
|
||||
// topology a star: every yield lands here, and only here decides who runs
|
||||
// next.
|
||||
r.scheduler_asyncify_stack.assign( DEFAULT_ASYNCIFY_BYTES, 0 );
|
||||
emscripten_fiber_init_from_current_context( &r.scheduler_fiber,
|
||||
r.scheduler_asyncify_stack.data(),
|
||||
r.scheduler_asyncify_stack.size() );
|
||||
r.scheduler_initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context trampoline. Per emscripten fiber.h the entry function must NEVER
|
||||
* return (returning ends the whole program), so it loops: run the body, mark
|
||||
* Finished, swap back to the scheduler, and if the scheduler ever enters this
|
||||
* context again, run again.
|
||||
*/
|
||||
void context_trampoline( void* aArg )
|
||||
{
|
||||
auto* ctx = static_cast<Context*>( aArg );
|
||||
|
||||
while( true )
|
||||
{
|
||||
ctx->status = Status::Running;
|
||||
|
||||
if( ctx->entry )
|
||||
ctx->entry( ctx->arg );
|
||||
|
||||
ctx->status = Status::Finished;
|
||||
ctx->park_reason = "finished";
|
||||
reg().finished++;
|
||||
reg().running = 0;
|
||||
reg().transition = false;
|
||||
|
||||
// Back to the scheduler. A finished context is never re-entered by
|
||||
// drain() (it only picks Ready), so this swap is terminal in practice.
|
||||
emscripten_fiber_swap( &ctx->fiber, ®().scheduler_fiber );
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
const char* status_name( Status aStatus )
|
||||
{
|
||||
switch( aStatus )
|
||||
{
|
||||
case Status::Fresh: return "fresh";
|
||||
case Status::Running: return "running";
|
||||
case Status::Parked: return "parked";
|
||||
case Status::Ready: return "ready";
|
||||
case Status::Finished: return "finished";
|
||||
}
|
||||
|
||||
return "?";
|
||||
}
|
||||
|
||||
|
||||
ContextId create( void ( *aEntry )( void* ), void* aArg, const char* aLabel )
|
||||
{
|
||||
Registry& r = reg();
|
||||
|
||||
if( !on_main_thread() )
|
||||
{
|
||||
beacon( "REFUSED", "create() off the main thread", 0 );
|
||||
r.refusals++;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if( !aEntry )
|
||||
{
|
||||
beacon( "REFUSED", "create() with a null entry", 0 );
|
||||
r.refusals++;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if( r.live >= MAX_LIVE_CONTEXTS )
|
||||
{
|
||||
char detail[160];
|
||||
std::snprintf( detail, sizeof( detail ),
|
||||
"context ceiling reached (%zu live) - registry: %s",
|
||||
r.live, registry_json().c_str() );
|
||||
beacon( "REFUSED", detail, 0 );
|
||||
r.refusals++;
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto* ctx = new( std::nothrow ) Context();
|
||||
|
||||
if( !ctx )
|
||||
{
|
||||
beacon( "REFUSED", "context allocation failed", 0 );
|
||||
r.refusals++;
|
||||
return 0;
|
||||
}
|
||||
|
||||
ctx->id = r.next_id++;
|
||||
ctx->label = aLabel ? aLabel : "";
|
||||
ctx->entry = aEntry;
|
||||
ctx->arg = aArg;
|
||||
ctx->c_stack.assign( DEFAULT_C_STACK_BYTES, 0 );
|
||||
ctx->asyncify_stack.assign( DEFAULT_ASYNCIFY_BYTES, 0 );
|
||||
|
||||
emscripten_fiber_init( &ctx->fiber, context_trampoline, ctx,
|
||||
ctx->c_stack.data(), ctx->c_stack.size(),
|
||||
ctx->asyncify_stack.data(), ctx->asyncify_stack.size() );
|
||||
|
||||
ctx->status = Status::Ready; // enters at aEntry on the first drain()
|
||||
ctx->park_reason = "created";
|
||||
|
||||
r.contexts[ctx->id] = ctx;
|
||||
r.ready_fifo.push_back( ctx->id );
|
||||
r.created++;
|
||||
r.live++;
|
||||
|
||||
if( r.live > r.peak_live )
|
||||
r.peak_live = r.live;
|
||||
|
||||
r.bytes += ctx->c_stack.size() + ctx->asyncify_stack.size();
|
||||
|
||||
if( r.bytes > r.peak_bytes )
|
||||
r.peak_bytes = r.bytes;
|
||||
|
||||
return ctx->id;
|
||||
}
|
||||
|
||||
|
||||
int yield_park( const char* aReason )
|
||||
{
|
||||
Registry& r = reg();
|
||||
Context* ctx = find( r.running );
|
||||
|
||||
if( !ctx )
|
||||
{
|
||||
// Called on the scheduler stack: there is nothing to yield. This is
|
||||
// the "parked in place" mistake the whole design forbids, so it is a
|
||||
// loud refusal rather than a silent no-op.
|
||||
beacon( "REFUSED", "yield_park() with no running context", 0 );
|
||||
r.refusals++;
|
||||
return -1;
|
||||
}
|
||||
|
||||
ctx->status = Status::Parked;
|
||||
ctx->park_reason = aReason ? aReason : "";
|
||||
ctx->parks++;
|
||||
r.running = 0;
|
||||
r.transition = false; // the swap below completes this transition
|
||||
|
||||
// Yield to the scheduler. Control returns here when drain() swaps us back
|
||||
// in after mark_ready() — and ONLY then, because Parked→Ready→resume is
|
||||
// the single path in.
|
||||
emscripten_fiber_swap( &ctx->fiber, &r.scheduler_fiber );
|
||||
|
||||
// Resumed. The registry set status/result before swapping in.
|
||||
note_asyncify_use( *ctx );
|
||||
return ctx->result;
|
||||
}
|
||||
|
||||
|
||||
bool mark_ready( ContextId aId, int aResult )
|
||||
{
|
||||
Registry& r = reg();
|
||||
Context* ctx = find( aId );
|
||||
|
||||
if( !ctx )
|
||||
{
|
||||
beacon( "REFUSED", "mark_ready() for an unknown context", aId );
|
||||
r.refusals++;
|
||||
return false;
|
||||
}
|
||||
|
||||
if( ctx->status != Status::Parked )
|
||||
{
|
||||
char detail[96];
|
||||
std::snprintf( detail, sizeof( detail ), "mark_ready() on a %s context",
|
||||
status_name( ctx->status ) );
|
||||
beacon( "REFUSED", detail, aId );
|
||||
r.refusals++;
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx->result = aResult;
|
||||
ctx->status = Status::Ready;
|
||||
r.ready_fifo.push_back( aId );
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
ContextId drain()
|
||||
{
|
||||
Registry& r = reg();
|
||||
|
||||
if( !on_main_thread() )
|
||||
{
|
||||
beacon( "REFUSED", "drain() off the main thread", 0 );
|
||||
r.refusals++;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// One transition at a time, and never re-entrantly from a context (a
|
||||
// context reaching drain() would make the star a cycle).
|
||||
if( r.transition || r.running != 0 )
|
||||
return 0;
|
||||
|
||||
ensure_scheduler_context();
|
||||
|
||||
// Skip ids that died or were consumed while queued.
|
||||
ContextId id = 0;
|
||||
Context* ctx = nullptr;
|
||||
|
||||
while( !r.ready_fifo.empty() )
|
||||
{
|
||||
id = r.ready_fifo.front();
|
||||
r.ready_fifo.erase( r.ready_fifo.begin() );
|
||||
ctx = find( id );
|
||||
|
||||
if( ctx && ctx->status == Status::Ready )
|
||||
break;
|
||||
|
||||
ctx = nullptr;
|
||||
}
|
||||
|
||||
if( !ctx )
|
||||
return 0;
|
||||
|
||||
r.transition = true;
|
||||
r.transitions++;
|
||||
r.running = id;
|
||||
ctx->status = Status::Running;
|
||||
ctx->resumes++;
|
||||
|
||||
// Swap in. Returns when the context parks (yield_park) or finishes; both
|
||||
// clear running/transition before swapping back.
|
||||
emscripten_fiber_swap( &r.scheduler_fiber, &ctx->fiber );
|
||||
|
||||
r.transition = false;
|
||||
r.running = 0;
|
||||
|
||||
// The context object may still exist (parked) or be finished; either way
|
||||
// its buffer use is now measurable.
|
||||
if( Context* back = find( id ) )
|
||||
note_asyncify_use( *back );
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
bool transition_in_flight()
|
||||
{
|
||||
return reg().transition;
|
||||
}
|
||||
|
||||
|
||||
ContextId current()
|
||||
{
|
||||
return reg().running;
|
||||
}
|
||||
|
||||
|
||||
Status status_of( ContextId aId )
|
||||
{
|
||||
Context* ctx = find( aId );
|
||||
return ctx ? ctx->status : Status::Finished;
|
||||
}
|
||||
|
||||
|
||||
bool destroy( ContextId aId )
|
||||
{
|
||||
Registry& r = reg();
|
||||
Context* ctx = find( aId );
|
||||
|
||||
if( !ctx )
|
||||
return false;
|
||||
|
||||
if( ctx->status != Status::Finished )
|
||||
{
|
||||
// Freeing a parked context's stack would strand whatever is on it —
|
||||
// the very failure mode this layer exists to make impossible.
|
||||
char detail[96];
|
||||
std::snprintf( detail, sizeof( detail ), "destroy() on a %s context",
|
||||
status_name( ctx->status ) );
|
||||
beacon( "REFUSED", detail, aId );
|
||||
r.refusals++;
|
||||
return false;
|
||||
}
|
||||
|
||||
r.bytes -= ctx->c_stack.size() + ctx->asyncify_stack.size();
|
||||
r.live--;
|
||||
r.contexts.erase( aId );
|
||||
delete ctx;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
std::string stats_json()
|
||||
{
|
||||
Registry& r = reg();
|
||||
char buf[640];
|
||||
std::snprintf( buf, sizeof( buf ),
|
||||
"{\"live\":%zu,\"peakLive\":%zu,\"created\":%u,\"finished\":%u,"
|
||||
"\"transitions\":%u,\"refusals\":%u,\"running\":%u,"
|
||||
"\"transitionInFlight\":%s,\"readyQueued\":%zu,"
|
||||
"\"bytes\":%zu,\"peakBytes\":%zu,"
|
||||
"\"perContextBytes\":%zu,\"cStackBytes\":%zu,\"asyncifyBytes\":%zu,"
|
||||
"\"asyncifyHighWater\":%zu}",
|
||||
r.live, r.peak_live, r.created, r.finished,
|
||||
r.transitions, r.refusals, r.running,
|
||||
r.transition ? "true" : "false", r.ready_fifo.size(),
|
||||
r.bytes, r.peak_bytes,
|
||||
DEFAULT_C_STACK_BYTES + DEFAULT_ASYNCIFY_BYTES,
|
||||
DEFAULT_C_STACK_BYTES, DEFAULT_ASYNCIFY_BYTES,
|
||||
r.asyncify_high_water );
|
||||
return buf;
|
||||
}
|
||||
|
||||
|
||||
std::string registry_json()
|
||||
{
|
||||
std::string out = "[";
|
||||
bool first = true;
|
||||
|
||||
for( const auto& [id, ctx] : reg().contexts )
|
||||
{
|
||||
char entry[256];
|
||||
std::snprintf( entry, sizeof( entry ),
|
||||
"%s{\"id\":%u,\"label\":\"%s\",\"status\":\"%s\",\"reason\":\"%s\","
|
||||
"\"parks\":%u,\"resumes\":%u,\"asyncifyHighWater\":%zu}",
|
||||
first ? "" : ",", id, ctx->label, status_name( ctx->status ),
|
||||
ctx->park_reason, ctx->parks, ctx->resumes,
|
||||
ctx->asyncify_high_water );
|
||||
out += entry;
|
||||
first = false;
|
||||
}
|
||||
|
||||
out += "]";
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
void reset_stats()
|
||||
{
|
||||
Registry& r = reg();
|
||||
r.created = 0;
|
||||
r.finished = 0;
|
||||
r.transitions = 0;
|
||||
r.refusals = 0;
|
||||
r.peak_live = r.live;
|
||||
r.peak_bytes = r.bytes;
|
||||
r.asyncify_high_water = 0;
|
||||
}
|
||||
|
||||
} // namespace pcbjam_sched
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
/*
|
||||
* Scheduler contexts — Design B core, phase D1
|
||||
* (pcbjam docs/features/async/20-design-b-core-plan.md §5, §6 D1).
|
||||
*
|
||||
* THE RULE THIS EXISTS TO ENFORCE: no activity may park "in place". A
|
||||
* suspendable activity runs on a scheduler-owned context (its own stack + its
|
||||
* own asyncify buffer) and suspends by YIELDING THAT CONTEXT back to the
|
||||
* scheduler. The registry below is then authoritative about what is parked and
|
||||
* why — never a fiber struct saying one thing while its body sits in a
|
||||
* handleSleep the fiber layer cannot see (docs/features/async/19).
|
||||
*
|
||||
* HOW THIS DIFFERS FROM libcontext (kicad/thirdparty/libcontext), which stays
|
||||
* exactly as it is:
|
||||
*
|
||||
* - libcontext is SYMMETRIC: any stack may jump_fcontext to any other, so
|
||||
* "is the target safe to enter?" has no recorded answer and is guessed
|
||||
* (swap_suspended, the parked/hot-main refusals). This layer is a STAR:
|
||||
* contexts only ever swap OUT to the scheduler, and only the scheduler
|
||||
* swaps IN. A resume is therefore never a guess — the registry says the
|
||||
* context is Parked/Ready and holds its buffer.
|
||||
* - At most ONE transition is in flight, enforced here rather than hoped for.
|
||||
*
|
||||
* SCOPE (D1): primitives + registry + memory accounting, exercised only by
|
||||
* tests/apps/standalone/sched-context. NO production path is switched to this
|
||||
* yet — dispatch moves at D2, waits at D3, bridges at D4.
|
||||
*
|
||||
* THREADING: main thread only, by construction (doc 21 §2 — every Asyncify
|
||||
* park in the tree is main-thread; the lib bridge's worker path is a blocking
|
||||
* proxy, not a park). Calling any of this from a pthread is a programming
|
||||
* error and is refused loudly.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace pcbjam_sched
|
||||
{
|
||||
|
||||
using ContextId = uint32_t;
|
||||
|
||||
/** Registry truth for one context. Only the scheduler mutates it. */
|
||||
enum class Status
|
||||
{
|
||||
Fresh, ///< created, never entered
|
||||
Running, ///< currently executing (at most one, plus the scheduler)
|
||||
Parked, ///< yielded, waiting for mark_ready()
|
||||
Ready, ///< mark_ready() called, waiting for drain() to swap it in
|
||||
Finished ///< entry returned; stack/buffer reclaimable
|
||||
};
|
||||
|
||||
const char* status_name( Status aStatus );
|
||||
|
||||
/** Per-context sizes. Both are charged to the memory budget (doc 20 risk 1). */
|
||||
struct Sizes
|
||||
{
|
||||
size_t stack_bytes = 0;
|
||||
size_t asyncify_bytes = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a context. It does NOT run until drain() picks it up: creation
|
||||
* marks it Ready so the first drain() enters it at aEntry.
|
||||
*
|
||||
* aLabel is borrowed for the context's lifetime (use a literal); it exists so
|
||||
* a stuck context can be named in a beacon instead of only numbered.
|
||||
* Returns 0 if the limit is hit or allocation fails.
|
||||
*/
|
||||
ContextId create( void ( *aEntry )( void* ), void* aArg, const char* aLabel );
|
||||
|
||||
/**
|
||||
* Park the CURRENTLY RUNNING context and yield to the scheduler. Returns the
|
||||
* result passed to mark_ready(). Must be called on a context (never on the
|
||||
* scheduler stack) — returns -1 immediately otherwise.
|
||||
*
|
||||
* aReason is recorded in the registry: "why is this parked" is exactly the
|
||||
* question the doc-19 guessing layer could not answer.
|
||||
*/
|
||||
int yield_park( const char* aReason );
|
||||
|
||||
/**
|
||||
* Mark a Parked context Ready with aResult. Callable from any stack (a JS
|
||||
* promise settling, a timer, another context). Does NOT resume it — resuming
|
||||
* is drain()'s job, so a wake can never rewind inside another transition
|
||||
* (doc 13 §1.4's deferred-wake law, applied to contexts).
|
||||
*
|
||||
* Returns false if the id is unknown or the context is not Parked.
|
||||
*/
|
||||
bool mark_ready( ContextId aId, int aResult );
|
||||
|
||||
/**
|
||||
* Scheduler entry: resume at most ONE Ready context, running it until it
|
||||
* parks or finishes. Returns the id it ran, or 0 if there was nothing to run
|
||||
* (or a transition was already in flight). Call from a clean stack — a fresh
|
||||
* JS task, never from inside an awaited export (#13302, doc 17 S3).
|
||||
*/
|
||||
ContextId drain();
|
||||
|
||||
/** True while a swap is in flight; drain() refuses to start another. */
|
||||
bool transition_in_flight();
|
||||
|
||||
/** The running context's id, or 0 when the scheduler stack is running. */
|
||||
ContextId current();
|
||||
|
||||
Status status_of( ContextId aId );
|
||||
|
||||
/** Destroy a Finished context and release its stack + buffer. */
|
||||
bool destroy( ContextId aId );
|
||||
|
||||
/**
|
||||
* Registry + memory snapshot as JSON, for tests and the D1 memory gate:
|
||||
* live/peakLive/created/finished, transitions, bytes/peakBytes,
|
||||
* perContextBytes, and asyncify high-water usage (asyncifyHighWater) —
|
||||
* the measurement doc 20 risk 1 asks for so buffer sizes get re-derived
|
||||
* from evidence instead of inherited from libcontext's 512K.
|
||||
*/
|
||||
std::string stats_json();
|
||||
|
||||
/** One line per live context: id, label, status, reason, asyncify usage. */
|
||||
std::string registry_json();
|
||||
|
||||
/** Test hook: reset all counters (does not touch live contexts). */
|
||||
void reset_stats();
|
||||
|
||||
} // namespace pcbjam_sched
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit 24843897e8f381def1fb3e8ac0d02c3f46fc39bc
|
||||
Subproject commit 5ee60a81c4a6b254266f8aa0dc3e4abd712802c3
|
||||
Loading…
Reference in a new issue