replace per-message Future.delayed with lightweight callback queue

drains delayed chat/7TV callbacks via the existing 200ms message buffer
timer instead of creating one Future.delayed per incoming event, removing
the biggest memory-pressure risk on busy channels with chat delay active

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tommy Chow 2026-03-07 00:11:42 -08:00
parent 9e81b2d80a
commit d28a3279e4

View file

@ -88,6 +88,10 @@ abstract class ChatStoreBase with Store {
return false;
}
int get _chatDelaySeconds => settings.effectiveChatDelay.toInt();
bool get _hasActiveChatDelay => settings.showVideo && _chatDelaySeconds > 0;
final TwitchApi twitchApi;
/// The amount of messages to free (remove) when the [_messageLimit] is reached.
@ -163,6 +167,12 @@ abstract class ChatStoreBase with Store {
/// This is used as an optimization to prevent the list from being updated/shifted while the user is scrolling.
final messageBuffer = ObservableList<IRCMessage>();
/// Pending delayed IRC payloads waiting to be rendered.
final _pendingChatCallbacks = <_DelayedCallback>[];
/// Pending delayed 7TV payloads waiting to be rendered.
final _pendingSevenTVCallbacks = <_DelayedCallback>[];
/// The set of message IDs that have been revealed by the user (for deleted messages).
final revealedMessageIds = ObservableSet<String>();
@ -357,9 +367,10 @@ abstract class ChatStoreBase with Store {
// Start chat delay countdown when toggling video on, cancel when off
reactions.add(
reaction((_) => settings.showVideo, (showVideo) {
if (showVideo && settings.chatDelay > 0) {
if (showVideo && settings.effectiveChatDelay > 0) {
_startChatDelayCountdown();
} else if (!showVideo) {
_flushPendingDelayedCallbacks();
_cancelChatDelayCountdown();
_chatDelaySyncCompleted = false;
}
@ -369,8 +380,9 @@ abstract class ChatStoreBase with Store {
// Start chat delay countdown when chatDelay is set (for auto sync mode)
// Cancel countdown if delay becomes 0
reactions.add(
reaction((_) => settings.chatDelay, (chatDelay) {
reaction((_) => settings.effectiveChatDelay, (chatDelay) {
if (chatDelay == 0) {
_flushPendingDelayedCallbacks();
_cancelChatDelayCountdown();
_chatDelaySyncCompleted = false;
} else if (settings.autoSyncChatDelay &&
@ -430,6 +442,71 @@ abstract class ChatStoreBase with Store {
});
}
void _enqueueDelayedCallback(
List<_DelayedCallback> queue,
void Function() callback,
) {
queue.add(
_DelayedCallback(
releaseAt: DateTime.now().add(Duration(seconds: _chatDelaySeconds)),
callback: callback,
),
);
}
void _flushDueDelayedCallbacks() {
_flushDueDelayedCallbacksForQueue(_pendingChatCallbacks);
_flushDueDelayedCallbacksForQueue(_pendingSevenTVCallbacks);
}
void _flushDueDelayedCallbacksForQueue(List<_DelayedCallback> queue) {
if (queue.isEmpty) return;
final now = DateTime.now();
final readyCallbacks = <_DelayedCallback>[];
queue.removeWhere((delayedCallback) {
final isReady = !delayedCallback.releaseAt.isAfter(now);
if (isReady) {
readyCallbacks.add(delayedCallback);
}
return isReady;
});
for (final delayedCallback in readyCallbacks) {
delayedCallback.callback();
}
}
void _flushPendingDelayedCallbacks() {
_flushPendingDelayedCallbacksForQueue(_pendingChatCallbacks);
_flushPendingDelayedCallbacksForQueue(_pendingSevenTVCallbacks);
}
void _flushPendingDelayedCallbacksForQueue(List<_DelayedCallback> queue) {
if (queue.isEmpty) return;
final pendingCallbacks = List<_DelayedCallback>.from(queue);
queue.clear();
for (final delayedCallback in pendingCallbacks) {
delayedCallback.callback();
}
}
void _clearPendingDelayedCallbacks({
bool clearChat = true,
bool clearSevenTV = true,
}) {
if (clearChat) {
_pendingChatCallbacks.clear();
}
if (clearSevenTV) {
_pendingSevenTVCallbacks.clear();
}
}
/// Handle and process the provided string-representation of the IRC data.
///
/// If a message, parses the IRC data into an [IRCMessage] and handles it based on the [Command].
@ -726,14 +803,11 @@ abstract class ChatStoreBase with Store {
);
_sevenTVChannel?.sink.close(1000);
_clearPendingDelayedCallbacks(clearChat: false);
_sevenTVChannel = WebSocketChannel.connect(
Uri.parse('wss://events.7tv.io/v3'),
);
// Track the current connection to prevent stale delayed callbacks
final connectionId = DateTime.now().millisecondsSinceEpoch;
var currentConnectionId = connectionId;
void listener(dynamic data) {
// debugPrint(data);
final decoded = jsonDecode(data);
@ -778,22 +852,18 @@ abstract class ChatStoreBase with Store {
_sevenTVChannelListener?.cancel();
_sevenTVChannelListener = _sevenTVChannel?.stream.listen(
(data) {
if (!settings.showVideo || settings.chatDelay == 0) {
if (!_hasActiveChatDelay) {
listener(data);
} else {
final capturedConnectionId = currentConnectionId;
Future.delayed(Duration(seconds: settings.chatDelay.toInt()), () {
// Only process if this is still the active connection
if (capturedConnectionId == currentConnectionId) {
listener(data);
}
});
_enqueueDelayedCallback(
_pendingSevenTVCallbacks,
() => listener(data),
);
}
},
onError: (error) => debugPrint('7TV events error: ${error.toString()}'),
onDone: () {
// Invalidate the current connection to cancel pending delayed callbacks
currentConnectionId = 0;
_clearPendingDelayedCallbacks(clearChat: false);
debugPrint('7TV events done');
},
);
@ -808,6 +878,7 @@ abstract class ChatStoreBase with Store {
// Cancel existing listener to prevent duplicate message processing
_channelListener?.cancel();
_clearPendingDelayedCallbacks(clearSevenTV: false);
_channel?.sink.close(1000);
_channel = WebSocketChannel.connect(
@ -815,38 +886,27 @@ abstract class ChatStoreBase with Store {
);
// Only show chat delay countdown on initial connection or video toggle, not on reconnects
if (!isReconnect && settings.showVideo && settings.chatDelay > 0) {
if (!isReconnect && _hasActiveChatDelay) {
_startChatDelayCountdown();
}
// Track the current connection to prevent stale delayed callbacks
final connectionId = DateTime.now().millisecondsSinceEpoch;
var currentConnectionId = connectionId;
// Listen for new messages and forward them to the handler.
_channelListener = _channel?.stream.listen(
(data) {
final dataStr = data.toString();
// Process immediately if delay is disabled or command should bypass delay
if (!settings.showVideo ||
settings.chatDelay == 0 ||
_shouldBypassDelay(dataStr)) {
if (!_hasActiveChatDelay || _shouldBypassDelay(dataStr)) {
_handleIRCData(dataStr);
} else {
final capturedConnectionId = currentConnectionId;
Future.delayed(Duration(seconds: settings.chatDelay.toInt()), () {
// Only process if this is still the active connection
if (capturedConnectionId == currentConnectionId) {
_handleIRCData(dataStr);
}
_enqueueDelayedCallback(_pendingChatCallbacks, () {
_handleIRCData(dataStr);
});
}
},
onError: (error) => debugPrint('Chat error: ${error.toString()}'),
onDone: () async {
// Invalidate the current connection to cancel pending delayed callbacks
currentConnectionId = 0;
_clearPendingDelayedCallbacks(clearSevenTV: false);
// Mark connection as disconnected for UI
_isConnected = false;
@ -986,6 +1046,8 @@ abstract class ChatStoreBase with Store {
@action
void addMessages() {
_flushDueDelayedCallbacks();
if (!_autoScroll || messageBuffer.isEmpty) return;
_messages.addAll(messageBuffer);
@ -1017,7 +1079,7 @@ abstract class ChatStoreBase with Store {
messageBuffer.clear();
}
var remainingSeconds = settings.chatDelay.toInt();
var remainingSeconds = _chatDelaySeconds;
// Create and store reference to the countdown message
_countdownMessage = IRCMessage.createNotice(
@ -1248,6 +1310,8 @@ abstract class ChatStoreBase with Store {
void dispose() {
_shouldDisconnect = true;
_clearPendingDelayedCallbacks();
_messageBufferTimer?.cancel();
_notificationTimer?.cancel();
_sendingTimeoutTimer?.cancel();
@ -1286,3 +1350,10 @@ abstract class ChatStoreBase with Store {
textFieldFocusNode.requestFocus();
}
}
class _DelayedCallback {
_DelayedCallback({required this.releaseAt, required this.callback});
final DateTime releaseAt;
final void Function() callback;
}