Skip to content

Commit 52ee998

Browse files
committed
fix: report why rshell command timed out instead of hiding a stuck client
The EFI client executes commands with a blocking os.system() call, so a tool that hangs on the DUT stops its polling loop for good and every later command times out. Track when each client last polled and which command it picked up, and return that diagnosis in the X-RShell-Timeout-Reason header. Signed-off-by: Ziomek, Kamil <kamil.ziomek@intel.com>
1 parent 4a14ce8 commit 52ee998

6 files changed

Lines changed: 906 additions & 97 deletions

File tree

README.md

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,6 +1173,14 @@ If code cannot establish connection, it will start deployment of python using [D
11731173
* `timeout` - Timeout for command execution.
11741174
* `command` - Command to be executed.
11751175
* `ip` - IP address of the EFI Shell target system.
1176+
1177+
Responses:
1178+
* `200` - command output in the body, `rc` header holds the return code.
1179+
* `400` - no command provided.
1180+
* `504` - the result did not arrive within `timeout` (+ client poll grace period). The body is empty
1181+
and `rc` is `-1`. The command is marked as *abandoned*, so it is never executed late and its result
1182+
is discarded on arrival - this keeps the caller and the EFI client in sync after a timeout.
1183+
The `X-RShell-Timeout-Reason` header explains *why* the wait failed (see below).
11761184
* `/post_result` - Endpoint to post results back to the host.
11771185
Headers fields:
11781186
* `CommandID` - Unique identifier for the command.
@@ -1184,8 +1192,56 @@ If code cannot establish connection, it will start deployment of python using [D
11841192
* `CommandID` - Unique identifier for the command.
11851193
Body:
11861194
* Exception details.
1187-
* `/getCommandToExecute` - Endpoint to retrieve commands to be executed on the EFI Shell target system. Returns commandline with generated CommandID.
1188-
* `/health/<ip>` - Endpoint to check the health status of the connection.
1195+
* `/getCommandToExecute` - Endpoint to retrieve commands to be executed on the EFI Shell target system. Returns commandline with generated CommandID. Commands abandoned after a timeout are skipped. Every poll also refreshes the client liveness marker.
1196+
* `/health/<ip>` - Reports `200` only when the client is known **and** polled within `CLIENT_LIVENESS_TIMEOUT_SECONDS`. A client that was seen once but went silent is reported as `503`, so a dead client no longer looks healthy for the rest of the run.
1197+
* `/disconnect_client/<ip>` - Removes the client and drops everything still queued for it.
1198+
1199+
### Diagnosing a timeout
1200+
1201+
The EFI client runs every command through a blocking `os.system()` call and asks for new work only
1202+
after the previous command finished. A tool that hangs on the DUT - for example one that waits for a
1203+
key press because it was not started in batch/silent mode - therefore stops the polling loop for good.
1204+
From the caller side that is indistinguishable from a slow command: **every** later command times out.
1205+
1206+
To make that visible the server tracks when each client last polled and which command it picked up,
1207+
and returns the conclusion in the `X-RShell-Timeout-Reason` header, which `RShellConnection` logs:
1208+
1209+
| Situation | Reported reason |
1210+
| --- | --- |
1211+
| Client never contacted the server | `has never contacted the server - check that rshell_client.py is running on the DUT` |
1212+
| Client fetched this command and never came back | `picked this command up Ns ago and never reported back ... run it manually with output redirected to a file to check` |
1213+
| Client stopped polling while running an earlier command | `stopped polling Ns ago ... still blocked executing '<command>' - the command most likely hangs on the DUT` |
1214+
| Client keeps polling, command is just slow | `is alive and polling ... consider passing a bigger timeout` |
1215+
1216+
The second and third rows point at the DUT, not at the server - no server-side change can make a
1217+
hung EFI process return.
1218+
1219+
### Timeouts
1220+
1221+
`execute_command(timeout=...)` is forwarded to the server. When no timeout is given, the server falls
1222+
back to `600` seconds and the connection logs that the fallback was applied. Set `default_timeout` on
1223+
the connection for commands that legitimately run longer:
1224+
1225+
```python
1226+
from mfd_connect import RShellConnection
1227+
1228+
conn = RShellConnection(ip="10.10.10.10", server_ip="10.10.10.1", default_timeout=2500)
1229+
```
1230+
1231+
### Server resource limits
1232+
1233+
The server keeps its in-memory state bounded, so a long test session cannot exhaust RAM:
1234+
1235+
| Constant | Default | Meaning |
1236+
| --- | --- | --- |
1237+
| `STALE_OUTPUT_TTL_SECONDS` | `600` | Age after which an uncollected output or abandoned ID is evicted. |
1238+
| `MAX_STORED_OUTPUTS` | `512` | Hard cap on results waiting to be collected. |
1239+
| `MAX_ABANDONED_COMMAND_IDS` | `512` | Hard cap on remembered abandoned command IDs. |
1240+
| `MAX_PENDING_COMMANDS_PER_CLIENT` | `256` | Hard cap on commands queued for a single client. |
1241+
| `CLIENT_LIVENESS_TIMEOUT_SECONDS` | `300` | Silence after which `/health` reports the client as gone. |
1242+
1243+
Waiting for a result is event driven - the caller is woken up as soon as the output is posted,
1244+
and all shared state is guarded by a lock because Werkzeug serves requests in threads.
11891245

11901246
`rshell.py` is a Connection class that calls RESTful API endpoints provided by `rshell_server.py` to execute commands on the EFI Shell target system. If required, starts `rshell_server.py` on the host machine.
11911247

@@ -1194,6 +1250,14 @@ RShell server can be started manually using the following command:
11941250
python -m mfd_connect.rshell_server
11951251
```
11961252

1253+
`rshell_client.py` runs on the DUT and accepts an optional source IP and source port:
1254+
```bash
1255+
rshell_client.py <server_ip> [source_ip] [source_port]
1256+
```
1257+
The source port defaults to `80`; override it when that fixed local port collides with sockets left
1258+
in `TIME_WAIT` by earlier connections. A transient network error no longer terminates the client -
1259+
it retries instead, so a single glitch cannot silence the DUT for the rest of the run.
1260+
11971261
## OS supported:
11981262
* LNX
11991263
* WINDOWS

mfd_connect/rshell.py

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@
3939
# can be adjusted based on requirements and observed behavior of platforms.
4040
PLATFORM_POWER_TRANSITION_DELAY_SECONDS = 10
4141

42+
# Timeout applied by the RShell server when the caller does not provide one.
43+
# Kept in sync with rshell_server.execute_command so the fallback can be logged explicitly.
44+
SERVER_DEFAULT_TIMEOUT_SECONDS = 600
45+
# Header carrying the server side explanation of a 504.
46+
TIMEOUT_REASON_HEADER = "X-RShell-Timeout-Reason"
47+
4248

4349
class RShellConnection(Connection):
4450
"""RShell Connection Class."""
@@ -50,6 +56,7 @@ def __init__(
5056
model: "BaseModel | None" = None,
5157
cache_system_data: bool = True,
5258
connection_timeout: int = 60,
59+
default_timeout: int | None = None,
5360
):
5461
"""
5562
Initialize RShellConnection.
@@ -58,8 +65,12 @@ def __init__(
5865
:param server_ip: The IP address of the server to connect to (optional).
5966
:param model: The Pydantic model to use for the connection (optional).
6067
:param cache_system_data: Whether to cache system data (default: True).
68+
:param connection_timeout: Time to wait for the RShell client to show up.
69+
:param default_timeout: Timeout used by execute_command when the caller passes none.
70+
When left as None the server applies its own default
71+
(``SERVER_DEFAULT_TIMEOUT_SECONDS``).
6172
"""
62-
super().__init__(model=model, cache_system_data=cache_system_data)
73+
super().__init__(model=model, default_timeout=default_timeout, cache_system_data=cache_system_data)
6374
self._ip = ip
6475
self.server_ip = server_ip
6576
self.server_process: LocalProcess | None = None
@@ -76,10 +87,14 @@ def wait_for_connection(self, connection_timeout: int) -> None:
7687
logger.log(level=log_levels.MODULE_DEBUG, msg="Checking RShell server health")
7788
try:
7889
status_code = requests.get(
79-
f"http://{self.server_ip}/health/{self._ip}", proxies={"no_proxy": "*"}
90+
f"http://{self.server_ip}/health/{self._ip}",
91+
proxies={"no_proxy": "*"},
8092
).status_code
8193
except requests.RequestException as e:
82-
logger.log(level=log_levels.MODULE_DEBUG, msg=f"RShell server health check failed with error: {e}")
94+
logger.log(
95+
level=log_levels.MODULE_DEBUG,
96+
msg=f"RShell server health check failed with error: {e}",
97+
)
8398
status_code = None
8499
if status_code == 200:
85100
logger.log(level=log_levels.MODULE_DEBUG, msg="RShell server is healthy")
@@ -96,7 +111,10 @@ def disconnect(self, stop_client: bool = False, stop_server: bool = False) -> No
96111
97112
:param stop_client: Whether to stop the RShell client (default: False).
98113
"""
99-
requests.post(f"http://{self.server_ip}/disconnect_client/{self._ip}", proxies={"no_proxy": "*"})
114+
requests.post(
115+
f"http://{self.server_ip}/disconnect_client/{self._ip}",
116+
proxies={"no_proxy": "*"},
117+
)
100118
if stop_client:
101119
logger.log(level=log_levels.MODULE_DEBUG, msg="Stopping RShell client")
102120
self.execute_command("end")
@@ -200,14 +218,40 @@ def execute_command(
200218
level=log_levels.MODULE_DEBUG,
201219
msg="Custom exceptions are not supported for RShellConnection and will be ignored.",
202220
)
203-
timeout_string = f" with timeout {timeout} seconds" if timeout is not None else ""
204-
logger.log(level=log_levels.CMD, msg=f"Executing >{self._ip}> '{command}',{timeout_string}")
221+
effective_timeout = timeout if timeout is not None else self.default_timeout
222+
if effective_timeout is None:
223+
logger.log(
224+
level=log_levels.MODULE_DEBUG,
225+
msg=f"No timeout given for '{command}'; the RShell server will apply its default of "
226+
f"{SERVER_DEFAULT_TIMEOUT_SECONDS} seconds. Pass 'timeout' or set 'default_timeout' "
227+
f"on the connection for commands that legitimately run longer.",
228+
)
229+
timeout_string = f" with timeout {effective_timeout} seconds" if effective_timeout is not None else ""
230+
logger.log(
231+
level=log_levels.CMD,
232+
msg=f"Executing >{self._ip}> '{command}',{timeout_string}",
233+
)
205234

206235
response = requests.post(
207236
f"http://{self.server_ip}/execute_command",
208-
data={"command": command, "timeout": timeout, "ip": self._ip},
237+
data={"command": command, "timeout": effective_timeout, "ip": self._ip},
209238
proxies={"no_proxy": "*"},
210239
)
240+
if response.status_code == 504:
241+
reason = response.headers.get(TIMEOUT_REASON_HEADER)
242+
waited = effective_timeout if effective_timeout is not None else SERVER_DEFAULT_TIMEOUT_SECONDS
243+
message = (
244+
f"RShell server timed out after {waited}s waiting for the result of '{command}'. "
245+
f"The command was dropped so it will not be executed later by the client."
246+
)
247+
if reason:
248+
message = f"{message} Reason: {reason}."
249+
logger.log(level=log_levels.MODULE_DEBUG, msg=message)
250+
elif response.status_code >= 500:
251+
logger.log(
252+
level=log_levels.MODULE_DEBUG,
253+
msg=f"RShell server returned an internal error ({response.status_code}) for '{command}'.",
254+
)
211255
completed_process = ConnectionCompletedProcess(
212256
args=command,
213257
stdout=response.text,
@@ -336,7 +380,10 @@ def stop_server(self) -> None:
336380
break
337381
time.sleep(1)
338382
else:
339-
logger.log(level=log_levels.MODULE_DEBUG, msg="RShell server did not stop within timeout")
383+
logger.log(
384+
level=log_levels.MODULE_DEBUG,
385+
msg="RShell server did not stop within timeout",
386+
)
340387
raise RuntimeError("RShell server did not stop within timeout")
341388

342389
logger.log(level=log_levels.MODULE_DEBUG, msg="RShell server stopped")

mfd_connect/rshell_client.py

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,18 @@
55
66
Make sure that the Python UEFI interpreter is compiled with
77
Socket module support.
8+
9+
Usage::
10+
11+
rshell_client.py <server_ip> [source_ip] [source_port]
12+
13+
The client polls the server for work, runs one command at a time and posts the result back.
14+
Commands are executed with a blocking ``os.system()`` call, so a tool that waits for input on
15+
the DUT stops the whole loop - every later command then times out on the server side. Keep
16+
that in mind when adding tools: always run them in a non interactive/batch mode.
817
"""
918

10-
__version__ = "1.1.0"
19+
__version__ = "1.2.0"
1120

1221
try:
1322
import httplib as client
@@ -23,6 +32,16 @@
2332
source_address = sys.argv[2]
2433
else:
2534
source_address = None
35+
# Local port the outgoing connection is bound to. Only relevant together with source_address,
36+
# which exists so that the server sees the expected client IP. Override it when the fixed
37+
# port collides with sockets left in TIME_WAIT by previous connections.
38+
if len(sys.argv) > 3:
39+
source_port = int(sys.argv[3])
40+
else:
41+
source_port = 80
42+
43+
# How long to wait before retrying after a failed server interaction.
44+
RETRY_WAIT_SECONDS = 5
2645

2746
os_name = os.name
2847

@@ -41,6 +60,16 @@ def _sleep(interval): # noqa: ANN001, ANN202
4160
time.sleep = _sleep
4261

4362

63+
def _close(connection): # noqa: ANN001, ANN202
64+
"""Close a connection without letting a broken socket kill the client."""
65+
if connection is None:
66+
return
67+
try:
68+
connection.close()
69+
except Exception as exp: # noqa: BLE001
70+
print("Ignoring error while closing the connection:", exp)
71+
72+
4473
def _get_command(): # noqa: ANN202
4574
"""Get the command from server to execute on client machine."""
4675
# construct the list of tests by interacting with server
@@ -61,27 +90,37 @@ def _get_command(): # noqa: ANN202
6190

6291
while True:
6392
# Connect to server
64-
source_address_parameter = (source_address, 80) if source_address else None
65-
conn = client.HTTPConnection(http_server, source_address=source_address_parameter)
66-
# get the command from server
67-
_command = _get_command()
93+
source_address_parameter = (source_address, source_port) if source_address else None
94+
conn = None
95+
try:
96+
conn = client.HTTPConnection(http_server, source_address=source_address_parameter)
97+
# get the command from server
98+
_command = _get_command()
99+
except Exception as exp: # noqa: BLE001
100+
# A transient network error must not end the client. If it did, the DUT would stop
101+
# asking for work and every following command would time out on the server.
102+
print("Failed to get a command from the server:", exp)
103+
_close(conn)
104+
time.sleep(RETRY_WAIT_SECONDS)
105+
continue
106+
68107
if not _command:
69-
conn.close()
70-
time.sleep(5)
108+
_close(conn)
109+
time.sleep(RETRY_WAIT_SECONDS)
71110
continue
72111
cmd_str, _id = _command
73112
cmd_str = cmd_str.decode("utf-8")
74113
cmd_name = cmd_str.split(" ")[0]
75114
if cmd_name == "end":
76115
print("No more commands available to run")
77-
conn.close()
116+
_close(conn)
78117
exit(0)
79118

80119
print("Executing", cmd_str)
81120
if cmd_name.startswith("reset"):
82121
print("Reset command received, shutting down the platform")
83122
os.system(cmd_str) # execute reset command on machine
84-
conn.close()
123+
_close(conn)
85124
exit(0)
86125

87126
non_echo = False
@@ -120,15 +159,19 @@ def _get_command(): # noqa: ANN202
120159
if non_echo and f:
121160
f.close()
122161
os.system("del " + out)
123-
except Exception as exp:
124-
conn.request(
125-
"POST",
126-
"exception",
127-
body=cmd + str(exp),
128-
headers={"Content-Type": "text/plain", "Connection": "keep-alive", "CommandID": _id},
129-
)
162+
except Exception as exp: # noqa: BLE001
163+
try:
164+
conn.request(
165+
"POST",
166+
"exception",
167+
body=cmd + str(exp),
168+
headers={"Content-Type": "text/plain", "Connection": "keep-alive", "CommandID": _id},
169+
)
170+
except Exception as report_exp: # noqa: BLE001
171+
# Reporting failed too - stay alive so the next poll can still reach the server.
172+
print("Failed to report the error to the server:", report_exp)
130173

131174
print("output posted to server")
132-
conn.close()
175+
_close(conn)
133176
print("closed the connection")
134177
time.sleep(1)

0 commit comments

Comments
 (0)