Skip to content

Commit 7c40ecd

Browse files
Merge pull request #668 from QueryaHub/issue/666-plugin-rpc-bridge-resilience
fix(extensions): harden plugin rpc bridge against stdin flush hangs and manifest request timeouts
2 parents c5daddc + 9957075 commit 7c40ecd

8 files changed

Lines changed: 199 additions & 6 deletions

‎lib/core/extensions/extension_driver_session.dart‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,16 @@ class ExtensionDriverSession {
5252

5353
final manifest = await _resolveManifestForRow(row);
5454
final hydrated = await _hydrateSecrets(row);
55-
final bridge = await _startBridge(manifest);
55+
late final PluginRpcBridge bridge;
56+
bridge = await _startBridge(
57+
manifest,
58+
onProcessExited: (code) {
59+
if (_bridges[id] == bridge) {
60+
_bridges.remove(id);
61+
_manifests.remove(id);
62+
}
63+
},
64+
);
5665

5766
try {
5867
await _injectAndConnect(bridge, connectionId: id, row: hydrated);
@@ -99,7 +108,10 @@ class ExtensionDriverSession {
99108
}
100109
}
101110

102-
Future<PluginRpcBridge> _startBridge(ExtensionManifest manifest) async {
111+
Future<PluginRpcBridge> _startBridge(
112+
ExtensionManifest manifest, {
113+
void Function(int code)? onProcessExited,
114+
}) async {
103115
final root = manifest.installPath;
104116
if (root == null || root.isEmpty) {
105117
throw StateError('Extension "${manifest.id}" has no install path');
@@ -127,7 +139,16 @@ class ExtensionDriverSession {
127139
}
128140
}
129141

130-
final bridge = bridgeFactory?.call() ?? PluginRpcBridge();
142+
final manifestTimeoutSeconds =
143+
manifest.sandbox?.resources.timeoutSeconds;
144+
final bridge = bridgeFactory?.call() ??
145+
PluginRpcBridge(
146+
requestTimeout:
147+
manifestTimeoutSeconds != null && manifestTimeoutSeconds > 0
148+
? Duration(seconds: manifestTimeoutSeconds)
149+
: const Duration(seconds: 60),
150+
onProcessExited: onProcessExited,
151+
);
131152
await _startBridgeWithConsent(
132153
bridge: bridge,
133154
manifest: manifest,

‎lib/core/extensions/models/sandbox_capabilities.dart‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ class ResourceLimits {
118118
const ResourceLimits({
119119
this.memoryMb = defaultMemoryMb,
120120
this.maxOpenFiles = defaultMaxOpenFiles,
121+
this.timeoutSeconds,
121122
});
122123

123124
static const defaultMemoryMb = 256;
@@ -129,16 +130,21 @@ class ResourceLimits {
129130
/// Maximum number of open file descriptors (`ulimit -n`).
130131
final int maxOpenFiles;
131132

133+
/// Optional custom request/query timeout declared in manifest, in seconds.
134+
final int? timeoutSeconds;
135+
132136
factory ResourceLimits.fromJson(Map<String, dynamic> json) {
133137
return ResourceLimits(
134138
memoryMb: json['memory_mb'] as int? ?? defaultMemoryMb,
135139
maxOpenFiles: json['max_open_files'] as int? ?? defaultMaxOpenFiles,
140+
timeoutSeconds: json['timeout_seconds'] as int?,
136141
);
137142
}
138143

139144
Map<String, dynamic> toJson() => {
140145
'memory_mb': memoryMb,
141146
'max_open_files': maxOpenFiles,
147+
if (timeoutSeconds != null) 'timeout_seconds': timeoutSeconds,
142148
};
143149
}
144150

‎lib/core/extensions/rpc/json_rpc_stdio_client.dart‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,17 @@ class JsonRpcStdioClient {
7474
};
7575

7676
_stdin.writeln(jsonEncode(payload));
77-
await _stdin.flush();
77+
try {
78+
await _stdin.flush().timeout(const Duration(seconds: 3));
79+
} on TimeoutException {
80+
_pending.remove(id);
81+
throw TimeoutException(
82+
'Failed to flush JSON-RPC request "$method" to plugin stdin within 3s',
83+
);
84+
} catch (e) {
85+
_pending.remove(id);
86+
rethrow;
87+
}
7888

7989
try {
8090
return await completer.future.timeout(requestTimeout);

‎lib/core/extensions/rpc/plugin_rpc_bridge.dart‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ import 'package:querya_desktop/core/extensions/sandbox/sandbox_auto_recovery.dar
1818
class PluginRpcBridge {
1919
PluginRpcBridge({
2020
SandboxProcessRunner? processRunner,
21-
this.handshakeTimeout = const Duration(seconds: 3),
21+
this.handshakeTimeout = const Duration(seconds: 6),
2222
this.shutdownTimeout = const Duration(seconds: 3),
2323
this.requestTimeout = const Duration(seconds: 30),
2424
this.enableWatchdog = true,
2525
this.enableStderrPipe = true,
26+
this.onProcessExited,
2627
SandboxSecurityAudit? audit,
2728
SandboxAutoRecovery? recovery,
2829
}) : _runner = processRunner ?? SandboxProcessRunner(),
@@ -35,6 +36,7 @@ class PluginRpcBridge {
3536
final Duration requestTimeout;
3637
final bool enableWatchdog;
3738
final bool enableStderrPipe;
39+
final void Function(int exitCode)? onProcessExited;
3840
final SandboxSecurityAudit? _audit;
3941
final SandboxAutoRecovery _recovery;
4042

@@ -125,6 +127,11 @@ class PluginRpcBridge {
125127
pluginId: handle.pluginId,
126128
detail: 'watchdog deadlock',
127129
));
130+
_failPending(PluginDeadlockException(
131+
pluginId: handle.pluginId,
132+
message: 'Watchdog detected plugin deadlock (ping timeout)',
133+
));
134+
unawaited(_disposeLocal(keepRecovery: true));
128135
}
129136
},
130137
);
@@ -138,9 +145,13 @@ class PluginRpcBridge {
138145
_recovery.recordSuccess();
139146
return result;
140147
} on TimeoutException {
148+
final recent = _stderrPipe?.recentLines ?? const [];
149+
final stderrTail = recent.isNotEmpty
150+
? '\nStderr output:\n${recent.join('\n')}'
151+
: '';
141152
await _forceKill();
142153
throw PluginProtocolTimeoutException(
143-
'system.handshake timed out after $handshakeTimeout',
154+
'system.handshake timed out after $handshakeTimeout$stderrTail',
144155
);
145156
}
146157
}
@@ -212,6 +223,7 @@ class PluginRpcBridge {
212223
pluginId: _handle?.pluginId ?? 'unknown',
213224
exitCode: code,
214225
));
226+
onProcessExited?.call(code);
215227
unawaited(_disposeLocal(keepRecovery: true));
216228
}
217229

‎lib/core/extensions/rpc/plugin_rpc_exceptions.dart‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,20 @@ class PluginProtocolTimeoutException implements Exception {
2626
@override
2727
String toString() => 'PluginProtocolTimeoutException: $message';
2828
}
29+
30+
/// Thrown when the watchdog detects that the plugin process is deadlocked (ping timeout).
31+
class PluginDeadlockException implements Exception {
32+
PluginDeadlockException({
33+
required this.pluginId,
34+
this.message,
35+
});
36+
37+
final String pluginId;
38+
final String? message;
39+
40+
@override
41+
String toString() {
42+
final detail = message == null ? '' : ': $message';
43+
return 'PluginDeadlockException($pluginId)$detail';
44+
}
45+
}

‎lib/core/extensions/sandbox/sandbox_stderr_pipe.dart‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ class SandboxStderrPipe {
3939
final int maxCarryChars;
4040
final int maxSanitizeChars;
4141

42+
final List<String> _recentLines = [];
43+
44+
/// Rolling buffer of recent sanitized stderr lines (last 20 lines max).
45+
List<String> get recentLines => List.unmodifiable(_recentLines);
46+
4247
StreamSubscription<List<int>>? _subscription;
4348
final StringBuffer _carry = StringBuffer();
4449
int _carryLength = 0;
@@ -193,6 +198,10 @@ class SandboxStderrPipe {
193198
);
194199
}
195200
onSanitizedLine?.call(sanitized);
201+
_recentLines.add(sanitized);
202+
if (_recentLines.length > 20) {
203+
_recentLines.removeAt(0);
204+
}
196205
await log.appendLine(sanitized);
197206
} catch (e, st) {
198207
debugPrint('SandboxStderrPipe($pluginId) write failed: $e\n$st');

‎test/core/extensions/extension_driver_session_test.dart‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:querya_desktop/core/extensions/extension_driver_session.dart';
33
import 'package:querya_desktop/core/extensions/models/extension_driver_capabilities.dart';
44
import 'package:querya_desktop/core/extensions/models/extension_object_metadata.dart';
55
import 'package:querya_desktop/core/extensions/models/extension_server_stats.dart';
6+
import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart';
67
import 'package:querya_desktop/core/storage/local_db.dart';
78

89
void main() {
@@ -136,5 +137,18 @@ void main() {
136137
expect(metadata.columns[0].comment, 'Primary ID');
137138
expect(metadata.properties['engine'], 'MergeTree');
138139
});
140+
141+
test('ResourceLimits parses timeout_seconds correctly', () {
142+
final limits = ResourceLimits.fromJson({
143+
'memory_mb': 512,
144+
'max_open_files': 128,
145+
'timeout_seconds': 600,
146+
});
147+
148+
expect(limits.memoryMb, 512);
149+
expect(limits.maxOpenFiles, 128);
150+
expect(limits.timeoutSeconds, 600);
151+
expect(limits.toJson()['timeout_seconds'], 600);
152+
});
139153
});
140154
}

‎test/core/extensions/rpc/plugin_rpc_bridge_test.dart‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import 'package:querya_desktop/core/extensions/models/extension_type.dart';
88
import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart';
99
import 'package:querya_desktop/core/extensions/rpc/plugin_rpc_bridge.dart';
1010
import 'package:querya_desktop/core/extensions/rpc/plugin_rpc_exceptions.dart';
11+
import 'package:querya_desktop/core/extensions/sandbox/sandbox_log_paths.dart';
1112
import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart';
1213

1314
class _FakeProcess implements Process {
@@ -66,9 +67,11 @@ void main() {
6667

6768
setUp(() async {
6869
tempBase = await Directory.systemTemp.createTemp('querya_rpc_bridge_');
70+
SandboxLogPaths.mockLogsDirectory = tempBase;
6971
});
7072

7173
tearDown(() async {
74+
SandboxLogPaths.mockLogsDirectory = null;
7275
try {
7376
if (await tempBase.exists()) {
7477
await tempBase.delete(recursive: true);
@@ -316,4 +319,105 @@ void main() {
316319
expect(bridge.isStarted, isFalse);
317320
await sub.cancel();
318321
});
322+
323+
test('unexpected exit fires onProcessExited callback', () async {
324+
final process = _FakeProcess();
325+
final sub = process.stdinLines.listen((line) {
326+
final req = jsonDecode(line) as Map<String, dynamic>;
327+
if (req['method'] == 'system.handshake') {
328+
process.reply({
329+
'jsonrpc': '2.0',
330+
'id': req['id'] as int,
331+
'result': {'ok': true},
332+
});
333+
}
334+
});
335+
336+
final runner = SandboxProcessRunner(
337+
platformOverride: 'windows',
338+
scratchBaseDirectory: tempBase,
339+
processStarter: (
340+
exe,
341+
args, {
342+
String? workingDirectory,
343+
Map<String, String>? environment,
344+
bool includeParentEnvironment = true,
345+
bool runInShell = false,
346+
ProcessStartMode mode = ProcessStartMode.normal,
347+
}) async =>
348+
process,
349+
);
350+
351+
int? capturedExitCode;
352+
final bridge = PluginRpcBridge(
353+
processRunner: runner,
354+
enableWatchdog: false,
355+
enableStderrPipe: false,
356+
onProcessExited: (code) {
357+
capturedExitCode = code;
358+
},
359+
);
360+
361+
await bridge.start(
362+
manifest: testManifest,
363+
pluginExecutable: '/opt/driver',
364+
allowUnsandboxedLaunch: true,
365+
);
366+
367+
process.completeExit(137); // SIGKILL
368+
await Future<void>.delayed(const Duration(milliseconds: 20));
369+
370+
expect(capturedExitCode, 137);
371+
expect(bridge.isStarted, isFalse);
372+
await sub.cancel();
373+
});
374+
375+
test('handshake timeout includes recent stderr output in exception', () async {
376+
final process = _FakeProcess();
377+
final sub = process.stdinLines.listen((_) {
378+
process._stderrController.add(
379+
utf8.encode('FATAL: libclickhouse.so not found\n'),
380+
);
381+
});
382+
383+
final runner = SandboxProcessRunner(
384+
platformOverride: 'windows',
385+
scratchBaseDirectory: tempBase,
386+
processStarter: (
387+
exe,
388+
args, {
389+
String? workingDirectory,
390+
Map<String, String>? environment,
391+
bool includeParentEnvironment = true,
392+
bool runInShell = false,
393+
ProcessStartMode mode = ProcessStartMode.normal,
394+
}) async =>
395+
process,
396+
);
397+
398+
final bridge = PluginRpcBridge(
399+
processRunner: runner,
400+
handshakeTimeout: const Duration(milliseconds: 50),
401+
enableWatchdog: false,
402+
enableStderrPipe: true,
403+
);
404+
405+
final startFuture = bridge.start(
406+
manifest: testManifest,
407+
pluginExecutable: '/opt/driver',
408+
allowUnsandboxedLaunch: true,
409+
);
410+
411+
await expectLater(
412+
startFuture,
413+
throwsA(
414+
isA<PluginProtocolTimeoutException>().having(
415+
(e) => e.message,
416+
'message',
417+
contains('FATAL: libclickhouse.so not found'),
418+
),
419+
),
420+
);
421+
await sub.cancel();
422+
});
319423
}

0 commit comments

Comments
 (0)