Skip to content

Commit ccc330f

Browse files
committed
fix: prevent rshell server unbounded server memory growth
Signed-off-by: Ziomek, Kamil <kamil.ziomek@intel.com>
1 parent 508e8c9 commit ccc330f

2 files changed

Lines changed: 73 additions & 11 deletions

File tree

mfd_connect/rshell_server.py

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,63 @@
88
"""
99

1010
import time
11-
from collections import namedtuple
1211
from queue import Queue
12+
from typing import NamedTuple
1313
from uuid import uuid4
1414

1515
from flask import Flask, Response, request
1616

1717
__version__ = "1.1.0"
1818

19+
CLIENT_LOOP_WAIT_SECONDS = 5
20+
STALE_OUTPUT_TTL_SECONDS = 3600
21+
22+
1923
# Global command queue
20-
output_object = namedtuple("OutputObject", ["output", "rc"])
21-
command_object = namedtuple("CommandObject", ["command_id", "str"])
24+
class OutputObject(NamedTuple):
25+
"""Store command output together with its return code."""
26+
27+
output: str
28+
rc: int
29+
30+
31+
class CommandObject(NamedTuple):
32+
"""Store queued command metadata sent to a specific client."""
33+
34+
command_id: str
35+
str: str
2236

23-
output_queue: dict[str, output_object] = dict()
37+
38+
output_object = OutputObject
39+
command_object = CommandObject
40+
41+
output_queue: dict[str, OutputObject] = dict()
42+
output_queue_timestamps: dict[str, float] = dict()
2443
command_dict_queue: dict[str, Queue] = dict()
2544
clients: list = []
2645

2746
app = Flask(__name__)
2847

2948

30-
def get_output(command_id: str, timeout: float = 600) -> output_object:
49+
def _cleanup_stale_outputs(now: float | None = None, ttl: int = STALE_OUTPUT_TTL_SECONDS) -> None:
50+
"""Remove orphaned command outputs that have been kept longer than the configured TTL."""
51+
current_time = time.monotonic() if now is None else now
52+
stale_command_ids = [
53+
command_id for command_id, created_at in output_queue_timestamps.items() if current_time - created_at >= ttl
54+
]
55+
for command_id in stale_command_ids:
56+
output_queue.pop(command_id, None)
57+
output_queue_timestamps.pop(command_id, None)
58+
59+
60+
def _store_output(command_id: str, output: str, rc: int) -> None:
61+
"""Persist command output together with its insertion timestamp."""
62+
_cleanup_stale_outputs()
63+
output_queue[command_id] = output_object(output=output, rc=rc)
64+
output_queue_timestamps[command_id] = time.time()
65+
66+
67+
def get_output(command_id: str, timeout: float = 600) -> OutputObject:
3168
"""
3269
Retrieve the output for a given command ID.
3370
@@ -38,11 +75,13 @@ def get_output(command_id: str, timeout: float = 600) -> output_object:
3875
"""
3976
print("Getting output for command ID:", command_id)
4077
print(f"Waiting for output {timeout} seconds")
41-
timeout = timeout + 5 # add time for client loop waiting
78+
timeout = timeout + CLIENT_LOOP_WAIT_SECONDS # add time for client loop waiting
4279
while timeout > 0:
43-
result = output_queue.get(command_id, None)
80+
result = output_queue.pop(command_id, None)
4481
if result is not None:
82+
output_queue_timestamps.pop(command_id, None)
4583
return result
84+
_cleanup_stale_outputs()
4685
time.sleep(1)
4786
timeout -= 1
4887
raise TimeoutError("Command timed out")
@@ -110,7 +149,7 @@ def post_exception() -> Response:
110149
command_id = str(request.headers.get("CommandID"))
111150
print("CommandID: ", command_id)
112151
print(str(read_data, encoding="utf-8"))
113-
output_queue[command_id] = output_object(output=str(read_data, encoding="utf-8"), rc=-1)
152+
_store_output(command_id, str(read_data, encoding="utf-8"), rc=-1)
114153
return Response("Exception received", status=200)
115154

116155

@@ -140,7 +179,7 @@ def execute_command() -> Response:
140179
headers={
141180
"Content-type": "text/plain",
142181
"CommandID": _id,
143-
"rc": process.rc,
182+
"rc": str(process.rc),
144183
},
145184
)
146185
else:
@@ -156,6 +195,7 @@ def disconnect_client(ip_address: str) -> Response:
156195
"""
157196
if ip_address in clients:
158197
clients.remove(ip_address)
198+
command_dict_queue.pop(ip_address, None)
159199
print(f"Client disconnected: {ip_address}")
160200
return Response("Client disconnected", status=200)
161201

@@ -168,7 +208,7 @@ def post_result() -> Response:
168208
rc = int(request.headers.get("rc", -1))
169209
print("CommandID: ", command_id)
170210
print(str(read_data, encoding="utf-8"))
171-
output_queue[command_id] = output_object(output=str(read_data, encoding="utf-8"), rc=rc)
211+
_store_output(command_id, str(read_data, encoding="utf-8"), rc=rc)
172212
return Response("Results received", status=200)
173213

174214

tests/unit/test_mfd_connect/test_rshell_server.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class TestRShellServerScript:
2727
def server_module(self):
2828
module = _load_server_module()
2929
module.output_queue.clear()
30+
module.output_queue_timestamps.clear()
3031
module.command_dict_queue.clear()
3132
module.clients.clear()
3233
return module
@@ -35,10 +36,13 @@ def test_get_output_success(self, server_module):
3536
command_id = "cmd1"
3637
expected = server_module.output_object(output="hello", rc=0)
3738
server_module.output_queue[command_id] = expected
39+
server_module.output_queue_timestamps[command_id] = 123.0
3840

3941
result = server_module.get_output(command_id, timeout=0)
4042

4143
assert result == expected
44+
assert command_id not in server_module.output_queue
45+
assert command_id not in server_module.output_queue_timestamps
4246

4347
def test_get_output_timeout(self, server_module):
4448
with pytest.raises(TimeoutError, match="Command timed out"):
@@ -49,13 +53,14 @@ class _QueueProbe:
4953
def __init__(self):
5054
self.count = 0
5155

52-
def get(self, _command_id, _default=None):
56+
def pop(self, _command_id, _default=None):
5357
self.count += 1
5458
if self.count == 1:
5559
return None
5660
return server_module.output_object(output="later", rc=4)
5761

5862
monkeypatch.setattr(server_module, "output_queue", _QueueProbe())
63+
monkeypatch.setattr(server_module, "output_queue_timestamps", {})
5964
monkeypatch.setattr(server_module.time, "sleep", lambda _x: None)
6065

6166
result = server_module.get_output("cmd-later", timeout=0)
@@ -103,6 +108,7 @@ def test_post_exception_endpoint(self, server_module):
103108
assert response.status_code == 200
104109
assert server_module.output_queue["cid-1"].output == "boom"
105110
assert server_module.output_queue["cid-1"].rc == -1
111+
assert "cid-1" in server_module.output_queue_timestamps
106112

107113
def test_execute_command_endpoint_paths(self, server_module, monkeypatch):
108114
client = server_module.app.test_client()
@@ -140,10 +146,12 @@ def test_execute_command_endpoint_paths(self, server_module, monkeypatch):
140146
def test_disconnect_client_endpoint(self, server_module):
141147
client = server_module.app.test_client()
142148
server_module.clients.append("2.2.2.2")
149+
server_module.add_command_to_queue("echo hi", "2.2.2.2")
143150

144151
response_existing = client.post("/disconnect_client/2.2.2.2")
145152
assert response_existing.status_code == 200
146153
assert "2.2.2.2" not in server_module.clients
154+
assert "2.2.2.2" not in server_module.command_dict_queue
147155

148156
response_missing = client.post("/disconnect_client/8.8.8.8")
149157
assert response_missing.status_code == 200
@@ -155,6 +163,7 @@ def test_post_result_endpoint(self, server_module):
155163
assert response_default_rc.status_code == 200
156164
assert server_module.output_queue["cmd-a"].output == "output-a"
157165
assert server_module.output_queue["cmd-a"].rc == -1
166+
assert "cmd-a" in server_module.output_queue_timestamps
158167

159168
response_given_rc = client.post(
160169
"/post_result",
@@ -164,6 +173,19 @@ def test_post_result_endpoint(self, server_module):
164173
assert response_given_rc.status_code == 200
165174
assert server_module.output_queue["cmd-b"].output == "output-b"
166175
assert server_module.output_queue["cmd-b"].rc == 3
176+
assert "cmd-b" in server_module.output_queue_timestamps
177+
178+
def test_cleanup_stale_outputs_removes_expired_entries_only(self, server_module):
179+
server_module.output_queue["stale"] = server_module.output_object(output="old", rc=-1)
180+
server_module.output_queue_timestamps["stale"] = 10.0
181+
server_module.output_queue["fresh"] = server_module.output_object(output="new", rc=0)
182+
server_module.output_queue_timestamps["fresh"] = 500.0
183+
184+
server_module._cleanup_stale_outputs(now=4000.0, ttl=3600)
185+
186+
assert "stale" not in server_module.output_queue
187+
assert "stale" not in server_module.output_queue_timestamps
188+
assert server_module.output_queue["fresh"].output == "new"
167189

168190
def test_main_block_starts_flask(self, monkeypatch):
169191
captured = {}

0 commit comments

Comments
 (0)