feat: inactivity watchdog for sockets

This commit is contained in:
cogwheel0
2025-09-07 23:17:26 +05:30
parent a850a567a1
commit 30f1650faf
3 changed files with 242 additions and 110 deletions

View File

@@ -333,7 +333,9 @@ class PersistentStreamingService with WidgetsBindingObserver {
final lastUpdate = metadata['lastUpdate'] as DateTime?; final lastUpdate = metadata['lastUpdate'] as DateTime?;
if (lastUpdate != null) { if (lastUpdate != null) {
final timeSinceUpdate = DateTime.now().difference(lastUpdate); final timeSinceUpdate = DateTime.now().difference(lastUpdate);
return timeSinceUpdate > const Duration(minutes: 1); // Align with app-side watchdogs: be less aggressive than UI guard
// but still attempt recovery before server timeouts become likely.
return timeSinceUpdate > const Duration(minutes: 2);
} }
return false; return false;

View File

@@ -0,0 +1,83 @@
import 'dart:async';
/// A simple activity-based watchdog.
///
/// Call [ping] whenever activity occurs. If no activity happens
/// within [window], [onTimeout] fires. Optionally, an [absoluteCap]
/// enforces a maximum total duration regardless of activity.
class InactivityWatchdog {
InactivityWatchdog({
required Duration window,
required this.onTimeout,
Duration? absoluteCap,
}) : _window = window,
_absoluteCap = absoluteCap;
final void Function() onTimeout;
Duration _window;
Duration? _absoluteCap;
Timer? _timer;
Timer? _absoluteTimer;
bool _started = false;
Duration get window => _window;
void setWindow(Duration newWindow) {
_window = newWindow;
if (_started) {
// Restart timer with new window
_restart();
}
}
void setAbsoluteCap(Duration? cap) {
_absoluteCap = cap;
if (_started) {
_absoluteTimer?.cancel();
if (_absoluteCap != null) {
_absoluteTimer = Timer(_absoluteCap!, _fire);
}
}
}
void start() {
if (_started) return;
_started = true;
_restart();
if (_absoluteCap != null) {
_absoluteTimer = Timer(_absoluteCap!, _fire);
}
}
void ping() {
if (!_started) {
start();
return;
}
_restart();
}
void stop() {
_timer?.cancel();
_timer = null;
_absoluteTimer?.cancel();
_absoluteTimer = null;
_started = false;
}
void dispose() => stop();
void _restart() {
_timer?.cancel();
_timer = Timer(_window, _fire);
}
void _fire() {
stop();
try {
onTimeout();
} catch (_) {}
}
}

View File

@@ -13,6 +13,7 @@ import '../../../core/auth/auth_state_manager.dart';
import '../../../core/utils/stream_chunker.dart'; import '../../../core/utils/stream_chunker.dart';
import '../../../core/services/persistent_streaming_service.dart'; import '../../../core/services/persistent_streaming_service.dart';
import '../../../core/utils/debug_logger.dart'; import '../../../core/utils/debug_logger.dart';
import '../../../core/utils/inactivity_watchdog.dart';
import '../services/reviewer_mode_service.dart'; import '../services/reviewer_mode_service.dart';
import '../../../shared/services/tasks/task_queue.dart'; import '../../../shared/services/tasks/task_queue.dart';
import '../../tools/providers/tools_providers.dart'; import '../../tools/providers/tools_providers.dart';
@@ -40,8 +41,8 @@ class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
StreamSubscription? _messageStream; StreamSubscription? _messageStream;
ProviderSubscription? _conversationListener; ProviderSubscription? _conversationListener;
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
// Inactivity watchdog to prevent stuck typing indicator // Activity-based watchdog to prevent stuck typing indicator
Timer? _typingStuckGuard; InactivityWatchdog? _typingWatchdog;
ChatMessagesNotifier(this._ref) : super([]) { ChatMessagesNotifier(this._ref) : super([]) {
// Load messages when conversation changes with proper cleanup // Load messages when conversation changes with proper cleanup
@@ -115,15 +116,16 @@ class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
} }
void _cancelTypingGuard() { void _cancelTypingGuard() {
_typingStuckGuard?.cancel(); _typingWatchdog?.stop();
_typingStuckGuard = null; _typingWatchdog = null;
} }
void _scheduleTypingGuard({Duration? timeout}) { void _scheduleTypingGuard({Duration? timeout}) {
// Default timeout tuned to balance long tool gaps and UX // Default timeout tuned to balance long tool gaps and UX
final effectiveTimeout = timeout ?? const Duration(seconds: 25); final effectiveTimeout = timeout ?? const Duration(seconds: 25);
_typingStuckGuard?.cancel(); _typingWatchdog ??= InactivityWatchdog(
_typingStuckGuard = Timer(effectiveTimeout, () async { window: effectiveTimeout,
onTimeout: () async {
try { try {
if (state.isEmpty) return; if (state.isEmpty) return;
final last = state.last; final last = state.last;
@@ -167,7 +169,8 @@ class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
final history = chatObj['history']; final history = chatObj['history'];
if (history is Map && history['messages'] is Map) { if (history is Map && history['messages'] is Map) {
final Map<String, dynamic> messagesMap = final Map<String, dynamic> messagesMap =
(history['messages'] as Map).cast<String, dynamic>(); (history['messages'] as Map)
.cast<String, dynamic>();
final msg = messagesMap[msgId]; final msg = messagesMap[msgId];
if (msg is Map) { if (msg is Map) {
final rawContent = msg['content']; final rawContent = msg['content'];
@@ -199,7 +202,10 @@ class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
} finally { } finally {
_cancelTypingGuard(); _cancelTypingGuard();
} }
}); },
);
_typingWatchdog!.setWindow(effectiveTimeout);
_typingWatchdog!.ping();
} }
void _touchStreamingActivity() { void _touchStreamingActivity() {
@@ -222,20 +228,17 @@ class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
final webSearchAvailable = _ref.read(webSearchAvailableProvider); final webSearchAvailable = _ref.read(webSearchAvailableProvider);
final globalImageGen = _ref.read(imageGenerationEnabledProvider); final globalImageGen = _ref.read(imageGenerationEnabledProvider);
// Extend guard windows to tolerate long reasoning/tools (> 1 min)
if (isWebSearchFlow || (globalWebSearch && webSearchAvailable)) { if (isWebSearchFlow || (globalWebSearch && webSearchAvailable)) {
timeout = Duration( if (timeout.inSeconds < 60) timeout = const Duration(seconds: 60);
milliseconds: timeout.inMilliseconds.clamp(0, 45000),
);
// If current < 45s, bump to 45s
if (timeout.inSeconds < 45) timeout = const Duration(seconds: 45);
} }
if (isBgFlow) { if (isBgFlow) {
// Background tools/dynamic channel can be longer // Background tools/dynamic channel can be much longer
if (timeout.inSeconds < 60) timeout = const Duration(seconds: 60); if (timeout.inSeconds < 120) timeout = const Duration(seconds: 120);
} }
if (isImageGenFlow || globalImageGen) { if (isImageGenFlow || globalImageGen) {
// Image generation tends to be the longest // Image generation tends to be the longest
if (timeout.inSeconds < 90) timeout = const Duration(seconds: 90); if (timeout.inSeconds < 180) timeout = const Duration(seconds: 180);
} }
} catch (_) {} } catch (_) {}
@@ -762,7 +765,7 @@ Future<void> regenerateMessage(
final bool isLastUser = (i == messages.length - 1) && msg.role == 'user'; final bool isLastUser = (i == messages.length - 1) && msg.role == 'user';
final List<String> messageAttachments = final List<String> messageAttachments =
(isLastUser && (attachments != null && attachments.isNotEmpty)) (isLastUser && (attachments != null && attachments.isNotEmpty))
? List<String>.from(attachments!) ? List<String>.from(attachments)
: (msg.attachmentIds ?? const <String>[]); : (msg.attachmentIds ?? const <String>[]);
if (messageAttachments.isNotEmpty) { if (messageAttachments.isNotEmpty) {
@@ -1468,6 +1471,26 @@ Future<void> _sendMessageInternal(
} catch (_) {} } catch (_) {}
if (socketService != null) { if (socketService != null) {
// Activity-based watchdog for chat/channel events (resets on activity)
final _chatWatchdog = InactivityWatchdog(
window: const Duration(minutes: 5),
onTimeout: () {
try {
socketService.offChatEvents();
socketService.offChannelEvents();
} catch (_) {}
// As a final safeguard, if we're still in streaming state, finish it
try {
final msgs = ref.read(chatMessagesProvider);
if (msgs.isNotEmpty &&
msgs.last.role == 'assistant' &&
msgs.last.isStreaming) {
ref.read(chatMessagesProvider.notifier).finishStreaming();
}
} catch (_) {}
},
)..start();
void chatHandler(Map<String, dynamic> ev) { void chatHandler(Map<String, dynamic> ev) {
try { try {
final data = ev['data']; final data = ev['data'];
@@ -1475,6 +1498,9 @@ Future<void> _sendMessageInternal(
final type = data['type']; final type = data['type'];
final payload = data['data']; final payload = data['data'];
DebugLogger.stream('Socket chat-events: type=$type'); DebugLogger.stream('Socket chat-events: type=$type');
// Any chat event indicates activity; reset inactivity watchdog
// (watchdog defined below, near handler registration)
_chatWatchdog.ping();
if (type == 'chat:completion' && payload != null) { if (type == 'chat:completion' && payload != null) {
if (payload is Map<String, dynamic>) { if (payload is Map<String, dynamic>) {
// Provider may emit tool_calls at the top level // Provider may emit tool_calls at the top level
@@ -1591,6 +1617,10 @@ Future<void> _sendMessageInternal(
try { try {
socketService.offChatEvents(); socketService.offChatEvents();
} catch (_) {} } catch (_) {}
try {
_chatWatchdog.ping(); // ensure timer exists
_chatWatchdog.stop();
} catch (_) {}
// Notify server that chat is completed (mirrors web client) // Notify server that chat is completed (mirrors web client)
try { try {
@@ -1703,6 +1733,9 @@ Future<void> _sendMessageInternal(
} }
// Normal path: finish now // Normal path: finish now
ref.read(chatMessagesProvider.notifier).finishStreaming(); ref.read(chatMessagesProvider.notifier).finishStreaming();
try {
_chatWatchdog.stop();
} catch (_) {}
} }
} }
} else if (type == 'request:chat:completion' && payload != null) { } else if (type == 'request:chat:completion' && payload != null) {
@@ -1722,6 +1755,10 @@ Future<void> _sendMessageInternal(
try { try {
if (line is String) { if (line is String) {
final s = line.trim(); final s = line.trim();
// Dynamic channel activity
try {
_chatWatchdog.ping();
} catch (_) {}
DebugLogger.stream( DebugLogger.stream(
'Socket [$channel] line=${s.length > 160 ? '${s.substring(0, 160)}' : s}', 'Socket [$channel] line=${s.length > 160 ? '${s.substring(0, 160)}' : s}',
); );
@@ -1988,27 +2025,15 @@ Future<void> _sendMessageInternal(
.read(chatMessagesProvider.notifier) .read(chatMessagesProvider.notifier)
.appendToLastMessage(content); .appendToLastMessage(content);
_updateImagesFromCurrentContent(ref); _updateImagesFromCurrentContent(ref);
_chatWatchdog.ping();
} }
} }
} catch (_) {} } catch (_) {}
} }
socketService.onChannelEvents(channelEventsHandler); socketService.onChannelEvents(channelEventsHandler);
Future.delayed(const Duration(seconds: 90), () { // Start activity watchdog
try { _chatWatchdog.ping();
socketService.offChatEvents();
socketService.offChannelEvents();
} catch (_) {}
// As a final safeguard, if we're still in streaming state, finish it to avoid stuck UI
try {
final msgs = ref.read(chatMessagesProvider);
if (msgs.isNotEmpty &&
msgs.last.role == 'assistant' &&
msgs.last.isStreaming) {
ref.read(chatMessagesProvider.notifier).finishStreaming();
}
} catch (_) {}
});
} }
// Prepare streaming and background handling // Prepare streaming and background handling
@@ -2063,8 +2088,17 @@ Future<void> _sendMessageInternal(
// Helpers were defined above // Helpers were defined above
int _chunkSeq = 0;
final streamSubscription = persistentController.stream.listen( final streamSubscription = persistentController.stream.listen(
(chunk) { (chunk) {
_chunkSeq += 1;
try {
persistentService.updateStreamProgress(
streamId,
chunkSequence: _chunkSeq,
appendedContent: chunk,
);
} catch (_) {}
var effectiveChunk = chunk; var effectiveChunk = chunk;
// Check for web search indicators in the stream // Check for web search indicators in the stream
if (webSearchEnabled && !isSearching) { if (webSearchEnabled && !isSearching) {
@@ -2960,11 +2994,32 @@ void _attachSocketStreamingHandlers({
final api = ref.read(apiServiceProvider); final api = ref.read(apiServiceProvider);
// Activity-based watchdog for socket-driven streaming (resets on activity)
final _socketWatchdog = InactivityWatchdog(
window: const Duration(minutes: 5),
onTimeout: () {
try {
socketService.offChatEvents();
socketService.offChannelEvents();
} catch (_) {}
try {
final msgs = ref.read(chatMessagesProvider);
if (msgs.isNotEmpty &&
msgs.last.role == 'assistant' &&
msgs.last.isStreaming) {
ref.read(chatMessagesProvider.notifier).finishStreaming();
}
} catch (_) {}
},
)..start();
void channelLineHandlerFactory(String channel) { void channelLineHandlerFactory(String channel) {
void handler(dynamic line) { void handler(dynamic line) {
try { try {
if (line is String) { if (line is String) {
final s = line.trim(); final s = line.trim();
// Any socket line is activity
_socketWatchdog.ping();
if (s == '[DONE]' || s == 'DONE') { if (s == '[DONE]' || s == 'DONE') {
try { try {
socketService.offEvent(channel); socketService.offEvent(channel);
@@ -2982,6 +3037,7 @@ void _attachSocketStreamingHandlers({
); );
} catch (_) {} } catch (_) {}
ref.read(chatMessagesProvider.notifier).finishStreaming(); ref.read(chatMessagesProvider.notifier).finishStreaming();
_socketWatchdog.stop();
return; return;
} }
if (s.startsWith('data:')) { if (s.startsWith('data:')) {
@@ -3003,6 +3059,7 @@ void _attachSocketStreamingHandlers({
); );
} catch (_) {} } catch (_) {}
ref.read(chatMessagesProvider.notifier).finishStreaming(); ref.read(chatMessagesProvider.notifier).finishStreaming();
_socketWatchdog.stop();
return; return;
} }
try { try {
@@ -3065,11 +3122,13 @@ void _attachSocketStreamingHandlers({
} }
} }
} else if (line is Map) { } else if (line is Map) {
_socketWatchdog.ping();
if (line['done'] == true) { if (line['done'] == true) {
try { try {
socketService.offEvent(channel); socketService.offEvent(channel);
} catch (_) {} } catch (_) {}
ref.read(chatMessagesProvider.notifier).finishStreaming(); ref.read(chatMessagesProvider.notifier).finishStreaming();
_socketWatchdog.stop();
return; return;
} }
} }
@@ -3077,11 +3136,8 @@ void _attachSocketStreamingHandlers({
} }
socketService.onEvent(channel, handler); socketService.onEvent(channel, handler);
Future.delayed(const Duration(minutes: 3), () { // Start activity watchdog now that handler is attached
try { _socketWatchdog.ping();
socketService.offEvent(channel);
} catch (_) {}
});
} }
void chatHandler(Map<String, dynamic> ev) { void chatHandler(Map<String, dynamic> ev) {
@@ -3175,6 +3231,9 @@ void _attachSocketStreamingHandlers({
try { try {
socketService.offChatEvents(); socketService.offChatEvents();
} catch (_) {} } catch (_) {}
try {
_socketWatchdog.stop();
} catch (_) {}
try { try {
unawaited( unawaited(
api api
@@ -3326,20 +3385,8 @@ void _attachSocketStreamingHandlers({
socketService.onChatEvents(chatHandler); socketService.onChatEvents(chatHandler);
socketService.onChannelEvents(channelEventsHandler); socketService.onChannelEvents(channelEventsHandler);
Future.delayed(const Duration(seconds: 90), () { // Start activity watchdog for chat/channel events
try { _socketWatchdog.ping();
socketService.offChatEvents();
socketService.offChannelEvents();
} catch (_) {}
try {
final msgs = ref.read(chatMessagesProvider);
if (msgs.isNotEmpty &&
msgs.last.role == 'assistant' &&
msgs.last.isStreaming) {
ref.read(chatMessagesProvider.notifier).finishStreaming();
}
} catch (_) {}
});
} }
// ========== Tool Servers (OpenAPI) Helpers ========== // ========== Tool Servers (OpenAPI) Helpers ==========