Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,12 @@ jobs:
- name: Validate universal nightly workflow
run: bash ./tests/test_nightly_universal_build.sh

- name: Validate cmux-tui client commit resolution
run: ./tests/test_ci_resolve_cmux_tui_client_commit.sh

- name: Validate cmux-tui client installation
run: bash ./tests/test_install_cmux_tui_client.sh

- name: Validate nightly notarization behavior
run: ./tests/test_notarize_nightly_dmg.sh

Expand Down Expand Up @@ -2353,6 +2359,7 @@ jobs:
- name: Install universal Ghostty CLI helper
run: |
set -euo pipefail
/bin/bash ./tests/test_install_cmux_tui_client.sh
./scripts/install-prebuilt-ghostty-cli-helper.sh \
ghostty-cli-helper/ghostty \
build-universal/Build/Products/Release/cmux.app
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,10 @@ jobs:
./scripts/install-cmux-tui-client.sh "$app" \
--require-capability wireguard-hub
else
cmux_tui_commit="$(git log -1 --format=%H -- cmux-tui ghostty .github/workflows/cmux-tui-artifacts.yml .github/workflows/cmux-tui-build-package.yml)"
# actions/checkout is depth 1 here, so a bare `git log -1 -- cmux-tui` would answer
# HEAD. The resolver deepens as needed and may bundle the newest *published*
# client when the latest cmux-tui commit has no artifacts yet (it warns when it does).
cmux_tui_commit="$(./scripts/ci/resolve-cmux-tui-client-commit.sh --max-fallback 5)"
test -n "$cmux_tui_commit"
./scripts/install-cmux-tui-client.sh \
"$app" \
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,9 @@ jobs:
./scripts/install-prebuilt-ghostty-cli-helper.sh \
ghostty-cli-helper/ghostty \
build-universal/Build/Products/Release/cmux.app
cmux_tui_commit="$(git log -1 --format=%H -- cmux-tui ghostty .github/workflows/cmux-tui-artifacts.yml .github/workflows/cmux-tui-build-package.yml)"
# actions/checkout is depth 1 here, so a bare `git log -1 -- cmux-tui` would answer
# HEAD. A release bundles the exact client of the newest cmux-tui commit or fails.
cmux_tui_commit="$(./scripts/ci/resolve-cmux-tui-client-commit.sh)"
test -n "$cmux_tui_commit"
./scripts/install-cmux-tui-client.sh \
build-universal/Build/Products/Release/cmux.app \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ struct IrxProtocolTests {
@Test("deadline returns when the operation ignores cancellation")
func deadlineReturnsWhenOperationIgnoresCancellation() async throws {
let gate = IrxDeadlineGate()
let startedAt = DispatchTime.now().uptimeNanoseconds

let result = try await withIrxDeadline(.milliseconds(20), onTimeout: {
await gate.open()
Expand All @@ -18,9 +17,10 @@ struct IrxProtocolTests {
return "late"
}

let elapsed = DispatchTime.now().uptimeNanoseconds - startedAt
// The operation only gets past the gate once the deadline has fired, so a nil
// result proves the deadline returned without waiting for it; a wall-clock bound
// on top of that only measured runner load.
#expect(result == nil)
#expect(elapsed < 100_000_000)
await gate.waitUntilFinished()
}

Expand Down
1 change: 0 additions & 1 deletion Sources/Cloud/CloudMachineLink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,6 @@ actor CloudMachineLink {
}
eventsProcess = process
eventsProcessExit = exit
let continuation = changesContinuation
let lines = CloudLinkPipe.lines(from: stdout.fileHandleForReading)
eventsReaderTask = Task.detached { [weak self] in
var receivedStreamEnd = false
Expand Down
5 changes: 3 additions & 2 deletions Sources/Cloud/CloudMachineLinkManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,9 @@ actor CloudMachineLinkManager {
var connectedMachineCount: Int {
get async {
var machineIDs = Set(connecting.keys)
for link in links.values where await link.isConnected {
machineIDs.insert(await link.machineID)
for link in links.values {
guard await link.isConnected else { continue }
machineIDs.insert(link.machineID)
}
return machineIDs.count
}
Expand Down
26 changes: 19 additions & 7 deletions cmuxTests/MobileHostConnectionLifecycleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,11 @@ extension MobileHostAuthorizationTests {
}
await waitForMobileHostConnectionCount(1)
try await persistentTransport.enqueue(Self.mobileHostStatusFrame(id: "persistent"))
_ = await persistentTransport.waitForSentBufferCount(1)
try await Task.sleep(nanoseconds: 25_000_000)
let sentAfterFirstStatus = await persistentTransport.waitForSentBufferCount(1).count
// Exercise a subsequent request without a wall-clock sleep. If the
// transport closes before replying, the waiter records a test failure.
try await persistentTransport.enqueue(Self.mobileHostStatusFrame(id: "persistent-again"))
_ = await persistentTransport.waitForSentBufferCount(sentAfterFirstStatus + 1)
Comment thread
austinywang marked this conversation as resolved.

#expect(await persistentTransport.observedCloseCount() == 0)
#expect(registry.count == 1)
Expand Down Expand Up @@ -881,6 +884,11 @@ private actor ScriptedMobileHostByteTransport: CmxByteTransport {

func close() async {
closeCount += 1
let pendingSentWaiters = sentWaiters
sentWaiters.removeAll()
for waiter in pendingSentWaiters {
waiter.continuation.resume(returning: sent)
}
let ready = closeWaiters.filter { closeCount >= $0.count }
closeWaiters.removeAll { closeCount >= $0.count }
for waiter in ready {
Expand Down Expand Up @@ -909,12 +917,16 @@ private actor ScriptedMobileHostByteTransport: CmxByteTransport {
}

func waitForSentBufferCount(_ count: Int) async -> [Data] {
if sent.count >= count {
return sent
}
return await withCheckedContinuation { continuation in
sentWaiters.append((count, continuation))
let buffers: [Data]
if sent.count >= count || closeCount > 0 {
buffers = sent
} else {
buffers = await withCheckedContinuation { continuation in
sentWaiters.append((count, continuation))
}
}
#expect(buffers.count >= count, "Transport closed before the expected response was sent")
return buffers
}

func observedCloseCount() -> Int { closeCount }
Expand Down
4 changes: 2 additions & 2 deletions docs/cli-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -682,8 +682,8 @@ the expected text without connecting to a cmux socket.
- `cmux capabilities --help` -> `Usage: cmux capabilities`
- `cmux events --help` -> `Usage: cmux events [options]`
- `cmux auth --help` -> `Usage: cmux auth <status|login|logout>`
- `cmux vm --help` -> `Usage: cmux vm <base|new|ls|tree|status|stats|resize|rename|snapshot|fork|restore|rm|run|route|agent|prompt|exec|push|pull|wait|shell|tui|desktop|open|workspace|ports|tools|handoff|promote-template|attach|ssh|ssh-info|terminal|tab> [args...]`
- `cmux cloud --help` -> `Usage: cmux cloud <base|new|ls|tree|status|stats|resize|rename|snapshot|fork|restore|rm|run|route|agent|prompt|exec|push|pull|wait|shell|tui|desktop|open|workspace|ports|tools|handoff|promote-template|attach|ssh|ssh-info|terminal|tab> [args...]`
- `cmux vm --help` -> `Usage: cmux vm <base|new|ls|domains|tree|status|stats|resize|rename|snapshot|fork|restore|rm|run|route|agent|prompt|exec|push|pull|wait|shell|tui|desktop|open|ports|tools|handoff|promote-template|attach|ssh|ssh-info|workspace|terminal|tab> [args...]`
- `cmux cloud --help` -> `Usage: cmux cloud <base|new|ls|domains|tree|status|stats|resize|rename|snapshot|fork|restore|rm|run|route|agent|prompt|exec|push|pull|wait|shell|tui|desktop|open|ports|tools|handoff|promote-template|attach|ssh|ssh-info|workspace|terminal|tab> [args...]`
- `cmux remotes --help` -> `Usage: cmux remotes <list|add|remove> [options]`
- `cmux remote --help` -> `Usage: cmux remotes <list|add|remove> [options]`
- `cmux coderouter --help` -> `Usage: cmux coderouter <status|machines|claude> [options]`
Expand Down
124 changes: 124 additions & 0 deletions scripts/ci/resolve-cmux-tui-client-commit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# resolve-cmux-tui-client-commit.sh — print the cmux-tui commit whose published client
# an app bundle should carry.
#
# The `cmux-tui artifacts` workflow publishes one client build per main commit that
# touches cmux-tui or its build inputs, at
# https://files.cmux.com/cmux-tui/<commit>/manifest.json. The app workflows bundle the
# client of the newest such commit in the checked-out history.
#
# A bare `git log -1 -- <paths>` is wrong on CI: actions/checkout clones with depth 1,
# and in a one-commit history the grafted root shows every file as added, so the answer
# is always HEAD. HEAD has a published client only when it touched cmux-tui itself, so
# the download 404s on almost every push. This deepens a shallow clone until real
# cmux-tui history is visible, ignores shallow boundary commits, and walks candidates
# newest first until one has a published manifest.
#
# Usage: scripts/ci/resolve-cmux-tui-client-commit.sh [--max-fallback <n>] [--head <rev>]
# --max-fallback <n> older published commits that may stand in when newer candidates
# have no manifest yet (artifacts run failed or still running).
# Default 0: the newest candidate must be published, or this fails.
# --head <rev> history to search (default HEAD).
# Env: CMUX_TUI_CLIENT_MANIFEST_BASE (default https://files.cmux.com/cmux-tui),
# CMUX_TUI_CLIENT_REMOTE (default origin; where a shallow clone deepens from).
# The chosen 40-hex commit is the only stdout line; diagnostics go to stderr.
set -euo pipefail

PATHS=(cmux-tui ghostty .github/workflows/cmux-tui-artifacts.yml .github/workflows/cmux-tui-build-package.yml)
BASE="${CMUX_TUI_CLIENT_MANIFEST_BASE:-https://files.cmux.com/cmux-tui}"
REMOTE="${CMUX_TUI_CLIENT_REMOTE:-origin}"
MAX_FALLBACK=0
HEAD_REV=HEAD

log() { echo "resolve-cmux-tui-client-commit: $*" >&2; }
usage() { sed -n '2,24p' "$0"; }

while [[ $# -gt 0 ]]; do
case "$1" in
--max-fallback) shift; MAX_FALLBACK="${1:?--max-fallback needs a value}" ;;
--head) shift; HEAD_REV="${1:?--head needs a value}" ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown option: $1" >&2; usage >&2; exit 64 ;;
esac
shift
done
case "$MAX_FALLBACK" in
''|*[!0-9]*) echo "error: --max-fallback must be a non-negative integer" >&2; exit 64 ;;
esac
# Decimal, so a value with a leading zero (08) is not read as octal by the arithmetic below.
MAX_FALLBACK=$((10#$MAX_FALLBACK))

head_sha="$(git rev-parse --verify "${HEAD_REV}^{commit}")"
shallow_file="$(git rev-parse --git-path shallow)"
want=$((MAX_FALLBACK + 1))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

is_shallow_boundary() {
[[ -f "$shallow_file" ]] && grep -qx "$1" "$shallow_file"
}

# Candidates: commits in the history that touch the client inputs, newest first. A
# shallow boundary commit is skipped: with its parents missing, git shows it as adding
# every file, so it would match whether or not it touched cmux-tui.
CANDIDATES=()
collect_candidates() {
CANDIDATES=()
local sha
while IFS= read -r sha; do
[[ -n "$sha" ]] || continue
if is_shallow_boundary "$sha"; then continue; fi
CANDIDATES[${#CANDIDATES[@]}]="$sha"
done < <(git log -n $((want + 8)) --format=%H "$head_sha" -- "${PATHS[@]}")
}

deepen=200
rounds=0
while :; do
collect_candidates
if [[ ${#CANDIDATES[@]} -ge $want ]]; then break; fi
# No shallow file means the history is complete: what we have is all there is.
if [[ ! -f "$shallow_file" ]]; then break; fi
if [[ $rounds -ge 6 ]]; then
log "gave up deepening after $rounds rounds with ${#CANDIDATES[@]} usable candidate(s)"
break
fi
log "shallow clone shows ${#CANDIDATES[@]} usable cmux-tui commit(s); deepening by $deepen from $REMOTE"
if ! git fetch --quiet --deepen="$deepen" "$REMOTE" "$head_sha" 2>/dev/null \
&& ! git fetch --quiet --deepen="$deepen" "$REMOTE"; then
log "could not deepen the clone from $REMOTE"
break
fi
rounds=$((rounds + 1))
deepen=$((deepen * 2))
done

if [[ ${#CANDIDATES[@]} -eq 0 ]]; then
echo "error: no commit touching ${PATHS[*]} is visible from $head_sha" >&2
exit 1
fi

chosen=""
skipped=0
for ((i = 0; i < ${#CANDIDATES[@]}; i++)); do
sha="${CANDIDATES[$i]}"
url="$BASE/$sha/manifest.json"
# One probe per candidate, no retry loop: a transient failure just moves on to the
# next candidate (or fails exact mode, which a re-run covers) instead of waiting.
if curl --proto '=https,file' --tlsv1.2 -fsS -o /dev/null "$url" 2>/dev/null; then
chosen="$sha"
break
fi
log "no published cmux-tui client for $sha ($url)"
skipped=$((skipped + 1))
if [[ $skipped -gt $MAX_FALLBACK ]]; then break; fi
done

if [[ -z "$chosen" ]]; then
echo "error: the newest cmux-tui commit ${CANDIDATES[0]} has no published client at $BASE/${CANDIDATES[0]}/manifest.json" >&2
echo " check the 'cmux-tui artifacts' run for that commit (--max-fallback $MAX_FALLBACK)" >&2
exit 1
fi
if [[ $skipped -gt 0 ]]; then
echo "::warning title=cmux-tui client fallback::bundling the client of $chosen; $skipped newer cmux-tui commit(s) have no published artifacts (newest: ${CANDIDATES[0]})" >&2
fi
log "using cmux-tui commit $chosen"
printf '%s\n' "$chosen"
4 changes: 3 additions & 1 deletion scripts/install-cmux-tui-client.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ verify_probe() {
echo "error: installed binary does not probe as cmux-tui: $probe" >&2
exit 1
}
for capability in "${REQUIRED_CAPABILITIES[@]}"; do
# Bash 3.2 treats an empty array as unset under nounset. Expand no arguments
# when there are no requirements, while preserving each supplied capability.
for capability in ${REQUIRED_CAPABILITIES[@]+"${REQUIRED_CAPABILITIES[@]}"}; do
if ! python3 - "$capability" "$probe" <<'PY'
import json
import sys
Expand Down
95 changes: 95 additions & 0 deletions tests/test_ci_resolve_cmux_tui_client_commit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Guards scripts/ci/resolve-cmux-tui-client-commit.sh, which the nightly and release
# workflows use to pick the cmux-tui client build to bundle.
#
# Regression: actions/checkout clones with depth 1. In a one-commit history the grafted
# root shows every file as added, so `git log -1 -- cmux-tui` answers HEAD whether or
# not HEAD touched cmux-tui. HEAD only has a published client when it touched cmux-tui,
# so the manifest download returned 404 on almost every push (nightly runs 33941558929
# and 33943122606 died in "Bundle the cmux-tui client" with `curl: (56) ... 404`).
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RESOLVER="$ROOT_DIR/scripts/ci/resolve-cmux-tui-client-commit.sh"
if [[ ! -x "$RESOLVER" ]]; then
echo "FAIL: missing executable $RESOLVER"
exit 1
fi

TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
export GIT_AUTHOR_NAME=cmux-test GIT_AUTHOR_EMAIL=test@example.com
export GIT_COMMITTER_NAME=cmux-test GIT_COMMITTER_EMAIL=test@example.com
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1

git init -q "$TMP/src"
git -C "$TMP/src" checkout -q -b main
commit_touching() {
mkdir -p "$(dirname "$TMP/src/$2")"
echo "$1" >"$TMP/src/$2"
git -C "$TMP/src" add -A
git -C "$TMP/src" commit -q -m "$1"
git -C "$TMP/src" rev-parse HEAD
}
C1="$(commit_touching "tui one" cmux-tui/src/main.rs)"
C2="$(commit_touching "app change" Sources/App.swift)"
C3="$(commit_touching "tui two" cmux-tui/src/lib.rs)"
C4="$(commit_touching "docs" docs/notes.md)"
C5="$(commit_touching "web" web/app.ts)"
: "$C2" "$C4"

# The artifact store: only C1 and C3 have a published client, like main after an
# artifacts run that only exists for commits touching cmux-tui.
STORE="$TMP/store"
mkdir -p "$STORE/$C1" "$STORE/$C3"
printf '{"commit":"%s"}\n' "$C1" >"$STORE/$C1/manifest.json"
printf '{"commit":"%s"}\n' "$C3" >"$STORE/$C3/manifest.json"
export CMUX_TUI_CLIENT_MANIFEST_BASE="file://$STORE"

git clone -q --depth 1 "file://$TMP/src" "$TMP/work"

# This is the bug the resolver exists for: a depth-1 clone answers HEAD.
naive="$(git -C "$TMP/work" log -1 --format=%H -- cmux-tui)"
if [[ "$naive" != "$C5" ]]; then
echo "FAIL: expected the depth-1 clone to answer HEAD ($C5) for the naive query, got $naive"
exit 1
fi

got="$(cd "$TMP/work" && "$RESOLVER")"
if [[ "$got" != "$C3" ]]; then
echo "FAIL: shallow clone must resolve the newest published cmux-tui commit $C3, got '$got'"
exit 1
fi

# The newest cmux-tui commit lost its artifacts (failed or still-running artifacts run).
rm "$STORE/$C3/manifest.json"
if (cd "$TMP/work" && "$RESOLVER" >/dev/null 2>&1); then
echo "FAIL: exact mode must fail when the newest cmux-tui commit has no published client"
exit 1
fi
got="$(cd "$TMP/work" && "$RESOLVER" --max-fallback 3 2>"$TMP/fallback.err")"
if [[ "$got" != "$C1" ]]; then
echo "FAIL: fallback must pick the previous published commit $C1, got '$got'"
exit 1
fi
if ! grep -q '^::warning' "$TMP/fallback.err"; then
echo "FAIL: a fallback must annotate the run with a ::warning"
exit 1
fi

# A leading zero is decimal, not octal: 08 means eight, not a Bash arithmetic error.
got="$(cd "$TMP/work" && "$RESOLVER" --max-fallback 08 2>/dev/null)"
if [[ "$got" != "$C1" ]]; then
echo "FAIL: --max-fallback 08 must be read as decimal 8 and resolve $C1, got '$got'"
exit 1
fi

# A full clone takes the same decision without deepening.
git clone -q "file://$TMP/src" "$TMP/full"
got="$(cd "$TMP/full" && "$RESOLVER" --max-fallback 3 2>/dev/null)"
if [[ "$got" != "$C1" ]]; then
echo "FAIL: full clone must resolve $C1, got '$got'"
exit 1
fi

echo "PASS: resolve-cmux-tui-client-commit picks the newest published cmux-tui commit, shallow or not"
Loading
Loading