Skip to content

Commit df42f3f

Browse files
committed
Merge branch 'feat/devagent-ci' into feat/devagent-ci-probe
2 parents 448a8d6 + 709487f commit df42f3f

2 files changed

Lines changed: 48 additions & 8 deletions

File tree

‎python-multiplatform/src/commonMain/python/python_multiplatform_devagent.py‎

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,15 +151,29 @@ def listen(self, port):
151151
self.server.bind((LOOPBACK, port))
152152
self.server.listen()
153153
self.port = self.server.getsockname()[1]
154-
threading.Thread(target=self._accept, name="python-multiplatform-devagent", daemon=True).start()
154+
self.accept_thread = threading.Thread(target=self._accept, name="python-multiplatform-devagent", daemon=True)
155+
self.accept_thread.start()
155156

156157
def stop(self):
158+
"""Stop listening and close every connection; the port is free when this returns."""
159+
# On Linux, close() alone leaves a socket that another thread is blocked accepting on in LISTEN, so the
160+
# port stays taken (a restarted agent's bind failed with EADDRINUSE there). shutdown() wakes accept().
161+
# macOS refuses shutdown() on a listening socket (ENOTCONN), and there close() is enough.
162+
try:
163+
self.server.shutdown(socket.SHUT_RDWR)
164+
except OSError:
165+
pass
157166
try:
158167
self.server.close()
159168
except OSError:
160169
pass
161-
for connection in list(self.connections):
170+
connections = list(self.connections)
171+
for connection in connections:
162172
connection.shutdown()
173+
current = threading.current_thread()
174+
for thread in [self.accept_thread] + [c.thread for c in connections]:
175+
if thread is not None and thread is not current:
176+
thread.join(timeout=5)
163177

164178
def _accept(self):
165179
while True:
@@ -169,11 +183,13 @@ def _accept(self):
169183
return
170184
connection = _Connection(self, sock)
171185
self.connections.append(connection)
172-
threading.Thread(target=connection.serve, name="python-multiplatform-devagent-client", daemon=True).start()
186+
connection.thread = threading.Thread(target=connection.serve, name="python-multiplatform-devagent-client", daemon=True)
187+
connection.thread.start()
173188

174189

175190
class _Connection:
176191
def __init__(self, agent, sock):
192+
self.thread = None
177193
self.agent = agent
178194
self.sock = sock
179195
self.reader = sock.makefile("r", encoding="utf-8")
@@ -298,9 +314,7 @@ def _push(self, request_id, message):
298314
written = []
299315
try:
300316
for relative, text in (message.get("files") or {}).items():
301-
normal = os.path.normpath(relative)
302-
if os.path.isabs(relative) or normal.startswith("..") or normal == "." or not normal.endswith(".py"):
303-
raise ValueError(f"{relative!r} is not a .py path inside the overlay")
317+
normal = self._overlay_path(relative)
304318
target = os.path.join(overlay, normal)
305319
os.makedirs(os.path.dirname(target), exist_ok=True)
306320
with open(target, "w", encoding="utf-8") as f:
@@ -327,6 +341,29 @@ def _push(self, request_id, message):
327341
reloaded.append(name)
328342
self._done(request_id, written=written, reloaded=reloaded)
329343

344+
def _overlay_path(self, relative):
345+
"""A pushed path, checked by the protocol's own rule rather than os.path's, as an OS path in the overlay.
346+
347+
os.path differs per platform: on Windows `isabs("/x")` is False (Python 3.13+) and joining such a path to
348+
the overlay lands on the drive root. So a path must be `/`-separated and relative, with no backslash, no
349+
drive colon, and no empty, `.` or `..` segment, and must still resolve inside the overlay.
350+
"""
351+
segments = relative.split("/") if isinstance(relative, str) else []
352+
if (
353+
not segments
354+
or "\\" in relative
355+
or ":" in relative
356+
or any(segment in ("", ".", "..") for segment in segments)
357+
or not segments[-1].endswith(".py")
358+
):
359+
raise ValueError(f"{relative!r} is not a relative /-separated .py path inside the overlay")
360+
normal = os.path.join(*segments)
361+
overlay = os.path.realpath(self.agent.overlay)
362+
target = os.path.realpath(os.path.join(overlay, normal))
363+
if os.path.commonpath([overlay, target]) != overlay:
364+
raise ValueError(f"{relative!r} resolves outside the overlay")
365+
return normal
366+
330367
@staticmethod
331368
def _module_name(path):
332369
parts = path[: -len(".py")].replace(os.sep, "/").split("/")

‎python-multiplatform/src/test/python/test_devagent.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def test_a_short_token_is_refused_before_anything_listens(self):
141141
def test_another_socket_cannot_take_the_port_over(self):
142142
agent = devagent.start(port=0, token=TOKEN, overlay=tempfile.mkdtemp())
143143
try:
144-
options = [socket.SO_REUSEADDR] + [getattr(socket, "SO_REUSEPORT")] * hasattr(socket, "SO_REUSEPORT")
144+
options = [socket.SO_REUSEADDR] + ([socket.SO_REUSEPORT] if hasattr(socket, "SO_REUSEPORT") else [])
145145
for option in options:
146146
hijacker = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
147147
try:
@@ -260,7 +260,10 @@ def test_a_reload_that_fails_keeps_the_old_module(self):
260260
self.assertEqual("1", self.of_type(replies, "result")[0]["data"]["text/plain"])
261261

262262
def test_a_path_outside_the_overlay_is_refused(self):
263-
for path in ["../escape.py", "/abs/escape.py", "a/../../escape.py"]:
263+
# Windows forms too, on every OS: on Windows `os.path.isabs("/abs/escape.py")` is False (Python 3.13+)
264+
# and joining it to the overlay lands on the drive root, so the rule cannot be os.path's.
265+
for path in ["../escape.py", "/abs/escape.py", "a/../../escape.py", "C:/escape.py", "C:escape.py",
266+
"\\escape.py", "a\\..\\..\\escape.py", "//server/share/escape.py", "./a.py", "a//b.py", ""]:
264267
done = self.done(self.client.request("push", files={path: "X = 1\n"}))
265268
self.assertEqual("error", done["status"], path)
266269
self.assertEqual("ValueError", done["ename"], path)

0 commit comments

Comments
 (0)