velxio/frontend/src/simulation/micropythonSession.ts

523 lines
20 KiB
TypeScript
Raw Normal View History

fix(micropython): upload a project's libraries in bounded steps (#219) A MicroPython library in Velxio is a .py file copied onto the board before main.py runs. It got there inlined into the program as ONE Python string literal: with open("mqtt_as.py",'w') as _f: _f.write("...36 KB of escaped source on one line...") so compiling the program asked for one CONTIGUOUS allocation the size of the file. Issue #219 is that bill coming due on a PSRAM-less ESP32: 36407 bytes of mqtt_as.py, `MemoryError: memory allocation failed, allocating 36410 bytes`. Never the firmware version the thread suspected. The v1.28 bump (bd1613a9) made that single allocation fit, so the reported message is gone — but the mechanism was not, and what it costs today is worse than the error was. Measured on the reporter's own project, on velxio.dev and on staging: 216 s pinned in the raw REPL before the first line ran (the paste went out at 64 bytes per 150 ms, ~426 B/s), and then `OSError: WiFi Out of Memory`, because 36 KB of heap was still held by the compiled literal when esp_wifi came up. The project could not work. Nothing is inlined now. 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 whatever the library weighs. The program is written as a file too and started with execfile, whose lexer streams off flash: a big sketch compiles in the same memory a small one does, and it still runs in __main__ exactly as the pasted code did. Flow control is the other half. One burst is in flight at a time, sized to fit the 128-byte RX FIFO both backends model, and what releases the next is the board's own end-of-execution marker — proof it consumed the last one, instead of a delay chosen by hoping. A step can only overflow one burst on a pathologically long file path, and those few bursts fall back to timed pacing rather than corrupt the stream. The hundreds of protocol exchanges stay out of the serial monitor; a progress line goes in their place, and a board-side failure mid-upload is reported instead of starting a program whose library never arrived. One implementation, shared by the QEMU bridge and the in-browser engines. The hand-ported second copy is why this bug lived in both paths, and the pro overlay now re-exports this module instead of keeping its own. Also fixes a latent corruption: sanitizeForRepl's inline-comment rule used to run over that one-line literal, so a ` #` inside a library file made it strip non-ASCII from everything after it. Library bytes are never tokenized now. Verified on staging with the reporter's project (main.py + mqtt_as.py): upload complete in 36.5 s, then "Got reliable connection / Connecting to broker / publish 0". The WiFi OOM is gone with the heap it was competing for. Not exercised live: the OSS QEMU MicroPython path — velxio.dev routes every supported ESP32 kind to the in-browser engines, so staging cannot reach it. It runs the same session and is covered by the unit tests, and its 800/200 ms handshake timings are preserved rather than unified with the engines' 400/150.
2026-08-22 01:03:24 +07:00
/**
* 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<string>();
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<ReturnType<typeof setTimeout>>();
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<typeof setTimeout> | 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);
}
}