Summary
When sending commands to an ESP32 via MicroPython WebREPL, any command longer than 255 bytes silently times out. The board never executes the command and the connection hangs until the client's socket timeout fires. This manifests as a wifi_timeout error even though the board is reachable and WebREPL is running.
This is not obvious because short commands work perfectly, and there is no error message from the board — it just never responds.
Root Cause
MicroPython's raw REPL mode uses a ~256-byte input buffer. When sending a command over WebREPL, the client breaks the command into WebSocket frames. In this project's implementation, commands are sent in 64-byte chunks (separate WebSocket frames) followed by a Ctrl-D (0x04) frame to trigger execution.
- A 255-byte command → 4 chunks (64 + 64 + 64 + 63 bytes) → Ctrl-D arrives → works
- A 256+-byte command → 5+ chunks → the 5th chunk (and Ctrl-D) never gets processed → hangs
The ESP32 fills its 256-byte buffer and stops reading. Ctrl-D never arrives. The board waits forever for more input while the client waits forever for a response.
This is not a WebSocket frame size issue. Large binary frames work fine (e.g. OTA uploads use 1024-byte chunks). The limit is specific to the raw REPL input buffer.
Isolation Testing
We isolated the cause through binary-search testing using webrepl_exec directly on the Pi (same network path as production):
| Command |
Size |
Result |
print(1) |
9 bytes |
✅ works |
import network; w=WLAN; print(w.isconnected()) |
62 bytes |
✅ works |
import json,network,os + ifconfig + statvfs + json.dumps |
150 bytes |
✅ works |
import json,sys,network,os + dhcp_hostname + firmware + statvfs |
255 bytes |
✅ works |
Same + gc.mem_free() (no gc.collect) |
280 bytes |
❌ timeout |
Same + gc.collect() + gc.mem_free() |
293 bytes |
❌ timeout |
| Full multi-line STATUS_SCRIPT |
755 bytes |
❌ timeout |
The cutoff is between 255 and 280 bytes. 255 bytes = exactly 4 × 64-byte chunks. 280 bytes = 5 chunks. The threshold aligns precisely with the 256-byte buffer boundary.
We also confirmed gc.collect() alone was not the cause — removing it still timed out at 280 bytes. It's purely the byte count.
Fix
Add a WiFi-specific script (STATUS_SCRIPT_WIFI) that stays within the 256-byte limit. The key techniques:
os.uname()[2] instead of ".".join(str(x) for x in sys.version_info[:3]) — gives the MicroPython release version (e.g. 1.27.0) and saves ~17 bytes
network.WLAN(0) instead of network.WLAN(network.STA_IF) — STA_IF == 0, saves 14 bytes
- Inline all values directly in
json.dumps() instead of assigning intermediate variables
- Drop
gc/free_memory — import gc + gc.mem_free() adds ~36 bytes and pushes over the limit
- No
sys import needed without the version_info approach
Final script (240 bytes, 16 bytes of headroom):
STATUS_SCRIPT_WIFI = (
'import json,network,os;W=network.WLAN(0);c=W.isconnected();s=os.statvfs("/");'
'print(json.dumps({"firmware":os.uname()[2],"wifi_connected":c,'
'"ip_address":W.ifconfig()[0]if c else"",'
'"board":W.config("dhcp_hostname"),"free_storage":s[0]*s[3]}))'
)
This is only used on the WiFi path. The USB path continues to use the full multi-line STATUS_SCRIPT (no size constraint over serial).
Fix committed in 4f60d4c.
Affected Versions
Tested on:
- MicroPython v1.27.0 on ESP32-S3
- Python 3.x client on Raspberry Pi
General Advice for Other Projects
If you're sending commands to an ESP32 via WebREPL and hitting silent timeouts:
- Measure your command in bytes (
len(command.encode()))
- Keep WiFi commands under 256 bytes
- Use
os.uname()[2] instead of sys.version_info for firmware version
- Use
WLAN(0) instead of WLAN(network.STA_IF)
- Inline values in
json.dumps() rather than assigning variables first
- If you need
gc.mem_free(), add it as a separate WebREPL call
Summary
When sending commands to an ESP32 via MicroPython WebREPL, any command longer than 255 bytes silently times out. The board never executes the command and the connection hangs until the client's socket timeout fires. This manifests as a
wifi_timeouterror even though the board is reachable and WebREPL is running.This is not obvious because short commands work perfectly, and there is no error message from the board — it just never responds.
Root Cause
MicroPython's raw REPL mode uses a ~256-byte input buffer. When sending a command over WebREPL, the client breaks the command into WebSocket frames. In this project's implementation, commands are sent in 64-byte chunks (separate WebSocket frames) followed by a Ctrl-D (
0x04) frame to trigger execution.The ESP32 fills its 256-byte buffer and stops reading. Ctrl-D never arrives. The board waits forever for more input while the client waits forever for a response.
This is not a WebSocket frame size issue. Large binary frames work fine (e.g. OTA uploads use 1024-byte chunks). The limit is specific to the raw REPL input buffer.
Isolation Testing
We isolated the cause through binary-search testing using
webrepl_execdirectly on the Pi (same network path as production):print(1)import network; w=WLAN; print(w.isconnected())import json,network,os+ ifconfig + statvfs + json.dumpsimport json,sys,network,os+ dhcp_hostname + firmware + statvfsgc.mem_free()(no gc.collect)gc.collect()+gc.mem_free()The cutoff is between 255 and 280 bytes. 255 bytes = exactly 4 × 64-byte chunks. 280 bytes = 5 chunks. The threshold aligns precisely with the 256-byte buffer boundary.
We also confirmed
gc.collect()alone was not the cause — removing it still timed out at 280 bytes. It's purely the byte count.Fix
Add a WiFi-specific script (
STATUS_SCRIPT_WIFI) that stays within the 256-byte limit. The key techniques:os.uname()[2]instead of".".join(str(x) for x in sys.version_info[:3])— gives the MicroPython release version (e.g.1.27.0) and saves ~17 bytesnetwork.WLAN(0)instead ofnetwork.WLAN(network.STA_IF)—STA_IF == 0, saves 14 bytesjson.dumps()instead of assigning intermediate variablesgc/free_memory—import gc+gc.mem_free()adds ~36 bytes and pushes over the limitsysimport needed without the version_info approachFinal script (240 bytes, 16 bytes of headroom):
This is only used on the WiFi path. The USB path continues to use the full multi-line
STATUS_SCRIPT(no size constraint over serial).Fix committed in 4f60d4c.
Affected Versions
Tested on:
General Advice for Other Projects
If you're sending commands to an ESP32 via WebREPL and hitting silent timeouts:
len(command.encode()))os.uname()[2]instead ofsys.version_infofor firmware versionWLAN(0)instead ofWLAN(network.STA_IF)json.dumps()rather than assigning variables firstgc.mem_free(), add it as a separate WebREPL call