Skip to content

Don't spend an in-flight credit when nothing was sent - #488

Merged
mathieucarbou merged 2 commits into
ESP32Async:mainfrom
MitchBradley:FixInFlightCreditStall
Oct 12, 2026
Merged

mathieucarbou merged 2 commits into
ESP32Async:mainfrom
MitchBradley:FixInFlightCreditStall

Conversation

@MitchBradley

Copy link
Copy Markdown

Problem

With ASYNCWEBSERVER_USE_CHUNK_INFLIGHT enabled (the default), a chunked or unknown-length response can stall permanently if its source isn't ready on a couple of consecutive calls.

AsyncAbstractResponse::write_send_buffs() takes an in-flight credit at the end of the content stage on every call:

    _in_flight += payloadlen;
    --_in_flight_credit;  // take a credit

It does this even when _fillBufferAndProcessTemplates() returned RESPONSE_TRY_AGAIN and payloadlen is 0. Credits come back only with acks of sent data (len > 0); polls never return one. So:

  1. The response starts with 2 credits.
  2. Each call that finds the source empty spends one, and nothing is sent, so no ack will ever return it.
  3. After two such calls, _in_flight_credit is 0. Every later poll hits the !_in_flight_credit early return, the user callback is never called again, and the response hangs until the connection times out.

This hits any response whose producer can be momentarily slower than the connection drains: a chunked response fed by another task, for example.

How it showed up

In FluidNC, HTTP commands run on another task and stream their output into a small buffer that a chunked response drains, returning RESPONSE_TRY_AGAIN while the buffer is empty. When the network side drained faster than the command produced output, the response stalled after its first couple of empty fills. The command task then gave up on the full, undrained buffer, and clients received truncated replies (the first 2 KB of a 9 KB JSON settings listing).

It's easiest to reproduce on a host build with a loopback-fast transport where acks are immediate. On ESP32 the same sequence needs a producer that falls behind the TCP drain, so it's intermittent there.

Fix

Take a credit, and count _in_flight, only when payloadlen is nonzero. An empty call puts nothing in flight, so it shouldn't consume flight credit. Retries from polls then keep working until the source has data, and normal ack pacing resumes from there.

Testing

On a host (Emscripten) build of FluidNC, using a custom AsyncTCP transport with immediate acks and ASYNCWEBSERVER_USE_CHUNK_INFLIGHT=1, fetching the ~9 KB settings listing over a chunked response:

  • before: 8 of 9 replies truncated (2047 or 3000 bytes)
  • after: 15 of 15 replies complete and valid, across five fresh starts; FluidNC's other HTTP and WebSocket tests also pass

Not yet tested on ESP32 hardware with lwIP. The change only affects calls that write zero bytes, so it can't increase data in flight.

🤖 Generated with Claude Code

With ASYNCWEBSERVER_USE_CHUNK_INFLIGHT, write_send_buffs() took an
in-flight credit on every call that reached the content stage, even when
the source had no data yet (RESPONSE_TRY_AGAIN) and nothing was written.
Credits come back only with acks of sent data, never from polls, so a
response whose source is empty on two such calls runs out of credits.
From then on every call is ignored and the response stalls for good.

Take a credit only when data was actually put in flight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 9, 2026 21:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The focused change correctly preserves retry capability without altering accounting for transmitted data.

0 open findings

What changed in this PR

Prevents stalled chunked or unknown-length responses when their source temporarily has no data.

Changes:

  • Consume in-flight credit only when bytes are actually sent.
  • Document why zero-byte retries must retain credit.
File Description
src/​WebResponses.cpp Guards in-flight accounting against zero-byte sends.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@mathieucarbou mathieucarbou added the Type: Bug Something isn't working label Oct 9, 2026
@mathieucarbou
mathieucarbou requested review from willmmiles and a balanced review from Copilot October 9, 2026 21:45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The focused change correctly preserves credits for zero-byte retries without altering normal send accounting.

0 open findings

🧠 Review effort: Balanced

@mathieucarbou

Copy link
Copy Markdown
Member

Thank you for this bug fix - I agree with the analysis we might have missed this use case!

I will try to test this weekend.

@mathieucarbou

Copy link
Copy Markdown
Member

@willmmiles : pinging you to approve this small fix also. thanks!

@willmmiles willmmiles left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd largely ignored this feature as somewhat misguided -- maybe it made sense at some point, but it definitely doesn't today.

In the case of a client returning data in small chunks, the current implementation of AsyncAbstractResponse::write_send_buffs will loop over _fillBuffer until it fills the outgoing packets completely (or the client requests a backoff with RESPONSE_TRY_AGAIN). Further, each connection is limited by the send buffer size to only 4 outstanding packets. Artificially throttling any one connection won't improve the transfer rates of others unless there's sufficient memory pressure to exhaust the underlying packet buffer pool -- transfers will pretty much always proceed at the rate and in the order that acks are received from their client partners. (It's worth noting that a single dropped ack can tank a connection for a very long retransmission timeout, but no amount of playing games with outbound packets will affect the inbound packet drop rate.)

Anyways, this fix is a valid correction, for all that IMO the "feature" is most likely counterproductive.

MitchBradley added a commit to bdring/FluidNC that referenced this pull request Oct 12, 2026
The wasm build now includes the real WebUI/ web server -- HTTP routes,
WSChannel and WebDAV on unmodified ESPAsyncWebServer -- so a browser can
use it exactly as it uses a controller.  A web page cannot listen on a
socket, so the sockets come from JS instead:

- BrowserAsyncTCP/: the AsyncTCP API that ESPAsyncWebServer builds on,
  over numbered virtual connections that JS opens, feeds and closes with
  fluidnc_vconn_open/send/close; output returns through
  self.fluidncOnVconnData/Close.  All callbacks run on one async_tcp
  thread, as on lwIP's task on ESP32.
- VirtualNet.cpp: the network module -- always up, at 192.168.0.1 (the
  access-point address) and <$Hostname>.local -- providing ESP800/ESP111
  and the $I and ESP420 network lines.
- env:wasm uses Arduino-Emulator, like the posix envs.  Library builds
  are switched to emcc too (previously only project sources were), FS is
  exported to JS, and wasm/IPAddress.h defers to Arduino-Emulator's.
- ESPAsyncWebServer's chunked in-flight pacing is off for this env: with
  instant acks it stalls responses whose source is briefly empty, which
  truncated /command replies such as [ESP400] (upstream fix proposed in
  ESP32Async/ESPAsyncWebServer#488).
- The postMessage WebUI bridge this replaces is removed: ShimChannel.cpp,
  wasm_fs_bridge.cpp and FwInfo.cpp.
- vconn_smoke_test.mjs: a headless Node test of HTTP, upload and
  WebSocket over virtual connections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MitchBradley added a commit to bdring/FluidNC that referenced this pull request Oct 12, 2026
* wasm: run FluidNC's own web server inside the wasm module

The wasm build now includes the real WebUI/ web server -- HTTP routes,
WSChannel and WebDAV on unmodified ESPAsyncWebServer -- so a browser can
use it exactly as it uses a controller.  A web page cannot listen on a
socket, so the sockets come from JS instead:

- BrowserAsyncTCP/: the AsyncTCP API that ESPAsyncWebServer builds on,
  over numbered virtual connections that JS opens, feeds and closes with
  fluidnc_vconn_open/send/close; output returns through
  self.fluidncOnVconnData/Close.  All callbacks run on one async_tcp
  thread, as on lwIP's task on ESP32.
- VirtualNet.cpp: the network module -- always up, at 192.168.0.1 (the
  access-point address) and <$Hostname>.local -- providing ESP800/ESP111
  and the $I and ESP420 network lines.
- env:wasm uses Arduino-Emulator, like the posix envs.  Library builds
  are switched to emcc too (previously only project sources were), FS is
  exported to JS, and wasm/IPAddress.h defers to Arduino-Emulator's.
- ESPAsyncWebServer's chunked in-flight pacing is off for this env: with
  instant acks it stalls responses whose source is briefly empty, which
  truncated /command replies such as [ESP400] (upstream fix proposed in
  ESP32Async/ESPAsyncWebServer#488).
- The postMessage WebUI bridge this replaces is removed: ShimChannel.cpp,
  wasm_fs_bridge.cpp and FwInfo.cpp.
- vconn_smoke_test.mjs: a headless Node test of HTTP, upload and
  WebSocket over virtual connections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* wasm demo: a virtual network from the page to FluidNC's web server

A page can't listen on a socket, so these carry a WebUI's traffic to the
web server inside the wasm module over its virtual connections.  A WebUI
loaded from FluidNC then talks ordinary HTTP and WebSocket, unmodified.

- vnet-sw.js: a Service Worker (scope device/) that relays every
  same-origin request from the device frame -- /, /command, /files,
  /upload, /sd/... -- to the demo page and streams the response back.  It
  decodes gzip (browsers don't decode a Response a worker builds), adds the
  COEP header the isolated page requires of a nested document, injects the
  WebSocket stand-in into HTML, and keeps redirects inside its scope.
- vnet-ws-shim.js: replaces window.WebSocket in the device frame, since
  Service Workers can't see WebSockets.  It treats the device's names and
  the page's own host (on any port) as the device, keeps same-device links
  and window.open inside the frame, and reports in-page URL changes.
- vnet-host.js: on the demo page, the HTTP/1.1 and WebSocket client over
  fluidnc_vconn_*, with a cookie jar (browsers drop Set-Cookie from
  worker-built responses), and helpers for device names and URLs.  Its
  createClient() core has no DOM dependencies.
- vnet_client_test.mjs: drives that client against the wasm server in
  Node -- gzip, cookies, chunked replies, uploads, the WebUI install
  sequence, and a WebSocket session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* wasm demo: run WebUIs the way a controller does

The demo now behaves like a board on the network.  A WebUI runs only by
browsing to FluidNC, which serves it -- or its built-in file manager --
from LocalFS; the demo needs no WebUI-specific code.

- An address bar preset to fluidnc.local, with Back/Forward/Reload:
  paths work as on a board (/?forcefallback=yes, /files?path=/,
  /command?plain=...), only <$Hostname>.local and the device's IP
  resolve, and other names, https: and other ports fail as a browser
  would report it.  ?browse=<address> opens the demo at an address, and a
  navigation that escapes the device frame is sent back into it.
- Install WebUI: fetches a project's latest GitHub release (through
  webui-proxy, which now returns the release's index.html.gz unchanged and
  which serve.py now also implements for local use) or a local build, and
  uploads it to LocalFS as index.html.gz through FluidNC's /files route,
  removing any old index.html/index.html.gz.  As after an upload to a
  board, the user browses to FluidNC to run it.
- The Upload/Download panel goes through FluidNC too: /files and /upload
  for uploads, their JSON for listings, WebDAV /flash and /sd for
  downloads.
- Files and NVS settings persist across reloads in IndexedDB (one saving
  tab, coordinated with Web Locks), seeded from seed-localfs/ and
  seed-sd/, with a button to reset to the built-in copies.

This supersedes #1900, whose persistence and seeding are included here;
its WebUI bridge features are replaced by the above.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* wasm demo: address review findings

- vnet-sw.js: never route a frame's requests to another tab's FluidNC.
  The frame-to-page mapping is lost when the browser stops the idle
  worker; it used to fall back to any demo page.  It now recovers by
  asking the frame for its page's token (vnet-ws-shim.js answers) and the
  pages which one owns it, and without an answer guesses only when a
  single demo page is open -- otherwise the request fails with 503.
- vnet-host.js: a Content-Length or chunked body cut short by the server
  closing is now an error, not a short success; only close-delimited
  bodies end at close.  A WebSocket closed while connect() is still
  waiting for the server no longer opens an upgrade afterwards.
- index.html: the WebUI installer checks each delete and fails if an old
  index.html survives, since FluidNC would keep serving it.
- IPAddress.h: comment named the old env:wasm_webui.
- vnet_unit_test.mjs: tests for the above against a fake module and fake
  browser clients (they fail on the previous code); the integration test
  also checks the delete replies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* wasm demo: path-scoped cookie jar; network-order loopback address

- vnet-host.js: the cookie jar now follows RFC 6265 path rules instead of
  keeping one cookie per name.  A cookie belongs to its Path (or the
  directory of the request that set it), is sent only on requests at or
  under that path, most specific first, and Max-Age=0 or a past Expires
  removes it.  FluidNC's sessionId is set for "/", and the HTTP/UIDir
  WebUIs of #1885 set a uiSession cookie for "/ui/<name>/" that must reach
  only that WebUI; sending one UI's uiSession everywhere defeated that
  isolation.
- BrowserAsyncTCP: keep the client and local addresses in network byte
  order, as lwIP does and IPAddress(uint32_t) expects; the log showed
  requests "from 1.0.0.127".
- vnet_unit_test.mjs: cookie-path tests (they fail on the previous jar).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* env:wasm: link the no-op posix mDNS

#1906 made WebUIServer and TelnetServer register mDNS services
unconditionally and added a no-op posix/Mdns.cpp so native builds still
link.  env:wasm takes only selected posix/ files, so it needs this one
too, or WebUIServer's Mdns::add/remove are undefined at link time.  (On a
base before #1906 the file doesn't exist yet and the filter line matches
nothing.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* wasm demo: give the device frame the device's own URLs; log traffic

The Service Worker served the device frame under device/, and the prefix
showed: a WebUI at /ui/webui2/ saw /device/ui/webui2/ as its own path.
WebUI 2's HTTP/UIDir support derives its base from location.pathname, so
it fell back to root-UI behavior and saved its preferences to LocalFS.
Navigations to a bare "/" also escaped the scope to the demo page.

The worker now covers the whole site and routes each request by who made
it: frame navigations and requests from nested windows go to FluidNC with
exactly their paths; the demo page, its workers and top-level tabs use the
network.  The device frame therefore sees the same URLs as on a
controller -- /, /ui/<name>/, /#/dashboard and the reload helper pages all
behave as they would on a board.  The frame guard and Location rewriting
are gone, the old device/-scoped registration is removed, and the demo
must be served from the root of its origin.

The virtual network's traffic is now logged to the DevTools console: a
line per HTTP exchange (method, path, status, bytes each way, time; the
demo page's own requests marked "(demo)") and per WebSocket open/close
with message and byte counts.  FluidNCVnet.logFrames = true adds each
WebSocket message; FluidNCVnet.log = false silences it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Demo: style the controls and frame borders

The demo's buttons, drop-downs and address bar get a larger
sans-serif font, padding and rounded corners: blue action buttons,
grey navigation buttons, and a red outline on Reset, whose
explanation moves into its tooltip.  The terminal and WebUI frames get
a heavier border.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* wasm demo: install a WebUI to /ui/<name>/ as well as LocalFS

"Install WebUI to" chooses LocalFS index.html.gz, as before, or
/ui/<name> (HTTP/UIDir), with a name defaulting to the project's (figui,
webui2, webui3) or the local file's.  The latter installs through
FluidNC's HTTP interface as a browser on the LAN would: a WebDAV DELETE
of any old index.html, which would be served in preference, then
PUT /ui/<name>/index.html.gz, which creates the directory.  The name
localfs is FluidNC's for the LocalFS root, so it installs there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@mathieucarbou

Copy link
Copy Markdown
Member

I'd largely ignored this feature as somewhat misguided -- maybe it made sense at some point, but it definitely doesn't today.

Yes we had some bug reports when dealing with some long connection problems like when reading files from sdcard or processing a long request. There are some examples in the project about these corner cases that were not possible before and were made possible by introducing the if light flag.

@mathieucarbou
mathieucarbou merged commit fa170f7 into ESP32Async:main Oct 12, 2026
34 checks passed
@willmmiles

willmmiles commented Oct 12, 2026 •

Copy link
Copy Markdown

I'd largely ignored this feature as somewhat misguided -- maybe it made sense at some point, but it definitely doesn't today.

Yes we had some bug reports when dealing with some long connection problems like when reading files from sdcard or processing a long request. There are some examples in the project about these corner cases that were not possible before and were made possible by introducing the if light flag.

Thanks - would you mind giving me a pointer to a specific example? I don't see any mention of the inflight flag in any of the examples, but maybe I'm not looking in the right place?

It really looks to me like it's now unnecessary. The better solution - looping over the available send buffer, instead of mapping one incoming callback to exactly one user code fetch - was introduced here in #316. I recall being happy to see that approach PR'd, as it's pretty much exactly what I'd done to mitigate this problem in the WLED fork. (In fact all the inflight logic was part of what held me back from upstreaming that code easily since the functions were so divergent -- I didn't really understand what it was for (and arguably still don't!), and I didn't have the time to do a manual merge.)

@mathieucarbou

mathieucarbou commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

I'd largely ignored this feature as somewhat misguided -- maybe it made sense at some point, but it definitely doesn't today.

Yes we had some bug reports when dealing with some long connection problems like when reading files from sdcard or processing a long request. There are some examples in the project about these corner cases that were not possible before and were made possible by introducing the if light flag.

Thanks - would you mind giving me a pointer to a specific example? I don't see any mention of the inflight flag in any of the examples, but maybe I'm not looking in the right place?

It really looks to me like it's now unnecessary. The better solution - looping over the available send buffer, instead of mapping one incoming callback to exactly one user code fetch - was introduced here in #316. I recall being happy to see that approach PR'd, as it's pretty much exactly what I'd done to mitigate this problem in the WLED fork. (In fact all the inflight logic was part of what held me back from upstreaming that code easily since the functions were so divergent -- I didn't really understand what it was for (and arguably still don't!), and I didn't have the time to do a manual merge.)

It was SlowChunkResponse: the idea for example was to serve a response (chunk encoding) with sdcard i/o (slow). File system access and directory listing is slow so the chunk callback was called and was executing slowly, slowing down the async_tcp task (we simulated that with a delay).

edit:

Maybe ChunkRetryResponse is also worth looking, and definitely also LargeResponse => there were issues serving large responses (it was impossible before)

MitchBradley added a commit to bdring/FluidNC that referenced this pull request Oct 12, 2026
ESPAsyncWebServer spent an in-flight credit even when a chunked fill
returned RESPONSE_TRY_AGAIN, so a /command reply produced slower than it
drained ([ESP400]) ran out of credits and stalled; WebClient then cut it
off at its 2 KB buffer.  env:wasm turned the pacing off to avoid that.
ESP32Async/ESPAsyncWebServer#488 fixed it upstream, so wasm now uses the
same ASYNCWEBSERVER_USE_CHUNK_INFLIGHT=1 as the other envs.

A checkout with an older ESPAsyncWebServer in .pio/libdeps keeps it
until `pio pkg update`; clean builds get the fix.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Status: Pending Merge Type: Bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants