@@ -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
175190class _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 ("/" )
0 commit comments