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