Coverage for /home/antoine/projects/xpra-git/dist/python3/lib64/python/xpra/net/subprocess_wrapper.py : 75%
Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# This file is part of Xpra.
2# Copyright (C) 2015-2020 Antoine Martin <antoine@xpra.org>
3# Xpra is released under the terms of the GNU GPL v2, or, at your option, any
4# later version. See the file COPYING for details.
6import os
7import sys
8import subprocess
9from queue import Queue
11from xpra.gtk_common.gobject_compat import register_os_signals
12from xpra.util import repr_ellipsized, envint, envbool
13from xpra.net.bytestreams import TwoFileConnection
14from xpra.net.common import ConnectionClosedException, PACKET_TYPES
15from xpra.net.protocol import Protocol
16from xpra.os_util import setbinarymode, SIGNAMES, bytestostr, hexstr, WIN32
17from xpra.child_reaper import getChildReaper
18from xpra.log import Logger
20log = Logger("util")
23#this wrapper allows us to interact with a subprocess as if it was
24#a normal class with gobject signals
25#so that we can interact with it using a standard xpra protocol layer
26#there is a wrapper for the caller
27#and one for the class
28#they talk to each other through stdin / stdout,
29#using the protocol for encoding the data
32DEBUG_WRAPPER = envbool("XPRA_WRAPPER_DEBUG", False)
33#to make it possible to inspect files (more human readable):
34HEXLIFY_PACKETS = envbool("XPRA_HEXLIFY_PACKETS", False)
35#avoids showing a new console window on win32:
36WIN32_SHOWWINDOW = envbool("XPRA_WIN32_SHOWWINDOW", False)
37#assume that the subprocess is running the same version of xpra,
38#so we can set the packet aliases without exchanging and parsing caps:
39#(this can break, ie: if both python2 and python3 builds are installed
40# and the python2 builds are from an older version)
41LOCAL_ALIASES = envbool("XPRA_LOCAL_ALIASES", False)
43LOCAL_SEND_ALIASES = dict((v, i) for i,v in enumerate(PACKET_TYPES))
44LOCAL_RECEIVE_ALIASES = dict(enumerate(PACKET_TYPES))
46FLUSH = envbool("XPRA_SUBPROCESS_FLUSH", False)
49FAULT_RATE = envint("XPRA_WRAPPER_FAULT_INJECTION_RATE")
50def noop(_p):
51 pass
52INJECT_FAULT = noop
53if FAULT_RATE>0:
54 _counter = 0
55 def DO_INJECT_FAULT(p):
56 global _counter
57 _counter += 1
58 if (_counter % FAULT_RATE)==0:
59 log.warn("injecting fault in %s", p)
60 p.raw_write("junk", "Wrapper JUNK! added by fault injection code")
61 INJECT_FAULT = DO_INJECT_FAULT
64def setup_fastencoder_nocompression(protocol):
65 from xpra.net.packet_encoding import get_enabled_encoders, PERFORMANCE_ORDER
66 encoders = get_enabled_encoders(PERFORMANCE_ORDER)
67 assert len(encoders)>0, "no packet encoders available!?"
68 for encoder in encoders:
69 try:
70 protocol.enable_encoder(encoder)
71 log("protocol using %s", encoder)
72 break
73 except Exception as e:
74 log("failed to enable %s: %s", encoder, e)
75 #we assume this is local, so no compression:
76 protocol.enable_compressor("none")
79class subprocess_callee:
80 """
81 This is the callee side, wrapping the gobject we want to interact with.
82 All the input received will be converted to method calls on the wrapped object.
83 Subclasses should register the signal handlers they want to see exported back to the caller.
84 The convenience connect_export(signal-name, *args) can be used to forward signals unmodified.
85 You can also call send() to pass packets back to the caller.
86 (there is no validation of which signals are valid or not)
87 """
88 def __init__(self, input_filename="-", output_filename="-", wrapped_object=None, method_whitelist=None):
89 self.name = ""
90 self._input = None
91 self._output = None
92 self.input_filename = input_filename
93 self.output_filename = output_filename
94 self.method_whitelist = method_whitelist
95 self.large_packets = []
96 #the gobject instance which is wrapped:
97 self.wrapped_object = wrapped_object
98 self.send_queue = Queue()
99 self.protocol = None
100 register_os_signals(self.handle_signal, self.name)
101 self.setup_mainloop()
103 def setup_mainloop(self):
104 from gi.repository import GLib
105 self.mainloop = GLib.MainLoop()
106 self.idle_add = GLib.idle_add
107 self.timeout_add = GLib.timeout_add
108 self.source_remove = GLib.source_remove
111 def connect_export(self, signal_name, *user_data):
112 """ gobject style signal registration for the wrapped object,
113 the signals will automatically be forwarded to the wrapper process
114 using send(signal_name, *signal_args, *user_data)
115 """
116 log("connect_export%s", [signal_name] + list(user_data))
117 args = list(user_data) + [signal_name]
118 self.wrapped_object.connect(signal_name, self.export, *args)
120 def export(self, *args):
121 signal_name = args[-1]
122 log("export(%s, ...)", signal_name)
123 data = args[1:-1]
124 self.send(signal_name, *tuple(data))
127 def start(self):
128 self.protocol = self.make_protocol()
129 self.protocol.start()
130 try:
131 self.run()
132 return 0
133 except KeyboardInterrupt as e:
134 log("start() KeyboardInterrupt %s", e)
135 if str(e):
136 log.warn("%s", e)
137 return 0
138 except Exception:
139 log.error("error in main loop", exc_info=True)
140 return 1
141 finally:
142 log("run() ended, calling cleanup and protocol close")
143 self.cleanup()
144 if self.protocol:
145 self.protocol.close()
146 self.protocol = None
147 i = self._input
148 if i:
149 self._input = None
150 try:
151 i.close()
152 except OSError:
153 log("%s.close()", i, exc_info=True)
154 o = self._output
155 if o:
156 self._output = None
157 try:
158 o.close()
159 except OSError:
160 log("%s.close()", o, exc_info=True)
162 def make_protocol(self):
163 #figure out where we read from and write to:
164 if self.input_filename=="-":
165 #disable stdin buffering:
166 self._input = os.fdopen(sys.stdin.fileno(), 'rb', 0)
167 setbinarymode(self._input.fileno())
168 else:
169 self._input = open(self.input_filename, 'rb')
170 if self.output_filename=="-":
171 #disable stdout buffering:
172 self._output = os.fdopen(sys.stdout.fileno(), 'wb', 0)
173 setbinarymode(self._output.fileno())
174 else:
175 self._output = open(self.output_filename, 'wb')
176 #stdin and stdout wrapper:
177 conn = TwoFileConnection(self._output, self._input,
178 abort_test=None, target=self.name,
179 socktype=self.name, close_cb=self.net_stop)
180 conn.timeout = 0
181 protocol = Protocol(self, conn, self.process_packet, get_packet_cb=self.get_packet)
182 if LOCAL_ALIASES:
183 protocol.send_aliases = LOCAL_SEND_ALIASES
184 protocol.receive_aliases = LOCAL_RECEIVE_ALIASES
185 setup_fastencoder_nocompression(protocol)
186 protocol.large_packets = self.large_packets
187 return protocol
190 def run(self):
191 self.mainloop.run()
194 def net_stop(self):
195 #this is called from the network thread,
196 #we use idle add to ensure we clean things up from the main thread
197 log("net_stop() will call stop from main thread")
198 self.idle_add(self.stop)
201 def cleanup(self):
202 pass
204 def stop(self):
205 self.cleanup()
206 p = self.protocol
207 log("stop() protocol=%s", p)
208 if p:
209 self.protocol = None
210 p.close()
211 self.do_stop()
213 def do_stop(self):
214 log("stop() stopping mainloop %s", self.mainloop)
215 self.mainloop.quit()
217 def handle_signal(self, sig):
218 """ This is for OS signals SIGINT and SIGTERM """
219 #next time, just stop:
220 register_os_signals(self.signal_stop, self.name)
221 signame = SIGNAMES.get(sig, sig)
222 log("handle_signal(%s) calling stop from main thread", signame)
223 self.send("signal", signame)
224 self.timeout_add(0, self.cleanup)
225 #give time for the network layer to send the signal message
226 self.timeout_add(150, self.stop)
228 def signal_stop(self, sig):
229 """ This time we really want to exit without waiting """
230 signame = SIGNAMES.get(sig, sig)
231 log("signal_stop(%s) calling stop", signame)
232 self.stop()
235 def send(self, *args):
236 if HEXLIFY_PACKETS:
237 args = args[:1]+[hexstr(str(x)[:32]) for x in args[1:]]
238 log("send: adding '%s' message (%s items already in queue)", args[0], self.send_queue.qsize())
239 self.send_queue.put(args)
240 p = self.protocol
241 if p:
242 p.source_has_more()
243 INJECT_FAULT(p)
245 def get_packet(self):
246 try:
247 item = self.send_queue.get(False)
248 except Exception:
249 item = None
250 return (item, None, None, self.send_queue.qsize()>0)
252 def process_packet(self, proto, packet):
253 command = bytestostr(packet[0])
254 if command==Protocol.CONNECTION_LOST:
255 log("connection-lost: %s, calling stop", packet[1:])
256 self.net_stop()
257 return
258 if command==Protocol.GIBBERISH:
259 log.warn("gibberish received:")
260 log.warn(" %s", repr_ellipsized(packet[1], limit=80))
261 log.warn(" stopping")
262 self.net_stop()
263 return
264 if command=="stop":
265 log("received stop message")
266 self.net_stop()
267 return
268 if command=="exit":
269 log("received exit message")
270 sys.exit(0)
271 return
272 #make it easier to hookup signals to methods:
273 attr = command.replace("-", "_")
274 if self.method_whitelist is not None and attr not in self.method_whitelist:
275 log.warn("invalid command: %s (not in whitelist: %s)", attr, self.method_whitelist)
276 return
277 wo = self.wrapped_object
278 if not wo:
279 log("wrapped object is no more, ignoring method call '%s'", attr)
280 return
281 method = getattr(wo, attr, None)
282 if not method:
283 log.warn("unknown command: '%s'", attr)
284 log.warn(" packet: '%s'", repr_ellipsized(str(packet)))
285 return
286 if DEBUG_WRAPPER:
287 log("calling %s.%s%s", wo, attr, str(tuple(packet[1:]))[:128])
288 self.idle_add(method, *packet[1:])
289 INJECT_FAULT(proto)
292def exec_kwargs() -> dict:
293 kwargs = {}
294 stderr = sys.stderr.fileno()
295 if WIN32:
296 from xpra.platform.win32 import REDIRECT_OUTPUT
297 if REDIRECT_OUTPUT:
298 #stderr is not valid and would give us this error:
299 # WindowsError: [Errno 6] The handle is invalid
300 stderr = open(os.devnull, 'w')
301 if not WIN32_SHOWWINDOW:
302 startupinfo = subprocess.STARTUPINFO()
303 startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
304 startupinfo.wShowWindow = 0 #aka win32.con.SW_HIDE
305 kwargs["startupinfo"] = startupinfo
306 kwargs["stderr"] = stderr
307 return kwargs
309def exec_env(blacklist=("LS_COLORS", )) -> dict:
310 env = os.environ.copy()
311 env["XPRA_SKIP_UI"] = "1"
312 env["XPRA_FORCE_COLOR_LOG"] = "1"
313 #let's make things more complicated than they should be:
314 #on win32, the environment can end up containing unicode, and subprocess chokes on it
315 for k,v in env.items():
316 if k in blacklist:
317 continue
318 try:
319 env[k] = bytestostr(v.encode("utf8"))
320 except Exception:
321 env[k] = bytestostr(v)
322 return env
325class subprocess_caller:
326 """
327 This is the caller side, wrapping the subprocess.
328 You can call send() to pass packets to it
329 which will get converted to method calls on the receiving end,
330 You can register for signals, in which case your callbacks will be called
331 when those signals are forwarded back.
332 (there is no validation of which signals are valid or not)
333 """
335 def __init__(self, description="wrapper"):
336 self.process = None
337 self.protocol = None
338 self.command = None
339 self.description = description
340 self.send_queue = Queue()
341 self.signal_callbacks = {}
342 self.large_packets = []
343 #hook a default packet handlers:
344 self.connect(Protocol.CONNECTION_LOST, self.connection_lost)
345 self.connect(Protocol.GIBBERISH, self.gibberish)
346 from gi.repository import GLib
347 self.idle_add = GLib.idle_add
348 self.timeout_add = GLib.timeout_add
349 self.source_remove = GLib.source_remove
352 def connect(self, signal, cb, *args):
353 """ gobject style signal registration """
354 self.signal_callbacks.setdefault(signal, []).append((cb, list(args)))
357 def subprocess_exit(self, *args):
358 #beware: this may fire more than once!
359 log("subprocess_exit%s command=%s", args, self.command)
360 self._fire_callback("exit")
362 def start(self):
363 assert self.process is None, "already started"
364 self.process = self.exec_subprocess()
365 self.protocol = self.make_protocol()
366 self.protocol.start()
368 def abort_test(self, action):
369 p = self.process
370 if p is None or p.poll():
371 raise ConnectionClosedException("cannot %s: subprocess has terminated" % action) from None
373 def make_protocol(self):
374 #make a connection using the process stdin / stdout
375 conn = TwoFileConnection(self.process.stdin, self.process.stdout,
376 abort_test=self.abort_test, target=self.description,
377 socktype=self.description, close_cb=self.subprocess_exit)
378 conn.timeout = 0
379 protocol = Protocol(self, conn, self.process_packet, get_packet_cb=self.get_packet)
380 if LOCAL_ALIASES:
381 protocol.send_aliases = LOCAL_SEND_ALIASES
382 protocol.receive_aliases = LOCAL_RECEIVE_ALIASES
383 setup_fastencoder_nocompression(protocol)
384 protocol.large_packets = self.large_packets
385 return protocol
388 def exec_subprocess(self):
389 kwargs = exec_kwargs()
390 env = self.get_env()
391 log("exec_subprocess() command=%s, env=%s, kwargs=%s", self.command, env, kwargs)
392 proc = subprocess.Popen(self.command, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
393 env=env, start_new_session=True, **kwargs)
394 getChildReaper().add_process(proc, self.description, self.command, True, True, callback=self.subprocess_exit)
395 return proc
397 def get_env(self):
398 env = exec_env()
399 env["XPRA_LOG_PREFIX"] = "%s " % self.description
400 env["XPRA_FIX_UNICODE_OUT"] = "0"
401 return env
403 def cleanup(self):
404 self.stop()
406 def stop(self):
407 self.stop_process()
408 self.stop_protocol()
410 def stop_process(self):
411 log("%s.stop_process() sending stop request to %s", self, self.description)
412 proc = self.process
413 if proc and proc.poll() is None:
414 try:
415 proc.terminate()
416 self.process = None
417 except Exception as e:
418 log.warn("failed to stop the wrapped subprocess %s: %s", proc, e)
420 def stop_protocol(self):
421 p = self.protocol
422 if p:
423 self.protocol = None
424 log("%s.stop_protocol() calling %s", self, p.close)
425 try:
426 p.close()
427 except Exception as e:
428 log.warn("failed to close the subprocess connection: %s", p, e)
431 def connection_lost(self, *args):
432 log("connection_lost%s", args)
433 self.stop()
435 def gibberish(self, *args):
436 log.warn("%s stopping on gibberish:", self.description)
437 log.warn(" %s", repr_ellipsized(args[1], limit=80))
438 self.stop()
441 def get_packet(self):
442 try:
443 item = self.send_queue.get(False)
444 except Exception:
445 item = None
446 return (item, None, None, None, False, self.send_queue.qsize()>0)
448 def send(self, *packet_data):
449 self.send_queue.put(packet_data)
450 p = self.protocol
451 if p:
452 p.source_has_more()
453 if FLUSH:
454 conn = p._conn
455 if conn and conn.is_active():
456 conn.flush()
457 INJECT_FAULT(p)
459 def process_packet(self, proto, packet):
460 if DEBUG_WRAPPER:
461 log("process_packet(%s, %s)", proto, [str(x)[:32] for x in packet])
462 signal_name = bytestostr(packet[0])
463 self._fire_callback(signal_name, packet[1:])
464 INJECT_FAULT(proto)
466 def _fire_callback(self, signal_name, extra_args=()):
467 callbacks = self.signal_callbacks.get(signal_name)
468 log("firing callback for '%s': %s", signal_name, callbacks)
469 if callbacks:
470 for cb, args in callbacks:
471 try:
472 all_args = list(args) + list(extra_args)
473 self.idle_add(cb, self, *all_args)
474 except Exception:
475 log.error("error processing callback %s for %s packet", cb, signal_name, exc_info=True)