@@ -80,6 +80,8 @@ export interface SSHRuntimeConfig {
8080export class SSHRuntime implements Runtime {
8181 private readonly config : SSHRuntimeConfig ;
8282 private readonly controlPath : string ;
83+ /** Cached resolved bgOutputDir (tilde expanded to absolute path) */
84+ private resolvedBgOutputDir : string | null = null ;
8385
8486 constructor ( config : SSHRuntimeConfig ) {
8587 // Note: srcBaseDir may contain tildes - they will be resolved via resolvePath() before use
@@ -91,6 +93,27 @@ export class SSHRuntime implements Runtime {
9193 this . controlPath = getControlPath ( config ) ;
9294 }
9395
96+ /**
97+ * Get resolved background output directory (tilde expanded), caching the result.
98+ * This ensures all background process paths are absolute from the start.
99+ */
100+ private async getBgOutputDir ( ) : Promise < string > {
101+ if ( this . resolvedBgOutputDir !== null ) {
102+ return this . resolvedBgOutputDir ;
103+ }
104+
105+ let dir = this . config . bgOutputDir ?? "/tmp/mux-bashes" ;
106+
107+ if ( dir === "~" || dir . startsWith ( "~/" ) ) {
108+ const result = await execBuffered ( this , 'echo "$HOME"' , { cwd : "/" , timeout : 10 } ) ;
109+ const home = result . exitCode === 0 && result . stdout . trim ( ) ? result . stdout . trim ( ) : "/tmp" ;
110+ dir = dir === "~" ? home : `${ home } /${ dir . slice ( 2 ) } ` ;
111+ }
112+
113+ this . resolvedBgOutputDir = dir ;
114+ return this . resolvedBgOutputDir ;
115+ }
116+
94117 /**
95118 * Get SSH configuration (for PTY terminal spawning)
96119 */
@@ -294,7 +317,7 @@ export class SSHRuntime implements Runtime {
294317 // Generate unique process ID and compute output directory
295318 // /tmp is cleaned by OS, so no explicit cleanup needed
296319 const processId = `bg-${ randomBytes ( 4 ) . toString ( "hex" ) } ` ;
297- const bgOutputDir = this . config . bgOutputDir ?? "/tmp/mux-bashes" ;
320+ const bgOutputDir = await this . getBgOutputDir ( ) ;
298321 const outputDir = `${ bgOutputDir } /${ options . workspaceId } /${ processId } ` ;
299322 const stdoutPath = `${ outputDir } /stdout.log` ;
300323 const stderrPath = `${ outputDir } /stderr.log` ;
@@ -354,7 +377,8 @@ export class SSHRuntime implements Runtime {
354377
355378 try {
356379 // No timeout - the spawn command backgrounds the process and returns immediately,
357- // but if wrapped in `timeout`, it would wait for the backgrounded process to exit
380+ // but if wrapped in `timeout`, it would wait for the backgrounded process to exit.
381+ // SSH connection hangs are protected by ConnectTimeout (see buildSshArgs in this file).
358382 const result = await execBuffered ( this , spawnCommand , {
359383 cwd : "/" , // cwd doesn't matter, we cd in the wrapper
360384 } ) ;
@@ -378,6 +402,7 @@ export class SSHRuntime implements Runtime {
378402
379403 log . debug ( `SSHRuntime.spawnBackground: Spawned with PID ${ pid } ` ) ;
380404
405+ // outputDir is already absolute (getBgOutputDir resolves tildes upfront)
381406 const handle = new SSHBackgroundHandle ( this , pid , outputDir ) ;
382407 return { success : true , handle, pid } ;
383408 } catch ( error ) {
0 commit comments