Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c718f0f1cb | ||
|
|
a9c351be97 | ||
|
|
d953ceda39 | ||
|
|
6f99395b78 | ||
|
|
87edd70966 | ||
|
|
ed44c7daf4 | ||
|
|
f4cd2774de | ||
|
|
840f8c074f | ||
|
|
145b60e893 | ||
|
|
37a4d212ab | ||
|
|
7693b19065 | ||
|
|
c37ea7a31f | ||
|
|
972084ddb3 | ||
|
|
9decec5213 | ||
|
|
20390187fd | ||
|
|
9d7cc488b8 | ||
|
|
97f4a5d6a3 | ||
|
|
490b45503b | ||
|
|
cec7ac3fa7 | ||
|
|
d35187ec47 | ||
|
|
2141de5722 | ||
|
|
4adc8e519a | ||
|
|
a2d28ba765 | ||
|
|
e733aa4954 | ||
|
|
16dbad16e4 | ||
|
|
ea8c7a6fb8 | ||
|
|
be6516f704 | ||
|
|
8cc5dc4a6e | ||
|
|
004d414746 | ||
|
|
f4787432f2 | ||
|
|
eee859d354 | ||
|
|
249d0ff799 | ||
|
|
6edf2570b8 | ||
|
|
1927f130f6 |
@@ -10,7 +10,7 @@ concurrency:
|
||||
jobs:
|
||||
build-ghosttykit:
|
||||
runs-on: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 35
|
||||
env:
|
||||
GHOSTTYKIT_CRASH_REPORT_SUBDIR: cmux/crash
|
||||
GHOSTTYKIT_BUILD_FLAVOR: crashsubdir-cmux-crash-sentry-off-v1
|
||||
@@ -54,7 +54,6 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Select Xcode
|
||||
if: steps.check-release.outputs.exists == 'false'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
|
||||
@@ -78,7 +77,6 @@ jobs:
|
||||
xcodebuild -version
|
||||
|
||||
- name: Cache Zig packages
|
||||
if: steps.check-release.outputs.exists == 'false'
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.cache/zig
|
||||
@@ -86,11 +84,20 @@ jobs:
|
||||
restore-keys: zig-packages-
|
||||
|
||||
- name: Install zig
|
||||
if: steps.check-release.outputs.exists == 'false'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./scripts/install-zig-ci.sh
|
||||
|
||||
- name: Test Ghostty OS opener stderr reader
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd ghostty
|
||||
zig build test \
|
||||
-Dapp-runtime=none \
|
||||
-Demit-macos-app=false \
|
||||
-Dsentry=false \
|
||||
-Dtest-filter="open stderr reader exits"
|
||||
|
||||
- name: Build GhosttyKit.xcframework
|
||||
if: steps.check-release.outputs.exists == 'false'
|
||||
run: |
|
||||
|
||||
@@ -258,7 +258,9 @@ jobs:
|
||||
run: python3 scripts/check-workspace-package-groups.py --check
|
||||
|
||||
- name: Validate SwiftPM lockfile policy
|
||||
run: python3 scripts/check-package-resolved-policy.py
|
||||
run: |
|
||||
python3 tests/test_check_package_resolved_policy.py
|
||||
python3 scripts/check-package-resolved-policy.py
|
||||
|
||||
- name: Validate bash shell integration job control
|
||||
run: python3 tests/test_bash_integration_no_done_notifications.py
|
||||
@@ -1062,6 +1064,7 @@ jobs:
|
||||
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omo_fallback_path.py
|
||||
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omx_fallback_path.py
|
||||
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omc_fallback_path.py
|
||||
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_issue_8743_path_directory_shadowing.py
|
||||
python3 tests/test_issue_2448_shell_claude_wrapper_dispatch.py
|
||||
python3 tests/test_issue_8093_ghostty_ssh_binary_path.py
|
||||
python3 tests/test_issue_6714_zsh_shim_noclobber.py
|
||||
|
||||
@@ -231,32 +231,32 @@ jobs:
|
||||
if: matrix.language == 'rust'
|
||||
working-directory: cmux-tui
|
||||
run: |
|
||||
cargo +1.88.0 fmt -p cmux-client -p cmux-sidebar -- --check
|
||||
cargo +1.88.0 fmt -p cmux-sdk -p cmux-sidebar -- --check
|
||||
cargo +1.88.0 test \
|
||||
-p cmux-client \
|
||||
-p cmux-sdk \
|
||||
-p cmux-sidebar \
|
||||
--all-targets \
|
||||
--locked
|
||||
cargo +1.88.0 test \
|
||||
-p cmux-client \
|
||||
-p cmux-sdk \
|
||||
-p cmux-sidebar \
|
||||
--doc \
|
||||
--locked
|
||||
cargo +1.88.0 clippy \
|
||||
-p cmux-client \
|
||||
-p cmux-sdk \
|
||||
-p cmux-sidebar \
|
||||
--all-targets \
|
||||
--locked \
|
||||
-- -D warnings
|
||||
RUSTDOCFLAGS="-D warnings" \
|
||||
cargo +1.88.0 doc \
|
||||
-p cmux-client \
|
||||
-p cmux-sdk \
|
||||
-p cmux-sidebar \
|
||||
--locked \
|
||||
--no-deps
|
||||
cargo +1.88.0 package -p cmux-client --locked
|
||||
cargo +1.88.0 package -p cmux-sdk --locked
|
||||
# Full sidebar packaging resolves its versioned crates.io dependency.
|
||||
# Publish cmux-client first; CI still verifies the exact sidebar file set.
|
||||
# Publish cmux-sdk first; CI still verifies the exact sidebar file set.
|
||||
cargo +1.88.0 package -p cmux-sidebar --locked --list
|
||||
|
||||
- name: Test Go SDK
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: coderouter CLI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "coderouter/**"
|
||||
- ".github/workflows/coderouter-*.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "coderouter/**"
|
||||
- ".github/workflows/coderouter-*.yml"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: coderouter
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy,rustfmt
|
||||
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.2
|
||||
with:
|
||||
workspaces: coderouter
|
||||
- run: cargo fmt --check
|
||||
- run: cargo clippy --all-targets -- -D warnings
|
||||
- run: cargo test --all-targets
|
||||
- run: node scripts/check-version.mjs
|
||||
- run: npm pack --dry-run
|
||||
working-directory: coderouter/npm
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
name: coderouter publish npm
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Stable X.Y.Z release version
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-coderouter
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22.14.0"
|
||||
registry-url: https://registry.npmjs.org
|
||||
- name: Validate release tag
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
[[ "$GITHUB_REF" == "refs/tags/coderouter-v$VERSION" ]]
|
||||
[[ "$(jq -r .version coderouter/npm/package.json)" == "$VERSION" ]]
|
||||
git fetch origin main
|
||||
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
|
||||
- name: Download verified release packages
|
||||
uses: robinraju/release-downloader@daf26c55d821e836577a15f77d86ddc078948b05 # v1.12
|
||||
with:
|
||||
tag: coderouter-v${{ inputs.version }}
|
||||
fileName: "coderouter-npm-*.tgz"
|
||||
out-file-path: dist
|
||||
- run: npm install -g [email protected]
|
||||
- name: Publish platform packages, then launcher
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for package in dist/coderouter-npm-cli-*.tgz; do
|
||||
npm publish --provenance "$package"
|
||||
done
|
||||
npm publish --provenance "dist/coderouter-npm-launcher.tgz"
|
||||
@@ -0,0 +1,45 @@
|
||||
name: coderouter publish pypi
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Stable X.Y.Z release version
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi-coderouter
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- name: Validate protected release tag
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
[[ "$GITHUB_REF" == "refs/tags/coderouter-v$VERSION" ]]
|
||||
[[ "$(node coderouter/scripts/check-version.mjs)" == "$VERSION" ]]
|
||||
git fetch origin main
|
||||
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
|
||||
- name: Download wheels from the signed GitHub release
|
||||
uses: robinraju/release-downloader@daf26c55d821e836577a15f77d86ddc078948b05 # v1.12
|
||||
with:
|
||||
tag: coderouter-v${{ inputs.version }}
|
||||
fileName: "coderouter-*.whl"
|
||||
out-file-path: dist
|
||||
- name: Publish through PyPI Trusted Publishing
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
||||
with:
|
||||
packages-dir: dist
|
||||
attestations: true
|
||||
@@ -0,0 +1,141 @@
|
||||
name: coderouter release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "coderouter-v*"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#coderouter-v}"
|
||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
actual="$(node coderouter/scripts/check-version.mjs)"
|
||||
[[ "$actual" == "$version" ]] || {
|
||||
echo "tag version $version does not match package version $actual" >&2
|
||||
exit 1
|
||||
}
|
||||
git fetch origin main
|
||||
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
needs: validate
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-14-xlarge
|
||||
rust_target: aarch64-apple-darwin
|
||||
npm_target: darwin-arm64
|
||||
executable: coderouter
|
||||
- runner: macos-15-intel
|
||||
rust_target: x86_64-apple-darwin
|
||||
npm_target: darwin-x64
|
||||
executable: coderouter
|
||||
- runner: ubuntu-latest
|
||||
rust_target: x86_64-unknown-linux-gnu
|
||||
npm_target: linux-x64
|
||||
executable: coderouter
|
||||
- runner: windows-latest
|
||||
rust_target: x86_64-pc-windows-msvc
|
||||
npm_target: win32-x64
|
||||
executable: coderouter.exe
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: coderouter
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.rust_target }}
|
||||
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.2
|
||||
with:
|
||||
workspaces: coderouter
|
||||
key: ${{ matrix.rust_target }}
|
||||
- run: cargo test --release --target ${{ matrix.rust_target }}
|
||||
- run: cargo build --release --target ${{ matrix.rust_target }} --bin coderouter
|
||||
- name: Package npm platform binary
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
NPM_TARGET: ${{ matrix.npm_target }}
|
||||
RUST_TARGET: ${{ matrix.rust_target }}
|
||||
EXECUTABLE: ${{ matrix.executable }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist/npm
|
||||
node scripts/package-npm.mjs \
|
||||
"$VERSION" "$NPM_TARGET" \
|
||||
"target/$RUST_TARGET/release/$EXECUTABLE" dist/npm
|
||||
npm pack "dist/npm/cli-$NPM_TARGET" --pack-destination dist
|
||||
package="$(find dist -maxdepth 1 -name 'coderouter-cli-*.tgz' -print -quit)"
|
||||
mv "$package" "dist/coderouter-npm-cli-$NPM_TARGET.tgz"
|
||||
- name: Build PyPI wheel
|
||||
uses: PyO3/maturin-action@86b9d133d34bc1b40018696f782949dac11bd380 # v1.49.4
|
||||
with:
|
||||
command: build
|
||||
target: ${{ matrix.rust_target }}
|
||||
args: --release --out coderouter/dist --manifest-path coderouter/Cargo.toml
|
||||
manylinux: auto
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: coderouter-${{ matrix.npm_target }}
|
||||
path: |
|
||||
coderouter/dist/coderouter-npm-cli-${{ matrix.npm_target }}.tgz
|
||||
coderouter/dist/*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
needs: [validate, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
pattern: coderouter-*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22.14.0"
|
||||
- name: Package npm launcher
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$(node coderouter/scripts/check-version.mjs)" == "$VERSION" ]]
|
||||
npm pack coderouter/npm --pack-destination dist
|
||||
mv "dist/coderouter-$VERSION.tgz" dist/coderouter-npm-launcher.tgz
|
||||
sha256sum dist/* > dist/SHA256SUMS
|
||||
- name: Create immutable GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release create "$GITHUB_REF_NAME" dist/* \
|
||||
--verify-tag \
|
||||
--title "CodeRouter ${{ needs.validate.outputs.version }}" \
|
||||
--generate-notes
|
||||
@@ -21,12 +21,13 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
artifact_sha256: ${{ steps.package.outputs.artifact_sha256 }}
|
||||
sdk_sha256: ${{ steps.package.outputs.sdk_sha256 }}
|
||||
sidebar_sha256: ${{ steps.package.outputs.sidebar_sha256 }}
|
||||
steps:
|
||||
- name: Require explicit bootstrap confirmation
|
||||
if: github.event.client_payload.confirm_bootstrap != true
|
||||
run: |
|
||||
echo "Refusing to reserve cmux-sidebar without confirm_bootstrap=true." >&2
|
||||
echo "Refusing to reserve the Rust SDK crates without confirm_bootstrap=true." >&2
|
||||
exit 1
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -60,46 +61,72 @@ jobs:
|
||||
id: package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_dir="cmux-tui/bindings/bootstrap/rust-sidebar"
|
||||
bootstrap_dir="$RUNNER_TEMP/cmux-sidebar-bootstrap"
|
||||
cp -R "$source_dir" "$bootstrap_dir"
|
||||
manifest="$bootstrap_dir/Cargo.toml"
|
||||
cargo test --manifest-path "$manifest" --locked
|
||||
cargo package --manifest-path "$manifest" --locked --no-verify
|
||||
artifact="$bootstrap_dir/target/package/cmux-sidebar-$BOOTSTRAP_VERSION.crate"
|
||||
[[ -f "$artifact" ]] || {
|
||||
echo "bootstrap crate was not created" >&2
|
||||
exit 1
|
||||
}
|
||||
verify_dir="$RUNNER_TEMP/cmux-sidebar-bootstrap-verify"
|
||||
mkdir -p "$verify_dir"
|
||||
tar -xzf "$artifact" -C "$verify_dir"
|
||||
cargo test \
|
||||
--manifest-path \
|
||||
"$verify_dir/cmux-sidebar-$BOOTSTRAP_VERSION/Cargo.toml" \
|
||||
--locked
|
||||
artifact_sha256="$(sha256sum "$artifact" | cut -d ' ' -f 1)"
|
||||
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]] || {
|
||||
echo "bootstrap crate digest is malformed" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "artifact_sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
|
||||
for specification in \
|
||||
"cmux-sdk:rust-sdk:sdk_sha256" \
|
||||
"cmux-sidebar:rust-sidebar:sidebar_sha256"; do
|
||||
IFS=: read -r package source output_name <<< "$specification"
|
||||
source_dir="cmux-tui/bindings/bootstrap/$source"
|
||||
bootstrap_dir="$RUNNER_TEMP/$package-bootstrap"
|
||||
cp -R "$source_dir" "$bootstrap_dir"
|
||||
manifest="$bootstrap_dir/Cargo.toml"
|
||||
cargo test --manifest-path "$manifest" --locked
|
||||
cargo package --manifest-path "$manifest" --locked --no-verify
|
||||
artifact="$bootstrap_dir/target/package/$package-$BOOTSTRAP_VERSION.crate"
|
||||
[[ -f "$artifact" ]] || {
|
||||
echo "$package bootstrap crate was not created" >&2
|
||||
exit 1
|
||||
}
|
||||
verify_dir="$RUNNER_TEMP/$package-bootstrap-verify"
|
||||
mkdir -p "$verify_dir"
|
||||
tar -xzf "$artifact" -C "$verify_dir"
|
||||
cargo test \
|
||||
--manifest-path \
|
||||
"$verify_dir/$package-$BOOTSTRAP_VERSION/Cargo.toml" \
|
||||
--locked
|
||||
artifact_dir="$RUNNER_TEMP/$package-bootstrap-artifact"
|
||||
mkdir -p "$artifact_dir"
|
||||
cp "$artifact" "$artifact_dir/"
|
||||
artifact_sha256="$(sha256sum "$artifact" | cut -d ' ' -f 1)"
|
||||
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]] || {
|
||||
echo "$package bootstrap crate digest is malformed" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "$output_name=$artifact_sha256" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- name: Upload the cmux-sdk bootstrap crate
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cmux-sdk-bootstrap-crate
|
||||
path: ${{ runner.temp }}/cmux-sdk-bootstrap-artifact
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
|
||||
- name: Upload the cmux-sidebar bootstrap crate
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cmux-sidebar-bootstrap-crate
|
||||
path: ${{ runner.temp }}/cmux-sidebar-bootstrap/target/package/*.crate
|
||||
path: ${{ runner.temp }}/cmux-sidebar-bootstrap-artifact
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
|
||||
preflight:
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
include:
|
||||
- package: cmux-sdk
|
||||
artifact: cmux-sdk-bootstrap-crate
|
||||
decision: cmux-sdk-bootstrap-decision
|
||||
- package: cmux-sidebar
|
||||
artifact: cmux-sidebar-bootstrap-crate
|
||||
decision: cmux-sidebar-bootstrap-decision
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
need_publish: ${{ steps.decision.outputs.need_publish }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -108,14 +135,16 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: cmux-sidebar-bootstrap-crate
|
||||
name: ${{ matrix.artifact }}
|
||||
path: bootstrap-crate
|
||||
|
||||
- name: Inspect the crates.io bootstrap state
|
||||
id: project
|
||||
env:
|
||||
PACKAGE: ${{ matrix.package }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
metadata="$RUNNER_TEMP/cmux-sidebar-bootstrap-registry.json"
|
||||
metadata="$RUNNER_TEMP/$PACKAGE-bootstrap-registry.json"
|
||||
status="$(
|
||||
curl \
|
||||
--silent \
|
||||
@@ -127,15 +156,15 @@ jobs:
|
||||
--user-agent 'cmux-sdk-bootstrap/1 (https://github.com/manaflow-ai/cmux; contact: https://github.com/manaflow-ai/cmux/issues)' \
|
||||
--output "$metadata" \
|
||||
--write-out '%{http_code}' \
|
||||
https://crates.io/api/v1/crates/cmux-sidebar
|
||||
"https://crates.io/api/v1/crates/$PACKAGE"
|
||||
)"
|
||||
case "$status" in
|
||||
404)
|
||||
echo "cmux-sidebar is unclaimed; bootstrap may continue."
|
||||
echo "$PACKAGE is unclaimed; bootstrap may continue."
|
||||
project_status=missing
|
||||
;;
|
||||
200)
|
||||
echo "cmux-sidebar exists; bootstrap bytes must match."
|
||||
echo "$PACKAGE exists; bootstrap bytes must match."
|
||||
project_status=exists
|
||||
;;
|
||||
*)
|
||||
@@ -144,11 +173,12 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
echo "status=$project_status" >> "$GITHUB_OUTPUT"
|
||||
# Keep the next crates.io API client inside the one-request-per-second policy.
|
||||
sleep 1
|
||||
|
||||
- name: Reconcile an existing crates.io ownership bootstrap
|
||||
if: steps.project.outputs.status == 'exists'
|
||||
env:
|
||||
PACKAGE: ${{ matrix.package }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
@@ -159,38 +189,102 @@ jobs:
|
||||
}
|
||||
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
|
||||
--registry crates \
|
||||
--package cmux-sidebar \
|
||||
--package "$PACKAGE" \
|
||||
--version "$BOOTSTRAP_VERSION" \
|
||||
--artifact "${artifacts[0]}" \
|
||||
--require-match
|
||||
sleep 1
|
||||
python3 cmux-tui/bindings/verify_crates_ownership.py \
|
||||
--package cmux-sidebar \
|
||||
--package "$PACKAGE" \
|
||||
--repository https://github.com/manaflow-ai/cmux \
|
||||
--owner-id 431397 \
|
||||
--owner-login lawrencecchen \
|
||||
--bootstrap-ownership-only
|
||||
|
||||
- name: Request publication for an unclaimed project
|
||||
id: decision
|
||||
if: steps.project.outputs.status == 'missing'
|
||||
run: echo "need_publish=true" >> "$GITHUB_OUTPUT"
|
||||
- name: Record the credential-job decision
|
||||
env:
|
||||
PACKAGE: ${{ matrix.package }}
|
||||
PROJECT_STATUS: ${{ steps.project.outputs.status }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$PROJECT_STATUS" in
|
||||
missing) decision=publish ;;
|
||||
exists) decision=skip ;;
|
||||
*)
|
||||
echo "unexpected $PACKAGE project state: $PROJECT_STATUS" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
decision_dir="$RUNNER_TEMP/$PACKAGE-bootstrap-decision"
|
||||
mkdir -p "$decision_dir"
|
||||
printf '%s\n' "$decision" > "$decision_dir/decision.txt"
|
||||
|
||||
publish:
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ matrix.decision }}
|
||||
path: ${{ runner.temp }}/${{ matrix.package }}-bootstrap-decision
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
|
||||
decisions:
|
||||
needs:
|
||||
- preflight
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: read
|
||||
outputs:
|
||||
sdk_need_publish: ${{ steps.read.outputs.sdk_need_publish }}
|
||||
sidebar_need_publish: ${{ steps.read.outputs.sidebar_need_publish }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: cmux-sdk-bootstrap-decision
|
||||
path: sdk-decision
|
||||
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: cmux-sidebar-bootstrap-decision
|
||||
path: sidebar-decision
|
||||
|
||||
- name: Export protected-environment decisions
|
||||
id: read
|
||||
run: |
|
||||
set -euo pipefail
|
||||
read_decision() {
|
||||
local path="$1"
|
||||
local output_name="$2"
|
||||
local decision
|
||||
decision="$(cat "$path")"
|
||||
case "$decision" in
|
||||
publish) need_publish=true ;;
|
||||
skip) need_publish=false ;;
|
||||
*)
|
||||
echo "invalid bootstrap publication decision: $decision" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "$output_name=$need_publish" >> "$GITHUB_OUTPUT"
|
||||
}
|
||||
read_decision sdk-decision/decision.txt sdk_need_publish
|
||||
read_decision sidebar-decision/decision.txt sidebar_need_publish
|
||||
|
||||
publish-sdk:
|
||||
needs:
|
||||
- build
|
||||
- preflight
|
||||
if: needs.preflight.outputs.need_publish == 'true'
|
||||
- decisions
|
||||
if: needs.decisions.outputs.sdk_need_publish == 'true'
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 10
|
||||
permissions: {}
|
||||
environment:
|
||||
name: crates-bootstrap
|
||||
url: https://crates.io/crates/cmux-sidebar
|
||||
url: https://crates.io/crates/cmux-sdk
|
||||
steps:
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: cmux-sidebar-bootstrap-crate
|
||||
name: cmux-sdk-bootstrap-crate
|
||||
path: bootstrap-crate
|
||||
|
||||
- name: Install pinned Rust toolchain
|
||||
@@ -203,7 +297,8 @@ jobs:
|
||||
- name: Verify protected source and reproduce the tested crate
|
||||
id: prepare
|
||||
env:
|
||||
EXPECTED_SHA256: ${{ needs.build.outputs.artifact_sha256 }}
|
||||
EXPECTED_SHA256: ${{ needs.build.outputs.sdk_sha256 }}
|
||||
PACKAGE: cmux-sdk
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$GITHUB_REPOSITORY" == "manaflow-ai/cmux" ]] || {
|
||||
@@ -224,6 +319,7 @@ jobs:
|
||||
echo "workflow commit $GITHUB_SHA is not current main $main_sha" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ "$EXPECTED_SHA256" =~ ^[0-9a-f]{64}$ ]] || {
|
||||
echo "validated crate digest is malformed" >&2
|
||||
exit 1
|
||||
@@ -235,14 +331,18 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
BOOTSTRAP_ARTIFACT="${artifacts[0]}"
|
||||
[[ "$(basename "$BOOTSTRAP_ARTIFACT")" == "$PACKAGE-$BOOTSTRAP_VERSION.crate" ]] || {
|
||||
echo "unexpected bootstrap crate filename" >&2
|
||||
exit 1
|
||||
}
|
||||
actual_sha256="$(sha256sum "$BOOTSTRAP_ARTIFACT" | cut -d ' ' -f 1)"
|
||||
[[ "$actual_sha256" == "$EXPECTED_SHA256" ]] || {
|
||||
echo "downloaded crates.io bootstrap artifact digest mismatch" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
package_prefix="cmux-sidebar-$BOOTSTRAP_VERSION"
|
||||
publish_root="$RUNNER_TEMP/cmux-sidebar-publish"
|
||||
package_prefix="$PACKAGE-$BOOTSTRAP_VERSION"
|
||||
publish_root="$RUNNER_TEMP/$PACKAGE-publish"
|
||||
mkdir -p "$publish_root"
|
||||
python3 - \
|
||||
"$BOOTSTRAP_ARTIFACT" \
|
||||
@@ -294,10 +394,157 @@ jobs:
|
||||
--no-verify
|
||||
REPACKED_ARTIFACT="$package_root/target/package/$package_prefix.crate"
|
||||
cmp "$BOOTSTRAP_ARTIFACT" "$REPACKED_ARTIFACT"
|
||||
{
|
||||
echo "artifact=$BOOTSTRAP_ARTIFACT"
|
||||
echo "manifest=$package_root/Cargo.toml"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "manifest=$package_root/Cargo.toml" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish the exact tested ownership bootstrap
|
||||
continue-on-error: true
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_BOOTSTRAP_TOKEN }}
|
||||
PUBLISH_MANIFEST: ${{ steps.prepare.outputs.manifest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ -n "$CARGO_REGISTRY_TOKEN" ]] || {
|
||||
echo "crates-bootstrap environment secret CARGO_BOOTSTRAP_TOKEN is required." >&2
|
||||
exit 1
|
||||
}
|
||||
cargo publish \
|
||||
--manifest-path "$PUBLISH_MANIFEST" \
|
||||
--locked \
|
||||
--no-verify
|
||||
|
||||
publish-sidebar:
|
||||
needs:
|
||||
- build
|
||||
- preflight
|
||||
- decisions
|
||||
- publish-sdk
|
||||
if: >-
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
needs.build.result == 'success' &&
|
||||
needs.preflight.result == 'success' &&
|
||||
needs.decisions.result == 'success' &&
|
||||
needs.decisions.outputs.sidebar_need_publish == 'true'
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 10
|
||||
permissions: {}
|
||||
environment:
|
||||
name: crates-bootstrap
|
||||
url: https://crates.io/crates/cmux-sidebar
|
||||
steps:
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: cmux-sidebar-bootstrap-crate
|
||||
path: bootstrap-crate
|
||||
|
||||
- name: Install pinned Rust toolchain
|
||||
run: |
|
||||
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal
|
||||
rustup default "$RUST_TOOLCHAIN"
|
||||
cargo --version
|
||||
rustc --version
|
||||
|
||||
- name: Verify protected source and reproduce the tested crate
|
||||
id: prepare
|
||||
env:
|
||||
EXPECTED_SHA256: ${{ needs.build.outputs.sidebar_sha256 }}
|
||||
PACKAGE: cmux-sidebar
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$GITHUB_REPOSITORY" == "manaflow-ai/cmux" ]] || {
|
||||
echo "bootstrap repository must be manaflow-ai/cmux" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$GITHUB_REF" == "refs/heads/main" ]] || {
|
||||
echo "bootstrap credential job must run from main" >&2
|
||||
exit 1
|
||||
}
|
||||
main_sha="$(
|
||||
git ls-remote \
|
||||
https://github.com/manaflow-ai/cmux.git \
|
||||
refs/heads/main |
|
||||
awk 'NR == 1 { print $1 }'
|
||||
)"
|
||||
[[ "$main_sha" == "$GITHUB_SHA" ]] || {
|
||||
echo "workflow commit $GITHUB_SHA is not current main $main_sha" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ "$EXPECTED_SHA256" =~ ^[0-9a-f]{64}$ ]] || {
|
||||
echo "validated crate digest is malformed" >&2
|
||||
exit 1
|
||||
}
|
||||
shopt -s nullglob
|
||||
artifacts=(bootstrap-crate/*.crate)
|
||||
[[ "${#artifacts[@]}" == 1 ]] || {
|
||||
echo "expected one tested crate, found ${#artifacts[@]}" >&2
|
||||
exit 1
|
||||
}
|
||||
BOOTSTRAP_ARTIFACT="${artifacts[0]}"
|
||||
[[ "$(basename "$BOOTSTRAP_ARTIFACT")" == "$PACKAGE-$BOOTSTRAP_VERSION.crate" ]] || {
|
||||
echo "unexpected bootstrap crate filename" >&2
|
||||
exit 1
|
||||
}
|
||||
actual_sha256="$(sha256sum "$BOOTSTRAP_ARTIFACT" | cut -d ' ' -f 1)"
|
||||
[[ "$actual_sha256" == "$EXPECTED_SHA256" ]] || {
|
||||
echo "downloaded crates.io bootstrap artifact digest mismatch" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
package_prefix="$PACKAGE-$BOOTSTRAP_VERSION"
|
||||
publish_root="$RUNNER_TEMP/$PACKAGE-publish"
|
||||
mkdir -p "$publish_root"
|
||||
python3 - \
|
||||
"$BOOTSTRAP_ARTIFACT" \
|
||||
"$publish_root" \
|
||||
"$package_prefix" <<'PY'
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
|
||||
archive_path = pathlib.Path(sys.argv[1])
|
||||
publish_root = pathlib.Path(sys.argv[2])
|
||||
package_prefix = sys.argv[3]
|
||||
expected = {
|
||||
f"{package_prefix}/Cargo.lock",
|
||||
f"{package_prefix}/Cargo.toml",
|
||||
f"{package_prefix}/Cargo.toml.orig",
|
||||
f"{package_prefix}/README.md",
|
||||
f"{package_prefix}/src/lib.rs",
|
||||
}
|
||||
with tarfile.open(archive_path, "r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
names = [member.name for member in members]
|
||||
if len(names) != len(expected) or set(names) != expected:
|
||||
raise SystemExit(
|
||||
f"bootstrap crate paths differ from the allowlist: {names!r}"
|
||||
)
|
||||
for member in members:
|
||||
if not member.isfile():
|
||||
raise SystemExit(
|
||||
f"bootstrap crate member is not a regular file: {member.name}"
|
||||
)
|
||||
source = archive.extractfile(member)
|
||||
if source is None:
|
||||
raise SystemExit(
|
||||
f"bootstrap crate member cannot be read: {member.name}"
|
||||
)
|
||||
destination = publish_root / member.name
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with source, destination.open("wb") as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
destination.chmod(member.mode & 0o777)
|
||||
PY
|
||||
package_root="$publish_root/$package_prefix"
|
||||
cp "$package_root/Cargo.toml.orig" "$package_root/Cargo.toml"
|
||||
cargo package \
|
||||
--manifest-path "$package_root/Cargo.toml" \
|
||||
--locked \
|
||||
--no-verify
|
||||
REPACKED_ARTIFACT="$package_root/target/package/$package_prefix.crate"
|
||||
cmp "$BOOTSTRAP_ARTIFACT" "$REPACKED_ARTIFACT"
|
||||
echo "manifest=$package_root/Cargo.toml" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish the exact tested ownership bootstrap
|
||||
continue-on-error: true
|
||||
@@ -319,12 +566,23 @@ jobs:
|
||||
needs:
|
||||
- build
|
||||
- preflight
|
||||
- publish
|
||||
- decisions
|
||||
- publish-sdk
|
||||
- publish-sidebar
|
||||
if: >-
|
||||
always() &&
|
||||
needs.build.result == 'success' &&
|
||||
needs.preflight.result == 'success' &&
|
||||
(needs.publish.result == 'success' || needs.publish.result == 'skipped')
|
||||
needs.decisions.result == 'success'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
include:
|
||||
- package: cmux-sdk
|
||||
artifact: cmux-sdk-bootstrap-crate
|
||||
- package: cmux-sidebar
|
||||
artifact: cmux-sidebar-bootstrap-crate
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
@@ -337,10 +595,12 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: cmux-sidebar-bootstrap-crate
|
||||
name: ${{ matrix.artifact }}
|
||||
path: bootstrap-crate
|
||||
|
||||
- name: Reconcile the exact crates.io ownership bootstrap
|
||||
env:
|
||||
PACKAGE: ${{ matrix.package }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
@@ -351,7 +611,7 @@ jobs:
|
||||
}
|
||||
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
|
||||
--registry crates \
|
||||
--package cmux-sidebar \
|
||||
--package "$PACKAGE" \
|
||||
--version "$BOOTSTRAP_VERSION" \
|
||||
--artifact "${artifacts[0]}" \
|
||||
--retry-missing-project \
|
||||
@@ -359,7 +619,7 @@ jobs:
|
||||
--require-match
|
||||
sleep 1
|
||||
python3 cmux-tui/bindings/verify_crates_ownership.py \
|
||||
--package cmux-sidebar \
|
||||
--package "$PACKAGE" \
|
||||
--repository https://github.com/manaflow-ai/cmux \
|
||||
--owner-id 431397 \
|
||||
--owner-login lawrencecchen \
|
||||
|
||||
@@ -126,19 +126,19 @@ jobs:
|
||||
CMUX_SDK_VERSION: ${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo test -p cmux-client -p cmux-sidebar --locked
|
||||
cargo package -p cmux-client --locked
|
||||
cargo test -p cmux-sdk -p cmux-sidebar --locked
|
||||
cargo package -p cmux-sdk --locked
|
||||
cargo package \
|
||||
-p cmux-sidebar \
|
||||
--locked \
|
||||
--no-verify \
|
||||
--config \
|
||||
"patch.crates-io.cmux-client.path='$GITHUB_WORKSPACE/cmux-tui/bindings/rust'"
|
||||
"patch.crates-io.cmux-sdk.path='$GITHUB_WORKSPACE/cmux-tui/bindings/rust'"
|
||||
|
||||
verify_root="$RUNNER_TEMP/cmux-rust-package-verify"
|
||||
mkdir -p "$verify_root"
|
||||
tar -xzf \
|
||||
"target/package/cmux-client-$CMUX_SDK_VERSION.crate" \
|
||||
"target/package/cmux-sdk-$CMUX_SDK_VERSION.crate" \
|
||||
-C "$verify_root"
|
||||
tar -xzf \
|
||||
"target/package/cmux-sidebar-$CMUX_SDK_VERSION.crate" \
|
||||
@@ -147,14 +147,14 @@ jobs:
|
||||
--manifest-path \
|
||||
"$verify_root/cmux-sidebar-$CMUX_SDK_VERSION/Cargo.toml" \
|
||||
--config \
|
||||
"patch.crates-io.cmux-client.path='$verify_root/cmux-client-$CMUX_SDK_VERSION'" \
|
||||
"patch.crates-io.cmux-sdk.path='$verify_root/cmux-sdk-$CMUX_SDK_VERSION'" \
|
||||
--all-targets
|
||||
|
||||
- name: Upload validated cmux-client crate
|
||||
- name: Upload validated cmux-sdk crate
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cmux-rust-client-crate
|
||||
path: cmux-tui/target/package/cmux-client-${{ needs.version.outputs.version }}.crate
|
||||
name: cmux-rust-sdk-crate
|
||||
path: cmux-tui/target/package/cmux-sdk-${{ needs.version.outputs.version }}.crate
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
|
||||
|
||||
@@ -164,11 +164,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Download the validated cmux-client crate
|
||||
- name: Download the validated cmux-sdk crate
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: cmux-rust-client-crate
|
||||
path: validated-client
|
||||
name: cmux-rust-sdk-crate
|
||||
path: validated-rust-sdk
|
||||
|
||||
- name: Download the validated cmux-sidebar crate
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
@@ -213,9 +213,9 @@ jobs:
|
||||
|
||||
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
|
||||
--registry crates \
|
||||
--package cmux-client \
|
||||
--package cmux-sdk \
|
||||
--version "$CMUX_SDK_VERSION" \
|
||||
--artifact "validated-client/cmux-client-$CMUX_SDK_VERSION.crate"
|
||||
--artifact "validated-rust-sdk/cmux-sdk-$CMUX_SDK_VERSION.crate"
|
||||
sleep 1
|
||||
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
|
||||
--registry crates \
|
||||
@@ -252,7 +252,7 @@ jobs:
|
||||
run: |
|
||||
sleep 1
|
||||
python3 cmux-tui/bindings/verify_crates_ownership.py \
|
||||
--package cmux-client \
|
||||
--package cmux-sdk \
|
||||
--package cmux-sidebar \
|
||||
--repository https://github.com/manaflow-ai/cmux \
|
||||
--owner-id 431397 \
|
||||
@@ -375,11 +375,11 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download the validated cmux-client crate for final revalidation
|
||||
- name: Download the validated cmux-sdk crate for final revalidation
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: cmux-rust-client-crate
|
||||
path: validated-client
|
||||
name: cmux-rust-sdk-crate
|
||||
path: validated-rust-sdk
|
||||
|
||||
- name: Download the validated cmux-sidebar crate for final revalidation
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
@@ -846,7 +846,7 @@ jobs:
|
||||
verify_tag: true
|
||||
release_ref: refs/tags/${{ needs.validate-release.outputs.go_tag }}
|
||||
|
||||
publish-crate-client:
|
||||
publish-crate-sdk:
|
||||
needs:
|
||||
- validate-release
|
||||
- cut-tags
|
||||
@@ -859,7 +859,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
environment:
|
||||
name: crates-io
|
||||
url: https://crates.io/crates/cmux-client
|
||||
url: https://crates.io/crates/cmux-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -867,11 +867,11 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download the validated cmux-client crate
|
||||
- name: Download the validated cmux-sdk crate
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: cmux-rust-client-crate
|
||||
path: validated-client
|
||||
name: cmux-rust-sdk-crate
|
||||
path: validated-rust-sdk
|
||||
|
||||
- name: Require the coordinated release source
|
||||
env:
|
||||
@@ -905,50 +905,50 @@ jobs:
|
||||
cargo --version
|
||||
rustc --version
|
||||
|
||||
- name: Reproduce the validated cmux-client crate without executing it
|
||||
- name: Reproduce the validated cmux-sdk crate without executing it
|
||||
working-directory: cmux-tui
|
||||
env:
|
||||
CMUX_SDK_VERSION: ${{ needs.validate-release.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo package -p cmux-client --locked --no-verify
|
||||
validated="$GITHUB_WORKSPACE/validated-client/cmux-client-$CMUX_SDK_VERSION.crate"
|
||||
reproduced="target/package/cmux-client-$CMUX_SDK_VERSION.crate"
|
||||
cargo package -p cmux-sdk --locked --no-verify
|
||||
validated="$GITHUB_WORKSPACE/validated-rust-sdk/cmux-sdk-$CMUX_SDK_VERSION.crate"
|
||||
reproduced="target/package/cmux-sdk-$CMUX_SDK_VERSION.crate"
|
||||
expected_digest="$(sha256sum "$validated" | awk '{print $1}')"
|
||||
actual_digest="$(sha256sum "$reproduced" | awk '{print $1}')"
|
||||
[[ "$actual_digest" == "$expected_digest" ]] || {
|
||||
echo "validated crate digest mismatch for cmux-client@$CMUX_SDK_VERSION" >&2
|
||||
echo "validated crate digest mismatch for cmux-sdk@$CMUX_SDK_VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Revalidate crates.io ownership immediately before authentication
|
||||
run: bash cmux-tui/bindings/verify_release_registry_authority.sh crates
|
||||
|
||||
- name: Authenticate cmux-client with crates.io trusted publishing
|
||||
id: auth_client
|
||||
- name: Authenticate cmux-sdk with crates.io trusted publishing
|
||||
id: auth_sdk
|
||||
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
|
||||
|
||||
- name: Publish cmux-client
|
||||
- name: Publish cmux-sdk
|
||||
working-directory: cmux-tui
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ steps.auth_client.outputs.token }}
|
||||
CARGO_REGISTRY_TOKEN: ${{ steps.auth_sdk.outputs.token }}
|
||||
CMUX_SDK_VERSION: ${{ needs.validate-release.outputs.version }}
|
||||
run: |
|
||||
python3 bindings/reconcile_registry_artifact.py publish \
|
||||
--registry crates \
|
||||
--package cmux-client \
|
||||
--package cmux-sdk \
|
||||
--version "$CMUX_SDK_VERSION" \
|
||||
--artifact "$GITHUB_WORKSPACE/validated-client/cmux-client-$CMUX_SDK_VERSION.crate" \
|
||||
--artifact "$GITHUB_WORKSPACE/validated-rust-sdk/cmux-sdk-$CMUX_SDK_VERSION.crate" \
|
||||
--wait-seconds 120 \
|
||||
--publish-timeout-seconds 600 \
|
||||
-- cargo publish -p cmux-client --locked --no-verify
|
||||
-- cargo publish -p cmux-sdk --locked --no-verify
|
||||
|
||||
publish-crate-sidebar:
|
||||
needs:
|
||||
- validate-release
|
||||
- cut-tags
|
||||
- verify-go-tag
|
||||
- publish-crate-client
|
||||
- publish-crate-sdk
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
@@ -971,11 +971,11 @@ jobs:
|
||||
name: cmux-rust-sidebar-crate
|
||||
path: validated-sidebar
|
||||
|
||||
- name: Download the validated cmux-client crate
|
||||
- name: Download the validated cmux-sdk crate
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: cmux-rust-client-crate
|
||||
path: validated-client
|
||||
name: cmux-rust-sdk-crate
|
||||
path: validated-rust-sdk
|
||||
|
||||
- name: Require the coordinated release source
|
||||
env:
|
||||
@@ -1009,7 +1009,7 @@ jobs:
|
||||
cargo --version
|
||||
rustc --version
|
||||
|
||||
- name: Confirm the validated cmux-client crate reached crates.io
|
||||
- name: Confirm the validated cmux-sdk crate reached crates.io
|
||||
working-directory: cmux-tui
|
||||
env:
|
||||
CMUX_SDK_VERSION: ${{ needs.validate-release.outputs.version }}
|
||||
@@ -1017,9 +1017,9 @@ jobs:
|
||||
set -euo pipefail
|
||||
python3 bindings/reconcile_registry_artifact.py check \
|
||||
--registry crates \
|
||||
--package cmux-client \
|
||||
--package cmux-sdk \
|
||||
--version "$CMUX_SDK_VERSION" \
|
||||
--artifact "$GITHUB_WORKSPACE/validated-client/cmux-client-$CMUX_SDK_VERSION.crate" \
|
||||
--artifact "$GITHUB_WORKSPACE/validated-rust-sdk/cmux-sdk-$CMUX_SDK_VERSION.crate" \
|
||||
--wait-seconds 300 \
|
||||
--require-match
|
||||
|
||||
@@ -1034,7 +1034,7 @@ jobs:
|
||||
--locked \
|
||||
--no-verify \
|
||||
--config \
|
||||
"patch.crates-io.cmux-client.path='$GITHUB_WORKSPACE/cmux-tui/bindings/rust'"
|
||||
"patch.crates-io.cmux-sdk.path='$GITHUB_WORKSPACE/cmux-tui/bindings/rust'"
|
||||
validated="$GITHUB_WORKSPACE/validated-sidebar/cmux-sidebar-$CMUX_SDK_VERSION.crate"
|
||||
reproduced="target/package/cmux-sidebar-$CMUX_SDK_VERSION.crate"
|
||||
expected_digest="$(sha256sum "$validated" | awk '{print $1}')"
|
||||
@@ -1069,7 +1069,7 @@ jobs:
|
||||
--locked \
|
||||
--no-verify \
|
||||
--config \
|
||||
"patch.crates-io.cmux-client.path='$GITHUB_WORKSPACE/cmux-tui/bindings/rust'"
|
||||
"patch.crates-io.cmux-sdk.path='$GITHUB_WORKSPACE/cmux-tui/bindings/rust'"
|
||||
|
||||
publish-npm:
|
||||
needs:
|
||||
@@ -1429,7 +1429,7 @@ jobs:
|
||||
- validate-release
|
||||
- typescript-preflight
|
||||
- python-preflight
|
||||
- publish-crate-client
|
||||
- publish-crate-sdk
|
||||
- publish-crate-sidebar
|
||||
- publish-npm
|
||||
- publish-python-wheel
|
||||
@@ -1447,7 +1447,7 @@ jobs:
|
||||
- name: Revalidate crates.io ownership and publishing policy
|
||||
run: |
|
||||
python3 cmux-tui/bindings/verify_crates_ownership.py \
|
||||
--package cmux-client \
|
||||
--package cmux-sdk \
|
||||
--package cmux-sidebar \
|
||||
--repository https://github.com/manaflow-ai/cmux \
|
||||
--owner-id 431397 \
|
||||
@@ -1541,7 +1541,7 @@ jobs:
|
||||
- revalidate-tags
|
||||
- cut-tags
|
||||
- verify-go-tag
|
||||
- publish-crate-client
|
||||
- publish-crate-sdk
|
||||
- publish-crate-sidebar
|
||||
- publish-npm
|
||||
- publish-python-wheel
|
||||
@@ -1564,7 +1564,7 @@ jobs:
|
||||
REVALIDATE_TAGS_RESULT: ${{ needs.revalidate-tags.result }}
|
||||
CUT_TAGS_RESULT: ${{ needs.cut-tags.result }}
|
||||
GO_TAG_RESULT: ${{ needs.verify-go-tag.result }}
|
||||
CRATE_CLIENT_RESULT: ${{ needs.publish-crate-client.result }}
|
||||
CRATE_SDK_RESULT: ${{ needs.publish-crate-sdk.result }}
|
||||
CRATE_SIDEBAR_RESULT: ${{ needs.publish-crate-sidebar.result }}
|
||||
NPM_RESULT: ${{ needs.publish-npm.result }}
|
||||
PYTHON_WHEEL_RESULT: ${{ needs.publish-python-wheel.result }}
|
||||
@@ -1585,7 +1585,7 @@ jobs:
|
||||
echo "- Approval-fresh revalidation: \`$REVALIDATE_TAGS_RESULT\`"
|
||||
echo "- Tag creation: \`$CUT_TAGS_RESULT\`"
|
||||
echo "- Public Go tag: \`$GO_TAG_RESULT\`"
|
||||
echo "- cmux-client publish: \`$CRATE_CLIENT_RESULT\`"
|
||||
echo "- cmux-sdk publish: \`$CRATE_SDK_RESULT\`"
|
||||
echo "- cmux-sidebar publish: \`$CRATE_SIDEBAR_RESULT\`"
|
||||
echo "- npm publish: \`$NPM_RESULT\`"
|
||||
echo "- PyPI wheel publish: \`$PYTHON_WHEEL_RESULT\`"
|
||||
|
||||
@@ -162,6 +162,10 @@ jobs:
|
||||
run: |
|
||||
swift test --package-path Packages/iOS/CmuxMobilePairedMac
|
||||
|
||||
- name: Run CmuxMobileChanges package tests
|
||||
run: |
|
||||
swift test --package-path Packages/iOS/CmuxMobileChanges
|
||||
|
||||
- name: Run CmuxMobileShell package tests
|
||||
run: |
|
||||
# iOS shell replay/liveness regressions live in this package target.
|
||||
|
||||
@@ -189,6 +189,26 @@ enum AgentHookNotificationClassifier {
|
||||
enum AgentHookNotificationPolicy {
|
||||
static let dedupeEligibleAgents: Set<String> = ["grok", "antigravity"]
|
||||
|
||||
static func notificationTitle(
|
||||
agentName: String,
|
||||
displayName: String,
|
||||
surfaceTitle: String?
|
||||
) -> String {
|
||||
guard agentName == "pi",
|
||||
let surfaceTitle = surfaceTitle?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!surfaceTitle.isEmpty else {
|
||||
return displayName
|
||||
}
|
||||
if surfaceTitle.caseInsensitiveCompare(displayName) == .orderedSame
|
||||
|| surfaceTitle.range(
|
||||
of: "\(displayName) · ",
|
||||
options: [.anchored, .caseInsensitive]
|
||||
) != nil {
|
||||
return surfaceTitle
|
||||
}
|
||||
return "\(displayName) · \(surfaceTitle)"
|
||||
}
|
||||
|
||||
/// Stable per-session fingerprint. Grok 0.2.91 emits an identical generic
|
||||
/// "Tool permission requested" Notification for every tool step, even in
|
||||
/// auto-approve mode where nothing awaits the user; those repeats dedupe by
|
||||
|
||||
@@ -141,6 +141,26 @@ struct AutoNamingEnvironmentPolicy: Sendable {
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return override.isEmpty ? "haiku" : override
|
||||
}
|
||||
|
||||
/// Inline MCP configuration passed with `--strict-mcp-config` so the
|
||||
/// summarizer starts no MCP servers. Claude Code validates this JSON
|
||||
/// against a schema requiring an `mcpServers` record, so a bare `{}` is
|
||||
/// rejected during argument parsing and the subprocess exits before it
|
||||
/// can produce a title (cmux#9457).
|
||||
static let emptyMCPConfigJSON = #"{"mcpServers":{}}"#
|
||||
|
||||
/// Argument vector for the tool-disabled `claude -p` summarizer call.
|
||||
func claudeSummarizerArguments(from env: [String: String]) -> [String] {
|
||||
[
|
||||
"-p",
|
||||
"--model", claudeModel(from: env),
|
||||
"--tools", "",
|
||||
"--disable-slash-commands",
|
||||
"--no-session-persistence",
|
||||
"--strict-mcp-config",
|
||||
"--mcp-config", Self.emptyMCPConfigJSON
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure auto-naming logic: throttle decisions, transcript extraction,
|
||||
|
||||
@@ -123,15 +123,7 @@ extension CMUXCLI {
|
||||
guard let executable else { return nil }
|
||||
return runAutoNamingSummarizer(
|
||||
executable: executable,
|
||||
arguments: [
|
||||
"-p",
|
||||
"--model", policy.claudeModel(from: env),
|
||||
"--tools", "",
|
||||
"--disable-slash-commands",
|
||||
"--no-session-persistence",
|
||||
"--strict-mcp-config",
|
||||
"--mcp-config", "{}"
|
||||
],
|
||||
arguments: policy.claudeSummarizerArguments(from: env),
|
||||
prompt: prompt,
|
||||
environment: policy.summarizerEnvironment(from: env),
|
||||
timeout: timeout
|
||||
|
||||
+94
-20
@@ -3,6 +3,7 @@ import Darwin
|
||||
import Foundation
|
||||
|
||||
private struct EventStreamLimitReached: Error {}
|
||||
private struct EventStreamSnapshotCaptured: Error {}
|
||||
|
||||
extension CMUXCLI {
|
||||
private struct EventsCommandOptions {
|
||||
@@ -12,6 +13,8 @@ extension CMUXCLI {
|
||||
var categories: [String] = []
|
||||
var reconnect = false
|
||||
var limit: Int?
|
||||
var timeout: TimeInterval?
|
||||
var snapshotOnly = false
|
||||
var printAck = true
|
||||
var printHeartbeats = true
|
||||
}
|
||||
@@ -28,15 +31,56 @@ extension CMUXCLI {
|
||||
|
||||
var lastSeq = options.afterSeq
|
||||
var emittedEvents = 0
|
||||
// The --timeout budget is measured on a MONOTONIC clock so a
|
||||
// wall-clock change (NTP step, timezone, manual set) can neither
|
||||
// expire the whole command instantly nor extend it indefinitely.
|
||||
// The socket layer takes wall-clock Dates, so each blocking call
|
||||
// derives a fresh short-lived Date from the monotonic remainder;
|
||||
// a wall jump can then only skew the single wait in flight, never
|
||||
// the accumulated budget.
|
||||
let budgetClock = ContinuousClock()
|
||||
let budgetDeadline = options.timeout.map { budgetClock.now.advanced(by: .seconds($0)) }
|
||||
func remainingBudget() -> TimeInterval? {
|
||||
guard let budgetDeadline else { return nil }
|
||||
let remaining = budgetClock.now.duration(to: budgetDeadline)
|
||||
let seconds = Double(remaining.components.seconds)
|
||||
+ Double(remaining.components.attoseconds) / 1e18
|
||||
return max(0, seconds)
|
||||
}
|
||||
func socketDeadline() -> Date? {
|
||||
remainingBudget().map { Date(timeIntervalSinceNow: $0) }
|
||||
}
|
||||
func timeoutError() -> CLIError {
|
||||
CLIError(message: String(
|
||||
localized: "cli.events.error.timeout",
|
||||
defaultValue: "Timed out waiting for a matching event"
|
||||
))
|
||||
}
|
||||
|
||||
while true {
|
||||
if let remaining = remainingBudget(), remaining <= 0 {
|
||||
throw timeoutError()
|
||||
}
|
||||
let client = SocketClient(path: socketPath)
|
||||
do {
|
||||
try client.connect()
|
||||
if let connectDeadline = socketDeadline() {
|
||||
try client.connect(deadline: connectDeadline)
|
||||
} else {
|
||||
try client.connect()
|
||||
}
|
||||
// Connection setup may have consumed the rest of the budget;
|
||||
// re-check before starting authentication so it always gets a
|
||||
// non-negative timeout.
|
||||
let authRemaining = remainingBudget()
|
||||
if let authRemaining, authRemaining <= 0 {
|
||||
throw timeoutError()
|
||||
}
|
||||
try authenticateClientIfNeeded(
|
||||
client,
|
||||
explicitPassword: explicitPassword,
|
||||
socketPath: socketPath
|
||||
socketPath: socketPath,
|
||||
responseTimeout: authRemaining,
|
||||
deadline: socketDeadline()
|
||||
)
|
||||
|
||||
var params: [String: Any] = [
|
||||
@@ -52,7 +96,11 @@ extension CMUXCLI {
|
||||
params["categories"] = options.categories
|
||||
}
|
||||
|
||||
try client.streamV2(method: "events.stream", params: params) { line in
|
||||
try client.streamV2(
|
||||
method: "events.stream",
|
||||
params: params,
|
||||
deadline: socketDeadline()
|
||||
) { line in
|
||||
guard !line.isEmpty else { return }
|
||||
let frame = try parseEventStreamFrame(line)
|
||||
let type = frame["type"] as? String ?? ""
|
||||
@@ -67,15 +115,17 @@ extension CMUXCLI {
|
||||
eventSequence = nil
|
||||
}
|
||||
|
||||
if type == "ack", !options.printAck {
|
||||
return
|
||||
}
|
||||
if type == "heartbeat", !options.printHeartbeats {
|
||||
return
|
||||
let shouldPrint =
|
||||
(type != "ack" || options.printAck)
|
||||
&& (type != "heartbeat" || options.printHeartbeats)
|
||||
if shouldPrint {
|
||||
print(line)
|
||||
fflush(stdout)
|
||||
}
|
||||
|
||||
print(line)
|
||||
fflush(stdout)
|
||||
if type == "ack", options.snapshotOnly {
|
||||
throw EventStreamSnapshotCaptured()
|
||||
}
|
||||
|
||||
if let eventSequence {
|
||||
if let cursorFile = options.cursorFile {
|
||||
@@ -88,15 +138,25 @@ extension CMUXCLI {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch is EventStreamSnapshotCaptured {
|
||||
client.close()
|
||||
return
|
||||
} catch is EventStreamLimitReached {
|
||||
client.close()
|
||||
return
|
||||
} catch {
|
||||
client.close()
|
||||
if let remaining = remainingBudget(), remaining <= 0 {
|
||||
throw timeoutError()
|
||||
}
|
||||
guard options.reconnect, isTransientEventStreamError(error) else {
|
||||
throw error
|
||||
}
|
||||
waitBeforeReconnectingEventStream()
|
||||
let remaining = remainingBudget() ?? 1
|
||||
guard remaining > 0 else {
|
||||
throw timeoutError()
|
||||
}
|
||||
waitBeforeReconnectingEventStream(maximumDelay: remaining)
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -133,15 +193,16 @@ extension CMUXCLI {
|
||||
|| description.contains("timed out")
|
||||
}
|
||||
|
||||
func waitBeforeReconnectingEventStream() {
|
||||
let deadline = Date(timeIntervalSinceNow: 1.0)
|
||||
var didFire = false
|
||||
let timer = Timer(timeInterval: 1.0, repeats: false) { _ in
|
||||
didFire = true
|
||||
}
|
||||
RunLoop.current.add(timer, forMode: .default)
|
||||
while !didFire, RunLoop.current.run(mode: .default, before: deadline) {}
|
||||
timer.invalidate()
|
||||
func waitBeforeReconnectingEventStream(maximumDelay: TimeInterval = 1) {
|
||||
let delay = min(1, max(0, maximumDelay))
|
||||
guard delay > 0 else { return }
|
||||
// This retry path runs on the CLI's synchronous command thread, which
|
||||
// pumps no run loop: a Timer + RunLoop.run() wait can spin or park
|
||||
// with `didFire` as its only exit. A bounded thread sleep is the
|
||||
// deterministic wait; the caller already clamps the delay to the
|
||||
// command's remaining --timeout budget, and killing the process (the
|
||||
// CLI's only cancellation) interrupts it.
|
||||
Thread.sleep(forTimeInterval: delay)
|
||||
}
|
||||
|
||||
private func parseEventsOptions(_ args: [String]) throws -> EventsCommandOptions {
|
||||
@@ -178,6 +239,19 @@ extension CMUXCLI {
|
||||
throw CLIError(message: "--limit must be greater than 0")
|
||||
}
|
||||
options.limit = limit
|
||||
case "--timeout":
|
||||
let raw = try requireValue()
|
||||
guard let timeout = TimeInterval(raw),
|
||||
timeout.isFinite,
|
||||
timeout > 0 else {
|
||||
throw CLIError(message: String(
|
||||
localized: "cli.events.error.invalidTimeout",
|
||||
defaultValue: "--timeout must be greater than 0"
|
||||
))
|
||||
}
|
||||
options.timeout = timeout
|
||||
case "--snapshot":
|
||||
options.snapshotOnly = true
|
||||
case "--no-ack":
|
||||
options.printAck = false
|
||||
case "--no-heartbeat", "--no-heartbeats":
|
||||
|
||||
@@ -85,7 +85,14 @@ extension CMUXCLI {
|
||||
let candidate = URL(fileURLWithPath: entry, isDirectory: true)
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
.path
|
||||
guard FileManager.default.isExecutableFile(atPath: candidate) else { continue }
|
||||
// `isExecutableFile(atPath:)` is true for directories, so a directory named
|
||||
// like the provider binary would otherwise shadow the real executable and
|
||||
// fail at execv (#8743). Reject directories the way the configured-candidate
|
||||
// path in `resolveClaudeExecutable` already does.
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: candidate, isDirectory: &isDirectory),
|
||||
!isDirectory.boolValue,
|
||||
FileManager.default.isExecutableFile(atPath: candidate) else { continue }
|
||||
guard !isBundledProviderExecutable(at: candidate) else { continue }
|
||||
if let skip, skip(candidate) { continue }
|
||||
return candidate
|
||||
|
||||
@@ -17,6 +17,7 @@ interface PendingCompletion {
|
||||
lastAssistantMessage?: string;
|
||||
notificationType: string;
|
||||
turnId: string;
|
||||
suppressNotification: boolean;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
@@ -375,18 +376,38 @@ function textFromContent(content: unknown): string | null {
|
||||
return parts.join("\n") || null;
|
||||
}
|
||||
|
||||
function lastAssistantMessage(event: unknown): string | undefined {
|
||||
interface AssistantCompletion {
|
||||
lastAssistantMessage?: string;
|
||||
suppressNotification: boolean;
|
||||
}
|
||||
|
||||
function assistantCompletionFrom(event: unknown): AssistantCompletion {
|
||||
const messagesValue = objectValue(event, ["messages"]);
|
||||
const messages = Array.isArray(messagesValue) ? messagesValue : [];
|
||||
let suppressNotification = false;
|
||||
let inspectedLatestAssistant = false;
|
||||
// Resolve text and interruption metadata in one reverse pass. agent_end may
|
||||
// carry a large message array, so notification support must not rescan it.
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (!message || typeof message !== "object") continue;
|
||||
const typed = message as { role?: unknown; content?: unknown };
|
||||
const typed = message as {
|
||||
role?: unknown;
|
||||
content?: unknown;
|
||||
stopReason?: unknown;
|
||||
cmuxSuppressNotification?: unknown;
|
||||
};
|
||||
if (typed.role !== "assistant") continue;
|
||||
if (!inspectedLatestAssistant) {
|
||||
// Input extensions may normalize an abort to `stop` to keep Pi's UI quiet;
|
||||
// the marker preserves the interruption intent across that normalization.
|
||||
suppressNotification = typed.stopReason === "aborted" || typed.cmuxSuppressNotification === true;
|
||||
inspectedLatestAssistant = true;
|
||||
}
|
||||
const text = firstString(textFromContent(typed.content));
|
||||
if (text) return text;
|
||||
if (text) return { lastAssistantMessage: text, suppressNotification };
|
||||
}
|
||||
return undefined;
|
||||
return { suppressNotification };
|
||||
}
|
||||
|
||||
function sessionIdFrom(ctx: ExtensionContext): string | null {
|
||||
|
||||
@@ -353,7 +353,11 @@ async function publishPendingCompletion(
|
||||
last_assistant_message: completion.lastAssistantMessage,
|
||||
turn_id: completion.turnId,
|
||||
};
|
||||
if (feedDelivered) {
|
||||
if (completion.suppressNotification) {
|
||||
// Stop normally creates cmux's native fallback notification when no explicit
|
||||
// notification was routed. Mark intentional interruption as already handled.
|
||||
stopPayload.cmux_notification_routed = true;
|
||||
} else if (feedDelivered) {
|
||||
const notificationRouted = await sendHook(dispatcher, "notification", context, {
|
||||
message: completion.lastAssistantMessage || "Task completed",
|
||||
turn_id: completion.turnId,
|
||||
@@ -453,12 +457,13 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) {
|
||||
const sessionId = context.sessionId;
|
||||
if (!sessionId) return;
|
||||
const state = stateFor(sessionStates, sessionId);
|
||||
const message = lastAssistantMessage(event);
|
||||
const assistantCompletion = assistantCompletionFrom(event);
|
||||
// Preserve the latest low-level result until Pi confirms no automatic work remains.
|
||||
state.pendingCompletion = {
|
||||
lastAssistantMessage: message || state.pendingCompletion?.lastAssistantMessage,
|
||||
lastAssistantMessage: assistantCompletion.lastAssistantMessage || state.pendingCompletion?.lastAssistantMessage,
|
||||
notificationType: firstString(objectValue(event, ["stopReason", "reason", "terminationReason"])) || "completed",
|
||||
turnId: currentTurnId(sessionStates, sessionId, event),
|
||||
suppressNotification: assistantCompletion.suppressNotification,
|
||||
};
|
||||
// Older Pi versions do not emit agent_settled, so retain their established completion behavior.
|
||||
if (!supportsAgentSettled()) {
|
||||
|
||||
+133
-18
@@ -1795,6 +1795,7 @@ final class SocketClient {
|
||||
|
||||
private let path: String
|
||||
private(set) var socketFD: Int32 = -1
|
||||
private var streamReadBuffer = Data()
|
||||
private var lastConfiguredReceiveTimeout: TimeInterval?
|
||||
private var lastOperationTelemetry: CLISocketOperationTelemetry.State?
|
||||
private static let defaultResponseTimeoutSeconds: TimeInterval = 15.0
|
||||
@@ -1959,6 +1960,7 @@ final class SocketClient {
|
||||
Darwin.close(socketFD)
|
||||
socketFD = -1
|
||||
}
|
||||
streamReadBuffer.removeAll(keepingCapacity: true)
|
||||
lastConfiguredReceiveTimeout = nil
|
||||
}
|
||||
|
||||
@@ -2943,6 +2945,7 @@ final class SocketClient {
|
||||
func streamV2(
|
||||
method: String,
|
||||
params: [String: Any] = [:],
|
||||
deadline: Date? = nil,
|
||||
onLine: (String) throws -> Void
|
||||
) throws {
|
||||
guard socketFD >= 0 else { throw CLIError(message: "Not connected") }
|
||||
@@ -2960,26 +2963,60 @@ final class SocketClient {
|
||||
try writeAll(
|
||||
Data((capabilityWrappedCommand(requestLine) + "\n").utf8),
|
||||
timeoutMessage: "Stream request timed out",
|
||||
failureMessage: "Failed to write stream request"
|
||||
failureMessage: "Failed to write stream request",
|
||||
deadline: deadline
|
||||
)
|
||||
|
||||
while true {
|
||||
let line = try readStreamLine()
|
||||
let line = try readStreamLine(deadline: deadline)
|
||||
try onLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
private func readStreamLine(maxBytes: Int = 4 * 1024 * 1024) throws -> String {
|
||||
var data = Data()
|
||||
try configureReceiveTimeout(45)
|
||||
while data.count < maxBytes {
|
||||
var byte: UInt8 = 0
|
||||
let count = Darwin.read(socketFD, &byte, 1)
|
||||
private func readStreamLine(
|
||||
maxBytes: Int = 4 * 1024 * 1024,
|
||||
deadline: Date? = nil
|
||||
) throws -> String {
|
||||
if deadline == nil {
|
||||
try configureReceiveTimeout(45)
|
||||
}
|
||||
while true {
|
||||
if let newlineIndex = streamReadBuffer.firstIndex(of: 0x0A) {
|
||||
let lineByteCount = streamReadBuffer.distance(
|
||||
from: streamReadBuffer.startIndex,
|
||||
to: newlineIndex
|
||||
)
|
||||
guard lineByteCount < maxBytes else {
|
||||
throw CLIError(message: "Event stream frame exceeded \(maxBytes) bytes")
|
||||
}
|
||||
let lineData = streamReadBuffer[..<newlineIndex]
|
||||
guard let line = String(data: Data(lineData), encoding: .utf8) else {
|
||||
throw CLIError(message: "Invalid UTF-8 event stream frame")
|
||||
}
|
||||
streamReadBuffer.removeSubrange(...newlineIndex)
|
||||
return line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
guard streamReadBuffer.count < maxBytes else {
|
||||
throw CLIError(message: "Event stream frame exceeded \(maxBytes) bytes")
|
||||
}
|
||||
if let deadline {
|
||||
try waitForReadableStream(deadline: deadline)
|
||||
}
|
||||
var chunk = [UInt8](repeating: 0, count: 8 * 1_024)
|
||||
let count = chunk.withUnsafeMutableBytes { bytes in
|
||||
Darwin.read(socketFD, bytes.baseAddress, bytes.count)
|
||||
}
|
||||
if count < 0 {
|
||||
if errno == EINTR {
|
||||
continue
|
||||
}
|
||||
if errno == EAGAIN || errno == EWOULDBLOCK {
|
||||
if let deadline {
|
||||
guard deadline.timeIntervalSinceNow > 0 else {
|
||||
throw CLIError(message: "Event stream deadline exceeded")
|
||||
}
|
||||
continue
|
||||
}
|
||||
throw CLIError(message: "Timed out waiting for event stream frame")
|
||||
}
|
||||
throw CLIError(message: "Event stream socket read error")
|
||||
@@ -2987,15 +3024,36 @@ final class SocketClient {
|
||||
if count == 0 {
|
||||
throw CLIError(message: "Event stream closed")
|
||||
}
|
||||
if byte == 0x0A {
|
||||
guard let line = String(data: data, encoding: .utf8) else {
|
||||
throw CLIError(message: "Invalid UTF-8 event stream frame")
|
||||
}
|
||||
return line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
streamReadBuffer.append(contentsOf: chunk.prefix(count))
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForReadableStream(deadline: Date) throws {
|
||||
while true {
|
||||
let remaining = deadline.timeIntervalSinceNow
|
||||
guard remaining > 0 else {
|
||||
throw CLIError(message: "Event stream deadline exceeded")
|
||||
}
|
||||
data.append(byte)
|
||||
var descriptor = pollfd(fd: socketFD, events: Int16(POLLIN), revents: 0)
|
||||
let timeoutMilliseconds = min(
|
||||
max(Int(ceil(remaining * 1_000)), 0),
|
||||
Int(Int32.max)
|
||||
)
|
||||
let ready = Darwin.poll(
|
||||
&descriptor,
|
||||
1,
|
||||
Int32(timeoutMilliseconds)
|
||||
)
|
||||
if ready > 0 {
|
||||
return
|
||||
}
|
||||
if ready == 0 {
|
||||
throw CLIError(message: "Event stream deadline exceeded")
|
||||
}
|
||||
if errno != EINTR {
|
||||
throw CLIError(message: "Event stream socket read error")
|
||||
}
|
||||
}
|
||||
throw CLIError(message: "Event stream frame exceeded \(maxBytes) bytes")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7093,6 +7151,17 @@ struct CMUXCLI {
|
||||
if shellCommand != nil, let unexpected = (remaining + (splitArgs.argv ?? [])).first {
|
||||
throw CLIError(message: "surface resume set: unexpected argument '\(unexpected)' after --shell. Quote the full shell command or use -- <argv...>")
|
||||
}
|
||||
if let unknownFlag = remaining.first(where: { $0.hasPrefix("-") && $0 != "-" }) {
|
||||
let knownFlags = Self.surfaceResumeSetValueOptions.sorted().joined(separator: ", ")
|
||||
throw CLIError(message: String(
|
||||
format: String(
|
||||
localized: "cli.surfaceResume.set.error.unknownFlag",
|
||||
defaultValue: "surface resume set: unknown flag '%1$@'. Known flags: %2$@. Use -- <argv...> for command arguments."
|
||||
),
|
||||
unknownFlag,
|
||||
knownFlags
|
||||
))
|
||||
}
|
||||
if splitArgs.argv != nil, let unexpected = remaining.first {
|
||||
throw CLIError(message: "surface resume set: unexpected argument '\(unexpected)' before --")
|
||||
}
|
||||
@@ -15575,6 +15644,14 @@ struct CMUXCLI {
|
||||
case "ios":
|
||||
return iosSubcommandUsage()
|
||||
case "events":
|
||||
let timeoutDescription = String(
|
||||
localized: "cli.events.help.timeout",
|
||||
defaultValue: "Exit unsuccessfully if no matching event arrives before the deadline"
|
||||
)
|
||||
let snapshotDescription = String(
|
||||
localized: "cli.events.help.snapshot",
|
||||
defaultValue: "Print the subscription snapshot and exit"
|
||||
)
|
||||
return """
|
||||
Usage: cmux events [options]
|
||||
|
||||
@@ -15587,6 +15664,8 @@ struct CMUXCLI {
|
||||
--category <name> Filter by category, repeatable
|
||||
--reconnect Reconnect forever and resume from the last received sequence
|
||||
--limit <n> Exit after printing n event frames
|
||||
--timeout <seconds> \(timeoutDescription)
|
||||
--snapshot \(snapshotDescription)
|
||||
--no-ack Do not print the subscription ack frame
|
||||
--no-heartbeat Do not print heartbeat frames
|
||||
|
||||
@@ -22471,7 +22550,12 @@ struct CMUXCLI {
|
||||
let candidate = URL(fileURLWithPath: entry, isDirectory: true)
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
.path
|
||||
if FileManager.default.isExecutableFile(atPath: candidate) {
|
||||
// `isExecutableFile(atPath:)` is true for directories, so a directory named
|
||||
// like the binary would otherwise shadow the real executable (#8743).
|
||||
var isDirectory: ObjCBool = false
|
||||
if FileManager.default.fileExists(atPath: candidate, isDirectory: &isDirectory),
|
||||
!isDirectory.boolValue,
|
||||
FileManager.default.isExecutableFile(atPath: candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
@@ -31023,6 +31107,27 @@ export default CMUXSessionRestore;
|
||||
body: body
|
||||
)
|
||||
}
|
||||
func notificationTitle(workspaceId: String, surfaceId: String) -> String {
|
||||
let surfaceTitle: String? = {
|
||||
guard def.name == "pi",
|
||||
let listed = try? client.sendV2(
|
||||
method: "surface.list",
|
||||
params: ["workspace_id": workspaceId]
|
||||
),
|
||||
let surfaces = listed["surfaces"] as? [[String: Any]],
|
||||
let surface = surfaces.first(where: {
|
||||
surfaceHandleMatches(surfaceId, item: $0)
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
return surface["title"] as? String
|
||||
}()
|
||||
return AgentHookNotificationPolicy.notificationTitle(
|
||||
agentName: def.name,
|
||||
displayName: def.displayName,
|
||||
surfaceTitle: surfaceTitle
|
||||
)
|
||||
}
|
||||
func hasActiveAntigravityBackgroundWork() -> Bool {
|
||||
def.name == "antigravity" && (input.rawObject?["fullyIdle"] as? Bool) == false
|
||||
}
|
||||
@@ -31903,7 +32008,12 @@ export default CMUXSessionRestore;
|
||||
let stopMeta: String? = stopNotificationStatus == .idle
|
||||
? AgentHookNotifyCategory.turnComplete.metaSegment(pending: antigravityHasActiveBackgroundWork)
|
||||
: nil
|
||||
let payload = notificationPayload(title: def.displayName, subtitle: subtitle, body: body, meta: stopMeta)
|
||||
let payload = notificationPayload(
|
||||
title: notificationTitle(workspaceId: workspaceId, surfaceId: surfaceId),
|
||||
subtitle: subtitle,
|
||||
body: body,
|
||||
meta: stopMeta
|
||||
)
|
||||
let notifyCommand = "notify_target_async \(workspaceId) \(surfaceId) \(payload)"
|
||||
#if DEBUG
|
||||
agentHookDebugLog(
|
||||
@@ -32302,7 +32412,12 @@ export default CMUXSessionRestore;
|
||||
pending: (summary.notifyCategory == .turnComplete || summary.notifyCategory == .idleReminder)
|
||||
&& hasActiveAntigravityBackgroundWork()
|
||||
)
|
||||
let payload = notificationPayload(title: def.displayName, subtitle: summary.subtitle, body: summary.body, meta: notificationMeta)
|
||||
let payload = notificationPayload(
|
||||
title: notificationTitle(workspaceId: workspaceId, surfaceId: surfaceId),
|
||||
subtitle: summary.subtitle,
|
||||
body: summary.body,
|
||||
meta: notificationMeta
|
||||
)
|
||||
let notifyCommand = "notify_target_async \(workspaceId) \(surfaceId) \(payload)"
|
||||
#if DEBUG
|
||||
agentHookDebugLog(
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# CMUXMobileCore
|
||||
|
||||
Shared protocol seams and value types used by both the iOS and macOS apps.
|
||||
Higher-level mobile packages depend on this package instead of importing one
|
||||
another for shared contracts.
|
||||
|
||||
## Testing telemetry consent
|
||||
|
||||
Inject a suite-scoped defaults store so tests do not read or mutate the user's
|
||||
preferences:
|
||||
|
||||
```swift
|
||||
let defaults = UserDefaults(suiteName: "example.telemetry-test")!
|
||||
let consent = UserDefaultsAnalyticsConsentProvider(defaults: defaults)
|
||||
|
||||
defaults.set(true, forKey: UserDefaultsAnalyticsConsentProvider.telemetryKey)
|
||||
#expect(consent.isTelemetryEnabled)
|
||||
```
|
||||
@@ -0,0 +1,12 @@
|
||||
/// The shared opt-out gate consulted before sending telemetry.
|
||||
///
|
||||
/// Analytics and crash-reporting infrastructure depend on this lower-level
|
||||
/// seam so both obey the same live consent source without depending on each
|
||||
/// other.
|
||||
public protocol AnalyticsConsentProviding: Sendable {
|
||||
/// Whether anonymous product telemetry may currently be sent.
|
||||
///
|
||||
/// A conformer must return its current value on every read so consent
|
||||
/// changes take effect without rebuilding the telemetry graph.
|
||||
var isTelemetryEnabled: Bool { get }
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
/// The minimal pairing-QR grammars for Iroh identity and Tailscale routes.
|
||||
///
|
||||
/// Current Iroh codes carry only the stable EndpointID:
|
||||
/// Retained Iroh codes carry only the stable EndpointID:
|
||||
/// `cmux-ios://attach?v=3&i=<endpoint-id>`.
|
||||
///
|
||||
/// The EndpointID is the only value the phone needs before dialing. The
|
||||
@@ -41,13 +41,12 @@ import Foundation
|
||||
/// Plain text is also smaller, which lowers the QR version (fewer, larger
|
||||
/// modules) and makes the code scan faster from a Mac screen.
|
||||
///
|
||||
/// Compatibility: these grammars only ever appear in the Mac's pairing QR.
|
||||
/// v2 remains decodable; an older iPhone presented with a v3 Iroh code gets
|
||||
/// the existing update-app error and can use the Tailscale compatibility code
|
||||
/// when one is available. Workspace-scoped tickets, dev loopback tickets, and
|
||||
/// every RPC consumer
|
||||
/// keep the compact v1 JSON payload (``CmxAttachTicketCompactCoder``), and the
|
||||
/// decoder keeps accepting both that and the legacy full-key grammar.
|
||||
/// Compatibility: the Mac pairing window emits only a Tailscale pairing
|
||||
/// payload. v3 remains decodable for existing Iroh links and explicit
|
||||
/// device-attach flows. Workspace-scoped tickets, dev loopback tickets, and
|
||||
/// every RPC consumer keep the compact v1 JSON payload
|
||||
/// (``CmxAttachTicketCompactCoder``), and the decoder keeps accepting both that
|
||||
/// and the legacy full-key grammar.
|
||||
public struct CmxPairingQRCode: Sendable {
|
||||
/// The newest grammar version this build can decode.
|
||||
///
|
||||
|
||||
+5
-4
@@ -1,13 +1,14 @@
|
||||
/// The private-route disclosure policy for a scannable attach payload.
|
||||
///
|
||||
/// Callers must choose explicitly so adding a route to a ticket cannot silently
|
||||
/// add it to a QR code. The legacy mode exists only while released clients still
|
||||
/// require Tailscale host routes during the Iroh migration.
|
||||
/// add it to a QR code. The compatibility name is retained because its grammar
|
||||
/// remains readable by released clients; the Mac pairing window uses it only
|
||||
/// for the user-selected Tailscale path.
|
||||
public enum CmxPairingRouteDisclosureMode: Equatable, Sendable {
|
||||
/// Encode only Iroh EndpointIDs. All Iroh hints and every host/port or URL
|
||||
/// route are removed.
|
||||
case irohIdentityOnly
|
||||
/// Preserve the pre-Iroh compact route grammar for released clients.
|
||||
/// This may disclose private-network routes and must not become a default.
|
||||
/// Preserve the pre-Iroh compact route grammar for a Tailscale pairing
|
||||
/// code. This discloses the selected tailnet destination.
|
||||
case legacyPrivateNetworkCompatibility
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ import Foundation
|
||||
/// decode here with both intentionally dropped: a pairing QR never expires,
|
||||
/// and the Mac's name is read post-handshake from `mobile.host.status`.
|
||||
/// Compact Iroh fallbacks disclose only EndpointID identity. The primary
|
||||
/// scannable Iroh code uses ``CmxPairingQRCode`` instead; the explicit
|
||||
/// compatibility mode temporarily retains released clients' legacy routes.
|
||||
/// retained Iroh attach-code path uses ``CmxPairingQRCode`` instead; the
|
||||
/// explicit compatibility mode temporarily retains released clients' legacy
|
||||
/// routes.
|
||||
struct CompactAttachTicket: Codable {
|
||||
let v: Int
|
||||
let w: String?
|
||||
|
||||
@@ -128,10 +128,15 @@ public struct DiagnosticReport: Sendable, Codable, Equatable {
|
||||
}
|
||||
|
||||
/// The latest event that marks a failed connection/lifecycle milestone.
|
||||
/// A `cancelled` outcome is an abandoned attempt, not a failure: callers
|
||||
/// cancel dials on supersession and teardown, so surfacing one here would
|
||||
/// report routine churn as the connection's latest problem.
|
||||
public var lastFailureEvent: DiagnosticEvent? {
|
||||
events.last(where: { event in
|
||||
event.code.isDiagnosticFailure
|
||||
|| event.diagnosticFailureKind.map { $0 != .none } == true
|
||||
if let kind = event.diagnosticFailureKind {
|
||||
return kind != .none && kind != .cancelled
|
||||
}
|
||||
return event.code.isDiagnosticFailure
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +203,8 @@ public struct GroupSyncRecord: MobileSyncRecord {
|
||||
public let isCollapsed: Bool
|
||||
/// Whether the group is pinned on the Mac.
|
||||
public let isPinned: Bool
|
||||
/// SF Symbol rendered by the corresponding group row on the Mac.
|
||||
public let iconSymbol: String?
|
||||
/// The anchor workspace that owns this group.
|
||||
public let anchorWorkspaceID: String
|
||||
/// Position in the Mac's presented section order.
|
||||
@@ -219,6 +221,7 @@ public struct GroupSyncRecord: MobileSyncRecord {
|
||||
name: String,
|
||||
isCollapsed: Bool,
|
||||
isPinned: Bool,
|
||||
iconSymbol: String? = nil,
|
||||
anchorWorkspaceID: String,
|
||||
sortIndex: Int
|
||||
) {
|
||||
@@ -226,6 +229,7 @@ public struct GroupSyncRecord: MobileSyncRecord {
|
||||
self.name = name
|
||||
self.isCollapsed = isCollapsed
|
||||
self.isPinned = isPinned
|
||||
self.iconSymbol = iconSymbol
|
||||
self.anchorWorkspaceID = anchorWorkspaceID
|
||||
self.sortIndex = sortIndex
|
||||
}
|
||||
@@ -235,6 +239,7 @@ public struct GroupSyncRecord: MobileSyncRecord {
|
||||
case name
|
||||
case isCollapsed = "is_collapsed"
|
||||
case isPinned = "is_pinned"
|
||||
case iconSymbol = "icon_symbol"
|
||||
case anchorWorkspaceID = "anchor_workspace_id"
|
||||
case sortIndex = "sort_index"
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
public import Foundation
|
||||
|
||||
/// A consent provider backed by the shared telemetry opt-out in `UserDefaults`.
|
||||
///
|
||||
/// This provider reads the same backing key as the app's anonymous-telemetry
|
||||
/// setting. A missing value defaults to disabled, and every access reads the
|
||||
/// store again so live setting changes apply without rebuilding consumers.
|
||||
///
|
||||
/// ```swift
|
||||
/// let consent = UserDefaultsAnalyticsConsentProvider(defaults: .standard)
|
||||
/// if consent.isTelemetryEnabled {
|
||||
/// // Start telemetry infrastructure.
|
||||
/// }
|
||||
/// ```
|
||||
public struct UserDefaultsAnalyticsConsentProvider: AnalyticsConsentProviding {
|
||||
/// The `UserDefaults` key shared with the anonymous-telemetry setting.
|
||||
public static let telemetryKey = "sendAnonymousTelemetry"
|
||||
|
||||
// UserDefaults is Apple-documented thread-safe; OK to hold nonisolated.
|
||||
private nonisolated(unsafe) let defaults: UserDefaults
|
||||
|
||||
/// Creates a consent provider over the given defaults store.
|
||||
///
|
||||
/// - Parameter defaults: The store holding the opt-out flag. Inject a
|
||||
/// suite-scoped store in tests; the app uses `.standard`.
|
||||
public init(defaults: UserDefaults) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
/// Whether anonymous product telemetry is enabled in the defaults store.
|
||||
public var isTelemetryEnabled: Bool {
|
||||
defaults.object(forKey: Self.telemetryKey) as? Bool ?? false
|
||||
}
|
||||
}
|
||||
@@ -289,7 +289,6 @@ import os
|
||||
#expect(DiagnosticSessionLifecycleKind.runtimeReconfigured.rawValue == 9)
|
||||
#expect(DiagnosticSessionLifecycleKind.explicitlyInvalidated.rawValue == 10)
|
||||
#expect(DiagnosticSessionLifecycleKind.allPathsClosed.rawValue == 11)
|
||||
|
||||
#expect(DiagnosticPathKind(.unavailable) == .unknown)
|
||||
#expect(DiagnosticPathKind(.direct) == .direct)
|
||||
#expect(DiagnosticPathKind(.privateNetwork) == .privateNetwork)
|
||||
@@ -442,6 +441,38 @@ import os
|
||||
}
|
||||
}
|
||||
|
||||
@Test func cancelledDialOutcomesDoNotCountAsFailures() {
|
||||
let realFailure = DiagnosticEvent(
|
||||
code: .rpcFailed,
|
||||
tNanos: 2,
|
||||
b: DiagnosticFailureKind.protocolViolation.rawValue
|
||||
)
|
||||
let abandonedDial = DiagnosticEvent(
|
||||
code: .transportDialFailed,
|
||||
tNanos: 3,
|
||||
a: DiagnosticTransportKind.iroh.rawValue,
|
||||
b: DiagnosticFailureKind.cancelled.rawValue,
|
||||
c: 7
|
||||
)
|
||||
|
||||
let onlyAbandoned = DiagnosticReport(
|
||||
anchorWallNanos: 1_000_000_000,
|
||||
anchorMonotonicNanos: 1,
|
||||
events: [abandonedDial]
|
||||
)
|
||||
#expect(onlyAbandoned.lastFailureEvent == nil)
|
||||
#expect(onlyAbandoned.lastFailureKind == nil)
|
||||
#expect(onlyAbandoned.lastFailureDate == nil)
|
||||
|
||||
let abandonedAfterRealFailure = DiagnosticReport(
|
||||
anchorWallNanos: 1_000_000_000,
|
||||
anchorMonotonicNanos: 1,
|
||||
events: [realFailure, abandonedDial]
|
||||
)
|
||||
#expect(abandonedAfterRealFailure.lastFailureEvent == realFailure)
|
||||
#expect(abandonedAfterRealFailure.lastFailureKind == .protocolViolation)
|
||||
}
|
||||
|
||||
@Test func gatedDialRefusalsReportRouteGatedNotTimedOut() {
|
||||
// A connect-registry gate refusal is instantaneous and never touched
|
||||
// the network. It used to be classified as `.timedOut`, fabricating
|
||||
|
||||
+29
@@ -80,6 +80,35 @@ struct MobileStateSyncFrameCodingTests {
|
||||
#expect(!decoded.customDescriptionIsTruncated)
|
||||
}
|
||||
|
||||
@Test func groupRecordCarriesIconAndDecodesOlderFrames() throws {
|
||||
let group = GroupSyncRecord(
|
||||
id: "group-1",
|
||||
name: "Release",
|
||||
isCollapsed: false,
|
||||
isPinned: true,
|
||||
iconSymbol: "shippingbox.fill",
|
||||
anchorWorkspaceID: "workspace-1",
|
||||
sortIndex: 0
|
||||
)
|
||||
let object = try MobileSyncFrameCoder().jsonObject(from: group)
|
||||
#expect(object["icon_symbol"] as? String == "shippingbox.fill")
|
||||
|
||||
let decodedOlder = try MobileSyncFrameCoder().decode(
|
||||
GroupSyncRecord.self,
|
||||
fromJSONString: """
|
||||
{
|
||||
"id": "group-older",
|
||||
"name": "Older Mac",
|
||||
"is_collapsed": false,
|
||||
"is_pinned": false,
|
||||
"anchor_workspace_id": "workspace-older",
|
||||
"sort_index": 0
|
||||
}
|
||||
"""
|
||||
)
|
||||
#expect(decodedOlder.iconSymbol == nil)
|
||||
}
|
||||
|
||||
@Test func deltaEventRoundTripsThroughJSONObject() throws {
|
||||
let event = MobileSyncDeltaEvent(
|
||||
epoch: "e1",
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import CMUXMobileCore
|
||||
|
||||
@Suite struct UserDefaultsAnalyticsConsentProviderTests {
|
||||
@Test func defaultsOffAndTracksLiveChanges() throws {
|
||||
let suiteName = "cmux.analytics-consent.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let consent = UserDefaultsAnalyticsConsentProvider(defaults: defaults)
|
||||
#expect(!consent.isTelemetryEnabled)
|
||||
|
||||
defaults.set(true, forKey: UserDefaultsAnalyticsConsentProvider.telemetryKey)
|
||||
#expect(consent.isTelemetryEnabled)
|
||||
|
||||
defaults.set(false, forKey: UserDefaultsAnalyticsConsentProvider.telemetryKey)
|
||||
#expect(!consent.isTelemetryEnabled)
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -35,13 +35,23 @@ struct CmxAuthoritativeDiscoveryResolver: Sendable {
|
||||
let response = try await authority.syncConnectivity(
|
||||
knownRevision: cached?.revision
|
||||
)
|
||||
if let snapshot = response.snapshot {
|
||||
if let snapshot = response.snapshot,
|
||||
response.snapshotComplete == true {
|
||||
try Self.requireRevision(snapshot, atLeast: minimumRevision)
|
||||
if !response.reset {
|
||||
try Self.requireRevision(snapshot, atLeast: cached?.revision)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
if response.snapshot != nil {
|
||||
let discovery = try await broker.discover()
|
||||
try Self.requireRevision(discovery, atLeast: response.revision)
|
||||
try Self.requireRevision(discovery, atLeast: minimumRevision)
|
||||
if !response.reset {
|
||||
try Self.requireRevision(discovery, atLeast: cached?.revision)
|
||||
}
|
||||
return discovery
|
||||
}
|
||||
guard !response.reset,
|
||||
let cached,
|
||||
cached.revision == response.revision else {
|
||||
|
||||
+112
-22
@@ -13,6 +13,12 @@ public actor CmxConnectivityEngine {
|
||||
let task: Task<Void, any Error>
|
||||
}
|
||||
|
||||
private struct EndpointReadinessOperation {
|
||||
let id: UUID
|
||||
let revision: UInt64
|
||||
let task: Task<Void, any Error>
|
||||
}
|
||||
|
||||
private let supervisor: CmxIrohEndpointSupervisor
|
||||
private let contextProvider: (any CmxIrohClientContextProvider)?
|
||||
private let protocolConfiguration: CmxIrohProtocolConfiguration
|
||||
@@ -27,6 +33,7 @@ public actor CmxConnectivityEngine {
|
||||
private var routeRevision: UInt64?
|
||||
private var routeContent: CmxConnectivityRouteContent?
|
||||
private var endpointEventTask: Task<Void, Never>?
|
||||
private var endpointReadinessOperation: EndpointReadinessOperation?
|
||||
private var routeSyncOperation: RouteSyncOperation?
|
||||
private var peers: [CmxConnectivityPeerID: CmxConnectivityPeerSession] = [:]
|
||||
private var peerSnapshots: [CmxConnectivityPeerID: CmxConnectivityPeerSnapshot] = [:]
|
||||
@@ -199,9 +206,10 @@ public actor CmxConnectivityEngine {
|
||||
|
||||
/// Verifies the preserved endpoint after suspension and recreates it if stale.
|
||||
public func resume() async throws {
|
||||
guard desiredActive, phase == .active else {
|
||||
guard desiredActive else {
|
||||
throw CmxConnectivityEngineError.inactive
|
||||
}
|
||||
try await ensureEndpointReady()
|
||||
let revision = lifecycleRevision
|
||||
let endpoint = try await supervisor.ensureHealthy()
|
||||
guard desiredActive, lifecycleRevision == revision else {
|
||||
@@ -225,6 +233,8 @@ public actor CmxConnectivityEngine {
|
||||
publishSnapshot()
|
||||
endpointEventTask?.cancel()
|
||||
endpointEventTask = nil
|
||||
endpointReadinessOperation?.task.cancel()
|
||||
endpointReadinessOperation = nil
|
||||
routeSyncOperation?.task.cancel()
|
||||
routeSyncOperation = nil
|
||||
let stoppedNetworkObservers = networkObservers.values
|
||||
@@ -244,14 +254,24 @@ public actor CmxConnectivityEngine {
|
||||
///
|
||||
/// Peers whose material route content is unchanged keep their live
|
||||
/// sessions; every other peer is invalidated before the new revision
|
||||
/// becomes visible.
|
||||
/// becomes visible. Account route revisions are monotonic, so an older
|
||||
/// completion of an overlapping reconciliation cannot roll back a newer
|
||||
/// installed revision or its content baseline.
|
||||
public func didInstallRouteRevision(
|
||||
_ revision: UInt64,
|
||||
routes: CmxIrohDiscoveryResponse
|
||||
) async {
|
||||
if let routeRevision, revision < routeRevision { return }
|
||||
let content = CmxConnectivityRouteContent(snapshot: routes)
|
||||
guard routeRevision != revision else {
|
||||
routeContent = content
|
||||
// The recorded revision can lack a content baseline when a sync
|
||||
// stored it from an unchanged response without a snapshot. A
|
||||
// missing or differing baseline fails closed like any other
|
||||
// material change before the content becomes the baseline.
|
||||
if routeContent != content {
|
||||
await invalidatePeersSuperseded(by: content)
|
||||
routeContent = content
|
||||
}
|
||||
return
|
||||
}
|
||||
await invalidatePeersSuperseded(by: content)
|
||||
@@ -262,27 +282,21 @@ public actor CmxConnectivityEngine {
|
||||
|
||||
/// Returns the exact active local endpoint identity.
|
||||
public func localEndpointIdentity() async throws -> CmxIrohPeerIdentity {
|
||||
guard desiredActive, endpointGeneration != nil else {
|
||||
throw CmxConnectivityEngineError.inactive
|
||||
}
|
||||
try await ensureEndpointReady()
|
||||
let endpoint = try await supervisor.activeEndpoint()
|
||||
return await endpoint.identity()
|
||||
}
|
||||
|
||||
/// Returns the active endpoint's public reachability snapshot.
|
||||
public func endpointAddress() async throws -> CmxIrohEndpointAddress {
|
||||
guard desiredActive, endpointGeneration != nil else {
|
||||
throw CmxConnectivityEngineError.inactive
|
||||
}
|
||||
try await ensureEndpointReady()
|
||||
let endpoint = try await supervisor.activeEndpoint()
|
||||
return await endpoint.address()
|
||||
}
|
||||
|
||||
/// Returns raw local direct addresses for authenticated registration only.
|
||||
public func localDirectAddresses() async throws -> [String] {
|
||||
guard desiredActive, endpointGeneration != nil else {
|
||||
throw CmxConnectivityEngineError.inactive
|
||||
}
|
||||
try await ensureEndpointReady()
|
||||
let endpoint = try await supervisor.activeEndpoint()
|
||||
return await endpoint.localDirectAddresses()
|
||||
}
|
||||
@@ -296,6 +310,7 @@ public actor CmxConnectivityEngine {
|
||||
public func waitForUsableHomeRelay(
|
||||
timeout: Duration = .seconds(15)
|
||||
) async throws {
|
||||
try await ensureEndpointReady()
|
||||
try await supervisor.waitForUsableHomeRelay(timeout: timeout)
|
||||
}
|
||||
|
||||
@@ -420,6 +435,7 @@ public actor CmxConnectivityEngine {
|
||||
lane: CmxIrohLane,
|
||||
priority: Int32
|
||||
) async throws -> CmxIrohBidirectionalStream {
|
||||
try await ensureEndpointReady()
|
||||
let peer = try activePeer(for: request)
|
||||
return try await peer.openBidirectionalLane(
|
||||
for: request,
|
||||
@@ -432,6 +448,7 @@ public actor CmxConnectivityEngine {
|
||||
public func serverEventByteStream(
|
||||
for request: CmxByteTransportRequest
|
||||
) async throws -> CmxIndependentEventByteStream {
|
||||
try await ensureEndpointReady()
|
||||
let peer = try activePeer(for: request)
|
||||
return try await peer.serverEventByteStream(for: request)
|
||||
}
|
||||
@@ -452,6 +469,7 @@ public actor CmxConnectivityEngine {
|
||||
for request: CmxByteTransportRequest,
|
||||
ownerID: UUID
|
||||
) async throws -> any CmxConnectivitySession {
|
||||
try await ensureEndpointReady()
|
||||
let peer = try activePeer(for: request)
|
||||
return try await peer.acquireControl(for: request, ownerID: ownerID)
|
||||
}
|
||||
@@ -552,12 +570,80 @@ public actor CmxConnectivityEngine {
|
||||
return peer
|
||||
}
|
||||
|
||||
/// Waits for the desired-active endpoint to finish recovery before admitting
|
||||
/// endpoint consumers. The engine owns this barrier because it is the sole
|
||||
/// owner of both endpoint phase and the installed generation.
|
||||
private func ensureEndpointReady() async throws {
|
||||
try Task.checkCancellation()
|
||||
guard desiredActive else {
|
||||
throw CmxConnectivityEngineError.inactive
|
||||
}
|
||||
if phase == .active, endpointGeneration != nil { return }
|
||||
|
||||
let revision = lifecycleRevision
|
||||
let operation: EndpointReadinessOperation
|
||||
if let current = endpointReadinessOperation,
|
||||
current.revision == revision {
|
||||
operation = current
|
||||
} else {
|
||||
endpointReadinessOperation?.task.cancel()
|
||||
let id = UUID()
|
||||
let task = Task { [weak self] in
|
||||
guard let self else {
|
||||
throw CmxConnectivityEngineError.inactive
|
||||
}
|
||||
try await self.performEndpointReadiness(revision: revision)
|
||||
}
|
||||
operation = EndpointReadinessOperation(
|
||||
id: id,
|
||||
revision: revision,
|
||||
task: task
|
||||
)
|
||||
endpointReadinessOperation = operation
|
||||
}
|
||||
|
||||
do {
|
||||
try await operation.task.value
|
||||
if endpointReadinessOperation?.id == operation.id {
|
||||
endpointReadinessOperation = nil
|
||||
}
|
||||
} catch {
|
||||
if endpointReadinessOperation?.id == operation.id {
|
||||
endpointReadinessOperation = nil
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
try Task.checkCancellation()
|
||||
guard desiredActive,
|
||||
lifecycleRevision == revision,
|
||||
phase == .active,
|
||||
endpointGeneration != nil else {
|
||||
throw CmxConnectivityEngineError.superseded
|
||||
}
|
||||
}
|
||||
|
||||
private func performEndpointReadiness(revision: UInt64) async throws {
|
||||
let endpoint = try await supervisor.activate()
|
||||
guard desiredActive, lifecycleRevision == revision else {
|
||||
throw CmxConnectivityEngineError.superseded
|
||||
}
|
||||
try await installEndpoint(endpoint)
|
||||
try await reconcileRoutesPreservingVerifiedPolicy()
|
||||
guard desiredActive, lifecycleRevision == revision else {
|
||||
throw CmxConnectivityEngineError.superseded
|
||||
}
|
||||
phase = .active
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
private func recoverEndpointForServer(
|
||||
expectedGeneration: UInt64
|
||||
) async throws -> CmxIrohEndpointSnapshot {
|
||||
guard desiredActive, phase == .active else {
|
||||
guard desiredActive else {
|
||||
throw CmxConnectivityEngineError.inactive
|
||||
}
|
||||
try await ensureEndpointReady()
|
||||
let revision = lifecycleRevision
|
||||
let endpoint = try await supervisor.ensureHealthy()
|
||||
guard desiredActive, lifecycleRevision == revision else {
|
||||
@@ -625,15 +711,7 @@ public actor CmxConnectivityEngine {
|
||||
phase = .starting
|
||||
publishSnapshot()
|
||||
}
|
||||
do {
|
||||
try await reconcileRoutes()
|
||||
} catch {
|
||||
guard routeRevision != nil,
|
||||
CmxIrohTrustBrokerClientError
|
||||
.preservesVerifiedPolicyDuringRefresh(error) else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
try await reconcileRoutesPreservingVerifiedPolicy()
|
||||
guard desiredActive else { return }
|
||||
phase = .active
|
||||
publishSnapshot()
|
||||
@@ -651,6 +729,18 @@ public actor CmxConnectivityEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private func reconcileRoutesPreservingVerifiedPolicy() async throws {
|
||||
do {
|
||||
try await reconcileRoutes()
|
||||
} catch {
|
||||
guard routeRevision != nil,
|
||||
CmxIrohTrustBrokerClientError
|
||||
.preservesVerifiedPolicyDuringRefresh(error) else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func installEndpoint(
|
||||
_ endpoint: CmxIrohEndpointSnapshot
|
||||
) async throws {
|
||||
|
||||
+37
-6
@@ -186,10 +186,16 @@ actor CmxConnectivityPeerSession {
|
||||
}
|
||||
|
||||
if let installed = activeConnection {
|
||||
if installed.id != pending.id {
|
||||
await connected.close()
|
||||
if installed.id == pending.id {
|
||||
return installed.session
|
||||
}
|
||||
return installed.session
|
||||
if let winner = await settleRedundantDial(
|
||||
connected,
|
||||
installedID: installed.id
|
||||
) {
|
||||
return winner
|
||||
}
|
||||
continue redial
|
||||
}
|
||||
if await connected.isClosed() {
|
||||
await connected.close()
|
||||
@@ -205,10 +211,16 @@ actor CmxConnectivityPeerSession {
|
||||
// installing over it would leak its session and double-record
|
||||
// an established lifecycle for the same peer.
|
||||
if let installed = activeConnection {
|
||||
if installed.id != pending.id {
|
||||
await connected.close()
|
||||
if installed.id == pending.id {
|
||||
return installed.session
|
||||
}
|
||||
return installed.session
|
||||
if let winner = await settleRedundantDial(
|
||||
connected,
|
||||
installedID: installed.id
|
||||
) {
|
||||
return winner
|
||||
}
|
||||
continue redial
|
||||
}
|
||||
install(
|
||||
connected,
|
||||
@@ -278,6 +290,25 @@ actor CmxConnectivityPeerSession {
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
/// Closes a redundant dial that lost to an installed winner.
|
||||
///
|
||||
/// Closing suspends this actor, so the winner can be invalidated,
|
||||
/// replaced, or remotely closed before the close settles. Only a
|
||||
/// still-installed live winner may be handed out; a nil result means
|
||||
/// the caller must redial.
|
||||
private func settleRedundantDial(
|
||||
_ connected: any CmxConnectivitySession,
|
||||
installedID: UUID
|
||||
) async -> (any CmxConnectivitySession)? {
|
||||
await connected.close()
|
||||
guard let current = activeConnection,
|
||||
current.id == installedID,
|
||||
!(await current.session.isClosed()) else {
|
||||
return nil
|
||||
}
|
||||
return current.session
|
||||
}
|
||||
|
||||
private func install(
|
||||
_ connected: any CmxConnectivitySession,
|
||||
id: UUID,
|
||||
|
||||
+21
-6
@@ -7,10 +7,28 @@
|
||||
/// keep healthy sessions whose routes did not materially change.
|
||||
struct CmxConnectivityRouteContent: Equatable, Sendable {
|
||||
/// Trust material shared by every route in one account snapshot.
|
||||
///
|
||||
/// Relay fleet and verification key order carries no trust meaning, so
|
||||
/// both are canonicalized here and a reorder-only revision compares
|
||||
/// equal to the installed material.
|
||||
struct AccountMaterial: Equatable, Sendable {
|
||||
let relayFleet: [String]
|
||||
let lanRendezvous: CmxIrohLANRendezvous
|
||||
let grantVerificationKeys: CmxIrohGrantVerificationKeySet
|
||||
|
||||
init(snapshot: CmxIrohDiscoveryResponse) {
|
||||
relayFleet = snapshot.relayFleet.sorted()
|
||||
lanRendezvous = snapshot.lanRendezvous
|
||||
let keySet = snapshot.grantVerificationKeys
|
||||
grantVerificationKeys = CmxIrohGrantVerificationKeySet(
|
||||
version: keySet.version,
|
||||
currentKeyID: keySet.currentKeyID,
|
||||
keys: keySet.keys.sorted {
|
||||
($0.kid, $0.alg, $0.spkiDerBase64)
|
||||
< ($1.kid, $1.alg, $1.spkiDerBase64)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Admission-relevant material of one broker binding.
|
||||
@@ -30,7 +48,8 @@ struct CmxConnectivityRouteContent: Equatable, Sendable {
|
||||
platform = binding.platform
|
||||
identityGeneration = binding.identityGeneration
|
||||
pairingEnabled = binding.pairingEnabled
|
||||
capabilities = binding.capabilities
|
||||
// The admission policy reads capabilities with set semantics.
|
||||
capabilities = binding.capabilities.sorted()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,11 +57,7 @@ struct CmxConnectivityRouteContent: Equatable, Sendable {
|
||||
private let peerRoutes: [CmxConnectivityPeerID: [BindingMaterial]]
|
||||
|
||||
init(snapshot: CmxIrohDiscoveryResponse) {
|
||||
account = AccountMaterial(
|
||||
relayFleet: snapshot.relayFleet,
|
||||
lanRendezvous: snapshot.lanRendezvous,
|
||||
grantVerificationKeys: snapshot.grantVerificationKeys
|
||||
)
|
||||
account = AccountMaterial(snapshot: snapshot)
|
||||
var routes: [CmxConnectivityPeerID: [BindingMaterial]] = [:]
|
||||
for binding in snapshot.bindings {
|
||||
let peerID = CmxConnectivityPeerID(
|
||||
|
||||
+14
-1
@@ -18,12 +18,17 @@ public struct CmxConnectivitySyncResponse: Decodable, Equatable, Sendable {
|
||||
/// Complete authoritative discovery state when `changed` is true.
|
||||
public let snapshot: CmxIrohDiscoveryResponse?
|
||||
|
||||
/// True only when the server proves `snapshot` covers every active binding.
|
||||
/// Older servers omit this field, so clients fetch paginated discovery.
|
||||
public let snapshotComplete: Bool?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case protocolVersion = "protocol_version"
|
||||
case revision
|
||||
case changed
|
||||
case reset
|
||||
case snapshot
|
||||
case snapshotComplete = "snapshot_complete"
|
||||
}
|
||||
|
||||
/// Decodes and validates one atomic reconciliation response.
|
||||
@@ -37,9 +42,14 @@ public struct CmxConnectivitySyncResponse: Decodable, Equatable, Sendable {
|
||||
CmxIrohDiscoveryResponse.self,
|
||||
forKey: .snapshot
|
||||
)
|
||||
let snapshotComplete = try container.decodeIfPresent(
|
||||
Bool.self,
|
||||
forKey: .snapshotComplete
|
||||
)
|
||||
guard protocolVersion == Self.protocolVersion,
|
||||
changed == (snapshot != nil),
|
||||
!reset || changed,
|
||||
snapshot != nil || snapshotComplete == nil,
|
||||
(snapshot?.routeContractVersion ?? 1) == 1,
|
||||
(snapshot?.revision ?? revision) == revision else {
|
||||
throw DecodingError.dataCorrupted(
|
||||
@@ -54,16 +64,19 @@ public struct CmxConnectivitySyncResponse: Decodable, Equatable, Sendable {
|
||||
self.changed = changed
|
||||
self.reset = reset
|
||||
self.snapshot = snapshot
|
||||
self.snapshotComplete = snapshotComplete
|
||||
}
|
||||
|
||||
init(
|
||||
legacySnapshot: CmxIrohDiscoveryResponse,
|
||||
knownRevision: UInt64?
|
||||
knownRevision: UInt64?,
|
||||
snapshotComplete: Bool? = true
|
||||
) {
|
||||
protocolVersion = Self.protocolVersion
|
||||
revision = legacySnapshot.revision ?? (knownRevision ?? 0) &+ 1
|
||||
changed = true
|
||||
reset = false
|
||||
snapshot = legacySnapshot
|
||||
self.snapshotComplete = snapshotComplete
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -14,16 +14,28 @@ public struct CmxIrohAdmittedServerSession: Sendable {
|
||||
public let controlTransport: any CmxByteTransport
|
||||
|
||||
private let session: CmxIrohServerSession
|
||||
private let promoteUsableSession: @Sendable () async -> Bool
|
||||
|
||||
init(
|
||||
peer: CmxIrohAdmittedPeer,
|
||||
session: CmxIrohServerSession
|
||||
session: CmxIrohServerSession,
|
||||
promoteUsableSession: @escaping @Sendable () async -> Bool = { true }
|
||||
) {
|
||||
self.peer = peer
|
||||
self.session = session
|
||||
self.promoteUsableSession = promoteUsableSession
|
||||
controlTransport = CmxIrohServerByteTransport(session: session)
|
||||
}
|
||||
|
||||
/// Promotes this connection after the application protocol is usable.
|
||||
///
|
||||
/// Promotion is generation-scoped and retires older connections from the
|
||||
/// same authenticated endpoint identity without risking a known-good
|
||||
/// session during transport admission.
|
||||
public func markUsable() async -> Bool {
|
||||
await promoteUsableSession()
|
||||
}
|
||||
|
||||
/// Accepts one client-created terminal or artifact lane.
|
||||
public func acceptBidirectionalLane() async throws -> (
|
||||
lane: CmxIrohLane,
|
||||
|
||||
+14
-2
@@ -11,6 +11,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
|
||||
case platform
|
||||
case endpointID
|
||||
case identityGeneration
|
||||
case pathHints
|
||||
}
|
||||
|
||||
/// The broker-owned binding UUID.
|
||||
@@ -34,6 +35,9 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
|
||||
/// The monotonically increasing endpoint identity generation.
|
||||
public let identityGeneration: Int
|
||||
|
||||
/// Broker-validated route hints that accelerate reconnect to this endpoint.
|
||||
public let pathHints: [CmxIrohPathHint]
|
||||
|
||||
/// Creates validated broker binding metadata.
|
||||
///
|
||||
/// - Parameters:
|
||||
@@ -44,6 +48,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
|
||||
/// - platform: The endpoint's platform role.
|
||||
/// - endpointID: The registered Iroh endpoint identity.
|
||||
/// - identityGeneration: The positive endpoint identity generation.
|
||||
/// - pathHints: Broker-validated route hints for the endpoint.
|
||||
/// - Throws: ``CmxIrohBrokerCredentialRepositoryError/invalidBinding`` for malformed input.
|
||||
public init(
|
||||
bindingID: String,
|
||||
@@ -52,7 +57,8 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
|
||||
tag: String,
|
||||
platform: CmxIrohPlatform,
|
||||
endpointID: CmxIrohPeerIdentity,
|
||||
identityGeneration: Int
|
||||
identityGeneration: Int,
|
||||
pathHints: [CmxIrohPathHint] = []
|
||||
) throws {
|
||||
guard Self.isCanonicalUUID(bindingID),
|
||||
Self.isCanonicalUUID(deviceID),
|
||||
@@ -68,6 +74,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
|
||||
self.platform = platform
|
||||
self.endpointID = endpointID
|
||||
self.identityGeneration = identityGeneration
|
||||
self.pathHints = pathHints
|
||||
}
|
||||
|
||||
/// Copies the exact recovery tuple from a validated broker response.
|
||||
@@ -81,6 +88,7 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
|
||||
platform = binding.platform
|
||||
endpointID = binding.endpointID
|
||||
identityGeneration = binding.identityGeneration
|
||||
pathHints = binding.pathHints
|
||||
}
|
||||
|
||||
/// Decodes and revalidates persisted broker binding metadata.
|
||||
@@ -96,7 +104,11 @@ public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable {
|
||||
tag: container.decode(String.self, forKey: .tag),
|
||||
platform: container.decode(CmxIrohPlatform.self, forKey: .platform),
|
||||
endpointID: container.decode(CmxIrohPeerIdentity.self, forKey: .endpointID),
|
||||
identityGeneration: container.decode(Int.self, forKey: .identityGeneration)
|
||||
identityGeneration: container.decode(Int.self, forKey: .identityGeneration),
|
||||
pathHints: container.decodeIfPresent(
|
||||
[CmxIrohPathHint].self,
|
||||
forKey: .pathHints
|
||||
) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+14
-1
@@ -298,6 +298,14 @@ public struct CmxIrohDiscoveryResponse: Decodable, Equatable, Sendable {
|
||||
|
||||
/// Registration response. Relay bootstrap failure never rolls back the binding.
|
||||
public struct CmxIrohRegistrationResponse: Decodable, Equatable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case revision
|
||||
case binding
|
||||
case relay
|
||||
case discovery
|
||||
case discoveryComplete = "discovery_complete"
|
||||
}
|
||||
|
||||
/// Monotonic account route revision after this registration commit.
|
||||
public let revision: UInt64?
|
||||
public let binding: CmxIrohBrokerBinding
|
||||
@@ -305,18 +313,23 @@ public struct CmxIrohRegistrationResponse: Decodable, Equatable, Sendable {
|
||||
/// The authoritative post-registration account snapshot when supplied by
|
||||
/// connectivity v2. Older brokers omit it and retain the separate sync.
|
||||
public let discovery: CmxIrohDiscoveryResponse?
|
||||
/// True only when the embedded snapshot covers every active binding.
|
||||
/// Older brokers omit this proof, so clients must fetch paginated discovery.
|
||||
public let discoveryComplete: Bool?
|
||||
|
||||
/// Creates a registration response for alternate brokers and tests.
|
||||
public init(
|
||||
revision: UInt64? = nil,
|
||||
binding: CmxIrohBrokerBinding,
|
||||
relay: CmxIrohRegistrationRelay,
|
||||
discovery: CmxIrohDiscoveryResponse? = nil
|
||||
discovery: CmxIrohDiscoveryResponse? = nil,
|
||||
discoveryComplete: Bool? = nil
|
||||
) {
|
||||
self.revision = revision
|
||||
self.binding = binding
|
||||
self.relay = relay
|
||||
self.discovery = discovery
|
||||
self.discoveryComplete = discoveryComplete
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
@@ -109,4 +109,14 @@ extension CmxIrohClientRuntime {
|
||||
static func isConnectivity(_ error: any Error) -> Bool {
|
||||
(error as? CmxIrohTrustBrokerClientError) == .connectivity
|
||||
}
|
||||
|
||||
/// Failures that may fall back to the verified offline policy cache.
|
||||
///
|
||||
/// Only transport availability qualifies. Authorization rejections fail
|
||||
/// closed even when an older policy was previously verified: the broker
|
||||
/// has explicitly withdrawn this session's authority after the client's
|
||||
/// exactly-once credential recovery.
|
||||
static func recoversWithCachedPolicy(_ error: any Error) -> Bool {
|
||||
isConnectivity(error)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-6
@@ -109,7 +109,7 @@ extension CmxIrohClientRuntime {
|
||||
registration = nil
|
||||
} else {
|
||||
guard !prefetchedDiscoveryRejectedCachedBinding,
|
||||
Self.isConnectivity(error),
|
||||
Self.recoversWithCachedPolicy(error),
|
||||
let cached = try await offlineBootstrap(
|
||||
expectation: offlineExpectation,
|
||||
confirmedLocalBinding: nil
|
||||
@@ -131,15 +131,25 @@ extension CmxIrohClientRuntime {
|
||||
}
|
||||
let discovery: CmxIrohDiscoveryResponse
|
||||
do {
|
||||
if let embedded = registration?.discovery {
|
||||
if let embedded = registration?.discovery,
|
||||
registration?.discoveryComplete == true {
|
||||
guard let snapshotRevision = embedded.revision,
|
||||
let registrationRevision = registration?.revision,
|
||||
snapshotRevision == registrationRevision,
|
||||
snapshotRevision >= registrationRevision,
|
||||
snapshotRevision >= (authoritativeDiscovery?.revision ?? 0) else {
|
||||
throw CmxIrohTrustBrokerClientError.invalidResponse
|
||||
}
|
||||
authoritativeDiscovery = embedded
|
||||
discovery = embedded
|
||||
let localMatches = embedded.bindings.filter(expectation.matches)
|
||||
if embedded.bindings.count
|
||||
== CmxIrohDiscoveryPage.legacyBindingLimit
|
||||
|| localMatches.count != 1 {
|
||||
discovery = try await discoverAuthoritatively(
|
||||
minimumRevision: registrationRevision
|
||||
)
|
||||
} else {
|
||||
authoritativeDiscovery = embedded
|
||||
discovery = embedded
|
||||
}
|
||||
} else {
|
||||
discovery = try await discoverAuthoritatively(
|
||||
minimumRevision: registration?.revision
|
||||
@@ -147,7 +157,7 @@ extension CmxIrohClientRuntime {
|
||||
}
|
||||
} catch {
|
||||
guard let registration,
|
||||
Self.isConnectivity(error),
|
||||
Self.recoversWithCachedPolicy(error),
|
||||
let cached = try await offlineBootstrap(
|
||||
expectation: offlineExpectation,
|
||||
confirmedLocalBinding: registration.binding
|
||||
|
||||
+36
-2
@@ -60,10 +60,37 @@ public struct CmxIrohConnectionCloseAttribution: Sendable, Equatable {
|
||||
|| cause.contains("ConnectionLost(Reset)") {
|
||||
return .remote
|
||||
}
|
||||
// Connection.closed()/close_reason() cross the uniffi boundary as
|
||||
// quinn ConnectionError DISPLAY strings, which start with the variant
|
||||
// text. Prefix-anchoring keeps a peer-chosen close reason from
|
||||
// spoofing a different initiator.
|
||||
if cause.hasPrefix("closed by peer")
|
||||
|| cause.hasPrefix("aborted by peer")
|
||||
|| cause.hasPrefix("reset by peer") {
|
||||
return .remote
|
||||
}
|
||||
if cause == "timed out" {
|
||||
return .timedOut
|
||||
}
|
||||
if cause == "closed" {
|
||||
return .local
|
||||
}
|
||||
return .unknown
|
||||
}
|
||||
|
||||
private static func applicationErrorCode(in cause: String) -> Int64? {
|
||||
// Display format of a peer application close is either
|
||||
// "closed by peer: {code}" or "closed by peer: {reason} (code {code})";
|
||||
// the formatter always appends the authentic code last, so a code-like
|
||||
// fragment inside the peer-chosen reason cannot shadow it.
|
||||
let displayPeerClose = "closed by peer: "
|
||||
if cause.hasPrefix(displayPeerClose) {
|
||||
let payload = cause.dropFirst(displayPeerClose.count)
|
||||
if let range = payload.range(of: "(code ", options: .backwards) {
|
||||
return firstInteger(in: payload[range.upperBound...])
|
||||
}
|
||||
return firstInteger(in: payload)
|
||||
}
|
||||
guard cause.contains("ApplicationClosed(") else { return nil }
|
||||
for label in [
|
||||
"application error code",
|
||||
@@ -103,12 +130,19 @@ public struct CmxIrohConnectionCloseAttribution: Sendable, Equatable {
|
||||
}
|
||||
|
||||
private static func failureKind(in cause: String) -> DiagnosticFailureKind {
|
||||
if cause.contains("ConnectionLost(TimedOut)") {
|
||||
if cause.contains("ConnectionLost(TimedOut)") || cause == "timed out" {
|
||||
return .transportIdleTimedOut
|
||||
}
|
||||
if cause.contains("ConnectionLost(LocallyClosed)") {
|
||||
if cause.contains("ConnectionLost(LocallyClosed)") || cause == "closed" {
|
||||
return .cancelled
|
||||
}
|
||||
// Display-format peer closes are prefix-anchored so a peer-chosen
|
||||
// reason cannot rewrite the kind via the keyword fallbacks below.
|
||||
if cause.hasPrefix("closed by peer")
|
||||
|| cause.hasPrefix("aborted by peer")
|
||||
|| cause.hasPrefix("reset by peer") {
|
||||
return .connectionClosed
|
||||
}
|
||||
if cause.contains("ConnectionLost(TransportError(")
|
||||
&& (cause.contains("Code::crypto(")
|
||||
|| cause.contains("TLS error:")) {
|
||||
|
||||
+99
-18
@@ -9,9 +9,33 @@ public actor CmxIrohEndpointServer {
|
||||
public typealias ConnectionHandler = @Sendable (
|
||||
_ connection: any CmxIrohConnection,
|
||||
_ runtimeGeneration: UInt64,
|
||||
_ markAdmitted: @escaping AdmissionMarker
|
||||
_ admission: AdmissionMarker
|
||||
) async throws -> Void
|
||||
public typealias AdmissionMarker = @Sendable () async -> Bool
|
||||
|
||||
/// Generation-scoped application lifecycle for one accepted connection.
|
||||
///
|
||||
/// Calling the value authenticates the connection. `markUsable()` promotes
|
||||
/// it only after the application protocol has proved ready end to end.
|
||||
public struct AdmissionMarker: Sendable {
|
||||
private let admit: @Sendable () async -> Bool
|
||||
private let promote: @Sendable () async -> Bool
|
||||
|
||||
fileprivate init(
|
||||
admit: @escaping @Sendable () async -> Bool,
|
||||
promote: @escaping @Sendable () async -> Bool
|
||||
) {
|
||||
self.admit = admit
|
||||
self.promote = promote
|
||||
}
|
||||
|
||||
public func callAsFunction() async -> Bool {
|
||||
await admit()
|
||||
}
|
||||
|
||||
public func markUsable() async -> Bool {
|
||||
await promote()
|
||||
}
|
||||
}
|
||||
typealias EndpointRecovery = @Sendable (
|
||||
_ expectedGeneration: UInt64
|
||||
) async throws -> CmxIrohEndpointSnapshot
|
||||
@@ -29,6 +53,8 @@ public actor CmxIrohEndpointServer {
|
||||
let remoteIdentity: CmxIrohPeerIdentity
|
||||
let connection: any CmxIrohConnection
|
||||
let handlerTask: Task<Void, Never>
|
||||
let sequence: UInt64
|
||||
var isUsable: Bool
|
||||
}
|
||||
|
||||
private let supervisor: CmxIrohEndpointSupervisor
|
||||
@@ -44,6 +70,7 @@ public actor CmxIrohEndpointServer {
|
||||
private var acceptTask: Task<Void, Never>?
|
||||
private var pendingAdmissions: [UUID: PendingAdmission] = [:]
|
||||
private var activeConnections: [UUID: ActiveConnection] = [:]
|
||||
private var nextConnectionSequence: UInt64 = 0
|
||||
private var currentGeneration: UInt64?
|
||||
|
||||
public init(
|
||||
@@ -234,14 +261,20 @@ public actor CmxIrohEndpointServer {
|
||||
let activeForIdentity = activeConnections.values.lazy.filter {
|
||||
$0.remoteIdentity == remoteIdentity
|
||||
}.count
|
||||
let isSameIdentityReplacement = pendingForIdentity == 0 && activeForIdentity > 0
|
||||
let hasReplaceableConnection = activeConnections.values.contains {
|
||||
$0.remoteIdentity == remoteIdentity && !$0.isUsable
|
||||
}
|
||||
let canReserveReplacement = pendingForIdentity == 0
|
||||
&& maximumConnectionsPerIdentity > 1
|
||||
&& activeForIdentity >= maximumConnectionsPerIdentity
|
||||
&& hasReplaceableConnection
|
||||
guard pendingAdmissions.count + activeConnections.count < maximumConnections
|
||||
|| isSameIdentityReplacement else {
|
||||
|| canReserveReplacement else {
|
||||
await connection.close(errorCode: 1, reason: "connection_capacity")
|
||||
return
|
||||
}
|
||||
guard pendingForIdentity + activeForIdentity < maximumConnectionsPerIdentity
|
||||
|| isSameIdentityReplacement else {
|
||||
|| canReserveReplacement else {
|
||||
await connection.close(
|
||||
errorCode: 1,
|
||||
reason: "connection_identity_capacity"
|
||||
@@ -252,9 +285,18 @@ public actor CmxIrohEndpointServer {
|
||||
let handler = handler
|
||||
let handlerTask = Task { [weak self] in
|
||||
do {
|
||||
try await handler(connection, generation) { [weak self] in
|
||||
await self?.markAdmitted(id, generation: generation) ?? false
|
||||
}
|
||||
try await handler(
|
||||
connection,
|
||||
generation,
|
||||
AdmissionMarker(
|
||||
admit: { [weak self] in
|
||||
await self?.markAdmitted(id, generation: generation) ?? false
|
||||
},
|
||||
promote: { [weak self] in
|
||||
await self?.markUsable(id, generation: generation) ?? false
|
||||
}
|
||||
)
|
||||
)
|
||||
await self?.finishHandler(id, error: nil)
|
||||
} catch {
|
||||
await self?.finishHandler(id, error: error)
|
||||
@@ -286,25 +328,64 @@ public actor CmxIrohEndpointServer {
|
||||
}
|
||||
admission.deadlineTask.cancel()
|
||||
|
||||
// One endpoint identity represents one installed client identity. A
|
||||
// newly authenticated connection from that identity is therefore the
|
||||
// authoritative replacement for older connections that may still look
|
||||
// alive after the client was force-quit, crashed, or changed networks.
|
||||
// Wait until admission succeeds before evicting them so an unauthenticated
|
||||
// or failed reconnect cannot disrupt a healthy session.
|
||||
let superseded = activeConnections.filter { _, connection in
|
||||
// An authenticated replacement may use the one admission reservation
|
||||
// above the steady identity bound. Reclaim only the oldest connection
|
||||
// that never became application-usable. A known-good session is retired
|
||||
// exclusively by markUsable below.
|
||||
let activeForIdentity = activeConnections.filter { _, connection in
|
||||
connection.generation == generation
|
||||
&& connection.remoteIdentity == admission.remoteIdentity
|
||||
}
|
||||
for supersededID in superseded.keys {
|
||||
activeConnections[supersededID] = nil
|
||||
let requiresReplacement = activeConnections.count >= maximumConnections
|
||||
|| activeForIdentity.count >= maximumConnectionsPerIdentity
|
||||
let replaced = requiresReplacement
|
||||
? activeForIdentity
|
||||
.filter { !$0.value.isUsable }
|
||||
.min { $0.value.sequence < $1.value.sequence }
|
||||
: nil
|
||||
if requiresReplacement, replaced == nil {
|
||||
return false
|
||||
}
|
||||
if let replaced {
|
||||
activeConnections[replaced.key] = nil
|
||||
}
|
||||
nextConnectionSequence &+= 1
|
||||
activeConnections[id] = ActiveConnection(
|
||||
generation: generation,
|
||||
remoteIdentity: admission.remoteIdentity,
|
||||
connection: admission.connection,
|
||||
handlerTask: admission.handlerTask
|
||||
handlerTask: admission.handlerTask,
|
||||
sequence: nextConnectionSequence,
|
||||
isUsable: false
|
||||
)
|
||||
if let replaced {
|
||||
replaced.value.handlerTask.cancel()
|
||||
await replaced.value.connection.close(
|
||||
errorCode: 0,
|
||||
reason: "superseded_unready_connection"
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func markUsable(_ id: UUID, generation: UInt64) async -> Bool {
|
||||
guard currentGeneration == generation,
|
||||
var promoted = activeConnections[id],
|
||||
promoted.generation == generation else {
|
||||
return false
|
||||
}
|
||||
if promoted.isUsable { return true }
|
||||
|
||||
let superseded = activeConnections.filter { otherID, connection in
|
||||
otherID != id
|
||||
&& connection.generation == generation
|
||||
&& connection.remoteIdentity == promoted.remoteIdentity
|
||||
}
|
||||
for supersededID in superseded.keys {
|
||||
activeConnections[supersededID] = nil
|
||||
}
|
||||
promoted.isUsable = true
|
||||
activeConnections[id] = promoted
|
||||
for connection in superseded.values {
|
||||
connection.handlerTask.cancel()
|
||||
await connection.connection.close(
|
||||
|
||||
+27
-6
@@ -132,17 +132,33 @@ extension CmxIrohHostRuntime {
|
||||
try validateLocalBinding(registration.binding, endpointID: expectedEndpointID)
|
||||
let discovery: CmxIrohDiscoveryResponse
|
||||
do {
|
||||
if let embedded = registration.discovery {
|
||||
if let embedded = registration.discovery,
|
||||
registration.discoveryComplete == true {
|
||||
guard let snapshotRevision = embedded.revision,
|
||||
let registrationRevision = registration.revision,
|
||||
snapshotRevision == registrationRevision,
|
||||
snapshotRevision >= (authoritativeDiscovery?.revision ?? 0) else {
|
||||
throw CmxIrohTrustBrokerClientError.invalidResponse
|
||||
}
|
||||
authoritativeDiscovery = embedded
|
||||
discovery = embedded
|
||||
if embedded.bindings.contains(where: {
|
||||
$0.bindingID == registration.binding.bindingID
|
||||
}) {
|
||||
authoritativeDiscovery = embedded
|
||||
discovery = embedded
|
||||
} else {
|
||||
// Legacy registration responses embed only the first
|
||||
// discovery page. Once an account has enough dev builds,
|
||||
// the binding just registered can land on a later page.
|
||||
// Resolve the complete snapshot instead of misclassifying
|
||||
// pagination as a replaced local identity.
|
||||
discovery = try await discoverAuthoritatively(
|
||||
minimumRevision: registration.revision
|
||||
)
|
||||
}
|
||||
} else {
|
||||
discovery = try await discoverAuthoritatively()
|
||||
discovery = try await discoverAuthoritatively(
|
||||
minimumRevision: registration.revision
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return try cachedPolicy(
|
||||
@@ -190,10 +206,15 @@ extension CmxIrohHostRuntime {
|
||||
)
|
||||
}
|
||||
|
||||
func discoverAuthoritatively() async throws -> CmxIrohDiscoveryResponse {
|
||||
func discoverAuthoritatively(
|
||||
minimumRevision: UInt64? = nil
|
||||
) async throws -> CmxIrohDiscoveryResponse {
|
||||
let discovery = try await CmxAuthoritativeDiscoveryResolver(
|
||||
broker: broker
|
||||
).resolve(cached: authoritativeDiscovery)
|
||||
).resolve(
|
||||
cached: authoritativeDiscovery,
|
||||
minimumRevision: minimumRevision
|
||||
)
|
||||
authoritativeDiscovery = discovery
|
||||
return discovery
|
||||
}
|
||||
|
||||
+8
-2
@@ -419,7 +419,7 @@ public actor CmxIrohHostRuntime {
|
||||
connection: any CmxIrohConnection,
|
||||
runtimeGeneration: UInt64,
|
||||
lifecycleRevision revision: UInt64,
|
||||
markAdmitted: @escaping CmxIrohEndpointServer.AdmissionMarker
|
||||
markAdmitted: CmxIrohEndpointServer.AdmissionMarker
|
||||
) async throws {
|
||||
try requireCurrent(revision)
|
||||
guard let admissionController,
|
||||
@@ -485,7 +485,13 @@ public actor CmxIrohHostRuntime {
|
||||
publishSelectedPathChange()
|
||||
}
|
||||
await handleTransport(
|
||||
CmxIrohAdmittedServerSession(peer: peer, session: session),
|
||||
CmxIrohAdmittedServerSession(
|
||||
peer: peer,
|
||||
session: session,
|
||||
promoteUsableSession: {
|
||||
await markAdmitted.markUsable()
|
||||
}
|
||||
),
|
||||
isCurrent
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -720,6 +720,6 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider {
|
||||
}
|
||||
|
||||
private static func isConnectivity(_ error: any Error) -> Bool {
|
||||
CmxIrohTrustBrokerClientError.preservesVerifiedPolicyDuringRefresh(error)
|
||||
(error as? CmxIrohTrustBrokerClientError) == .connectivity
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -1,3 +1,4 @@
|
||||
public import CMUXMobileCore
|
||||
public import Foundation
|
||||
|
||||
/// Computes bounded exponential retry delays with a server-provided floor.
|
||||
@@ -60,4 +61,17 @@ public struct CmxIrohRetrySchedule: Equatable, Sendable {
|
||||
let jitterWindow = min(available, floor * jitterFraction)
|
||||
return floor + jitterWindow * jitter
|
||||
}
|
||||
|
||||
/// Shared relay-policy retry cadence for both app platforms.
|
||||
///
|
||||
/// A broker authorization failure already survived exactly-once
|
||||
/// credential recovery and should re-check on auth-store timescales.
|
||||
/// Availability failures keep the ordinary network backoff.
|
||||
public static func relayPolicy(
|
||||
for failureKind: DiagnosticFailureKind
|
||||
) -> Self {
|
||||
failureKind == .authorizationFailed
|
||||
? Self(initialDelay: 2, maximumDelay: 120)
|
||||
: Self()
|
||||
}
|
||||
}
|
||||
|
||||
+180
-10
@@ -25,6 +25,24 @@ public struct CmxIrohBrokerCredentials: Sendable, CustomStringConvertible,
|
||||
public var debugDescription: String { description }
|
||||
}
|
||||
|
||||
/// One authenticated account and credential pair captured atomically.
|
||||
///
|
||||
/// Platform auth coordinators map their native session snapshot into this
|
||||
/// transport-owned value so account pinning and exactly-once rejection
|
||||
/// recovery stay identical on macOS and iOS.
|
||||
public struct CmxIrohAccountCredentialSnapshot: Sendable {
|
||||
public let accountID: String
|
||||
public let credentials: CmxIrohBrokerCredentials
|
||||
|
||||
public init(
|
||||
accountID: String,
|
||||
credentials: CmxIrohBrokerCredentials
|
||||
) {
|
||||
self.accountID = accountID
|
||||
self.credentials = credentials
|
||||
}
|
||||
}
|
||||
|
||||
/// Supplies the short-lived Stack credentials required by native API calls.
|
||||
///
|
||||
/// The ONLY construction input is `credentialPair`, which must return BOTH
|
||||
@@ -49,14 +67,86 @@ public struct CmxIrohBrokerTokenSource: Sendable {
|
||||
/// Both tokens from ONE snapshot, so a request can never mix an old access
|
||||
/// token with a rotated refresh token.
|
||||
public let credentialPair: @Sendable () async throws -> CmxIrohBrokerCredentials?
|
||||
/// Replaces a pair the broker just rejected as unauthorized.
|
||||
///
|
||||
/// A pair that was coherent at capture can still be rejected when another
|
||||
/// lane rotates the session between capture and server validation (the
|
||||
/// wake-time RPC force refresh, most commonly). Live sources force-mint
|
||||
/// through their session owner and return the replacement pair; frozen
|
||||
/// pinned sources (sign-out revocation) return nil so a destructive flow
|
||||
/// never silently switches credentials. The client retries the rejected
|
||||
/// request at most once with the recovered pair.
|
||||
public let recoveredCredentialPair:
|
||||
@Sendable (_ rejected: CmxIrohBrokerCredentials) async throws
|
||||
-> CmxIrohBrokerCredentials?
|
||||
|
||||
public init(
|
||||
credentialPair: @escaping @Sendable () async throws -> CmxIrohBrokerCredentials?
|
||||
credentialPair: @escaping @Sendable () async throws -> CmxIrohBrokerCredentials?,
|
||||
recoveredCredentialPair: @escaping @Sendable (
|
||||
_ rejected: CmxIrohBrokerCredentials
|
||||
) async throws -> CmxIrohBrokerCredentials? = { _ in nil }
|
||||
) {
|
||||
self.credentialPair = credentialPair
|
||||
self.recoveredCredentialPair = recoveredCredentialPair
|
||||
self.accessToken = { try await credentialPair()?.accessToken }
|
||||
self.refreshToken = { try await credentialPair()?.refreshToken }
|
||||
}
|
||||
|
||||
/// Builds a live token source pinned to one account.
|
||||
///
|
||||
/// A rejected pair first re-reads the atomic session snapshot. If another
|
||||
/// lane already rotated it, that newer pair is reused. Otherwise the
|
||||
/// platform auth owner is asked to refresh once, followed by one final
|
||||
/// account-pinned snapshot. Account switches and missing sessions fail
|
||||
/// closed throughout.
|
||||
public static func accountPinned(
|
||||
to expectedAccountID: String,
|
||||
snapshot: @escaping @Sendable () async throws
|
||||
-> CmxIrohAccountCredentialSnapshot?,
|
||||
forceRefresh: @escaping @Sendable () async throws -> Void
|
||||
) -> Self {
|
||||
Self(
|
||||
credentialPair: {
|
||||
guard let captured = try await snapshot(),
|
||||
captured.accountID == expectedAccountID else {
|
||||
return nil
|
||||
}
|
||||
return captured.credentials
|
||||
},
|
||||
recoveredCredentialPair: { rejected in
|
||||
do {
|
||||
if let captured = try await snapshot(),
|
||||
captured.accountID == expectedAccountID,
|
||||
captured.credentials.accessToken != rejected.accessToken {
|
||||
return captured.credentials
|
||||
}
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
// A transient snapshot read can still be repaired by the
|
||||
// one explicit refresh below.
|
||||
}
|
||||
do {
|
||||
try await forceRefresh()
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
let refreshed: CmxIrohAccountCredentialSnapshot?
|
||||
do {
|
||||
refreshed = try await snapshot()
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
guard let refreshed,
|
||||
refreshed.accountID == expectedAccountID else { return nil }
|
||||
return refreshed.credentials
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Injectable URL-loading boundary used by the trust broker client.
|
||||
@@ -78,6 +168,8 @@ struct CmxIrohURLSessionTransport: CmxIrohHTTPTransport {
|
||||
}
|
||||
|
||||
/// Authenticated client for endpoint registration, discovery, grants, and relay tokens.
|
||||
private struct DiscoverySnapshotChanged: Error {}
|
||||
|
||||
public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
private struct ConnectivitySyncRequest: Encodable {
|
||||
let protocolVersion: Int
|
||||
@@ -403,6 +495,24 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
}
|
||||
|
||||
private func discoverAllPages() async throws -> CmxIrohDiscoveryResponse {
|
||||
for attempt in 0 ..< 3 {
|
||||
do {
|
||||
return try await discoverSnapshotAttempt()
|
||||
} catch is DiscoverySnapshotChanged {
|
||||
if attempt == 2 {
|
||||
throw CmxIrohTrustBrokerClientError.invalidResponse
|
||||
}
|
||||
// Older brokers expose discovery as optimistic pages. Restart
|
||||
// immediately from page one when an account mutation makes
|
||||
// those pages disagree. The next request captures the newly
|
||||
// committed revision, so a timing delay would add no safety.
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw CmxIrohTrustBrokerClientError.invalidResponse
|
||||
}
|
||||
|
||||
private func discoverSnapshotAttempt() async throws -> CmxIrohDiscoveryResponse {
|
||||
var bindings: [CmxIrohBrokerBinding] = []
|
||||
var bindingIDs: Set<String> = []
|
||||
var seenCursors: Set<String> = []
|
||||
@@ -419,12 +529,18 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
if let cursor {
|
||||
queryItems.append(URLQueryItem(name: "cursor", value: cursor))
|
||||
}
|
||||
let page: CmxIrohDiscoveryPage = try await performRequest(
|
||||
path: "api/devices/iroh",
|
||||
method: "GET",
|
||||
body: nil,
|
||||
queryItems: queryItems
|
||||
)
|
||||
let page: CmxIrohDiscoveryPage
|
||||
do {
|
||||
page = try await performRequest(
|
||||
path: "api/devices/iroh",
|
||||
method: "GET",
|
||||
body: nil,
|
||||
queryItems: queryItems
|
||||
)
|
||||
} catch let error as CmxIrohTrustBrokerClientError
|
||||
where cursor != nil && Self.isStaleDiscoveryCursor(error) {
|
||||
throw DiscoverySnapshotChanged()
|
||||
}
|
||||
if let first {
|
||||
guard page.discovery.routeContractVersion == first.routeContractVersion,
|
||||
page.discovery.revision == first.revision,
|
||||
@@ -432,7 +548,7 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
page.discovery.lanRendezvous == first.lanRendezvous,
|
||||
page.discovery.grantVerificationKeys
|
||||
== first.grantVerificationKeys else {
|
||||
throw CmxIrohTrustBrokerClientError.invalidResponse
|
||||
throw DiscoverySnapshotChanged()
|
||||
}
|
||||
} else {
|
||||
first = page.discovery
|
||||
@@ -464,6 +580,13 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
)
|
||||
}
|
||||
|
||||
private static func isStaleDiscoveryCursor(
|
||||
_ error: CmxIrohTrustBrokerClientError
|
||||
) -> Bool {
|
||||
guard case let .rejected(statusCode, code) = error else { return false }
|
||||
return statusCode == 409 && code == "discovery_cursor_stale"
|
||||
}
|
||||
|
||||
private func sendUngated<Response: Decodable & Sendable, Body: Encodable>(
|
||||
path: String,
|
||||
method: String,
|
||||
@@ -518,8 +641,55 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
|
||||
guard let pair = capturedPair else {
|
||||
throw CmxIrohTrustBrokerClientError.missingAuthentication
|
||||
}
|
||||
let accessToken = pair.accessToken
|
||||
let refreshToken = pair.refreshToken
|
||||
do {
|
||||
return try await performAuthenticatedRequest(
|
||||
path: path,
|
||||
method: method,
|
||||
body: body,
|
||||
queryItems: queryItems,
|
||||
credentials: pair
|
||||
)
|
||||
} catch let error as CmxIrohTrustBrokerClientError
|
||||
where Self.isUnauthorizedRejection(error) {
|
||||
// A pair that was coherent at capture can be rejected when another
|
||||
// lane rotated the session before the server validated it. Recover
|
||||
// ONCE with a pair minted after the rejection; a second rejection
|
||||
// is authoritative and propagates.
|
||||
let recovered: CmxIrohBrokerCredentials?
|
||||
do {
|
||||
recovered = try await tokenSource.recoveredCredentialPair(pair)
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
throw CmxIrohTrustBrokerClientError.connectivity
|
||||
}
|
||||
guard let recovered else { throw error }
|
||||
return try await performAuthenticatedRequest(
|
||||
path: path,
|
||||
method: method,
|
||||
body: body,
|
||||
queryItems: queryItems,
|
||||
credentials: recovered
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func isUnauthorizedRejection(
|
||||
_ error: CmxIrohTrustBrokerClientError
|
||||
) -> Bool {
|
||||
guard case let .rejected(statusCode, _) = error else { return false }
|
||||
return statusCode == 401
|
||||
}
|
||||
|
||||
private func performAuthenticatedRequest<Response: Decodable & Sendable>(
|
||||
path: String,
|
||||
method: String,
|
||||
body: Data?,
|
||||
queryItems: [URLQueryItem],
|
||||
credentials: CmxIrohBrokerCredentials
|
||||
) async throws -> Response {
|
||||
let accessToken = credentials.accessToken
|
||||
let refreshToken = credentials.refreshToken
|
||||
guard Self.isSafeHeaderValue(accessToken), Self.isSafeHeaderValue(refreshToken) else {
|
||||
throw CmxIrohTrustBrokerClientError.invalidAuthentication
|
||||
}
|
||||
|
||||
+13
-1
@@ -28,7 +28,17 @@ public enum CmxIrohTrustBrokerClientError:
|
||||
case .rateLimited:
|
||||
return true
|
||||
case let .rejected(statusCode, _):
|
||||
return statusCode == 408
|
||||
// A 401 here already survived the
|
||||
// broker client's single force-refresh retry, so it is a session
|
||||
// transition still settling (rotation race, locked token store) or
|
||||
// a server-side availability condition — not a trust change. The
|
||||
// cached policy was verified when stored; tearing the runtime down
|
||||
// buys nothing and turns a seconds-long auth blip into a full
|
||||
// endpoint rebuild. A genuinely dead session clears auth state
|
||||
// through the coordinator, which stops the runtime through the
|
||||
// lifecycle owner instead.
|
||||
return statusCode == 401
|
||||
|| statusCode == 408
|
||||
|| statusCode == 425
|
||||
|| statusCode == 429
|
||||
|| (500...599).contains(statusCode)
|
||||
@@ -53,6 +63,8 @@ public enum CmxIrohTrustBrokerClientError:
|
||||
case let .rejected(statusCode, _):
|
||||
// A server failure cannot establish trust, so retrying the request
|
||||
// is safe while the lifecycle-owned start task remains current.
|
||||
// An authentication rejection cannot establish initial trust. It
|
||||
// must return to the auth lifecycle instead of retrying forever.
|
||||
return statusCode == 408
|
||||
|| statusCode == 425
|
||||
|| statusCode == 429
|
||||
|
||||
+288
-19
@@ -124,6 +124,47 @@ struct CmxConnectivityEngineTests {
|
||||
await engine.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func endpointConsumerWaitsForUnexpectedClosureRecovery() async throws {
|
||||
let identity = try CmxIrohPeerIdentity(
|
||||
endpointID: String(repeating: "f", count: 64)
|
||||
)
|
||||
let firstEndpoint = TestIrohEndpoint(identity: identity)
|
||||
let replacementEndpoint = TestIrohEndpoint(identity: identity)
|
||||
let factory = GatedReplacementEndpointFactory(
|
||||
first: firstEndpoint,
|
||||
replacement: replacementEndpoint
|
||||
)
|
||||
let engine = CmxConnectivityEngine(
|
||||
factory: factory,
|
||||
endpointConfiguration: try Self.endpointConfiguration(),
|
||||
contextProvider: FailingConnectivityContextProvider()
|
||||
)
|
||||
try await engine.start()
|
||||
|
||||
await firstEndpoint.emit(.closedUnexpectedly)
|
||||
try await Self.waitUntil {
|
||||
let bindCallCount = await factory.bindCallCount()
|
||||
let snapshot = await engine.snapshot()
|
||||
return bindCallCount == 2 && snapshot.phase == .starting
|
||||
}
|
||||
|
||||
let lookupStarted = ConnectivityObservationFlag()
|
||||
let lookup = Task {
|
||||
await lookupStarted.markFinished()
|
||||
return try await engine.localEndpointIdentity()
|
||||
}
|
||||
try await Self.waitUntil { await lookupStarted.value() }
|
||||
for _ in 0 ..< 100 { await Task.yield() }
|
||||
|
||||
await factory.releaseReplacement()
|
||||
|
||||
#expect(try await lookup.value == identity)
|
||||
#expect(await engine.snapshot().phase == .active)
|
||||
#expect(await engine.snapshot().endpointGeneration == 2)
|
||||
await engine.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func equivalentRouteRevisionBumpKeepsTheLivePeerSession() async throws {
|
||||
let rig = try await Self.admittedPeerRig(responses: [
|
||||
@@ -286,6 +327,186 @@ struct CmxConnectivityEngineTests {
|
||||
await rig.engine.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func reorderedCapabilitiesOnRevisionBumpKeepsTheLivePeerSession() async throws {
|
||||
let rig = try await Self.admittedPeerRig(responses: [
|
||||
Self.peerRouteResponse(
|
||||
revision: 9,
|
||||
lastSeenAt: "2026-07-30T00:00:00Z",
|
||||
capabilities: ["artifact", "terminal"]
|
||||
),
|
||||
Self.peerRouteResponse(
|
||||
revision: 10,
|
||||
lastSeenAt: "2026-07-30T00:00:45Z",
|
||||
capabilities: ["terminal", "artifact"]
|
||||
),
|
||||
])
|
||||
let session = try await rig.engine.acquireControl(
|
||||
for: rig.request,
|
||||
ownerID: UUID()
|
||||
)
|
||||
|
||||
try await rig.engine.reconcileRoutes()
|
||||
|
||||
#expect(await rig.engine.snapshot().routeRevision == 10)
|
||||
#expect(await rig.connection.observedCloseCallCount() == 0)
|
||||
#expect(await session.isClosed() == false)
|
||||
await rig.engine.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func reorderedRelayFleetOnRevisionBumpKeepsTheLivePeerSession() async throws {
|
||||
let rig = try await Self.admittedPeerRig(responses: [
|
||||
Self.peerRouteResponse(
|
||||
revision: 9,
|
||||
lastSeenAt: "2026-07-30T00:00:00Z",
|
||||
relayFleet: [
|
||||
"https://relay-a.example/",
|
||||
"https://relay-b.example/",
|
||||
]
|
||||
),
|
||||
Self.peerRouteResponse(
|
||||
revision: 10,
|
||||
lastSeenAt: "2026-07-30T00:00:45Z",
|
||||
relayFleet: [
|
||||
"https://relay-b.example/",
|
||||
"https://relay-a.example/",
|
||||
]
|
||||
),
|
||||
])
|
||||
let session = try await rig.engine.acquireControl(
|
||||
for: rig.request,
|
||||
ownerID: UUID()
|
||||
)
|
||||
|
||||
try await rig.engine.reconcileRoutes()
|
||||
|
||||
#expect(await rig.engine.snapshot().routeRevision == 10)
|
||||
#expect(await rig.connection.observedCloseCallCount() == 0)
|
||||
#expect(await session.isClosed() == false)
|
||||
await rig.engine.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func reorderedGrantVerificationKeysOnRevisionBumpKeepsTheLivePeerSession() async throws {
|
||||
let rig = try await Self.admittedPeerRig(responses: [
|
||||
Self.peerRouteResponse(
|
||||
revision: 9,
|
||||
lastSeenAt: "2026-07-30T00:00:00Z",
|
||||
grantVerificationKeyIDs: ["current", "previous"]
|
||||
),
|
||||
Self.peerRouteResponse(
|
||||
revision: 10,
|
||||
lastSeenAt: "2026-07-30T00:00:45Z",
|
||||
grantVerificationKeyIDs: ["previous", "current"]
|
||||
),
|
||||
])
|
||||
let session = try await rig.engine.acquireControl(
|
||||
for: rig.request,
|
||||
ownerID: UUID()
|
||||
)
|
||||
|
||||
try await rig.engine.reconcileRoutes()
|
||||
|
||||
#expect(await rig.engine.snapshot().routeRevision == 10)
|
||||
#expect(await rig.connection.observedCloseCallCount() == 0)
|
||||
#expect(await session.isClosed() == false)
|
||||
await rig.engine.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func snapshotInstallForARevisionRecordedWithoutContentFailsClosed() async throws {
|
||||
let rig = try await Self.admittedPeerRig(
|
||||
responses: [
|
||||
Self.peerRouteResponse(
|
||||
revision: 9,
|
||||
lastSeenAt: "2026-07-30T00:00:00Z"
|
||||
),
|
||||
Self.unchangedResponse(revision: 12),
|
||||
],
|
||||
dialableConnections: 2
|
||||
)
|
||||
let first = try await rig.engine.acquireControl(
|
||||
for: rig.request,
|
||||
ownerID: UUID()
|
||||
)
|
||||
try await rig.engine.reconcileRoutes()
|
||||
#expect(await first.isClosed())
|
||||
let second = try await rig.engine.acquireControl(
|
||||
for: rig.request,
|
||||
ownerID: UUID()
|
||||
)
|
||||
#expect(await second.isClosed() == false)
|
||||
let snapshot = try #require(Self.peerRouteResponse(
|
||||
revision: 12,
|
||||
lastSeenAt: "2026-07-30T00:00:45Z"
|
||||
).snapshot)
|
||||
|
||||
await rig.engine.didInstallRouteRevision(12, routes: snapshot)
|
||||
|
||||
#expect(await rig.engine.snapshot().routeRevision == 12)
|
||||
#expect(await rig.connections[1].observedCloseCallCount() == 1)
|
||||
#expect(await second.isClosed())
|
||||
await rig.engine.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func sameRevisionReinstallWithUnchangedContentKeepsTheLivePeerSession() async throws {
|
||||
let rig = try await Self.admittedPeerRig(responses: [
|
||||
Self.peerRouteResponse(
|
||||
revision: 9,
|
||||
lastSeenAt: "2026-07-30T00:00:00Z"
|
||||
),
|
||||
])
|
||||
let session = try await rig.engine.acquireControl(
|
||||
for: rig.request,
|
||||
ownerID: UUID()
|
||||
)
|
||||
let snapshot = try #require(Self.peerRouteResponse(
|
||||
revision: 10,
|
||||
lastSeenAt: "2026-07-30T00:00:45Z"
|
||||
).snapshot)
|
||||
|
||||
await rig.engine.didInstallRouteRevision(10, routes: snapshot)
|
||||
await rig.engine.didInstallRouteRevision(10, routes: snapshot)
|
||||
|
||||
#expect(await rig.engine.snapshot().routeRevision == 10)
|
||||
#expect(await rig.connection.observedCloseCallCount() == 0)
|
||||
#expect(await session.isClosed() == false)
|
||||
await rig.engine.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func olderRouteRevisionInstallCannotRollBackANewerInstall() async throws {
|
||||
let rig = try await Self.admittedPeerRig(responses: [
|
||||
Self.peerRouteResponse(
|
||||
revision: 9,
|
||||
lastSeenAt: "2026-07-30T00:00:00Z"
|
||||
),
|
||||
])
|
||||
let session = try await rig.engine.acquireControl(
|
||||
for: rig.request,
|
||||
ownerID: UUID()
|
||||
)
|
||||
let newer = try #require(Self.peerRouteResponse(
|
||||
revision: 11,
|
||||
lastSeenAt: "2026-07-30T00:00:45Z"
|
||||
).snapshot)
|
||||
let older = try #require(Self.peerRouteResponse(
|
||||
revision: 10,
|
||||
lastSeenAt: "2026-07-30T00:00:30Z",
|
||||
identityGeneration: 2
|
||||
).snapshot)
|
||||
|
||||
await rig.engine.didInstallRouteRevision(11, routes: newer)
|
||||
await rig.engine.didInstallRouteRevision(10, routes: older)
|
||||
|
||||
#expect(await rig.engine.snapshot().routeRevision == 11)
|
||||
#expect(await rig.connection.observedCloseCallCount() == 0)
|
||||
#expect(await session.isClosed() == false)
|
||||
await rig.engine.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func stopFinishesNetworkChangeObservers() async throws {
|
||||
let identity = try CmxIrohPeerIdentity(
|
||||
@@ -317,34 +538,38 @@ struct CmxConnectivityEngineTests {
|
||||
|
||||
private struct AdmittedPeerRig {
|
||||
let engine: CmxConnectivityEngine
|
||||
let connection: TestIrohConnection
|
||||
let connections: [TestIrohConnection]
|
||||
let authority: ScriptedConnectivityAuthority
|
||||
let request: CmxByteTransportRequest
|
||||
|
||||
var connection: TestIrohConnection { connections[0] }
|
||||
}
|
||||
|
||||
private static func admittedPeerRig(
|
||||
responses: [CmxConnectivitySyncResponse]
|
||||
responses: [CmxConnectivitySyncResponse],
|
||||
dialableConnections: Int = 1
|
||||
) async throws -> AdmittedPeerRig {
|
||||
let localIdentity = try CmxIrohPeerIdentity(
|
||||
endpointID: String(repeating: "1", count: 64)
|
||||
)
|
||||
let peerIdentity = try CmxIrohPeerIdentity(endpointID: peerEndpointID)
|
||||
let control = CmxIrohBidirectionalStream(
|
||||
receiveStream: TestIrohReceiveStream(
|
||||
buffer: CmxIrohAdmissionAckCodec()
|
||||
.encodeFrame(.acceptedPendingNatTraversal)
|
||||
+ admissionFrame(status: 3)
|
||||
),
|
||||
sendStream: TestIrohSendStream()
|
||||
)
|
||||
let connection = TestIrohConnection(
|
||||
remoteIdentity: peerIdentity,
|
||||
bidirectionalStreams: [control],
|
||||
selectedPath: .direct
|
||||
)
|
||||
let connections = (0 ..< dialableConnections).map { _ in
|
||||
TestIrohConnection(
|
||||
remoteIdentity: peerIdentity,
|
||||
bidirectionalStreams: [CmxIrohBidirectionalStream(
|
||||
receiveStream: TestIrohReceiveStream(
|
||||
buffer: CmxIrohAdmissionAckCodec()
|
||||
.encodeFrame(.acceptedPendingNatTraversal)
|
||||
+ admissionFrame(status: 3)
|
||||
),
|
||||
sendStream: TestIrohSendStream()
|
||||
)],
|
||||
selectedPath: .direct
|
||||
)
|
||||
}
|
||||
let endpoint = TestDialingIrohEndpoint(
|
||||
localIdentity: localIdentity,
|
||||
dialResults: [.connection(connection)]
|
||||
dialResults: connections.map { .connection($0) }
|
||||
)
|
||||
let supervisor = CmxIrohEndpointSupervisor(
|
||||
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
|
||||
@@ -373,7 +598,7 @@ struct CmxConnectivityEngineTests {
|
||||
)
|
||||
return AdmittedPeerRig(
|
||||
engine: engine,
|
||||
connection: connection,
|
||||
connections: connections,
|
||||
authority: authority,
|
||||
request: request
|
||||
)
|
||||
@@ -384,8 +609,13 @@ struct CmxConnectivityEngineTests {
|
||||
lastSeenAt: String,
|
||||
identityGeneration: Int = 1,
|
||||
relayFleet: [String] = ["https://relay.example/"],
|
||||
capabilities: [String] = ["terminal"],
|
||||
grantVerificationKeyIDs: [String] = [],
|
||||
includesPeerBinding: Bool = true
|
||||
) throws -> CmxConnectivitySyncResponse {
|
||||
let capabilityList = capabilities
|
||||
.map { "\"\($0)\"" }
|
||||
.joined(separator: ", ")
|
||||
let binding = """
|
||||
{
|
||||
"binding_id": "0a0a0a0a-0000-4000-8000-000000000001",
|
||||
@@ -396,7 +626,7 @@ struct CmxConnectivityEngineTests {
|
||||
"endpoint_id": "\(peerEndpointID)",
|
||||
"identity_generation": \(identityGeneration),
|
||||
"pairing_enabled": true,
|
||||
"capabilities": ["terminal"],
|
||||
"capabilities": [\(capabilityList)],
|
||||
"path_hints": [],
|
||||
"last_seen_at": "\(lastSeenAt)"
|
||||
}
|
||||
@@ -404,6 +634,13 @@ struct CmxConnectivityEngineTests {
|
||||
let fleet = relayFleet
|
||||
.map { "\"\($0)\"" }
|
||||
.joined(separator: ", ")
|
||||
let keys = grantVerificationKeyIDs
|
||||
.map {
|
||||
"""
|
||||
{"kid": "\($0)", "alg": "ed25519", "spki_der_base64": "QUJD"}
|
||||
"""
|
||||
}
|
||||
.joined(separator: ", ")
|
||||
return try decodeResponse(
|
||||
"""
|
||||
{
|
||||
@@ -423,7 +660,7 @@ struct CmxConnectivityEngineTests {
|
||||
"grant_verification_keys": {
|
||||
"version": 1,
|
||||
"current_kid": "current",
|
||||
"keys": []
|
||||
"keys": [\(keys)]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -557,6 +794,38 @@ private actor ConnectivityObservationFlag {
|
||||
func value() -> Bool { finished }
|
||||
}
|
||||
|
||||
private actor GatedReplacementEndpointFactory: CmxIrohEndpointFactory {
|
||||
private let first: any CmxIrohEndpoint
|
||||
private let replacement: any CmxIrohEndpoint
|
||||
private var calls = 0
|
||||
private var replacementWaiter: CheckedContinuation<any CmxIrohEndpoint, Never>?
|
||||
|
||||
init(
|
||||
first: any CmxIrohEndpoint,
|
||||
replacement: any CmxIrohEndpoint
|
||||
) {
|
||||
self.first = first
|
||||
self.replacement = replacement
|
||||
}
|
||||
|
||||
func bind(
|
||||
configuration _: CmxIrohEndpointConfiguration
|
||||
) async -> any CmxIrohEndpoint {
|
||||
calls += 1
|
||||
if calls == 1 { return first }
|
||||
return await withCheckedContinuation { continuation in
|
||||
replacementWaiter = continuation
|
||||
}
|
||||
}
|
||||
|
||||
func bindCallCount() -> Int { calls }
|
||||
|
||||
func releaseReplacement() {
|
||||
replacementWaiter?.resume(returning: replacement)
|
||||
replacementWaiter = nil
|
||||
}
|
||||
}
|
||||
|
||||
private actor GatedConnectivityAuthority: CmxConnectivityAuthorityServing {
|
||||
private let changed: CmxConnectivitySyncResponse
|
||||
private let unchanged: CmxConnectivitySyncResponse
|
||||
|
||||
+129
-2
@@ -266,6 +266,111 @@ struct CmxConnectivityPeerSessionTests {
|
||||
await peer.invalidate()
|
||||
}
|
||||
|
||||
@Test
|
||||
func invalidationDuringRedundantDialCloseTriggersAFreshDial() async throws {
|
||||
let request = try Self.request()
|
||||
let peerID = try CmxConnectivityPeerID(request: request)
|
||||
let winner = TestConnectivitySession(
|
||||
continuityID: 91,
|
||||
gatesFirstIsClosedCheck: true
|
||||
)
|
||||
let loser = TestConnectivitySession(
|
||||
continuityID: 92,
|
||||
gatesFirstClose: true
|
||||
)
|
||||
let replacement = TestConnectivitySession(continuityID: 93)
|
||||
let builder = OrderedGatedConnectivitySessionBuilder(
|
||||
sessions: [winner, loser, replacement]
|
||||
)
|
||||
let peer = CmxConnectivityPeerSession(
|
||||
peerID: peerID,
|
||||
buildSession: { request in
|
||||
try await builder.build(request)
|
||||
}
|
||||
)
|
||||
|
||||
// Park the first caller at the dead-on-arrival probe so the second
|
||||
// caller starts its own dial, then let the winner install before the
|
||||
// second dial resolves.
|
||||
let firstCaller = Task { try await peer.connectedSession(for: request) }
|
||||
try await Self.waitUntil { await builder.callCount() == 1 }
|
||||
await builder.release(call: 0)
|
||||
try await Self.waitUntil { await winner.isClosedGateIsWaiting() }
|
||||
let secondCaller = Task { try await peer.connectedSession(for: request) }
|
||||
try await Self.waitUntil { await builder.callCount() == 2 }
|
||||
await winner.releaseIsClosedGate()
|
||||
_ = try await firstCaller.value
|
||||
await builder.release(call: 1)
|
||||
try await Self.waitUntil { await loser.closeGateIsWaiting() }
|
||||
|
||||
// The redundant close is in flight; invalidation evicts the winner
|
||||
// before that close settles. The second caller must not receive the
|
||||
// stale winner capture.
|
||||
await peer.invalidate()
|
||||
await loser.releaseCloseGate()
|
||||
try await Self.waitUntil { await builder.callCount() == 3 }
|
||||
await builder.release(call: 2)
|
||||
|
||||
let session = try await secondCaller.value
|
||||
#expect(await session.connectionContinuityID() == 93)
|
||||
#expect(await peer.connectionContinuityID() == 93)
|
||||
#expect(await winner.closeCount() == 1)
|
||||
#expect(await loser.closeCount() == 1)
|
||||
await peer.invalidate()
|
||||
}
|
||||
|
||||
@Test
|
||||
func invalidationDuringPostProbeRedundantDialCloseTriggersAFreshDial() async throws {
|
||||
let request = try Self.request()
|
||||
let peerID = try CmxConnectivityPeerID(request: request)
|
||||
let winner = TestConnectivitySession(
|
||||
continuityID: 101,
|
||||
gatesFirstIsClosedCheck: true
|
||||
)
|
||||
let loser = TestConnectivitySession(
|
||||
continuityID: 102,
|
||||
gatesFirstIsClosedCheck: true,
|
||||
gatesFirstClose: true
|
||||
)
|
||||
let replacement = TestConnectivitySession(continuityID: 103)
|
||||
let builder = OrderedGatedConnectivitySessionBuilder(
|
||||
sessions: [winner, loser, replacement]
|
||||
)
|
||||
let peer = CmxConnectivityPeerSession(
|
||||
peerID: peerID,
|
||||
buildSession: { request in
|
||||
try await builder.build(request)
|
||||
}
|
||||
)
|
||||
|
||||
// Park both callers at their dead-on-arrival probes so the winner
|
||||
// installs while the second caller is past its post-resolve check.
|
||||
let firstCaller = Task { try await peer.connectedSession(for: request) }
|
||||
try await Self.waitUntil { await builder.callCount() == 1 }
|
||||
await builder.release(call: 0)
|
||||
try await Self.waitUntil { await winner.isClosedGateIsWaiting() }
|
||||
let secondCaller = Task { try await peer.connectedSession(for: request) }
|
||||
try await Self.waitUntil { await builder.callCount() == 2 }
|
||||
await builder.release(call: 1)
|
||||
try await Self.waitUntil { await loser.isClosedGateIsWaiting() }
|
||||
await winner.releaseIsClosedGate()
|
||||
_ = try await firstCaller.value
|
||||
await loser.releaseIsClosedGate()
|
||||
try await Self.waitUntil { await loser.closeGateIsWaiting() }
|
||||
|
||||
await peer.invalidate()
|
||||
await loser.releaseCloseGate()
|
||||
try await Self.waitUntil { await builder.callCount() == 3 }
|
||||
await builder.release(call: 2)
|
||||
|
||||
let session = try await secondCaller.value
|
||||
#expect(await session.connectionContinuityID() == 103)
|
||||
#expect(await peer.connectionContinuityID() == 103)
|
||||
#expect(await winner.closeCount() == 1)
|
||||
#expect(await loser.closeCount() == 1)
|
||||
await peer.invalidate()
|
||||
}
|
||||
|
||||
@Test
|
||||
func deadOnArrivalSessionIsClosedAndRedialedOnce() async throws {
|
||||
let request = try Self.request()
|
||||
@@ -563,6 +668,9 @@ private actor TestConnectivitySession: CmxConnectivitySession {
|
||||
private var isClosedGatePending: Bool
|
||||
private var isClosedGateWaiting = false
|
||||
private var isClosedGateWaiter: CheckedContinuation<Void, Never>?
|
||||
private var closeGatePending: Bool
|
||||
private var closeGateWaiting = false
|
||||
private var closeGateWaiter: CheckedContinuation<Void, Never>?
|
||||
private var received: [Data] = []
|
||||
private var selectedPath = CmxIrohObservedConnectionPath.direct
|
||||
private var selectedPathContinuation:
|
||||
@@ -572,12 +680,14 @@ private actor TestConnectivitySession: CmxConnectivitySession {
|
||||
continuityID: UInt64,
|
||||
gatesCloseAttribution: Bool = false,
|
||||
keepsSelectedPathStreamOpen: Bool = false,
|
||||
gatesFirstIsClosedCheck: Bool = false
|
||||
gatesFirstIsClosedCheck: Bool = false,
|
||||
gatesFirstClose: Bool = false
|
||||
) {
|
||||
self.continuityID = continuityID
|
||||
self.gatesCloseAttribution = gatesCloseAttribution
|
||||
self.keepsSelectedPathStreamOpen = keepsSelectedPathStreamOpen
|
||||
isClosedGatePending = gatesFirstIsClosedCheck
|
||||
closeGatePending = gatesFirstClose
|
||||
}
|
||||
|
||||
func receiveControl(maximumByteCount: Int) -> Data? {
|
||||
@@ -681,11 +791,28 @@ private actor TestConnectivitySession: CmxConnectivitySession {
|
||||
}
|
||||
}
|
||||
|
||||
func close() {
|
||||
func close() async {
|
||||
if closeGatePending {
|
||||
closeGatePending = false
|
||||
closeGateWaiting = true
|
||||
await withCheckedContinuation { continuation in
|
||||
closeGateWaiter = continuation
|
||||
}
|
||||
closeGateWaiting = false
|
||||
}
|
||||
closes += 1
|
||||
finish(failure: .cancelled)
|
||||
}
|
||||
|
||||
func closeGateIsWaiting() -> Bool {
|
||||
closeGateWaiting
|
||||
}
|
||||
|
||||
func releaseCloseGate() {
|
||||
closeGateWaiter?.resume()
|
||||
closeGateWaiter = nil
|
||||
}
|
||||
|
||||
func finishRemotely(failure: DiagnosticFailureKind) {
|
||||
finish(failure: failure)
|
||||
}
|
||||
|
||||
+11
-3
@@ -25,7 +25,13 @@ struct CmxIrohBrokerCredentialRepositoryTests {
|
||||
let (defaults, suiteName) = try isolatedDefaults()
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
let secureStore = TestSecureCredentialStore()
|
||||
let binding = try metadata()
|
||||
let pathHint = try CmxIrohPathHint(
|
||||
kind: .relayURL,
|
||||
value: relayFleet[0],
|
||||
source: .native,
|
||||
privacyScope: .publicInternet
|
||||
)
|
||||
let binding = try metadata(pathHints: [pathHint])
|
||||
let response = relayResponse()
|
||||
let repository = makeRepository(defaults: defaults, secureStore: secureStore)
|
||||
|
||||
@@ -450,7 +456,8 @@ struct CmxIrohBrokerCredentialRepositoryTests {
|
||||
private func metadata(
|
||||
bindingID: String = "123e4567-e89b-42d3-a456-426614174010",
|
||||
endpointByte: String = "ab",
|
||||
generation: Int = 1
|
||||
generation: Int = 1,
|
||||
pathHints: [CmxIrohPathHint] = []
|
||||
) throws -> CmxIrohBrokerBindingMetadata {
|
||||
try CmxIrohBrokerBindingMetadata(
|
||||
bindingID: bindingID,
|
||||
@@ -461,7 +468,8 @@ struct CmxIrohBrokerCredentialRepositoryTests {
|
||||
endpointID: CmxIrohPeerIdentity(
|
||||
endpointID: String(repeating: endpointByte, count: 32)
|
||||
),
|
||||
identityGeneration: generation
|
||||
identityGeneration: generation,
|
||||
pathHints: pathHints
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+68
-18
@@ -76,7 +76,7 @@ struct CmxIrohClientRuntimeTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func embeddedDiscoveryMustExactlyMatchTheRegistrationRevision() async throws {
|
||||
func embeddedDiscoveryMayFollowTheRegistrationRevision() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
let discovery = try ClientRuntimeTestFixture.discovery(
|
||||
binding: fixture.binding,
|
||||
@@ -98,9 +98,11 @@ struct CmxIrohClientRuntimeTests {
|
||||
now: { fixture.now }
|
||||
)
|
||||
|
||||
await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) {
|
||||
try await runtime.start()
|
||||
}
|
||||
try await runtime.start()
|
||||
|
||||
#expect(await runtime.snapshot().state == .active)
|
||||
#expect(await runtime.connectivityEngine.snapshot().routeRevision == 2)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -214,6 +216,44 @@ struct CmxIrohClientRuntimeTests {
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func startupFetchesPaginatedDiscoveryWhenRegistrationAndSyncSnapshotsAreUnproven() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
let truncatedRegistrationDiscovery = try ClientRuntimeTestFixture.discovery(
|
||||
binding: fixture.binding,
|
||||
includeBinding: false,
|
||||
revision: 1
|
||||
)
|
||||
let completeDiscovery = try ClientRuntimeTestFixture.discovery(
|
||||
binding: fixture.binding,
|
||||
revision: 1
|
||||
)
|
||||
let broker = TestRevisionedClientBroker(
|
||||
binding: fixture.binding,
|
||||
discoveries: [truncatedRegistrationDiscovery, completeDiscovery],
|
||||
relay: fixture.relayResponse(),
|
||||
embeddedRegistrationDiscovery: truncatedRegistrationDiscovery,
|
||||
connectivitySnapshotsProvenComplete: nil
|
||||
)
|
||||
let runtime = try CmxIrohClientRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: [
|
||||
TestIrohEndpoint(identity: fixture.endpointID),
|
||||
]),
|
||||
broker: broker,
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: fixture.pendingRevocations(),
|
||||
now: { fixture.now }
|
||||
)
|
||||
|
||||
try await runtime.start()
|
||||
|
||||
#expect(await broker.registrationCount == 1)
|
||||
#expect(await broker.syncCount == 1)
|
||||
#expect(await broker.discoveryCount == 1)
|
||||
#expect(await runtime.connectivityEngine.snapshot().routeRevision == 1)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func cachedBindingSyncOverlapsBindAndRegistersAfterActivation() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
@@ -790,7 +830,7 @@ struct CmxIrohClientRuntimeTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func foregroundTerminalBrokerFailureRevokesLocalPolicy() async throws {
|
||||
func foregroundUnauthorizedBrokerFailurePreservesLocalPolicy() async throws {
|
||||
let fixture = try ClientRuntimeTestFixture()
|
||||
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
|
||||
let broker = TestIrohClientBroker(
|
||||
@@ -820,14 +860,13 @@ struct CmxIrohClientRuntimeTests {
|
||||
)
|
||||
await broker.setRegistrationError(terminal)
|
||||
|
||||
await #expect(throws: terminal) {
|
||||
try await runtime.didBecomeActive()
|
||||
}
|
||||
try await runtime.didBecomeActive()
|
||||
|
||||
#expect(await runtime.snapshot().state == .failed)
|
||||
#expect(await endpoint.observedCloseCallCount() == 1)
|
||||
#expect(await offlineStore.deleteAllCount() == 1)
|
||||
#expect(await recorder.observedPolicyInvalidationCount() == 1)
|
||||
#expect(await runtime.snapshot().state == .active)
|
||||
#expect(await endpoint.observedCloseCallCount() == 0)
|
||||
#expect(await offlineStore.deleteAllCount() == 0)
|
||||
#expect(await recorder.observedPolicyInvalidationCount() == 0)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1119,9 +1158,12 @@ private actor TestRevisionedClientBroker:
|
||||
private let blockedSyncCount: Int?
|
||||
private let blockedRegistrationCount: Int?
|
||||
private let embeddedRegistrationDiscovery: CmxIrohDiscoveryResponse?
|
||||
private let embeddedRegistrationDiscoveryIsComplete: Bool?
|
||||
private let registrationRevision: UInt64?
|
||||
private let registrationError: CmxIrohTrustBrokerClientError?
|
||||
private let connectivitySnapshotsProvenComplete: Bool?
|
||||
private(set) var registrationCount = 0
|
||||
private(set) var discoveryCount = 0
|
||||
private(set) var syncCount = 0
|
||||
private var blockedSyncReleased = false
|
||||
private var blockedRegistrationReleased = false
|
||||
@@ -1133,19 +1175,24 @@ private actor TestRevisionedClientBroker:
|
||||
blockedSyncCount: Int? = nil,
|
||||
blockedRegistrationCount: Int? = nil,
|
||||
embedInitialDiscovery: Bool = false,
|
||||
embeddedRegistrationDiscovery: CmxIrohDiscoveryResponse? = nil,
|
||||
embeddedRegistrationDiscoveryIsComplete: Bool? = nil,
|
||||
registrationRevision: UInt64? = nil,
|
||||
registrationError: CmxIrohTrustBrokerClientError? = nil
|
||||
registrationError: CmxIrohTrustBrokerClientError? = nil,
|
||||
connectivitySnapshotsProvenComplete: Bool? = true
|
||||
) {
|
||||
self.binding = binding
|
||||
self.discoveries = discoveries
|
||||
self.relay = relay
|
||||
self.blockedSyncCount = blockedSyncCount
|
||||
self.blockedRegistrationCount = blockedRegistrationCount
|
||||
embeddedRegistrationDiscovery = embedInitialDiscovery
|
||||
? discoveries.first
|
||||
: nil
|
||||
self.embeddedRegistrationDiscovery = embeddedRegistrationDiscovery
|
||||
?? (embedInitialDiscovery ? discoveries.first : nil)
|
||||
self.embeddedRegistrationDiscoveryIsComplete = embeddedRegistrationDiscoveryIsComplete
|
||||
?? (embedInitialDiscovery ? true : nil)
|
||||
self.registrationRevision = registrationRevision
|
||||
self.registrationError = registrationError
|
||||
self.connectivitySnapshotsProvenComplete = connectivitySnapshotsProvenComplete
|
||||
}
|
||||
|
||||
func register(
|
||||
@@ -1165,7 +1212,8 @@ private actor TestRevisionedClientBroker:
|
||||
?? discoveries.first?.revision,
|
||||
binding: binding,
|
||||
relay: .issued(relay),
|
||||
discovery: embeddedRegistrationDiscovery
|
||||
discovery: embeddedRegistrationDiscovery,
|
||||
discoveryComplete: embeddedRegistrationDiscoveryIsComplete
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1184,11 +1232,13 @@ private actor TestRevisionedClientBroker:
|
||||
let discovery = discoveries.removeFirst()
|
||||
return CmxConnectivitySyncResponse(
|
||||
legacySnapshot: discovery,
|
||||
knownRevision: knownRevision
|
||||
knownRevision: knownRevision,
|
||||
snapshotComplete: connectivitySnapshotsProvenComplete
|
||||
)
|
||||
}
|
||||
|
||||
func discover() throws -> CmxIrohDiscoveryResponse {
|
||||
discoveryCount += 1
|
||||
guard let discovery = discoveries.first else {
|
||||
throw TestIrohTransportError.unsupported
|
||||
}
|
||||
|
||||
+93
@@ -70,6 +70,99 @@ struct CmxIrohConnectionCloseAttributionTests {
|
||||
)
|
||||
}
|
||||
|
||||
// The uniffi boundary returns quinn ConnectionError DISPLAY strings from
|
||||
// Connection.closed()/close_reason() ("timed out", "closed",
|
||||
// "closed by peer: ..."), not the Debug fragments matched above. Every
|
||||
// production close cause fell through to unknown/unknown until these
|
||||
// formats were recognized (https://github.com/manaflow-ai/cmux/issues/9169).
|
||||
|
||||
@Test
|
||||
func classifiesDisplayIdleTimeout() {
|
||||
#expect(
|
||||
CmxIrohConnectionCloseAttribution.classify("timed out")
|
||||
== CmxIrohConnectionCloseAttribution(
|
||||
initiator: .timedOut,
|
||||
applicationErrorCode: nil,
|
||||
failureKind: .transportIdleTimedOut
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func classifiesDisplayLocalClose() {
|
||||
#expect(
|
||||
CmxIrohConnectionCloseAttribution.classify("closed")
|
||||
== CmxIrohConnectionCloseAttribution(
|
||||
initiator: .local,
|
||||
applicationErrorCode: nil,
|
||||
failureKind: .cancelled
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func classifiesDisplayPeerApplicationCloseWithBareCode() {
|
||||
#expect(
|
||||
CmxIrohConnectionCloseAttribution.classify("closed by peer: 42")
|
||||
== CmxIrohConnectionCloseAttribution(
|
||||
initiator: .remote,
|
||||
applicationErrorCode: 42,
|
||||
failureKind: .connectionClosed
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func classifiesDisplayPeerApplicationCloseWithReasonAndCode() {
|
||||
#expect(
|
||||
CmxIrohConnectionCloseAttribution.classify(
|
||||
"closed by peer: going away (code 42)"
|
||||
) == CmxIrohConnectionCloseAttribution(
|
||||
initiator: .remote,
|
||||
applicationErrorCode: 42,
|
||||
failureKind: .connectionClosed
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func classifiesDisplayPeerReset() {
|
||||
#expect(
|
||||
CmxIrohConnectionCloseAttribution.classify("reset by peer")
|
||||
== CmxIrohConnectionCloseAttribution(
|
||||
initiator: .remote,
|
||||
applicationErrorCode: nil,
|
||||
failureKind: .connectionClosed
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func classifiesDisplayPeerTransportAbortWithoutStealingApplicationCode() {
|
||||
#expect(
|
||||
CmxIrohConnectionCloseAttribution.classify(
|
||||
"aborted by peer: CONNECTION_REFUSED: server busy"
|
||||
) == CmxIrohConnectionCloseAttribution(
|
||||
initiator: .remote,
|
||||
applicationErrorCode: nil,
|
||||
failureKind: .connectionClosed
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func displayPeerReasonCannotSpoofInitiatorOrTimeoutKind() {
|
||||
#expect(
|
||||
CmxIrohConnectionCloseAttribution.classify(
|
||||
"closed by peer: timed out (code 7)"
|
||||
) == CmxIrohConnectionCloseAttribution(
|
||||
initiator: .remote,
|
||||
applicationErrorCode: 7,
|
||||
failureKind: .connectionClosed
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func authoritativeDriverCauseSupersedesTentativeLocalClose() async {
|
||||
let store = CmxIrohConnectionCloseAttributionStore()
|
||||
|
||||
+44
-39
@@ -5,7 +5,7 @@ import Testing
|
||||
|
||||
extension CmxIrohEndpointServerTests {
|
||||
@Test
|
||||
func fullServerReservesOnePendingReconnectForAnActiveIdentity() async throws {
|
||||
func fullServerRejectsReconnectCandidateWithoutDisruptingActiveConnection() async throws {
|
||||
let localIdentity = try CmxIrohPeerIdentity(
|
||||
endpointID: String(repeating: "8", count: 64)
|
||||
)
|
||||
@@ -27,8 +27,6 @@ extension CmxIrohEndpointServerTests {
|
||||
)
|
||||
_ = try await supervisor.activate()
|
||||
let started = EndpointServerRecorder()
|
||||
let admitted = EndpointServerRecorder()
|
||||
let replacementAuthorization = EndpointServerHandlerBlocker()
|
||||
let connectionLifetime = EndpointServerHandlerBlocker()
|
||||
let server = CmxIrohEndpointServer(
|
||||
supervisor: supervisor,
|
||||
@@ -37,11 +35,7 @@ extension CmxIrohEndpointServerTests {
|
||||
) { connection, generation, markAdmitted in
|
||||
let identity = await connection.remoteIdentity()
|
||||
await started.record(identity: identity, generation: generation)
|
||||
if await started.recordedCount() == 2 {
|
||||
await replacementAuthorization.wait()
|
||||
}
|
||||
#expect(await markAdmitted())
|
||||
await admitted.record(identity: identity, generation: generation)
|
||||
await connectionLifetime.wait()
|
||||
}
|
||||
let active = TestIrohConnection(
|
||||
@@ -56,28 +50,26 @@ extension CmxIrohEndpointServerTests {
|
||||
remoteIdentity: newIdentity,
|
||||
bidirectionalStreams: []
|
||||
)
|
||||
var activeCloses = await active.closeEvents().makeAsyncIterator()
|
||||
var replacementCloses = await replacement.closeEvents().makeAsyncIterator()
|
||||
var newcomerCloses = await newcomer.closeEvents().makeAsyncIterator()
|
||||
|
||||
await server.start()
|
||||
await endpoint.enqueue(active)
|
||||
#expect(await started.next().identity == activeIdentity)
|
||||
#expect(await admitted.next().identity == activeIdentity)
|
||||
|
||||
await endpoint.enqueue(replacement)
|
||||
for _ in 0 ..< 100 {
|
||||
let startedCount = await started.recordedCount()
|
||||
let replacementCloseCount = await replacement.observedCloseCallCount()
|
||||
guard startedCount < 2, replacementCloseCount == 0 else { break }
|
||||
guard startedCount == 1, replacementCloseCount == 0 else { break }
|
||||
await Task.yield()
|
||||
}
|
||||
let replacementStarted = await started.recordedCount() == 2
|
||||
#expect(replacementStarted)
|
||||
guard replacementStarted else {
|
||||
await connectionLifetime.releaseAll()
|
||||
await server.stop()
|
||||
await supervisor.deactivate()
|
||||
return
|
||||
#expect(await started.recordedCount() == 1)
|
||||
let replacementCloseCount = await replacement.observedCloseCallCount()
|
||||
#expect(replacementCloseCount == 1)
|
||||
if replacementCloseCount == 1 {
|
||||
let replacementClose = try #require(await replacementCloses.next())
|
||||
#expect(replacementClose.reason == "connection_capacity")
|
||||
}
|
||||
#expect(await active.observedCloseCallCount() == 0)
|
||||
|
||||
@@ -86,12 +78,6 @@ extension CmxIrohEndpointServerTests {
|
||||
let newcomerClose = try #require(await newcomerCloses.next())
|
||||
#expect(newcomerClose.reason == "connection_capacity")
|
||||
|
||||
await replacementAuthorization.releaseAll()
|
||||
#expect(await admitted.next().identity == activeIdentity)
|
||||
let activeClose = try #require(await activeCloses.next())
|
||||
#expect(activeClose.reason == "superseded_connection")
|
||||
#expect(await replacement.observedCloseCallCount() == 0)
|
||||
|
||||
await connectionLifetime.releaseAll()
|
||||
await server.stop()
|
||||
await supervisor.deactivate()
|
||||
@@ -156,7 +142,7 @@ extension CmxIrohEndpointServerTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func sameEndpointReconnectsDoNotConsumeEveryLiveConnectionSlot() async throws {
|
||||
func sameEndpointReconnectsReplaceOldestUnreadyConnectionAtBound() async throws {
|
||||
let localIdentity = try CmxIrohPeerIdentity(
|
||||
endpointID: String(repeating: "1", count: 64)
|
||||
)
|
||||
@@ -192,23 +178,42 @@ extension CmxIrohEndpointServerTests {
|
||||
}
|
||||
|
||||
await server.start()
|
||||
var reconnects: [TestIrohConnection] = []
|
||||
for _ in 0 ..< 3 {
|
||||
let reconnect = TestIrohConnection(
|
||||
remoteIdentity: firstRemoteIdentity,
|
||||
bidirectionalStreams: []
|
||||
)
|
||||
reconnects.append(reconnect)
|
||||
await endpoint.enqueue(reconnect)
|
||||
#expect(await recorder.next().identity == firstRemoteIdentity)
|
||||
if reconnects.count > 1 {
|
||||
await reconnects[reconnects.count - 2].waitUntilClosed()
|
||||
}
|
||||
let first = TestIrohConnection(
|
||||
remoteIdentity: firstRemoteIdentity,
|
||||
bidirectionalStreams: []
|
||||
)
|
||||
let replacement = TestIrohConnection(
|
||||
remoteIdentity: firstRemoteIdentity,
|
||||
bidirectionalStreams: []
|
||||
)
|
||||
let excessCandidate = TestIrohConnection(
|
||||
remoteIdentity: firstRemoteIdentity,
|
||||
bidirectionalStreams: []
|
||||
)
|
||||
var firstCloses = await first.closeEvents().makeAsyncIterator()
|
||||
|
||||
await endpoint.enqueue(first)
|
||||
#expect(await recorder.next().identity == firstRemoteIdentity)
|
||||
await endpoint.enqueue(replacement)
|
||||
#expect(await recorder.next().identity == firstRemoteIdentity)
|
||||
await endpoint.enqueue(excessCandidate)
|
||||
for _ in 0 ..< 100 {
|
||||
let recordedCount = await recorder.recordedCount()
|
||||
let firstCloseCount = await first.observedCloseCallCount()
|
||||
if recordedCount == 3, firstCloseCount == 1 { break }
|
||||
await Task.yield()
|
||||
}
|
||||
#expect(await reconnects[0].observedCloseCallCount() == 1)
|
||||
#expect(await reconnects[1].observedCloseCallCount() == 1)
|
||||
#expect(await reconnects[2].observedCloseCallCount() == 0)
|
||||
|
||||
#expect(await recorder.recordedCount() == 3)
|
||||
#expect(await recorder.next().identity == firstRemoteIdentity)
|
||||
#expect(await first.observedCloseCallCount() == 1)
|
||||
#expect(await replacement.observedCloseCallCount() == 0)
|
||||
#expect(await excessCandidate.observedCloseCallCount() == 0)
|
||||
let firstCloseCount = await first.observedCloseCallCount()
|
||||
if firstCloseCount == 1 {
|
||||
let close = try #require(await firstCloses.next())
|
||||
#expect(close.reason == "superseded_unready_connection")
|
||||
}
|
||||
|
||||
await endpoint.enqueue(
|
||||
TestIrohConnection(
|
||||
|
||||
+62
-8
@@ -265,7 +265,7 @@ struct CmxIrohEndpointServerTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func newlyAdmittedConnectionSupersedesOlderConnectionFromSameEndpointIdentity() async throws {
|
||||
func newlyAdmittedConnectionPreservesOlderConnectionFromSameEndpointIdentity() async throws {
|
||||
let localIdentity = try CmxIrohPeerIdentity(
|
||||
endpointID: String(repeating: "a", count: 64)
|
||||
)
|
||||
@@ -304,8 +304,6 @@ struct CmxIrohEndpointServerTests {
|
||||
remoteIdentity: remoteIdentity,
|
||||
bidirectionalStreams: []
|
||||
)
|
||||
var firstCloses = await first.closeEvents().makeAsyncIterator()
|
||||
|
||||
await server.start()
|
||||
await endpoint.enqueue(first)
|
||||
#expect(await recorder.next().identity == remoteIdentity)
|
||||
@@ -313,12 +311,68 @@ struct CmxIrohEndpointServerTests {
|
||||
#expect(await recorder.next().identity == remoteIdentity)
|
||||
|
||||
for _ in 0 ..< 20 { await Task.yield() }
|
||||
let firstCloseCount = await first.observedCloseCallCount()
|
||||
#expect(firstCloseCount == 1)
|
||||
if firstCloseCount == 1 {
|
||||
let close = try #require(await firstCloses.next())
|
||||
#expect(close.reason == "superseded_connection")
|
||||
#expect(await first.observedCloseCallCount() == 0)
|
||||
#expect(await replacement.observedCloseCallCount() == 0)
|
||||
|
||||
await blocker.releaseAll()
|
||||
await server.stop()
|
||||
await supervisor.deactivate()
|
||||
}
|
||||
|
||||
@Test
|
||||
func usableConnectionRetiresOlderConnectionsFromSameEndpointIdentity() async throws {
|
||||
let localIdentity = try CmxIrohPeerIdentity(
|
||||
endpointID: String(repeating: "c", count: 64)
|
||||
)
|
||||
let remoteIdentity = try CmxIrohPeerIdentity(
|
||||
endpointID: String(repeating: "d", count: 64)
|
||||
)
|
||||
let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity)
|
||||
let supervisor = CmxIrohEndpointSupervisor(
|
||||
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
|
||||
configuration: try CmxIrohEndpointConfiguration(
|
||||
secretKey: CmxIrohSecretKey(bytes: Data(repeating: 9, count: 32)),
|
||||
alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn],
|
||||
managedRelayURLs: [],
|
||||
relays: []
|
||||
)
|
||||
)
|
||||
_ = try await supervisor.activate()
|
||||
let blocker = EndpointServerHandlerBlocker()
|
||||
let recorder = EndpointServerRecorder()
|
||||
let server = CmxIrohEndpointServer(supervisor: supervisor) {
|
||||
connection,
|
||||
generation,
|
||||
admission in
|
||||
await recorder.record(
|
||||
identity: await connection.remoteIdentity(),
|
||||
generation: generation
|
||||
)
|
||||
#expect(await admission())
|
||||
if await recorder.recordedCount() == 2 {
|
||||
#expect(await admission.markUsable())
|
||||
}
|
||||
await blocker.wait()
|
||||
}
|
||||
let first = TestIrohConnection(
|
||||
remoteIdentity: remoteIdentity,
|
||||
bidirectionalStreams: []
|
||||
)
|
||||
let replacement = TestIrohConnection(
|
||||
remoteIdentity: remoteIdentity,
|
||||
bidirectionalStreams: []
|
||||
)
|
||||
var firstCloses = await first.closeEvents().makeAsyncIterator()
|
||||
|
||||
await server.start()
|
||||
await endpoint.enqueue(first)
|
||||
#expect(await recorder.next().identity == remoteIdentity)
|
||||
await endpoint.enqueue(replacement)
|
||||
#expect(await recorder.next().identity == remoteIdentity)
|
||||
await first.waitUntilClosed()
|
||||
|
||||
let close = try #require(await firstCloses.next())
|
||||
#expect(close.reason == "superseded_connection")
|
||||
#expect(await replacement.observedCloseCallCount() == 0)
|
||||
|
||||
await blocker.releaseAll()
|
||||
|
||||
+4
@@ -573,6 +573,10 @@ extension CmxIrohHostRuntimeTests {
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
CmxIrohTrustBrokerClientError.rejected(
|
||||
statusCode: 401,
|
||||
code: "unauthorized"
|
||||
),
|
||||
CmxIrohTrustBrokerClientError.rejected(
|
||||
statusCode: 408,
|
||||
code: "request_timeout"
|
||||
|
||||
+125
-33
@@ -6,6 +6,87 @@ import Testing
|
||||
@testable import CmuxIrohTransport
|
||||
|
||||
extension CmxIrohHostRuntimeTests {
|
||||
@Test
|
||||
func startupFetchesAuthoritativeDiscoveryWhenRegistrationSnapshotIsIncomplete() async throws {
|
||||
let fixture = try HostRuntimeFixture()
|
||||
let pageOneBinding = try HostRuntimeFixture.binding(
|
||||
endpointID: fixture.endpointID.endpointID,
|
||||
bindingID: "123e4567-e89b-42d3-a456-426614174099"
|
||||
)
|
||||
let pageOne = try HostRuntimeFixture.discovery(
|
||||
binding: pageOneBinding,
|
||||
relays: HostRuntimeFixture.relayURLs,
|
||||
revision: 1
|
||||
)
|
||||
let completeDiscovery = try HostRuntimeFixture.discovery(
|
||||
binding: fixture.binding,
|
||||
relays: HostRuntimeFixture.relayURLs,
|
||||
revision: 1
|
||||
)
|
||||
let broker = TestIrohHostBroker(
|
||||
registrationBinding: fixture.binding,
|
||||
discovery: completeDiscovery,
|
||||
embeddedRegistrationDiscovery: pageOne,
|
||||
embeddedRegistrationDiscoveryIsComplete: false,
|
||||
registrationRevision: 1
|
||||
)
|
||||
let runtime = CmxIrohHostRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: [
|
||||
TestIrohEndpoint(identity: fixture.endpointID),
|
||||
]),
|
||||
broker: broker,
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: fixture.pendingRevocations(),
|
||||
handleTransport: { session, _ in await session.close() }
|
||||
)
|
||||
|
||||
try await runtime.start()
|
||||
|
||||
#expect(await broker.observedDiscoveryCount() == 1)
|
||||
#expect(await runtime.snapshot().state == .active)
|
||||
#expect(await runtime.connectivityEngine?.snapshot().routeRevision == 1)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func truncatedEmbeddedDiscoveryFallsBackToAuthoritativeDiscovery() async throws {
|
||||
let fixture = try HostRuntimeFixture()
|
||||
let authoritative = try HostRuntimeFixture.discovery(
|
||||
binding: fixture.binding,
|
||||
relays: HostRuntimeFixture.relayURLs,
|
||||
revision: 7
|
||||
)
|
||||
let truncated = CmxIrohDiscoveryResponse(
|
||||
routeContractVersion: authoritative.routeContractVersion,
|
||||
revision: authoritative.revision,
|
||||
bindings: [],
|
||||
relayFleet: authoritative.relayFleet,
|
||||
lanRendezvous: authoritative.lanRendezvous,
|
||||
grantVerificationKeys: authoritative.grantVerificationKeys
|
||||
)
|
||||
let broker = TestIrohHostBroker(
|
||||
registrationBinding: fixture.binding,
|
||||
discovery: authoritative,
|
||||
embedDiscoveryInRegistration: true,
|
||||
embeddedRegistrationDiscovery: truncated
|
||||
)
|
||||
let runtime = CmxIrohHostRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: [
|
||||
TestIrohEndpoint(identity: fixture.endpointID),
|
||||
]),
|
||||
broker: broker,
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: fixture.pendingRevocations(),
|
||||
handleTransport: { session, _ in await session.close() }
|
||||
)
|
||||
|
||||
try await runtime.start()
|
||||
|
||||
#expect(await runtime.snapshot().state == .active)
|
||||
#expect(await broker.observedDiscoveryCount() == 1)
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func embeddedDiscoveryMustExactlyMatchTheRegistrationRevision() async throws {
|
||||
let fixture = try HostRuntimeFixture()
|
||||
@@ -77,39 +158,6 @@ extension CmxIrohHostRuntimeTests {
|
||||
#expect(await runtime.snapshot().state == .failed)
|
||||
}
|
||||
|
||||
@Test
|
||||
func unauthorizedRegistrationRefreshDeactivatesActiveEndpoint() async throws {
|
||||
let fixture = try HostRuntimeFixture()
|
||||
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
|
||||
let broker = TestIrohHostBroker(
|
||||
registrationBinding: fixture.binding,
|
||||
discovery: fixture.discovery,
|
||||
subsequentRegistrationErrors: [
|
||||
.rejected(statusCode: 401, code: "unauthorized"),
|
||||
]
|
||||
)
|
||||
let deactivations = HostRuntimeDeactivationRecorder()
|
||||
let runtime = CmxIrohHostRuntime(
|
||||
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
|
||||
broker: broker,
|
||||
configuration: fixture.configuration,
|
||||
pendingRevocations: fixture.pendingRevocations(),
|
||||
handleTransport: { session, _ in await session.close() },
|
||||
handleDeactivation: { bindingID in
|
||||
await deactivations.record(bindingID)
|
||||
}
|
||||
)
|
||||
try await runtime.start()
|
||||
|
||||
await endpoint.emit(.networkChanged)
|
||||
await broker.waitForRegistrationCount(2)
|
||||
await deactivations.waitForCount(1)
|
||||
|
||||
#expect(await runtime.snapshot().state == .failed)
|
||||
#expect(await endpoint.observedCloseCallCount() == 1)
|
||||
#expect(await deactivations.values() == [fixture.binding.bindingID])
|
||||
}
|
||||
|
||||
@Test
|
||||
func networkChangeDuringRegistrationIsObservedAfterStartup() async throws {
|
||||
let fixture = try HostRuntimeFixture()
|
||||
@@ -297,6 +345,38 @@ extension CmxIrohHostRuntimeTests {
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func cachedConnectivityFallbackPublishesResolvedBinding() async throws {
|
||||
let fixture = try HostRuntimeFixture()
|
||||
let cachedFixture = try fixture.cachedPolicyFixture()
|
||||
let now = cachedFixture.now
|
||||
let resolvedBindings = HostRuntimeResolvedBindingRecorder()
|
||||
let runtime = CmxIrohHostRuntime(
|
||||
factory: TestIrohEndpointFactory(
|
||||
endpoints: [TestIrohEndpoint(identity: fixture.endpointID)]
|
||||
),
|
||||
broker: TestIrohHostBroker(
|
||||
registrationBinding: fixture.binding,
|
||||
discovery: fixture.discovery,
|
||||
registrationError: .connectivity
|
||||
),
|
||||
configuration: fixture.configuration(
|
||||
cachedHostPolicy: try cachedFixture.policy()
|
||||
),
|
||||
pendingRevocations: fixture.pendingRevocations(),
|
||||
now: { now },
|
||||
handleTransport: { session, _ in await session.close() },
|
||||
handleRoute: { binding, _ in
|
||||
await resolvedBindings.record(binding)
|
||||
}
|
||||
)
|
||||
|
||||
try await runtime.start()
|
||||
|
||||
#expect(await resolvedBindings.values() == [cachedFixture.binding])
|
||||
await runtime.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
func forgedCachedPolicyFailsAfterConnectivityFailure() async throws {
|
||||
let fixture = try HostRuntimeFixture()
|
||||
@@ -433,3 +513,15 @@ extension CmxIrohHostRuntimeTests {
|
||||
#expect(await runtime.snapshot().state == .failed)
|
||||
}
|
||||
}
|
||||
|
||||
private actor HostRuntimeResolvedBindingRecorder {
|
||||
private var bindings: [CmxIrohBrokerBindingMetadata] = []
|
||||
|
||||
func record(_ binding: CmxIrohBrokerBindingMetadata) {
|
||||
bindings.append(binding)
|
||||
}
|
||||
|
||||
func values() -> [CmxIrohBrokerBindingMetadata] {
|
||||
bindings
|
||||
}
|
||||
}
|
||||
|
||||
+68
-5
@@ -348,6 +348,8 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing {
|
||||
private let subsequentRegistrationHook: (@Sendable () async -> Void)?
|
||||
private let relayIssueHook: (@Sendable () async -> Void)?
|
||||
private let embedDiscoveryStartingAtRegistrationCount: Int?
|
||||
private let embeddedRegistrationDiscovery: CmxIrohDiscoveryResponse?
|
||||
private let embeddedRegistrationDiscoveryIsComplete: Bool?
|
||||
private let registrationRevision: UInt64?
|
||||
private var preflightErrors: [CmxIrohBrokerCooldownError]
|
||||
private var subsequentRegistrationErrors: [CmxIrohTrustBrokerClientError]
|
||||
@@ -375,6 +377,8 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing {
|
||||
relayIssueHook: (@Sendable () async -> Void)? = nil,
|
||||
embedDiscoveryInRegistration: Bool = false,
|
||||
embedDiscoveryStartingAtRegistrationCount: Int? = nil,
|
||||
embeddedRegistrationDiscovery: CmxIrohDiscoveryResponse? = nil,
|
||||
embeddedRegistrationDiscoveryIsComplete: Bool? = nil,
|
||||
registrationRevision: UInt64? = nil,
|
||||
preflightErrors: [CmxIrohBrokerCooldownError] = [],
|
||||
subsequentRegistrationErrors: [CmxIrohTrustBrokerClientError] = []
|
||||
@@ -391,6 +395,9 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing {
|
||||
embedDiscoveryInRegistration
|
||||
? 1
|
||||
: embedDiscoveryStartingAtRegistrationCount
|
||||
self.embeddedRegistrationDiscovery = embeddedRegistrationDiscovery
|
||||
self.embeddedRegistrationDiscoveryIsComplete =
|
||||
embeddedRegistrationDiscoveryIsComplete
|
||||
self.registrationRevision = registrationRevision
|
||||
self.preflightErrors = preflightErrors
|
||||
self.subsequentRegistrationErrors = subsequentRegistrationErrors
|
||||
@@ -432,14 +439,17 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing {
|
||||
let embedsDiscovery = embedDiscoveryStartingAtRegistrationCount
|
||||
.map { registrationCount >= $0 }
|
||||
?? false
|
||||
let embeddedDiscovery = embeddedRegistrationDiscovery
|
||||
?? (embedsDiscovery ? discoveryResponses[0] : nil)
|
||||
return CmxIrohRegistrationResponse(
|
||||
revision: registrationRevision
|
||||
?? (embedsDiscovery ? discoveryResponses[0].revision : nil),
|
||||
?? embeddedDiscovery?.revision,
|
||||
binding: binding,
|
||||
relay: .unavailable,
|
||||
discovery: embedsDiscovery
|
||||
? discoveryResponses[0]
|
||||
: nil
|
||||
discovery: embeddedDiscovery,
|
||||
discoveryComplete: embeddedRegistrationDiscovery == nil
|
||||
? (embedsDiscovery ? true : nil)
|
||||
: embeddedRegistrationDiscoveryIsComplete
|
||||
)
|
||||
}
|
||||
|
||||
@@ -542,9 +552,62 @@ actor TestIrohHostBroker: CmxIrohHostBrokerServing {
|
||||
|
||||
actor HostRuntimeBindingRecorder {
|
||||
private var recordedCount = 0
|
||||
private var waiters: [
|
||||
UUID: (minimum: Int, continuation: CheckedContinuation<Void, Never>)
|
||||
] = [:]
|
||||
|
||||
func record() {
|
||||
recordedCount += 1
|
||||
let readyIDs = waiters.compactMap { id, waiter in
|
||||
recordedCount >= waiter.minimum ? id : nil
|
||||
}
|
||||
for id in readyIDs {
|
||||
waiters.removeValue(forKey: id)?.continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func record() { recordedCount += 1 }
|
||||
func count() -> Int { recordedCount }
|
||||
|
||||
func waitForCount(_ count: Int, timeout: Duration) async -> Bool {
|
||||
if recordedCount >= count { return true }
|
||||
return await withTaskGroup(of: Bool.self) { group in
|
||||
group.addTask {
|
||||
await self.waitForCount(count)
|
||||
return !Task.isCancelled
|
||||
}
|
||||
group.addTask {
|
||||
do {
|
||||
try await ContinuousClock().sleep(for: timeout)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
let result = await group.next() ?? false
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForCount(_ count: Int) async {
|
||||
if recordedCount >= count { return }
|
||||
let id = UUID()
|
||||
await withTaskCancellationHandler {
|
||||
await withCheckedContinuation { continuation in
|
||||
if Task.isCancelled {
|
||||
continuation.resume()
|
||||
} else {
|
||||
waiters[id] = (count, continuation)
|
||||
}
|
||||
}
|
||||
} onCancel: {
|
||||
Task { await self.cancelWaiter(id) }
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelWaiter(_ id: UUID) {
|
||||
waiters.removeValue(forKey: id)?.continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
actor HostRuntimeRouteRecorder {
|
||||
|
||||
+24
@@ -63,4 +63,28 @@ struct CmxIrohRetryScheduleTests {
|
||||
jitterUnitInterval: 1
|
||||
) == 750)
|
||||
}
|
||||
|
||||
@Test
|
||||
func relayPolicyScheduleIsCauseAwareOnEveryPlatform() {
|
||||
let authorization = CmxIrohRetrySchedule.relayPolicy(
|
||||
for: .authorizationFailed
|
||||
)
|
||||
#expect(authorization.delay(
|
||||
failureCount: 0,
|
||||
retryAfterSeconds: nil,
|
||||
jitterUnitInterval: 0
|
||||
) == 2)
|
||||
#expect(authorization.delay(
|
||||
failureCount: 20,
|
||||
retryAfterSeconds: nil,
|
||||
jitterUnitInterval: 0
|
||||
) == 120)
|
||||
|
||||
let connectivity = CmxIrohRetrySchedule.relayPolicy(for: .offline)
|
||||
#expect(connectivity.delay(
|
||||
failureCount: 0,
|
||||
retryAfterSeconds: nil,
|
||||
jitterUnitInterval: 0
|
||||
) == 30)
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CmuxIrohTransport
|
||||
|
||||
/// Regression coverage for the wake-time authorization outage: a broker 401
|
||||
/// used to tear down the whole verified runtime (endpoint, routes, offline
|
||||
/// cache) and nap for 30s+ of backoff, turning a seconds-long token rotation
|
||||
/// race into a multi-minute connectivity gap on every app foreground.
|
||||
struct CmxIrohTrustBrokerClientAuthClassifierTests {
|
||||
@Test
|
||||
func unauthorizedRejectionPreservesVerifiedPolicyDuringRefresh() {
|
||||
#expect(CmxIrohTrustBrokerClientError.preservesVerifiedPolicyDuringRefresh(
|
||||
CmxIrohTrustBrokerClientError.rejected(
|
||||
statusCode: 401,
|
||||
code: "unauthorized"
|
||||
)
|
||||
))
|
||||
#expect(!CmxIrohTrustBrokerClientError.preservesVerifiedPolicyDuringRefresh(
|
||||
CmxIrohTrustBrokerClientError.rejected(statusCode: 403, code: nil)
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func unauthorizedRejectionRetriesInitialActivation() {
|
||||
#expect(!CmxIrohTrustBrokerClientError.retriesInitialActivation(
|
||||
CmxIrohTrustBrokerClientError.rejected(
|
||||
statusCode: 401,
|
||||
code: "unauthorized"
|
||||
)
|
||||
))
|
||||
// 401 and 403 can both be durable authorization failures; initial
|
||||
// activation must return them to the auth lifecycle instead of spin.
|
||||
#expect(!CmxIrohTrustBrokerClientError.retriesInitialActivation(
|
||||
CmxIrohTrustBrokerClientError.rejected(statusCode: 403, code: nil)
|
||||
))
|
||||
}
|
||||
}
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CmuxIrohTransport
|
||||
|
||||
/// The broker client's exactly-once credential recovery: a pair that was
|
||||
/// coherent at capture can still be rejected when another lane rotates the
|
||||
/// session between capture and server validation (the wake-time RPC force
|
||||
/// refresh, most commonly). One retry with a pair minted after the rejection
|
||||
/// absorbs the race; a second rejection is authoritative.
|
||||
@Suite(.serialized)
|
||||
struct CmxIrohTrustBrokerClientAuthRecoveryTests {
|
||||
private actor AccountSnapshotSource {
|
||||
private var snapshots: [CmxIrohAccountCredentialSnapshot]
|
||||
private var lastSnapshot: CmxIrohAccountCredentialSnapshot?
|
||||
private(set) var forceRefreshCount = 0
|
||||
|
||||
init(_ snapshots: [CmxIrohAccountCredentialSnapshot]) {
|
||||
self.snapshots = snapshots
|
||||
self.lastSnapshot = snapshots.last
|
||||
}
|
||||
|
||||
func snapshot() -> CmxIrohAccountCredentialSnapshot? {
|
||||
guard !snapshots.isEmpty else { return lastSnapshot }
|
||||
let next = snapshots.removeFirst()
|
||||
lastSnapshot = next
|
||||
return next
|
||||
}
|
||||
|
||||
func forceRefresh() {
|
||||
forceRefreshCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
private actor RecoveryRecorder {
|
||||
private(set) var rejectedPairs: [CmxIrohBrokerCredentials] = []
|
||||
private let recovered: CmxIrohBrokerCredentials?
|
||||
|
||||
init(recovered: CmxIrohBrokerCredentials?) {
|
||||
self.recovered = recovered
|
||||
}
|
||||
|
||||
func recover(
|
||||
_ rejected: CmxIrohBrokerCredentials
|
||||
) -> CmxIrohBrokerCredentials? {
|
||||
rejectedPairs.append(rejected)
|
||||
return recovered
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func unauthorizedRejectionRetriesOnceWithRecoveredPair() async throws {
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(status: 401, body: #"{"error":"unauthorized"}"#),
|
||||
.json(status: 201, body: Self.challengeBody),
|
||||
])
|
||||
let recorder = RecoveryRecorder(recovered: CmxIrohBrokerCredentials(
|
||||
accessToken: "fresh-access",
|
||||
refreshToken: "fresh-refresh"
|
||||
))
|
||||
let client = try makeClient(transport: transport, recorder: recorder)
|
||||
|
||||
let response = try await client.issueChallenge(try Self.challengeRequest)
|
||||
|
||||
#expect(response.challengeID == "123e4567-e89b-42d3-a456-426614174000")
|
||||
let requests = await transport.requests()
|
||||
#expect(requests.count == 2)
|
||||
#expect(requests.first?.value(
|
||||
forHTTPHeaderField: "Authorization"
|
||||
) == "Bearer stale-access")
|
||||
#expect(requests.last?.value(
|
||||
forHTTPHeaderField: "Authorization"
|
||||
) == "Bearer fresh-access")
|
||||
#expect(requests.last?.value(
|
||||
forHTTPHeaderField: "X-Stack-Refresh-Token"
|
||||
) == "fresh-refresh")
|
||||
let rejected = await recorder.rejectedPairs
|
||||
#expect(rejected.count == 1)
|
||||
#expect(rejected.first?.accessToken == "stale-access")
|
||||
#expect(rejected.first?.refreshToken == "stale-refresh")
|
||||
}
|
||||
|
||||
@Test
|
||||
func unauthorizedRejectionWithoutRecoveryPropagates() async throws {
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(status: 401, body: #"{"error":"unauthorized"}"#),
|
||||
])
|
||||
let client = try CmxIrohTrustBrokerClient(
|
||||
baseURL: #require(URL(string: "https://cmux.example")),
|
||||
tokenSource: CmxIrohBrokerTokenSource(credentialPair: {
|
||||
CmxIrohBrokerCredentials(
|
||||
accessToken: "stale-access",
|
||||
refreshToken: "stale-refresh"
|
||||
)
|
||||
}),
|
||||
transport: transport
|
||||
)
|
||||
|
||||
await #expect(throws: CmxIrohTrustBrokerClientError.rejected(
|
||||
statusCode: 401,
|
||||
code: "unauthorized"
|
||||
)) {
|
||||
_ = try await client.issueChallenge(try Self.challengeRequest)
|
||||
}
|
||||
#expect(await transport.requests().count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func repeatedUnauthorizedRejectionStopsAfterOneRetry() async throws {
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(status: 401, body: #"{"error":"unauthorized"}"#),
|
||||
.json(status: 401, body: #"{"error":"unauthorized"}"#),
|
||||
])
|
||||
let recorder = RecoveryRecorder(recovered: CmxIrohBrokerCredentials(
|
||||
accessToken: "fresh-access",
|
||||
refreshToken: "fresh-refresh"
|
||||
))
|
||||
let client = try makeClient(transport: transport, recorder: recorder)
|
||||
|
||||
await #expect(throws: CmxIrohTrustBrokerClientError.rejected(
|
||||
statusCode: 401,
|
||||
code: "unauthorized"
|
||||
)) {
|
||||
_ = try await client.issueChallenge(try Self.challengeRequest)
|
||||
}
|
||||
#expect(await transport.requests().count == 2)
|
||||
#expect(await recorder.rejectedPairs.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func forbiddenRejectionDoesNotInvokeRecovery() async throws {
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(status: 403, body: #"{"error":"forbidden"}"#),
|
||||
])
|
||||
let recorder = RecoveryRecorder(recovered: CmxIrohBrokerCredentials(
|
||||
accessToken: "fresh-access",
|
||||
refreshToken: "fresh-refresh"
|
||||
))
|
||||
let client = try makeClient(transport: transport, recorder: recorder)
|
||||
|
||||
await #expect(throws: CmxIrohTrustBrokerClientError.rejected(
|
||||
statusCode: 403,
|
||||
code: "forbidden"
|
||||
)) {
|
||||
_ = try await client.issueChallenge(try Self.challengeRequest)
|
||||
}
|
||||
#expect(await transport.requests().count == 1)
|
||||
#expect(await recorder.rejectedPairs.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func sharedAccountPinnedSourceReusesAnAlreadyRotatedPair() async throws {
|
||||
let stale = Self.accountSnapshot(
|
||||
accountID: "account-a",
|
||||
accessToken: "stale-access"
|
||||
)
|
||||
let fresh = Self.accountSnapshot(
|
||||
accountID: "account-a",
|
||||
accessToken: "fresh-access"
|
||||
)
|
||||
let snapshots = AccountSnapshotSource([stale, fresh])
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(status: 401, body: #"{"error":"unauthorized"}"#),
|
||||
.json(status: 201, body: Self.challengeBody),
|
||||
])
|
||||
let client = try CmxIrohTrustBrokerClient(
|
||||
baseURL: #require(URL(string: "https://cmux.example")),
|
||||
tokenSource: .accountPinned(
|
||||
to: "account-a",
|
||||
snapshot: { await snapshots.snapshot() },
|
||||
forceRefresh: { await snapshots.forceRefresh() }
|
||||
),
|
||||
transport: transport
|
||||
)
|
||||
|
||||
_ = try await client.issueChallenge(try Self.challengeRequest)
|
||||
|
||||
#expect(await snapshots.forceRefreshCount == 0)
|
||||
#expect(await transport.requests().count == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func sharedAccountPinnedSourceForceRefreshesAnUnchangedRejectedPairOnce() async throws {
|
||||
let stale = Self.accountSnapshot(
|
||||
accountID: "account-a",
|
||||
accessToken: "stale-access"
|
||||
)
|
||||
let fresh = Self.accountSnapshot(
|
||||
accountID: "account-a",
|
||||
accessToken: "fresh-access"
|
||||
)
|
||||
let snapshots = AccountSnapshotSource([stale, stale, fresh])
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(status: 401, body: #"{"error":"unauthorized"}"#),
|
||||
.json(status: 201, body: Self.challengeBody),
|
||||
])
|
||||
let client = try CmxIrohTrustBrokerClient(
|
||||
baseURL: #require(URL(string: "https://cmux.example")),
|
||||
tokenSource: .accountPinned(
|
||||
to: "account-a",
|
||||
snapshot: { await snapshots.snapshot() },
|
||||
forceRefresh: { await snapshots.forceRefresh() }
|
||||
),
|
||||
transport: transport
|
||||
)
|
||||
|
||||
_ = try await client.issueChallenge(try Self.challengeRequest)
|
||||
|
||||
#expect(await snapshots.forceRefreshCount == 1)
|
||||
#expect(await transport.requests().count == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func cachedPolicyRecoveryFailsClosedForAuthRejections() {
|
||||
#expect(CmxIrohClientRuntime.recoversWithCachedPolicy(
|
||||
CmxIrohTrustBrokerClientError.connectivity
|
||||
))
|
||||
#expect(!CmxIrohClientRuntime.recoversWithCachedPolicy(
|
||||
CmxIrohTrustBrokerClientError.rejected(
|
||||
statusCode: 401,
|
||||
code: "unauthorized"
|
||||
)
|
||||
))
|
||||
#expect(!CmxIrohClientRuntime.recoversWithCachedPolicy(
|
||||
CmxIrohTrustBrokerClientError.rejected(statusCode: 403, code: nil)
|
||||
))
|
||||
#expect(!CmxIrohClientRuntime.recoversWithCachedPolicy(
|
||||
CmxIrohTrustBrokerClientError.rejected(statusCode: 500, code: nil)
|
||||
))
|
||||
#expect(!CmxIrohClientRuntime.recoversWithCachedPolicy(
|
||||
CmxIrohTrustBrokerClientError.invalidResponse
|
||||
))
|
||||
}
|
||||
|
||||
private func makeClient(
|
||||
transport: RecordingBrokerTransport,
|
||||
recorder: RecoveryRecorder
|
||||
) throws -> CmxIrohTrustBrokerClient {
|
||||
try CmxIrohTrustBrokerClient(
|
||||
baseURL: #require(URL(string: "https://cmux.example")),
|
||||
tokenSource: CmxIrohBrokerTokenSource(
|
||||
credentialPair: {
|
||||
CmxIrohBrokerCredentials(
|
||||
accessToken: "stale-access",
|
||||
refreshToken: "stale-refresh"
|
||||
)
|
||||
},
|
||||
recoveredCredentialPair: { rejected in
|
||||
await recorder.recover(rejected)
|
||||
}
|
||||
),
|
||||
transport: transport
|
||||
)
|
||||
}
|
||||
|
||||
private static let challengeBody =
|
||||
#"{"challenge_id":"123e4567-e89b-42d3-a456-426614174000","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expires_at":"2026-07-10T01:00:00.000Z"}"#
|
||||
|
||||
private static let endpointID =
|
||||
"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8"
|
||||
|
||||
private static var challengeRequest: CmxIrohChallengeRequest {
|
||||
get throws {
|
||||
let secret = try CmxIrohSecretKey(
|
||||
bytes: Data((0 ..< 32).map(UInt8.init))
|
||||
)
|
||||
let identity = try CmxIrohIdentityMaterial(
|
||||
secretKey: secret,
|
||||
generation: 1
|
||||
)
|
||||
let signer = try CmxIrohRegistrationSigner(
|
||||
identity: identity,
|
||||
endpointID: endpointID
|
||||
)
|
||||
let payload = try CmxIrohRegistrationPayload(
|
||||
deviceID: "123e4567-e89b-42d3-a456-426614174001",
|
||||
appInstanceID: "123e4567-e89b-42d3-a456-426614174002",
|
||||
tag: "stable",
|
||||
platform: .ios,
|
||||
endpointID: endpointID,
|
||||
identityGeneration: 1,
|
||||
pairingEnabled: false,
|
||||
capabilities: ["control"],
|
||||
pathHints: [],
|
||||
now: Date(timeIntervalSince1970: 1_782_000_000)
|
||||
)
|
||||
return try signer.prepare(payload: payload).challengeRequest
|
||||
}
|
||||
}
|
||||
|
||||
private static func accountSnapshot(
|
||||
accountID: String,
|
||||
accessToken: String
|
||||
) -> CmxIrohAccountCredentialSnapshot {
|
||||
CmxIrohAccountCredentialSnapshot(
|
||||
accountID: accountID,
|
||||
credentials: CmxIrohBrokerCredentials(
|
||||
accessToken: accessToken,
|
||||
refreshToken: "\(accessToken)-refresh"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+107
-2
@@ -50,6 +50,7 @@ struct CmxIrohTrustBrokerClientTests {
|
||||
let response = try await client.register(prepared: prepared, signer: signer)
|
||||
|
||||
#expect(response.binding.tag == "stable")
|
||||
#expect(response.discoveryComplete == nil)
|
||||
#expect(await transport.requests().compactMap { $0.url?.path } == [
|
||||
"/api/devices/iroh/challenge",
|
||||
"/api/devices/iroh/register",
|
||||
@@ -65,6 +66,7 @@ struct CmxIrohTrustBrokerClientTests {
|
||||
)
|
||||
responseObject["revision"] = 7
|
||||
responseObject["discovery"] = try Self.discoveryObject(revision: 7)
|
||||
responseObject["discovery_complete"] = true
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(status: 201, body: try Self.jsonString(responseObject)),
|
||||
])
|
||||
@@ -82,6 +84,7 @@ struct CmxIrohTrustBrokerClientTests {
|
||||
#expect(response.revision == 7)
|
||||
#expect(response.discovery?.revision == 7)
|
||||
#expect(response.discovery?.bindings.count == 1)
|
||||
#expect(response.discoveryComplete == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -476,7 +479,7 @@ struct CmxIrohTrustBrokerClientTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func paginatedDiscoveryRejectsAnAccountRevisionChange() async throws {
|
||||
func paginatedDiscoveryRestartsAfterAnAccountRevisionChange() async throws {
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(
|
||||
status: 200,
|
||||
@@ -494,12 +497,110 @@ struct CmxIrohTrustBrokerClientTests {
|
||||
revision: 42
|
||||
)
|
||||
),
|
||||
.json(
|
||||
status: 200,
|
||||
body: try Self.discoveryResponse(
|
||||
bindingRange: 1 ..< 129,
|
||||
nextCursor: "cursor-2",
|
||||
revision: 42
|
||||
)
|
||||
),
|
||||
.json(
|
||||
status: 200,
|
||||
body: try Self.discoveryResponse(
|
||||
bindingRange: 129 ..< 130,
|
||||
nextCursor: nil,
|
||||
revision: 42
|
||||
)
|
||||
),
|
||||
])
|
||||
let client = try makeClient(transport: transport)
|
||||
|
||||
let discovery = try await client.discover()
|
||||
|
||||
#expect(discovery.revision == 42)
|
||||
#expect(discovery.bindings.count == 129)
|
||||
#expect(await transport.requests().map { $0.url?.query } == [
|
||||
"page_size=128",
|
||||
"page_size=128&cursor=cursor-1",
|
||||
"page_size=128",
|
||||
"page_size=128&cursor=cursor-2",
|
||||
])
|
||||
}
|
||||
|
||||
@Test
|
||||
func paginatedDiscoveryRestartsAfterAStaleCursorRejection() async throws {
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(
|
||||
status: 200,
|
||||
body: try Self.discoveryResponse(
|
||||
bindingRange: 1 ..< 129,
|
||||
nextCursor: "cursor-1",
|
||||
revision: 41
|
||||
)
|
||||
),
|
||||
.json(status: 409, body: #"{"error":"discovery_cursor_stale"}"#),
|
||||
.json(
|
||||
status: 200,
|
||||
body: try Self.discoveryResponse(
|
||||
bindingRange: 1 ..< 129,
|
||||
nextCursor: "cursor-2",
|
||||
revision: 42
|
||||
)
|
||||
),
|
||||
.json(
|
||||
status: 200,
|
||||
body: try Self.discoveryResponse(
|
||||
bindingRange: 129 ..< 130,
|
||||
nextCursor: nil,
|
||||
revision: 42
|
||||
)
|
||||
),
|
||||
])
|
||||
let client = try makeClient(transport: transport)
|
||||
|
||||
let discovery = try await client.discover()
|
||||
|
||||
#expect(discovery.revision == 42)
|
||||
#expect(discovery.bindings.count == 129)
|
||||
#expect(await transport.requests().map { $0.url?.query } == [
|
||||
"page_size=128",
|
||||
"page_size=128&cursor=cursor-1",
|
||||
"page_size=128",
|
||||
"page_size=128&cursor=cursor-2",
|
||||
])
|
||||
}
|
||||
|
||||
@Test
|
||||
func paginatedDiscoveryBoundsRepeatedSnapshotRestarts() async throws {
|
||||
let responses = try (0 ..< 3).flatMap { attempt in
|
||||
let revision = 41 + attempt
|
||||
return [
|
||||
RecordingBrokerTransport.Response.json(
|
||||
status: 200,
|
||||
body: try Self.discoveryResponse(
|
||||
bindingRange: 1 ..< 129,
|
||||
nextCursor: "cursor-\(attempt)",
|
||||
revision: revision
|
||||
)
|
||||
),
|
||||
RecordingBrokerTransport.Response.json(
|
||||
status: 200,
|
||||
body: try Self.discoveryResponse(
|
||||
bindingRange: 129 ..< 130,
|
||||
nextCursor: nil,
|
||||
revision: revision + 1
|
||||
)
|
||||
),
|
||||
]
|
||||
}
|
||||
let transport = RecordingBrokerTransport(responses: responses)
|
||||
let client = try makeClient(transport: transport)
|
||||
|
||||
await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) {
|
||||
_ = try await client.discover()
|
||||
}
|
||||
#expect(await transport.requests().count == 6)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -595,13 +696,16 @@ struct CmxIrohTrustBrokerClientTests {
|
||||
"changed": true,
|
||||
"reset": false,
|
||||
"snapshot": snapshot,
|
||||
"snapshot_complete": true,
|
||||
])
|
||||
let transport = RecordingBrokerTransport(responses: [
|
||||
.json(status: 200, body: responseBody),
|
||||
])
|
||||
let client = try makeClient(transport: transport)
|
||||
|
||||
_ = try await client.syncConnectivity(knownRevision: nil)
|
||||
let response = try await client.syncConnectivity(knownRevision: nil)
|
||||
|
||||
#expect(response.snapshotComplete == true)
|
||||
|
||||
let captured = try #require(await transport.requests().first)
|
||||
let body = try #require(captured.httpBody)
|
||||
@@ -632,6 +736,7 @@ struct CmxIrohTrustBrokerClientTests {
|
||||
#expect(response.revision == 42)
|
||||
#expect(response.snapshot?.revision == 42)
|
||||
#expect(response.snapshot?.bindings.count == 1)
|
||||
#expect(response.snapshotComplete == nil)
|
||||
|
||||
let mismatchedBody = try Self.jsonString([
|
||||
"protocol_version": 2,
|
||||
|
||||
@@ -39,7 +39,10 @@ let package = Package(
|
||||
),
|
||||
.testTarget(
|
||||
name: "CmuxMobileAnalyticsTests",
|
||||
dependencies: ["CmuxMobileAnalytics"],
|
||||
dependencies: [
|
||||
"CMUXMobileCore",
|
||||
"CmuxMobileAnalytics",
|
||||
],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v6),
|
||||
.enableUpcomingFeature("ExistentialAny"),
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
public import CMUXMobileCore
|
||||
|
||||
/// A consent provider backed by an injected closure.
|
||||
///
|
||||
/// The closure is read on each capture so a live settings change takes effect
|
||||
/// immediately.
|
||||
///
|
||||
/// ```swift
|
||||
/// let consent = AnalyticsConsentProvider {
|
||||
/// settings.sendAnonymousTelemetry
|
||||
/// }
|
||||
/// ```
|
||||
public struct AnalyticsConsentProvider: AnalyticsConsentProviding {
|
||||
private let isEnabled: @Sendable () -> Bool
|
||||
|
||||
/// Wraps a closure that reports the current opt-out state.
|
||||
///
|
||||
/// - Parameter isEnabled: Returns `true` when telemetry is allowed. Read on
|
||||
/// every capture so a live toggle is honored without rewiring.
|
||||
public init(isEnabled: @escaping @Sendable () -> Bool) {
|
||||
self.isEnabled = isEnabled
|
||||
}
|
||||
|
||||
/// Whether anonymous product telemetry may currently be sent.
|
||||
public var isTelemetryEnabled: Bool { isEnabled() }
|
||||
}
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
public import Foundation
|
||||
|
||||
/// The opt-out gate the emitter consults before every capture and identify.
|
||||
///
|
||||
/// The analytics package must not depend on the settings domain (`CmuxSettings`),
|
||||
/// so the telemetry opt-out is injected as this seam rather than read directly.
|
||||
/// The app composition root provides a conformer backed by
|
||||
/// `CmuxSettings.catalog.app.sendAnonymousTelemetry`; tests provide a fixed
|
||||
/// value. The gate is evaluated *inside* the emitter so no fire-site can bypass
|
||||
/// it.
|
||||
public protocol AnalyticsConsentProviding: Sendable {
|
||||
/// Whether anonymous product telemetry may currently be sent.
|
||||
///
|
||||
/// When `false`, the emitter drops every event and identify call and sends
|
||||
/// nothing over the network.
|
||||
var isTelemetryEnabled: Bool { get }
|
||||
}
|
||||
|
||||
/// A consent provider backed by an injected closure.
|
||||
///
|
||||
/// Lets the composition root bridge the telemetry opt-out into the analytics
|
||||
/// package without an import edge. The closure is read on each capture so a live
|
||||
/// settings change takes effect immediately.
|
||||
///
|
||||
/// ```swift
|
||||
/// let consent = AnalyticsConsentProvider { defaults.bool(forKey: "sendAnonymousTelemetry") }
|
||||
/// ```
|
||||
public struct AnalyticsConsentProvider: AnalyticsConsentProviding {
|
||||
private let isEnabled: @Sendable () -> Bool
|
||||
|
||||
/// Wraps a closure that reports the current opt-out state.
|
||||
/// - Parameter isEnabled: Returns `true` when telemetry is allowed. Read on
|
||||
/// every capture so a live toggle is honored without rewiring.
|
||||
public init(isEnabled: @escaping @Sendable () -> Bool) {
|
||||
self.isEnabled = isEnabled
|
||||
}
|
||||
|
||||
public var isTelemetryEnabled: Bool { isEnabled() }
|
||||
}
|
||||
|
||||
/// A consent provider backed by the shared telemetry opt-out in `UserDefaults`.
|
||||
///
|
||||
/// The iOS app cannot import the macOS-only `CmuxSettings` package, so this reads
|
||||
/// the same backing key that `CmuxSettings.catalog.app.sendAnonymousTelemetry`
|
||||
/// writes (`"sendAnonymousTelemetry"`). iOS defaults to telemetry off until the
|
||||
/// user enables the Settings toggle. The value is read on every capture so
|
||||
/// toggling the switch takes effect immediately without rewiring.
|
||||
public struct UserDefaultsAnalyticsConsentProvider: AnalyticsConsentProviding {
|
||||
/// The `UserDefaults` key shared with the settings catalog's
|
||||
/// `app.sendAnonymousTelemetry` entry.
|
||||
public static let telemetryKey = "sendAnonymousTelemetry"
|
||||
|
||||
// UserDefaults is Apple-documented thread-safe; OK to hold nonisolated.
|
||||
private nonisolated(unsafe) let defaults: UserDefaults
|
||||
|
||||
/// Creates a consent provider over the given defaults.
|
||||
/// - Parameter defaults: The defaults store holding the opt-out flag. Inject
|
||||
/// a suite-scoped store in tests; the app uses `.standard`.
|
||||
public init(defaults: UserDefaults) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
public var isTelemetryEnabled: Bool {
|
||||
defaults.object(forKey: Self.telemetryKey) as? Bool ?? false
|
||||
}
|
||||
}
|
||||
+1
-15
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
import CMUXMobileCore
|
||||
@testable import CmuxMobileAnalytics
|
||||
|
||||
private struct FixedConsent: AnalyticsConsentProviding {
|
||||
@@ -26,21 +27,6 @@ private final class MutableConsent: AnalyticsConsentProviding, @unchecked Sendab
|
||||
}
|
||||
|
||||
@Suite struct AnalyticsEmitterTests {
|
||||
@Test func userDefaultsConsentDefaultsOffUntilEnabled() {
|
||||
let suiteName = "cmux.analytics-consent.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let consent = UserDefaultsAnalyticsConsentProvider(defaults: defaults)
|
||||
#expect(!consent.isTelemetryEnabled)
|
||||
|
||||
defaults.set(true, forKey: UserDefaultsAnalyticsConsentProvider.telemetryKey)
|
||||
#expect(consent.isTelemetryEnabled)
|
||||
|
||||
defaults.set(false, forKey: UserDefaultsAnalyticsConsentProvider.telemetryKey)
|
||||
#expect(!consent.isTelemetryEnabled)
|
||||
}
|
||||
|
||||
private func makeEmitter(
|
||||
uploader: any AnalyticsUploading,
|
||||
consent: (any AnalyticsConsentProviding)? = nil,
|
||||
|
||||
+3
-1
@@ -31,6 +31,7 @@ extension FileDiffPageView {
|
||||
) {
|
||||
self.fileIndex = fileIndex
|
||||
self.file = file
|
||||
self.initialScrollRowID = initialScrollRowID
|
||||
self.fontSize = fontSize
|
||||
self.onFontSizeChanged = onFontSizeChanged
|
||||
self.onScrollRowIDChanged = onScrollRowIDChanged
|
||||
@@ -40,7 +41,8 @@ extension FileDiffPageView {
|
||||
self.onCopy = onCopy
|
||||
self.inlinePreview = inlinePreview
|
||||
loadState = initialPresentation.map(FileDiffLoadState.loaded) ?? .loading
|
||||
scrollRowID = initialScrollRowID
|
||||
rowTracker = ScrollRowTracker(topRowID: initialScrollRowID)
|
||||
pendingRestoreRowID = initialScrollRowID
|
||||
previewRevision = FileDiffPreviewPolicy(kind: file.kind).defaultRevision
|
||||
}
|
||||
}
|
||||
|
||||
+60
-23
@@ -13,8 +13,16 @@ public struct FileDiffPageView: View {
|
||||
let onLoadCurrentLines: @MainActor @Sendable (String) async throws -> DiffExpansionCurrentFile
|
||||
let onCopy: @MainActor @Sendable (String) -> Void
|
||||
let inlinePreview: (@MainActor @Sendable (_ index: Int, _ revision: FileDiffPreviewRevision) -> AnyView)?
|
||||
var initialScrollRowID: String?
|
||||
@State var loadState: FileDiffLoadState = .loading
|
||||
@State var scrollRowID: String?
|
||||
@State var rowTracker = ScrollRowTracker(topRowID: nil)
|
||||
/// Row to anchor on the next one-shot restore. Captured explicitly (from
|
||||
/// the pager at mount, from the live tracker when a refresh re-arms the
|
||||
/// restore) because the tracker itself is overwritten by every visibility
|
||||
/// callback, including the ones that fire for the unrestored top of the
|
||||
/// list before `onAppear` runs.
|
||||
@State var pendingRestoreRowID: String?
|
||||
@State private var didRestoreScroll = false
|
||||
@State private var magnificationStart: Double?
|
||||
@State var previewRevision: FileDiffPreviewRevision = .current
|
||||
@State var expansionState = DiffExpansionState()
|
||||
@@ -40,6 +48,9 @@ public struct FileDiffPageView: View {
|
||||
}
|
||||
.onDisappear {
|
||||
cancelPageTasks()
|
||||
// Unmount can arrive while a fling is still settling; persist
|
||||
// the row here since no further idle phase will report it.
|
||||
onScrollRowIDChanged(rowTracker.topRowID)
|
||||
}
|
||||
}
|
||||
@ViewBuilder
|
||||
@@ -94,32 +105,49 @@ public struct FileDiffPageView: View {
|
||||
let gutterWidth = DiffGutterLayout(
|
||||
maximumLineNumber: presentation.maximumLineNumber
|
||||
).measuredWidth(fontSize: fontSize)
|
||||
ScrollView {
|
||||
let continuation = FileDiffContinuation(
|
||||
lineBudget: lineBudget,
|
||||
document: document,
|
||||
reachedTransportCeiling: reachedTransportCeiling
|
||||
)
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(presentation.rows) { row in
|
||||
diffRow(row, gutterWidth: gutterWidth)
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
let continuation = FileDiffContinuation(
|
||||
lineBudget: lineBudget,
|
||||
document: document,
|
||||
reachedTransportCeiling: reachedTransportCeiling
|
||||
)
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(presentation.rows) { row in
|
||||
diffRow(row, gutterWidth: gutterWidth)
|
||||
}
|
||||
if continuation.shouldShowFooter {
|
||||
FileDiffContinuationFooter(
|
||||
continuation: continuation,
|
||||
state: continuationLoadState,
|
||||
onShowMore: showMore
|
||||
)
|
||||
}
|
||||
}
|
||||
if continuation.shouldShowFooter {
|
||||
FileDiffContinuationFooter(
|
||||
continuation: continuation,
|
||||
state: continuationLoadState,
|
||||
onShowMore: showMore
|
||||
)
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.modifier(SettledScrollRowReporter(
|
||||
tracker: rowTracker,
|
||||
rowOrderIndex: presentation.rowOrderIndex,
|
||||
onSettled: onScrollRowIDChanged
|
||||
))
|
||||
.refreshable { await load(forceRefresh: true) }
|
||||
.simultaneousGesture(magnifyGesture)
|
||||
.onAppear {
|
||||
// One-shot programmatic restore; after this the scroll
|
||||
// offset has a single owner (the scroll view's physics).
|
||||
guard !didRestoreScroll else { return }
|
||||
didRestoreScroll = true
|
||||
guard let restoreRowID = pendingRestoreRowID else { return }
|
||||
proxy.scrollTo(restoreRowID, anchor: .top)
|
||||
// LazyVStack can only estimate the offset of a row it has
|
||||
// not realized yet; re-apply once after the first layout
|
||||
// pass so the anchor lands on the realized row.
|
||||
Task { @MainActor in
|
||||
proxy.scrollTo(restoreRowID, anchor: .top)
|
||||
}
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.scrollPosition(id: $scrollRowID, anchor: .top)
|
||||
.onChange(of: scrollRowID) {
|
||||
onScrollRowIDChanged(scrollRowID)
|
||||
}
|
||||
.refreshable { await load(forceRefresh: true) }
|
||||
.simultaneousGesture(magnifyGesture)
|
||||
}
|
||||
}
|
||||
@ViewBuilder
|
||||
@@ -181,6 +209,15 @@ public struct FileDiffPageView: View {
|
||||
cancelContinuationTask()
|
||||
let generation = requestGeneration.begin()
|
||||
resetExpansion()
|
||||
switch loadState {
|
||||
case .loading:
|
||||
break
|
||||
case .failed, .loaded(_):
|
||||
// Refresh keeps the user's place: restore to where they are now,
|
||||
// not to the row persisted when the page originally mounted.
|
||||
pendingRestoreRowID = rowTracker.topRowID ?? pendingRestoreRowID
|
||||
didRestoreScroll = false
|
||||
}
|
||||
loadState = .loading
|
||||
continuationLoadState = .idle
|
||||
do {
|
||||
|
||||
+12
@@ -4,6 +4,11 @@ public struct FileDiffPresentation: Sendable, Equatable {
|
||||
public let document: FileDiffDocument
|
||||
let rows: [DiffRowSnapshot]
|
||||
let maximumLineNumber: Int
|
||||
/// Document-order position of each row id. `onScrollTargetVisibilityChange`
|
||||
/// does not guarantee the order of the ids it reports, so the topmost
|
||||
/// visible row must be resolved against this index rather than taken
|
||||
/// positionally from the callback array.
|
||||
let rowOrderIndex: [String: Int]
|
||||
|
||||
/// Builds the default row projection away from the caller's actor.
|
||||
///
|
||||
@@ -11,6 +16,7 @@ public struct FileDiffPresentation: Sendable, Equatable {
|
||||
/// - document: Parsed diff document to project.
|
||||
/// - fileKind: Change kind controlling hidden-context expansion.
|
||||
/// - Returns: A presentation ready for one atomic UI-state publication.
|
||||
@concurrent
|
||||
public nonisolated static func prepareOffMain(
|
||||
document: FileDiffDocument,
|
||||
fileKind: FileChangeKind
|
||||
@@ -23,6 +29,7 @@ public struct FileDiffPresentation: Sendable, Equatable {
|
||||
)
|
||||
}
|
||||
|
||||
@concurrent
|
||||
nonisolated static func prepareOffMain(
|
||||
document: FileDiffDocument,
|
||||
expansionState: DiffExpansionState,
|
||||
@@ -38,6 +45,7 @@ public struct FileDiffPresentation: Sendable, Equatable {
|
||||
}
|
||||
|
||||
/// Builds an expansion projection that cooperatively stops when superseded.
|
||||
@concurrent
|
||||
nonisolated static func prepareOffMainCancellable(
|
||||
document: FileDiffDocument,
|
||||
expansionState: DiffExpansionState,
|
||||
@@ -86,5 +94,9 @@ public struct FileDiffPresentation: Sendable, Equatable {
|
||||
self.document = document
|
||||
self.rows = rows
|
||||
self.maximumLineNumber = maximumLineNumber
|
||||
self.rowOrderIndex = Dictionary(
|
||||
rows.enumerated().map { ($0.element.id, $0.offset) },
|
||||
uniquingKeysWith: { first, _ in first }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Last row seen near the top of the diff scroll view, kept OUTSIDE SwiftUI
|
||||
/// state on purpose: it changes on every frame of a scroll, and routing it
|
||||
/// through `@State` or a `scrollPosition` binding makes SwiftUI a second
|
||||
/// owner of the scroll offset. That ownership fight is what killed fling
|
||||
/// deceleration, rubber-banding, and pull-to-refresh displacement on real
|
||||
/// diffs (the offset was re-resolved against the tracked row on every lazy
|
||||
/// row materialization). UIKit physics own the offset; we only observe.
|
||||
@MainActor
|
||||
final class ScrollRowTracker {
|
||||
var topRowID: String?
|
||||
|
||||
nonisolated init(topRowID: String?) {
|
||||
self.topRowID = topRowID
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the topmost visible row from an unordered set of visible ids.
|
||||
///
|
||||
/// `onScrollTargetVisibilityChange` documents no ordering for the ids it
|
||||
/// reports, so the topmost row is the one earliest in document order, not
|
||||
/// `visibleIDs.first`. Ids absent from the index (never expected) sort last.
|
||||
struct TopVisibleRowPolicy {
|
||||
let rowOrderIndex: [String: Int]
|
||||
|
||||
func topRow(among visibleIDs: [String]) -> String? {
|
||||
visibleIDs.compactMap { id -> (order: Int, id: String)? in
|
||||
guard let order = rowOrderIndex[id] else { return nil }
|
||||
return (order, id)
|
||||
}
|
||||
.min { lhs, rhs in
|
||||
if lhs.order == rhs.order { return lhs.id < rhs.id }
|
||||
return lhs.order < rhs.order
|
||||
}?
|
||||
.id
|
||||
}
|
||||
}
|
||||
|
||||
/// Observes the top visible row and reports it only after scrolling settles.
|
||||
///
|
||||
/// Both modifiers are pure observers: neither adds a body dependency nor
|
||||
/// writes view state during a scroll, so the scroll view is never laid out
|
||||
/// or repositioned mid-gesture. The row id is read at event time from the
|
||||
/// tracker and handed to `onSettled` at `.idle` phase for persistence.
|
||||
struct SettledScrollRowReporter: ViewModifier {
|
||||
let tracker: ScrollRowTracker
|
||||
let rowOrderIndex: [String: Int]
|
||||
let onSettled: @MainActor @Sendable (String?) -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 18.0, macOS 15.0, *) {
|
||||
content
|
||||
.onScrollTargetVisibilityChange(idType: String.self, threshold: 0.01) { visibleIDs in
|
||||
let policy = TopVisibleRowPolicy(rowOrderIndex: rowOrderIndex)
|
||||
tracker.topRowID = policy.topRow(among: visibleIDs)
|
||||
}
|
||||
.onScrollPhaseChange { _, newPhase in
|
||||
guard newPhase == .idle else { return }
|
||||
onSettled(tracker.topRowID)
|
||||
}
|
||||
} else {
|
||||
// Both observers are iOS 18 / macOS 15 APIs. The app's iOS floor
|
||||
// is 18.4, so this branch is reachable only on macOS 14, where
|
||||
// this package builds for tests alone; no shipping surface
|
||||
// renders the diff pager there. Scroll-position persistence is
|
||||
// intentionally absent on that path.
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ public struct UnifiedDiffParser: Sendable {
|
||||
/// - isBinary: Whether the file is binary.
|
||||
/// - totalLineCount: Number of lines in the full raw diff, when reported.
|
||||
/// - Returns: A display-ready immutable document.
|
||||
@concurrent
|
||||
public nonisolated func parseOffMain(
|
||||
_ unifiedDiff: String,
|
||||
truncated: Bool = false,
|
||||
@@ -49,6 +50,7 @@ public struct UnifiedDiffParser: Sendable {
|
||||
/// - contentFingerprint: Working-file revision fingerprint, when reported.
|
||||
/// - fileKind: Change kind controlling hidden-context expansion.
|
||||
/// - Returns: A parsed document and display projection ready for publication.
|
||||
@concurrent
|
||||
public nonisolated func parsePresentationOffMain(
|
||||
_ unifiedDiff: String,
|
||||
truncated: Bool = false,
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import Testing
|
||||
|
||||
@testable import CmuxMobileChanges
|
||||
|
||||
@Suite struct TopVisibleRowPolicyTests {
|
||||
private let policy = TopVisibleRowPolicy(rowOrderIndex: [
|
||||
"row-0": 0,
|
||||
"row-1": 1,
|
||||
"row-2": 2,
|
||||
"row-3": 3,
|
||||
])
|
||||
|
||||
@Test
|
||||
func picksDocumentTopmostRegardlessOfCallbackOrder() {
|
||||
#expect(policy.topRow(among: ["row-2", "row-0", "row-3"]) == "row-0")
|
||||
#expect(policy.topRow(among: ["row-3", "row-2"]) == "row-2")
|
||||
#expect(policy.topRow(among: ["row-1"]) == "row-1")
|
||||
}
|
||||
|
||||
@Test
|
||||
func unknownIDsDoNotOverrideKnownRows() {
|
||||
#expect(policy.topRow(among: ["ghost", "row-3"]) == "row-3")
|
||||
#expect(policy.topRow(among: ["ghost-b", "ghost-a"]) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func emptyVisibleSetResolvesToNil() {
|
||||
#expect(policy.topRow(among: []) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func presentationIndexesRowsInDocumentOrder() async {
|
||||
let diff = """
|
||||
@@ -1,2 +1,3 @@
|
||||
let stable = true
|
||||
+let added = 1
|
||||
let tail = false
|
||||
"""
|
||||
let document = UnifiedDiffParser().parse(diff)
|
||||
let presentation = await FileDiffPresentation.prepareOffMain(
|
||||
document: document,
|
||||
fileKind: .modified
|
||||
)
|
||||
let orderedByIndex = presentation.rows
|
||||
.map(\.id)
|
||||
.enumerated()
|
||||
.allSatisfy { presentation.rowOrderIndex[$0.element] == $0.offset }
|
||||
#expect(orderedByIndex)
|
||||
#expect(presentation.rowOrderIndex.count == presentation.rows.count)
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import PackageDescription
|
||||
|
||||
// `CmuxMobileCrashReporting` is the iOS crash telemetry leaf package. It owns
|
||||
// the Sentry startup options for mobile, including watchdog termination,
|
||||
// app-hang, and MetricKit diagnostics, while depending on `CmuxMobileAnalytics`
|
||||
// only for the shared telemetry consent seam so crash reporting follows the
|
||||
// same opt-out as analytics.
|
||||
// app-hang, and MetricKit diagnostics. It depends on the telemetry consent seam
|
||||
// in `CMUXMobileCore`, making crash reporting and analytics sibling consumers
|
||||
// of the same opt-out contract.
|
||||
let package = Package(
|
||||
name: "CmuxMobileCrashReporting",
|
||||
platforms: [
|
||||
@@ -20,7 +20,7 @@ let package = Package(
|
||||
),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../CmuxMobileAnalytics"),
|
||||
.package(path: "../../Shared/CMUXMobileCore"),
|
||||
.package(path: "../../Shared/CmuxSentryTelemetry"),
|
||||
.package(
|
||||
url: "https://github.com/getsentry/sentry-cocoa.git",
|
||||
@@ -31,7 +31,7 @@ let package = Package(
|
||||
.target(
|
||||
name: "CmuxMobileCrashReporting",
|
||||
dependencies: [
|
||||
"CmuxMobileAnalytics",
|
||||
"CMUXMobileCore",
|
||||
.product(name: "CmuxSentryScrubbing", package: "CmuxSentryTelemetry"),
|
||||
.product(name: "CmuxSentryReporting", package: "CmuxSentryTelemetry"),
|
||||
.product(name: "Sentry", package: "sentry-cocoa"),
|
||||
@@ -44,7 +44,10 @@ let package = Package(
|
||||
),
|
||||
.testTarget(
|
||||
name: "CmuxMobileCrashReportingTests",
|
||||
dependencies: ["CmuxMobileCrashReporting"],
|
||||
dependencies: [
|
||||
"CMUXMobileCore",
|
||||
"CmuxMobileCrashReporting",
|
||||
],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v6),
|
||||
.enableUpcomingFeature("ExistentialAny"),
|
||||
|
||||
+14
-2
@@ -1,4 +1,4 @@
|
||||
public import CmuxMobileAnalytics
|
||||
public import CMUXMobileCore
|
||||
import CmuxSentryReporting
|
||||
import Foundation
|
||||
public import Sentry
|
||||
@@ -6,7 +6,7 @@ public import Sentry
|
||||
/// Starts Sentry-backed crash reporting for the iOS app.
|
||||
///
|
||||
/// ``MobileCrashReporter`` intentionally reuses
|
||||
/// ``CmuxMobileAnalytics/AnalyticsConsentProviding`` so crash telemetry and
|
||||
/// ``CMUXMobileCore/AnalyticsConsentProviding`` so crash telemetry and
|
||||
/// analytics obey one opt-out source. `sendDefaultPii` is disabled and every
|
||||
/// outgoing event, breadcrumb, and structured log is redacted by the shared
|
||||
/// `SentryEventScrubber` (CmuxSentryReporting) before it leaves the device.
|
||||
@@ -40,6 +40,8 @@ public struct MobileCrashReporter {
|
||||
/// - consent: The shared analytics/crash telemetry opt-out gate.
|
||||
/// - arguments: Process arguments used to gate the DEBUG-only test crash.
|
||||
/// Defaults to `ProcessInfo.processInfo.arguments`.
|
||||
/// - prepareLocale: Process-locale initialization performed before Sentry
|
||||
/// starts any background work.
|
||||
/// - start: The Sentry start function. Tests inject this closure so they
|
||||
/// can assert the consent gate without starting the real SDK.
|
||||
/// - crash: The DEBUG-only test crash function. Tests inject this closure
|
||||
@@ -51,6 +53,10 @@ public struct MobileCrashReporter {
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
notificationCenter: NotificationCenter = .default,
|
||||
revocationWatcher: RevocationWatcher,
|
||||
prepareLocale: () -> Void = {
|
||||
_ = Locale.current
|
||||
_ = NSLocale.preferredLanguages
|
||||
},
|
||||
start: @escaping (Options) -> Void = { SentrySDK.start(options: $0) },
|
||||
close: @escaping @Sendable () -> Void = { SentrySDK.close() },
|
||||
purgeCache: (@Sendable () -> Void)? = nil,
|
||||
@@ -62,6 +68,12 @@ public struct MobileCrashReporter {
|
||||
// and CI sessions would all send deliberate crashes and hangs to the
|
||||
// shared Sentry project.
|
||||
guard !isTestRun(environment: environment) else { return }
|
||||
// Foundation lazily initializes process locale through setlocale().
|
||||
// Sentry also starts a background `sentry-init` thread that reads
|
||||
// locale environment state. Completing Foundation's initialization on
|
||||
// the composition-root actor first prevents that thread from racing
|
||||
// libghostty's own locale initialization when its first surface mounts.
|
||||
prepareLocale()
|
||||
let cachePurger = self.cachePurger
|
||||
let purgeCache = purgeCache ?? { cachePurger.purge() }
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
internal import CmuxMobileAnalytics
|
||||
internal import CMUXMobileCore
|
||||
internal import Foundation
|
||||
|
||||
// Safety: the app composition root is the single owner that calls `arm`.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
import CmuxMobileAnalytics
|
||||
import CMUXMobileCore
|
||||
|
||||
final class CrashTestToggleConsent: AnalyticsConsentProviding, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
|
||||
+19
-1
@@ -1,7 +1,7 @@
|
||||
import Sentry
|
||||
import Testing
|
||||
|
||||
import CmuxMobileAnalytics
|
||||
import CMUXMobileCore
|
||||
@testable import CmuxMobileCrashReporting
|
||||
|
||||
private struct FixedConsent: AnalyticsConsentProviding {
|
||||
@@ -50,6 +50,24 @@ private struct FixedConsent: AnalyticsConsentProviding {
|
||||
#expect(capturedOptions?.shutdownTimeInterval == 0)
|
||||
}
|
||||
|
||||
@Test func localePreparationPrecedesSentryStartup() {
|
||||
var sequence: [String] = []
|
||||
|
||||
MobileCrashReporter().startIfEnabled(
|
||||
consent: FixedConsent(isTelemetryEnabled: true),
|
||||
arguments: ["cmux"],
|
||||
environment: [:],
|
||||
revocationWatcher: MobileCrashReporter.RevocationWatcher(),
|
||||
prepareLocale: { sequence.append("locale") },
|
||||
start: { _ in sequence.append("sentry") },
|
||||
close: {},
|
||||
purgeCache: {},
|
||||
crash: {}
|
||||
)
|
||||
|
||||
#expect(sequence == ["locale", "sentry"])
|
||||
}
|
||||
|
||||
@Test func optionsFactoryMatchesMobileContract() {
|
||||
let options = MobileCrashReporter().makeOptions()
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@ internal import CmuxMobileSupport
|
||||
public import Foundation
|
||||
internal import os
|
||||
|
||||
/// Controls whether a request carries the connection's attach-ticket context.
|
||||
/// Stack account authorization is always sent for authorized bearer requests.
|
||||
public enum MobileCoreRPCAttachTicketPolicy: Sendable, Equatable {
|
||||
/// Include a current attach token when its route/workspace scope covers the request.
|
||||
case whenCovered
|
||||
/// Omit attach-ticket context so it cannot narrow an account-authorized request.
|
||||
case omit
|
||||
}
|
||||
|
||||
/// A multiplexed RPC client over a single persistent transport to a paired Mac.
|
||||
///
|
||||
/// All stored properties are immutable `let`s of `Sendable` types (the session
|
||||
@@ -159,8 +168,18 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
|
||||
public func sharesPhysicalTransportRoute(
|
||||
with otherRoute: CmxAttachRoute
|
||||
) -> Bool {
|
||||
MobileRPCConnectAttemptKey(route: route)
|
||||
== MobileRPCConnectAttemptKey(route: otherRoute)
|
||||
Self.routesSharePhysicalTransport(route, otherRoute)
|
||||
}
|
||||
|
||||
/// Returns whether two routes compete for the same physical connection
|
||||
/// lease. Shell ownership arbitration uses this before either route has a
|
||||
/// live client, including while a background admission is still suspended.
|
||||
public static func routesSharePhysicalTransport(
|
||||
_ lhs: CmxAttachRoute,
|
||||
_ rhs: CmxAttachRoute
|
||||
) -> Bool {
|
||||
MobileRPCConnectAttemptKey(route: lhs)
|
||||
== MobileRPCConnectAttemptKey(route: rhs)
|
||||
}
|
||||
|
||||
/// Synchronously prevent this client from allocating another transport.
|
||||
@@ -241,10 +260,27 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
|
||||
///
|
||||
/// The optional timeout is a hard end-to-end deadline for auth augmentation,
|
||||
/// connection setup, and response wait, not a per-subphase timeout.
|
||||
public func sendRequest(_ requestData: Data, timeoutNanoseconds: UInt64? = nil) async throws -> Data {
|
||||
public func sendRequest(
|
||||
_ requestData: Data,
|
||||
timeoutNanoseconds: UInt64? = nil
|
||||
) async throws -> Data {
|
||||
try await sendRequest(
|
||||
requestData,
|
||||
timeoutNanoseconds: timeoutNanoseconds,
|
||||
attachTicketPolicy: .whenCovered
|
||||
)
|
||||
}
|
||||
|
||||
/// Sends one request with explicit control over attach-ticket context.
|
||||
public func sendRequest(
|
||||
_ requestData: Data,
|
||||
timeoutNanoseconds: UInt64? = nil,
|
||||
attachTicketPolicy: MobileCoreRPCAttachTicketPolicy
|
||||
) async throws -> Data {
|
||||
try await sendRequestOperation(
|
||||
requestData,
|
||||
timeoutNanoseconds: timeoutNanoseconds
|
||||
timeoutNanoseconds: timeoutNanoseconds,
|
||||
attachTicketPolicy: attachTicketPolicy
|
||||
).response
|
||||
}
|
||||
|
||||
@@ -322,7 +358,8 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
|
||||
private func sendRequestOperation(
|
||||
_ requestData: Data,
|
||||
timeoutNanoseconds: UInt64?,
|
||||
hostStatusStackToken: String? = nil
|
||||
hostStatusStackToken: String? = nil,
|
||||
attachTicketPolicy: MobileCoreRPCAttachTicketPolicy = .whenCovered
|
||||
) async throws -> AuthenticatedRequestResult {
|
||||
let deadline = RPCRequestDeadline(
|
||||
timeoutNanoseconds: timeoutNanoseconds ?? runtime.rpcRequestTimeoutNanoseconds
|
||||
@@ -336,7 +373,8 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
|
||||
preparedRequest,
|
||||
deadline: deadline,
|
||||
allowAuthRetry: true,
|
||||
hostStatusStackToken: hostStatusStackToken
|
||||
hostStatusStackToken: hostStatusStackToken,
|
||||
attachTicketPolicy: attachTicketPolicy
|
||||
)
|
||||
} catch let error as MobileShellConnectionError {
|
||||
// The host rejected this request on Stack-auth grounds. Before
|
||||
@@ -358,7 +396,8 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
|
||||
preparedRequest,
|
||||
deadline: deadline,
|
||||
allowAuthRetry: false,
|
||||
hostStatusStackToken: hostStatusStackToken
|
||||
hostStatusStackToken: hostStatusStackToken,
|
||||
attachTicketPolicy: attachTicketPolicy
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -423,7 +462,8 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
|
||||
_ requestData: Data,
|
||||
deadline: RPCRequestDeadline,
|
||||
allowAuthRetry: Bool,
|
||||
hostStatusStackToken: String?
|
||||
hostStatusStackToken: String?,
|
||||
attachTicketPolicy: MobileCoreRPCAttachTicketPolicy
|
||||
) async throws -> AuthenticatedRequestResult {
|
||||
// Multiplexed over a persistent transport: each request gets a unique
|
||||
// id, the session's reader task routes the response back here. No
|
||||
@@ -437,7 +477,8 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
|
||||
let authenticated = try await requestDataWithAuth(
|
||||
augmented,
|
||||
deadline: deadline,
|
||||
hostStatusStackToken: hostStatusStackToken
|
||||
hostStatusStackToken: hostStatusStackToken,
|
||||
attachTicketPolicy: attachTicketPolicy
|
||||
)
|
||||
try Task.checkCancellation()
|
||||
let response = try await session.send(
|
||||
@@ -472,7 +513,8 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
|
||||
private func requestDataWithAuth(
|
||||
_ requestData: Data,
|
||||
deadline: RPCRequestDeadline,
|
||||
hostStatusStackToken: String?
|
||||
hostStatusStackToken: String?,
|
||||
attachTicketPolicy: MobileCoreRPCAttachTicketPolicy = .whenCovered
|
||||
) async throws -> AuthenticatedRequestPayload {
|
||||
guard var request = try JSONSerialization.jsonObject(with: requestData) as? [String: Any] else {
|
||||
return AuthenticatedRequestPayload(data: requestData, stackAccessToken: nil)
|
||||
@@ -493,6 +535,7 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
|
||||
if let attachToken,
|
||||
requestNeedsAuth,
|
||||
hasAttachToken,
|
||||
attachTicketPolicy == .whenCovered,
|
||||
requestIsCoveredByAttachTicket {
|
||||
// Expiry is enforced only here, where the RPC-minted attach token
|
||||
// is actually used. QR-decoded tickets carry no token (and no
|
||||
@@ -639,9 +682,10 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
|
||||
|
||||
switch method {
|
||||
case "mobile.workspace.list", "workspace.list",
|
||||
"mobile.task.models.list",
|
||||
"mobile.directory.list", "mobile.directory.search":
|
||||
return false
|
||||
case "workspace.create":
|
||||
case "workspace.create", "mobile.task.attachment.upload":
|
||||
return false
|
||||
case "workspace.action", "workspace.close":
|
||||
return !ticketCoverage.ticketCoversWorkspaceRequest(
|
||||
@@ -669,7 +713,8 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
|
||||
workspaceSelection: workspaceSelection.value,
|
||||
terminalSelection: terminalSelection.value
|
||||
)
|
||||
case "mobile.events.subscribe", "mobile.events.unsubscribe":
|
||||
case "mobile.events.subscribe", "mobile.events.unsubscribe",
|
||||
"mobile.events.probe":
|
||||
return false
|
||||
case "notification.feed.list", "notification.feed.mark_read", "notification.feed.mark_unread",
|
||||
"notification.feed.mark_all_read":
|
||||
|
||||
@@ -470,7 +470,7 @@ actor MobileCoreRPCSession {
|
||||
// their cooperative-cancellation retry semantics.
|
||||
if connectAttemptKey != nil,
|
||||
!abandonedConnectionCleanupTasks.isEmpty {
|
||||
throw MobileShellConnectionError.requestTimedOut
|
||||
throw MobileShellConnectionError.routeCleanupBlocked
|
||||
}
|
||||
let waiterID = UUID()
|
||||
let connectionID: UUID
|
||||
@@ -502,6 +502,20 @@ actor MobileCoreRPCSession {
|
||||
let diagnosticTransport = diagnosticTransport
|
||||
let transportConnectObserver = transportConnectObserver
|
||||
let initialSessionPurpose = transportSessionPurpose
|
||||
let reportCancelledConnect: @Sendable () -> Void = {
|
||||
if let diagnosticTransport, let transportConnectObserver {
|
||||
transportConnectObserver(
|
||||
.failed(
|
||||
attemptID: connectAttemptID,
|
||||
transport: diagnosticTransport,
|
||||
failure: .cancelled,
|
||||
elapsedMilliseconds: Self.elapsedMilliseconds(
|
||||
since: connectStartedAt
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
if let diagnosticTransport, let transportConnectObserver {
|
||||
transportConnectObserver(
|
||||
.attempt(
|
||||
@@ -520,6 +534,7 @@ actor MobileCoreRPCSession {
|
||||
await rejected.task.value
|
||||
}
|
||||
if Task.isCancelled {
|
||||
reportCancelledConnect()
|
||||
throw CancellationError()
|
||||
}
|
||||
let error = MobileShellConnectionError.connectionClosed
|
||||
@@ -540,6 +555,7 @@ actor MobileCoreRPCSession {
|
||||
} catch {
|
||||
await connectAttemptRegistry.finishConnect(lease: connectLease)
|
||||
if error is CancellationError || Task.isCancelled {
|
||||
reportCancelledConnect()
|
||||
throw CancellationError()
|
||||
}
|
||||
if let diagnosticTransport, let transportConnectObserver {
|
||||
@@ -583,18 +599,20 @@ actor MobileCoreRPCSession {
|
||||
// A cancellation-ignoring transport must still return its
|
||||
// late candidate to the existing abandoned-connect cleanup
|
||||
// path so that path can close it again after completion.
|
||||
// Suppress the success event without replacing that result
|
||||
// with `CancellationError`.
|
||||
if !Task.isCancelled,
|
||||
let diagnosticTransport,
|
||||
let transportConnectObserver {
|
||||
// Report the abandoned attempt as cancelled without
|
||||
// replacing that result with `CancellationError`.
|
||||
if Task.isCancelled {
|
||||
reportCancelledConnect()
|
||||
} else if let diagnosticTransport,
|
||||
let transportConnectObserver {
|
||||
transportConnectObserver(
|
||||
.connected(
|
||||
attemptID: connectAttemptID,
|
||||
transport: diagnosticTransport,
|
||||
elapsedMilliseconds: Self.elapsedMilliseconds(
|
||||
since: connectStartedAt
|
||||
)
|
||||
elapsedMilliseconds:
|
||||
Self.elapsedMilliseconds(
|
||||
since: connectStartedAt
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -605,14 +623,17 @@ actor MobileCoreRPCSession {
|
||||
} else {
|
||||
await cancellationClose.finishWithoutClose()
|
||||
}
|
||||
reportCancelledConnect()
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
// Some transports surface their close error instead of
|
||||
// `CancellationError` after the cancellation handler closes
|
||||
// them. Treat the task's cancellation bit as authoritative
|
||||
// so an abandoned dial never becomes a false failure event.
|
||||
// so an abandoned dial reports cancelled, never a false
|
||||
// transport failure.
|
||||
if Task.isCancelled {
|
||||
_ = await cancellationClose.task()
|
||||
reportCancelledConnect()
|
||||
throw CancellationError()
|
||||
}
|
||||
await cancellationClose.finishWithoutClose()
|
||||
|
||||
+10
@@ -101,6 +101,16 @@ public actor MobileRPCConnectAttemptRegistry {
|
||||
routeStates[key] = state
|
||||
}
|
||||
|
||||
public func resetRouteHealthForNetworkChange() {
|
||||
// Current main keeps only active leases and physical cleanup debt. Those
|
||||
// are ownership facts, not route-health strikes, so a network change must
|
||||
// not erase them and accidentally admit duplicate dials.
|
||||
for (key, state) in routeStates
|
||||
where state.activeLeaseID == nil && state.physicalCleanupTasks.isEmpty {
|
||||
routeStates[key] = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func physicalCleanupDidFinish(
|
||||
key: MobileRPCConnectAttemptKey,
|
||||
cleanupID: UUID
|
||||
|
||||
+15
-4
@@ -105,7 +105,7 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
|
||||
}
|
||||
|
||||
/// A workspace group section in the list response. Mirrors the iOS-facing
|
||||
/// subset the Mac emits (no v2 handle refs, color, or icon). Members are
|
||||
/// subset the Mac emits (no v2 handle refs or color). Members are
|
||||
/// listed in the Mac's spatial (`tabs`) order. Absent on Macs old enough not
|
||||
/// to emit groups.
|
||||
public struct Group: Decodable, Sendable {
|
||||
@@ -117,6 +117,8 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
|
||||
public let isCollapsed: Bool
|
||||
/// Whether the group is pinned on the Mac.
|
||||
public let isPinned: Bool
|
||||
/// SF Symbol rendered by the corresponding group row on the Mac.
|
||||
public let iconSymbol: String?
|
||||
/// The anchor workspace that owns this group. It is represented by the
|
||||
/// group header and never rendered as a separate row.
|
||||
public let anchorWorkspaceID: String
|
||||
@@ -130,6 +132,7 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
|
||||
case name
|
||||
case isCollapsed = "is_collapsed"
|
||||
case isPinned = "is_pinned"
|
||||
case iconSymbol = "icon_symbol"
|
||||
case anchorWorkspaceID = "anchor_workspace_id"
|
||||
}
|
||||
|
||||
@@ -139,12 +142,14 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
|
||||
name: String,
|
||||
isCollapsed: Bool,
|
||||
isPinned: Bool,
|
||||
iconSymbol: String? = nil,
|
||||
anchorWorkspaceID: String
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.isCollapsed = isCollapsed
|
||||
self.isPinned = isPinned
|
||||
self.iconSymbol = iconSymbol
|
||||
self.anchorWorkspaceID = anchorWorkspaceID
|
||||
}
|
||||
}
|
||||
@@ -188,9 +193,13 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
|
||||
|
||||
/// The full workspace list.
|
||||
public let workspaces: [Workspace]
|
||||
/// Group sections, in section order. Empty on Macs old enough not to emit
|
||||
/// groups (the field is decoded with `decodeIfPresent`).
|
||||
/// Group sections, in section order. Empty when the Mac reports no groups or
|
||||
/// when an older payload omits the field.
|
||||
public let groups: [Group]
|
||||
/// Whether the decoded payload carried a `groups` field at all. Older or
|
||||
/// partial responses omit the field, and callers use that to preserve the
|
||||
/// last authoritative group headers across reconnect churn.
|
||||
public let groupsFieldWasPresent: Bool
|
||||
/// Identifier of a workspace created by the request, if any.
|
||||
public let createdWorkspaceID: String?
|
||||
/// Identifier of a terminal created by the request, if any.
|
||||
@@ -211,6 +220,7 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
workspaces = try container.decode([Workspace].self, forKey: .workspaces)
|
||||
groupsFieldWasPresent = container.contains(.groups)
|
||||
groups = try container.decodeIfPresent([Group].self, forKey: .groups) ?? []
|
||||
createdWorkspaceID = try container.decodeIfPresent(String.self, forKey: .createdWorkspaceID)
|
||||
createdTerminalID = try container.decodeIfPresent(String.self, forKey: .createdTerminalID)
|
||||
@@ -234,13 +244,14 @@ extension MobileSyncWorkspaceListResponse {
|
||||
public init(
|
||||
workspaces: [Workspace],
|
||||
groups: [Group],
|
||||
groupsFieldWasPresent: Bool = true,
|
||||
createdWorkspaceID: String?,
|
||||
createdTerminalID: String?
|
||||
) {
|
||||
self.workspaces = workspaces
|
||||
self.groups = groups
|
||||
self.groupsFieldWasPresent = groupsFieldWasPresent
|
||||
self.createdWorkspaceID = createdWorkspaceID
|
||||
self.createdTerminalID = createdTerminalID
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -35,6 +35,7 @@ extension MobileWorkspaceGroupPreview {
|
||||
name: remote.name,
|
||||
isCollapsed: remote.isCollapsed,
|
||||
isPinned: remote.isPinned,
|
||||
iconSymbol: remote.iconSymbol,
|
||||
anchorWorkspaceID: MobileWorkspacePreview.ID(rawValue: remote.anchorWorkspaceID)
|
||||
)
|
||||
}
|
||||
|
||||
+7
-6
@@ -195,7 +195,7 @@ import Testing
|
||||
allowsStackAuthFallback: true
|
||||
)
|
||||
|
||||
for id in ["stuck-connect-1", "stuck-connect-2", "stuck-connect-3"] {
|
||||
for (index, id) in ["stuck-connect-1", "stuck-connect-2", "stuck-connect-3"].enumerated() {
|
||||
let request = try MobileCoreRPCClient.requestData(
|
||||
method: "terminal.input",
|
||||
params: [
|
||||
@@ -207,10 +207,11 @@ import Testing
|
||||
)
|
||||
do {
|
||||
_ = try await client.sendRequest(request)
|
||||
Issue.record("Expected \(id) to time out")
|
||||
} catch MobileShellConnectionError.requestTimedOut {
|
||||
Issue.record("Expected \(id) to fail")
|
||||
} catch MobileShellConnectionError.requestTimedOut where index == 0 {
|
||||
} catch MobileShellConnectionError.routeCleanupBlocked where index > 0 {
|
||||
} catch {
|
||||
Issue.record("Expected requestTimedOut for \(id), got \(error)")
|
||||
Issue.record("Expected bounded admission failure for \(id), got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,9 +279,9 @@ import Testing
|
||||
do {
|
||||
_ = try await client.sendRequest(retryRequest)
|
||||
Issue.record("Expected \(id) to be rejected while cancelled connect cleanup is stuck")
|
||||
} catch MobileShellConnectionError.requestTimedOut {
|
||||
} catch MobileShellConnectionError.routeCleanupBlocked {
|
||||
} catch {
|
||||
Issue.record("Expected requestTimedOut for \(id), got \(error)")
|
||||
Issue.record("Expected routeCleanupBlocked for \(id), got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -270,6 +270,73 @@ import Testing
|
||||
#expect(mapped.customColorHex == nil)
|
||||
}
|
||||
|
||||
@Test func workspaceListResponseTracksWhetherGroupsFieldWasPresent() throws {
|
||||
let absentGroupsJSON = Data("""
|
||||
{
|
||||
"workspaces": []
|
||||
}
|
||||
""".utf8)
|
||||
let emptyGroupsJSON = Data("""
|
||||
{
|
||||
"workspaces": [],
|
||||
"groups": []
|
||||
}
|
||||
""".utf8)
|
||||
|
||||
let absentGroups = try MobileSyncWorkspaceListResponse.decode(absentGroupsJSON)
|
||||
let emptyGroups = try MobileSyncWorkspaceListResponse.decode(emptyGroupsJSON)
|
||||
|
||||
#expect(absentGroups.groups.isEmpty)
|
||||
#expect(!absentGroups.groupsFieldWasPresent)
|
||||
#expect(emptyGroups.groups.isEmpty)
|
||||
#expect(emptyGroups.groupsFieldWasPresent)
|
||||
}
|
||||
|
||||
@Test func workspaceListResponseCarriesWorkspaceGroupIcon() throws {
|
||||
let json = Data("""
|
||||
{
|
||||
"workspaces": [],
|
||||
"groups": [
|
||||
{
|
||||
"id": "group-1",
|
||||
"name": "Release",
|
||||
"is_collapsed": false,
|
||||
"is_pinned": true,
|
||||
"icon_symbol": "shippingbox.fill",
|
||||
"anchor_workspace_id": "workspace-1"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".utf8)
|
||||
|
||||
let response = try MobileSyncWorkspaceListResponse.decode(json)
|
||||
let remoteGroup = try #require(response.groups.first)
|
||||
#expect(remoteGroup.iconSymbol == "shippingbox.fill")
|
||||
|
||||
let mappedGroup = MobileWorkspaceGroupPreview(remote: remoteGroup)
|
||||
#expect(mappedGroup.iconSymbol == "shippingbox.fill")
|
||||
}
|
||||
|
||||
@Test func workspaceListResponseDefaultsMissingWorkspaceGroupIconToNil() throws {
|
||||
let json = Data("""
|
||||
{
|
||||
"workspaces": [],
|
||||
"groups": [
|
||||
{
|
||||
"id": "group-older",
|
||||
"name": "Older Mac",
|
||||
"is_collapsed": false,
|
||||
"is_pinned": false,
|
||||
"anchor_workspace_id": "workspace-older"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".utf8)
|
||||
|
||||
let response = try MobileSyncWorkspaceListResponse.decode(json)
|
||||
#expect(response.groups.first?.iconSymbol == nil)
|
||||
}
|
||||
|
||||
/// The Mac emits an optional per-workspace `preview` + `preview_at` (latest
|
||||
/// notification text + epoch seconds) for the iMessage-style row preview.
|
||||
/// Both must decode when present and stay `nil` when an older Mac omits them.
|
||||
|
||||
+44
@@ -129,6 +129,50 @@ import Testing
|
||||
#expect(frame.hasAuth)
|
||||
}
|
||||
|
||||
@Test func accountAuthorizedGroupActionCanOmitWorkspaceScopedAttachTicketContext() async throws {
|
||||
let route = try hostPortRoute(kind: .debugLoopback, host: "127.0.0.1", port: 58465)
|
||||
let transport = QueuedCancellationProbeTransport()
|
||||
let runtime = TestMobileSyncRuntime(
|
||||
transportFactory: QueuedCancellationProbeTransportFactory(transport: transport),
|
||||
stackAccessToken: "test-stack-token"
|
||||
)
|
||||
let ticket = try CmxAttachTicket(
|
||||
workspaceID: "workspace-main",
|
||||
terminalID: nil,
|
||||
macDeviceID: "test-mac",
|
||||
macDisplayName: "Test Mac",
|
||||
routes: [route],
|
||||
expiresAt: Date().addingTimeInterval(60),
|
||||
authToken: "ticket-secret"
|
||||
)
|
||||
let client = MobileCoreRPCClient(
|
||||
runtime: runtime,
|
||||
route: route,
|
||||
ticket: ticket,
|
||||
allowsStackAuthFallback: true
|
||||
)
|
||||
let request = try MobileCoreRPCClient.requestData(
|
||||
method: "workspace.group.action",
|
||||
params: [
|
||||
"group_id": "group-main",
|
||||
"action": "rename",
|
||||
"title": "Project Alpha",
|
||||
]
|
||||
)
|
||||
let task = Task {
|
||||
try await client.sendRequest(request, attachTicketPolicy: .omit)
|
||||
}
|
||||
let sent = try await transport.waitForSentRequestCount(1)
|
||||
task.cancel()
|
||||
_ = try? await task.value
|
||||
|
||||
let frame = try #require(sent.first)
|
||||
#expect(frame.method == "workspace.group.action")
|
||||
#expect(frame.attachToken == nil)
|
||||
#expect(frame.stackAccessToken == "test-stack-token")
|
||||
#expect(frame.hasAuth)
|
||||
}
|
||||
|
||||
@Test func workspaceGroupCreateCarriesWorkspaceScopedAttachTicketContext() async throws {
|
||||
let route = try hostPortRoute(kind: .debugLoopback, host: "127.0.0.1", port: 58465)
|
||||
let transport = QueuedCancellationProbeTransport()
|
||||
|
||||
+41
-18
@@ -52,29 +52,31 @@ import Testing
|
||||
#expect(failure == .unsupportedRoute)
|
||||
}
|
||||
|
||||
@Test func callerCancellationSuppressesCloseInducedFailureAndRetryConnects() async throws {
|
||||
@Test func abandonedDialEmitsCancelledOutcomeAndRetryConnects() async throws {
|
||||
let transport = FirstConnectClosedErrorThenSucceedsTransport()
|
||||
let (events, continuation) = AsyncStream<MobileRPCTransportConnectEvent>.makeStream()
|
||||
let cancellationSignal = MobileRPCConnectCancellationSignal()
|
||||
let session = MobileCoreRPCSession(
|
||||
makeTransport: { transport },
|
||||
diagnosticTransport: .debugLoopback,
|
||||
transportConnectObserver: { event in
|
||||
_ = continuation.yield(event)
|
||||
Task { await cancellationSignal.record(event) }
|
||||
}
|
||||
)
|
||||
let first = try MobileCoreRPCClient.requestData(
|
||||
method: "mobile.host.status",
|
||||
id: "cancelled-closed-connect"
|
||||
id: "abandoned-connect"
|
||||
)
|
||||
let second = try MobileCoreRPCClient.requestData(
|
||||
method: "mobile.host.status",
|
||||
id: "retry-after-closed-connect"
|
||||
id: "retry-after-abandoned-connect"
|
||||
)
|
||||
let deadline = DispatchTime.now().uptimeNanoseconds + 60 * 1_000_000_000
|
||||
let firstTask = Task {
|
||||
try await session.send(
|
||||
payload: first,
|
||||
requestID: "cancelled-closed-connect",
|
||||
requestID: "abandoned-connect",
|
||||
deadlineUptimeNanoseconds: deadline
|
||||
)
|
||||
}
|
||||
@@ -89,37 +91,35 @@ import Testing
|
||||
Issue.record("Expected CancellationError, got \(error)")
|
||||
}
|
||||
await transport.waitUntilFirstConnectFinished()
|
||||
#expect(await cancellationSignal.waitUntilObserved())
|
||||
|
||||
let data = try await session.send(
|
||||
payload: second,
|
||||
requestID: "retry-after-closed-connect",
|
||||
requestID: "retry-after-abandoned-connect",
|
||||
deadlineUptimeNanoseconds: deadline
|
||||
)
|
||||
let response = try #require(JSONSerialization.jsonObject(with: data) as? [String: String])
|
||||
#expect(response["status"] == "ok")
|
||||
#expect(await transport.connectCount() == 2)
|
||||
#expect(try await transport.sentRequests().map(\.id) == ["retry-after-closed-connect"])
|
||||
|
||||
continuation.finish()
|
||||
let recorded = await collect(events)
|
||||
#expect(recorded.count == 3)
|
||||
guard recorded.count == 3 else {
|
||||
#expect(recorded.count == 4)
|
||||
guard recorded.count == 4 else {
|
||||
await session.tearDown(error: .connectionClosed)
|
||||
return
|
||||
}
|
||||
guard case let .attempt(firstAttemptID, firstTransport) = recorded[0],
|
||||
case let .attempt(secondAttemptID, secondTransport) = recorded[1],
|
||||
case let .connected(connectedID, connectedTransport, _) = recorded[2] else {
|
||||
Issue.record("Expected attempt, attempt, connected with no failure")
|
||||
guard case let .attempt(firstAttemptID, _) = recorded[0],
|
||||
case let .failed(abandonedID, abandonedTransport, abandonedFailure, _) = recorded[1],
|
||||
case let .attempt(secondAttemptID, _) = recorded[2],
|
||||
case let .connected(connectedID, _, _) = recorded[3] else {
|
||||
Issue.record("Expected attempt, failed(cancelled), attempt, connected")
|
||||
await session.tearDown(error: .connectionClosed)
|
||||
return
|
||||
}
|
||||
#expect(firstAttemptID > 0)
|
||||
#expect(secondAttemptID > 0)
|
||||
#expect(firstTransport == .debugLoopback)
|
||||
#expect(secondTransport == .debugLoopback)
|
||||
#expect(abandonedID == firstAttemptID)
|
||||
#expect(abandonedTransport == .debugLoopback)
|
||||
#expect(abandonedFailure == .cancelled)
|
||||
#expect(connectedID == secondAttemptID)
|
||||
#expect(connectedTransport == .debugLoopback)
|
||||
await session.tearDown(error: .connectionClosed)
|
||||
}
|
||||
|
||||
@@ -159,3 +159,26 @@ import Testing
|
||||
return events
|
||||
}
|
||||
}
|
||||
|
||||
private actor MobileRPCConnectCancellationSignal {
|
||||
private var observed = false
|
||||
|
||||
func record(_ event: MobileRPCTransportConnectEvent) {
|
||||
guard case let .failed(_, _, failure, _) = event,
|
||||
failure == .cancelled else {
|
||||
return
|
||||
}
|
||||
observed = true
|
||||
}
|
||||
|
||||
func waitUntilObserved(
|
||||
timeout: Duration = .seconds(2)
|
||||
) async -> Bool {
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: timeout)
|
||||
while !observed, clock.now < deadline {
|
||||
await Task.yield()
|
||||
}
|
||||
return observed
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import CMUXMobileCore
|
||||
import CmuxMobilePairedMac
|
||||
import CmuxMobileRPC
|
||||
import Foundation
|
||||
|
||||
/// Foreground ownership published before its first transport suspension.
|
||||
/// Background aggregation consults this reservation so a previously selected
|
||||
/// control candidate cannot admit a second session on the foreground route.
|
||||
struct ForegroundConnectionAttemptReservation {
|
||||
let id: UUID
|
||||
let requestedMacDeviceID: String?
|
||||
let instanceTagExpectation: MobileMacInstanceTagExpectation
|
||||
let routes: [CmxAttachRoute]
|
||||
|
||||
func conflicts(with mac: MobilePairedMac) -> Bool {
|
||||
if targetsSamePairing(as: mac) {
|
||||
return true
|
||||
}
|
||||
return mac.routes.contains { storedRoute in
|
||||
routes.contains { foregroundRoute in
|
||||
MobileCoreRPCClient.routesSharePhysicalTransport(
|
||||
storedRoute,
|
||||
foregroundRoute
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func targetsSamePairing(as mac: MobilePairedMac) -> Bool {
|
||||
guard let requestedMacDeviceID,
|
||||
cmxCanonicalDeviceID(requestedMacDeviceID)
|
||||
== cmxCanonicalDeviceID(mac.macDeviceID) else {
|
||||
return false
|
||||
}
|
||||
switch instanceTagExpectation {
|
||||
case .adopt:
|
||||
// Until authentication reports a tag, this attempt can own any
|
||||
// saved row for the requested logical Mac.
|
||||
return true
|
||||
case .preserve(let tag), .require(let tag):
|
||||
// A legacy nil-tag row aliases the requested tagged instance.
|
||||
return mac.instanceTag == nil || mac.instanceTag == tag
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,10 @@ public enum MobilePairingFailureCategory: Equatable, Sendable {
|
||||
/// Two cancellation-ignoring route cleanups are still alive. Retrying in
|
||||
/// this process cannot start another transport without exceeding the cap.
|
||||
case routeCleanupBlocked
|
||||
/// Another connection attempt already owns this route. The Mac did not
|
||||
/// fail to respond; the active attempt resolves the route on its own, so
|
||||
/// the user should wait for it rather than treat this as a timeout.
|
||||
case connectAttemptGated
|
||||
/// The attempt was cancelled (the user tapped Cancel, or a newer attempt
|
||||
/// superseded it). Not surfaced as an error.
|
||||
case cancelled
|
||||
@@ -123,6 +127,7 @@ extension MobilePairingFailureCategory {
|
||||
case .unsupportedRoute: return "unsupported_route"
|
||||
case .noSupportedRoute: return "no_supported_route"
|
||||
case .routeCleanupBlocked: return "route_cleanup_blocked"
|
||||
case .connectAttemptGated: return "connect_attempt_gated"
|
||||
case .cancelled: return "cancelled"
|
||||
case .unknown: return "other"
|
||||
}
|
||||
@@ -252,7 +257,7 @@ extension MobilePairingFailureCategory {
|
||||
case .invalidCode:
|
||||
return L10n.string(
|
||||
"mobile.pairing.invalidCode",
|
||||
defaultValue: "This isn't a cmux pairing QR. Scan the code shown in the Pair iPhone window on your Mac."
|
||||
defaultValue: "This isn't a cmux pairing QR. Scan the code shown in Tailscale Pairing on your Mac."
|
||||
)
|
||||
case .unrecognizedVersion:
|
||||
return L10n.string(
|
||||
@@ -262,7 +267,10 @@ extension MobilePairingFailureCategory {
|
||||
case .loopbackRejected:
|
||||
return L10n.string(
|
||||
"mobile.pairing.loopbackRejected",
|
||||
defaultValue: "This code points at the Mac itself (localhost), so your iPhone can't use it. Update cmux on the Mac and scan its Iroh code."
|
||||
defaultValue: """
|
||||
This code points at the Mac itself (localhost), so your iPhone can't use it. \
|
||||
Open Tailscale Pairing on the Mac and scan a fresh code.
|
||||
"""
|
||||
)
|
||||
case .macUpdateRequired:
|
||||
return L10n.string(
|
||||
@@ -284,6 +292,11 @@ extension MobilePairingFailureCategory {
|
||||
"mobile.pairing.routeCleanupBlocked",
|
||||
defaultValue: "cmux paused new connections because earlier connection cleanups are still stuck."
|
||||
)
|
||||
case .connectAttemptGated:
|
||||
return L10n.string(
|
||||
"mobile.pairing.connectAttemptGated",
|
||||
defaultValue: "Already reconnecting to this computer."
|
||||
)
|
||||
case .cancelled:
|
||||
return ""
|
||||
case let .unknown(host, port):
|
||||
@@ -346,7 +359,7 @@ extension MobilePairingFailureCategory {
|
||||
case .ticketExpired, .unsupportedRoute, .noSupportedRoute:
|
||||
return L10n.string(
|
||||
"mobile.pairing.guidance.rescanFresh",
|
||||
defaultValue: "Open the pairing window on your Mac and scan a fresh QR or link."
|
||||
defaultValue: "Open Tailscale Pairing on your Mac and scan a fresh QR or link."
|
||||
)
|
||||
case .unrecognizedVersion:
|
||||
return L10n.string(
|
||||
@@ -363,6 +376,11 @@ extension MobilePairingFailureCategory {
|
||||
"mobile.pairing.guidance.routeCleanupBlocked",
|
||||
defaultValue: "Force-quit and reopen cmux on this iPhone, then reconnect. If this returns, restart cmux on the Mac."
|
||||
)
|
||||
case .connectAttemptGated:
|
||||
return L10n.string(
|
||||
"mobile.pairing.guidance.connectAttemptGated",
|
||||
defaultValue: "A connection attempt is already in progress. Give it a moment to finish; retry only if this computer stays disconnected."
|
||||
)
|
||||
case .invalidCode, .loopbackRejected, .cancelled, .unknown:
|
||||
return nil
|
||||
}
|
||||
@@ -415,6 +433,11 @@ extension MobilePairingFailureCategory {
|
||||
switch connectionError {
|
||||
case .requestTimedOut, .connectAttemptGated:
|
||||
return .handshakeTimedOut(host: host, port: port)
|
||||
case .connectAttemptGated:
|
||||
// Another attempt owns this route: the Mac did not time out,
|
||||
// so timeout guidance ("No response from …") would misdirect
|
||||
// the user. Surface the wait-for-active-attempt state instead.
|
||||
return .connectAttemptGated
|
||||
case .insecureManualRoute:
|
||||
return .unsupportedRoute
|
||||
case .attachTicketExpired:
|
||||
|
||||
+8
-2
@@ -38,9 +38,15 @@ extension MobileShellComposite {
|
||||
/// Whether the Mac supports workspace group mutation requests.
|
||||
public var supportsWorkspaceGroupActions: Bool { supportedHostCapabilities.contains(Self.workspaceGroupActionsCapability) && allowsMacScopedWorkspaceMutations }
|
||||
/// Whether the Mac supports creating a workspace directly inside a group.
|
||||
public var supportsWorkspaceCreateInGroup: Bool { supportedHostCapabilities.contains(Self.workspaceCreateInGroupCapability) && allowsMacScopedWorkspaceMutations }
|
||||
public var supportsWorkspaceCreateInGroup: Bool {
|
||||
supportedHostCapabilities.contains(Self.workspaceCreateInGroupCapability)
|
||||
&& discoversMacScopedWorkspaceMutations
|
||||
}
|
||||
/// Whether the Mac supports creating workspace groups from iOS.
|
||||
public var supportsWorkspaceGroupCreate: Bool { supportedHostCapabilities.contains(Self.workspaceGroupCreateCapability) && allowsMacScopedWorkspaceMutations }
|
||||
public var supportsWorkspaceGroupCreate: Bool {
|
||||
supportedHostCapabilities.contains(Self.workspaceGroupCreateCapability)
|
||||
&& discoversMacScopedWorkspaceMutations
|
||||
}
|
||||
/// Whether the Mac supports dogfood feedback submission.
|
||||
public var supportsDogfoodFeedback: Bool { supportedHostCapabilities.contains(Self.dogfoodFeedbackCapability) }
|
||||
/// Whether the Mac supports chat artifact stat/fetch/thumbnail/list RPCs.
|
||||
|
||||
+33
-5
@@ -40,6 +40,11 @@ extension MobileShellComposite {
|
||||
.reachabilityChanged,
|
||||
a: isOnline ? 1 : 0
|
||||
))
|
||||
// Route strikes and hard gates accumulated on the old path
|
||||
// predict nothing about the new one; drop them before this
|
||||
// recovery pass so it is not refused by stale poisoning.
|
||||
await self.connectAttemptRegistry.resetRouteHealthForNetworkChange()
|
||||
guard !Task.isCancelled else { return }
|
||||
self.recoverMobileConnection(trigger: .networkChange)
|
||||
}
|
||||
}
|
||||
@@ -97,7 +102,14 @@ extension MobileShellComposite {
|
||||
probeCurrentConnection: connectionState == .connected && remoteClient != nil,
|
||||
resyncAfterHealthy: true
|
||||
)
|
||||
if multiMacAggregationEnabled, trigger.reschedulesSecondaryAggregation {
|
||||
// A disconnected redial has cleared its foreground identity. Starting
|
||||
// aggregation then would classify that same stored Mac as secondary and
|
||||
// race the foreground attempt for one physical route lease.
|
||||
if multiMacAggregationEnabled,
|
||||
trigger.reschedulesSecondaryAggregation,
|
||||
connectionState == .connected,
|
||||
remoteClient != nil,
|
||||
!connectionRecoveryOwner.isRedialingOrValidating {
|
||||
scheduleSecondaryAggregation()
|
||||
}
|
||||
}
|
||||
@@ -269,9 +281,17 @@ extension MobileShellComposite {
|
||||
self.macConnectionStatus = .unavailable
|
||||
self.clearRemoteConnectionContext()
|
||||
self.applyConnectionRecoveryOwnerState()
|
||||
await expectedClient.disconnect()
|
||||
MobileDebugLog.anchormux(
|
||||
"connection.recovery waiting for physical transport drain "
|
||||
+ "attempt=\(attempt.id.uuidString)"
|
||||
)
|
||||
await expectedClient.disconnectAndWaitForTransportDrain()
|
||||
guard !Task.isCancelled,
|
||||
self.connectionRecoveryOwner.isCurrent(attempt) else { return }
|
||||
MobileDebugLog.anchormux(
|
||||
"connection.recovery physical transport drained "
|
||||
+ "attempt=\(attempt.id.uuidString)"
|
||||
)
|
||||
}
|
||||
if self.connectionState == .connected {
|
||||
self.connectionState = .disconnected
|
||||
@@ -321,7 +341,8 @@ extension MobileShellComposite {
|
||||
_ attempt: MobileConnectionRecoveryOwner.Attempt,
|
||||
connectionGeneration: UUID
|
||||
) -> Bool {
|
||||
if lastSuccessfulTerminalSubscriptionGeneration == connectionGeneration {
|
||||
if lastSuccessfulTerminalSubscription?.connectionGeneration
|
||||
== connectionGeneration {
|
||||
return completeConnectionRecovery(attempt)
|
||||
}
|
||||
return connectionRecoveryOwner.transitionToValidation(
|
||||
@@ -385,8 +406,15 @@ extension MobileShellComposite {
|
||||
))
|
||||
}
|
||||
|
||||
func recordSuccessfulTerminalSubscription() {
|
||||
lastSuccessfulTerminalSubscriptionGeneration = connectionGeneration
|
||||
func recordSuccessfulTerminalSubscription(
|
||||
connectionGeneration: UUID,
|
||||
listenerID: UUID? = nil
|
||||
) {
|
||||
lastSuccessfulTerminalSubscription =
|
||||
MobileTerminalSubscriptionValidation(
|
||||
connectionGeneration: connectionGeneration,
|
||||
listenerID: listenerID
|
||||
)
|
||||
if connectionRecoveryOwner.completeValidation(connectionGeneration: connectionGeneration) {
|
||||
recordConnectionRecoverySucceeded()
|
||||
applyConnectionRecoveryOwnerState()
|
||||
|
||||
+3
-1
@@ -325,7 +325,9 @@ extension MobileShellComposite {
|
||||
// but the other Macs are a read-only snapshot. Re-aggregate them on
|
||||
// foreground so workspaces created on another Mac while backgrounded
|
||||
// appear without a manual pull-to-refresh.
|
||||
if multiMacAggregationEnabled, connectionState == .connected {
|
||||
if multiMacAggregationEnabled,
|
||||
connectionState == .connected,
|
||||
remoteClient != nil {
|
||||
self.scheduleSecondaryAggregation()
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -374,6 +374,7 @@ extension MobileShellComposite {
|
||||
name: record.name,
|
||||
isCollapsed: record.isCollapsed,
|
||||
isPinned: record.isPinned,
|
||||
iconSymbol: record.iconSymbol,
|
||||
anchorWorkspaceID: record.anchorWorkspaceID
|
||||
)
|
||||
}
|
||||
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
internal import CmuxMobileRPC
|
||||
public import CmuxMobileShellModel
|
||||
public import Foundation
|
||||
|
||||
extension MobileShellComposite {
|
||||
/// Whether the selected Mac instance currently advertises task attachments.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - macDeviceID: Physical Mac selected in the task composer.
|
||||
/// - instanceTag: Exact paired app instance, when known.
|
||||
/// - Returns: `true` only for a matching host capability announcement.
|
||||
public func supportsTaskAttachments(
|
||||
macDeviceID: String,
|
||||
instanceTag: String?
|
||||
) -> Bool {
|
||||
if matchesForegroundPairing(
|
||||
macDeviceID: macDeviceID,
|
||||
instanceTag: instanceTag
|
||||
) {
|
||||
return supportedHostCapabilities.contains(Self.taskAttachmentCapability)
|
||||
}
|
||||
if let subscription = controlSubscriptionMatching(
|
||||
macDeviceID: macDeviceID,
|
||||
instanceTag: instanceTag
|
||||
) {
|
||||
return subscription.supportedHostCapabilities.contains(
|
||||
Self.taskAttachmentCapability
|
||||
)
|
||||
}
|
||||
let aliases = pairedMacAliasIDs(
|
||||
for: macDeviceID,
|
||||
instanceTag: instanceTag
|
||||
)
|
||||
if let instanceTag {
|
||||
return aliases.contains {
|
||||
presenceMap.instance(deviceId: $0, tag: instanceTag)?
|
||||
.capabilities.contains(Self.taskAttachmentCapability) == true
|
||||
}
|
||||
}
|
||||
return aliases.contains {
|
||||
presenceMap.soleRouteAdvertisingInstance(deviceId: $0)?
|
||||
.capabilities.contains(Self.taskAttachmentCapability) == true
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads one staged task attachment to the selected Mac in 3 MiB chunks.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - attachment: App-owned staged attachment file.
|
||||
/// - operationID: Task submission idempotency key.
|
||||
/// - macDeviceID: Target Mac device id.
|
||||
/// - instanceTag: Exact paired app instance, when known.
|
||||
/// - Returns: The final absolute Mac path, or a user-actionable failure.
|
||||
public func uploadTaskAttachment(
|
||||
_ attachment: TaskComposerAttachment,
|
||||
operationID: UUID,
|
||||
macDeviceID: String,
|
||||
instanceTag: String?
|
||||
) async -> Result<String, MobileWorkspaceMutationFailure> {
|
||||
if !matchesForegroundPairing(
|
||||
macDeviceID: macDeviceID,
|
||||
instanceTag: instanceTag
|
||||
) || remoteClient == nil {
|
||||
guard await switchToMac(
|
||||
macDeviceID: macDeviceID,
|
||||
instanceTag: instanceTag
|
||||
) else {
|
||||
return .failure(.notConnected(
|
||||
hostDisplayName: taskComposerTargetName(
|
||||
macDeviceID: macDeviceID,
|
||||
instanceTag: instanceTag
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
guard !Task.isCancelled,
|
||||
let context = captureWorkspaceCreateContext(),
|
||||
context.macDeviceID == macDeviceID,
|
||||
instanceTag == nil || context.instanceTag == instanceTag else {
|
||||
return .failure(.notConnected(
|
||||
hostDisplayName: taskComposerTargetName(
|
||||
macDeviceID: macDeviceID,
|
||||
instanceTag: instanceTag
|
||||
)
|
||||
))
|
||||
}
|
||||
guard context.supportedHostCapabilities.contains(
|
||||
Self.taskAttachmentCapability
|
||||
) else {
|
||||
return .failure(.unsupported(hostDisplayName: context.hostDisplayName))
|
||||
}
|
||||
|
||||
let data: Data
|
||||
do {
|
||||
data = try await loadTaskAttachmentData(
|
||||
from: attachment.localStagedFileURL
|
||||
)
|
||||
} catch {
|
||||
return .failure(.rejected(hostDisplayName: context.hostDisplayName))
|
||||
}
|
||||
guard data.count == attachment.byteCount,
|
||||
data.count <= TaskComposerAttachment.maximumFileBytes else {
|
||||
return .failure(.rejected(hostDisplayName: context.hostDisplayName))
|
||||
}
|
||||
|
||||
let plan = MobileTaskAttachmentChunkPlan(totalByteCount: data.count)
|
||||
do {
|
||||
var finalPath: String?
|
||||
for (index, range) in plan.ranges.enumerated() {
|
||||
try Task.checkCancellation()
|
||||
let isLast = index == plan.ranges.count - 1
|
||||
let params: [String: Any] = [
|
||||
"operation_id": operationID.uuidString,
|
||||
"upload_id": attachment.id.uuidString,
|
||||
"file_name": attachment.displayName,
|
||||
"total_bytes": data.count,
|
||||
"offset": range.lowerBound,
|
||||
"data_b64": data.subdata(in: range).base64EncodedString(),
|
||||
"last": isLast,
|
||||
]
|
||||
let response = try await context.client.sendRequest(
|
||||
MobileCoreRPCClient.requestData(
|
||||
method: "mobile.task.attachment.upload",
|
||||
params: params
|
||||
)
|
||||
)
|
||||
guard context.isCurrent(
|
||||
macDeviceID: foregroundMacDeviceID,
|
||||
instanceTag: activeMacInstanceTag,
|
||||
client: remoteClient,
|
||||
generation: connectionGeneration
|
||||
), isSignedIn else {
|
||||
return .failure(.notConnected(
|
||||
hostDisplayName: context.hostDisplayName
|
||||
))
|
||||
}
|
||||
guard let object = try JSONSerialization.jsonObject(with: response)
|
||||
as? [String: Any] else {
|
||||
throw MobileShellConnectionError.invalidResponse
|
||||
}
|
||||
if isLast {
|
||||
guard let path = object["path"] as? String,
|
||||
path.hasPrefix("/") else {
|
||||
throw MobileShellConnectionError.invalidResponse
|
||||
}
|
||||
finalPath = path
|
||||
}
|
||||
}
|
||||
guard let finalPath else {
|
||||
return .failure(.rejected(hostDisplayName: context.hostDisplayName))
|
||||
}
|
||||
return .success(finalPath)
|
||||
} catch {
|
||||
if context.isCurrent(
|
||||
macDeviceID: foregroundMacDeviceID,
|
||||
instanceTag: activeMacInstanceTag,
|
||||
client: remoteClient,
|
||||
generation: connectionGeneration
|
||||
) {
|
||||
handleMacAvailabilityFailureIfCurrent(
|
||||
after: error,
|
||||
expectedClient: context.client,
|
||||
expectedGeneration: context.generation
|
||||
)
|
||||
}
|
||||
return .failure(
|
||||
workspaceMutationFailure(
|
||||
error,
|
||||
hostDisplayName: context.hostDisplayName
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadTaskAttachmentData(from url: URL) async throws -> Data {
|
||||
try await withThrowingTaskGroup(of: Data.self) { group in
|
||||
group.addTask(priority: .utility) {
|
||||
try Task.checkCancellation()
|
||||
return try Data(contentsOf: url, options: .mappedIfSafe)
|
||||
}
|
||||
guard let data = try await group.next() else {
|
||||
throw CancellationError()
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -247,7 +247,7 @@ extension MobileShellComposite {
|
||||
)
|
||||
}
|
||||
|
||||
private func taskComposerTargetName(macDeviceID: String, instanceTag: String?) -> String {
|
||||
func taskComposerTargetName(macDeviceID: String, instanceTag: String?) -> String {
|
||||
displayPairedMacs.first {
|
||||
$0.macDeviceID == macDeviceID
|
||||
&& (instanceTag == nil || $0.instanceTag == instanceTag)
|
||||
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
internal import CmuxMobileRPC
|
||||
public import CmuxMobileShellModel
|
||||
import Foundation
|
||||
|
||||
extension MobileShellComposite {
|
||||
/// Resolves a secondary control subscription for a physical Mac: the
|
||||
/// exact pairing when a tag is given, otherwise any same-device pairing.
|
||||
/// Mirrors the pre-MacPairingKey device-id lookup these capability
|
||||
/// checks were written against.
|
||||
func controlSubscriptionMatching(
|
||||
macDeviceID: String,
|
||||
instanceTag: String?
|
||||
) -> SecondaryMacSubscription? {
|
||||
let probe = MacPairingKey(macDeviceID: macDeviceID, instanceTag: instanceTag)
|
||||
if probe.normalizedInstanceTag != nil,
|
||||
let exact = secondaryMacSubscriptions[probe] {
|
||||
return exact
|
||||
}
|
||||
for key in secondaryMacSubscriptions.keys
|
||||
where key.canonicalMacDeviceID == probe.canonicalMacDeviceID {
|
||||
guard let subscription = secondaryMacSubscriptions[key] else { continue }
|
||||
if instanceTag == nil
|
||||
|| key.normalizedInstanceTag == probe.normalizedInstanceTag
|
||||
|| subscription.authenticatedInstanceTag == instanceTag
|
||||
|| subscription.storedInstanceTag == instanceTag {
|
||||
return subscription
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Whether the selected Mac instance advertises task model discovery.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - macDeviceID: Physical Mac selected in the task composer.
|
||||
/// - instanceTag: Exact paired app instance, when known.
|
||||
/// - Returns: `true` only for a matching host capability announcement.
|
||||
public func supportsTaskModels(
|
||||
macDeviceID: String,
|
||||
instanceTag: String?
|
||||
) -> Bool {
|
||||
if matchesForegroundPairing(
|
||||
macDeviceID: macDeviceID,
|
||||
instanceTag: instanceTag
|
||||
) {
|
||||
return supportedHostCapabilities.contains(Self.taskModelsCapability)
|
||||
}
|
||||
if let subscription = controlSubscriptionMatching(
|
||||
macDeviceID: macDeviceID,
|
||||
instanceTag: instanceTag
|
||||
) {
|
||||
return subscription.supportedHostCapabilities.contains(
|
||||
Self.taskModelsCapability
|
||||
)
|
||||
}
|
||||
let aliases = pairedMacAliasIDs(
|
||||
for: macDeviceID,
|
||||
instanceTag: instanceTag
|
||||
)
|
||||
if let instanceTag {
|
||||
return aliases.contains {
|
||||
presenceMap.instance(deviceId: $0, tag: instanceTag)?
|
||||
.capabilities.contains(Self.taskModelsCapability) == true
|
||||
}
|
||||
}
|
||||
return aliases.contains {
|
||||
presenceMap.soleRouteAdvertisingInstance(deviceId: $0)?
|
||||
.capabilities.contains(Self.taskModelsCapability) == true
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches one provider's models from the selected Mac.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - provider: Coding-agent provider to query.
|
||||
/// - macDeviceID: Physical Mac selected in the task composer.
|
||||
/// - instanceTag: Exact paired app instance, when known.
|
||||
/// - Returns: Models plus their discovery source.
|
||||
/// - Throws: A connection or response error when discovery cannot complete.
|
||||
public func fetchTaskModels(
|
||||
provider: MobileTaskAgentProvider,
|
||||
macDeviceID: String,
|
||||
instanceTag: String?
|
||||
) async throws -> MobileTaskModelListResult {
|
||||
if !matchesForegroundPairing(
|
||||
macDeviceID: macDeviceID,
|
||||
instanceTag: instanceTag
|
||||
) || remoteClient == nil {
|
||||
guard await switchToMac(
|
||||
macDeviceID: macDeviceID,
|
||||
instanceTag: instanceTag
|
||||
) else {
|
||||
throw MobileShellConnectionError.connectionClosed
|
||||
}
|
||||
}
|
||||
guard !Task.isCancelled,
|
||||
let context = captureWorkspaceCreateContext(),
|
||||
context.macDeviceID == macDeviceID,
|
||||
instanceTag == nil || context.instanceTag == instanceTag,
|
||||
context.supportedHostCapabilities.contains(
|
||||
Self.taskModelsCapability
|
||||
) else {
|
||||
throw MobileShellConnectionError.invalidResponse
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await context.client.sendRequest(
|
||||
MobileCoreRPCClient.requestData(
|
||||
method: "mobile.task.models.list",
|
||||
params: ["provider": provider.rawValue]
|
||||
)
|
||||
)
|
||||
guard context.isCurrent(
|
||||
macDeviceID: foregroundMacDeviceID,
|
||||
instanceTag: activeMacInstanceTag,
|
||||
client: remoteClient,
|
||||
generation: connectionGeneration
|
||||
), isSignedIn,
|
||||
let object = try JSONSerialization.jsonObject(with: response)
|
||||
as? [String: Any],
|
||||
let rawSource = object["source"] as? String,
|
||||
let source = MobileTaskModelListSource(rawValue: rawSource),
|
||||
let rawModels = object["models"] as? [[String: Any]]
|
||||
else {
|
||||
throw MobileShellConnectionError.invalidResponse
|
||||
}
|
||||
var models: [MobileTaskAgentModel] = []
|
||||
models.reserveCapacity(rawModels.count)
|
||||
var seenIDs: Set<String> = []
|
||||
for rawModel in rawModels {
|
||||
guard let id = rawModel["id"] as? String,
|
||||
!id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let displayName = rawModel["display_name"] as? String,
|
||||
!displayName.isEmpty,
|
||||
seenIDs.insert(id).inserted else {
|
||||
throw MobileShellConnectionError.invalidResponse
|
||||
}
|
||||
models.append(MobileTaskAgentModel(
|
||||
id: id,
|
||||
displayName: displayName
|
||||
))
|
||||
}
|
||||
guard !models.isEmpty else {
|
||||
throw MobileShellConnectionError.invalidResponse
|
||||
}
|
||||
return MobileTaskModelListResult(models: models, source: source)
|
||||
} catch {
|
||||
if context.isCurrent(
|
||||
macDeviceID: foregroundMacDeviceID,
|
||||
instanceTag: activeMacInstanceTag,
|
||||
client: remoteClient,
|
||||
generation: connectionGeneration
|
||||
) {
|
||||
handleMacAvailabilityFailureIfCurrent(
|
||||
after: error,
|
||||
expectedClient: context.client,
|
||||
expectedGeneration: context.generation
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns cached models synchronously for composer rendering and restore.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - provider: Coding-agent provider to resolve.
|
||||
/// - macDeviceID: Physical Mac selected in the task composer.
|
||||
/// - instanceTag: Exact paired instance; the cache intentionally remains
|
||||
/// device/provider scoped so app rebuilds on one Mac share discovery.
|
||||
/// - Returns: Previously fetched models, or `nil`.
|
||||
public func discoveredTaskModels(
|
||||
provider: MobileTaskAgentProvider,
|
||||
macDeviceID: String,
|
||||
instanceTag _: String?
|
||||
) -> [MobileTaskAgentModel]? {
|
||||
taskModelCache[
|
||||
MobileTaskModelCacheKey(
|
||||
macDeviceID: macDeviceID,
|
||||
provider: provider
|
||||
)
|
||||
]?.result.models
|
||||
}
|
||||
|
||||
/// Refreshes and caches one provider's models when the Mac supports it.
|
||||
///
|
||||
/// Failed refreshes leave an earlier valid cache entry intact.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - provider: Coding-agent provider to query.
|
||||
/// - macDeviceID: Physical Mac selected in the task composer.
|
||||
/// - instanceTag: Exact paired app instance, when known.
|
||||
public func refreshTaskModels(
|
||||
provider: MobileTaskAgentProvider,
|
||||
macDeviceID: String,
|
||||
instanceTag: String?
|
||||
) async {
|
||||
guard supportsTaskModels(
|
||||
macDeviceID: macDeviceID,
|
||||
instanceTag: instanceTag
|
||||
) else {
|
||||
return
|
||||
}
|
||||
guard let result = try? await fetchTaskModels(
|
||||
provider: provider,
|
||||
macDeviceID: macDeviceID,
|
||||
instanceTag: instanceTag
|
||||
) else {
|
||||
return
|
||||
}
|
||||
taskModelCache[
|
||||
MobileTaskModelCacheKey(
|
||||
macDeviceID: macDeviceID,
|
||||
provider: provider
|
||||
)
|
||||
] = MobileTaskModelCacheEntry(
|
||||
result: result,
|
||||
fetchedAt: runtime?.now() ?? Date()
|
||||
)
|
||||
}
|
||||
|
||||
/// Fetch timestamp used by package tests and cache diagnostics.
|
||||
func taskModelsFetchedAt(
|
||||
provider: MobileTaskAgentProvider,
|
||||
macDeviceID: String,
|
||||
instanceTag _: String?
|
||||
) -> Date? {
|
||||
taskModelCache[
|
||||
MobileTaskModelCacheKey(
|
||||
macDeviceID: macDeviceID,
|
||||
provider: provider
|
||||
)
|
||||
]?.fetchedAt
|
||||
}
|
||||
}
|
||||
+39
-6
@@ -241,7 +241,8 @@ extension MobileShellComposite {
|
||||
target: target,
|
||||
hostDisplayName: hostDisplayName,
|
||||
logID: id.rawValue,
|
||||
actionName: "move"
|
||||
actionName: "move",
|
||||
isMacScoped: true
|
||||
)
|
||||
switch result {
|
||||
case .success:
|
||||
@@ -347,7 +348,8 @@ extension MobileShellComposite {
|
||||
target: target,
|
||||
hostDisplayName: hostDisplayName,
|
||||
logID: "foreground",
|
||||
actionName: "create_group"
|
||||
actionName: "create_group",
|
||||
isMacScoped: true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -381,6 +383,14 @@ extension MobileShellComposite {
|
||||
)
|
||||
}
|
||||
|
||||
private func hostAuthorizesAccountScopedMutations(target: WorkspaceMutationTarget) -> Bool {
|
||||
if target.isForeground {
|
||||
return hostAuthorizesAccountScopedMutations
|
||||
}
|
||||
return target.ownerKey.flatMap { secondaryMacSubscriptions[$0] }?
|
||||
.supportedHostCapabilities.contains(Self.workspaceMutationAccountAuthCapability) ?? false
|
||||
}
|
||||
|
||||
private func sendWorkspaceMutation(
|
||||
method: String,
|
||||
params: [String: Any],
|
||||
@@ -412,9 +422,11 @@ extension MobileShellComposite {
|
||||
let target = workspaceGroupMutationTarget(for: id)
|
||||
let hostDisplayName = workspaceGroupHostDisplayName(for: id, target: target)
|
||||
guard workspaceGroupActionCapabilities(for: id).supportsGroupActions else {
|
||||
MobileDebugLog.anchormux("workspace.mutation blocked action=\(actionName) id=\(id.rawValue) reason=capability")
|
||||
return .failure(.unsupported(hostDisplayName: hostDisplayName))
|
||||
}
|
||||
guard macScopedWorkspaceMutationIsAuthorized(target: target) else {
|
||||
MobileDebugLog.anchormux("workspace.mutation blocked action=\(actionName) id=\(id.rawValue) reason=scope")
|
||||
return .failure(.authorizationFailed(hostDisplayName: hostDisplayName))
|
||||
}
|
||||
var params: [String: Any] = ["group_id": id.rawValue, "action": action]
|
||||
@@ -427,7 +439,8 @@ extension MobileShellComposite {
|
||||
target: target,
|
||||
hostDisplayName: hostDisplayName,
|
||||
logID: id.rawValue,
|
||||
actionName: actionName
|
||||
actionName: actionName,
|
||||
isMacScoped: true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -438,7 +451,8 @@ extension MobileShellComposite {
|
||||
hostDisplayName: String?,
|
||||
logID: String,
|
||||
actionName: String,
|
||||
refreshAfterMutation: Bool = true
|
||||
refreshAfterMutation: Bool = true,
|
||||
isMacScoped: Bool = false
|
||||
) async -> Result<Void, MobileWorkspaceMutationFailure> {
|
||||
// Route the mutation to the Mac that actually OWNS this workspace. The
|
||||
// aggregated list can include rows from secondary Macs, whose connection is
|
||||
@@ -447,6 +461,7 @@ extension MobileShellComposite {
|
||||
// mutate a foreground workspace). The foreground path is unchanged for
|
||||
// foreground-owned (or single-Mac / anonymous) rows.
|
||||
guard let client = target.client else {
|
||||
MobileDebugLog.anchormux("workspace.mutation blocked action=\(actionName) id=\(logID) reason=no_route")
|
||||
// Owner is a known non-foreground Mac with no live connection: can't
|
||||
// deliver. Snap the row back to the authoritative state instead of
|
||||
// misrouting to the foreground Mac.
|
||||
@@ -456,10 +471,27 @@ extension MobileShellComposite {
|
||||
return .failure(.notConnected(hostDisplayName: hostDisplayName))
|
||||
}
|
||||
let generation = connectionGeneration
|
||||
MobileDebugLog.anchormux("workspace.mutation sending action=\(actionName) id=\(logID) foreground=\(target.isForeground)")
|
||||
do {
|
||||
let request = try MobileCoreRPCClient.requestData(method: method, params: params)
|
||||
_ = try await client.sendRequest(request)
|
||||
let attachTicketPolicy: MobileCoreRPCAttachTicketPolicy =
|
||||
isMacScoped && hostAuthorizesAccountScopedMutations(target: target)
|
||||
? .omit
|
||||
: .whenCovered
|
||||
_ = try await client.sendRequest(
|
||||
request,
|
||||
attachTicketPolicy: attachTicketPolicy
|
||||
)
|
||||
} catch {
|
||||
// Diagnostics carry only the bounded failure vocabulary plus the
|
||||
// short RPC code: an rpcError message is an arbitrary host string
|
||||
// and must never be retained in exported diagnostics.
|
||||
let failureKind = DiagnosticFailureKind.classify(error)
|
||||
var rpcCode = "none"
|
||||
if case let MobileShellConnectionError.rpcError(code, _) = error {
|
||||
rpcCode = code ?? "unknown"
|
||||
}
|
||||
MobileDebugLog.anchormux("workspace.mutation failed action=\(actionName) id=\(logID) kind=\(failureKind) code=\(rpcCode)")
|
||||
if disconnectForAuthorizationFailureIfNeeded(error) {
|
||||
return .failure(.authorizationFailed(hostDisplayName: hostDisplayName))
|
||||
}
|
||||
@@ -473,7 +505,7 @@ extension MobileShellComposite {
|
||||
expectedGeneration: generation
|
||||
)
|
||||
}
|
||||
mobileShellLog.error("workspace mutation failed action=\(actionName, privacy: .public) id=\(logID, privacy: .public) error=\(String(describing: error), privacy: .public)")
|
||||
mobileShellLog.error("workspace mutation failed action=\(actionName, privacy: .public) id=\(logID, privacy: .public) kind=\(String(describing: failureKind), privacy: .public) error=\(String(describing: error), privacy: .private)")
|
||||
if refreshAfterMutation {
|
||||
await refreshAfterWorkspaceMutation(target)
|
||||
}
|
||||
@@ -483,6 +515,7 @@ extension MobileShellComposite {
|
||||
if refreshAfterMutation {
|
||||
await refreshAfterWorkspaceMutation(target)
|
||||
}
|
||||
MobileDebugLog.anchormux("workspace.mutation accepted action=\(actionName) id=\(logID)")
|
||||
return .success(())
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -99,7 +99,11 @@ extension MobileShellComposite {
|
||||
return .failure(.notConnected(hostDisplayName: context.hostDisplayName))
|
||||
}
|
||||
let resultData = try await client.sendRequest(
|
||||
MobileCoreRPCClient.requestData(method: "workspace.create", params: params)
|
||||
MobileCoreRPCClient.requestData(method: "workspace.create", params: params),
|
||||
attachTicketPolicy: groupID != nil
|
||||
&& context.supportedHostCapabilities.contains(Self.workspaceMutationAccountAuthCapability)
|
||||
? .omit
|
||||
: .whenCovered
|
||||
)
|
||||
let response = try MobileSyncWorkspaceListResponse.decode(resultData)
|
||||
let createdWorkspace: MobileWorkspacePreview.ID?
|
||||
|
||||
+17
-1
@@ -3,10 +3,25 @@ internal import CmuxMobileRPC
|
||||
internal import Foundation
|
||||
|
||||
extension MobileShellComposite {
|
||||
/// Whether the active ticket was issued with Mac-wide mutation scope.
|
||||
///
|
||||
/// Menu discovery depends on stable scope, not the ticket's short-lived
|
||||
/// expiry. New hosts authorize by signed-in account after expiry; legacy
|
||||
/// hosts keep the existing visible action and surface a failure on use.
|
||||
var hasMacScopedWorkspaceMutationTicketScope: Bool {
|
||||
let ticket = activeTicket ?? remoteClient?.attachTicket
|
||||
return MobileShellWorkspaceMutationTicketPolicy(now: runtime?.now() ?? Date())
|
||||
.hasMacScopedWorkspaceMutationScope(ticket)
|
||||
}
|
||||
|
||||
var allowsMacScopedWorkspaceMutations: Bool {
|
||||
allowsMacScopedWorkspaceMutations(targetClient: nil)
|
||||
}
|
||||
|
||||
var discoversMacScopedWorkspaceMutations: Bool {
|
||||
hasMacScopedWorkspaceMutationTicketScope || allowsMacScopedWorkspaceMutations
|
||||
}
|
||||
|
||||
func allowsMacScopedWorkspaceMutations(targetClient: MobileCoreRPCClient?) -> Bool {
|
||||
let ticket = activeTicket ?? targetClient?.attachTicket
|
||||
return MobileShellWorkspaceMutationTicketPolicy(now: runtime?.now() ?? Date())
|
||||
@@ -17,7 +32,8 @@ extension MobileShellComposite {
|
||||
}
|
||||
|
||||
/// Whether the foreground Mac authorizes Mac-scoped workspace mutations by
|
||||
/// the signed-in account (attach tickets only narrow while current).
|
||||
/// the signed-in account. Requests using this capability omit attach-ticket
|
||||
/// context so a saved workspace route cannot narrow the account authority.
|
||||
var hostAuthorizesAccountScopedMutations: Bool {
|
||||
supportedHostCapabilities.contains(Self.workspaceMutationAccountAuthCapability)
|
||||
}
|
||||
|
||||
@@ -121,6 +121,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
static let workspaceGroupActionsCapability = "workspace.group_actions.v1"
|
||||
static let workspaceCreateInGroupCapability = "workspace.create_in_group.v1", workspaceGroupCreateCapability = "workspace.group_create.v1"
|
||||
static let taskCreateCapability = "workspace.task_create.v1"
|
||||
static let taskAttachmentCapability = "task.attachments.v1"
|
||||
static let taskModelsCapability = "task.models.v1"
|
||||
static let chatArtifactCapability = "chat.artifact.v1"
|
||||
static let chatArtifactGalleryCapability = "chat.artifact.gallery.v1"
|
||||
static let terminalArtifactCapability = "terminal.artifact.v1"
|
||||
@@ -429,6 +431,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
@ObservationIgnored var groupCollapseStore: MobileWorkspaceGroupCollapseStore
|
||||
/// Device-local task templates used by the iOS task composer.
|
||||
@ObservationIgnored public let taskTemplateStore: (any MobileTaskTemplateStoring)?
|
||||
/// Mac/provider model responses observed by the task composer.
|
||||
var taskModelCache: [MobileTaskModelCacheKey: MobileTaskModelCacheEntry] = [:]
|
||||
/// The connected Mac's `mobile.host.status` capabilities. Feature gates are
|
||||
/// computed from this set so version-skew checks cannot drift from the raw
|
||||
/// host payload.
|
||||
@@ -526,14 +530,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
private var composerDismissedTerminalIDs: Set<String> = []
|
||||
/// Monotonic focus-request token for the iMessage-style composer field.
|
||||
///
|
||||
/// The composer's text field owns its first responder via SwiftUI `@FocusState`,
|
||||
/// which neither the terminal surface nor the representable coordinator can set
|
||||
/// directly. When the surface needs the field re-focused without re-presenting the
|
||||
/// composer — the reveal-after-hide case, where the chrome and draft are already
|
||||
/// back but the terminal proxy stole first responder — it bumps this token through
|
||||
/// ``presentAndFocusComposer()``. ``TerminalComposerView`` observes the change and
|
||||
/// drives `isFieldFocused = true`, keeping `@FocusState` the single source of truth
|
||||
/// for who holds the keyboard.
|
||||
/// The surface input session owns first-responder commands; SwiftUI `@FocusState`
|
||||
/// mirrors their result. When the surface needs the field re-focused without
|
||||
/// re-presenting the composer, it bumps this token through
|
||||
/// ``presentAndFocusComposer()``. ``TerminalComposerView`` consumes the keyed
|
||||
/// intent and forwards it to that session owner.
|
||||
public private(set) var composerFocusRequest: Int = 0
|
||||
/// True while a ``composerFocusRequest`` has been issued but not yet consumed
|
||||
/// by the composer field. The field's `onChange` of the token only observes
|
||||
@@ -853,9 +854,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
/// consumed, not buffered invisibly behind the await.
|
||||
private var terminalSubscriptionStartTask: Task<Void, Never>?
|
||||
/// Subscription success is the final validation edge for a replacement
|
||||
/// connection. This generation record closes the race where the ack arrives
|
||||
/// before the stored-Mac redial task returns from `connect`.
|
||||
var lastSuccessfulTerminalSubscriptionGeneration: UUID?
|
||||
/// connection or listener. This snapshot closes the race where an old
|
||||
/// acknowledgement arrives after a newer listener has taken ownership.
|
||||
var lastSuccessfulTerminalSubscription: MobileTerminalSubscriptionValidation?
|
||||
/// The focused client whose terminal subscribe/reassert operations are
|
||||
/// fenced while its final unsubscribe is prepared. Existing wire requests
|
||||
/// drain before unsubscribe; new ones cannot start.
|
||||
@@ -1617,6 +1618,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
enqueueDraftOperation { await draftStore.clearAllDrafts() }
|
||||
}
|
||||
taskTemplateStore?.clearAllUserData()
|
||||
taskModelCache.removeAll()
|
||||
// Drop unflushed keystroke snapshots too: an armed flush that runs
|
||||
// before the wipe would only write text the wipe then deletes, but the
|
||||
// buffer itself must not carry one account's text into the next.
|
||||
@@ -2449,36 +2451,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
setHasKnownPairedMac(true, generation: generation)
|
||||
}
|
||||
let irohReconnectIsBlocked = automaticIrohReconnectIsBlocked(accountID: scope.userID)
|
||||
let zeroTouchCandidates: [MobilePairedMac] = if irohReconnectIsBlocked {
|
||||
[]
|
||||
} else {
|
||||
await discoverZeroTouchIrohCandidates(
|
||||
scope: scope,
|
||||
generation: generation,
|
||||
excluding: Set(candidates.map {
|
||||
MobilePairedMac.pairingID(
|
||||
macDeviceID: $0.macDeviceID,
|
||||
instanceTag: $0.instanceTag
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
guard generation == storedMacReconnectGeneration else {
|
||||
return .superseded
|
||||
}
|
||||
guard await isScopeCurrent(scope) else {
|
||||
finishStoredMacReconnectAttempt(generation: generation)
|
||||
return .superseded
|
||||
}
|
||||
candidates.append(contentsOf: zeroTouchCandidates)
|
||||
let zeroTouchCandidateIDs = Set(zeroTouchCandidates.map(\.id))
|
||||
guard !candidates.isEmpty else {
|
||||
if !hasKnownStoredMac, !irohReconnectIsBlocked {
|
||||
setHasKnownPairedMac(false, generation: generation)
|
||||
}
|
||||
finishStoredMacReconnectAttempt(generation: generation)
|
||||
return .failed(.noRoute)
|
||||
}
|
||||
// Capture one coherent post-request view of the registry and paired-Mac
|
||||
// store. The store read happens after the registry await, so an
|
||||
// authenticated Presence write that lands during the request wins. The
|
||||
@@ -2543,8 +2515,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
)
|
||||
}
|
||||
if connectionState != .connected,
|
||||
!automaticIrohReconnectIsBlocked(accountID: scope.userID),
|
||||
!zeroTouchCandidateIDs.contains(mac.id) {
|
||||
!automaticIrohReconnectIsBlocked(accountID: scope.userID) {
|
||||
switch await freshReconnectRoutesAfterLocalFailure(
|
||||
for: mac,
|
||||
scope: scope,
|
||||
@@ -2576,6 +2547,63 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
}
|
||||
if connectionState == .connected { break }
|
||||
}
|
||||
// A saved authenticated route is the cheapest and most authoritative
|
||||
// recovery path. Broker discovery can be slow for accounts with a large
|
||||
// development fleet, so only ask for zero-touch candidates after every
|
||||
// saved candidate failed. This keeps a healthy saved Mac from sitting
|
||||
// behind an unrelated account-wide discovery request.
|
||||
var zeroTouchCandidates: [MobilePairedMac] = []
|
||||
if connectionState != .connected,
|
||||
!automaticIrohReconnectIsBlocked(accountID: scope.userID) {
|
||||
zeroTouchCandidates = await discoverZeroTouchIrohCandidates(
|
||||
scope: scope,
|
||||
generation: generation,
|
||||
excluding: Set(candidates.map {
|
||||
MobilePairedMac.pairingID(
|
||||
macDeviceID: $0.macDeviceID,
|
||||
instanceTag: $0.instanceTag
|
||||
)
|
||||
})
|
||||
)
|
||||
guard generation == storedMacReconnectGeneration else {
|
||||
return .superseded
|
||||
}
|
||||
guard await isScopeCurrent(scope) else {
|
||||
finishStoredMacReconnectAttempt(generation: generation)
|
||||
return .superseded
|
||||
}
|
||||
for mac in zeroTouchCandidates {
|
||||
guard generation == storedMacReconnectGeneration,
|
||||
await isScopeCurrent(scope),
|
||||
await !isHiddenMacDeviceID(
|
||||
mac.macDeviceID,
|
||||
instanceTag: mac.instanceTag,
|
||||
scope: scope
|
||||
) else { break }
|
||||
let routes = storedReconnectRoutes(mac)
|
||||
attemptedAutomaticIroh = attemptedAutomaticIroh
|
||||
|| routes.contains { $0.kind == .iroh }
|
||||
lastDialOutcome = await connectStoredMacOutcome(
|
||||
name: mac.displayName ?? mac.macDeviceID,
|
||||
routes: routes,
|
||||
pairedMacDeviceID: mac.macDeviceID,
|
||||
instanceTag: mac.instanceTag,
|
||||
legacyTailscaleRoutes: mac.legacyTailscaleRoutes ?? [],
|
||||
automaticReconnectAccountID: scope.userID,
|
||||
ifStillCurrent: { [weak self] in
|
||||
self?.storedMacReconnectGeneration == generation
|
||||
}
|
||||
)
|
||||
if connectionState == .connected { break }
|
||||
}
|
||||
}
|
||||
if candidates.isEmpty, zeroTouchCandidates.isEmpty {
|
||||
if !hasKnownStoredMac, !irohReconnectIsBlocked {
|
||||
setHasKnownPairedMac(false, generation: generation)
|
||||
}
|
||||
finishStoredMacReconnectAttempt(generation: generation)
|
||||
return .failed(.noRoute)
|
||||
}
|
||||
// A newer attempt may have started during the connect; it now owns the flags.
|
||||
guard generation == storedMacReconnectGeneration else { return .superseded }
|
||||
guard await isScopeCurrent(scope) else {
|
||||
@@ -4994,9 +5022,29 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
supportedKinds: supportedRouteKinds,
|
||||
preferNonLoopback: Self.prefersNonLoopbackRoutes
|
||||
)
|
||||
let foregroundIDSet: Set<String>
|
||||
// During a bounded foreground redial, `clearRemoteConnectionContext()`
|
||||
// has already nil'd `foregroundMacDeviceID`, which would make the very
|
||||
// Mac being redialed eligible as a "secondary". That opens a duplicate
|
||||
// background-control session the redial must then drain — one wasted
|
||||
// QUIC dial plus up to the handoff-drain timeout of added reconnect
|
||||
// latency. Exclude the in-flight recovery target exactly like a live
|
||||
// foreground; once recovery settles the normal exclusion (success) or
|
||||
// eligibility (terminal failure) resumes.
|
||||
let exclusionMacDeviceID: String?
|
||||
let exclusionTag: String?
|
||||
if let foregroundMacDeviceID {
|
||||
let canonicalID = cmxCanonicalDeviceID(foregroundMacDeviceID)
|
||||
exclusionMacDeviceID = foregroundMacDeviceID
|
||||
exclusionTag = activeMacInstanceTag
|
||||
} else if isReconnectingStoredMac || connectionRecoveryOwner.isActive {
|
||||
exclusionMacDeviceID = recoveryTargetMacDeviceID
|
||||
exclusionTag = recoveryTargetInstanceTag
|
||||
} else {
|
||||
exclusionMacDeviceID = nil
|
||||
exclusionTag = nil
|
||||
}
|
||||
let foregroundIDSet: Set<String>
|
||||
if let exclusionMacDeviceID {
|
||||
let canonicalID = cmxCanonicalDeviceID(exclusionMacDeviceID)
|
||||
foregroundIDSet = physicalAliasIDsByCanonicalID[canonicalID]
|
||||
?? Set([canonicalID])
|
||||
} else {
|
||||
@@ -5006,9 +5054,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
if case let .peer(identity, _)? = activeRoute?.endpoint {
|
||||
foregroundIrohEndpointIDs.insert(identity.endpointID)
|
||||
}
|
||||
let activeTag = activeMacInstanceTag
|
||||
if let foregroundMacDeviceID {
|
||||
let canonicalForegroundID = cmxCanonicalDeviceID(foregroundMacDeviceID)
|
||||
let activeTag = exclusionTag
|
||||
if let exclusionMacDeviceID {
|
||||
let canonicalForegroundID = cmxCanonicalDeviceID(exclusionMacDeviceID)
|
||||
// With no authenticated tag the foreground could be any of the
|
||||
// device's rows, so every row's endpoint is treated as the
|
||||
// foreground's own; with a tag, only the exact pairing's endpoints
|
||||
@@ -5160,7 +5208,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
)
|
||||
}
|
||||
secondaryMacEstablishmentFlights[flightKey] =
|
||||
SecondaryMacEstablishmentFlight(id: flightID, task: task)
|
||||
SecondaryMacEstablishmentFlight(
|
||||
id: flightID,
|
||||
mac: mac,
|
||||
task: task
|
||||
)
|
||||
let outcome = await task.value
|
||||
if secondaryMacEstablishmentFlights[flightKey]?.id == flightID {
|
||||
secondaryMacEstablishmentFlights[flightKey] = nil
|
||||
@@ -9278,12 +9330,55 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
macConnectionStatus = .unavailable
|
||||
return
|
||||
}
|
||||
let subscriptionIsValidated =
|
||||
terminalEventListenerID.map { listenerID in
|
||||
lastSuccessfulTerminalSubscription
|
||||
== MobileTerminalSubscriptionValidation(
|
||||
connectionGeneration: connectionGeneration,
|
||||
listenerID: listenerID
|
||||
)
|
||||
} ?? false
|
||||
let requiresSubscriptionValidation =
|
||||
runtime?.supportsServerPushEvents == true
|
||||
|| terminalEventListenerID != nil
|
||||
guard !requiresSubscriptionValidation
|
||||
|| subscriptionIsValidated else {
|
||||
macConnectionStatus = .reconnecting
|
||||
connectionRecoveryFailed = false
|
||||
return
|
||||
}
|
||||
let foregroundKey = foregroundMacKey
|
||||
if var foregroundState = workspacesByMac[foregroundKey],
|
||||
foregroundState.status != .connected {
|
||||
foregroundState.status = .connected
|
||||
workspacesByMac[foregroundKey] = foregroundState
|
||||
}
|
||||
macConnectionStatus = .connected
|
||||
isRecoveringConnection = false
|
||||
connectionRecoveryFailed = false
|
||||
connectionRequiresReauth = false
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func recordUsableTerminalSubscription(
|
||||
client: MobileCoreRPCClient,
|
||||
connectionGeneration: UUID,
|
||||
listenerID: UUID
|
||||
) -> Bool {
|
||||
guard isCurrentRemoteOperation(
|
||||
client: client,
|
||||
generation: connectionGeneration
|
||||
) else {
|
||||
return false
|
||||
}
|
||||
recordSuccessfulTerminalSubscription(
|
||||
connectionGeneration: connectionGeneration,
|
||||
listenerID: listenerID
|
||||
)
|
||||
markMacConnectionHealthy()
|
||||
return true
|
||||
}
|
||||
|
||||
func markMacConnectionReconnecting() {
|
||||
guard connectionState == .connected, remoteClient != nil else {
|
||||
macConnectionStatus = .unavailable
|
||||
@@ -10010,6 +10105,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
}
|
||||
}
|
||||
|
||||
private enum TerminalEventSubscriptionProbeResult {
|
||||
case active
|
||||
case missing
|
||||
case unsupported
|
||||
case failed
|
||||
}
|
||||
|
||||
private func requestTerminalEventSubscription(
|
||||
client: MobileCoreRPCClient,
|
||||
reason: String,
|
||||
@@ -10019,6 +10121,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
let requestData: Data
|
||||
do {
|
||||
var params: [String: Any] = [
|
||||
"client_id": clientID,
|
||||
"stream_id": terminalEventStreamID,
|
||||
"topics": topics,
|
||||
]
|
||||
@@ -10233,7 +10336,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
terminalSubscriptionHandoffFenceClientID = nil
|
||||
}
|
||||
let listenerID = UUID()
|
||||
let listenerConnectionGeneration = connectionGeneration
|
||||
terminalEventListenerID = listenerID
|
||||
markMacConnectionHealthy()
|
||||
// Arm the liveness watchdog for this subscription generation. Done only
|
||||
// inside the push-events path (after the guard above) so scripted
|
||||
// transport tests, which set `supportsServerPushEvents = false`, never
|
||||
@@ -10286,6 +10391,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
self?.beginTerminalEventSubscriptionStart(
|
||||
client: client,
|
||||
listenerID: listenerID,
|
||||
connectionGeneration: listenerConnectionGeneration,
|
||||
topics: topics,
|
||||
transport: outputTransport,
|
||||
subscriptionReadiness: subscriptionReadiness,
|
||||
@@ -10296,7 +10402,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
for await event in stream {
|
||||
guard !Task.isCancelled else { return }
|
||||
guard let self else { return }
|
||||
guard self.remoteClient === client, self.connectionState == .connected else { return }
|
||||
guard self.isCurrentRemoteOperation(
|
||||
client: client,
|
||||
generation: listenerConnectionGeneration
|
||||
) else {
|
||||
return
|
||||
}
|
||||
// Any yielded envelope proves the transport is still pushing, so
|
||||
// it resets the liveness window (not just render_grid events).
|
||||
self.cancelTerminalInputAckResubscribeRetry()
|
||||
@@ -10367,6 +10478,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
private func beginTerminalEventSubscriptionStart(
|
||||
client: MobileCoreRPCClient,
|
||||
listenerID: UUID,
|
||||
connectionGeneration: UUID,
|
||||
topics: [String],
|
||||
transport: TerminalOutputTransport,
|
||||
subscriptionReadiness: MobileTerminalEventSubscriptionReadiness? = nil,
|
||||
@@ -10401,7 +10513,14 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
topics: topics
|
||||
) ?? .failed
|
||||
guard let self else { return }
|
||||
guard !Task.isCancelled, self.terminalEventListenerID == listenerID else { return }
|
||||
guard !Task.isCancelled,
|
||||
self.terminalEventListenerID == listenerID,
|
||||
self.isCurrentRemoteOperation(
|
||||
client: client,
|
||||
generation: connectionGeneration
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.terminalSubscriptionStartTask = nil
|
||||
guard ack.isSubscribed else {
|
||||
MobileDebugLog.anchormux("sync.subscribe_failed reason=start")
|
||||
@@ -10418,8 +10537,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
// cross-actor readiness hop can admit a cancellation or newer
|
||||
// listener, and a stale acknowledgement must never mutate that
|
||||
// replacement connection after it resumes.
|
||||
self.recordSuccessfulTerminalSubscription()
|
||||
self.markMacConnectionHealthy()
|
||||
guard self.recordUsableTerminalSubscription(
|
||||
client: client,
|
||||
connectionGeneration: connectionGeneration,
|
||||
listenerID: listenerID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
didSubscribe = true
|
||||
MobileDebugLog.anchormux("sync.subscribe_ok topics=\(topics.count) transport=\(transport)")
|
||||
// Negotiate state sync v2 only from the subscription
|
||||
@@ -10579,15 +10703,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
/// every healthy idle subscription every ~10.5s, forever (the 2026-06-10
|
||||
/// Release-sim bisect finding).
|
||||
///
|
||||
/// The probe is an idempotent `mobile.events.subscribe` for the SAME
|
||||
/// stream id and current topics, not a generic ping: a completed
|
||||
/// round-trip proves the transport the events ride on is alive AND that
|
||||
/// the server-side registration is (re)installed, and the host's
|
||||
/// subscription tracker re-evaluates producer demand on every replace. A
|
||||
/// generic `mobile.host.status` answer could mask a dropped registration
|
||||
/// behind a live RPC channel forever. Unlike the resync recovery, the
|
||||
/// probe restarts nothing: no listener teardown, no replay, no stream
|
||||
/// interruption.
|
||||
/// The read-only `mobile.events.probe` checks the SAME stream id: a
|
||||
/// completed response proves the control channel is alive and reports
|
||||
/// whether the host still owns the registration without replacing it or
|
||||
/// churning producer demand. A missing registration is repaired with one
|
||||
/// subscribe and replay; an older host that lacks the probe verb falls
|
||||
/// back to the former idempotent subscribe behavior.
|
||||
private func checkRenderGridLiveness(listenerID: UUID) {
|
||||
guard renderGridLivenessListenerID == listenerID else { return }
|
||||
guard let client = remoteClient, connectionState == .connected else { return }
|
||||
@@ -10681,10 +10802,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded positive-liveness probe: re-assert the event subscription and
|
||||
/// only count a completed round-trip as alive. Any failure (timeout,
|
||||
/// closed connection, rpc rejection) reports dead and lets the watchdog
|
||||
/// run its recovery.
|
||||
/// Bounded positive-liveness probe: inspect the existing event
|
||||
/// registration without mutating it, repairing it only when missing.
|
||||
///
|
||||
/// The deadline bounds the WHOLE attempt, including any Stack token work
|
||||
/// that precedes the wire write inside `sendRequest`; an unbounded hang
|
||||
@@ -10696,12 +10815,23 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
timeoutNanoseconds: UInt64
|
||||
) async -> TerminalEventSubscriptionAck {
|
||||
let probe = Task { @MainActor [weak self] in
|
||||
await self?.requestTerminalEventSubscription(
|
||||
guard let self else { return TerminalEventSubscriptionAck.failed }
|
||||
switch await self.requestTerminalEventSubscriptionProbe(
|
||||
client: client,
|
||||
reason: "liveness_probe",
|
||||
topics: topics,
|
||||
timeoutNanoseconds: timeoutNanoseconds
|
||||
) ?? .failed
|
||||
) {
|
||||
case .active:
|
||||
return .subscribed(alreadySubscribed: true)
|
||||
case .missing, .unsupported:
|
||||
return await self.requestTerminalEventSubscription(
|
||||
client: client,
|
||||
reason: "liveness_probe_repair",
|
||||
topics: topics,
|
||||
timeoutNanoseconds: timeoutNanoseconds
|
||||
)
|
||||
case .failed:
|
||||
return .failed
|
||||
}
|
||||
}
|
||||
// Bounded deadline via a one-shot DispatchSourceTimer — the same
|
||||
// sanctioned primitive the watchdog tick uses — with cancellation
|
||||
@@ -10717,6 +10847,45 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
return ack
|
||||
}
|
||||
|
||||
private func requestTerminalEventSubscriptionProbe(
|
||||
client: MobileCoreRPCClient,
|
||||
timeoutNanoseconds: UInt64
|
||||
) async -> TerminalEventSubscriptionProbeResult {
|
||||
let requestData: Data
|
||||
do {
|
||||
requestData = try MobileCoreRPCClient.requestData(
|
||||
method: "mobile.events.probe",
|
||||
params: [
|
||||
"client_id": clientID,
|
||||
"stream_id": terminalEventStreamID,
|
||||
]
|
||||
)
|
||||
} catch {
|
||||
return .failed
|
||||
}
|
||||
do {
|
||||
let data = try await client.sendRequest(
|
||||
requestData,
|
||||
timeoutNanoseconds: timeoutNanoseconds
|
||||
)
|
||||
guard let object = try JSONSerialization.jsonObject(with: data)
|
||||
as? [String: Any],
|
||||
object["stream_id"] as? String == terminalEventStreamID,
|
||||
let subscribed = object["subscribed"] as? Bool else {
|
||||
return .failed
|
||||
}
|
||||
return subscribed ? .active : .missing
|
||||
} catch MobileShellConnectionError.rpcError(let code, _)
|
||||
where code == "method_not_found" {
|
||||
return .unsupported
|
||||
} catch {
|
||||
if remoteClient === client {
|
||||
_ = disconnectForAuthorizationFailureIfNeeded(error)
|
||||
}
|
||||
return .failed
|
||||
}
|
||||
}
|
||||
|
||||
func resyncTerminalOutput(
|
||||
reason: String,
|
||||
restartEventStream: Bool,
|
||||
@@ -11816,12 +11985,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
// to leave the existing sections intact. Authoritative groups are passed
|
||||
// through the device-local collapse store before entering the per-Mac
|
||||
// source of truth, so derived groups keep this phone's collapse choices.
|
||||
let groups: [MobileWorkspaceGroupPreview]? =
|
||||
(mergeExistingWorkspaces || !groupsAreAuthoritative)
|
||||
? nil
|
||||
: groupCollapseStore.apply(
|
||||
to: response.groups.map { MobileWorkspaceGroupPreview(remote: $0) }
|
||||
)
|
||||
// Empty or missing group metadata during reconnect/rebind is not enough to
|
||||
// remove sections; only a healthy, complete ungrouped snapshot can do that.
|
||||
let groups = remoteWorkspaceGroups(
|
||||
from: response,
|
||||
mergeExistingWorkspaces: mergeExistingWorkspaces,
|
||||
groupsAreAuthoritative: groupsAreAuthoritative
|
||||
)
|
||||
setForegroundWorkspaceState(
|
||||
workspaces: remoteWorkspaces, groups: groups, merge: mergeExistingWorkspaces)
|
||||
#if DEBUG
|
||||
@@ -11865,6 +12035,34 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
|
||||
syncSelectedTerminalForWorkspace()
|
||||
}
|
||||
|
||||
private func remoteWorkspaceGroups(
|
||||
from response: MobileSyncWorkspaceListResponse,
|
||||
mergeExistingWorkspaces: Bool,
|
||||
groupsAreAuthoritative: Bool
|
||||
) -> [MobileWorkspaceGroupPreview]? {
|
||||
guard !mergeExistingWorkspaces, groupsAreAuthoritative else { return nil }
|
||||
guard response.groupsFieldWasPresent else { return nil }
|
||||
let groups = response.groups.map { MobileWorkspaceGroupPreview(remote: $0) }
|
||||
guard groups.isEmpty else {
|
||||
return groupCollapseStore.apply(to: groups)
|
||||
}
|
||||
guard canAcceptEmptyGroupSnapshot(from: response) else { return nil }
|
||||
return []
|
||||
}
|
||||
|
||||
private func canAcceptEmptyGroupSnapshot(
|
||||
from response: MobileSyncWorkspaceListResponse
|
||||
) -> Bool {
|
||||
guard connectionState == .connected, macConnectionStatus == .connected else {
|
||||
return false
|
||||
}
|
||||
let responseStillReferencesGroups = response.workspaces.contains { workspace in
|
||||
workspace.groupID?.isEmpty == false
|
||||
}
|
||||
guard !responseStillReferencesGroups else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private func remoteWorkspacesPreservingSnapshots(
|
||||
from response: MobileSyncWorkspaceListResponse
|
||||
) -> [MobileWorkspacePreview] {
|
||||
|
||||
+19
-3
@@ -5,17 +5,29 @@ internal import Foundation
|
||||
struct MobileShellWorkspaceMutationTicketPolicy {
|
||||
let now: Date
|
||||
|
||||
func hasMacScopedWorkspaceMutationScope(_ ticket: CmxAttachTicket?) -> Bool {
|
||||
guard let ticket,
|
||||
ticket.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
|
||||
return false
|
||||
}
|
||||
return ticket.workspaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
/// - Parameters:
|
||||
/// - ticket: The connection's attach ticket, if any.
|
||||
/// - hostAuthorizesByAccount: Whether the host advertises
|
||||
/// `workspace.mutations.account_auth.v1`: the signed-in Stack account
|
||||
/// authorizes Mac-scoped mutations and an attach ticket only narrows
|
||||
/// scope while current. Legacy hosts reject these verbs without a
|
||||
/// current mac-scoped ticket, so the client fails closed for them.
|
||||
/// authorizes Mac-scoped mutations. Callers omit attach-ticket context
|
||||
/// for these requests so an older saved workspace route cannot narrow
|
||||
/// them. Legacy hosts reject these verbs without a current mac-scoped
|
||||
/// ticket, so the client fails closed for them.
|
||||
func allowsMacScopedWorkspaceMutations(
|
||||
_ ticket: CmxAttachTicket?,
|
||||
hostAuthorizesByAccount: Bool
|
||||
) -> Bool {
|
||||
if hostAuthorizesByAccount {
|
||||
return true
|
||||
}
|
||||
guard let ticket,
|
||||
ticket.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false,
|
||||
!ticket.isExpired(at: now) else {
|
||||
@@ -26,4 +38,8 @@ struct MobileShellWorkspaceMutationTicketPolicy {
|
||||
}
|
||||
return ticket.workspaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
func allowsMacScopedWorkspaceMutations(_ ticket: CmxAttachTicket?) -> Bool {
|
||||
allowsMacScopedWorkspaceMutations(ticket, hostAuthorizesByAccount: false)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user