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