#5912 gate found 2 defects the capture-clear salvage exposed: - [CORE] uploadPendingFiles() read still-live S.pendingFiles, so a re-entrant/ interrupt send during the upload window inherited + re-uploaded the first send's attachment. Now snapshot _submittedFiles before the await, detach from S.pendingFiles, and upload exactly that snapshot. - [SILENT] the persisted-draft clear ran AFTER the await, cancelling a draft typed during the upload window. Moved it (and _composerDraftClearPromise) alongside the textarea clear, before any await. Failed-send restore still chains off the promise via the pre-await _failedSendFilesSnapshot. Updated source-assertion tests for the new call shape + ordering.
189 lines
7.8 KiB
Python
189 lines
7.8 KiB
Python
"""Regression coverage for the composer capture-and-clear race (salvage of #4750).
|
|
|
|
Bug (salvaged from #4750, credit @harryazj): ``send()`` in ``static/messages.js``
|
|
captured the composer text early but only wiped the textarea
|
|
(``$('msg').value=''``) LATE — after ``await uploadPendingFiles()`` and the
|
|
forced-skill-directive await. ``send()`` is re-entrant: while a send is in
|
|
flight, a second invocation (e.g. an interrupt-mode / ``busy_input_mode`` drain,
|
|
or a fast second Enter) hits the ``if (_sendInProgress)`` guard at the top and
|
|
re-reads the LIVE composer via ``_composerTextWithPendingSelections()``. Because
|
|
the textarea still held the original text during the async upload window, the
|
|
re-entrant guard read the stale DOM and QUEUED the same message again ->
|
|
double-submit.
|
|
|
|
Fix: capture the composer value and clear the textarea IMMEDIATELY after capture,
|
|
BEFORE any await. Once cleared, a re-entrant read sees an empty composer and the
|
|
guard's ``if(_text && _targetSid)`` short-circuits -> no duplicate queue.
|
|
|
|
This module verifies BOTH:
|
|
1. (static) the capture+wipe is ordered before the upload await in send(), and
|
|
2. (behavioral, via node's ``vm``) the REAL re-entrancy guard block extracted
|
|
from send() does NOT re-queue when the composer was already cleared, and
|
|
WOULD have re-queued had the composer still held the stale text.
|
|
"""
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
MESSAGES_JS = ROOT.joinpath("static", "messages.js").read_text(encoding="utf-8")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Static ordering assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _function_body(src: str, name: str) -> str:
|
|
marker = f"function {name}"
|
|
start = src.index(marker)
|
|
brace = src.index("{", start)
|
|
depth = 1
|
|
i = brace + 1
|
|
while depth and i < len(src):
|
|
if src[i] == "{":
|
|
depth += 1
|
|
elif src[i] == "}":
|
|
depth -= 1
|
|
i += 1
|
|
return src[brace + 1 : i - 1]
|
|
|
|
|
|
def test_composer_captured_and_cleared_before_upload_await():
|
|
"""The fix: capture + textarea wipe must run before uploadPendingFiles()."""
|
|
body = _function_body(MESSAGES_JS, "send")
|
|
|
|
capture_idx = body.index("const _submittedDraftTextForClear=$('msg').value||'';")
|
|
# The wipe sits immediately after the capture.
|
|
wipe_rel = body.index("$('msg').value='';autoResize();", capture_idx)
|
|
assert wipe_rel - capture_idx < 120, "the textarea wipe must sit immediately after the capture"
|
|
|
|
upload_idx = body.index("uploaded=await uploadPendingFiles(")
|
|
directive_await_idx = body.index("const _directivePayload = await _pending.promise;")
|
|
|
|
assert capture_idx < upload_idx, (
|
|
"composer capture+clear must happen BEFORE the uploadPendingFiles() await "
|
|
"(salvage of #4750 — closes the re-entrant double-send race)"
|
|
)
|
|
assert capture_idx < directive_await_idx, (
|
|
"composer capture+clear must happen BEFORE the forced-skill-directive await too"
|
|
)
|
|
|
|
|
|
def test_reentrancy_guard_reads_live_composer():
|
|
"""Sanity: the in-flight guard reads the LIVE composer (why the clear must be early)."""
|
|
body = _function_body(MESSAGES_JS, "send")
|
|
guard_idx = body.index("if (_sendInProgress) {")
|
|
guard_block = body[guard_idx : body.index("_sendInProgress = true;", guard_idx)]
|
|
assert "_composerTextWithPendingSelections().trim()" in guard_block, (
|
|
"the re-entrant guard reads the live composer, so the composer must be "
|
|
"cleared before the first await to avoid a stale double-send"
|
|
)
|
|
assert "queueSessionMessage(" in guard_block, (
|
|
"the re-entrant guard queues the message it reads from the live composer"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Behavioral test — run the REAL re-entrancy guard against a cleared composer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _extract_reentrancy_guard() -> str:
|
|
"""Slice the real `if (_sendInProgress) { ... }` guard block out of send()."""
|
|
body = _function_body(MESSAGES_JS, "send")
|
|
start = body.index("if (_sendInProgress) {")
|
|
# Balance braces from the guard's opening brace to its close.
|
|
brace = body.index("{", start)
|
|
depth = 1
|
|
i = brace + 1
|
|
while depth and i < len(body):
|
|
if body[i] == "{":
|
|
depth += 1
|
|
elif body[i] == "}":
|
|
depth -= 1
|
|
i += 1
|
|
return body[start:i]
|
|
|
|
|
|
def _run_reentrant_guard_in_node(composer_value: str):
|
|
"""Execute the REAL re-entrancy guard with a given live-composer value.
|
|
|
|
Returns the list of queueSessionMessage calls the guard made. A non-empty
|
|
list means the guard re-queued (double-submit); an empty list means it
|
|
short-circuited on an empty composer (the fix's post-clear state).
|
|
"""
|
|
node = shutil.which("node")
|
|
if not node: # pragma: no cover
|
|
pytest.skip("node not available")
|
|
|
|
helper = _function_body(MESSAGES_JS, "_composerTextWithPendingSelections")
|
|
guard = _extract_reentrancy_guard()
|
|
|
|
harness = textwrap.dedent(
|
|
"""
|
|
const queued = [];
|
|
const state = { input: { value: %(composer_value)s } };
|
|
const $ = (id) => (id === 'msg' ? state.input : null);
|
|
global.document = { getElementById: (id) => $(id) };
|
|
// No pending inline selections in this scenario.
|
|
const _pendingSelections = [];
|
|
function _formatSelectedTextReplyQuote(t){ return t; }
|
|
// Real helper the guard uses to read the live composer.
|
|
function _composerTextWithPendingSelections(){%(helper)s}
|
|
|
|
// Minimal in-flight state: a send is already running for sid-1.
|
|
let _sendInProgress = true;
|
|
let _sendInProgressSid = 'sid-1';
|
|
const S = { session: { session_id: 'sid-1' }, pendingFiles: [], activeProfile: 'default' };
|
|
|
|
// Stubs the guard branch touches.
|
|
function _chatPayloadModelState(){ return { model: 'm', model_provider: 'p' }; }
|
|
function queueSessionMessage(sid, payload){ queued.push({ sid, payload }); }
|
|
function _clearComposerAfterQueuedSelectionSend(){ state.input.value = ''; }
|
|
function _clearComposerDraft(){}
|
|
function updateQueueBadge(){}
|
|
function renderTray(){}
|
|
function showToast(){}
|
|
|
|
// Run the REAL guard block verbatim.
|
|
(function () {
|
|
%(guard)s
|
|
})();
|
|
|
|
console.log(JSON.stringify({ queued, composerAfter: state.input.value }));
|
|
"""
|
|
) % {
|
|
"composer_value": json.dumps(composer_value),
|
|
"helper": helper,
|
|
"guard": guard,
|
|
}
|
|
|
|
proc = subprocess.run([node, "-e", harness], capture_output=True, text=True, timeout=30)
|
|
assert proc.returncode == 0, f"node harness failed: {proc.stderr}"
|
|
return json.loads(proc.stdout.strip())
|
|
|
|
|
|
def test_reentrant_send_does_not_requeue_after_composer_cleared():
|
|
"""With the fix, the composer is EMPTY when the re-entrant guard runs, so it
|
|
must NOT queue a duplicate of the stale text."""
|
|
out = _run_reentrant_guard_in_node("")
|
|
assert out["queued"] == [], (
|
|
"a re-entrant send must not re-queue anything once the composer has been "
|
|
"cleared by the in-flight send (salvage of #4750)"
|
|
)
|
|
|
|
|
|
def test_reentrant_send_would_double_submit_if_composer_not_cleared():
|
|
"""Sanity / bug demonstration: had the composer NOT been cleared before the
|
|
async window (the pre-fix behaviour), the re-entrant guard reads the stale
|
|
DOM text and queues a DUPLICATE — exactly the double-send this fix prevents."""
|
|
out = _run_reentrant_guard_in_node("hello world")
|
|
assert len(out["queued"]) == 1, (
|
|
"the stale-composer scenario must reproduce the double-submit the fix removes"
|
|
)
|
|
assert out["queued"][0]["payload"]["text"] == "hello world"
|
|
assert out["queued"][0]["sid"] == "sid-1"
|