/ concept-collection / seqlab
concept-collection / seqlab
seqlab / src / engine / runner.ts
124 lines · 3.9 KBCodeBlameHistory
78d04f1seqlab: write and view pulseq MRI sequences in the browserJeremy Magland 1// Run-per-execution pulseq runner: each run boots a fresh numbl/browser
2// session (no mip, no persistence) with the vendored +mr tree and the user's
3// script staged as main.m, executes it, then collects every .seq file the
4// script wrote into the session cwd. Cancel = dispose the worker, which
5// always works (no cross-origin isolation needed).
6import { createNumblSession, type NumblSession } from 'numbl/browser'
7import { mrBootFiles } from './mrFiles'
9export interface SeqOutputFile {
10 name: string
11 text: string
14export interface RunResult {
15 ok: boolean
16 /** True when the run was cancelled (or timed out) rather than finishing. */
17 aborted?: boolean
18 /** Concatenated console output (also streamed via onOutput). */
19 output: string
20 /** Formatted MATLAB error message when ok is false. */
21 error?: string
22 /** .seq files the script wrote, in directory order. */
23 seqFiles: SeqOutputFile[]
24 elapsedMs: number
27export interface RunHooks {
28 /** Streaming console output from the script. */
29 onOutput?: (text: string) => void
32export interface RunHandle {
33 promise: Promise<RunResult>
34 /** Stop the run by terminating the worker. The promise resolves aborted. */
35 cancel(): void
38const RUN_TIMEOUT_MS = 600_000
40// Lists the .seq files the script produced, on output lines the runner can
41// pick out. Runs in the same workspace after main.m, but the session is
42// disposed right after, so the seqlab_* variables never collide with anything.
43const COLLECT_SNIPPET = `seqlab_d = dir('*.seq');
44for seqlab_i = 1:numel(seqlab_d)
45 fprintf('SEQLAB_FILE:%s\\n', seqlab_d(seqlab_i).name);
46end
49export function runScript(script: string, hooks: RunHooks = {}): RunHandle {
50 let session: NumblSession | null = null
51 let cancelled = false
52 let timeoutId: ReturnType<typeof setTimeout> | undefined
54 const cancel = () => {
55 cancelled = true
56 session?.dispose()
57 }
59 const promise = (async (): Promise<RunResult> => {
60 const t0 = performance.now()
61 let streaming = true
62 const outputs: string[] = []
63 timeoutId = setTimeout(cancel, RUN_TIMEOUT_MS)
64 try {
65 session = await createNumblSession({
66 files: [...mrBootFiles(), { path: 'main.m', content: script }],
67 mip: false,
68 persistSystem: false,
69 optimization: '1',
70 onOutput: (text) => {
71 if (!streaming) return
72 outputs.push(text)
73 hooks.onOutput?.(text)
74 },
75 })
76 // A cancel during boot has no worker to terminate yet — honor it now.
77 if (cancelled) throw new Error('cancelled')
78 // Semicolon: execute uses REPL display semantics, and a bare `main`
79 // would display a spurious `ans` after the script returns.
80 const result = await session.execute('main;')
81 streaming = false
83 // Collect .seq outputs even after an error — the script may have
84 // written some before failing.
85 const seqFiles: SeqOutputFile[] = []
86 const collect = await session.execute(COLLECT_SNIPPET)
87 if (collect.ok) {
88 const names = [...collect.output.matchAll(/^SEQLAB_FILE:(.+)$/gm)].map((m) => m[1])
89 const decoder = new TextDecoder()
90 for (const name of names) {
91 const bytes = await session.readFile(name)
92 seqFiles.push({ name, text: decoder.decode(bytes) })
93 }
94 }
96 return {
97 ok: result.ok,
98 aborted: result.aborted,
99 output: outputs.join(''),
100 error: result.error,
101 seqFiles,
102 elapsedMs: performance.now() - t0,
103 }
104 } catch (err) {
105 if (cancelled) {
106 return {
107 ok: false,
108 aborted: true,
109 output: outputs.join(''),
110 error: 'Run cancelled',
111 seqFiles: [],
112 elapsedMs: performance.now() - t0,
113 }
114 }
115 throw err
116 } finally {
117 clearTimeout(timeoutId)
118 session?.dispose()
119 session = null
120 }
121 })()
123 return { promise, cancel }