summaryrefslogtreecommitdiff
path: root/SCons/Tool/ninja/ninja_scons_daemon.py
blob: 2084097fcaa3d28a20af0581deee34be797731e2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
#!/usr/bin/env python3
#
# MIT License
#
# Copyright The SCons Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be included
# in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

"""
This script primarily consists of two threads, the http server thread and the scons interactive
process thread. The http server thread will listen on the passed port for http get request
which should indicate some action for the scons interactive process to take.

The daemon will keep log files in a tmp directory correlated to the hash of the absolute path
of the ninja build dir passed. The daemon will also use a keep alive time to know when to shut
itself down after the passed timeout of no activity. Any time the server receives a get request,
the keep alive time will be reset.
"""

import http.server
import socketserver
from urllib.parse import urlparse, parse_qs
import time
from threading import Condition
from subprocess import PIPE, Popen
import sys
import os
import threading
import queue
import pathlib
import logging
from timeit import default_timer as timer
import traceback
import tempfile
import hashlib
import signal

port = int(sys.argv[1])
ninja_builddir = pathlib.Path(sys.argv[2])
daemon_keep_alive = int(sys.argv[3])
args = sys.argv[4:]

# TODO: Remove the following when Python3.6 support is dropped.
# Windows and Python36 passed nothing for the std handles because of issues with popen
# and its handles so we have to make some fake ones to prevent exceptions.
if sys.platform == 'win32' and sys.version_info[0] == 3 and sys.version_info[1] == 6:
    from io import StringIO
    sys.stderr = StringIO()
    sys.stdout = StringIO()

daemon_dir = pathlib.Path(tempfile.gettempdir()) / (
    "scons_daemon_" + str(hashlib.md5(str(ninja_builddir).encode()).hexdigest())
)
os.makedirs(daemon_dir, exist_ok=True)
logging.basicConfig(
    filename=daemon_dir / "scons_daemon.log",
    filemode="a",
    format="%(asctime)s %(message)s",
    level=logging.DEBUG,
)


def daemon_log(message) -> None:
    logging.debug(message)


def custom_readlines(handle, line_separator: str="\n", chunk_size: int=1):
    buf = ""
    while not handle.closed:
        data = handle.read(chunk_size)
        if not data:
            break
        buf += data.decode("utf-8")
        if line_separator in buf:
            chunks = buf.split(line_separator)
            buf = chunks.pop()
            for chunk in chunks:
                yield chunk + line_separator
        if buf.endswith("scons>>>"):
            yield buf
            buf = ""


def custom_readerr(handle, line_separator: str="\n", chunk_size: int=1):
    buf = ""
    while not handle.closed:
        data = handle.read(chunk_size)
        if not data:
            break
        buf += data.decode("utf-8")
        if line_separator in buf:
            chunks = buf.split(line_separator)
            buf = chunks.pop()
            for chunk in chunks:
                yield chunk + line_separator


def enqueue_output(out, queue) -> None:
    for line in iter(custom_readlines(out)):
        queue.put(line)
    out.close()


def enqueue_error(err, queue) -> None:
    for line in iter(custom_readerr(err)):
        queue.put(line)
    err.close()


input_q = queue.Queue()
output_q = queue.Queue()
error_q = queue.Queue()

building_cv = Condition()
error_cv = Condition()

class StateInfo:
    def __init__(self) -> None:
        self.thread_error = False
        self.finished_building = []
        self.error_nodes = []
        self.startup_failed = False
        self.startup_output = ''
        self.daemon_needs_to_shutdown = False
        self.httpd = None

shared_state = StateInfo()

def sigint_func(signum, frame) -> None:
    global shared_state
    shared_state.daemon_needs_to_shutdown = True

signal.signal(signal.SIGINT, sigint_func)


def daemon_thread_func():
    global shared_state
    try:
        args_list = args + ["--interactive"]
        daemon_log(f"Starting daemon with args: {' '.join(args_list)}")
        daemon_log(f"cwd: {os.getcwd()}")

        p = Popen(args_list, stdout=PIPE, stderr=PIPE, stdin=PIPE)

        t = threading.Thread(target=enqueue_output, args=(p.stdout, output_q))
        t.daemon = True
        t.start()

        te = threading.Thread(target=enqueue_error, args=(p.stderr, error_q))
        te.daemon = True
        te.start()

        daemon_ready = False
        
        building_node = None
        startup_complete = False

        # While scons interactive process is stil running...
        while p.poll() is None:

            # while there is scons output to process
            while True:
                try:
                    line = output_q.get(block=False, timeout=0.01)
                except queue.Empty:
                    # breaks out of the output processing loop
                    break
                else:
                    daemon_log("output: " + line.strip())
                    if not startup_complete:
                        shared_state.startup_output += line

                    if "scons: building terminated because of errors." in line:
                        error_output = ""
                        while True:
                            try:
                                error_output += error_q.get(block=False, timeout=0.01)
                            except queue.Empty:
                                break
                        shared_state.error_nodes += [{"node": building_node, "error": error_output}]
                        daemon_ready = True
                        building_node = None
                        with building_cv:
                            building_cv.notify()

                    elif line == "scons>>>":
                        shared_state.startup_output = ''
                        startup_complete = True

                        with error_q.mutex:
                            error_q.queue.clear()
                        daemon_ready = True
                        with building_cv:
                            building_cv.notify()
                        building_node = None

            # while there is input to process...
            while daemon_ready and not input_q.empty():

                try:
                    building_node = input_q.get(block=False, timeout=0.01)
                except queue.Empty:
                    break
                if "exit" in building_node:
                    daemon_log("input: " + "exit")
                    p.stdin.write("exit\n".encode("utf-8"))
                    p.stdin.flush()
                    with building_cv:
                        shared_state.finished_building += [building_node]
                    daemon_ready = False
                    shared_state.daemon_needs_to_shutdown = True
                    break

                else:
                    input_command = "build " + building_node + "\n"
                    daemon_log("input: " + input_command.strip())

                    p.stdin.write(input_command.encode("utf-8"))
                    p.stdin.flush()
                    with building_cv:
                        shared_state.finished_building += [building_node]
                    daemon_ready = False

            if shared_state.daemon_needs_to_shutdown:
                break
            time.sleep(0.01)

        # our scons process is done, make sure we are shutting down in this case
        if not shared_state.daemon_needs_to_shutdown:
            if not startup_complete:
                shared_state.startup_failed = True
            shared_state.daemon_needs_to_shutdown = True

    except Exception:
        shared_state.thread_error = True
        daemon_log("SERVER ERROR: " + traceback.format_exc())
        raise


daemon_thread = threading.Thread(target=daemon_thread_func)
daemon_thread.daemon = True
daemon_thread.start()

logging.debug(
    f"Starting request server on port {port}, keep alive: {daemon_keep_alive}"
)

keep_alive_timer = timer()

def server_thread_func() -> None:
    global shared_state
    class S(http.server.BaseHTTPRequestHandler):
        def do_GET(self):
            global shared_state
            global keep_alive_timer
            try:
                gets = parse_qs(urlparse(self.path).query)
                
                # process a request from ninja for a node for scons to build. 
                # Currently this is a serial process because scons interactive is serial
                # is it was originally meant for a real human user to be providing input
                # parallel input was never implemented.
                build = gets.get("build")
                if build:
                    keep_alive_timer = timer()

                    daemon_log(f"Got request: {build[0]}")
                    input_q.put(build[0])

                    def pred() -> bool:
                        return build[0] in shared_state.finished_building

                    with building_cv:
                        building_cv.wait_for(pred)

                    for error_node in shared_state.error_nodes:
                        if error_node["node"] == build[0]:
                            self.send_response(500)
                            self.send_header("Content-type", "text/html")
                            self.end_headers()
                            self.wfile.write(error_node["error"].encode())
                            return

                    self.send_response(200)
                    self.send_header("Content-type", "text/html")
                    self.end_headers()
                    return

                # this message is used in server startup, to make sure the server launched
                # successfully. If SCons interactive got to a input prompt (scons>>>), then
                # the server is ready to start processing commands. Otherwise the server will
                # send an error response back to ninja and shut itself down.
                ready = gets.get("ready")
                if ready:
                    if shared_state.startup_failed:
                        self.send_response(500)
                        self.send_header("Content-type", "text/html")
                        self.end_headers()
                        self.wfile.write(shared_state.startup_output.encode())
                        return

                exitbuild = gets.get("exit")
                if exitbuild:
                    input_q.put("exit")

                self.send_response(200)
                self.send_header("Content-type", "text/html")
                self.end_headers()

            except Exception:
                shared_state.thread_error = True
                daemon_log("SERVER ERROR: " + traceback.format_exc())
                raise

            def log_message(self, format, *args) -> None:
                return

    socketserver.TCPServer.allow_reuse_address = True
    shared_state.httpd = socketserver.TCPServer(("127.0.0.1", port), S)
    shared_state.httpd.serve_forever()


server_thread = threading.Thread(target=server_thread_func)
server_thread.daemon = True
server_thread.start()

while (timer() - keep_alive_timer < daemon_keep_alive 
        and not shared_state.thread_error 
        and not shared_state.daemon_needs_to_shutdown):
    time.sleep(1)

if shared_state.thread_error:
    daemon_log(f"Shutting server on port {port} down because thread error.")
elif shared_state.daemon_needs_to_shutdown:
    daemon_log("Server shutting down upon request.")
else:
    daemon_log(
        f"Shutting server on port {port} down because timed out: {daemon_keep_alive}"
    )
shared_state.httpd.shutdown()
if os.path.exists(ninja_builddir / "scons_daemon_dirty"):
    os.unlink(ninja_builddir / "scons_daemon_dirty")
if os.path.exists(daemon_dir / "pidfile"):
    os.unlink(daemon_dir / "pidfile")

# Local Variables:
# tab-width:4
# indent-tabs-mode:nil
# End:
# vim: set expandtab tabstop=4 shiftwidth=4: