wasm: WebUSB/WASM CH341A flash programmer support#1
Conversation
Core changes against original c590b6a: spi_nor_flash.c: - snor_wait_ready: WASM polling at 100ms intervals (WebUSB latency) - snor_read: 4096-byte chunking for WebUSB transfer limits - snor_read_sr/rg/devid: combine opcode write+response read in a single ch341a_spi_send_command() call to prevent stale IN buffer corruption - Suppress per-chunk progress output (#ifndef __EMSCRIPTEN__) ch341a_spi.c: - WASM usb_transfer: row-by-row chunked OUT+IN USB transfers - Write-only shortcut: chunked 32-byte SPI packets with per-chunk IN FIFO drain to prevent endpoint backpressure - enable_pins WASM: 4-byte simple UIO (DIR_ALL_OUTPUT) - Static buffers (em_safe_wbuf/rbuf) for WASM transport - #ifndef guards around native async libusb code flashcmd_api.c: - EEPROM ifdef guards for WASM build compatibility Web frontend (web/): - CMakeLists.txt for emscripten/Asyncify build - libusb-webusb.js: WebUSB shim replacing native libusb - app.js, main.js, index.html: web UI - web_main.c: C entry point for WASM exports
themactep
left a comment
There was a problem hiding this comment.
Review: WASM/WebUSB CH341A flash programmer support
Bug: enable_pins WASM path ignores the "disable" case
File: src/ch341a_spi.c — Severity: Medium
In the WASM path of enable_pins(), the DIR command always sets CH341A_UIO_DIR_ALL_OUTPUT, regardless of the enable parameter:
#ifdef __EMSCRIPTEN__
CH341A_CMD_UIO_STM_DIR | CH341A_UIO_DIR_ALL_OUTPUT, // <-- always ALL_OUTPUT
#else
CH341A_CMD_UIO_STM_DIR | (enable ? CH341A_UIO_DIR_ALL_OUTPUT : CH341A_UIO_DIR_INPUT),
#endifThe native path correctly sets CH341A_UIO_DIR_INPUT when disabling. This means enable_pins(false) (called from ch341a_spi_shutdown()) leaves the CH341A's GPIO pins in output mode instead of high-impedance/input on the WASM build. While the subsequent libusb_close/WebUSB disconnect usually resets device state, chips that maintain pin state across interface release could behave unexpectedly after the programmer is "shut down."
Bug: wasmCall busy-wait has no timeout — can permanently hang the UI
File: web/src/app.js:34-43 — Severity: Medium
async function wasmCall(name, returnType, argTypes, args) {
while (wasmBusy) {
await new Promise(function(r) { setTimeout(r, 50); });
}If a prior WASM operation hangs (USB timeout, device disconnect mid-operation, etc.), wasmBusy stays true forever. All subsequent operations silently spin forever — the UI becomes permanently non-functional with no indication to the user. The USB disconnect listener only logs to console.
A timeout on the spin-lock OR clearing wasmBusy in the disconnect handler would prevent this.
Bug: snor_wait_ready WASM timeout is dramatically shorter than native
File: src/spi_nor_flash.c:84-105 — Severity: Low-Medium (depends on flash chip)
Native path: (sleep_ms + 1) * 1000 iterations × 500µs = up to ~500×(sleep_ms+1) ms.
WASM path: (sleep_ms < 100 ? 10 : sleep_ms + 30) iterations × 100ms = up to 100×(sleep_ms+30) ms max.
For sleep_ms=950 (used in snor_erase_sector):
- Native: ~475 seconds max wait
- WASM: ~98 seconds max wait
This means erase operations in the browser may time out on slower chips. The 100ms sleep intervals are reasonable for WebUSB but the iteration count should compensate to match the native timeout window.
Bug: snor_read WASM 4096-byte chunking has fragile null-check for len
File: src/spi_nor_flash.c:1110-1155
In the WASM read chunking, len is set to -1 (i.e., 0xFFFFFFFF) on SPI read failure, and the code has:
if (len == (unsigned long)-1) break;But this check is inside the else block (sector-boundary case), and in the non-boundary case the chunk_remain loop continues even after len is set to -1. The outer while(remain_len > 0) loop will iterate with remain_len potentially still > 0, but subsequent SPI_CONTROLLER_Read_NByte calls will pass a bogus buffer offset. It works out because the read function will likely also fail, but the control flow is fragile.
Weak erase verification
File: web/src/app.js:491-530 — Severity: Low
The doErase function only verifies the first 256 bytes after erasing. If a chip has bad sectors that fail to erase at higher addresses, the verification passes and the user gets a false "success" message.
Minor: Redundant extern declaration
File: src/ch341a_spi.c:359 and src/ch341a_spi.c:452
extern int usb_clear_halt(void *handle_ptr, int endpoint); appears twice in the same file.
Minor: Dead else if in write verification
File: web/src/app.js:434-436
if (verifyOk) log('Verify: OK - data matches');
else if (verifyOk === false && verifyOffset >= writeLen) {
/* already logged */
}The else if condition can never be true — when verifyOk is set to false inside the loop, execution always breaks without incrementing verifyOffset to writeLen.
Minor: package-lock.json in the diff
The PR includes a 1088-line package-lock.json addition. Lockfiles should generally be committed, but they're noisy in reviews. If not already done, consider committing this separately or calling it out in the PR description.
- enable_pins WASM: use DIR_INPUT on disable (matching native path) - snor_wait_ready: increase WASM timeout iterations to match native (sleep_ms*5+5 instead of sleep_ms+30, ~475s at sleep_ms=950) - wasmCall: add 60s busy-wait timeout, clear wasmBusy on USB disconnect - snor_read: clean up ifdef nesting for clarity (no functional change) - erase verification: check 64KB instead of 256 bytes - Remove duplicate extern usb_clear_halt declarations - Remove dead else-if in write verification
- JS: if erase completes in < 3 seconds, show an error telling user to re-seat the flash chip and reload the page - C: in full_erase_chip, verify WEL is set after WREN before sending BE command. If WEL not set, reinit CH341A and retry WREN (matching the pattern already used in snor_erase_sector)
Summary
Adds WebAssembly/WebUSB support so scriba runs in the browser without driver installation. Uses the same C codebase compiled to WASM via Emscripten, with a WebUSB JavaScript shim replacing native libusb.
Changes
Core C changes (
src/)ch341a_spi.c: WASM transport path vialibusb_bulk_transferwith row-by-row chunked OUT+IN transfers; write-only shortcut with per-chunk IN FIFO drain to prevent endpoint backpressure;enable_pinsWASM variant; static buffers for WASM;#ifndef __EMSCRIPTEN__guards around native async libusb codespi_nor_flash.c: WASM status polling at 100ms intervals (WebUSB latency); 4096-byte read chunking; combined opcode write+response read viach341a_spi_send_command()to prevent stale IN buffer corruption; progress output suppressed in WASM buildflashcmd_api.c: EEPROM#ifdefguards for WASM compatibilityWeb frontend (
web/)src/web_main.c: C entry point exporting scriba API to JavaScriptsrc/libusb-webusb.js: WebUSB shim implementing libusb API via browser WebUSBsrc/app.js: UI logic — connect, detect, read, write, erase, verifyCMakeLists.txt: Emscripten/Asyncify build configurationindex.html,vite.config.js,package.json: Vite-based web appDocs
README.md: Build and deploy instructions for the web versionTesting
Tested with CH341A programmer and GD25Q128CSIG (16MB NOR flash). Chip detection, full chip erase (~60s), 8MB write + verify all pass. CLI version verified unaffected by
#ifdefguards.