@@ -2230,14 +2230,55 @@ def terminate_remote_tmux(self):
22302230 # died, peer FIN, etc.) — best-effort, fall through.
22312231 pass
22322232
2233+ # ── ControlMaster side-channel plumbing ─────────────────────────
2234+ #
2235+ # Every side-channel operation (tmux capture/options, file transfer,
2236+ # ls) runs a one-shot command over the existing ControlMaster socket
2237+ # with the same ssh argv. Build it in exactly one place so a future
2238+ # option change can't silently miss one of the seven call sites.
2239+ # terminate_remote_tmux is deliberately NOT on this helper: it
2240+ # re-dials with -p/-l/ConnectTimeout because its socket may belong
2241+ # to a master that is still authenticating.
2242+
2243+ def _mux_ready (self ):
2244+ """True when the ControlMaster socket exists and can be dialed."""
2245+ return bool (self ._control_path ) and os .path .exists (self ._control_path )
2246+
2247+ def _mux_argv (self , remote_cmd ):
2248+ """argv for a one-shot remote command over the ControlMaster."""
2249+ return [
2250+ "ssh" , "-T" ,
2251+ "-o" , "BatchMode=yes" ,
2252+ "-o" , "ControlPath=" + self ._control_path ,
2253+ "--" , self ._host , remote_cmd ,
2254+ ]
2255+
2256+ def _mux_run (self , remote_cmd , timeout , timeout_msg ,
2257+ err_prefix = "ssh error: " ):
2258+ """subprocess.run a remote command over the ControlMaster with
2259+ the shared timeout/exception handling. Returns (CompletedProcess,
2260+ None) on spawn success — the caller still interprets returncode/
2261+ stdout/stderr, which genuinely differ per operation — or
2262+ (None, error_string)."""
2263+ try :
2264+ proc = subprocess .run (self ._mux_argv (remote_cmd ),
2265+ capture_output = True , timeout = timeout )
2266+ except subprocess .TimeoutExpired :
2267+ return None , timeout_msg
2268+ except Exception as e :
2269+ # `or repr(e)` keeps the error truthy even for the rare
2270+ # exception with an empty str() — callers branch on `if err:`.
2271+ return None , err_prefix + (str (e ) or repr (e ))
2272+ return proc , None
2273+
22332274 def tmux_capture (self ):
22342275 """Capture the full tmux pane buffer (scrollback + visible) over
22352276 the ControlMaster channel. Only meaningful for persistent
22362277 sessions — xterm.js can't see tmux's own scrollback. Returns
22372278 (bytes, error)."""
22382279 if not self .persistent or not self .slot_id :
22392280 return None , "not a persistent session"
2240- if not self ._control_path or not os . path . exists ( self . _control_path ):
2281+ if not self ._mux_ready ( ):
22412282 return None , "control socket not ready"
22422283 # `-S -<N>` reads the most recent N lines of history (was `-S -`,
22432284 # the whole history, which on a 10M-line scrollback buffers
@@ -2248,19 +2289,9 @@ def tmux_capture(self):
22482289 tname = "websh-" + self .slot_id
22492290 remote_cmd = (self .tmux_cmd + " capture-pane -p -J -S -" +
22502291 str (MAX_TMUX_CAPTURE_LINES ) + " -t " + tname )
2251- ssh_cmd = [
2252- "ssh" , "-T" ,
2253- "-o" , "BatchMode=yes" ,
2254- "-o" , "ControlPath=" + self ._control_path ,
2255- "--" , self ._host , remote_cmd ,
2256- ]
2257- try :
2258- proc = subprocess .run (
2259- ssh_cmd , capture_output = True , timeout = 30 )
2260- except subprocess .TimeoutExpired :
2261- return None , "tmux capture timeout"
2262- except Exception as e :
2263- return None , "ssh error: " + str (e )
2292+ proc , err = self ._mux_run (remote_cmd , 30 , "tmux capture timeout" )
2293+ if err :
2294+ return None , err
22642295 if proc .returncode != 0 :
22652296 err = proc .stderr .decode ("utf-8" , "replace" ).strip ()[:300 ]
22662297 return None , "tmux exit %d: %s" % (proc .returncode , err )
@@ -2283,7 +2314,7 @@ def push_tmux_options(self, options):
22832314 used at connect time. Returns (ok, error)."""
22842315 if not self .persistent or not self .slot_id :
22852316 return False , "not a persistent session"
2286- if not self ._control_path or not os . path . exists ( self . _control_path ):
2317+ if not self ._mux_ready ( ):
22872318 return False , "control socket not ready"
22882319 if not options :
22892320 return True , ""
@@ -2294,19 +2325,9 @@ def push_tmux_options(self, options):
22942325 # connect time.
22952326 parts = ["set -g " + opt + " " + val for opt , val in options ]
22962327 remote_cmd = self .tmux_cmd + " " + " \\ ; " .join (parts )
2297- ssh_cmd = [
2298- "ssh" , "-T" ,
2299- "-o" , "BatchMode=yes" ,
2300- "-o" , "ControlPath=" + self ._control_path ,
2301- "--" , self ._host , remote_cmd ,
2302- ]
2303- try :
2304- proc = subprocess .run (
2305- ssh_cmd , capture_output = True , timeout = 10 )
2306- except subprocess .TimeoutExpired :
2307- return False , "tmux set timeout"
2308- except Exception as e :
2309- return False , "ssh error: " + str (e )
2328+ proc , err = self ._mux_run (remote_cmd , 10 , "tmux set timeout" )
2329+ if err :
2330+ return False , err
23102331 if proc .returncode != 0 :
23112332 err = proc .stderr .decode ("utf-8" , "replace" ).strip ()[:300 ]
23122333 return False , "tmux exit %d: %s" % (proc .returncode , err )
@@ -2321,7 +2342,7 @@ def upload_file(self, rel_path, body_stream, length,
23212342 re-auth and no PTY overhead. Returns (ok, error)."""
23222343 if not self .alive :
23232344 return False , "session is dead"
2324- if not self ._control_path or not os . path . exists ( self . _control_path ):
2345+ if not self ._mux_ready ( ):
23252346 return False , "control socket not ready"
23262347
23272348 # rel_path is base64-encoded and decoded inside the remote shell so
@@ -2334,14 +2355,8 @@ def upload_file(self, rel_path, body_stream, length,
23342355 'cat > "$HOME/$n"'
23352356 )
23362357
2337- ssh_cmd = [
2338- "ssh" , "-T" ,
2339- "-o" , "BatchMode=yes" ,
2340- "-o" , "ControlPath=" + self ._control_path ,
2341- "--" , self ._host , remote_cmd ,
2342- ]
23432358 proc = subprocess .Popen (
2344- ssh_cmd ,
2359+ self . _mux_argv ( remote_cmd ) ,
23452360 stdin = subprocess .PIPE ,
23462361 # `cat >` produces no stdout; discard it. stderr is kept (for the
23472362 # "ssh exit N: <msg>" error below) but MUST be drained while we
@@ -2452,7 +2467,7 @@ def finalize_upload(self, tmp_name, final_name):
24522467 guard for vim/less/htop)."""
24532468 if not self .persistent or not self .slot_id :
24542469 return False , "non-persistent"
2455- if not self ._control_path or not os . path . exists ( self . _control_path ):
2470+ if not self ._mux_ready ( ):
24562471 return False , "control socket not ready"
24572472
24582473 # Both names are base64-encoded in case the user picked a file
@@ -2493,18 +2508,9 @@ def finalize_upload(self, tmp_name, final_name):
24932508 'fi; '
24942509 'mv -- "$HOME/$t" "./$f" && printf %s "$cwd/$f"'
24952510 )
2496- ssh_cmd = [
2497- "ssh" , "-T" ,
2498- "-o" , "BatchMode=yes" ,
2499- "-o" , "ControlPath=" + self ._control_path ,
2500- "--" , self ._host , remote_cmd ,
2501- ]
2502- try :
2503- proc = subprocess .run (ssh_cmd , capture_output = True , timeout = 15 )
2504- except subprocess .TimeoutExpired :
2505- return False , "finalize timeout"
2506- except Exception as e :
2507- return False , "ssh error: " + str (e )
2511+ proc , err = self ._mux_run (remote_cmd , 15 , "finalize timeout" )
2512+ if err :
2513+ return False , err
25082514 if proc .returncode != 0 :
25092515 err = proc .stderr .decode ("utf-8" , "replace" ).strip ()[:300 ]
25102516 return False , "finalize exit %d: %s" % (proc .returncode , err )
@@ -2516,7 +2522,7 @@ def remove_remote_tmp(self, rel_path):
25162522 the user cancelled — keystroke-free, so no risk of poking a
25172523 running editor in the foreground PTY. Idempotent. rel_path
25182524 must come from the caller's path validator."""
2519- if not self ._control_path or not os . path . exists ( self . _control_path ):
2525+ if not self ._mux_ready ( ):
25202526 return False , "control socket not ready"
25212527 b = base64 .b64encode (rel_path .encode ("utf-8" )).decode ("ascii" )
25222528 # The `--` after rm protects against an attacker-supplied path
@@ -2526,18 +2532,9 @@ def remove_remote_tmp(self, rel_path):
25262532 'n=$(printf %s ' + b + ' | base64 -d) && '
25272533 'rm -f -- "$HOME/$n"'
25282534 )
2529- ssh_cmd = [
2530- "ssh" , "-T" ,
2531- "-o" , "BatchMode=yes" ,
2532- "-o" , "ControlPath=" + self ._control_path ,
2533- "--" , self ._host , remote_cmd ,
2534- ]
2535- try :
2536- proc = subprocess .run (ssh_cmd , capture_output = True , timeout = 10 )
2537- except subprocess .TimeoutExpired :
2538- return False , "rm timeout"
2539- except Exception as e :
2540- return False , "ssh error: " + str (e )
2535+ proc , err = self ._mux_run (remote_cmd , 10 , "rm timeout" )
2536+ if err :
2537+ return False , err
25412538 if proc .returncode != 0 :
25422539 return False , "rm exit %d" % proc .returncode
25432540 return True , ""
@@ -2546,7 +2543,7 @@ def list_dir(self, remote_path):
25462543 """List a directory via the ControlMaster side-channel.
25472544 remote_path may be absolute, ~, ~/sub, or relative-to-$HOME.
25482545 Returns (entries, abs_path, error_string)."""
2549- if not self ._control_path or not os . path . exists ( self . _control_path ):
2546+ if not self ._mux_ready ( ):
25502547 return None , None , "control socket not ready"
25512548
25522549 b64 = base64 .b64encode (remote_path .encode ("utf-8" )).decode ("ascii" )
@@ -2581,18 +2578,10 @@ def list_dir(self, remote_path):
25812578 'printf "%s\\ t%s\\ t%s\\ t%s\\ 0" "$t" "$s" "$m" "$f"; '
25822579 'done'
25832580 )
2584- ssh_cmd = [
2585- "ssh" , "-T" ,
2586- "-o" , "BatchMode=yes" ,
2587- "-o" , "ControlPath=" + self ._control_path ,
2588- "--" , self ._host , remote_cmd ,
2589- ]
2590- try :
2591- result = subprocess .run (ssh_cmd , capture_output = True , timeout = 10 )
2592- except subprocess .TimeoutExpired :
2593- return None , None , "timeout"
2594- except Exception as e :
2595- return None , None , str (e )
2581+ # err_prefix="" keeps this method's historical bare str(e) message.
2582+ result , err = self ._mux_run (remote_cmd , 10 , "timeout" , err_prefix = "" )
2583+ if err :
2584+ return None , None , err
25962585 if result .returncode != 0 :
25972586 return None , None , "directory not found"
25982587
@@ -2628,7 +2617,7 @@ def download_file(self, remote_path):
26282617 Subprocess stdout starts with a header "OK\\ t<size>\\ n" or
26292618 "ERR\\ t<msg>\\ n" so the caller can detect failure before
26302619 sending HTTP response headers."""
2631- if not self ._control_path or not os . path . exists ( self . _control_path ):
2620+ if not self ._mux_ready ( ):
26322621 return None , "control socket not ready"
26332622
26342623 b64 = base64 .b64encode (remote_path .encode ("utf-8" )).decode ("ascii" )
@@ -2646,20 +2635,14 @@ def download_file(self, remote_path):
26462635 'cat -- "$F"; '
26472636 'else printf "ERR\\ tFile not found\\ n"; fi'
26482637 )
2649- ssh_cmd = [
2650- "ssh" , "-T" ,
2651- "-o" , "BatchMode=yes" ,
2652- "-o" , "ControlPath=" + self ._control_path ,
2653- "--" , self ._host , remote_cmd ,
2654- ]
26552638 try :
26562639 # stderr→DEVNULL: the protocol header (OK/ERR on stdout) already
26572640 # signals failure to the caller, and a PIPE that nobody drains
26582641 # would deadlock the child once ssh writes >~64 KB of warnings
26592642 # (host-key prompts, banners, debug). Same pattern as
26602643 # terminate_remote_tmux.
26612644 proc = subprocess .Popen (
2662- ssh_cmd ,
2645+ self . _mux_argv ( remote_cmd ) ,
26632646 stdin = subprocess .DEVNULL ,
26642647 stdout = subprocess .PIPE ,
26652648 stderr = subprocess .DEVNULL ,
0 commit comments