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.
@@ -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