Skip to content

Retry SSH PTY resize relay - #6307

Closed
boolafish wants to merge 1 commit into
manaflow-ai:mainfrom
boolafish:fix/ssh-pty-resize-retry
Closed

boolafish wants to merge 1 commit into
manaflow-ai:mainfrom
boolafish:fix/ssh-pty-resize-retry

Conversation

@boolafish

@boolafish boolafish commented Jun 17, 2026 •

Copy link
Copy Markdown

Fixes #6306.
Related to #5700.

Summary

SSH PTY attach currently forwards pane resize changes through workspace.remote.pty_resize from the SIGWINCH handler, but delivery is best-effort and failures are silently dropped. If that send races a stale or temporarily unavailable remote-session control path, the remote PTY can miss the current size while output continues to flow.

This change adds a small coalesced retry loop for SSH PTY resize delivery:

  • sends resize updates through a helper that surfaces delivery errors
  • retries failed delivery with short delays
  • coalesces rapid resize events so only the latest generation continues retrying
  • re-reads the terminal size before each retry

Validation

  • xcrun swiftc -parse CLI/cmux.swift
  • git diff --check

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Make SSH PTY resize delivery reliable by adding a coalesced retry loop so the remote PTY doesn’t miss size updates during brief control-path hiccups.

  • Bug Fixes
    • Send resize via a helper that surfaces errors and uses a 2s sendV2 timeout.
    • Retry on failure with short delays [0.05, 0.15, 0.35]; only the latest resize keeps retrying.
    • Re-read terminal size before each retry to always send the current size.
    • Use a dedicated queue and NSLock for thread-safe sends; include allow_moved_surface when surface_id is present.

Written for commit 24b7d7e. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes
    • Improved SSH terminal window resize reliability with automatic retry logic on failures and better concurrency handling to prevent stale resize operations from interfering with newer events.

@vercel

vercel Bot commented Jun 17, 2026

Copy link
Copy Markdown

@boolafish is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 17, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0c2d3d9a-7542-4b15-a833-386b980292ff

📥 Commits

Reviewing files that changed from the base of the PR and between 0cd4955 and 24b7d7e.

📒 Files selected for processing (1)
  • CLI/cmux.swift

📝 Walkthrough

Walkthrough

CLI/cmux.swift replaces a fire-and-forget try? SSH PTY resize call with a sendSSHPTYResize helper that enforces a 2.0s timeout and propagates errors. A sshPTYResizeRetryDelays backoff array and a resize-generation counter are added to startSSHPTYResizeSource, enabling coalesced retries that are discarded when a newer resize event has already superseded them.

Changes

SSH PTY Resize Resilience

Layer / File(s) Summary
sendSSHPTYResize helper and backoff constants
CLI/cmux.swift
Adds sshPTYResizeRetryDelays array and a sendSSHPTYResize helper that builds workspace.remote.pty_resize request params (with optional surface_id/allow_moved_surface), acquires an NSLock, and calls the RPC with a fixed 2.0s responseTimeout, propagating any thrown error to the caller.
SIGWINCH generation-gated retry loop
CLI/cmux.swift
Reworks startSSHPTYResizeSource to hold a dedicated DispatchQueue and an integer generation counter. Each SIGWINCH increments the generation and schedules sendLatestResize at attempt 0; on error it reschedules with the next backoff delay only if the generation still matches, preventing stale retries from a superseded resize event.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 Hop, hop — the window grew!
No more silent drops in the queue.
A generation guards the gate,
stale retries arrive too late.
Each SIGWINCH finds its way through ✨


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (3 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error The diff introduces DispatchQueue.asyncAfter for retry delays (0.05, 0.15, 0.35s) and NSLock without documentation. Per swift-blocking-runtime.md: asyncAfter for retry backoff and sleep-based timin... Replace asyncAfter-based retry with a real timer abstraction or completion callback. Document or refactor NSLock as an actor-based alternative, or add comments explaining why the lock is necessary and actors are infeasible.
Cmux Swift Concurrency ❌ Error The new CLI/cmux.swift file introduces DispatchQueue.global(qos: .userInteractive).async for blocking I/O operations (stdin reading) as fire-and-forget work in startInputPump() and runSSHPTYAttach(... Modernize the CLI to use async/await by making main() async, allowing the dispatch queue patterns to be replaced with structured concurrency and Task groups for concurrent stdin/socket reading.
Cmux Source Artifacts ❌ Error .claude/scheduled_tasks.lock contains process runtime metadata (pid, sessionId, timestamps) and violates the source-control-artifacts policy as local tool output/cache without deliberate product/do... Remove .claude/scheduled_tasks.lock from the commit, or add .claude/*.lock to .gitignore to prevent future accidental commits of runtime artifacts.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (17 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Retry SSH PTY resize relay' accurately captures the main change of adding retry logic to SSH PTY resize delivery.
Description check ✅ Passed The PR description is comprehensive and covers the summary, validation steps, and includes detailed context from issue #6306.
Linked Issues check ✅ Passed The code changes fully address issue #6306 by implementing a coalesced retry mechanism for SSH PTY resize delivery with generation gating, error handling, and delay backoff sequences.
Out of Scope Changes check ✅ Passed All changes in CLI/cmux.swift are directly scoped to the SSH PTY resize retry implementation described in the linked issues.
Cmux Swift Actor Isolation ✅ Passed The PR does not introduce Swift 6 actor isolation violations. New code properly uses NSLock for socket synchronization, follows existing patterns with immutable static constants, and contains no im...
Cmux Expensive Synchronous Load ✅ Passed PR adds no expensive synchronous loaders to main actor or interactive paths. New SSH PTY resize functions only use ioctl() for terminal size and network communication via client.sendV2(), properly...
Cmux Cache Substitution Correctness ✅ Passed The code freshly reads terminal size via ioctl(TIOCGWINSZ) on every retry attempt and uses generation-based coalescing to prevent stale retries. No cached values replace fresh authoritative reads.
Cmux No Hacky Sleeps ✅ Passed Swift code is explicitly out of scope for this check (covered by swift-blocking-runtime.md instead). PR modifies only CLI/cmux.swift, uses dispatch queue scheduling with generation coalescing, not...
Cmux Algorithmic Complexity ✅ Passed Code uses only fixed-size retry array (3 elements) indexed directly, executes in per-session resize handler (not hot workspace/pane iteration path), with bounded recursion and no collection scans.
Cmux Swift @Concurrent ✅ Passed The changes use synchronous functions and GCD-based scheduling, not Swift structured concurrency. No async/await patterns or @concurrent annotations are introduced, so concurrent annotation rules d...
Cmux Swift File And Package Boundaries ✅ Passed Changes add +64 lines (net +47 after removals) to CLI/cmux.swift—well below the 250-line threshold for oversized files. The additions are a focused bug fix for SSH PTY resize reliability with clear...
Cmux Swift Logging ✅ Passed SSH PTY resize functions (sendSSHPTYResize, startSSHPTYResizeSource, sshPTYResizeRetryDelays) contain no print/debugPrint/dump/NSLog logging violations and expose no secrets.
Cmux User-Facing Error Privacy ✅ Passed The SSH PTY resize retry changes do not add user-facing error messages that expose sensitive data. The sendSSHPTYResize and startSSHPTYResizeSource functions silently retry internally without expos...
Cmux Full Internationalization ✅ Passed No user-facing text was added; all new strings in SSH PTY resize code are protocol identifiers (API params, RPC method names) or debug labels (dispatch queue label), which are exempt per full-inter...
Cmux Swiftui State Layout ✅ Passed CLI/cmux.swift is a command-line interface file with no SwiftUI imports or state patterns. Changes are pure Swift signal handling and networking code for SSH PTY resize retry logic, not SwiftUI state.
Cmux Architecture Rethink ✅ Passed Small correctness fix with clear owner (SSH PTY resize source), explicit invariant (generation-gating prevents stale retries), and named root cause (socket delivery failures). Retries are scoped to...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR modifies CLI/cmux.swift to add SSH PTY resize retry logic (sendSSHPTYResize helper, sshPTYResizeRetryDelays backoff, startSSHPTYResizeSource enhancements) with no NSWindow, NSPanel, NSWindowCont...
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a coalesced retry loop to the SSH PTY resize relay: a new sendSSHPTYResize helper surfaces delivery errors, and a sendLatestResize nested function retries failed sends up to three times using hardcoded asyncAfter delays [0.05, 0.15, 0.35] while a generation counter coalesces rapid SIGWINCH events.

  • Timing-based retry violates the blocking-runtime rule: DispatchQueue.asyncAfter with fixed wall-clock delays is explicitly prohibited for socket retry paths; the fix should use a cancellation-aware Swift concurrency abstraction.
  • Teardown hazard: pending asyncAfter callbacks have no cancellation path — they survive source cancellation and will acquire socketLock and attempt sendV2 on a potentially closed socket for up to 550 ms after the session ends.
  • Architectural concern: the retry loop papers over the underlying race (stale control path) without naming the invariant or connecting the failure to the session's reconnection lifecycle.

Confidence Score: 3/5

The change introduces a retry loop that uses asyncAfter with hardcoded delays and has no teardown cancellation path, leaving the socket lock exposed after session end.

Three issues were found in the new retry mechanism. The asyncAfter-based backoff violates the repo's explicit blocking-runtime policy for socket paths. The generation counter cannot signal cancellation on teardown, so in-flight retries will attempt to acquire socketLock and send on a potentially closed socket. The overall approach papers over the underlying delivery race without connecting to the session's reconnection lifecycle. These are present defects in the changed code, not speculative concerns.

CLI/cmux.swift — the new sendLatestResize function and sshPTYResizeRetryDelays constant.

Important Files Changed

Filename Overview
CLI/cmux.swift Adds sendSSHPTYResize helper and a sendLatestResize nested function with a coalesced asyncAfter retry loop; introduces timing-based retry (violating the blocking-runtime rule), a teardown hazard where pending retries survive session cancellation, and an architectural pattern that papers over the underlying socket race with wall-clock delays.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant T as Terminal (SIGWINCH)
    participant Q as DispatchQueue
    participant SLR as sendLatestResize
    participant SND as sendSSHPTYResize
    participant SC as SocketClient

    T->>Q: SIGWINCH fires
    Q->>Q: "resizeGeneration &+= 1"
    Q->>SLR: "attempt=0, generation=N"
    SLR->>SND: currentCLITerminalSize() + sendV2
    SND->>SC: "sendV2(pty_resize, timeout=2s)"
    alt success
        SC-->>SND: OK
    else failure
        SC-->>SND: throws
        SND-->>SLR: catch
        SLR->>Q: asyncAfter(+0.05s)
        Q->>SLR: "attempt=1, guard generation==resizeGeneration"
        SLR->>SND: sendV2 retry 1
        SND-->>SLR: throws
        SLR->>Q: asyncAfter(+0.15s)
        Q->>SLR: "attempt=2, guard generation==resizeGeneration"
        SLR->>SND: sendV2 retry 2
        SND-->>SLR: throws
        SLR->>Q: asyncAfter(+0.35s)
        Q->>SLR: "attempt=3, guard generation==resizeGeneration"
        SLR->>SND: sendV2 retry 3 final
    end
    Note over Q,SC: source.cancel() stops new SIGWNCHs but asyncAfter callbacks already queued still fire
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant T as Terminal (SIGWINCH)
    participant Q as DispatchQueue
    participant SLR as sendLatestResize
    participant SND as sendSSHPTYResize
    participant SC as SocketClient

    T->>Q: SIGWINCH fires
    Q->>Q: "resizeGeneration &+= 1"
    Q->>SLR: "attempt=0, generation=N"
    SLR->>SND: currentCLITerminalSize() + sendV2
    SND->>SC: "sendV2(pty_resize, timeout=2s)"
    alt success
        SC-->>SND: OK
    else failure
        SC-->>SND: throws
        SND-->>SLR: catch
        SLR->>Q: asyncAfter(+0.05s)
        Q->>SLR: "attempt=1, guard generation==resizeGeneration"
        SLR->>SND: sendV2 retry 1
        SND-->>SLR: throws
        SLR->>Q: asyncAfter(+0.15s)
        Q->>SLR: "attempt=2, guard generation==resizeGeneration"
        SLR->>SND: sendV2 retry 2
        SND-->>SLR: throws
        SLR->>Q: asyncAfter(+0.35s)
        Q->>SLR: "attempt=3, guard generation==resizeGeneration"
        SLR->>SND: sendV2 retry 3 final
    end
    Note over Q,SC: source.cancel() stops new SIGWNCHs but asyncAfter callbacks already queued still fire
Loading

Reviews (1): Last reviewed commit: "Retry SSH PTY resize relay" | Re-trigger Greptile

Comment thread CLI/cmux.swift
Comment on lines +11965 to +11968
queue.asyncAfter(deadline: .now() + delay) {
guard generation == resizeGeneration else { return }
sendLatestResize(attempt: attempt + 1, generation: generation)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 asyncAfter timing-based retry violates swift-blocking-runtime rule

queue.asyncAfter with hardcoded delays [0.05, 0.15, 0.35] is explicitly prohibited by the cmux blocking-runtime rule: "DispatchQueue.asyncAfter … treat these as failures by default, even when the delay is small. Retry backoff … still need a real cancellation-aware scheduler, timer abstraction, async sequence, callback, notification, or state transition." The socket send path is also called out as latency-sensitive in the rule. The real event here is acknowledgment or error from sendV2 — the fix should use Swift concurrency (async/await with a cancellation-aware Task and structured backoff via an AsyncSequence or a CancellableTimer abstraction) rather than wall-clock polling via asyncAfter.

Rule Used: Flag new blocking or timing-based synchronization ... (source)

Comment thread CLI/cmux.swift
Comment on lines +11946 to +11968
var resizeGeneration: UInt64 = 0

func sendLatestResize(attempt: Int, generation: UInt64) {
let size = self.currentCLITerminalSize()
do {
try self.sendSSHPTYResize(
client: client,
workspaceId: workspaceId,
surfaceID: surfaceID,
sessionID: sessionID,
attachmentID: attachmentID,
attachmentToken: attachmentToken,
cols: size.cols,
rows: size.rows,
socketLock: socketLock
)
} catch {
guard attempt < Self.sshPTYResizeRetryDelays.count else { return }
let delay = Self.sshPTYResizeRetryDelays[attempt]
queue.asyncAfter(deadline: .now() + delay) {
guard generation == resizeGeneration else { return }
sendLatestResize(attempt: attempt + 1, generation: generation)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Pending retries survive session teardown

The generation counter prevents a stale retry from scheduling the next attempt, but it does not cancel an already-dispatched asyncAfter block. When the SSH session ends and the caller cancels the DispatchSourceSignal, no new SIGWINCH events fire, so resizeGeneration is never incremented. Any in-flight asyncAfter block will pass guard generation == resizeGeneration and call sendLatestResize, which acquires socketLock and calls client.sendV2 with a 2-second responseTimeout on what may already be a closed socket. This can hold socketLock for up to 2 seconds after teardown. The fix is to expose a cancellation path — for example, incrementing resizeGeneration in a cancellation/deinit hook, or returning a handle whose cancel() sets an isCancelled flag that each pending block checks before proceeding.

Comment thread CLI/cmux.swift
throw CLIError(message: "ssh-pty-attach: bridge status exceeded \(maxStatusBytes) bytes")
}

private static let sshPTYResizeRetryDelays: [TimeInterval] = [0.05, 0.15, 0.35]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Timing repair papers over the underlying socket race (architectural rethink)

The swift-architectural-rethink rule flags asyncAfter used to paper over socket races and requires the fix to name the invariant and state transition that makes the whole class impossible. The retry loop assumes the send will succeed within 0.55 s of accumulated wall-clock delay, but the actual failure condition — a stale or temporarily unavailable remote-session control path — is still representable after this change. If sendV2 continues to fail beyond the third attempt, the PTY size is silently wrong with no diagnostic. A more durable fix would surface send errors through the session's existing error or reconnection channel, so the invariant "the remote PTY always reflects the current terminal size" is owned by the session lifecycle rather than a blind retry counter.

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@boolafish

Copy link
Copy Markdown
Author

Closing this in favor of #6320, which addresses #6306 with a fuller coordinator-based fix, lifecycle-safe cancellation, and regression coverage. Thanks for picking this up.

@boolafish boolafish closed this Jun 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSH workspace TUI resize can remain stale until workspace reconnect

1 participant