Yufeng He 3aa1ac477d
fix(channels): cap WeCom outbound content at the 20480-byte protocol limit (#5148)
* fix(channels): cap WeCom outbound content at the 20480-byte protocol limit

Both _send_ws paths sent unbounded text. Stream replies now clip on a
character boundary with a truncation marker (one stream carries the whole
reply and cannot split mid-way), and proactive pushes split into
sequential markdown messages at newline boundaries. Measured in UTF-8
bytes, matching the documented protocol cap.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* test(channels): pin emoji boundary behavior in the WeCom content limit

Review on #5148 raised 4-byte emoji cut points. Probes show the split
path already carries a byte-split character into the next chunk and
terminates on all-emoji input; these tests pin that behavior so a later
refactor cannot regress it.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* fix(channels): preserve the delimiter when splitting WeCom pushes

The boundary newline was stripped by lstrip, so the sequential markdown
messages lost one delimiter per split and could not rebuild the original
response. Keep it on the chunk's tail and assert the exact round trip in
the tests, including leading blank lines.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* fix(channels): serialize WeCom proactive chunk batches per chat

Each chunk send awaits, so two manager workers pushing long texts to the
same chat could interleave markdown messages (A1, B1, A2, B2) and break
the sequential-message contract. Hold a per-chat lock across the whole
split batch; different chats still send concurrently.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* fix(channels): keep WeCom split advancing and cap the chunk batch

Two edge cases in _split_for_byte_limit left after the delimiter fix:

- A limit narrower than one whole character made the decode window
  empty, so the hard cut became 0 and the loop appended empty chunks
  forever. Take the character anyway when the window decodes empty, so
  the loop always advances.
- A single oversized push produced one message per 20480 bytes with no
  ceiling, flooding the chat and holding the per-chat lock for the whole
  drain. Cap one push at 10 messages: keep the first nine verbatim and
  collapse the rest into one clipped tail carrying the truncation
  marker, with a warning log when the cap trips.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* fix(channels): reclaim completed WeCom send locks, cap the split before it does the work

Two leftovers from the last review round:

- _ws_send_locks kept one lock per chat forever. A guard-locked refcount
  now reclaims an entry only when no sender is queued on it, so a waiter
  can never land on a fresh lock mid-batch for the same chat. Pinned by
  three reclamation tests (single push, capped batch, 20 concurrent chats).
- _split_for_byte_limit built every chunk and then joined the discarded
  tail to clip it — quadratic work on pathological pushes. The batch cap
  now applies inside the loop: once the kept chunks are full, the
  remainder is clipped whole. A spy test pins that the clipper receives
  the unsplit remainder.

tests/test_wecom_content_limit.py 25 passed, plus tests/test_wecom_ws_text.py.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* docs(channels): describe the WeCom push cap, pin the staggered-lock race

The channels guide still described proactive pushes as an uncapped split. Also add the staggered-start regression the reviewer asked for: a waiter queuing while the holder's cleanup runs must share one lock, keep batches contiguous, and leave both lock registries empty.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

---------

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
2026-09-12 09:59:58 +08:00
..