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"""Starting the office, its profile, and the session that restarts it after a hang or crash.""" 23import socket 24import uuid 25from pathlib import Path 26 27from aootest import proc 28from aootest.client import ProtocolError, WorkerCrash, WorkerHang 29 30XCU_HEAD = ('<?xml version="1.0" encoding="UTF-8"?>\n<oor:items xmlns:oor="http://openoffice.org/2001/registry" ' 31 'xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">\n') 32 33 34def registry_xcu(default_version=None, extra=None): 35 """registrymodifications.xcu content. `default_version` is Save/ODF/DefaultVersion (3 = ODF 1.2 extended, 4 = 1.2, 5 = 1.3, 36 6 = 1.3 extended; 0 leaves the product default). `extra` maps other Save/ODF boolean properties (UseSHA1InODF12, ...) to values.""" 37 values = {} 38 if default_version is not None: 39 values["DefaultVersion"] = "%d" % default_version 40 for name, v in (extra or {}).items(): 41 values[name] = ("true" if v else "false") if isinstance(v, bool) else str(v) 42 items = "".join('<item oor:path="/org.openoffice.Office.Common/Save/ODF"><prop oor:name="%s" oor:op="fuse">' 43 '<value>%s</value></prop></item>\n' % kv for kv in values.items()) 44 return XCU_HEAD + items + "</oor:items>\n" 45 46 47def make_profile(directory, default_version=None, extra=None): 48 """Creates a user profile in `directory` (empty or absent; nothing is deleted) and returns its file URL.""" 49 d = Path(directory).resolve() 50 if d.is_dir() and any(d.iterdir()): 51 raise ValueError("profile directory is not empty: %s" % d) 52 (d / "user").mkdir(parents=True, exist_ok=True) 53 (d / "user" / "registrymodifications.xcu").write_text(registry_xcu(default_version, extra), encoding="utf-8") 54 return d.as_uri() 55 56 57def free_port(): 58 with socket.socket() as s: 59 s.bind(("127.0.0.1", 0)) 60 return s.getsockname()[1] 61 62 63def accept_string(connect="pipe", name=None, port=None): 64 """The -accept= value: a uniquely named pipe, or a socket on `port` (a free one by default).""" 65 if connect == "pipe": 66 return "pipe,name=%s;urp;" % (name or "aootest_" + uuid.uuid4().hex[:12]) 67 if connect == "socket": 68 return "socket,host=localhost,port=%d;urp;" % (port or free_port()) 69 raise ValueError("connect must be 'pipe' or 'socket'") 70 71 72def command(layout, profile_url, accept, headless=True): 73 """soffice command line. AOO takes single-dash options only and ignores `--headless`.""" 74 return ([str(layout.soffice)] + (["-headless"] if headless else []) + 75 ["-invisible", "-nofirststartwizard", "-norestore", "-nologo", "-nodefault", "-nolockcheck", 76 "-env:UserInstallation=" + profile_url, "-accept=" + accept]) 77 78 79class Office: 80 """A running soffice. Readiness is the worker connecting to it, so there is nothing to poll here.""" 81 82 def __init__(self, cmd, env=None, log=None): 83 self.cmd, self.env, self.log, self._proc = list(cmd), env, log, None 84 85 def start(self): 86 out = open(self.log, "ab") if self.log else None 87 try: 88 self._proc = proc.spawn(self.cmd, env=self.env, stdin=None, stdout=out, stderr=out) 89 finally: 90 if out: 91 out.close() 92 return self 93 94 @property 95 def pid(self): 96 return self._proc.pid if self._proc else None 97 98 def alive(self): 99 return self._proc is not None and self._proc.poll() is None 100 101 def kill(self): 102 if self._proc is not None: 103 proc.kill_tree(self._proc) 104 105 106class SessionAborted(Exception): 107 pass 108 109 110class OfficeSession: 111 """One office and one worker, started lazily. A hang, crash or protocol error tears both down, is re-raised to the 112 caller, and the next call starts fresh; after more than `max_restarts` such failures the session is aborted. 113 114 start_office() returns an object with kill(); start_worker(office) returns a started client with call() and close().""" 115 116 def __init__(self, start_office, start_worker, max_restarts=5): 117 self._start_office, self._start_worker, self.max_restarts = start_office, start_worker, max_restarts 118 self.office = self.worker = None 119 self.failures = 0 120 self.aborted = False 121 122 def _ensure(self): 123 if self.aborted: 124 raise SessionAborted("aborted after %d failures (limit %d)" % (self.failures, self.max_restarts)) 125 if self.worker is None: 126 self.office = self._start_office() 127 try: 128 self.worker = self._start_worker(self.office) 129 except BaseException as e: 130 self._teardown() 131 if isinstance(e, (WorkerHang, WorkerCrash, ProtocolError)): 132 self._failed() 133 raise 134 135 def _failed(self): 136 self.failures += 1 137 if self.failures > self.max_restarts: 138 self.aborted = True 139 140 def _teardown(self): 141 w, o, self.worker, self.office = self.worker, self.office, None, None 142 if w is not None: 143 w.close() 144 if o is not None: 145 o.kill() 146 147 def call(self, op, args=None, timeout=60.0): 148 self._ensure() 149 try: 150 return self.worker.call(op, args, timeout) 151 except (WorkerHang, WorkerCrash, ProtocolError): 152 self._teardown() 153 self._failed() 154 raise 155 156 def close(self): 157 self._teardown() 158