22import asyncio
33import os
44import shlex
5+ import signal
56import subprocess
67import tempfile
78from dataclasses import dataclass
89from typing import Dict , Iterable , List , Literal , NoReturn , Optional , Union
910
11+ from dstack ._internal .compat import IS_WINDOWS
1012from dstack ._internal .core .errors import SSHError
1113from dstack ._internal .core .models .instances import SSHConnectionParams
1214from dstack ._internal .core .services .ssh import get_ssh_error
@@ -71,6 +73,7 @@ def __init__(
7173 port : Optional [int ] = None ,
7274 ssh_proxies : Iterable [tuple [SSHConnectionParams , Optional [FilePathOrContent ]]] = (),
7375 batch_mode : bool = False ,
76+ background : bool = True ,
7477 ):
7578 """
7679 :param forwarded_sockets: Connections to the specified local sockets will be
@@ -91,6 +94,10 @@ def __init__(
9194 Control commands (`check`, `close`, `exec`) always run in batch mode, since they
9295 only talk to the local master and must not prompt if ssh falls back to a direct
9396 connection.
97+ :param background: If False, own a foreground SSH process instead of a daemon.
98+ Use only the async methods and a dedicated control socket. `aopen()` waits
99+ for startup readiness; `wait_closed()` waits for exit without polling;
100+ `aclose()` cleans up the process and its ProxyCommand children.
94101 """
95102 self .destination = destination
96103 self .forwarded_sockets = list (forwarded_sockets )
@@ -114,6 +121,8 @@ def __init__(
114121 )
115122 self .ssh_proxies .append ((proxy_params , proxy_identity_path ))
116123 self .batch_mode = batch_mode
124+ self .background = background
125+ self ._process : Optional [asyncio .subprocess .Process ] = None
117126 self .log_path = normalize_path (os .path .join (temp_dir .name , "tunnel.log" ))
118127 self .ssh_client_info = get_ssh_client_info ()
119128 self .ssh_exec_path = str (self .ssh_client_info .path )
@@ -136,19 +145,21 @@ def open_command(self) -> List[str]:
136145 self .log_path ,
137146 "-N" , # do not run commands on remote
138147 ]
139- if self .ssh_client_info .supports_background_mode :
148+ if self .background :
149+ if not self .ssh_client_info .supports_background_mode :
150+ raise SSHError ("Unsupported SSH client" )
140151 command += ["-f" ] # go to background after successful authentication
141152 else :
142- raise SSHError ( "Unsupported SSH client" )
153+ command += [ "-o" , "ForkAfterAuthentication=no" , "-o" , "ControlPersist=no" ]
143154 if self .ssh_client_info .supports_control_socket :
144155 # It's safe to use ControlMaster even if the ssh client does not support multiplexing
145156 # as long as we don't allow more than one tunnel to the specific host to be running.
146157 # We use this feature for control only (see :meth:`close_command`).
147158 command += [
148- # Not `-M`, which means `ControlMaster=yes`, to avoid spawning uncontrollable
149- # ssh instances if more than one tunnel is started (precaution) .
159+ # Background connections may reuse a master. Foreground connections must
160+ # own their process, rather than attach to another master and exit .
150161 "-o" ,
151- "ControlMaster=auto" ,
162+ "ControlMaster=auto" if self . background else "ControlMaster=yes" ,
152163 "-S" ,
153164 self .control_sock_path ,
154165 ]
@@ -185,6 +196,8 @@ def exec_command(self) -> List[str]:
185196 return [* self ._control_command_prefix (), self .destination ]
186197
187198 def open (self ) -> None :
199+ if not self .background :
200+ raise SSHError ("Foreground SSH tunnels require aopen()" )
188201 # We cannot use `stderr=subprocess.PIPE` here since the forked process (daemon) does not
189202 # close standard streams if ProxyJump is used, therefore we will wait EOF from the pipe
190203 # as long as the daemon exists.
@@ -206,6 +219,9 @@ def open(self) -> None:
206219 self ._raise_ssh_error_from_log_output (log_output )
207220
208221 async def aopen (self ) -> None :
222+ if not self .background :
223+ await self ._aopen_foreground ()
224+ return
209225 await run_async (self ._remove_log_file )
210226 proc = await asyncio .create_subprocess_exec (
211227 * self .open_command (), stdout = subprocess .DEVNULL , stderr = subprocess .DEVNULL
@@ -222,6 +238,12 @@ async def aopen(self) -> None:
222238 log_output = await run_async (self ._read_log_file )
223239 self ._raise_ssh_error_from_log_output (log_output )
224240
241+ async def wait_closed (self ) -> int :
242+ """Wait for the foreground process to exit; retain it for `aclose()` cleanup."""
243+ if self ._process is None :
244+ raise SSHError ("No foreground SSH process to wait for" )
245+ return await self ._process .wait ()
246+
225247 def close (self ) -> None :
226248 if not os .path .exists (self .control_sock_path ):
227249 logger .debug (
@@ -247,6 +269,16 @@ def close(self) -> None:
247269 )
248270
249271 async def aclose (self ) -> None :
272+ if not self .background :
273+ if self ._process is None :
274+ return
275+ cleanup = asyncio .create_task (self ._close_foreground (self ._process ))
276+ try :
277+ await asyncio .shield (cleanup )
278+ except asyncio .CancelledError :
279+ await cleanup
280+ raise
281+ return
250282 if not os .path .exists (self .control_sock_path ):
251283 logger .debug (
252284 "Control socket does not exist, it seems that ssh process has already exited"
@@ -330,6 +362,64 @@ def _control_command_prefix(self) -> List[str]:
330362 self .control_sock_path ,
331363 ]
332364
365+ async def _aopen_foreground (self ) -> None :
366+ if self ._process is not None :
367+ raise SSHError ("Close the previous foreground SSH process before opening" )
368+ await run_async (self ._remove_log_file )
369+ creation = asyncio .create_task (
370+ asyncio .create_subprocess_exec (
371+ * self .open_command (),
372+ stdout = subprocess .DEVNULL ,
373+ stderr = subprocess .DEVNULL ,
374+ start_new_session = not IS_WINDOWS ,
375+ )
376+ )
377+ try :
378+ # Retain the process handle even if cancellation arrives during its creation.
379+ proc = await asyncio .shield (creation )
380+ self ._process = proc
381+ await asyncio .wait_for (self ._wait_until_ready (proc ), SSH_TIMEOUT )
382+ except BaseException as e :
383+ if self ._process is None :
384+ try :
385+ self ._process = await creation
386+ except Exception :
387+ # Preserve cancellation if creation also failed.
388+ pass
389+ await self .aclose ()
390+ if isinstance (e , asyncio .TimeoutError ):
391+ raise SSHError (
392+ f"SSH tunnel to { self .destination } did not open in { SSH_TIMEOUT } seconds"
393+ ) from e
394+ raise
395+
396+ async def _wait_until_ready (self , proc : asyncio .subprocess .Process ) -> None :
397+ while proc .returncode is None :
398+ if os .path .exists (self .control_sock_path ) and await self .acheck ():
399+ if proc .returncode is None :
400+ return
401+ break
402+ await asyncio .sleep (0.1 )
403+ log_output = await run_async (self ._read_log_file )
404+ self ._raise_ssh_error_from_log_output (log_output )
405+
406+ async def _close_foreground (self , proc : asyncio .subprocess .Process ) -> None :
407+ try :
408+ if IS_WINDOWS :
409+ proc .kill ()
410+ else :
411+ # The launcher may have exited while a ProxyCommand child remains alive.
412+ os .killpg (proc .pid , signal .SIGKILL ) # pyright: ignore[reportAttributeAccessIssue]
413+ except ProcessLookupError :
414+ pass
415+ await proc .wait ()
416+ self ._process = None
417+ # SIGKILL leaves the control socket behind. This path is owned by this tunnel.
418+ try :
419+ os .remove (self .control_sock_path )
420+ except FileNotFoundError :
421+ pass
422+
333423 def _get_proxy_command (self ) -> Optional [str ]:
334424 proxy_command : Optional [str ] = None
335425 for params , identity_path in self .ssh_proxies :
@@ -410,16 +500,19 @@ def _get_identity_path(self, identity: FilePathOrContent, tmp_filename: str) ->
410500async def _arun (command : List [str ], timeout : float ) -> tuple [int , bytes , bytes ]:
411501 """
412502 Runs `command` with stdin redirected from /dev/null and returns its exit status, stdout,
413- and stderr. Kills the process and raises `asyncio.TimeoutError` if it does not exit in
414- `timeout` seconds.
503+ and stderr. Kills and reaps the process on cancellation or if it does not exit in
504+ `timeout` seconds, preserving the cancellation or `asyncio.TimeoutError` .
415505 """
416506 proc = await asyncio .create_subprocess_exec (
417507 * command , stdin = subprocess .DEVNULL , stdout = subprocess .PIPE , stderr = subprocess .PIPE
418508 )
419509 try :
420510 stdout , stderr = await asyncio .wait_for (proc .communicate (), timeout )
421- except asyncio .TimeoutError :
422- proc .kill ()
511+ except (asyncio .CancelledError , asyncio .TimeoutError ):
512+ try :
513+ proc .kill ()
514+ except ProcessLookupError :
515+ pass
423516 await proc .wait ()
424517 raise
425518 assert proc .returncode is not None
0 commit comments