1# This file is part of ReText
2# Copyright: 2016 Maurice van der Pot
3#
4# This program is free software; you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation; either version 2 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17import markups
18import multiprocessing as mp
19import os
20import pickle
21import signal
22from socket import socketpair
23import struct
24import traceback
25import weakref
26
27from PyQt5.QtCore import pyqtSignal, QObject, QSocketNotifier
28
29def recvall(sock, remaining):
30    alldata = bytearray()
31    while remaining > 0:
32        data = sock.recv(remaining)
33        if len(data) == 0:
34            raise EOFError('Received 0 bytes from socket while more bytes were expected. Did the sender process exit unexpectedly?')
35        alldata.extend(data)
36        remaining -= len(data)
37
38    return alldata
39
40def receiveObject(sock):
41    sizeBuf = recvall(sock, 4)
42    size = struct.unpack('I', sizeBuf)[0]
43    message = recvall(sock, size)
44    obj = pickle.loads(message)
45    return obj
46
47def sendObject(sock, obj):
48    message = pickle.dumps(obj)
49    sizeBuf = struct.pack('I', len(message))
50    sock.sendall(sizeBuf)
51    sock.sendall(message)
52
53class ConversionError(Exception):
54    pass
55
56class MarkupNotAvailableError(Exception):
57    pass
58
59def _indent(text, prefix):
60    return ''.join(('%s%s\n' % (prefix, line) for line in text.splitlines()))
61
62def _converter_process_func(conn_parent, conn_child):
63    conn_parent.close()
64
65    # Ignore ctrl-C. The main application will also receive the signal and
66    # determine if the application should be stopped or not.
67    signal.signal(signal.SIGINT, signal.SIG_IGN)
68
69    current_markup = None
70
71    while True:
72        job = receiveObject(conn_child)
73        if job['command'] == 'quit':
74            break
75        elif job['command'] == 'convert':
76            try:
77                os.chdir(job['current_dir'])
78                if (not current_markup or
79                    current_markup.name != job['markup_name'] or
80                    current_markup.filename != job['filename']):
81                    markup_class = markups.find_markup_class_by_name(job['markup_name'])
82                    if not markup_class.available():
83                        raise MarkupNotAvailableError('The specified markup was not available')
84
85                    current_markup = markup_class(job['filename'])
86                    current_markup.requested_extensions = job['requested_extensions']
87
88                converted = current_markup.convert(job['text'])
89                result = ('ok', converted)
90            except MarkupNotAvailableError as e:
91                result = ('markupnotavailableerror', e.args)
92            except Exception:
93                result = ('conversionerror',
94                          'The background markup conversion process received this exception:\n%s' %
95                          _indent(traceback.format_exc(), '    '))
96
97            try:
98                sendObject(conn_child, result)
99            except BrokenPipeError:
100                # Continue despite the broken pipe because we expect that a
101                # 'quit' command will have been sent. If it has been then we
102                # should terminate without any error messages. If no command
103                # was queued we will get an EOFError from the read, giving us a
104                # second chance to show that something went wrong by exiting
105                # with a traceback.
106                continue
107
108
109class ConverterProcess(QObject):
110
111    conversionDone = pyqtSignal()
112
113    def __init__(self):
114        super().__init__()
115
116        conn_parent, conn_child = socketpair()
117
118        # TODO: figure out which of the two sockets should be set to
119        #       inheritable and which should be passed to the child
120        if hasattr(conn_child, 'set_inheritable'):
121            conn_child.set_inheritable(True)
122
123        # Use a local variable for child so that we can talk to the child in
124        # on_finalize without needing a reference to self
125        child = mp.Process(target=_converter_process_func, args=(conn_parent, conn_child))
126        child.daemon = True
127        child.start()
128        self.child = child
129
130        conn_child.close()
131        self.conn = conn_parent
132
133        self.busy = False
134        self.notificationPending = False
135        self.conversionNotifier = QSocketNotifier(self.conn.fileno(),
136                                                  QSocketNotifier.Type.Read)
137        self.conversionNotifier.activated.connect(self._conversionNotifierActivated)
138
139        def on_finalize(conn):
140            sendObject(conn_parent, {'command':'quit'})
141            conn_parent.close()
142            child.join()
143
144        weakref.finalize(self, on_finalize, conn_parent)
145
146    def _conversionNotifierActivated(self):
147        # The ready-for-read signal on the socket may be triggered multiple
148        # times, but we only send a single notification to the client as soon
149        # as the results of the conversion are starting to come in. This makes
150        # it easy for clients to avoid multiple calls to get_result for the
151        # same conversion.
152        if self.notificationPending:
153            self.notificationPending = False
154
155            # Set the socket to blocking before waking up any interested parties,
156            # because it has been set to unblocking by QSocketNotifier
157            self.conn.setblocking(True)
158            self.conversionDone.emit()
159
160    def start_conversion(self, markup_name, filename, requested_extensions, text, current_dir):
161        if self.busy:
162            raise RuntimeError('Already converting')
163
164        sendObject(self.conn, {'command': 'convert',
165                               'markup_name' : markup_name,
166                               'filename' : filename,
167                               'current_dir': current_dir,
168                               'requested_extensions' : requested_extensions,
169                               'text' : text})
170        self.busy = True
171        self.notificationPending = True
172
173    def get_result(self):
174        if not self.busy:
175            raise RuntimeError('No ongoing conversion')
176
177        self.busy = False
178
179        status, result = receiveObject(self.conn)
180
181        if status == 'markupnotavailableerror':
182            raise MarkupNotAvailableError(result)
183        elif status == 'conversionerror':
184            raise ConversionError(result)
185
186        return result
187
188    def stop(self):
189        sendObject(self.conn, {'command': 'quit'})
190        self.conn.close()
191
192