/** * micropythonSession — boot a MicroPython board to its raw REPL and put a whole * project on it: every auxiliary .py file onto the board filesystem, then the * program itself. * * Why this exists (issue #219). The old path pasted the ENTIRE project into ONE * raw-REPL compile unit, with each library file inlined as a single Python * string literal: * * with open("mqtt_as.py",'w') as _f: * _f.write("...36 KB of escaped source on one line...") * * A 36 KB library therefore asked MicroPython for one CONTIGUOUS 36 KB * allocation on the ~100 KB heap of a PSRAM-less ESP32, and the board answered * `MemoryError: memory allocation failed, allocating 36410 bytes` — the file's * own size, to within a few bytes. It was never the firmware version. * * Here nothing is inlined. Each file is written by a SEQUENCE of small * executions (`open` / `write(b'...')` x N / `close`) — the shape mpremote's * `fs cp` uses against real hardware — so peak guest RAM is a few dozen bytes * per step no matter how big the library is. The program itself is written as * a file too and started with `execfile`, whose lexer streams from flash: a * 100 KB sketch compiles in the same bounded memory a 100-byte one does, and it * still runs in the `__main__` namespace, exactly like the pasted code did. * * Flow control is the second half of the fix. The old code pushed 64 bytes * every 150 ms and hoped the guest had drained its UART RX FIFO in between — * ~426 B/s, so this project's library alone would have taken 100 s to transfer * even if the memory had held. Instead every step is one burst small enough to * fit the 128-byte RX FIFO both backends model, followed by a wait for the * device's own end-of-execution marker. That marker is proof the guest consumed * the burst, so there is never a second burst in flight and never a delay that * is not earned. On the in-browser engines the serial callback fires INSIDE the * step loop, so the next step is injected mid-tick and the whole upload runs at * emulated-CPU speed rather than at browser-frame speed. * * The session is backend-agnostic: it needs a sink into UART0 RX and a tap on * everything the guest prints. The OSS QEMU bridge and the pro in-browser * engines share this one implementation — the two hand-ported copies that drift * apart are what let this bug live in both. */ /** One file to materialise on the board filesystem. */ export interface MpyFile { /** Path on the board, e.g. `mqtt_as.py` or `umqtt/simple.py`. */ name: string; content: string; } export interface MpyProgram { /** Libraries and other modules the program imports. */ files: MpyFile[]; /** The program to run (WiFi/stub prelude + the user's main.py). */ main: string; } export type MicroPythonSessionState = | 'idle' | 'banner_seen' | 'prompt_seen' | 'uploading' | 'running' | 'failed'; export interface MicroPythonSessionOptions { /** Log prefix, e.g. 'Esp32JsBridge:board-1'. */ tag?: string; /** Delay before the \r poke once the banner is seen (default 400 ms). */ pokeDelayMs?: number; /** Delay before Ctrl-A / the first step on a stage change (default 150 ms). */ stageDelayMs?: number; /** * Largest burst pushed into the guest's RX in one go, Ctrl-D included. * * The ceiling is the 128-byte RX FIFO QEMU models and the mod-128 MEM_RX_STATUS * pointers the engines keep; overshoot either and the stream is silently * corrupted. The default stays at the 64 every backend has been shipping and * proven at, because raising it buys almost nothing here: only ONE burst is * ever in flight, so throughput is set by the round trip, not the burst size. */ burstBytes?: number; /** How long one step may go unanswered before the upload is failed (default 20 s). */ stepTimeoutMs?: number; /** Progress / error lines for the serial console. */ onNotice?: (line: string) => void; /** Called once the program has been started. */ onRunning?: () => void; /** Called if the upload could not complete; the program never starts. */ onFailed?: (reason: string) => void; } /** The file the program is written to and started from. */ export const MAIN_MODULE_PATH = '_vlx_main.py'; /** * Gap between the bursts of a single oversized step. Only the fallback path in * pump() uses it: with no end-of-execution marker to wait on mid-statement, * this is the same blind pacing the uploader used everywhere before #219. */ const OVERFLOW_BURST_DELAY_MS = 150; const CTRL_A = 0x01; const CTRL_D = 0x04; const CR = 0x0d; /** * Sanitize MicroPython source before it is compiled by the board. * * MicroPython's tokenizer is byte-oriented and the ESP32 build has no wide * unicode, so a multi-byte UTF-8 sequence in a comment (Spanish accents, say) * makes it report a SyntaxError on an unrelated line. Only comments are * rewritten — non-ASCII string literals would fail on the real board too, and * identifiers must be ASCII either way. * * This applies to SOURCE the board compiles. Library files travel as `bytes` * literals and are never tokenized on the way in, so they are left untouched: * the old code ran this over the one-line literal holding a whole library and * its inline-comment rule could eat from a `#` inside the file to the end of * it. */ export function sanitizeForRepl(code: string): string { // 1. Strip UTF-8 BOM if present let s = code.startsWith('') ? code.slice(1) : code; // 2. Normalize line endings to LF s = s.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); // 3. Replace non-ASCII in line-comments with '?' so the line is preserved s = s.replace(/^([ \t]*#.*)$/gm, (line) => line.replace(/[^\x00-\x7F]/g, '?')); // 4. Replace non-ASCII in inline comments (after code on the same line) s = s.replace(/([ \t]+#.*)$/gm, (comment) => comment.replace(/[^\x00-\x7F]/g, '?')); return s; } /** Python single-quoted string literal, ASCII-safe. */ function pyStr(s: string): string { return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; } /** How many source characters one byte costs inside a b'...' literal. */ function byteCost(b: number): number { if (b === 0x27 /* ' */ || b === 0x5c /* \ */) return 2; if (b >= 0x20 && b < 0x7f) return 1; return 4; // \xNN } function appendByte(out: string[], b: number): void { if (b === 0x27 || b === 0x5c) out.push('\\' + String.fromCharCode(b)); else if (b >= 0x20 && b < 0x7f) out.push(String.fromCharCode(b)); else out.push('\\x' + b.toString(16).padStart(2, '0')); } interface Step { /** The statement to execute on the board. */ code: string; /** Human-readable, for the failure message. */ label: string; } /** * Split `bytes` into `_vf.write(b'...')` steps, each of which fits in one * burst. Packing is greedy on the ENCODED length rather than a fixed byte * count: a line of ordinary Python costs one character per byte, a newline * costs four, and sizing on the encoded form is what keeps the burst under the * FIFO for both. */ function writeSteps(bytes: Uint8Array, path: string, burstBytes: number): Step[] { const steps: Step[] = []; const overhead = "_vf.write(b'')".length + 1; // + Ctrl-D const budget = Math.max(16, burstBytes - overhead); let i = 0; while (i < bytes.length) { const parts: string[] = []; let cost = 0; while (i < bytes.length) { const c = byteCost(bytes[i]); if (cost + c > budget) break; appendByte(parts, bytes[i]); cost += c; i++; } steps.push({ code: `_vf.write(b'${parts.join('')}')`, label: path }); } return steps; } /** Directories that must exist before these files can be opened, parents first. */ function dirsFor(files: MpyFile[]): string[] { const dirs = new Set(); for (const f of files) { const segs = f.name.split('/').slice(0, -1); for (let i = 1; i <= segs.length; i++) dirs.add(segs.slice(0, i).join('/')); } return [...dirs].sort(); } export class MicroPythonSession { private buffer = ''; private _state: MicroPythonSessionState = 'idle'; private readonly timers = new Set>(); private disposed = false; private readonly tag: string; private readonly pokeDelayMs: number; private readonly stageDelayMs: number; private readonly burstBytes: number; private readonly stepTimeoutMs: number; private readonly onNotice?: (line: string) => void; private readonly onRunning?: () => void; private readonly onFailed?: (reason: string) => void; private steps: Step[] = []; private stepIndex = 0; /** Ctrl-D markers seen since the running step was handed to the board. */ private eotSeen = 0; /** Text between the first and second Ctrl-D: the board's traceback, if any. */ private errText = ''; private stepTimer: ReturnType | null = null; /** Bytes of the running step not yet pushed (only ever non-empty on the * oversized-step fallback in pump()). */ private outbox: number[] = []; /** Characters of the board's `>`+`OK` still to swallow after the handover. */ private okToSwallow = 0; private startedAt = 0; private totalBytes = 0; private readonly sendSerialBytes: (bytes: number[]) => void; private readonly program: MpyProgram; constructor( sendSerialBytes: (bytes: number[]) => void, program: MpyProgram, opts: MicroPythonSessionOptions = {}, ) { this.sendSerialBytes = sendSerialBytes; this.program = program; this.tag = opts.tag ?? 'MicroPythonSession'; this.pokeDelayMs = opts.pokeDelayMs ?? 400; this.stageDelayMs = opts.stageDelayMs ?? 150; this.burstBytes = opts.burstBytes ?? 64; this.stepTimeoutMs = opts.stepTimeoutMs ?? 20_000; this.onNotice = opts.onNotice; this.onRunning = opts.onRunning; this.onFailed = opts.onFailed; } get state(): MicroPythonSessionState { return this._state; } /** Cancel everything pending (SoC restart / disconnect). Terminal. */ dispose(): void { this.disposed = true; for (const t of this.timers) clearTimeout(t); this.timers.clear(); if (this.stepTimer) clearTimeout(this.stepTimer); this.stepTimer = null; } /** * Feed everything the guest prints. Returns the part of it the serial console * should show: the boot banner and the program's own output pass through, the * upload's protocol chatter (hundreds of `OK`/Ctrl-D/`>` exchanges) does not. */ feed(text: string): string { if (this.disposed) return text; if (this._state === 'failed') return text; if (this._state === 'running') { // The program owns the stream now. Two leftovers of the protocol are // still worth hiding: the `OK` the board prints to acknowledge the // statement that started it, and the Ctrl-D pair it prints when the // program finally returns. let out = ''; for (const ch of text) { // '>' is the previous step's prompt, 'OK' the board acknowledging the // one that starts the program. Both land after the handover. if (this.okToSwallow > 0 && (ch === '>' || ch === 'O' || ch === 'K')) { this.okToSwallow--; continue; } this.okToSwallow = 0; if (ch !== '\x04') out += ch; } return out; } if (this._state === 'uploading') { this.consumeUploadOutput(text); return ''; } this.buffer += text; // From Ctrl-A onward the board is answering the handshake, not the user: // 'raw REPL; CTRL-B to exit' and its prompt are ours. Hiding the whole // banner beats hiding half of it — the match completes mid-word, so // showing up to that point left a truncated 'raw REP' in the monitor. const visible = this._state === 'prompt_seen' ? '' : text; // Stage 1: banner seen -> poke \r. The QEMU chardev holds the '>>> ' prompt // (it has no trailing newline) until another write flushes it, so over there // this poke is what makes the prompt appear at all. // // It is NOT harmless once the prompt has arrived on its own. The in-browser // engines deliver serial per byte, so '>>>' follows the banner inside the // SAME feed(): stage 2 arms Ctrl-A at +150 ms while this timer is still // pending at +400 ms, and the poke then lands 250 ms AFTER Ctrl-A — that is, // inside the code stream. MicroPython reads a lone \r as a newline, so it // cut a source line in half and every sketch died with a SyntaxError on a // line the user never wrote. Hence the guard: poke only while the prompt is // still missing. if (this._state === 'idle' && this.buffer.includes('Type "help()"')) { this._state = 'banner_seen'; this.later(() => { if (this._state !== 'banner_seen') return; // prompt already arrived this.sendSerialBytes([CR]); }, this.pokeDelayMs); } // Stage 2: interactive '>>>' prompt -> enter raw REPL with Ctrl-A. if (this._state === 'banner_seen' && this.buffer.includes('>>>')) { this._state = 'prompt_seen'; this.buffer = ''; this.later(() => this.sendSerialBytes([CTRL_A]), this.stageDelayMs); } // Stage 3: 'raw REPL; CTRL-B to exit' confirmed -> start uploading. The // state moves NOW, not when beginUpload() fires: the board keeps printing // through the stage delay, and a second match would queue a second upload. if (this._state === 'prompt_seen' && this.buffer.includes('raw REPL')) { this._state = 'uploading'; this.buffer = ''; this.later(() => this.beginUpload(), this.stageDelayMs); return ''; } // Keep the buffer bounded while the guest boots. if (this.buffer.length > 8192) this.buffer = this.buffer.slice(-1024); return visible; } // ── Upload ──────────────────────────────────────────────────────────────── private beginUpload(): void { if (this.disposed) return; const enc = new TextEncoder(); const steps: Step[] = []; // A packaged module ("umqtt/simple.py" — how micropython-lib ships // umqtt.simple) needs its directory first: MicroPython's open() does not // mkdir, and without this the upload died with ENOENT. for (const d of dirsFor(this.program.files)) { steps.push({ code: `try:\n __import__('os').mkdir(${pyStr(d)})\nexcept OSError:pass`, label: `mkdir ${d}`, }); } const files: MpyFile[] = [ ...this.program.files, { name: MAIN_MODULE_PATH, content: sanitizeForRepl(this.program.main) }, ]; for (const f of files) { const bytes = enc.encode(f.content); this.totalBytes += bytes.length; steps.push({ code: `_vf=open(${pyStr(f.name)},'wb')`, label: f.name }); steps.push(...writeSteps(bytes, f.name, this.burstBytes)); steps.push({ code: '_vf.close()', label: f.name }); } // execfile streams the source off the filesystem instead of holding it in // RAM, and runs it in these globals — so a 100 KB sketch compiles in the // same memory a 100-byte one does, and the program still sees // __name__ == '__main__', exactly as it did when it was pasted. // // Every ESP32 build ships execfile, but the name is a build option, and // falling back is two lines: the guard covers only the NAME lookup, so a // NameError raised by the program itself is still the program's. steps.push({ code: "_r=getattr(__import__('builtins'),'execfile',None)", label: 'run', }); steps.push({ code: 'if _r is None:_r=lambda p:exec(open(p).read(),globals())', label: 'run', }); steps.push({ code: `_r(${pyStr(MAIN_MODULE_PATH)})`, label: 'run' }); this.steps = steps; this.stepIndex = 0; this._state = 'uploading'; this.startedAt = Date.now(); const n = this.program.files.length; if (n > 0) { this.notice( `[velxio] uploading ${n} file${n === 1 ? '' : 's'} to the board filesystem ` + `(${this.totalBytes} bytes, ${steps.length} steps)`, ); } console.log(`[${this.tag}] raw REPL confirmed - ${steps.length} steps, ${this.totalBytes} bytes`); this.sendStep(); } /** Hand the current step to the board and start its answer's deadline. */ private sendStep(): void { if (this.disposed || this._state !== 'uploading') return; const step = this.steps[this.stepIndex]; this.eotSeen = 0; this.errText = ''; this.outbox = Array.from(new TextEncoder().encode(step.code)); // Arm the deadline BEFORE pushing. A backend that answered inside the send // would otherwise complete this step, start the next one, and then have // this line overwrite the new step's deadline with one already spent. if (this.stepTimer) clearTimeout(this.stepTimer); this.stepTimer = setTimeout(() => { this.fail(`the board stopped answering while writing ${step.label}`); }, this.stepTimeoutMs); this.pump(); } /** * Push the current step out, then Ctrl-D to execute it. * * Every step this builds fits in one burst, which is the whole point: one * burst in flight, and the board's answer is what releases the next. A step * can only overflow on a pathologically long file path (the `open` line * carries it verbatim), and rather than let that corrupt the stream it falls * back to the old timed pacing for those few bursts alone. */ private pump(): void { if (this.disposed || this._state === 'failed') return; if (this.outbox.length <= this.burstBytes) { const last = this.outbox; this.outbox = []; this.sendSerialBytes([...last, CTRL_D]); return; } this.sendSerialBytes(this.outbox.slice(0, this.burstBytes)); this.outbox = this.outbox.slice(this.burstBytes); this.later(() => this.pump(), OVERFLOW_BURST_DELAY_MS); } /** * Watch the board's answer to the running step. In raw REPL it replies `OK`, * then the statement's output, then Ctrl-D, then the traceback (empty when it * succeeded), then Ctrl-D and the `>` prompt. Two Ctrl-Ds mean the guest has * consumed the whole burst and is ready — the only signal worth pacing on. */ private consumeUploadOutput(text: string): void { // Nothing is in flight between stage 3 and the first step; the board is // only finishing its raw-REPL banner. if (this.steps.length === 0) return; for (const ch of text) { if (ch === '\x04') { this.eotSeen++; if (this.eotSeen === 2) { this.finishStep(); return; } } else if (this.eotSeen === 1) { this.errText += ch; } } } private finishStep(): void { if (this.stepTimer) { clearTimeout(this.stepTimer); this.stepTimer = null; } const step = this.steps[this.stepIndex]; const err = this.errText.trim(); if (err) { this.fail(`${step.label}: ${err.split('\n').pop() ?? err}`); return; } this.stepIndex++; if (this.stepIndex >= this.steps.length) { // Cannot happen: the last step is execfile, which hands over below. this._state = 'running'; return; } if (this.stepIndex === this.steps.length - 1) { // Everything is on the filesystem; the next step starts the program and // its output belongs to the user, not to us. const ms = Date.now() - this.startedAt; if (this.program.files.length > 0) { this.notice(`[velxio] upload complete in ${(ms / 1000).toFixed(1)}s - starting program`); } this.outbox = Array.from(new TextEncoder().encode(this.steps[this.stepIndex].code)); this._state = 'running'; this.okToSwallow = 3; this.pump(); this.onRunning?.(); return; } this.sendStep(); } private fail(reason: string): void { if (this.stepTimer) { clearTimeout(this.stepTimer); this.stepTimer = null; } this._state = 'failed'; console.error(`[${this.tag}] upload failed: ${reason}`); this.notice(`[velxio] could not load the project onto the board - ${reason}`); this.onFailed?.(reason); } private notice(line: string): void { this.onNotice?.(`\r\n${line}\r\n`); } private later(fn: () => void, ms: number): void { if (this.disposed) return; const t = setTimeout(() => { this.timers.delete(t); if (!this.disposed) fn(); }, ms); this.timers.add(t); } }