Skip to content

Commit c7e7d87

Browse files
yasithdevclaude
andcommitted
Fix overlay SSH port mapping, connection retry, and error handling robustness
- Use tunnel-mapped SSH port for overlay mount instead of original port - Add retry with exponential backoff for initial SSH/SFTP connection - Add SSH connection timeout (10s) to prevent indefinite hangs - Replace fmt.Sscanf with strconv.Atoi in toInt() for proper error handling - Log malformed port mappings in devtunnel CLI instead of silently accepting - Log process kill errors in devtunnel host restart - Fix off-by-one in overlay retry attempt logging - Add mapped_ssh_port output from tunnel connect actions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent acd68a6 commit c7e7d87

File tree

4 files changed

+58
-11
lines changed

4 files changed

+58
-11
lines changed

internal/workflow/actions.go

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,18 @@ func actionDevTunnelConnect(params map[string]any) (*ActionResult, error) {
130130
"command_id": cmdID,
131131
"port_map": portMapStr,
132132
}
133+
134+
// If ssh_port was provided, resolve the mapped local port for the overlay
135+
if sshPort := toInt(params["ssh_port"]); sshPort != 0 {
136+
if mapped, ok := portMap[sshPort]; ok {
137+
result["mapped_ssh_port"] = mapped
138+
log.Printf("[tunnel.devtunnel_connect] mapped SSH port %d -> %d", sshPort, mapped)
139+
} else {
140+
log.Printf("[tunnel.devtunnel_connect] warning: SSH port %d not found in port map", sshPort)
141+
result["mapped_ssh_port"] = sshPort // fallback to original
142+
}
143+
}
144+
133145
return &result, nil
134146
}
135147

@@ -316,10 +328,23 @@ func actionTunnelConnect(params map[string]any) (*ActionResult, error) {
316328
portMapStr[strconv.Itoa(remote)] = local
317329
}
318330

319-
return &ActionResult{
331+
result := ActionResult{
320332
"connection_id": cr.ConnectionID,
321333
"port_map": portMapStr,
322-
}, nil
334+
}
335+
336+
// If ssh_port was provided, resolve the mapped local port for the overlay
337+
if sshPort := toInt(params["ssh_port"]); sshPort != 0 {
338+
if mapped, ok := cr.PortMap[sshPort]; ok {
339+
result["mapped_ssh_port"] = mapped
340+
log.Printf("[tunnel.connect] mapped SSH port %d -> %d", sshPort, mapped)
341+
} else {
342+
log.Printf("[tunnel.connect] warning: SSH port %d not found in port map", sshPort)
343+
result["mapped_ssh_port"] = sshPort
344+
}
345+
}
346+
347+
return &result, nil
323348
}
324349

325350
// --- tunnel.disconnect (provider-agnostic) ---
@@ -384,9 +409,10 @@ func toInt(v any) int {
384409
case float64:
385410
return int(val)
386411
case string:
387-
// Support template-resolved numeric strings.
388-
var n int
389-
fmt.Sscanf(val, "%d", &n)
412+
n, err := strconv.Atoi(strings.TrimSpace(val))
413+
if err != nil {
414+
return 0
415+
}
390416
return n
391417
default:
392418
return 0

subsystems/mount/overlayfs.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,11 +180,26 @@ func MountOverlayFS(sshAddr, remoteRoot, upperDir, mountDir string) (*OverlayFS,
180180
User: "linkspan",
181181
Auth: []ssh.AuthMethod{},
182182
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
183+
Timeout: 10 * time.Second,
183184
}
184185

185-
rsftp, err := newReconnectingSftpClient(sshAddr, sshConfig)
186-
if err != nil {
187-
return nil, err
186+
// Retry the initial SSH/SFTP connection — tunnel forwarded ports may
187+
// not be ready immediately after devtunnel connect reports them.
188+
var rsftp *reconnectingSftpClient
189+
delays := []time.Duration{0, 1 * time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second}
190+
var lastErr error
191+
for i, delay := range delays {
192+
if delay > 0 {
193+
log.Printf("[overlayfs] SSH to %s failed (attempt %d/%d), retrying in %s: %v", sshAddr, i+1, len(delays), delay, lastErr)
194+
time.Sleep(delay)
195+
}
196+
rsftp, lastErr = newReconnectingSftpClient(sshAddr, sshConfig)
197+
if lastErr == nil {
198+
break
199+
}
200+
}
201+
if lastErr != nil {
202+
return nil, lastErr
188203
}
189204

190205
root := &overlayNode{

subsystems/tunnel/devtunnel.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,9 @@ func DevTunnelForward(tunnelName string, port int, authToken string) error {
130130
// No -p flags needed — ports are registered via SDK and forwarded by the relay.
131131
if devTunInfo.HostCmdID != "" {
132132
log.Printf("devtunnel forward: restarting host for %q", tunnelName)
133-
_ = pm.GlobalProcessManager.Kill(devTunInfo.HostCmdID)
133+
if err := pm.GlobalProcessManager.Kill(devTunInfo.HostCmdID); err != nil {
134+
log.Printf("devtunnel forward: failed to kill old host process %s: %v", devTunInfo.HostCmdID, err)
135+
}
134136

135137
hostToken := devTunInfo.HostToken
136138
if hostToken == "" {

subsystems/tunnel/devtunnel_cli.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,8 +222,12 @@ func CLIConnectTunnel(tunnelID string, accessToken string) (commandID string, po
222222
// Parse port map from output
223223
portMap = make(map[int]int) // remotePort → localPort
224224
for _, match := range forwardRe.FindAllStringSubmatch(combined, -1) {
225-
localPort, _ := strconv.Atoi(match[1])
226-
remotePort, _ := strconv.Atoi(match[2])
225+
localPort, err1 := strconv.Atoi(match[1])
226+
remotePort, err2 := strconv.Atoi(match[2])
227+
if err1 != nil || err2 != nil {
228+
log.Printf("devtunnel cli: skipping malformed port mapping %q → %q", match[1], match[2])
229+
continue
230+
}
227231
portMap[remotePort] = localPort
228232
}
229233
log.Printf("devtunnel cli: connect established for tunnel %s (ports=%v)", tunnelID, portMap)

0 commit comments

Comments
 (0)