robotcode-repl 2.9.0.dev51__py3-none-any.whl → 2.9.0.dev74__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- robotcode/repl/__version__.py +1 -1
- robotcode/repl/_debug/controller.py +17 -0
- robotcode/repl/_session.py +1119 -0
- robotcode/repl/base_interpreter.py +7 -1
- robotcode/repl/cli.py +142 -31
- robotcode/repl/console_interpreter.py +43 -12
- robotcode/repl/hooks.py +2 -1
- robotcode/repl/prompt_toolkit_interpreter.py +2 -0
- robotcode/repl/session_cli.py +530 -0
- {robotcode_repl-2.9.0.dev51.dist-info → robotcode_repl-2.9.0.dev74.dist-info}/METADATA +2 -2
- {robotcode_repl-2.9.0.dev51.dist-info → robotcode_repl-2.9.0.dev74.dist-info}/RECORD +13 -11
- {robotcode_repl-2.9.0.dev51.dist-info → robotcode_repl-2.9.0.dev74.dist-info}/WHEEL +0 -0
- {robotcode_repl-2.9.0.dev51.dist-info → robotcode_repl-2.9.0.dev74.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,1119 @@
|
|
|
1
|
+
"""Sessions of `robotcode repl` and `robotcode robot-debug` that run in the background.
|
|
2
|
+
|
|
3
|
+
`robotcode session start` runs `repl` or `robot-debug` with `--serve-session` in a
|
|
4
|
+
process of its own. The interpreter of that process reads its inputs from a local
|
|
5
|
+
channel instead of standard input, one whole input per request, and answers each
|
|
6
|
+
request with the output written since the last answer and the status of the input.
|
|
7
|
+
|
|
8
|
+
The process writes its standard output and standard error to a pipe, which the session
|
|
9
|
+
reads into memory. So the output of Robot Framework, of libraries and of child
|
|
10
|
+
processes is part of the answers, and none of it is written to a file.
|
|
11
|
+
|
|
12
|
+
Each open session has a file in a folder of the user, by which the `robotcode session`
|
|
13
|
+
commands find it. A session stays open after its REPL or run has ended, until it is
|
|
14
|
+
stopped or has been idle too long. A restart starts a new process, which takes over the
|
|
15
|
+
ID, the file and the pipe of the session.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import codecs
|
|
19
|
+
import contextlib
|
|
20
|
+
import dataclasses
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import select
|
|
25
|
+
import signal
|
|
26
|
+
import socket
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
import uuid
|
|
32
|
+
import warnings
|
|
33
|
+
from collections import deque
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from enum import Enum, unique
|
|
36
|
+
from multiprocessing.connection import Client, Connection, Listener
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from types import TracebackType
|
|
39
|
+
from typing import Any, Callable, Deque, Dict, Iterator, List, Optional, Tuple, Type, TypeVar
|
|
40
|
+
|
|
41
|
+
import platformdirs
|
|
42
|
+
from robot.errors import ExecutionFailed, ExecutionPassed
|
|
43
|
+
from robot.running import Keyword
|
|
44
|
+
|
|
45
|
+
from robotcode.plugin import Application
|
|
46
|
+
from robotcode.robot.config.utils import get_config_files
|
|
47
|
+
|
|
48
|
+
from ._debug import DebugController
|
|
49
|
+
from .console_interpreter import ConsoleInterpreter
|
|
50
|
+
|
|
51
|
+
if sys.platform == "win32":
|
|
52
|
+
import _winapi
|
|
53
|
+
import msvcrt
|
|
54
|
+
|
|
55
|
+
# Without `$XDG_RUNTIME_DIR`, as in a container, platformdirs falls back to another folder, which works for the sessions
|
|
56
|
+
# too, and warns about it at every call. Older versions of platformdirs don't warn.
|
|
57
|
+
if hasattr(platformdirs, "RuntimeDirWarning"):
|
|
58
|
+
warnings.filterwarnings("ignore", category=platformdirs.RuntimeDirWarning)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _Value(str, Enum):
|
|
62
|
+
"""A string enum whose members stand for their plain values in the file of a session, on its channel and in the
|
|
63
|
+
output."""
|
|
64
|
+
|
|
65
|
+
def __str__(self) -> str:
|
|
66
|
+
return str(self.value)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@unique
|
|
70
|
+
class SessionKind(_Value):
|
|
71
|
+
"""What a session runs."""
|
|
72
|
+
|
|
73
|
+
REPL = "repl"
|
|
74
|
+
DEBUG = "debug"
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def command(self) -> str:
|
|
78
|
+
"""The `robotcode` command that runs a session of this kind."""
|
|
79
|
+
return "repl" if self is SessionKind.REPL else "robot-debug"
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def what(self) -> str:
|
|
83
|
+
"""`The REPL` or `The run`: what a session of this kind runs, as the messages name it."""
|
|
84
|
+
return "The REPL" if self is SessionKind.REPL else "The run"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@unique
|
|
88
|
+
class SessionState(_Value):
|
|
89
|
+
"""Whether a session waits at its prompt, runs something, or its REPL or run has ended."""
|
|
90
|
+
|
|
91
|
+
WAITS = "waits"
|
|
92
|
+
RUNS = "runs"
|
|
93
|
+
ENDED = "ended"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@unique
|
|
97
|
+
class Request(_Value):
|
|
98
|
+
"""The requests that the channel of a session takes."""
|
|
99
|
+
|
|
100
|
+
SEND = "send"
|
|
101
|
+
WAIT = "wait"
|
|
102
|
+
# Sent by a command after its `--timeout`: the output so far.
|
|
103
|
+
STATUS = "status"
|
|
104
|
+
INTERRUPT = "interrupt"
|
|
105
|
+
STOP = "stop"
|
|
106
|
+
# Whether the session runs, waits or its REPL or run has ended; takes none of the output.
|
|
107
|
+
STATE = "state"
|
|
108
|
+
RESULT = "result"
|
|
109
|
+
RESTART = "restart"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@unique
|
|
113
|
+
class AnswerStatus(_Value):
|
|
114
|
+
"""How the input of a request went."""
|
|
115
|
+
|
|
116
|
+
PASSED = "passed"
|
|
117
|
+
FAILED = "failed"
|
|
118
|
+
RUNNING = "running"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
_ID_PATTERN = re.compile(rf"(?:{'|'.join(kind.value for kind in SessionKind)})-[0-9a-f]{{8}}")
|
|
122
|
+
|
|
123
|
+
_T = TypeVar("_T")
|
|
124
|
+
|
|
125
|
+
# A Unix socket in the folder of the sessions on Linux and macOS, a named pipe on Windows.
|
|
126
|
+
_FAMILY = "AF_PIPE" if sys.platform == "win32" else "AF_UNIX"
|
|
127
|
+
|
|
128
|
+
# How many connections the channel keeps waiting; `multiprocessing` keeps one. Each command connects twice, to check the
|
|
129
|
+
# channel and for its request, and macOS refuses a connection to a full queue at once, where Linux lets it wait.
|
|
130
|
+
_BACKLOG = 16
|
|
131
|
+
|
|
132
|
+
# How long a new session process may take until it accepts requests.
|
|
133
|
+
STARTUP_LIMIT = 60
|
|
134
|
+
|
|
135
|
+
# The session process keeps running after the command that started it has returned: in a new process session on Linux
|
|
136
|
+
# and macOS, on Windows in a new process group, which a Ctrl-C at the console of the command doesn't reach, and with a
|
|
137
|
+
# console without a window. Console programs that the session starts use that console instead of opening windows of
|
|
138
|
+
# their own.
|
|
139
|
+
_DETACHED: Dict[str, Any]
|
|
140
|
+
if sys.platform == "win32":
|
|
141
|
+
_DETACHED = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW}
|
|
142
|
+
else:
|
|
143
|
+
_DETACHED = {"start_new_session": True}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class SessionError(Exception):
|
|
147
|
+
"""A session can't start, for the reason in the message."""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def sessions_dir() -> Path:
|
|
151
|
+
"""The folder of the files of the open sessions."""
|
|
152
|
+
return Path(platformdirs.user_runtime_dir("robotcode", appauthor=False), "sessions")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def create_sessions_dir() -> Path:
|
|
156
|
+
"""Create the folder of the session files, open only to the user, and return it. Only a session that starts
|
|
157
|
+
writes to it; the other `session` commands only read it, so that they also work where they can't write, as in a
|
|
158
|
+
sandbox."""
|
|
159
|
+
path = sessions_dir()
|
|
160
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
161
|
+
if sys.platform != "win32":
|
|
162
|
+
# `mkdir` doesn't change the mode of a folder that exists already.
|
|
163
|
+
os.chmod(path, 0o700)
|
|
164
|
+
return path
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def project_folder(app: Application) -> Path:
|
|
168
|
+
"""The project the current folder belongs to: the folder RobotCode finds for its `robot.toml` from there, or the
|
|
169
|
+
one given with `--root`."""
|
|
170
|
+
_, root, _ = get_config_files(None, app.config.config_files, root_folder=app.config.root, no_vcs=app.config.no_vcs)
|
|
171
|
+
return root or Path.cwd()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _entry_path(session_id: str) -> Path:
|
|
175
|
+
return sessions_dir() / f"{session_id}.json"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _address(session_id: str, generation: int = 0) -> str:
|
|
179
|
+
"""The address of the channel of a session process. After a restart, each new process has a `generation` of its
|
|
180
|
+
own, and with it an address of its own."""
|
|
181
|
+
name = f"{session_id}-{generation}" if generation else session_id
|
|
182
|
+
if sys.platform == "win32":
|
|
183
|
+
return rf"\\.\pipe\robotcode-{name}"
|
|
184
|
+
return str(sessions_dir() / f"{name}.sock")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _restart_path(session_id: str) -> Path:
|
|
188
|
+
"""The file in which a session process hands its breakpoints, its start time and the next generation to the new
|
|
189
|
+
process of a restart."""
|
|
190
|
+
return sessions_dir() / f"{session_id}.restart"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def new_session_id(kind: SessionKind) -> str:
|
|
194
|
+
"""A new ID for a session of `kind`, such as `repl-3f9a2c1e`, that no open session has."""
|
|
195
|
+
while True:
|
|
196
|
+
session_id = f"{kind}-{uuid.uuid4().hex[:8]}"
|
|
197
|
+
if not _entry_path(session_id).exists():
|
|
198
|
+
return session_id
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class SessionEntry:
|
|
203
|
+
"""The file of an open session."""
|
|
204
|
+
|
|
205
|
+
id: str
|
|
206
|
+
kind: SessionKind
|
|
207
|
+
pid: int
|
|
208
|
+
address: str
|
|
209
|
+
# The key of the channel, in hexadecimal.
|
|
210
|
+
key: str
|
|
211
|
+
project: str
|
|
212
|
+
started: float
|
|
213
|
+
# In seconds.
|
|
214
|
+
idle_timeout: float
|
|
215
|
+
last_request: float
|
|
216
|
+
state: SessionState = SessionState.RUNS
|
|
217
|
+
# When the session last arrived at its prompt.
|
|
218
|
+
waits_since: Optional[float] = None
|
|
219
|
+
|
|
220
|
+
def __post_init__(self) -> None:
|
|
221
|
+
# The file holds the plain values of the enums.
|
|
222
|
+
self.kind = SessionKind(self.kind)
|
|
223
|
+
self.state = SessionState(self.state)
|
|
224
|
+
|
|
225
|
+
def time_left(self) -> Optional[float]:
|
|
226
|
+
"""The seconds until the session ends for being idle, None while it runs."""
|
|
227
|
+
if self.state is SessionState.RUNS:
|
|
228
|
+
return None
|
|
229
|
+
idle_since = max(self.last_request, self.waits_since or 0)
|
|
230
|
+
return max(0.0, self.idle_timeout - (time.time() - idle_since))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _retried(action: Callable[[], _T]) -> _T:
|
|
234
|
+
"""`action()`, tried again for a moment while it fails with `PermissionError`: Windows doesn't replace a file that
|
|
235
|
+
another process reads at the moment, nor open one that another process replaces."""
|
|
236
|
+
attempt = 0
|
|
237
|
+
while True:
|
|
238
|
+
try:
|
|
239
|
+
return action()
|
|
240
|
+
except PermissionError:
|
|
241
|
+
attempt += 1
|
|
242
|
+
if attempt == 50:
|
|
243
|
+
raise
|
|
244
|
+
time.sleep(0.01)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _temp_path(path: Path) -> Path:
|
|
248
|
+
return path.with_name(f"{path.name}.tmp")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _write_json(path: Path, data: Any) -> None:
|
|
252
|
+
"""Write `data` to `path` as a whole, readable only by the user."""
|
|
253
|
+
temp = _temp_path(path)
|
|
254
|
+
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
255
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
256
|
+
json.dump(data, f)
|
|
257
|
+
_retried(lambda: os.replace(temp, path))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _write_entry(entry: SessionEntry) -> None:
|
|
261
|
+
_write_json(_entry_path(entry.id), dataclasses.asdict(entry))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def remove_session_files(session_id: str) -> None:
|
|
265
|
+
"""Remove the files of the session `session_id`: its file, the one a session leaves when it ends while it writes
|
|
266
|
+
its file, the file of a restart, and the sockets of all its processes."""
|
|
267
|
+
folder = sessions_dir()
|
|
268
|
+
entry = _entry_path(session_id)
|
|
269
|
+
restart = _restart_path(session_id)
|
|
270
|
+
sockets = [folder / f"{session_id}.sock", *folder.glob(f"{session_id}-*.sock")]
|
|
271
|
+
for path in (entry, _temp_path(entry), restart, _temp_path(restart), *sockets):
|
|
272
|
+
with contextlib.suppress(OSError):
|
|
273
|
+
path.unlink()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _channel_gone(address: str) -> bool:
|
|
277
|
+
"""Whether nothing listens on the channel at `address` any more, because its session process has ended.
|
|
278
|
+
|
|
279
|
+
The process ID can't tell in a sandbox with processes of its own: a command inside doesn't see the processes
|
|
280
|
+
outside, and the ID of a session that ended inside can belong to another process outside. A channel that can't be
|
|
281
|
+
reached for another reason, such as a sandbox that forbids the connection, counts as open.
|
|
282
|
+
"""
|
|
283
|
+
try:
|
|
284
|
+
if sys.platform == "win32":
|
|
285
|
+
# Waits for an instance of the pipe without connecting to it, and fails at once without the pipe.
|
|
286
|
+
_winapi.WaitNamedPipe(address, 1)
|
|
287
|
+
else:
|
|
288
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
|
|
289
|
+
probe.settimeout(1)
|
|
290
|
+
probe.connect(address)
|
|
291
|
+
except (FileNotFoundError, ConnectionRefusedError):
|
|
292
|
+
return True
|
|
293
|
+
except OSError:
|
|
294
|
+
return False
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _load_entry(session_id: str) -> Optional[SessionEntry]:
|
|
299
|
+
path = _entry_path(session_id)
|
|
300
|
+
try:
|
|
301
|
+
return SessionEntry(**json.loads(_retried(lambda: path.read_text(encoding="utf-8"))))
|
|
302
|
+
except (OSError, ValueError, TypeError):
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def read_entry(session_id: str) -> Optional[SessionEntry]:
|
|
307
|
+
"""The file of the open session `session_id`, None without one. The files of a session whose channel nobody
|
|
308
|
+
listens on any more, because its process no longer runs, are removed."""
|
|
309
|
+
if not _ID_PATTERN.fullmatch(session_id):
|
|
310
|
+
return None
|
|
311
|
+
entry = _load_entry(session_id)
|
|
312
|
+
while entry is not None and _channel_gone(entry.address):
|
|
313
|
+
# At a restart, the new process names its channel in the file before the old process closes its own.
|
|
314
|
+
again = _load_entry(session_id)
|
|
315
|
+
if again is None or again.address == entry.address:
|
|
316
|
+
remove_session_files(session_id)
|
|
317
|
+
return None
|
|
318
|
+
entry = again
|
|
319
|
+
return entry
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def open_sessions() -> List[SessionEntry]:
|
|
323
|
+
"""The open sessions of the user, the oldest first. The files of sessions that no longer run are removed."""
|
|
324
|
+
entries = [entry for path in sessions_dir().glob("*.json") if (entry := read_entry(path.stem)) is not None]
|
|
325
|
+
return sorted(entries, key=lambda entry: entry.started)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def connect(entry: SessionEntry) -> Connection:
|
|
329
|
+
"""A connection to the channel of the session `entry`."""
|
|
330
|
+
return Client(entry.address, family=_FAMILY, authkey=bytes.fromhex(entry.key))
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# By default a pipe on Windows holds only a few kilobytes; this holds what a session writes before it starts reading the
|
|
334
|
+
# pipe.
|
|
335
|
+
_PIPE_SIZE = 1 << 20
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def create_output_pipe() -> Tuple[int, int]:
|
|
339
|
+
"""The read end and the write end of a new pipe for the output of a session."""
|
|
340
|
+
if sys.platform == "win32":
|
|
341
|
+
read_handle, write_handle = _winapi.CreatePipe(None, _PIPE_SIZE)
|
|
342
|
+
return msvcrt.open_osfhandle(read_handle, 0), msvcrt.open_osfhandle(write_handle, 0)
|
|
343
|
+
return os.pipe()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def read_to_end(fd: int) -> str:
|
|
347
|
+
"""What the pipe `fd` holds up to its end, once nothing writes to it any more."""
|
|
348
|
+
chunks = []
|
|
349
|
+
while data := os.read(fd, 65536):
|
|
350
|
+
chunks.append(data)
|
|
351
|
+
return b"".join(chunks).decode("utf-8", errors="replace").replace("\r\n", "\n")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def start_session_process(
|
|
355
|
+
command: List[str], output: int, stdout: int, env: Dict[str, str], cwd: Optional[str] = None
|
|
356
|
+
) -> "subprocess.Popen[bytes]":
|
|
357
|
+
"""Start a session process, detached from this one. The process writes its standard output and standard error to
|
|
358
|
+
`stdout` and reads them back from `output`, the read end of the same pipe, which it inherits: a handle on
|
|
359
|
+
Windows, and there the only one it inherits beside its standard handles."""
|
|
360
|
+
passed: Dict[str, Any]
|
|
361
|
+
if sys.platform == "win32":
|
|
362
|
+
os.set_handle_inheritable(output, True)
|
|
363
|
+
passed = {"startupinfo": subprocess.STARTUPINFO(lpAttributeList={"handle_list": [output]})}
|
|
364
|
+
else:
|
|
365
|
+
passed = {"pass_fds": (output,)}
|
|
366
|
+
try:
|
|
367
|
+
process = subprocess.Popen(
|
|
368
|
+
command,
|
|
369
|
+
stdin=subprocess.PIPE,
|
|
370
|
+
stdout=stdout,
|
|
371
|
+
stderr=subprocess.STDOUT,
|
|
372
|
+
env=env,
|
|
373
|
+
cwd=cwd,
|
|
374
|
+
**_DETACHED,
|
|
375
|
+
**passed,
|
|
376
|
+
)
|
|
377
|
+
finally:
|
|
378
|
+
if sys.platform == "win32":
|
|
379
|
+
os.set_handle_inheritable(output, False)
|
|
380
|
+
# The session reads its inputs from its channel. A closed pipe as standard input makes it a session without a
|
|
381
|
+
# terminal on every platform, unlike the null device, which Windows takes for a terminal.
|
|
382
|
+
if process.stdin is not None:
|
|
383
|
+
process.stdin.close()
|
|
384
|
+
return process
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _take_handover(session_id: str) -> Dict[str, Any]:
|
|
388
|
+
"""Read and remove the file that the previous process of the session wrote for this one at a restart; empty for
|
|
389
|
+
the first process of a session."""
|
|
390
|
+
path = _restart_path(session_id)
|
|
391
|
+
try:
|
|
392
|
+
handover: Dict[str, Any] = json.loads(_retried(lambda: path.read_text(encoding="utf-8")))
|
|
393
|
+
except (OSError, ValueError):
|
|
394
|
+
return {}
|
|
395
|
+
with contextlib.suppress(OSError):
|
|
396
|
+
path.unlink()
|
|
397
|
+
return handover
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _own_pipe(output: int) -> int:
|
|
401
|
+
"""The file descriptor of the read end `output` that `session start` passed down, a handle on Windows, which the
|
|
402
|
+
processes that the session starts don't inherit."""
|
|
403
|
+
if sys.platform == "win32":
|
|
404
|
+
os.set_handle_inheritable(output, False)
|
|
405
|
+
return msvcrt.open_osfhandle(output, 0)
|
|
406
|
+
os.set_inheritable(output, False)
|
|
407
|
+
os.set_blocking(output, False)
|
|
408
|
+
return output
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _flush_output() -> None:
|
|
412
|
+
for stream in {id(s): s for s in (sys.stdout, sys.stderr, sys.__stdout__, sys.__stderr__) if s}.values():
|
|
413
|
+
with contextlib.suppress(Exception):
|
|
414
|
+
stream.flush()
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _passed_or_failed(failed: bool) -> AnswerStatus:
|
|
418
|
+
return AnswerStatus.FAILED if failed else AnswerStatus.PASSED
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@dataclass
|
|
422
|
+
class _Request:
|
|
423
|
+
"""The input of a `send` request, and its answer once it has run."""
|
|
424
|
+
|
|
425
|
+
text: str
|
|
426
|
+
started: bool = False
|
|
427
|
+
done: bool = False
|
|
428
|
+
output_end: int = 0
|
|
429
|
+
failed: bool = False
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class SessionServer:
|
|
433
|
+
"""The channel of a session, in the session process.
|
|
434
|
+
|
|
435
|
+
The interpreter takes its inputs from `next_input`, in the main thread. Each request is answered on a thread of its
|
|
436
|
+
own, so that a request to interrupt or stop the session, or one for the output so far, is answered while a statement
|
|
437
|
+
runs; the inputs of the `send` requests run one after the other. When the REPL or the run has ended, `end` keeps the
|
|
438
|
+
session open until it is stopped or has been idle too long, or starts it again in a new process for a restart.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
def __init__(self, session_id: str, kind: SessionKind, project: Path, idle_timeout: float, output: int) -> None:
|
|
442
|
+
self.session_id = session_id
|
|
443
|
+
self._kind = kind
|
|
444
|
+
self._condition = threading.Condition()
|
|
445
|
+
self._requests: Deque[_Request] = deque()
|
|
446
|
+
# The request whose input runs.
|
|
447
|
+
self._current: Optional[_Request] = None
|
|
448
|
+
# The prompt the interpreter waits at, None while something runs.
|
|
449
|
+
self._prompt: Optional[str] = None
|
|
450
|
+
# Updated each time the interpreter arrives at its prompt: how often it has arrived, whether the input failed,
|
|
451
|
+
# and how much output there was then.
|
|
452
|
+
self._arrivals = 0
|
|
453
|
+
self._arrival_failed = False
|
|
454
|
+
self._arrival_end = 0
|
|
455
|
+
# Whether the input that runs has failed; reset when the interpreter arrives at its prompt.
|
|
456
|
+
self._failed = False
|
|
457
|
+
# Once the REPL or the run has ended: its exit code, and whether the input that ran at the end failed.
|
|
458
|
+
self._run_ended = False
|
|
459
|
+
self._exit_code = 0
|
|
460
|
+
self._end_failed = False
|
|
461
|
+
# The REPL or the run ended because the session was idle too long, which ends the session too.
|
|
462
|
+
self._idle_ended = False
|
|
463
|
+
self._stop_requested = False
|
|
464
|
+
self._restart_requested = False
|
|
465
|
+
self._interrupted = False
|
|
466
|
+
# The interpreter, which tells whether a signal has ended the REPL or the run.
|
|
467
|
+
self._interpreter: Optional["SessionInterpreter"] = None
|
|
468
|
+
# The answer to `restart`, once the new process has taken over or failed to.
|
|
469
|
+
self._restart_answer: Optional[Dict[str, Any]] = None
|
|
470
|
+
# Set while a restart starts the new process, which writes the session file from then on; `_handed_over` once
|
|
471
|
+
# the new process has taken over and answers the requests.
|
|
472
|
+
self._handing_over = False
|
|
473
|
+
self._handed_over = False
|
|
474
|
+
# Set when the channel starts to close, so that the requests that wait send their last answers, and once it has
|
|
475
|
+
# closed.
|
|
476
|
+
self._closing = False
|
|
477
|
+
self._closed = False
|
|
478
|
+
# Requests that are being answered.
|
|
479
|
+
self._serving = 0
|
|
480
|
+
# The debugger, whose breakpoints a restart hands on.
|
|
481
|
+
self._controller: Optional[DebugController] = None
|
|
482
|
+
# A restart starts the new process the way this one was started, with the environment and the current folder
|
|
483
|
+
# from before the run could change them. With `sys.executable`, not `sys.orig_argv[0]`: on Windows, the
|
|
484
|
+
# `python.exe` of a virtual environment runs the base interpreter, which `sys.orig_argv[0]` names and which
|
|
485
|
+
# doesn't find the packages of the environment.
|
|
486
|
+
self._command = [sys.executable, *sys.orig_argv[1:]]
|
|
487
|
+
self._environment = dict(os.environ)
|
|
488
|
+
self._folder = os.getcwd()
|
|
489
|
+
handover = _take_handover(session_id)
|
|
490
|
+
self._generation: int = handover.get("generation", 0)
|
|
491
|
+
self._handed_breakpoints: Optional[Dict[str, Any]] = handover.get("breakpoints")
|
|
492
|
+
# The pipe the process writes its standard output and standard error to. What the session has read from it
|
|
493
|
+
# stays in memory until an answer carries it.
|
|
494
|
+
self._pipe = _own_pipe(output)
|
|
495
|
+
self._output = bytearray()
|
|
496
|
+
self._output_lock = threading.Lock()
|
|
497
|
+
# Whether this process reads the pipe; at a restart, the new process takes the reading over.
|
|
498
|
+
self._reading = True
|
|
499
|
+
# How much the session has read from the pipe, and how much of it the answers have carried.
|
|
500
|
+
self._read_size = 0
|
|
501
|
+
self._position = 0
|
|
502
|
+
# Whether what the process has written so far ends with a line break, or there is nothing yet.
|
|
503
|
+
self._at_line_start = True
|
|
504
|
+
self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
505
|
+
key = os.urandom(32)
|
|
506
|
+
create_sessions_dir()
|
|
507
|
+
address = _address(session_id, self._generation)
|
|
508
|
+
try:
|
|
509
|
+
self._listener = Listener(address, family=_FAMILY, backlog=_BACKLOG, authkey=key)
|
|
510
|
+
except OSError as e:
|
|
511
|
+
raise SessionError(f"Can't open the channel of the session at {address}: {e.strerror or e}.") from e
|
|
512
|
+
now = time.time()
|
|
513
|
+
self._entry = SessionEntry(
|
|
514
|
+
id=session_id,
|
|
515
|
+
kind=kind,
|
|
516
|
+
pid=os.getpid(),
|
|
517
|
+
address=str(self._listener.address),
|
|
518
|
+
key=key.hex(),
|
|
519
|
+
project=str(project),
|
|
520
|
+
started=handover.get("started", now),
|
|
521
|
+
idle_timeout=idle_timeout,
|
|
522
|
+
last_request=now,
|
|
523
|
+
)
|
|
524
|
+
_write_entry(self._entry)
|
|
525
|
+
threading.Thread(target=self._accept, name="robotcode-session", daemon=True).start()
|
|
526
|
+
# Only once the file exists: until then, `session start` reads the pipe itself if the process ends before it is
|
|
527
|
+
# ready.
|
|
528
|
+
self._reader = self._start_reading()
|
|
529
|
+
|
|
530
|
+
def __enter__(self) -> "SessionServer":
|
|
531
|
+
return self
|
|
532
|
+
|
|
533
|
+
def __exit__(
|
|
534
|
+
self, exc_type: Optional[Type[BaseException]], exc: Optional[BaseException], tb: Optional[TracebackType]
|
|
535
|
+
) -> None:
|
|
536
|
+
if isinstance(exc, SystemExit):
|
|
537
|
+
code = exc.code if isinstance(exc.code, int) else 0 if exc.code is None else 1
|
|
538
|
+
else:
|
|
539
|
+
code = 0 if exc is None else 1
|
|
540
|
+
self.end(code)
|
|
541
|
+
|
|
542
|
+
def watch(self, interpreter: "SessionInterpreter") -> None:
|
|
543
|
+
"""Remember `interpreter`, which tells at the end whether a signal, such as SIGTERM at a shutdown, has ended
|
|
544
|
+
its REPL or run."""
|
|
545
|
+
self._interpreter = interpreter
|
|
546
|
+
|
|
547
|
+
def keep_breakpoints(self, controller: DebugController) -> None:
|
|
548
|
+
"""Remember `controller`, whose breakpoints a restart hands to the new process, and give it the breakpoints
|
|
549
|
+
of the process before when a restart has started this one."""
|
|
550
|
+
self._controller = controller
|
|
551
|
+
if self._handed_breakpoints is not None:
|
|
552
|
+
controller.restore_breakpoints(self._handed_breakpoints)
|
|
553
|
+
|
|
554
|
+
# --- the interpreter, in the main thread ---------------------------------
|
|
555
|
+
|
|
556
|
+
def next_input(self, prompt: str) -> str:
|
|
557
|
+
"""Wait at `prompt` for the next request and return its whole input; the request that ran before gets its
|
|
558
|
+
answer first. While it waits, end the REPL or the run when the session has been idle too long or is asked to
|
|
559
|
+
stop or restart."""
|
|
560
|
+
prompt = prompt.strip()
|
|
561
|
+
with self._condition:
|
|
562
|
+
self._arrive(prompt)
|
|
563
|
+
while True:
|
|
564
|
+
if self._requests:
|
|
565
|
+
self._current = request = self._requests.popleft()
|
|
566
|
+
request.started = True
|
|
567
|
+
self._prompt = None
|
|
568
|
+
self._update_entry(state=SessionState.RUNS)
|
|
569
|
+
return request.text
|
|
570
|
+
if self._kind is SessionKind.DEBUG and prompt == ConsoleInterpreter.DEBUG_PROMPT.strip():
|
|
571
|
+
if self._stop_requested or self._restart_requested:
|
|
572
|
+
# A debug session ends its run as `.abort` does.
|
|
573
|
+
return ".abort"
|
|
574
|
+
if self._idle():
|
|
575
|
+
self._idle_ended = True
|
|
576
|
+
return ".abort"
|
|
577
|
+
elif self._kind is SessionKind.REPL and self._idle():
|
|
578
|
+
# Ends the REPL as the end of its input does.
|
|
579
|
+
self._idle_ended = True
|
|
580
|
+
raise EOFError
|
|
581
|
+
# In short steps, so that a signal handler gets to run soon: on Windows, a signal doesn't interrupt the
|
|
582
|
+
# wait.
|
|
583
|
+
self._condition.wait(0.1)
|
|
584
|
+
|
|
585
|
+
def line_failed(self) -> None:
|
|
586
|
+
"""The input that runs has failed."""
|
|
587
|
+
self._failed = True
|
|
588
|
+
|
|
589
|
+
def end(self, exit_code: int) -> None:
|
|
590
|
+
"""The REPL or the run has ended with `exit_code`. The session stays open until it is stopped or idle too
|
|
591
|
+
long, or until a restart has started it again in a new process."""
|
|
592
|
+
_flush_output()
|
|
593
|
+
with self._condition:
|
|
594
|
+
if self._run_ended:
|
|
595
|
+
return
|
|
596
|
+
self._end_failed, self._failed = self._failed, False
|
|
597
|
+
self._run_ended = True
|
|
598
|
+
self._exit_code = exit_code
|
|
599
|
+
self._prompt = None
|
|
600
|
+
self._update_entry(state=SessionState.ENDED, waits_since=time.time())
|
|
601
|
+
self._condition.notify_all()
|
|
602
|
+
# A signal from outside, such as SIGTERM at a shutdown, ends the session with its REPL or run.
|
|
603
|
+
ended_from_outside = (
|
|
604
|
+
self._interpreter is not None
|
|
605
|
+
and self._interpreter.termination_requested
|
|
606
|
+
and not (self._stop_requested or self._restart_requested or self._interrupted)
|
|
607
|
+
)
|
|
608
|
+
if not (self._idle_ended or ended_from_outside):
|
|
609
|
+
while self._wait_after_end():
|
|
610
|
+
if self._start_again():
|
|
611
|
+
return
|
|
612
|
+
with self._condition:
|
|
613
|
+
# Before the last answers go out, so that a `session` command right after one doesn't find the session.
|
|
614
|
+
_entry_path(self.session_id).unlink(missing_ok=True)
|
|
615
|
+
self._close_channel()
|
|
616
|
+
|
|
617
|
+
def _wait_after_end(self) -> bool:
|
|
618
|
+
"""Wait after the end of the REPL or the run until the session is asked to stop or restart, or has been idle
|
|
619
|
+
too long; True for a restart."""
|
|
620
|
+
with self._condition:
|
|
621
|
+
while not (self._stop_requested or self._restart_requested or self._idle()):
|
|
622
|
+
self._condition.wait(0.1)
|
|
623
|
+
return self._restart_requested and not self._stop_requested
|
|
624
|
+
|
|
625
|
+
def _close_channel(self) -> None:
|
|
626
|
+
"""Give the requests that wait a moment for their last answers, then close the channel. The caller holds
|
|
627
|
+
`_condition`."""
|
|
628
|
+
self._closing = True
|
|
629
|
+
self._condition.notify_all()
|
|
630
|
+
deadline = time.monotonic() + 5
|
|
631
|
+
while self._serving and time.monotonic() < deadline:
|
|
632
|
+
self._condition.wait(0.1)
|
|
633
|
+
self._closed = True
|
|
634
|
+
with contextlib.suppress(OSError):
|
|
635
|
+
self._listener.close()
|
|
636
|
+
|
|
637
|
+
def _start_again(self) -> bool:
|
|
638
|
+
"""Start the session again in a new process, which takes over the session file and the pipe and opens a
|
|
639
|
+
channel of its own; True once it has taken over. When the new process doesn't get ready, the session stays as
|
|
640
|
+
it is."""
|
|
641
|
+
with self._condition:
|
|
642
|
+
self._handing_over = True
|
|
643
|
+
breakpoints = self._controller.breakpoint_state() if self._controller is not None else None
|
|
644
|
+
# Only one process may read the pipe; from now on it is the new one.
|
|
645
|
+
self._stop_reading()
|
|
646
|
+
process: "Optional[subprocess.Popen[bytes]]" = None
|
|
647
|
+
try:
|
|
648
|
+
_write_json(
|
|
649
|
+
_restart_path(self.session_id),
|
|
650
|
+
{"generation": self._generation + 1, "started": self._entry.started, "breakpoints": breakpoints},
|
|
651
|
+
)
|
|
652
|
+
process = start_session_process(self._command, self._pipe_handle(), 1, self._environment, self._folder)
|
|
653
|
+
failure = self._wait_for_new_process(process)
|
|
654
|
+
except OSError as e:
|
|
655
|
+
failure = f"The session couldn't start again: {e.strerror or e}."
|
|
656
|
+
if failure is None:
|
|
657
|
+
self._hand_over()
|
|
658
|
+
return True
|
|
659
|
+
self._keep_session(process, failure)
|
|
660
|
+
return False
|
|
661
|
+
|
|
662
|
+
def _wait_for_new_process(self, process: "subprocess.Popen[bytes]") -> Optional[str]:
|
|
663
|
+
"""Wait until `process` has written the session file, which it does once it accepts requests; None then,
|
|
664
|
+
otherwise why it didn't get ready."""
|
|
665
|
+
# Known by its channel: on Windows, the `python.exe` of a virtual environment runs the interpreter in a process
|
|
666
|
+
# of its own, whose ID isn't that of `process`.
|
|
667
|
+
address = _address(self.session_id, self._generation + 1)
|
|
668
|
+
ready_by = time.monotonic() + STARTUP_LIMIT
|
|
669
|
+
while True:
|
|
670
|
+
entry = _load_entry(self.session_id)
|
|
671
|
+
if entry is not None and entry.address == address:
|
|
672
|
+
return None
|
|
673
|
+
if process.poll() is not None:
|
|
674
|
+
return f"The session couldn't start again; its new process ended with exit code {process.returncode}."
|
|
675
|
+
if time.monotonic() >= ready_by:
|
|
676
|
+
process.kill()
|
|
677
|
+
process.wait()
|
|
678
|
+
return (
|
|
679
|
+
f"The session couldn't start again; its new process wasn't ready after {STARTUP_LIMIT} seconds "
|
|
680
|
+
"and was ended."
|
|
681
|
+
)
|
|
682
|
+
time.sleep(0.05)
|
|
683
|
+
|
|
684
|
+
def _hand_over(self) -> None:
|
|
685
|
+
"""Answer `restart` with the output since the last command, and close the channel, now that the new process
|
|
686
|
+
has taken over the session file and the pipe."""
|
|
687
|
+
with self._output_lock:
|
|
688
|
+
size = self._read_size
|
|
689
|
+
with self._condition:
|
|
690
|
+
self._restart_answer = {"output": self._read_output(size), "restarted": True}
|
|
691
|
+
self._handed_over = True
|
|
692
|
+
self._close_channel()
|
|
693
|
+
# Anything this process still writes would end up in the output of the new process.
|
|
694
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
695
|
+
os.dup2(devnull, 1)
|
|
696
|
+
os.dup2(devnull, 2)
|
|
697
|
+
os.close(devnull)
|
|
698
|
+
|
|
699
|
+
def _keep_session(self, process: "Optional[subprocess.Popen[bytes]]", failure: str) -> None:
|
|
700
|
+
"""After a restart that failed: end the new process, take what it wrote, and keep the session with its REPL or
|
|
701
|
+
run ended."""
|
|
702
|
+
if process is not None and process.poll() is None:
|
|
703
|
+
process.kill()
|
|
704
|
+
process.wait()
|
|
705
|
+
with contextlib.suppress(OSError):
|
|
706
|
+
_restart_path(self.session_id).unlink()
|
|
707
|
+
with self._output_lock:
|
|
708
|
+
# What the new process wrote, so that the answer carries it, before the reading goes on.
|
|
709
|
+
self._take_output()
|
|
710
|
+
self._reader = self._start_reading()
|
|
711
|
+
with self._condition:
|
|
712
|
+
self._handing_over = False
|
|
713
|
+
# The new process may have written the file before it ended.
|
|
714
|
+
self._update_entry()
|
|
715
|
+
self._restart_answer = {"output": self._read_output(self._output_size()), "restart_failed": failure}
|
|
716
|
+
self._restart_requested = False
|
|
717
|
+
self._condition.notify_all()
|
|
718
|
+
|
|
719
|
+
def _arrive(self, prompt: str) -> None:
|
|
720
|
+
"""The interpreter has arrived at `prompt`: give the request that ran its answer, and wake the threads that
|
|
721
|
+
wait for it."""
|
|
722
|
+
_flush_output()
|
|
723
|
+
end = self._output_size()
|
|
724
|
+
# No I/O up to the notification: a Ctrl-C at the prompt can interrupt this method, and the request must then
|
|
725
|
+
# either have its answer or be left to the next arrival.
|
|
726
|
+
failed, self._failed = self._failed, False
|
|
727
|
+
request, self._current = self._current, None
|
|
728
|
+
if request is not None:
|
|
729
|
+
request.output_end = end
|
|
730
|
+
request.failed = failed
|
|
731
|
+
request.done = True
|
|
732
|
+
self._arrivals += 1
|
|
733
|
+
self._arrival_failed = failed
|
|
734
|
+
self._arrival_end = end
|
|
735
|
+
self._prompt = prompt
|
|
736
|
+
# A pending `interrupt` has done its work; a signal that ends the REPL or the run from now on doesn't come from
|
|
737
|
+
# it.
|
|
738
|
+
self._interrupted = False
|
|
739
|
+
self._condition.notify_all()
|
|
740
|
+
self._update_entry(state=SessionState.WAITS, waits_since=time.time())
|
|
741
|
+
|
|
742
|
+
def _idle(self) -> bool:
|
|
743
|
+
idle_since = max(self._entry.last_request, self._entry.waits_since or 0)
|
|
744
|
+
return time.time() - idle_since >= self._entry.idle_timeout
|
|
745
|
+
|
|
746
|
+
# --- the requests, each on a thread of its own ---------------------------
|
|
747
|
+
|
|
748
|
+
def _accept(self) -> None:
|
|
749
|
+
while not self._closed:
|
|
750
|
+
try:
|
|
751
|
+
connection = self._listener.accept()
|
|
752
|
+
except Exception:
|
|
753
|
+
# A client without the key, or the channel has closed at the end.
|
|
754
|
+
time.sleep(0.01)
|
|
755
|
+
continue
|
|
756
|
+
threading.Thread(target=self._serve, args=(connection,), daemon=True).start()
|
|
757
|
+
|
|
758
|
+
def _serve(self, connection: Connection) -> None:
|
|
759
|
+
with self._condition:
|
|
760
|
+
self._serving += 1
|
|
761
|
+
try:
|
|
762
|
+
with connection:
|
|
763
|
+
answer = self._answer(connection, connection.recv())
|
|
764
|
+
if answer is not None:
|
|
765
|
+
connection.send(answer)
|
|
766
|
+
except (EOFError, OSError):
|
|
767
|
+
# The client has gone.
|
|
768
|
+
pass
|
|
769
|
+
finally:
|
|
770
|
+
with self._condition:
|
|
771
|
+
self._serving -= 1
|
|
772
|
+
self._condition.notify_all()
|
|
773
|
+
|
|
774
|
+
def _answer(self, connection: Connection, message: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
775
|
+
request = message.get("request")
|
|
776
|
+
with self._condition:
|
|
777
|
+
if self._handed_over:
|
|
778
|
+
# The command then asks the new process, which the session file names now.
|
|
779
|
+
return {"moved": True}
|
|
780
|
+
if self._closing:
|
|
781
|
+
# The session has just ended, as the next command would find.
|
|
782
|
+
return {"error": f"No session '{self.session_id}' is open."}
|
|
783
|
+
self._update_entry(last_request=time.time())
|
|
784
|
+
run_ended = self._run_ended
|
|
785
|
+
if request == Request.SEND:
|
|
786
|
+
if run_ended:
|
|
787
|
+
return self._ended_error()
|
|
788
|
+
return self._send(connection, str(message.get("input", "")))
|
|
789
|
+
if request == Request.WAIT:
|
|
790
|
+
return self._wait(connection)
|
|
791
|
+
if request == Request.STATUS:
|
|
792
|
+
with self._condition:
|
|
793
|
+
return self._status()
|
|
794
|
+
if request == Request.STATE:
|
|
795
|
+
with self._condition:
|
|
796
|
+
return {"state": self._state()}
|
|
797
|
+
if request == Request.RESULT:
|
|
798
|
+
if not run_ended:
|
|
799
|
+
return {"error": f"{self._kind.what} of session '{self.session_id}' hasn't ended yet."}
|
|
800
|
+
return {"exit_code": self._exit_code}
|
|
801
|
+
if request == Request.INTERRUPT:
|
|
802
|
+
if run_ended:
|
|
803
|
+
return self._ended_error()
|
|
804
|
+
with self._condition:
|
|
805
|
+
self._interrupted = True
|
|
806
|
+
# As Ctrl-C does at the terminal of the session.
|
|
807
|
+
self._signal(signal.SIGINT)
|
|
808
|
+
return {"interrupted": True}
|
|
809
|
+
if request == Request.STOP:
|
|
810
|
+
return self._stop(connection)
|
|
811
|
+
if request == Request.RESTART:
|
|
812
|
+
return self._restart(connection)
|
|
813
|
+
return {"error": f"Unknown request {request!r}."}
|
|
814
|
+
|
|
815
|
+
def _ended_error(self) -> Dict[str, Any]:
|
|
816
|
+
session_id = self.session_id
|
|
817
|
+
return {
|
|
818
|
+
"error": f"{self._kind.what} of session '{session_id}' has ended; `robotcode session restart {session_id}` "
|
|
819
|
+
f"starts it again, and `robotcode session result {session_id}` prints its exit code."
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
def _send(self, connection: Connection, text: str) -> Optional[Dict[str, Any]]:
|
|
823
|
+
lines = text.strip().splitlines()
|
|
824
|
+
if len(lines) > 1 and lines[0].lstrip().startswith("."):
|
|
825
|
+
# A dot-command is an input of its own.
|
|
826
|
+
return {"output": "[ ERROR ] Dot-commands can only be sent on their own.\n", "status": AnswerStatus.FAILED}
|
|
827
|
+
request = _Request(text)
|
|
828
|
+
with self._condition:
|
|
829
|
+
self._requests.append(request)
|
|
830
|
+
self._condition.notify_all()
|
|
831
|
+
return self._await(
|
|
832
|
+
connection,
|
|
833
|
+
lambda: request.done,
|
|
834
|
+
lambda: self._make_answer(request.output_end, _passed_or_failed(request.failed)),
|
|
835
|
+
# An input that was still queued when the REPL or the run ended never ran.
|
|
836
|
+
lambda: self._answer_ended() if request.started else self._ended_error(),
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
def _wait(self, connection: Connection) -> Optional[Dict[str, Any]]:
|
|
840
|
+
with self._condition:
|
|
841
|
+
if self._run_ended:
|
|
842
|
+
return self._answer_ended()
|
|
843
|
+
if not self._runs():
|
|
844
|
+
# The session waits at its prompt already: what was written since the last answer.
|
|
845
|
+
return self._status()
|
|
846
|
+
arrivals = self._arrivals
|
|
847
|
+
return self._await(
|
|
848
|
+
connection,
|
|
849
|
+
lambda: self._arrivals > arrivals,
|
|
850
|
+
lambda: self._make_answer(self._arrival_end, _passed_or_failed(self._arrival_failed)),
|
|
851
|
+
self._answer_ended,
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
def _stop(self, connection: Connection) -> Optional[Dict[str, Any]]:
|
|
855
|
+
with self._condition:
|
|
856
|
+
self._stop_requested = True
|
|
857
|
+
self._condition.notify_all()
|
|
858
|
+
self._end_run()
|
|
859
|
+
return self._await(connection, lambda: self._closing, self._stopped_answer)
|
|
860
|
+
|
|
861
|
+
def _stopped_answer(self) -> Dict[str, Any]:
|
|
862
|
+
if self._handed_over:
|
|
863
|
+
# A restart has taken over meanwhile: the command stops the new process.
|
|
864
|
+
return {"moved": True}
|
|
865
|
+
return {"output": self._read_output(self._output_size()), "stopped": True}
|
|
866
|
+
|
|
867
|
+
def _restart(self, connection: Connection) -> Optional[Dict[str, Any]]:
|
|
868
|
+
with self._condition:
|
|
869
|
+
if self._restart_requested:
|
|
870
|
+
return {"error": f"Session '{self.session_id}' restarts already."}
|
|
871
|
+
self._restart_requested = True
|
|
872
|
+
self._restart_answer = None
|
|
873
|
+
self._condition.notify_all()
|
|
874
|
+
self._end_run()
|
|
875
|
+
return self._await(
|
|
876
|
+
connection, lambda: self._restart_answer is not None, lambda: dict(self._restart_answer or {})
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
def _end_run(self) -> None:
|
|
880
|
+
"""End the REPL or the run, unless it has ended, for a `stop` or a `restart` that the caller has noted."""
|
|
881
|
+
with self._condition:
|
|
882
|
+
if self._run_ended:
|
|
883
|
+
return
|
|
884
|
+
waits_at_stop = self._kind is SessionKind.DEBUG and self._prompt == ConsoleInterpreter.DEBUG_PROMPT.strip()
|
|
885
|
+
if self._kind is SessionKind.REPL:
|
|
886
|
+
# As SIGTERM does.
|
|
887
|
+
self._signal(signal.SIGTERM)
|
|
888
|
+
elif not waits_at_stop:
|
|
889
|
+
# The run pauses at the next keyword, where `next_input` aborts it; without the debugger, it ends as on
|
|
890
|
+
# Ctrl-C.
|
|
891
|
+
self._signal(signal.SIGINT)
|
|
892
|
+
|
|
893
|
+
def _await(
|
|
894
|
+
self,
|
|
895
|
+
connection: Connection,
|
|
896
|
+
done: Callable[[], bool],
|
|
897
|
+
answer: Callable[[], Dict[str, Any]],
|
|
898
|
+
at_end: Optional[Callable[[], Dict[str, Any]]] = None,
|
|
899
|
+
) -> Optional[Dict[str, Any]]:
|
|
900
|
+
"""Wait until `done()` and return `answer()`, or return `at_end()` once the REPL or the run has ended.
|
|
901
|
+
Meanwhile the client can send a `status` request after its timeout: it then gets the output so far, and a
|
|
902
|
+
later `wait` gets the answer."""
|
|
903
|
+
while True:
|
|
904
|
+
with self._condition:
|
|
905
|
+
if done():
|
|
906
|
+
return answer()
|
|
907
|
+
if at_end is not None and self._run_ended:
|
|
908
|
+
return at_end()
|
|
909
|
+
self._condition.wait(0.1)
|
|
910
|
+
if connection.poll():
|
|
911
|
+
if connection.recv().get("request") == Request.STATUS:
|
|
912
|
+
with self._condition:
|
|
913
|
+
return self._status()
|
|
914
|
+
|
|
915
|
+
def _runs(self) -> bool:
|
|
916
|
+
return self._prompt is None or self._current is not None or bool(self._requests)
|
|
917
|
+
|
|
918
|
+
def _state(self) -> SessionState:
|
|
919
|
+
if self._run_ended:
|
|
920
|
+
return SessionState.ENDED
|
|
921
|
+
return SessionState.RUNS if self._runs() else SessionState.WAITS
|
|
922
|
+
|
|
923
|
+
def _status(self) -> Dict[str, Any]:
|
|
924
|
+
if self._run_ended:
|
|
925
|
+
return self._answer_ended()
|
|
926
|
+
if self._runs():
|
|
927
|
+
return self._make_answer(self._output_size(), AnswerStatus.RUNNING)
|
|
928
|
+
return self._make_answer(self._output_size(), _passed_or_failed(self._arrival_failed))
|
|
929
|
+
|
|
930
|
+
def _answer_ended(self) -> Dict[str, Any]:
|
|
931
|
+
"""The output since the last answer, with the status of the input that ran at the end and the exit code of the
|
|
932
|
+
REPL or the run."""
|
|
933
|
+
answer = self._make_answer(self._output_size(), _passed_or_failed(self._end_failed))
|
|
934
|
+
return {**answer, "exit_code": self._exit_code}
|
|
935
|
+
|
|
936
|
+
def _make_answer(self, end: int, status: AnswerStatus) -> Dict[str, Any]:
|
|
937
|
+
return {"output": self._read_output(end), "status": status}
|
|
938
|
+
|
|
939
|
+
def _read_output(self, end: int) -> str:
|
|
940
|
+
"""Take the output from where the last answer ended up to `end` out of memory, and return it."""
|
|
941
|
+
with self._output_lock:
|
|
942
|
+
if end <= self._position:
|
|
943
|
+
return ""
|
|
944
|
+
size = end - self._position
|
|
945
|
+
data = bytes(self._output[:size])
|
|
946
|
+
del self._output[:size]
|
|
947
|
+
self._position = end
|
|
948
|
+
return self._decoder.decode(data).replace("\r\n", "\n")
|
|
949
|
+
|
|
950
|
+
def _output_size(self) -> int:
|
|
951
|
+
"""How much the process has written so far, with what is still in the pipe; once the new process of a restart
|
|
952
|
+
reads the pipe, only what this process has read."""
|
|
953
|
+
with self._output_lock:
|
|
954
|
+
if self._reading:
|
|
955
|
+
self._take_output()
|
|
956
|
+
return self._read_size
|
|
957
|
+
|
|
958
|
+
def at_line_start(self) -> bool:
|
|
959
|
+
"""Whether what the process has written up to now ends with a line break, or there is nothing yet."""
|
|
960
|
+
_flush_output()
|
|
961
|
+
with self._output_lock:
|
|
962
|
+
if self._reading:
|
|
963
|
+
self._take_output()
|
|
964
|
+
return self._at_line_start
|
|
965
|
+
|
|
966
|
+
def _start_reading(self) -> threading.Thread:
|
|
967
|
+
self._reading = True
|
|
968
|
+
reader = threading.Thread(target=self._read_pipe, name="robotcode-session-output", daemon=True)
|
|
969
|
+
reader.start()
|
|
970
|
+
return reader
|
|
971
|
+
|
|
972
|
+
def _stop_reading(self) -> None:
|
|
973
|
+
"""Stop reading the pipe, so that the new process of a restart can read it; what the pipe holds now still
|
|
974
|
+
belongs to this process and is read first."""
|
|
975
|
+
with self._output_lock:
|
|
976
|
+
self._take_output()
|
|
977
|
+
self._reading = False
|
|
978
|
+
self._reader.join()
|
|
979
|
+
|
|
980
|
+
def _pipe_handle(self) -> int:
|
|
981
|
+
"""The pipe of the output as `session start` passed it down, a handle on Windows."""
|
|
982
|
+
if sys.platform == "win32":
|
|
983
|
+
return msvcrt.get_osfhandle(self._pipe)
|
|
984
|
+
return self._pipe
|
|
985
|
+
|
|
986
|
+
def _read_pipe(self) -> None:
|
|
987
|
+
"""Read the pipe into memory while this process reads it, so that it doesn't fill up and hold up the processes
|
|
988
|
+
that write to it."""
|
|
989
|
+
delay = 0.001
|
|
990
|
+
while True:
|
|
991
|
+
if sys.platform == "win32":
|
|
992
|
+
# Windows can't wait until a pipe has something to read.
|
|
993
|
+
time.sleep(delay)
|
|
994
|
+
else:
|
|
995
|
+
# With a timeout, so that the thread soon notices when a restart stops the reading.
|
|
996
|
+
select.select([self._pipe], [], [], 0.1)
|
|
997
|
+
with self._output_lock:
|
|
998
|
+
if not self._reading:
|
|
999
|
+
return
|
|
1000
|
+
size = self._read_size
|
|
1001
|
+
if not self._take_output():
|
|
1002
|
+
return
|
|
1003
|
+
delay = 0.001 if self._read_size > size else min(delay * 2, 0.05)
|
|
1004
|
+
|
|
1005
|
+
def _take_output(self) -> bool:
|
|
1006
|
+
"""Move what the pipe holds now into memory, without waiting for more; False at the end of the pipe. The
|
|
1007
|
+
caller holds `_output_lock`."""
|
|
1008
|
+
while True:
|
|
1009
|
+
try:
|
|
1010
|
+
if sys.platform == "win32":
|
|
1011
|
+
size = _winapi.PeekNamedPipe(msvcrt.get_osfhandle(self._pipe), 0)[0]
|
|
1012
|
+
if not size:
|
|
1013
|
+
return True
|
|
1014
|
+
data = os.read(self._pipe, size)
|
|
1015
|
+
else:
|
|
1016
|
+
data = os.read(self._pipe, 65536)
|
|
1017
|
+
except BlockingIOError:
|
|
1018
|
+
return True
|
|
1019
|
+
except OSError:
|
|
1020
|
+
# The end of the pipe on Windows.
|
|
1021
|
+
return False
|
|
1022
|
+
if not data:
|
|
1023
|
+
return False
|
|
1024
|
+
self._output += data
|
|
1025
|
+
self._read_size += len(data)
|
|
1026
|
+
self._at_line_start = data.endswith(b"\n")
|
|
1027
|
+
|
|
1028
|
+
def _update_entry(self, **changes: Any) -> None:
|
|
1029
|
+
if self._closing or self._handing_over:
|
|
1030
|
+
return
|
|
1031
|
+
self._entry = dataclasses.replace(self._entry, **changes)
|
|
1032
|
+
with contextlib.suppress(OSError):
|
|
1033
|
+
_write_entry(self._entry)
|
|
1034
|
+
|
|
1035
|
+
def _signal(self, signum: int) -> None:
|
|
1036
|
+
"""Let the interpreter, in the main thread, handle `signum` as when the signal arrives."""
|
|
1037
|
+
if sys.platform == "win32":
|
|
1038
|
+
signal.raise_signal(signum)
|
|
1039
|
+
else:
|
|
1040
|
+
main = threading.main_thread().ident
|
|
1041
|
+
if main is not None:
|
|
1042
|
+
signal.pthread_kill(main, signum)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
class SessionInterpreter(ConsoleInterpreter):
|
|
1046
|
+
"""The console interpreter of a session: it takes its inputs from the channel of the session, each as a whole,
|
|
1047
|
+
instead of line by line from a terminal."""
|
|
1048
|
+
|
|
1049
|
+
def __init__(
|
|
1050
|
+
self, server: SessionServer, app: Application, files: List[Path], show_keywords: bool, inspect: bool
|
|
1051
|
+
) -> None:
|
|
1052
|
+
super().__init__(app=app, files=files, show_keywords=show_keywords, inspect=inspect)
|
|
1053
|
+
self._server = server
|
|
1054
|
+
server.watch(self)
|
|
1055
|
+
|
|
1056
|
+
@property
|
|
1057
|
+
def termination_requested(self) -> bool:
|
|
1058
|
+
"""Whether a signal has asked the REPL or the run to end, as SIGTERM does."""
|
|
1059
|
+
return self._termination_requested
|
|
1060
|
+
|
|
1061
|
+
def read_line(
|
|
1062
|
+
self, prompt: str, *, multiline_continuation: bool = False, prefill: str = "", completer: Any = None
|
|
1063
|
+
) -> str:
|
|
1064
|
+
del multiline_continuation, prefill, completer # no editor in a session
|
|
1065
|
+
return self._server.next_input(prompt)
|
|
1066
|
+
|
|
1067
|
+
def get_input(self) -> Iterator[Optional[Keyword]]:
|
|
1068
|
+
# Script files given to `session start repl` run first, as in `robotcode repl`.
|
|
1069
|
+
if self.files or (self.executed_files and not self.inspect):
|
|
1070
|
+
yield from super().get_input()
|
|
1071
|
+
return
|
|
1072
|
+
self._input_from_file = False
|
|
1073
|
+
try:
|
|
1074
|
+
text = self._read(self.PROMPT)
|
|
1075
|
+
except KeyboardInterrupt:
|
|
1076
|
+
# An `interrupt` while the session waits for an input, as Ctrl-C at an empty prompt.
|
|
1077
|
+
self._report_interrupt(self.PROMPT)
|
|
1078
|
+
return
|
|
1079
|
+
if text.lstrip().startswith(".") and self._dispatch_dot_command(text):
|
|
1080
|
+
return
|
|
1081
|
+
# The input runs as a whole, as a script file does; a block it leaves unfinished fails with what Robot
|
|
1082
|
+
# Framework says it lacks.
|
|
1083
|
+
parsed = self.parse_input(text)
|
|
1084
|
+
if parsed.test.body and not parsed.errors:
|
|
1085
|
+
self._session_lines.append(text)
|
|
1086
|
+
yield from self._body_to_run(parsed)
|
|
1087
|
+
|
|
1088
|
+
def _report_interrupt(self, prompt: str) -> None:
|
|
1089
|
+
# A session doesn't write its prompt, so there is no line to end.
|
|
1090
|
+
del prompt
|
|
1091
|
+
self._echo("KeyboardInterrupt")
|
|
1092
|
+
|
|
1093
|
+
def _record_failure(self, error: ExecutionFailed) -> None:
|
|
1094
|
+
self._server.line_failed()
|
|
1095
|
+
super()._record_failure(error)
|
|
1096
|
+
|
|
1097
|
+
def _end_console_line(self) -> None:
|
|
1098
|
+
# The session knows its output: if it ends with a line break already, another one would leave an empty line.
|
|
1099
|
+
if not self._server.at_line_start():
|
|
1100
|
+
super()._end_console_line()
|
|
1101
|
+
|
|
1102
|
+
def _evaluate_at_stop(self, line: str) -> bool:
|
|
1103
|
+
# A keyword at a stop fails the input as at the REPL prompt.
|
|
1104
|
+
try:
|
|
1105
|
+
ran = super()._evaluate_at_stop(line)
|
|
1106
|
+
except ExecutionPassed as e:
|
|
1107
|
+
if e.earlier_failures:
|
|
1108
|
+
self._server.line_failed()
|
|
1109
|
+
raise
|
|
1110
|
+
except ExecutionFailed as e:
|
|
1111
|
+
if not e.skip:
|
|
1112
|
+
self._server.line_failed()
|
|
1113
|
+
raise
|
|
1114
|
+
except Exception:
|
|
1115
|
+
self._server.line_failed()
|
|
1116
|
+
raise
|
|
1117
|
+
if not ran:
|
|
1118
|
+
self._server.line_failed()
|
|
1119
|
+
return ran
|