diff --git a/docs/features/async/22-absorbing-libcontext.md b/docs/features/async/22-absorbing-libcontext.md index 4ae2684..1655c02 100644 --- a/docs/features/async/22-absorbing-libcontext.md +++ b/docs/features/async/22-absorbing-libcontext.md @@ -986,6 +986,43 @@ engines, `wakeup_during_transition` layout-sensitive, `modal.spec.ts:125`), with pthread-ondemand green on both engines. The shim's resolveWait retention change is regression-free against every modal/nested/popup wait the battery stages. +### Phase E: K2–K6 converted — the bridge set is COMPLETE (2026-08-08) + +Same pattern as K1, one build, gated per-bridge then confirmed: + +| bridge | file | wait kind | +|---|---|---| +| K2 footprint libs | `kicad/pcbnew/pcb_io/pcbjam_fp/pcb_io_pcbjam_fp.cpp` | `fp-lib` | +| K3 3D model fetch | `kicad/3d-viewer/3d_cache/pcbjam_model_fetch.cpp` | `3d` | +| K4 STEP export | `wasm/stubs/exporter_step_stub.cpp` | `occ` | +| K5 OCE model load | `wasm/stubs/oce_plugin_stub.cpp` | `occ` | +| K6 ngspice ×2 | `wasm/stubs/sharedspice_client.cpp` | `ngspice` | + +Every bridge: `beginWait` → start `EM_JS` (ALL resolutions deferred ≥ a microtask, +result marshalling moved into the resolve callback) → `wxWasmYieldUntil` → result as +the wait's int32 (pointers recovered via `(uint32_t)`). Worker/proxy paths untouched. + +**Gates:** `pcbnew` + `3d-viewer-models` + `occ-export-models` + `eeschema-sim` +**8/8 in 1.8 min** — and the occ-export spec drove **17 real async provider requests +through converted K3 during a live K4 export**, which is the resolve-AFTER-park +coverage the K1 retry noted as thin (test pages resolve early; these two specs +install real providers). Full suite: **139 / 1 (pre-existing occ-probe glb) / 30 — +baseline held.** Battery not rerun: no wx or shim code changed; the stubs link only +into the KiCad apps. + +**Converter-diet hardening (K1+K2):** `sym_convert` keeps `sch_io/` and +`pcb_convert` keeps `pcb_io/`, so those TUs can land in links WITHOUT the wx wasm +port. `wxWasmBeginWait`/`wxWasmYieldUntil` are declared WEAK there with a null guard +returning "request unavailable" — correct, since a converter diet can never have a +`/mnt/pcbjam` provider either. (Same class as the nanosleep lesson above.) + +**Phase E remaining before it is DONE done:** the §5 assertion ("no `handleSleep` +park happens on any fiber stack — assert it") is not built, per-bridge deep-park +high-water beacons for buffer sizing are not collected, and the standalone/web smoke +with the production provider is still owed. The natural next measurement is a D-on +probe: with every bridge a token wait, the `current() != 0` inline fallback window +that D-on tolerated should now be structurally empty. + 1. **pthreads.** Doc 21 §2 settled that every Asyncify park is main-thread and the lib bridge's worker path is a blocking proxy. Phase A must re-check that libcontext is never driven from a worker before assuming the scheduler is main-thread-only. diff --git a/kicad b/kicad index ab1ac9b..72b754b 160000 --- a/kicad +++ b/kicad @@ -1 +1 @@ -Subproject commit ab1ac9b940596a97820eab3b8403f443db1e111a +Subproject commit 72b754b2c5552d7596ad5e5a31721eddb048f53e diff --git a/wasm/stubs/exporter_step_stub.cpp b/wasm/stubs/exporter_step_stub.cpp index 0863296..4a99c5b 100644 --- a/wasm/stubs/exporter_step_stub.cpp +++ b/wasm/stubs/exporter_step_stub.cpp @@ -53,16 +53,28 @@ extern "C" void Pcbjam_SetExportJobJson( const char* aJson ) s_nextJobJson = aJson ? aJson : ""; } -// Suspends pcbnew (Asyncify; the __asyncjs__* import is auto-covered by -// scripts/common/asyncify-imports.txt) while the worker exports. JS returns a -// malloc'd JSON string: { ok, report } — the download already happened there. -EM_ASYNC_JS( char*, js_occExportRequest, - ( const char* aBoardPath, const char* aJobJson, const char* aFileName ), +// Phase E shape (docs/features/async/22 §5, K4): waits for the worker export +// via a token wait (context park when the frame stands on a scheduler context) +// instead of Asyncify-parking the stack in place. The wait result is a +// malloc'd JSON string: { ok, report } — the download already happened in JS. +// Every resolution defers to at least a microtask (the early-resolve +// contract, doc 22 §10 Phase E retry entry). +EM_JS( void, js_occExportStart, + ( int aToken, const char* aBoardPath, const char* aJobJson, const char* aFileName ), { const boardPath = UTF8ToString( aBoardPath ); const jobJson = UTF8ToString( aJobJson ); const fileName = UTF8ToString( aFileName ); - let res; + + const finish = ( res ) => { + const s = JSON.stringify( res || { ok: false, report: 'occ_service: no response' } ); + const n = lengthBytesUTF8( s ) + 1; + const p = _malloc( n ); + stringToUTF8( s, p, n ); + globalThis.__wxScheduler.resolveWait( aToken, p ); + }; + + let req; try { @@ -70,27 +82,30 @@ EM_ASYNC_JS( char*, js_occExportRequest, if( !hook || typeof hook.request !== 'function' ) { - res = { ok: false, report: 'occ_service provider not installed' }; + req = Promise.resolve( { ok: false, report: 'occ_service provider not installed' } ); } else { const board = FS.readFile( boardPath ); // Uint8Array copy — transferable - res = await hook.request( { kind: 'export', board, jobJson, fileName } ); + req = Promise.resolve( hook.request( { kind: 'export', board, jobJson, fileName } ) ); } } catch( e ) { console.error( '[pcbjam-occ] export request failed:', e ); - res = { ok: false, report: 'occ_service request failed: ' + e }; + req = Promise.resolve( { ok: false, report: 'occ_service request failed: ' + e } ); } - const s = JSON.stringify( res || { ok: false, report: 'occ_service: no response' } ); - const n = lengthBytesUTF8( s ) + 1; - const p = _malloc( n ); - stringToUTF8( s, p, n ); - return p; + req.then( finish ).catch( ( e ) => { + console.error( '[pcbjam-occ] export request failed:', e ); + finish( { ok: false, report: 'occ_service request failed: ' + e } ); + } ); } ) +// Token waits live in the wx wasm port (evtloop.cpp). +extern "C" int wxWasmBeginWait( const char* aKind ); +extern "C" int wxWasmYieldUntil( int aToken ); + EXPORTER_STEP::EXPORTER_STEP( BOARD* aBoard, const EXPORTER_STEP_PARAMS& aParams, REPORTER* aReporter ) : @@ -152,8 +167,11 @@ bool EXPORTER_STEP::Export() const wxString downloadName = wxFileName( m_outputFile ).GetFullName(); - char* response = js_occExportRequest( TMP_BOARD, jobJson.c_str(), - downloadName.utf8_string().c_str() ); + const int token = wxWasmBeginWait( "occ" ); + js_occExportStart( token, TMP_BOARD, jobJson.c_str(), downloadName.utf8_string().c_str() ); + + // The malloc'd JSON pointer rides the wait as an int32. + char* response = (char*) (uintptr_t) (uint32_t) wxWasmYieldUntil( token ); bool ok = false; diff --git a/wasm/stubs/oce_plugin_stub.cpp b/wasm/stubs/oce_plugin_stub.cpp index d2e6977..3478316 100644 --- a/wasm/stubs/oce_plugin_stub.cpp +++ b/wasm/stubs/oce_plugin_stub.cpp @@ -58,13 +58,23 @@ bool acceptAnyCacheTag( const char*, void* ) } // namespace -// Suspends pcbnew while the worker parses + tessellates the model. JS returns -// a malloc'd path string: the scenegraph-cache file it wrote into this -// module's MEMFS ("" on failure). -EM_ASYNC_JS( char*, js_occLoadModelRequest, ( const char* aModelPath ), +// Phase E shape (docs/features/async/22 §5, K5): waits for the worker +// parse+tessellate via a token wait instead of Asyncify-parking in place. The +// wait result is a malloc'd path string: the scenegraph-cache file written +// into this module's MEMFS ("" on failure). Every resolution defers to at +// least a microtask (the early-resolve contract, doc 22 §10 Phase E retry). +EM_JS( void, js_occLoadModelStart, ( int aToken, const char* aModelPath ), { const modelPath = UTF8ToString( aModelPath ); - let cachePath = ''; + + const finish = ( cachePath ) => { + const n = lengthBytesUTF8( cachePath ) + 1; + const p = _malloc( n ); + stringToUTF8( cachePath, p, n ); + globalThis.__wxScheduler.resolveWait( aToken, p ); + }; + + let req; try { @@ -73,37 +83,46 @@ EM_ASYNC_JS( char*, js_occLoadModelRequest, ( const char* aModelPath ), if( !hook || typeof hook.request !== 'function' ) { console.error( '[pcbjam-occ] loadModel: occ_service provider not installed' ); + req = Promise.resolve( null ); } else { const bytes = FS.readFile( modelPath ); // Uint8Array copy — transferable const dot = modelPath.lastIndexOf( '.' ); const ext = dot >= 0 ? modelPath.slice( dot + 1 ) : 'step'; - const res = await hook.request( { kind: 'loadModel', bytes, ext } ); - - if( res && res.ok && res.bytes && res.bytes.length ) - { - cachePath = '/tmp/pcbjam_occ_model_cache.3dc'; - FS.writeFile( cachePath, res.bytes ); - } - else if( res && res.report ) - { - console.error( '[pcbjam-occ] loadModel failed:', res.report ); - } + req = Promise.resolve( hook.request( { kind: 'loadModel', bytes, ext } ) ); } } catch( e ) { console.error( '[pcbjam-occ] loadModel request failed:', e ); - cachePath = ''; + req = Promise.resolve( null ); } - const n = lengthBytesUTF8( cachePath ) + 1; - const p = _malloc( n ); - stringToUTF8( cachePath, p, n ); - return p; + req.then( ( res ) => { + let cachePath = ''; + + if( res && res.ok && res.bytes && res.bytes.length ) + { + cachePath = '/tmp/pcbjam_occ_model_cache.3dc'; + FS.writeFile( cachePath, res.bytes ); + } + else if( res && res.report ) + { + console.error( '[pcbjam-occ] loadModel failed:', res.report ); + } + + finish( cachePath ); + } ).catch( ( e ) => { + console.error( '[pcbjam-occ] loadModel request failed:', e ); + finish( '' ); + } ); } ) +// Token waits live in the wx wasm port (evtloop.cpp). +extern "C" int wxWasmBeginWait( const char* aKind ); +extern "C" int wxWasmYieldUntil( int aToken ); + extern "C" { @@ -205,7 +224,11 @@ SCENEGRAPH* oce3d_Load( char const* aFileName ) if( !aFileName ) return nullptr; - char* cachePath = js_occLoadModelRequest( aFileName ); + const int token = wxWasmBeginWait( "occ" ); + js_occLoadModelStart( token, aFileName ); + + // The malloc'd path pointer rides the wait as an int32. + char* cachePath = (char*) (uintptr_t) (uint32_t) wxWasmYieldUntil( token ); if( !cachePath || !*cachePath ) { diff --git a/wasm/stubs/sharedspice_client.cpp b/wasm/stubs/sharedspice_client.cpp index 95bda65..7710ed2 100644 --- a/wasm/stubs/sharedspice_client.cpp +++ b/wasm/stubs/sharedspice_client.cpp @@ -56,69 +56,88 @@ using nlohmann::json; // Generic request: JSON in, JSON out (malloc'd; caller frees). Vector data // never travels this path — see js_ngspice_get_vec. +// +// Phase E shape (docs/features/async/22 §5, K6): token wait instead of an +// in-place Asyncify park; resolution ALWAYS deferred to at least a microtask +// (the early-resolve contract, doc 22 §10 Phase E retry entry). // clang-format off -EM_ASYNC_JS( char*, js_ngspice_request, ( const char* aReqJson ), { - let res; +EM_JS( void, js_ngspice_request_start, ( int aToken, const char* aReqJson ), { + const finish = ( res ) => { + const s = JSON.stringify( res ?? {} ); + const n = lengthBytesUTF8( s ) + 1; + const p = _malloc( n ); + stringToUTF8( s, p, n ); + globalThis.__wxScheduler.resolveWait( aToken, p ); + }; + let req; try { const svc = globalThis.ngspiceService; if( !svc ) - res = { error: 'ngspiceService provider not installed' }; + req = Promise.resolve( { error: 'ngspiceService provider not installed' } ); else - res = await svc.request( JSON.parse( UTF8ToString( aReqJson ) ) ); + req = Promise.resolve( svc.request( JSON.parse( UTF8ToString( aReqJson ) ) ) ); } catch( e ) { - res = { error: String( e ) }; + req = Promise.resolve( { error: String( e ) } ); } - const s = JSON.stringify( res ?? {} ); - const n = lengthBytesUTF8( s ) + 1; - const p = _malloc( n ); - stringToUTF8( s, p, n ); - return p; + req.then( finish ).catch( ( e ) => finish( { error: String( e ) } ) ); } ); // Vector fetch: fills editor-heap buffers directly (no JSON for MB arrays). // aMeta: int[4] = { found, vtype, flags, length }; aReal/aComp receive // malloc'd double buffers (comp interleaved re,im — the ngcomplex_t layout); -// aVName receives a malloc'd name string. Returns non-zero on transport error. -EM_ASYNC_JS( int, js_ngspice_get_vec, - ( const char* aName, int* aMeta, double** aReal, double** aComp, char** aVName ), { - let res; +// aVName receives a malloc'd name string. The wait result is non-zero on +// transport error. All output writes happen in the resolve callback, BEFORE +// resolveWait — the parked caller reads them only after it resumes, the same +// ordering the in-place park had. Phase E shape, resolution always deferred. +EM_JS( void, js_ngspice_get_vec_start, + ( int aToken, const char* aName, int* aMeta, double** aReal, double** aComp, + char** aVName ), { + const finish = ( status ) => globalThis.__wxScheduler.resolveWait( aToken, status ); + let req; try { const svc = globalThis.ngspiceService; - res = svc ? await svc.request( { kind: 'get_vec_info', name: UTF8ToString( aName ) } ) - : { error: 'ngspiceService provider not installed' }; + req = svc ? Promise.resolve( svc.request( { kind: 'get_vec_info', + name: UTF8ToString( aName ) } ) ) + : Promise.resolve( { error: 'ngspiceService provider not installed' } ); } catch( e ) { - res = { error: String( e ) }; + req = Promise.resolve( { error: String( e ) } ); } - HEAP32[aMeta >> 2] = 0; - HEAPU32[aReal >> 2] = 0; - HEAPU32[aComp >> 2] = 0; - HEAPU32[aVName >> 2] = 0; - if( !res || res.error ) - return 1; - if( !res.found ) - return 0; - HEAP32[( aMeta >> 2 ) + 1] = res.vtype | 0; - HEAP32[( aMeta >> 2 ) + 2] = res.flags | 0; - HEAP32[( aMeta >> 2 ) + 3] = res.length | 0; - if( res.real && res.real.length ) { - const p = _malloc( res.real.length * 8 ); - HEAPF64.set( res.real, p >> 3 ); - HEAPU32[aReal >> 2] = p; - } - if( res.comp && res.comp.length ) { - const p = _malloc( res.comp.length * 8 ); - HEAPF64.set( res.comp, p >> 3 ); - HEAPU32[aComp >> 2] = p; - } - const s = res.vname || ''; - const n = lengthBytesUTF8( s ) + 1; - const vp = _malloc( n ); - stringToUTF8( s, vp, n ); - HEAPU32[aVName >> 2] = vp; - HEAP32[aMeta >> 2] = 1; - return 0; + req.catch( ( e ) => ( { error: String( e ) } ) ).then( ( res ) => { + HEAP32[aMeta >> 2] = 0; + HEAPU32[aReal >> 2] = 0; + HEAPU32[aComp >> 2] = 0; + HEAPU32[aVName >> 2] = 0; + if( !res || res.error ) + return finish( 1 ); + if( !res.found ) + return finish( 0 ); + HEAP32[( aMeta >> 2 ) + 1] = res.vtype | 0; + HEAP32[( aMeta >> 2 ) + 2] = res.flags | 0; + HEAP32[( aMeta >> 2 ) + 3] = res.length | 0; + if( res.real && res.real.length ) { + const p = _malloc( res.real.length * 8 ); + HEAPF64.set( res.real, p >> 3 ); + HEAPU32[aReal >> 2] = p; + } + if( res.comp && res.comp.length ) { + const p = _malloc( res.comp.length * 8 ); + HEAPF64.set( res.comp, p >> 3 ); + HEAPU32[aComp >> 2] = p; + } + const s = res.vname || ''; + const n = lengthBytesUTF8( s ) + 1; + const vp = _malloc( n ); + stringToUTF8( s, vp, n ); + HEAPU32[aVName >> 2] = vp; + HEAP32[aMeta >> 2] = 1; + finish( 0 ); + } ); } ); +// Token waits live in the wx wasm port (evtloop.cpp). +extern "C" int wxWasmBeginWait( const char* aKind ); +extern "C" int wxWasmYieldUntil( int aToken ); + // Event dispatcher: provider `{ evt }` frames -> KiCad's registered callbacks // via the exported pcbjam_ngspice_event (fresh wasm entries; see header // comment). Installed once, at first pcbjam_ngSpice_Init. @@ -166,7 +185,11 @@ std::atomic s_bgRunning{ false }; json rpc( const json& aReq ) { - char* raw = js_ngspice_request( aReq.dump().c_str() ); + const int token = wxWasmBeginWait( "ngspice" ); + js_ngspice_request_start( token, aReq.dump().c_str() ); + + // The malloc'd JSON pointer rides the wait as an int32. + char* raw = (char*) (uintptr_t) (uint32_t) wxWasmYieldUntil( token ); json res = json::parse( raw ? raw : "{}", nullptr, /* allow_exceptions */ false ); std::free( raw ); @@ -408,7 +431,10 @@ pvector_info pcbjam_ngGet_Vec_Info( char* aVecName ) double* comp = nullptr; char* vname = nullptr; - if( js_ngspice_get_vec( aVecName ? aVecName : "", meta, &real, &comp, &vname ) != 0 ) + const int token = wxWasmBeginWait( "ngspice" ); + js_ngspice_get_vec_start( token, aVecName ? aVecName : "", meta, &real, &comp, &vname ); + + if( wxWasmYieldUntil( token ) != 0 ) return nullptr; if( !meta[0] )