pcbjam/wasm/stubs/exporter_step_stub.cpp
Istvan Matejcsok c14e76651c findings(E-5,E-8,E-9): module-identity ngspice events + runWaitCompletion admission gate
E-8 (re-implemented for JSPI — the codex gate is entangled with the dropped
execution owner; under JSPI a fresh non-suspending JS→wasm entry while
another activation is suspended is structurally safe on its own stack, so
the admission boundary for worker completions is liveness + trap state, not
execution ownership):
- jspi-scheduler.js grows `terminal` (trapped instance; distinct from `dead`),
  canTouchNative(), _terminalizeNativeTrap() (WebAssembly.RuntimeError +
  cross-realm string classification), and runWaitCompletion(site, token,
  prepare, inertResult): prepare runs immediately and owns ALL native work;
  stale tokens and dead/terminal instances drop loudly without resolving
  (resolving would resume the parked frame inside the damaged module); a
  trap latches terminal; a plain JS bug resolves inertResult so the wait
  fails instead of stranding. beginWait refuses (token 0) when dead/terminal.
- all four delayed completion sites route their native work through the
  gate: 'OCC export completion' (exporter_step_stub), 'OCC model completion'
  (oce_plugin_stub — the MEMFS cache write moves inside the gate too),
  'ngspice request completion' and 'ngspice vector completion'
  (sharedspice_client — every HEAP32/HEAPF64/malloc write inside prepare,
  inertResult 1 = transport error). Every wxWasmBeginWait caller in the
  stubs bails on token <= 0.
- deliberately NOT ported from codex: ownerModule, enqueueNativeCompletion,
  executionBarrier, the byte-credit native-entry FIFO — completions are
  one-shot per wait token and stream volume is bounded at the E-6 transport
  credit window. Cross-refs logged for group M (M-2/M-6/M-8).

E-5 (re-implemented; codex shape kept, owner APIs replaced with the E-8
gate): js_ngspice_install_events binds the handler to the EXACT installing
module (handler.__pcbjamNgspiceOwnerModule stamp; presence is not identity),
re-installation replaces a foreign module's handler, a superseded handler
disarms itself, native entry goes through installingModule._malloc/
._pcbjam_ngspice_event (never lexical Module), each dispatch checks
canTouchNative() (loud drop on a dead/terminal module), and a trap on the
per-line entry latches the terminal gate.

Tests: scheduler-shim.test.ts +7 (gate happy/stale/dead/terminal/cross-realm/
js-bug/beginWait-refusal). e2e specs updated from the codex line: occ-export
decode-fault recovery (real onmessageerror transition via failDecode, J-4),
ngspice-probe direct-service coverage, eeschema-sim rewritten onto the E-7
applied-generation receipt (codex's executionBarrier await replaced with a
pendingWaits('ngspice') drain poll — the JSPI-line equivalent).

Also bumps the kicad submodule to the E-7/E-9 commit (dd5751038f7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 18:18:21 +02:00

219 lines
7.1 KiB
C++

/*
* WASM shadow of EXPORTER_STEP — the editor build compiles this INSTEAD of
* exporters/step/exporter_step.cpp (no OCC in pcbnew.wasm; see
* docs/features/occ-split/README.md). The class layout comes from the real
* header; only the three symbols other TUs reference are defined here:
* constructor, destructor, Export().
*
* Export() bridges to the occ_service worker: stage the live BOARD as sexpr
* text in MEMFS, ship it + the official JOB_EXPORT_PCB_3D JSON through an
* EM_ASYNC_JS suspend (globalThis.occService.request, installed by the web
* app), and report the outcome. The exported file's bytes never enter this
* module — the JS provider hands them straight to the browser download path.
*
* Callers stay untouched: PCBNEW_JOBS_HANDLER::JobExportStep and the browser
* branch of DIALOG_EXPORT_STEP construct EXPORTER_STEP exactly as on desktop.
*/
#include <cstdlib>
#include <string>
#include <emscripten.h>
#include <wx/filename.h>
#include <wx/string.h>
#include <nlohmann/json.hpp>
#include <board.h>
#include <reporter.h>
#include <jobs/job_export_pcb_3d.h>
#include <pcb_io/kicad_sexpr/pcb_io_kicad_sexpr.h>
#include <exporters/step/exporter_step.h>
// Complete types for the unique_ptr members destroyed in ~EXPORTER_STEP (their
// headers still exist in the tree/sysroot; only the OCC *link* is gone).
#include <exporters/step/step_pcb_model.h>
#include <filename_resolver.h>
namespace
{
const char* const TMP_BOARD = "/tmp/pcbjam_occ_export_board.kicad_pcb";
// Optional side-channel: the dialog seam serializes the FULL job (including
// fields EXPORTER_STEP_PARAMS doesn't carry, e.g. the assembly variant) right
// before calling Export(). Consumed once. When unset (jobs-handler path), the
// job JSON is reconstructed from m_params.
std::string s_nextJobJson;
} // namespace
// One-shot job-JSON override for the next EXPORTER_STEP::Export() call.
extern "C" void Pcbjam_SetExportJobJson( const char* aJson )
{
s_nextJobJson = aJson ? aJson : "";
}
// 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 suspending 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 );
// E-8: all native work (malloc + heap writes) runs inside the scheduler's
// completion gate — a dead or trapped instance drops the completion
// loudly instead of re-entering wasm, and a trap inside the prepare
// latches the instance terminal without resolving the wait.
const finish = ( res ) => {
globalThis.__wxScheduler.runWaitCompletion( 'OCC export completion', aToken, () => {
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;
} );
};
let req;
try
{
const hook = globalThis.occService;
if( !hook || typeof hook.request !== 'function' )
{
req = Promise.resolve( { ok: false, report: 'occ_service provider not installed' } );
}
else
{
const board = FS.readFile( boardPath ); // Uint8Array copy — transferable
req = Promise.resolve( hook.request( { kind: 'export', board, jobJson, fileName } ) );
}
}
catch( e )
{
console.error( '[pcbjam-occ] export request failed:', e );
req = Promise.resolve( { ok: false, report: 'occ_service request failed: ' + e } );
}
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 ) :
m_params( aParams ),
m_reporter( aReporter ),
m_board( aBoard ),
m_platingThickness( 0 )
{
}
EXPORTER_STEP::~EXPORTER_STEP()
{
}
bool EXPORTER_STEP::Export()
{
if( !m_board )
return false;
// Stage the LIVE board (unsaved edits included) as sexpr text in MEMFS.
try
{
PCB_IO_KICAD_SEXPR io;
io.SaveBoard( wxString::FromUTF8( TMP_BOARD ), m_board );
}
catch( const std::exception& e )
{
if( m_reporter )
{
m_reporter->Report( wxString::Format( wxT( "Failed to stage board for export: %s" ),
e.what() ),
RPT_SEVERITY_ERROR );
}
return false;
}
// The official job JSON. Prefer the seam-provided full job; otherwise
// rebuild one from the params we were constructed with.
std::string jobJson;
if( !s_nextJobJson.empty() )
{
jobJson = std::move( s_nextJobJson );
s_nextJobJson.clear();
}
else
{
JOB_EXPORT_PCB_3D job;
job.m_3dparams = m_params;
job.SetStepFormat( m_params.m_Format );
nlohmann::json j;
job.ToJson( j );
jobJson = j.dump();
}
const wxString downloadName = wxFileName( m_outputFile ).GetFullName();
const int token = wxWasmBeginWait( "occ" );
// Token 0 = the scheduler refused the wait (dead or terminal instance):
// never start an RPC whose completion could not be admitted.
if( token <= 0 )
{
if( m_reporter )
m_reporter->Report( wxT( "occ_service: scheduler unavailable" ), RPT_SEVERITY_ERROR );
wxRemoveFile( wxString::FromUTF8( TMP_BOARD ) );
return false;
}
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;
try
{
nlohmann::json res = nlohmann::json::parse( response ? response : "{}" );
ok = res.value( "ok", false );
const std::string report = res.value( "report", std::string() );
if( m_reporter && !report.empty() )
{
m_reporter->Report( wxString::FromUTF8( report.c_str() ),
ok ? RPT_SEVERITY_INFO : RPT_SEVERITY_ERROR );
}
}
catch( ... )
{
if( m_reporter )
m_reporter->Report( wxT( "occ_service: malformed response" ), RPT_SEVERITY_ERROR );
}
std::free( response );
wxRemoveFile( wxString::FromUTF8( TMP_BOARD ) );
return ok;
}