1################################################################ 2# 3# Licensed to the Apache Software Foundation (ASF) under one 4# or more contributor license agreements. See the NOTICE file 5# distributed with this work for additional information 6# regarding copyright ownership. The ASF licenses this file 7# to you under the Apache License, Version 2.0 (the 8# "License"); you may not use this file except in compliance 9# with the License. You may obtain a copy of the License at 10# 11# http://www.apache.org/licenses/LICENSE-2.0 12# 13# Unless required by applicable law or agreed to in writing, 14# software distributed under the License is distributed on an 15# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16# KIND, either express or implied. See the License for the 17# specific language governing permissions and limitations 18# under the License. 19# 20################################################################ 21 22"""Host side of the worker protocol: JSON Lines over the worker's stdin and stdout, with a host-enforced timeout per call. 23 24The worker runs under whichever Python can import uno (aootest_worker). It sends {"hello": {...}} first; each request 25{"id", "op", "args", "timeout"} gets one reply {"id", "ok": true, "result"} or {"id", "ok": false, "error": {...}}. 26Failures are distinguished: WorkerError (the worker answered with an error and is still usable), WorkerHang (no answer 27in time; the process tree was killed), WorkerCrash (the process ended; OfficeDead when it reports its office gone) and ProtocolError (it said something that is not 28the protocol; killed). After the last three the client is finished and a new one must be started. 29""" 30import collections 31import itertools 32import json 33import queue 34import signal 35import subprocess 36import threading 37 38from aootest import proc 39 40PROTOCOL = 1 41_EOF = object() 42 43 44class WorkerFailure(Exception): 45 pass 46 47 48class WorkerError(WorkerFailure): 49 def __init__(self, kind, message="", exc="", traceback=""): 50 super().__init__("%s: %s" % (kind, message)) 51 self.kind, self.message, self.exc, self.traceback = kind, message, exc, traceback 52 53 54class WorkerHang(WorkerFailure): 55 pass 56 57 58class WorkerCrash(WorkerFailure): 59 def __init__(self, message, returncode=None, stderr_tail=""): 60 super().__init__("%s (%s)%s" % (message, describe_exit(returncode), "\n" + stderr_tail if stderr_tail else "")) 61 self.returncode, self.stderr_tail = returncode, stderr_tail 62 63 64class OfficeDead(WorkerCrash): 65 """The worker is fine but its office is gone (the bridge was disposed). Handled as a crash: both are torn down.""" 66 67 def __init__(self, message): 68 super().__init__("office died: " + message) 69 70 71class ProtocolError(WorkerFailure): 72 pass 73 74 75def describe_exit(rc): 76 if rc is None: 77 return "no exit status" 78 if rc == 0 or 0 < rc <= 128: 79 return "exit %d" % rc 80 n = -rc if rc < 0 else rc - 128 81 try: 82 name = signal.Signals(n).name 83 except ValueError: 84 name = "signal %d" % n 85 return "killed by %s" % name if rc < 0 else "exit %d, 128 + %s" % (rc, name) 86 87 88class WorkerClient: 89 def __init__(self, cmd, env=None, cwd=None, hello_timeout=30.0): 90 self.cmd, self.env, self.cwd, self.hello_timeout = list(cmd), env, cwd, hello_timeout 91 self.hello = None 92 self._proc = None 93 self._lines = queue.Queue() 94 self._stderr = collections.deque(maxlen=50) 95 self._ids = itertools.count(1) 96 self._dead = None 97 98 def __enter__(self): 99 self.start() 100 return self 101 102 def __exit__(self, *exc): 103 self.close() 104 105 @property 106 def pid(self): 107 return self._proc.pid if self._proc else None 108 109 def start(self): 110 self._proc = proc.spawn(self.cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=self.env, cwd=self.cwd) 111 threading.Thread(target=self._read_stdout, daemon=True).start() 112 threading.Thread(target=self._read_stderr, daemon=True).start() 113 line = self._next("hello", self.hello_timeout) 114 msg = self._parse(line, "hello") 115 h = msg.get("hello") 116 if not isinstance(h, dict) or h.get("protocol") != PROTOCOL: 117 self._fail_protocol("expected a hello for protocol %d, got %r" % (PROTOCOL, msg)) 118 self.hello = h 119 return self 120 121 def _read_stdout(self): 122 for raw in self._proc.stdout: 123 self._lines.put(raw) 124 self._lines.put(_EOF) 125 126 def _read_stderr(self): 127 for raw in self._proc.stderr: 128 self._stderr.append(raw.decode("utf-8", "replace").rstrip()) 129 130 def _tail(self): 131 return "\n".join(list(self._stderr)[-10:]) 132 133 def _next(self, what, timeout): 134 try: 135 line = self._lines.get(timeout=timeout) 136 except queue.Empty: 137 self._kill() 138 self._dead = WorkerHang("%s: no answer within %gs; worker killed" % (what, timeout)) 139 raise self._dead 140 if line is _EOF: 141 self._lines.put(_EOF) 142 self._crash("worker ended while waiting for %s" % what) 143 return line 144 145 def _parse(self, line, what): 146 try: 147 msg = json.loads(line.decode("utf-8")) 148 except (UnicodeDecodeError, ValueError): 149 self._fail_protocol("%s: not a JSON line: %r" % (what, line[:80])) 150 if not isinstance(msg, dict): 151 self._fail_protocol("%s: not a JSON object: %r" % (what, line[:80])) 152 return msg 153 154 def _kill(self): 155 proc.kill_tree(self._proc) 156 157 def _fail_protocol(self, message): 158 self._kill() 159 self._dead = ProtocolError(message) 160 raise self._dead 161 162 def _crash(self, message): 163 try: 164 self._proc.wait(timeout=5) 165 except Exception: 166 self._kill() 167 self._dead = WorkerCrash(message, self._proc.returncode, self._tail()) 168 raise self._dead 169 170 def call(self, op, args=None, timeout=60.0): 171 """Runs `op` in the worker and returns its result; see the module docstring for what can be raised.""" 172 if self._dead: 173 raise self._dead 174 if self._proc is None: 175 raise RuntimeError("client not started") 176 rid = next(self._ids) 177 data = json.dumps({"id": rid, "op": op, "args": args or {}, "timeout": timeout}).encode("utf-8") + b"\n" 178 try: 179 self._proc.stdin.write(data) 180 self._proc.stdin.flush() 181 except (BrokenPipeError, OSError): 182 self._crash("worker is not reading requests") 183 msg = self._parse(self._next(op, timeout), op) 184 if msg.get("id") != rid or not isinstance(msg.get("ok"), bool): 185 self._fail_protocol("%s: reply does not match request %d: %r" % (op, rid, str(msg)[:120])) 186 if msg["ok"]: 187 return msg.get("result") 188 err = msg.get("error") if isinstance(msg.get("error"), dict) else {} 189 if err.get("kind") == "office_dead": 190 self._kill() 191 self._dead = OfficeDead(err.get("message", "")) 192 raise self._dead 193 raise WorkerError(err.get("kind", "python"), err.get("message", ""), err.get("exc", ""), err.get("traceback", "")) 194 195 def close(self): 196 """Asks the worker to shut down, then kills whatever is left.""" 197 if self._proc is None: 198 return 199 if self._dead is None and self._proc.poll() is None: 200 try: 201 self.call("shutdown", timeout=5) 202 except WorkerFailure: 203 pass 204 self._kill() 205 for s in (self._proc.stdin, self._proc.stdout, self._proc.stderr): 206 try: 207 s.close() 208 except OSError: 209 pass 210