From b509491c6017e8d512f8d00ee69e9c2f57afaa2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 11 Jul 2026 06:38:38 +0400 Subject: [PATCH] feat: bufferedAmount flow control in WS upload, bump 1.0.16 --- History/2026-07-11-ws-stall-flow-control.md | 90 +++++++++++++++++++++ site/app.py | 2 +- site/static/app.js | 13 ++- 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 History/2026-07-11-ws-stall-flow-control.md diff --git a/History/2026-07-11-ws-stall-flow-control.md b/History/2026-07-11-ws-stall-flow-control.md new file mode 100644 index 0000000..b1e45fb --- /dev/null +++ b/History/2026-07-11-ws-stall-flow-control.md @@ -0,0 +1,90 @@ +# 2026-07-11 — WS stall: root cause (Sonnet) + план flow control + +## Контекст + +WebSocket-загрузка файлов через ingress (Flask + flask-sock + Werkzeug dev server). +Файл 3.7 MB, чанки 64 KB. Первые ~90% загружаются мгновенно, затем пауза ~51 сек. + +## Диагноз (Sonnet) + +**Первопричина: TCP receive buffer saturation на поде.** + +### Механика + +``` +Браузер: ws.send() × 58 без пауз, без проверки bufferedAmount + ↓ +nginx: пробрасывает всё в под (proxy-request-buffering: off) + ↓ +Ядро пода: TCP recv buffer автотюнится до ~6 MB + ↓ +При ~3.3 MB (90%) буфер ЗАПОЛНЕН → TCP zero window + ↓ +nginx: send() → EAGAIN → перестаёт читать от браузера + ↓ +Werkzeug: читает 1 фрейм (64 KB) → окно приоткрывается + → nginx пихает 1-2 фрейма → окно схлопывается + → ЦИКЛ: stop-and-go oscillation + ↓ +Оставшиеся ~400 KB (6 чанков) дрейнуются ~8 KB/s = 51 сек +``` + +### Почему HTTP POST не виснет? + +HTTP POST тестировался на nginx:alpine (C-сервер) — вычитывает со скоростью линии, +буфер не заполняется. На Flask/Werkzeug HTTP POST падает в 30-50% случаев — +та же проблема, но проявляется через Connection: close → новый TCP → conntrack timeout. + +### Почему внутри кластера (прямой WS на pod:5000) быстро? + +Нет nginx-прослойки. Браузер → напрямую под, TCP buffer тот же, но: +- Меньше промежуточных буферов +- Нет дополнительной задержки на nginx event loop + +## План: `bufferedAmount` flow control + +**Меняется:** `app.js`, функция `uploadFileWS` + +### Суть + +Вместо прямого `ws.send(data)` — проверка `ws.bufferedAmount`: + +```javascript +function sendOrWait(data) { + if (ws.bufferedAmount > 256 * 1024) { + setTimeout(function () { sendOrWait(data); }, 10); + return; + } + ws.send(data); + offset += CHUNK; + if (offset < f.size) { + readNext(); + } else { + ws.send('DONE'); + } +} +``` + +`reader.onload` становится однострочным: `sendOrWait(e.target.result)`. + +Порог 256 KB выбран так, чтобы: +- Не заполнять TCP buffer пода (>6 MB max) +- Оставлять запас для nginx внутренних буферов +- Не создавать излишних задержек + +### Версия + +`app.py`: `VERSION = '1.0.15'` → `'1.0.16'` + +### Scope + +- Только client-side (app.js) +- app.py — без изменений в логике +- CHUNK (64 KB), порог прогресса (10%), таймаут (300 сек) — без изменений + +### Ожидаемый результат + +- Буфер не переполняется → нет stop-and-go oscillation +- Прогресс-бар обновляется плавно на всём протяжении загрузки +- Файл 3.7 MB загружается без 51-секундной паузы +- Скорость определяется скоростью чтения сервером (Werkzeug), а не TCP-буфером diff --git a/site/app.py b/site/app.py index 9277f36..025659b 100644 --- a/site/app.py +++ b/site/app.py @@ -7,7 +7,7 @@ from flask_sock import Sock app = Flask(__name__) sock = Sock(app) -VERSION = '1.0.15' +VERSION = '1.0.16' @app.route('/') diff --git a/site/static/app.js b/site/static/app.js index 02e0a50..a0948f1 100644 --- a/site/static/app.js +++ b/site/static/app.js @@ -210,8 +210,13 @@ function uploadFileWS(url, f, idx) { ws.onopen = function () { var reader = new FileReader(); - reader.onload = function (e) { - ws.send(e.target.result); + + function sendOrWait(data) { + if (ws.bufferedAmount > 256 * 1024) { + setTimeout(function () { sendOrWait(data); }, 10); + return; + } + ws.send(data); offset += CHUNK; if (offset < f.size) { var pct = Math.round(offset / f.size * 100); @@ -224,6 +229,10 @@ function uploadFileWS(url, f, idx) { } else { ws.send('DONE'); } + } + + reader.onload = function (e) { + sendOrWait(e.target.result); }; function readNext() { var end = Math.min(offset + CHUNK, f.size);