|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2013 - 2022 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a background service for the various checkers and other |
|
8 Python interpreter dependent functions. |
|
9 """ |
|
10 |
|
11 import contextlib |
|
12 import json |
|
13 import os |
|
14 import struct |
|
15 import sys |
|
16 from zlib import adler32 |
|
17 |
|
18 from PyQt6.QtCore import QProcess, pyqtSignal, QTimer, QThread |
|
19 from PyQt6.QtWidgets import QApplication |
|
20 from PyQt6.QtNetwork import QTcpServer, QHostAddress |
|
21 |
|
22 from EricWidgets import EricMessageBox |
|
23 from EricWidgets.EricApplication import ericApp |
|
24 import Preferences |
|
25 import Utilities |
|
26 import Globals |
|
27 |
|
28 |
|
29 class BackgroundService(QTcpServer): |
|
30 """ |
|
31 Class implementing the main part of the background service. |
|
32 |
|
33 @signal serviceNotAvailable(function, language, filename, message) |
|
34 emitted to indicate the non-availability of a service function |
|
35 (str, str, str, str) |
|
36 @signal batchJobDone(function, language) emitted to indicate the end of |
|
37 a batch job (str, str) |
|
38 """ |
|
39 serviceNotAvailable = pyqtSignal(str, str, str, str) |
|
40 batchJobDone = pyqtSignal(str, str) |
|
41 |
|
42 def __init__(self, parent=None): |
|
43 """ |
|
44 Constructor |
|
45 |
|
46 @param parent reference to the parent object |
|
47 @type QObject |
|
48 """ |
|
49 super().__init__(parent) |
|
50 |
|
51 self.processes = {} |
|
52 self.connections = {} |
|
53 self.isWorking = None |
|
54 self.runningJob = [None, None, None, None] |
|
55 self.__queue = [] |
|
56 self.services = {} |
|
57 |
|
58 networkInterface = Preferences.getDebugger("NetworkInterface") |
|
59 if networkInterface == "all" or '.' in networkInterface: |
|
60 self.hostAddress = '127.0.0.1' |
|
61 else: |
|
62 self.hostAddress = '::1' |
|
63 self.listen(QHostAddress(self.hostAddress)) |
|
64 |
|
65 self.newConnection.connect(self.on_newConnection) |
|
66 |
|
67 port = self.serverPort() |
|
68 ## Note: Need the port if started external in debugger: |
|
69 print('Background Service listening on: {0:d}'.format(port)) |
|
70 # __IGNORE_WARNING__ |
|
71 venvName = Preferences.getDebugger("Python3VirtualEnv") |
|
72 interpreter = ericApp().getObject( |
|
73 "VirtualEnvManager").getVirtualenvInterpreter(venvName) |
|
74 if not interpreter: |
|
75 interpreter = Globals.getPythonExecutable() |
|
76 if interpreter: |
|
77 process = self.__startExternalClient(interpreter, port) |
|
78 if process: |
|
79 self.processes['Python3'] = process, interpreter |
|
80 |
|
81 def __startExternalClient(self, interpreter, port): |
|
82 """ |
|
83 Private method to start the background client as external process. |
|
84 |
|
85 @param interpreter path and name of the executable to start |
|
86 @type str |
|
87 @param port socket port to which the interpreter should connect |
|
88 @type int |
|
89 @return the process object |
|
90 @rtype QProcess or None |
|
91 """ |
|
92 if interpreter == "" or not Utilities.isinpath(interpreter): |
|
93 return None |
|
94 |
|
95 backgroundClient = os.path.join( |
|
96 os.path.dirname(__file__), "BackgroundClient.py") |
|
97 proc = QProcess(self) |
|
98 proc.setProcessChannelMode( |
|
99 QProcess.ProcessChannelMode.ForwardedChannels) |
|
100 args = [ |
|
101 backgroundClient, |
|
102 self.hostAddress, |
|
103 str(port), |
|
104 str(Preferences.getUI("BackgroundServiceProcesses")), |
|
105 Globals.getPythonLibraryDirectory(), |
|
106 ] |
|
107 proc.start(interpreter, args) |
|
108 if not proc.waitForStarted(10000): |
|
109 proc = None |
|
110 return proc |
|
111 |
|
112 def __processQueue(self): |
|
113 """ |
|
114 Private method to take the next service request and send it to the |
|
115 client. |
|
116 """ |
|
117 if self.__queue and self.isWorking is None: |
|
118 fx, lang, fn, data = self.__queue.pop(0) |
|
119 self.isWorking = lang |
|
120 self.runningJob = fx, lang, fn, data |
|
121 self.__send(fx, lang, fn, data) |
|
122 |
|
123 def __send(self, fx, lang, fn, data): |
|
124 """ |
|
125 Private method to send a job request to one of the clients. |
|
126 |
|
127 @param fx remote function name to execute |
|
128 @type str |
|
129 @param lang language to connect to |
|
130 @type str |
|
131 @param fn filename for identification |
|
132 @type str |
|
133 @param data function argument(s) |
|
134 @type any basic datatype |
|
135 """ |
|
136 self.__cancelled = False |
|
137 connection = self.connections.get(lang) |
|
138 if connection is None: |
|
139 if fx != 'INIT': |
|
140 # Avoid growing recursion depth which could itself result in an |
|
141 # exception |
|
142 QTimer.singleShot( |
|
143 0, |
|
144 lambda: self.serviceNotAvailable.emit( |
|
145 fx, lang, fn, self.tr( |
|
146 '{0} not configured.').format(lang))) |
|
147 # Reset flag and continue processing queue |
|
148 self.isWorking = None |
|
149 self.__processQueue() |
|
150 else: |
|
151 packedData = json.dumps([fx, fn, data]) |
|
152 packedData = bytes(packedData, 'utf-8') |
|
153 header = struct.pack( |
|
154 b'!II', len(packedData), adler32(packedData) & 0xffffffff) |
|
155 connection.write(header) |
|
156 connection.write(b'JOB ') # 6 character message type |
|
157 connection.write(packedData) |
|
158 |
|
159 def __receive(self, lang): |
|
160 """ |
|
161 Private method to receive the response from the clients. |
|
162 |
|
163 @param lang language of the incoming connection |
|
164 @type str |
|
165 @exception RuntimeError raised if hashes don't match |
|
166 """ |
|
167 connection = self.connections[lang] |
|
168 while connection.bytesAvailable(): |
|
169 if self.__cancelled: |
|
170 connection.readAll() |
|
171 continue |
|
172 |
|
173 header = connection.read(struct.calcsize(b'!II')) |
|
174 length, datahash = struct.unpack(b'!II', header) |
|
175 |
|
176 packedData = b'' |
|
177 while len(packedData) < length: |
|
178 maxSize = length - len(packedData) |
|
179 if connection.bytesAvailable() < maxSize: |
|
180 connection.waitForReadyRead(50) |
|
181 packedData += connection.read(maxSize) |
|
182 |
|
183 if adler32(packedData) & 0xffffffff != datahash: |
|
184 raise RuntimeError('Hashes not equal') |
|
185 packedData = packedData.decode('utf-8') |
|
186 # "check" if is's a tuple of 3 values |
|
187 fx, fn, data = json.loads(packedData) |
|
188 |
|
189 if fx == 'INIT': |
|
190 if data != "ok": |
|
191 EricMessageBox.critical( |
|
192 None, |
|
193 self.tr("Initialization of Background Service"), |
|
194 self.tr( |
|
195 "<p>Initialization of Background Service" |
|
196 " <b>{0}</b> failed.</p><p>Reason: {1}</p>") |
|
197 .format(fn, data) |
|
198 ) |
|
199 elif fx == 'EXCEPTION': |
|
200 # Remove connection because it'll close anyway |
|
201 self.connections.pop(lang, None) |
|
202 # Call sys.excepthook(type, value, traceback) to emulate the |
|
203 # exception which was caught on the client |
|
204 sys.excepthook(*data) |
|
205 res = EricMessageBox.question( |
|
206 None, |
|
207 self.tr("Restart background client?"), |
|
208 self.tr( |
|
209 "<p>The background client for <b>{0}</b> has stopped" |
|
210 " due to an exception. It's used by various plug-ins" |
|
211 " like the different checkers.</p>" |
|
212 "<p>Select" |
|
213 "<ul>" |
|
214 "<li><b>'Yes'</b> to restart the client, but abort the" |
|
215 " last job</li>" |
|
216 "<li><b>'Retry'</b> to restart the client and the last" |
|
217 " job</li>" |
|
218 "<li><b>'No'</b> to leave the client off.</li>" |
|
219 "</ul></p>" |
|
220 "<p>Note: The client can be restarted by opening and" |
|
221 " accepting the preferences dialog or reloading/" |
|
222 "changing the project.</p>").format(lang), |
|
223 EricMessageBox.Yes | |
|
224 EricMessageBox.No | |
|
225 EricMessageBox.Retry, |
|
226 EricMessageBox.Yes) |
|
227 |
|
228 if res == EricMessageBox.Retry: |
|
229 self.enqueueRequest(*self.runningJob) |
|
230 else: |
|
231 fx, lng, fn, data = self.runningJob |
|
232 with contextlib.suppress(KeyError, TypeError): |
|
233 self.services[(fx, lng)][3](fx, lng, fn, self.tr( |
|
234 "An error in Eric's background client stopped the" |
|
235 " service.") |
|
236 ) |
|
237 if res != EricMessageBox.No: |
|
238 self.isWorking = None |
|
239 self.restartService(lang, True) |
|
240 return |
|
241 elif data == 'Unknown service.': |
|
242 callback = self.services.get((fx, lang)) |
|
243 if callback: |
|
244 callback[3](fx, lang, fn, data) |
|
245 elif fx.startswith("batch_"): |
|
246 fx = fx.replace("batch_", "") |
|
247 if data != "__DONE__": |
|
248 callback = self.services.get((fx, lang)) |
|
249 if callback: |
|
250 if isinstance(data, (list, tuple)): |
|
251 callback[2](fn, *data) |
|
252 elif isinstance(data, str): |
|
253 callback[3](fx, lang, fn, data) |
|
254 if data == 'Unknown batch service.': |
|
255 self.batchJobDone.emit(fx, lang) |
|
256 self.__cancelled = True |
|
257 else: |
|
258 self.batchJobDone.emit(fx, lang) |
|
259 else: |
|
260 callback = self.services.get((fx, lang)) |
|
261 if callback: |
|
262 callback[2](fn, *data) |
|
263 |
|
264 self.isWorking = None |
|
265 self.__processQueue() |
|
266 |
|
267 def preferencesOrProjectChanged(self): |
|
268 """ |
|
269 Public slot to restart the built in languages. |
|
270 """ |
|
271 venvName = Preferences.getDebugger("Python3VirtualEnv") |
|
272 interpreter = ericApp().getObject( |
|
273 "VirtualEnvManager").getVirtualenvInterpreter(venvName) |
|
274 if not interpreter: |
|
275 interpreter = Globals.getPythonExecutable() |
|
276 |
|
277 # Tweak the processes list to reflect the changed interpreter |
|
278 proc, inter = self.processes.pop('Python3', [None, None]) |
|
279 self.processes['Python3'] = proc, interpreter |
|
280 |
|
281 self.restartService('Python3') |
|
282 |
|
283 def restartService(self, language, forceKill=False): |
|
284 """ |
|
285 Public method to restart a given language. |
|
286 |
|
287 @param language to restart |
|
288 @type str |
|
289 @param forceKill flag to kill a running task |
|
290 @type bool |
|
291 """ |
|
292 try: |
|
293 proc, interpreter = self.processes.pop(language) |
|
294 except KeyError: |
|
295 return |
|
296 |
|
297 # Don't kill a process if it's still working |
|
298 if not forceKill: |
|
299 while self.isWorking is not None: |
|
300 QThread.msleep(100) |
|
301 QApplication.processEvents() |
|
302 |
|
303 conn = self.connections.pop(language, None) |
|
304 if conn: |
|
305 conn.blockSignals(True) |
|
306 conn.close() |
|
307 if proc: |
|
308 proc.close() |
|
309 |
|
310 if interpreter: |
|
311 port = self.serverPort() |
|
312 process = self.__startExternalClient(interpreter, port) |
|
313 if process: |
|
314 self.processes[language] = process, interpreter |
|
315 |
|
316 def enqueueRequest(self, fx, lang, fn, data): |
|
317 """ |
|
318 Public method implementing a queued processing of incoming events. |
|
319 |
|
320 Duplicate service requests update an older request to avoid overrun or |
|
321 starving of the services. |
|
322 |
|
323 @param fx function name of the service |
|
324 @type str |
|
325 @param lang language to connect to |
|
326 @type str |
|
327 @param fn filename for identification |
|
328 @type str |
|
329 @param data function argument(s) |
|
330 @type any basic datatype |
|
331 """ |
|
332 args = [fx, lang, fn, data] |
|
333 if fx == 'INIT': |
|
334 self.__queue.insert(0, args) |
|
335 else: |
|
336 for pendingArg in self.__queue: |
|
337 # Check if it's the same service request (fx, lang, fn equal) |
|
338 if pendingArg[:3] == args[:3]: |
|
339 # Update the data |
|
340 pendingArg[3] = args[3] |
|
341 break |
|
342 else: |
|
343 self.__queue.append(args) |
|
344 self.__processQueue() |
|
345 |
|
346 def requestCancel(self, fx, lang): |
|
347 """ |
|
348 Public method to ask a batch job to terminate. |
|
349 |
|
350 @param fx function name of the service |
|
351 @type str |
|
352 @param lang language to connect to |
|
353 @type str |
|
354 """ |
|
355 self.__cancelled = True |
|
356 |
|
357 entriesToRemove = [] |
|
358 for pendingArg in self.__queue: |
|
359 if pendingArg[:2] == [fx, lang]: |
|
360 entriesToRemove.append(pendingArg) |
|
361 for entryToRemove in entriesToRemove: |
|
362 self.__queue.remove(entryToRemove) |
|
363 |
|
364 connection = self.connections.get(lang) |
|
365 if connection is None: |
|
366 return |
|
367 else: |
|
368 header = struct.pack(b'!II', 0, 0) |
|
369 connection.write(header) |
|
370 connection.write(b'CANCEL') # 6 character message type |
|
371 |
|
372 def serviceConnect( |
|
373 self, fx, lang, modulepath, module, callback, |
|
374 onErrorCallback=None, onBatchDone=None): |
|
375 """ |
|
376 Public method to announce a new service to the background |
|
377 service/client. |
|
378 |
|
379 @param fx function name of the service |
|
380 @type str |
|
381 @param lang language of the new service |
|
382 @type str |
|
383 @param modulepath full path to the module |
|
384 @type str |
|
385 @param module name to import |
|
386 @type str |
|
387 @param callback function called on service response |
|
388 @type function |
|
389 @param onErrorCallback function called, if client isn't available |
|
390 (function) |
|
391 @param onBatchDone function called when a batch job is done |
|
392 @type function |
|
393 """ |
|
394 self.services[(fx, lang)] = ( |
|
395 modulepath, module, callback, onErrorCallback |
|
396 ) |
|
397 self.enqueueRequest('INIT', lang, fx, [modulepath, module]) |
|
398 if onErrorCallback: |
|
399 self.serviceNotAvailable.connect(onErrorCallback) |
|
400 if onBatchDone: |
|
401 self.batchJobDone.connect(onBatchDone) |
|
402 |
|
403 def serviceDisconnect(self, fx, lang): |
|
404 """ |
|
405 Public method to remove the service from the service list. |
|
406 |
|
407 @param fx function name of the service |
|
408 @type function |
|
409 @param lang language of the service |
|
410 @type str |
|
411 """ |
|
412 serviceArgs = self.services.pop((fx, lang), None) |
|
413 if serviceArgs and serviceArgs[3]: |
|
414 self.serviceNotAvailable.disconnect(serviceArgs[3]) |
|
415 |
|
416 def on_newConnection(self): |
|
417 """ |
|
418 Private slot for new incoming connections from the clients. |
|
419 """ |
|
420 connection = self.nextPendingConnection() |
|
421 if not connection.waitForReadyRead(1000): |
|
422 return |
|
423 lang = connection.read(64) |
|
424 lang = lang.decode('utf-8') |
|
425 # Avoid hanging of eric on shutdown |
|
426 if self.connections.get(lang): |
|
427 self.connections[lang].close() |
|
428 if self.isWorking == lang: |
|
429 self.isWorking = None |
|
430 self.connections[lang] = connection |
|
431 connection.readyRead.connect( |
|
432 lambda: self.__receive(lang)) |
|
433 connection.disconnected.connect( |
|
434 lambda: self.on_disconnectSocket(lang)) |
|
435 |
|
436 for (fx, lng), args in self.services.items(): |
|
437 if lng == lang: |
|
438 # Register service with modulepath and module |
|
439 self.enqueueRequest('INIT', lng, fx, args[:2]) |
|
440 |
|
441 # Syntax check the open editors again |
|
442 try: |
|
443 vm = ericApp().getObject("ViewManager") |
|
444 except KeyError: |
|
445 return |
|
446 for editor in vm.getOpenEditors(): |
|
447 if editor.getLanguage() == lang: |
|
448 QTimer.singleShot(0, editor.checkSyntax) |
|
449 |
|
450 def on_disconnectSocket(self, lang): |
|
451 """ |
|
452 Private slot called when connection to a client is lost. |
|
453 |
|
454 @param lang client language which connection is lost |
|
455 @type str |
|
456 """ |
|
457 conn = self.connections.pop(lang, None) |
|
458 if conn: |
|
459 conn.close() |
|
460 fx, lng, fn, data = self.runningJob |
|
461 if fx != 'INIT' and lng == lang: |
|
462 self.services[(fx, lng)][3](fx, lng, fn, self.tr( |
|
463 "Eric's background client disconnected because of an" |
|
464 " unknown reason.") |
|
465 ) |
|
466 self.isWorking = None |
|
467 |
|
468 res = EricMessageBox.yesNo( |
|
469 None, |
|
470 self.tr('Background client disconnected.'), |
|
471 self.tr( |
|
472 'The background client for <b>{0}</b> disconnected because' |
|
473 ' of an unknown reason.<br>Should it be restarted?' |
|
474 ).format(lang), |
|
475 yesDefault=True) |
|
476 if res: |
|
477 self.restartService(lang) |
|
478 |
|
479 def shutdown(self): |
|
480 """ |
|
481 Public method to cleanup the connections and processes when eric is |
|
482 shutting down. |
|
483 """ |
|
484 self.close() |
|
485 |
|
486 for connection in self.connections.values(): |
|
487 connection.readyRead.disconnect() |
|
488 connection.disconnected.disconnect() |
|
489 connection.close() |
|
490 connection.deleteLater() |
|
491 |
|
492 for process, _interpreter in self.processes.values(): |
|
493 process.close() |
|
494 if not process.waitForFinished(10000): |
|
495 process.kill() |
|
496 process = None |