xref: /trunk/test/aoo-test/src/aootest/client.py (revision 2cb6d6f119bbe2f78742f1c5c8538cb0d4e72ede)
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