src/eric7/Utilities/BackgroundService.py

branch
eric7
changeset 9209
b99e7fd55fd3
parent 9016
6f079c524e99
child 9221
bf71ee032bb4
equal deleted inserted replaced
9208:3fc8dfeb6ebe 9209:b99e7fd55fd3
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

eric ide

mercurial