Tweak send no-response handling

This commit is contained in:
Jack Kingsman
2026-03-15 16:12:17 -07:00
parent 7cb84ea6c7
commit 29368961fc
35 changed files with 72 additions and 3 deletions
+13 -1
View File
@@ -20,6 +20,11 @@ from app.services.messages import (
logger = logging.getLogger(__name__)
NO_RADIO_RESPONSE_AFTER_SEND_DETAIL = (
"Send command was issued to the radio, but no response was heard back. "
"The message may or may not have sent successfully."
)
BroadcastFn = Callable[..., Any]
TrackAckFn = Callable[[str, int, int], bool]
NowFn = Callable[[], float]
@@ -279,7 +284,14 @@ async def send_direct_message_to_contact(
timestamp=sender_timestamp,
)
if result is None or result.type == EventType.ERROR:
if result is None:
logger.warning(
"No response from radio after direct send to %s; send outcome is unknown",
contact.public_key[:12],
)
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
message = await create_outgoing_direct_message(
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6 -2
View File
@@ -24,6 +24,7 @@ const CHANNEL_WARNING_THRESHOLD = 120; // Conservative for multi-hop
const CHANNEL_DANGER_BUFFER = 8; // Red zone starts this many bytes before hard limit
const textEncoder = new TextEncoder();
const RADIO_NO_RESPONSE_SNIPPET = 'no response was heard back';
/** Get UTF-8 byte length of a string (LoRa packets are byte-constrained, not character-constrained). */
function byteLen(s: string): number {
return textEncoder.encode(s).length;
@@ -118,8 +119,11 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
setText('');
} catch (err) {
console.error('Failed to send message:', err);
toast.error('Failed to send message', {
description: err instanceof Error ? err.message : 'Check radio connection',
const description = err instanceof Error ? err.message : 'Check radio connection';
const isRadioNoResponse =
err instanceof Error && err.message.toLowerCase().includes(RADIO_NO_RESPONSE_SNIPPET);
toast.error(isRadioNoResponse ? 'Radio did not confirm send' : 'Failed to send message', {
description,
});
return;
} finally {
+26
View File
@@ -9,12 +9,18 @@ import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MessageInput } from '../components/MessageInput';
import { toast } from '../components/ui/sonner';
// Mock sonner (toast)
vi.mock('../components/ui/sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
const mockToast = toast as unknown as {
success: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
const textEncoder = new TextEncoder();
function byteLen(s: string): number {
@@ -182,4 +188,24 @@ describe('MessageInput', () => {
expect(getSendButton()).toBeEnabled();
});
});
describe('send failure toasts', () => {
it('shows the radio no-response toast when the send outcome is unknown', async () => {
onSend.mockRejectedValueOnce(
new Error(
'Send command was issued to the radio, but no response was heard back. The message may or may not have sent successfully.'
)
);
renderInput({ conversationType: 'contact' });
fireEvent.change(getInput(), { target: { value: 'Hello' } });
fireEvent.click(getSendButton());
expect(await screen.findByDisplayValue('Hello')).toBeTruthy();
expect(mockToast.error).toHaveBeenCalledWith('Radio did not confirm send', {
description:
'Send command was issued to the radio, but no response was heard back. The message may or may not have sent successfully.',
});
});
});
});
+27
View File
@@ -25,6 +25,7 @@ from app.routers.messages import (
send_direct_message,
)
from app.services import dm_ack_tracker
from app.services.message_send import NO_RADIO_RESPONSE_AFTER_SEND_DETAIL
@pytest.fixture(autouse=True)
@@ -1024,6 +1025,32 @@ class TestRadioExceptionMidSend:
)
assert len(messages) == 0
@pytest.mark.asyncio
async def test_dm_send_no_radio_response_returns_504_without_storing_message(self, test_db):
"""When mc.commands.send_msg() returns None, report unknown outcome and store nothing."""
mc = _make_mc()
pub_key = "ac" * 32
await _insert_contact(pub_key, "Alice")
mc.commands.send_msg = AsyncMock(return_value=None)
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
pytest.raises(HTTPException) as exc_info,
):
await send_direct_message(
SendDirectMessageRequest(destination=pub_key, text="Did this send?")
)
assert exc_info.value.status_code == 504
assert exc_info.value.detail == NO_RADIO_RESPONSE_AFTER_SEND_DETAIL
messages = await MessageRepository.get_all(
msg_type="PRIV", conversation_key=pub_key, limit=10
)
assert len(messages) == 0
@pytest.mark.asyncio
async def test_channel_send_radio_exception_no_orphan_message(self, test_db):
"""When mc.commands.send_chan_msg() raises, no message should be stored in DB."""