/ concept-collection / mesh-pde-solver
Sign in
concept-collection / mesh-pde-solver
mesh-pde-solver / src / engine / engine.ts
139 lines · 4.8 KBBlameHistoryRaw
1// The solver engine, built on numbl/browser's managed session: numbl owns
2// the worker, the VFS, the mip bootstrap (main.m does
3// `mip load --install surfacefun`), and IndexedDB persistence of installed
4// packages across page loads. This wrapper adds the app's solve protocol:
5// mesh.msh in the VFS + one 'solve' event in flight at a time, with a
6// timeout.
8import { createNumblSession, type NumblSession } from 'numbl/browser'
9import { PROJECT_FILES, MAIN_FILE } from './project'
10import type { SolveParams, SolutionData } from './protocol'
12const SOLVE_TIMEOUT_MS = 300_000
14export class EngineError extends Error {}
16export class SolverEngine {
17 private session: NumblSession | null = null
18 private compId: string | null = null
19 private disposed = false
20 private pendingMesh: Uint8Array | null = null
22 private solveWaiter: {
23 resolve: (data: SolutionData) => void
24 reject: (err: Error) => void
25 timeoutId: ReturnType<typeof setTimeout>
26 } | null = null
28 /** Boot progress messages (downloads, engine start) for the UI. */
29 onProgress: (message: string) => void = () => {}
30 /** MATLAB console output (mip install logs etc.), for a console panel. */
31 onOutput: (text: string) => void = () => {}
32 /** Hard failures (boot errors) — the engine is unusable afterwards. */
33 onError: (message: string) => void = () => {}
34 /** Last hard failure, for subscribers that attach after it happened. */
35 lastError: string | null = null
37 async start(): Promise<void> {
38 try {
39 const session = await createNumblSession({
40 files: PROJECT_FILES.map((f) => ({ path: f.path, content: f.text })),
41 mainFile: MAIN_FILE,
42 onProgress: (message) => this.onProgress(message),
43 onOutput: (text) => this.onOutput(text),
44 onHtmlSourceEvent: (_compId, name, dataJson) =>
45 this.handleScriptEvent(name, dataJson),
46 })
47 if (this.disposed) {
48 session.dispose()
49 return
50 }
51 this.compId = session.uihtmlComponents[0]?.compId ?? null
52 if (!session.hasUihtmlSession || !this.compId) {
53 session.dispose()
54 throw new EngineError('script finished without a live uihtml session')
55 }
56 this.session = session
57 if (this.pendingMesh) {
58 session.writeFile('mesh.msh', this.pendingMesh)
59 this.pendingMesh = null
60 }
61 } catch (err) {
62 const message = err instanceof Error ? err.message : String(err)
63 this.lastError = message
64 if (!this.disposed) this.onError(message)
65 throw err instanceof Error ? err : new EngineError(message)
66 }
67 }
69 /** Make `bytes` the mesh.msh the next solve reads. */
70 setMesh(bytes: Uint8Array): void {
71 if (this.session) this.session.writeFile('mesh.msh', bytes)
72 else this.pendingMesh = bytes
73 }
75 solve(params: SolveParams): Promise<SolutionData> {
76 if (!this.session || !this.compId) {
77 return Promise.reject(new EngineError('engine not ready'))
78 }
79 if (this.solveWaiter) {
80 return Promise.reject(new EngineError('a solve is already running'))
81 }
82 const dispatched = this.session.dispatchHtmlEvent(this.compId, 'solve', params)
83 return new Promise<SolutionData>((resolve, reject) => {
84 const timeoutId = setTimeout(() => {
85 this.settleSolve((w) => w.reject(new EngineError('solve timed out')))
86 }, SOLVE_TIMEOUT_MS)
87 this.solveWaiter = { resolve, reject, timeoutId }
88 // An interpreter-level dispatch failure (vs. the solveError event the
89 // script sends for caught errors) also settles the solve.
90 dispatched.catch((err) => {
91 this.settleSolve((w) =>
92 w.reject(err instanceof Error ? err : new EngineError(String(err))),
93 )
94 })
95 })
96 }
98 get isReady(): boolean {
99 return this.session !== null
100 }
102 get isBusy(): boolean {
103 return this.solveWaiter !== null
104 }
106 dispose(): void {
107 if (this.disposed) return
108 this.disposed = true
109 this.settleSolve((w) => w.reject(new EngineError('engine disposed')))
110 this.session?.dispose()
111 this.session = null
112 }
114 // ---- internals ---------------------------------------------------------
116 private settleSolve(settle: (w: NonNullable<typeof this.solveWaiter>) => void) {
117 const w = this.solveWaiter
118 if (!w) return
119 this.solveWaiter = null
120 clearTimeout(w.timeoutId)
121 settle(w)
122 }
124 private handleScriptEvent(name: string, dataJson: string) {
125 if (name === 'solution') {
126 this.settleSolve((w) => {
127 try {
128 w.resolve(JSON.parse(dataJson) as SolutionData)
129 } catch (err) {
130 w.reject(new EngineError(`bad solution payload: ${String(err)}`))
131 }
132 })
133 } else if (name === 'solveError') {
134 const message =
135 (JSON.parse(dataJson) as { message?: string }).message ?? 'unknown solver error'
136 this.settleSolve((w) => w.reject(new EngineError(message)))
137 }
138 }
moveopenescclose