Compare commits
55
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 | ||
|
|
053ba0291c | ||
|
|
ddd4a01bc5 | ||
|
|
67d4fc12e1 | ||
|
|
b0b96e7b34 | ||
|
|
42d4f04126 | ||
|
|
63c8c28288 | ||
|
|
1d48844494 | ||
|
|
06bc29603c | ||
|
|
2d709e87b7 | ||
|
|
5e35ff0c4a | ||
|
|
84f5755b56 | ||
|
|
7dff5ec471 | ||
|
|
4de871173e | ||
|
|
b4c2163a37 | ||
|
|
bd89d1c16c | ||
|
|
786a077bc3 | ||
|
|
33ac210ab4 | ||
|
|
ff3b4aa3cd | ||
|
|
e7ca40e6e1 | ||
|
|
9fc3212e72 | ||
|
|
afe629534f |
@@ -10,10 +10,10 @@ 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-v1
|
||||
GHOSTTYKIT_BUILD_FLAVOR: crashsubdir-cmux-crash-sentry-off-v1
|
||||
steps:
|
||||
- name: Clear stale git locks (self-hosted reused workspace)
|
||||
shell: bash
|
||||
@@ -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,16 +84,25 @@ 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: |
|
||||
set -euo pipefail
|
||||
cd ghostty && zig build -Dcrash-report-subdir="$GHOSTTYKIT_CRASH_REPORT_SUBDIR" -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=universal -Doptimize=ReleaseFast
|
||||
cd ghostty && zig build -Dcrash-report-subdir="$GHOSTTYKIT_CRASH_REPORT_SUBDIR" -Dsentry=false -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=universal -Doptimize=ReleaseFast
|
||||
|
||||
- name: Package xcframework
|
||||
if: steps.check-release.outputs.exists == 'false'
|
||||
@@ -121,6 +128,6 @@ jobs:
|
||||
--repo manaflow-ai/ghostty \
|
||||
--target "${{ steps.ghostty-sha.outputs.sha }}" \
|
||||
--title "GhosttyKit xcframework (${{ steps.ghostty-sha.outputs.sha }}, ${GHOSTTYKIT_BUILD_FLAVOR})" \
|
||||
--notes "Pre-built GhosttyKit.xcframework for commit ${{ steps.ghostty-sha.outputs.sha }} with crash-report-subdir=${GHOSTTYKIT_CRASH_REPORT_SUBDIR}" \
|
||||
--notes "Pre-built GhosttyKit.xcframework for commit ${{ steps.ghostty-sha.outputs.sha }} with crash-report-subdir=${GHOSTTYKIT_CRASH_REPORT_SUBDIR} and sentry=false" \
|
||||
GhosttyKit.xcframework.tar.gz
|
||||
echo "Published release $TAG"
|
||||
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: GhosttyKit.xcframework
|
||||
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
|
||||
key: ghosttykit-sentry-off-v1-${{ steps.ghostty-revision.outputs.sha }}
|
||||
|
||||
- name: Download pre-built GhosttyKit.xcframework
|
||||
if: steps.cache-ghosttykit.outputs.cache-hit != 'true'
|
||||
|
||||
@@ -175,6 +175,9 @@ jobs:
|
||||
- name: Validate cmux scheme test configuration
|
||||
run: ./tests/test_ci_scheme_testaction_debug.sh
|
||||
|
||||
- name: Validate selected iOS test execution guard
|
||||
run: python3 tests/test_ios_selected_test_execution.py
|
||||
|
||||
- name: Validate cmuxTests sharding
|
||||
run: |
|
||||
python3 scripts/ci/cmux_unit_test_shard.py --validate
|
||||
@@ -255,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
|
||||
@@ -510,7 +515,7 @@ jobs:
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: GhosttyKit.xcframework
|
||||
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
|
||||
key: ghosttykit-sentry-off-v1-${{ steps.ghostty-revision.outputs.sha }}
|
||||
|
||||
- name: Download pre-built GhosttyKit.xcframework
|
||||
if: steps.cache-ghosttykit.outputs.cache-hit != 'true'
|
||||
@@ -1059,9 +1064,11 @@ 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
|
||||
python3 tests/test_issue_9356_bash_shim_noclobber.py
|
||||
python3 tests/test_issue_8953_zsh_prompt_wrap_guard.py
|
||||
python3 tests/test_shell_git_branch_stale_cwd.py
|
||||
python3 tests/test_shell_git_config_remote_url_parsing.py
|
||||
@@ -1246,7 +1253,7 @@ jobs:
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: GhosttyKit.xcframework
|
||||
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
|
||||
key: ghosttykit-sentry-off-v1-${{ steps.ghostty-revision.outputs.sha }}
|
||||
|
||||
- name: Validate cached GhosttyKit.xcframework
|
||||
id: validate-ghosttykit-package-tests
|
||||
@@ -1558,7 +1565,7 @@ jobs:
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: GhosttyKit.xcframework
|
||||
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
|
||||
key: ghosttykit-sentry-off-v1-${{ steps.ghostty-revision.outputs.sha }}
|
||||
|
||||
- name: Download pre-built GhosttyKit.xcframework
|
||||
if: steps.cache-ghosttykit-lag.outputs.cache-hit != 'true'
|
||||
@@ -1879,7 +1886,7 @@ jobs:
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: GhosttyKit.xcframework
|
||||
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
|
||||
key: ghosttykit-sentry-off-v1-${{ steps.ghostty-revision.outputs.sha }}
|
||||
|
||||
- name: Download pre-built GhosttyKit.xcframework
|
||||
if: steps.cache-ghosttykit-release.outputs.cache-hit != 'true'
|
||||
|
||||
@@ -6,13 +6,43 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- "cmux-tui/**"
|
||||
- ".github/workflows/cmux-tui-nightly.yml"
|
||||
- ".github/workflows/cmux-tui-release-cut.yml"
|
||||
- ".github/workflows/cmux-tui-release.yml"
|
||||
- ".github/workflows/cmux-tui-sdks.yml"
|
||||
- ".github/workflows/cmux-tui-spec.yml"
|
||||
- ".github/workflows/sdk-bootstrap-crates.yml"
|
||||
- ".github/workflows/sdk-bootstrap-npm.yml"
|
||||
- ".github/workflows/sdk-bootstrap-pypi.yml"
|
||||
- ".github/workflows/sdk-publish-crates.yml"
|
||||
- ".github/workflows/sdk-publish-go.yml"
|
||||
- ".github/workflows/sdk-publish-java.yml"
|
||||
- ".github/workflows/sdk-publish-npm.yml"
|
||||
- ".github/workflows/sdk-publish-python.yml"
|
||||
- ".github/workflows/sdk-release-cut.yml"
|
||||
- ".github/workflows/tui-publish-npm.yml"
|
||||
- ".github/workflows/tui-publish-pypi.yml"
|
||||
- "tests/test_tui_publish_workflow_security.py"
|
||||
pull_request:
|
||||
paths:
|
||||
- "cmux-tui/**"
|
||||
- ".github/workflows/cmux-tui-nightly.yml"
|
||||
- ".github/workflows/cmux-tui-release-cut.yml"
|
||||
- ".github/workflows/cmux-tui-release.yml"
|
||||
- ".github/workflows/cmux-tui-sdks.yml"
|
||||
- ".github/workflows/cmux-tui-spec.yml"
|
||||
- ".github/workflows/sdk-bootstrap-crates.yml"
|
||||
- ".github/workflows/sdk-bootstrap-npm.yml"
|
||||
- ".github/workflows/sdk-bootstrap-pypi.yml"
|
||||
- ".github/workflows/sdk-publish-crates.yml"
|
||||
- ".github/workflows/sdk-publish-go.yml"
|
||||
- ".github/workflows/sdk-publish-java.yml"
|
||||
- ".github/workflows/sdk-publish-npm.yml"
|
||||
- ".github/workflows/sdk-publish-python.yml"
|
||||
- ".github/workflows/sdk-release-cut.yml"
|
||||
- ".github/workflows/tui-publish-npm.yml"
|
||||
- ".github/workflows/tui-publish-pypi.yml"
|
||||
- "tests/test_tui_publish_workflow_security.py"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -37,6 +67,12 @@ jobs:
|
||||
with:
|
||||
python-version: "3.12.8"
|
||||
|
||||
- name: Install workflow guard dependencies
|
||||
run: |
|
||||
python3 -m pip install \
|
||||
--disable-pip-version-check \
|
||||
"PyYAML==6.0.3"
|
||||
|
||||
- name: Test protocol inventory
|
||||
run: python3 cmux-tui/scripts/test_check_spec_inventory.py
|
||||
|
||||
@@ -70,8 +106,11 @@ jobs:
|
||||
-p 'test_*.py' \
|
||||
-v
|
||||
|
||||
- name: Test SDK publishing workflow guards
|
||||
run: python3 tests/test_tui_publish_workflow_security.py -v
|
||||
|
||||
- name: Check package versions
|
||||
run: python3 cmux-tui/bindings/check-versions.py
|
||||
run: python3 cmux-tui/bindings/check-versions.py --published-only
|
||||
|
||||
- name: Test shared conformance runner
|
||||
run: |
|
||||
@@ -153,10 +192,10 @@ jobs:
|
||||
env:
|
||||
PYTHONPATH: cmux-tui/bindings/python
|
||||
run: |
|
||||
python3 -m unittest discover -s cmux-tui/bindings/python/tests -v
|
||||
python3 -m pip install \
|
||||
--disable-pip-version-check \
|
||||
"setuptools==80.9.0"
|
||||
python3 -m unittest discover -s cmux-tui/bindings/python/tests -v
|
||||
python3 -m pip install \
|
||||
--no-build-isolation \
|
||||
--no-deps \
|
||||
@@ -176,7 +215,7 @@ jobs:
|
||||
distribution = next(
|
||||
item
|
||||
for item in importlib.metadata.distributions(path=[str(package)])
|
||||
if item.metadata["Name"] == "cmux"
|
||||
if item.metadata["Name"] == "cmux-sdk"
|
||||
)
|
||||
assert not distribution.requires
|
||||
PY
|
||||
@@ -192,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
|
||||
@@ -0,0 +1,626 @@
|
||||
name: sdk bootstrap crates
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [sdk-bootstrap-crates]
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: sdk-bootstrap-crates
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
BOOTSTRAP_VERSION: "0.0.0-bootstrap.0"
|
||||
RUST_TOOLCHAIN: "1.95.0"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
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 the Rust SDK crates without confirm_bootstrap=true." >&2
|
||||
exit 1
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Require current protected main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$GITHUB_REF" == "refs/heads/main" ]] || {
|
||||
echo "Dispatch sdk-bootstrap-crates.yml from main, found $GITHUB_REF." >&2
|
||||
exit 1
|
||||
}
|
||||
git fetch --force origin main
|
||||
main_sha="$(git rev-parse origin/main)"
|
||||
[[ "$GITHUB_SHA" == "$main_sha" ]] || {
|
||||
echo "workflow commit $GITHUB_SHA is not current main $main_sha" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Install pinned Rust toolchain
|
||||
run: |
|
||||
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal
|
||||
rustup default "$RUST_TOOLCHAIN"
|
||||
cargo --version
|
||||
rustc --version
|
||||
|
||||
- name: Build and test the ownership bootstrap
|
||||
id: package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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
|
||||
|
||||
- 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-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
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
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/$PACKAGE-bootstrap-registry.json"
|
||||
status="$(
|
||||
curl \
|
||||
--silent \
|
||||
--show-error \
|
||||
--location \
|
||||
--retry 5 \
|
||||
--retry-delay 1 \
|
||||
--retry-all-errors \
|
||||
--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/$PACKAGE"
|
||||
)"
|
||||
case "$status" in
|
||||
404)
|
||||
echo "$PACKAGE is unclaimed; bootstrap may continue."
|
||||
project_status=missing
|
||||
;;
|
||||
200)
|
||||
echo "$PACKAGE exists; bootstrap bytes must match."
|
||||
project_status=exists
|
||||
;;
|
||||
*)
|
||||
echo "crates.io returned HTTP $status; refusing to infer availability." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "status=$project_status" >> "$GITHUB_OUTPUT"
|
||||
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
|
||||
artifacts=(bootstrap-crate/*.crate)
|
||||
[[ "${#artifacts[@]}" == 1 ]] || {
|
||||
echo "expected one tested crate, found ${#artifacts[@]}" >&2
|
||||
exit 1
|
||||
}
|
||||
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
|
||||
--registry crates \
|
||||
--package "$PACKAGE" \
|
||||
--version "$BOOTSTRAP_VERSION" \
|
||||
--artifact "${artifacts[0]}" \
|
||||
--require-match
|
||||
sleep 1
|
||||
python3 cmux-tui/bindings/verify_crates_ownership.py \
|
||||
--package "$PACKAGE" \
|
||||
--repository https://github.com/manaflow-ai/cmux \
|
||||
--owner-id 431397 \
|
||||
--owner-login lawrencecchen \
|
||||
--bootstrap-ownership-only
|
||||
|
||||
- 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"
|
||||
|
||||
- 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
|
||||
- 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-sdk
|
||||
steps:
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: cmux-sdk-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.sdk_sha256 }}
|
||||
PACKAGE: cmux-sdk
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
verify:
|
||||
needs:
|
||||
- build
|
||||
- preflight
|
||||
- decisions
|
||||
- publish-sdk
|
||||
- publish-sidebar
|
||||
if: >-
|
||||
always() &&
|
||||
needs.build.result == 'success' &&
|
||||
needs.preflight.result == 'success' &&
|
||||
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:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
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
|
||||
artifacts=(bootstrap-crate/*.crate)
|
||||
[[ "${#artifacts[@]}" == 1 ]] || {
|
||||
echo "expected one tested crate, found ${#artifacts[@]}" >&2
|
||||
exit 1
|
||||
}
|
||||
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
|
||||
--registry crates \
|
||||
--package "$PACKAGE" \
|
||||
--version "$BOOTSTRAP_VERSION" \
|
||||
--artifact "${artifacts[0]}" \
|
||||
--retry-missing-project \
|
||||
--wait-seconds 300 \
|
||||
--require-match
|
||||
sleep 1
|
||||
python3 cmux-tui/bindings/verify_crates_ownership.py \
|
||||
--package "$PACKAGE" \
|
||||
--repository https://github.com/manaflow-ai/cmux \
|
||||
--owner-id 431397 \
|
||||
--owner-login lawrencecchen \
|
||||
--bootstrap-ownership-only
|
||||
@@ -0,0 +1,340 @@
|
||||
name: sdk bootstrap npm
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [sdk-bootstrap-npm]
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: sdk-bootstrap-npm
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
BOOTSTRAP_VERSION: "0.0.0-bootstrap.0"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
artifact_sha256: ${{ steps.package.outputs.artifact_sha256 }}
|
||||
steps:
|
||||
- name: Require explicit bootstrap confirmation
|
||||
if: github.event.client_payload.confirm_bootstrap != true
|
||||
run: |
|
||||
echo "Refusing to reserve cmux-sdk without confirm_bootstrap=true." >&2
|
||||
exit 1
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Require current protected main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$GITHUB_REF" == "refs/heads/main" ]] || {
|
||||
echo "Dispatch sdk-bootstrap-npm.yml from main, found $GITHUB_REF." >&2
|
||||
exit 1
|
||||
}
|
||||
git fetch --force origin main
|
||||
main_sha="$(git rev-parse origin/main)"
|
||||
[[ "$GITHUB_SHA" == "$main_sha" ]] || {
|
||||
echo "workflow commit $GITHUB_SHA is not current main $main_sha" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22.14.0"
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global --ignore-scripts [email protected]
|
||||
|
||||
- name: Build, test, and pack the bootstrap prerelease
|
||||
id: package
|
||||
working-directory: cmux-tui/bindings/typescript
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci --no-audit --no-fund
|
||||
npm version "$BOOTSTRAP_VERSION" --no-git-tag-version
|
||||
npm test
|
||||
mkdir -p "$RUNNER_TEMP/cmux-npm-bootstrap"
|
||||
npm pack --pack-destination "$RUNNER_TEMP/cmux-npm-bootstrap"
|
||||
shopt -s nullglob
|
||||
packages=("$RUNNER_TEMP"/cmux-npm-bootstrap/*.tgz)
|
||||
[[ "${#packages[@]}" == 1 ]] || {
|
||||
echo "expected one bootstrap artifact, found ${#packages[@]}" >&2
|
||||
exit 1
|
||||
}
|
||||
CMUX_NPM_PACKAGE="${packages[0]}" \
|
||||
node scripts/verify-packaged-consumer.mjs
|
||||
artifact_sha256="$(sha256sum "${packages[0]}" | cut -d ' ' -f 1)"
|
||||
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]] || {
|
||||
echo "bootstrap package digest is malformed" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "artifact_sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cmux-npm-bootstrap-package
|
||||
path: ${{ runner.temp }}/cmux-npm-bootstrap/*.tgz
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
|
||||
preflight:
|
||||
needs: build
|
||||
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:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: cmux-npm-bootstrap-package
|
||||
path: bootstrap-package
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22.14.0"
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global --ignore-scripts [email protected]
|
||||
|
||||
- name: Inspect the npm bootstrap state
|
||||
id: project
|
||||
run: |
|
||||
set -euo pipefail
|
||||
metadata="$RUNNER_TEMP/cmux-sdk-bootstrap-registry.json"
|
||||
status="$(
|
||||
curl \
|
||||
--silent \
|
||||
--show-error \
|
||||
--location \
|
||||
--retry 5 \
|
||||
--retry-all-errors \
|
||||
--output "$metadata" \
|
||||
--write-out '%{http_code}' \
|
||||
https://registry.npmjs.org/cmux-sdk
|
||||
)"
|
||||
case "$status" in
|
||||
404)
|
||||
echo "cmux-sdk is unclaimed; bootstrap may continue."
|
||||
project_status=missing
|
||||
;;
|
||||
200)
|
||||
echo "cmux-sdk exists; bootstrap bytes and provenance must match."
|
||||
project_status=exists
|
||||
;;
|
||||
*)
|
||||
echo "npm registry returned HTTP $status; refusing to infer availability." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "status=$project_status" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Reconcile an existing npm ownership bootstrap
|
||||
if: steps.project.outputs.status == 'exists'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
packages=(bootstrap-package/*.tgz)
|
||||
[[ "${#packages[@]}" == 1 ]] || {
|
||||
echo "expected one tested npm artifact, found ${#packages[@]}" >&2
|
||||
exit 1
|
||||
}
|
||||
python3 cmux-tui/bindings/verify_npm_provenance.py \
|
||||
--package cmux-sdk \
|
||||
--version "$BOOTSTRAP_VERSION" \
|
||||
--repository-url git+https://github.com/manaflow-ai/cmux.git \
|
||||
--repository-directory cmux-tui/bindings/typescript \
|
||||
--owner lawrencechen \
|
||||
--workflow .github/workflows/sdk-bootstrap-npm.yml \
|
||||
--workflow-ref refs/heads/main \
|
||||
--dist-tag bootstrap \
|
||||
--publisher owner \
|
||||
--artifact "${packages[0]}"
|
||||
|
||||
- name: Request publication for an unclaimed project
|
||||
id: decision
|
||||
if: steps.project.outputs.status == 'missing'
|
||||
run: echo "need_publish=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
publish:
|
||||
needs:
|
||||
- build
|
||||
- preflight
|
||||
if: needs.preflight.outputs.need_publish == 'true'
|
||||
runs-on: ubuntu-latest # github-hosted-required: npm provenance publishing
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
id-token: write
|
||||
environment:
|
||||
name: npm-bootstrap
|
||||
url: https://www.npmjs.com/package/cmux-sdk
|
||||
steps:
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: cmux-npm-bootstrap-package
|
||||
path: bootstrap-package
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22.14.0"
|
||||
registry-url: https://registry.npmjs.org
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global --ignore-scripts [email protected]
|
||||
|
||||
- name: Verify protected source and the exact tested package
|
||||
env:
|
||||
EXPECTED_SHA256: ${{ needs.build.outputs.artifact_sha256 }}
|
||||
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 npm artifact digest is malformed" >&2
|
||||
exit 1
|
||||
}
|
||||
shopt -s nullglob
|
||||
packages=(bootstrap-package/*.tgz)
|
||||
[[ "${#packages[@]}" == 1 ]] || {
|
||||
echo "expected one tested npm artifact, found ${#packages[@]}" >&2
|
||||
exit 1
|
||||
}
|
||||
actual_sha256="$(sha256sum "${packages[0]}" | cut -d ' ' -f 1)"
|
||||
[[ "$actual_sha256" == "$EXPECTED_SHA256" ]] || {
|
||||
echo "downloaded npm bootstrap artifact digest mismatch" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Publish the exact tested prerelease artifact
|
||||
continue-on-error: true
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_BOOTSTRAP_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ -n "$NODE_AUTH_TOKEN" ]] || {
|
||||
echo "npm-bootstrap environment secret NPM_BOOTSTRAP_TOKEN is required." >&2
|
||||
exit 1
|
||||
}
|
||||
shopt -s nullglob
|
||||
packages=(bootstrap-package/*.tgz)
|
||||
[[ "${#packages[@]}" == 1 ]] || {
|
||||
echo "expected one tested npm artifact, found ${#packages[@]}" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "npm lifecycle scripts are disabled in the credentialed publisher"
|
||||
npm publish "${packages[0]}" \
|
||||
--ignore-scripts \
|
||||
--tag bootstrap \
|
||||
--provenance \
|
||||
--access public
|
||||
|
||||
verify:
|
||||
needs:
|
||||
- build
|
||||
- preflight
|
||||
- publish
|
||||
if: >-
|
||||
always() &&
|
||||
needs.build.result == 'success' &&
|
||||
needs.preflight.result == 'success' &&
|
||||
(needs.publish.result == 'success' || needs.publish.result == 'skipped')
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: cmux-npm-bootstrap-package
|
||||
path: bootstrap-package
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22.14.0"
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global --ignore-scripts [email protected]
|
||||
|
||||
- name: Verify the prerelease did not claim latest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tags="$RUNNER_TEMP/cmux-sdk-bootstrap-tags.json"
|
||||
deadline=$((SECONDS + 300))
|
||||
until npm view cmux-sdk dist-tags --json > "$tags"; do
|
||||
(( SECONDS < deadline )) || {
|
||||
echo "cmux-sdk bootstrap tags did not become visible within 300 seconds." >&2
|
||||
exit 1
|
||||
}
|
||||
sleep 15
|
||||
done
|
||||
node - "$tags" "$BOOTSTRAP_VERSION" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const [path, expected] = process.argv.slice(2);
|
||||
const tags = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
if (tags.bootstrap !== expected || Object.hasOwn(tags, "latest")) {
|
||||
throw new Error(`unexpected cmux-sdk dist-tags: ${JSON.stringify(tags)}`);
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Verify the npm ownership bootstrap
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
packages=(bootstrap-package/*.tgz)
|
||||
[[ "${#packages[@]}" == 1 ]] || {
|
||||
echo "expected one tested npm artifact, found ${#packages[@]}" >&2
|
||||
exit 1
|
||||
}
|
||||
python3 cmux-tui/bindings/verify_npm_provenance.py \
|
||||
--package cmux-sdk \
|
||||
--version "$BOOTSTRAP_VERSION" \
|
||||
--repository-url git+https://github.com/manaflow-ai/cmux.git \
|
||||
--repository-directory cmux-tui/bindings/typescript \
|
||||
--owner lawrencechen \
|
||||
--workflow .github/workflows/sdk-bootstrap-npm.yml \
|
||||
--workflow-ref refs/heads/main \
|
||||
--dist-tag bootstrap \
|
||||
--publisher owner \
|
||||
--artifact "${packages[0]}"
|
||||
@@ -0,0 +1,413 @@
|
||||
name: sdk bootstrap pypi
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [sdk-bootstrap-pypi]
|
||||
|
||||
permissions: {}
|
||||
|
||||
env:
|
||||
BOOTSTRAP_VERSION: "0.0.0a0"
|
||||
|
||||
concurrency:
|
||||
group: sdk-bootstrap-pypi
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
artifact_id: ${{ steps.upload.outputs.artifact-id }}
|
||||
artifact_sha256: ${{ steps.package.outputs.artifact_sha256 }}
|
||||
steps:
|
||||
- name: Require explicit bootstrap confirmation
|
||||
if: github.event.client_payload.confirm_bootstrap != true
|
||||
run: |
|
||||
echo "Refusing to reserve cmux-sdk without confirm_bootstrap=true." >&2
|
||||
exit 1
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Require current protected main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$GITHUB_REF" == "refs/heads/main" ]] || {
|
||||
echo "Dispatch sdk-bootstrap-pypi.yml from main." >&2
|
||||
exit 1
|
||||
}
|
||||
git fetch --force origin main
|
||||
main_sha="$(git rev-parse origin/main)"
|
||||
[[ "$GITHUB_SHA" == "$main_sha" ]] || {
|
||||
echo "workflow commit is not current main" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.12.8"
|
||||
|
||||
- name: Install pinned packaging tools
|
||||
run: |
|
||||
python3 -m pip install \
|
||||
--disable-pip-version-check \
|
||||
"build==1.3.0" \
|
||||
"setuptools==80.9.0" \
|
||||
"wheel==0.45.1"
|
||||
|
||||
- name: Prepare the prerelease source tree
|
||||
env:
|
||||
CMUX_BOOTSTRAP_VERSION: ${{ env.BOOTSTRAP_VERSION }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
|
||||
source = Path("cmux-tui/bindings/python")
|
||||
target = Path(os.environ["RUNNER_TEMP"]) / "cmux-python-bootstrap"
|
||||
shutil.copytree(source, target)
|
||||
manifest = target / "pyproject.toml"
|
||||
contents = manifest.read_text(encoding="utf-8")
|
||||
contents, count = re.subn(
|
||||
r'(?m)^version = "[^"]+"$',
|
||||
f'version = "{os.environ["CMUX_BOOTSTRAP_VERSION"]}"',
|
||||
contents,
|
||||
)
|
||||
if count != 1:
|
||||
raise SystemExit("expected one static project version")
|
||||
manifest.write_text(contents, encoding="utf-8")
|
||||
PY
|
||||
|
||||
- name: Test the prerelease source tree
|
||||
run: |
|
||||
cd "$RUNNER_TEMP/cmux-python-bootstrap"
|
||||
PYTHONPATH=. python3 -m unittest discover -s tests -v
|
||||
|
||||
- name: Build deterministic bootstrap distributions
|
||||
run: |
|
||||
export SOURCE_DATE_EPOCH
|
||||
SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
|
||||
python3 -m build --no-isolation --sdist --wheel \
|
||||
--outdir "$GITHUB_WORKSPACE/bootstrap-dist" \
|
||||
"$RUNNER_TEMP/cmux-python-bootstrap"
|
||||
python3 cmux-tui/bindings/normalize_python_sdist.py \
|
||||
--archive bootstrap-dist/*.tar.gz \
|
||||
--epoch "$SOURCE_DATE_EPOCH"
|
||||
|
||||
- name: Test the exact bootstrap distributions
|
||||
env:
|
||||
CMUX_PYTHON_DIST_DIR: ${{ github.workspace }}/bootstrap-dist
|
||||
run: |
|
||||
cd "$RUNNER_TEMP/cmux-python-bootstrap"
|
||||
PYTHONPATH=. python3 -m unittest tests.test_package_consumer -v
|
||||
|
||||
- name: Fingerprint the bootstrap distributions
|
||||
id: package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
wheels=(bootstrap-dist/*.whl)
|
||||
sdists=(bootstrap-dist/*.tar.gz)
|
||||
[[ "${#wheels[@]}" == 1 && "${#sdists[@]}" == 1 ]] || {
|
||||
echo "expected one bootstrap wheel and one source distribution" >&2
|
||||
exit 1
|
||||
}
|
||||
artifact_sha256="$(
|
||||
cd bootstrap-dist
|
||||
sha256sum *.whl *.tar.gz | sort -k2 | sha256sum | cut -d ' ' -f 1
|
||||
)"
|
||||
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]] || exit 1
|
||||
echo "artifact_sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- id: upload
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cmux-python-bootstrap-dist-${{ github.run_attempt }}
|
||||
path: bootstrap-dist/*
|
||||
if-no-files-found: error
|
||||
|
||||
preflight:
|
||||
needs: build
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
need_publish: ${{ steps.decision.outputs.need_publish }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
artifact-ids: ${{ needs.build.outputs.artifact_id }}
|
||||
path: bootstrap-dist
|
||||
|
||||
- name: Check whether the PyPI project exists
|
||||
id: project
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
request = Request(
|
||||
"https://pypi.org/pypi/cmux-sdk/json",
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=20) as response:
|
||||
metadata = json.loads(response.read())
|
||||
except HTTPError as error:
|
||||
if error.code != 404:
|
||||
raise SystemExit("PyPI project lookup failed") from error
|
||||
status = "missing"
|
||||
except (OSError, URLError, json.JSONDecodeError) as error:
|
||||
raise SystemExit("PyPI project lookup failed") from error
|
||||
else:
|
||||
if not isinstance(metadata, dict) or not isinstance(
|
||||
metadata.get("info"), dict
|
||||
):
|
||||
raise SystemExit("PyPI project metadata is malformed")
|
||||
status = "exists"
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
|
||||
output.write(f"status={status}\n")
|
||||
PY
|
||||
|
||||
- name: Check the existing bootstrap wheel
|
||||
if: steps.project.outputs.status == 'exists'
|
||||
id: wheel_state
|
||||
run: |
|
||||
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
|
||||
--registry pypi \
|
||||
--package cmux-sdk \
|
||||
--version "$BOOTSTRAP_VERSION" \
|
||||
--artifact bootstrap-dist/*.whl \
|
||||
--allowed-artifact bootstrap-dist/*.whl \
|
||||
--allowed-artifact bootstrap-dist/*.tar.gz \
|
||||
--write-github-output
|
||||
|
||||
- name: Check the existing bootstrap source distribution
|
||||
if: steps.project.outputs.status == 'exists'
|
||||
id: sdist_state
|
||||
run: |
|
||||
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
|
||||
--registry pypi \
|
||||
--package cmux-sdk \
|
||||
--version "$BOOTSTRAP_VERSION" \
|
||||
--artifact bootstrap-dist/*.tar.gz \
|
||||
--allowed-artifact bootstrap-dist/*.whl \
|
||||
--allowed-artifact bootstrap-dist/*.tar.gz \
|
||||
--write-github-output
|
||||
|
||||
- name: Decide whether publishing is required
|
||||
id: decision
|
||||
env:
|
||||
PROJECT_STATUS: ${{ steps.project.outputs.status }}
|
||||
WHEEL_STATUS: ${{ steps.wheel_state.outputs.status }}
|
||||
SDIST_STATUS: ${{ steps.sdist_state.outputs.status }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$PROJECT_STATUS" == "missing" ]]; then
|
||||
need_publish=true
|
||||
elif [[ "$WHEEL_STATUS" == "match" && "$SDIST_STATUS" == "match" ]]; then
|
||||
need_publish=false
|
||||
elif [[ "$WHEEL_STATUS" == "missing" && "$SDIST_STATUS" == "missing" ]]; then
|
||||
echo "cmux-sdk exists without the expected bootstrap release" >&2
|
||||
exit 1
|
||||
elif { [[ "$WHEEL_STATUS" == "match" && "$SDIST_STATUS" == "missing" ]] ||
|
||||
[[ "$WHEEL_STATUS" == "missing" && "$SDIST_STATUS" == "match" ]]; }; then
|
||||
need_publish=true
|
||||
else
|
||||
echo "unexpected bootstrap registry state" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "need_publish=$need_publish" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
if: steps.project.outputs.status == 'exists'
|
||||
with:
|
||||
python-version: "3.12.8"
|
||||
|
||||
- name: Install the pinned provenance verifier
|
||||
if: steps.project.outputs.status == 'exists'
|
||||
run: |
|
||||
python3 -m pip install \
|
||||
--disable-pip-version-check \
|
||||
"pypi-attestations==0.0.29"
|
||||
|
||||
- name: Verify existing bootstrap with pypi-attestations verify pypi
|
||||
if: steps.project.outputs.status == 'exists'
|
||||
env:
|
||||
WHEEL_STATUS: ${{ steps.wheel_state.outputs.status }}
|
||||
SDIST_STATUS: ${{ steps.sdist_state.outputs.status }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
filenames=()
|
||||
if [[ "$WHEEL_STATUS" == "match" ]]; then
|
||||
wheels=(bootstrap-dist/*.whl)
|
||||
filenames+=(--filename "$(basename "${wheels[0]}")")
|
||||
fi
|
||||
if [[ "$SDIST_STATUS" == "match" ]]; then
|
||||
sdists=(bootstrap-dist/*.tar.gz)
|
||||
filenames+=(--filename "$(basename "${sdists[0]}")")
|
||||
fi
|
||||
python3 cmux-tui/bindings/verify_pypi_provenance.py \
|
||||
--package cmux-sdk \
|
||||
--version "$BOOTSTRAP_VERSION" \
|
||||
--repository https://github.com/manaflow-ai/cmux \
|
||||
--owner lawrencecchen \
|
||||
--workflow sdk-bootstrap-pypi.yml \
|
||||
--environment pypi-bootstrap \
|
||||
"${filenames[@]}"
|
||||
|
||||
publish:
|
||||
needs:
|
||||
- build
|
||||
- preflight
|
||||
if: needs.preflight.outputs.need_publish == 'true'
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
permissions:
|
||||
id-token: write
|
||||
environment:
|
||||
name: pypi-bootstrap
|
||||
url: https://pypi.org/p/cmux-sdk
|
||||
outputs:
|
||||
outcome: ${{ steps.publish.outcome }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
artifact-ids: ${{ needs.build.outputs.artifact_id }}
|
||||
path: bootstrap-dist
|
||||
|
||||
- name: Verify the immutable bootstrap distributions
|
||||
env:
|
||||
EXPECTED_ARTIFACT_SHA256: ${{ needs.build.outputs.artifact_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$EXPECTED_ARTIFACT_SHA256" =~ ^[0-9a-f]{64}$ ]] || exit 1
|
||||
actual_sha256="$(
|
||||
cd bootstrap-dist
|
||||
sha256sum *.whl *.tar.gz | sort -k2 | sha256sum | cut -d ' ' -f 1
|
||||
)"
|
||||
[[ "$actual_sha256" == "$EXPECTED_ARTIFACT_SHA256" ]] || {
|
||||
echo "downloaded Python bootstrap artifact digest mismatch" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Revalidate protected source before bootstrap publication
|
||||
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
|
||||
}
|
||||
[[ "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ ]] || {
|
||||
echo "bootstrap commit is malformed" >&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
|
||||
}
|
||||
|
||||
- name: Publish the attested bootstrap distributions
|
||||
id: publish
|
||||
continue-on-error: true
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
||||
with:
|
||||
packages-dir: bootstrap-dist
|
||||
attestations: true
|
||||
skip-existing: true
|
||||
|
||||
verify:
|
||||
needs:
|
||||
- build
|
||||
- preflight
|
||||
- publish
|
||||
if: >-
|
||||
always() &&
|
||||
needs.build.result == 'success' &&
|
||||
needs.preflight.result == 'success' &&
|
||||
(needs.publish.result == 'success' || needs.publish.result == 'skipped')
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.12.8"
|
||||
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
artifact-ids: ${{ needs.build.outputs.artifact_id }}
|
||||
path: bootstrap-dist
|
||||
|
||||
- name: Install the pinned provenance verifier
|
||||
run: |
|
||||
python3 -m pip install \
|
||||
--disable-pip-version-check \
|
||||
"pypi-attestations==0.0.29"
|
||||
|
||||
- name: Reconcile exact bootstrap distributions
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
|
||||
--registry pypi \
|
||||
--package cmux-sdk \
|
||||
--version "$BOOTSTRAP_VERSION" \
|
||||
--artifact bootstrap-dist/*.whl \
|
||||
--allowed-artifact bootstrap-dist/*.whl \
|
||||
--allowed-artifact bootstrap-dist/*.tar.gz \
|
||||
--wait-seconds 300 \
|
||||
--require-match
|
||||
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
|
||||
--registry pypi \
|
||||
--package cmux-sdk \
|
||||
--version "$BOOTSTRAP_VERSION" \
|
||||
--artifact bootstrap-dist/*.tar.gz \
|
||||
--allowed-artifact bootstrap-dist/*.whl \
|
||||
--allowed-artifact bootstrap-dist/*.tar.gz \
|
||||
--wait-seconds 300 \
|
||||
--require-match
|
||||
|
||||
- name: Verify trusted-publisher provenance with pypi-attestations verify pypi
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
wheels=(bootstrap-dist/*.whl)
|
||||
sdists=(bootstrap-dist/*.tar.gz)
|
||||
python3 cmux-tui/bindings/verify_pypi_provenance.py \
|
||||
--package cmux-sdk \
|
||||
--version "$BOOTSTRAP_VERSION" \
|
||||
--filename "$(basename "${wheels[0]}")" \
|
||||
--filename "$(basename "${sdists[0]}")" \
|
||||
--repository https://github.com/manaflow-ai/cmux \
|
||||
--owner lawrencecchen \
|
||||
--workflow sdk-bootstrap-pypi.yml \
|
||||
--environment pypi-bootstrap
|
||||
@@ -1,19 +1,24 @@
|
||||
name: sdk publish crates
|
||||
name: sdk preflight crates
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "mux-sdk-v*"
|
||||
- "cmux-sdk-v*"
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: "SDK version to validate"
|
||||
required: true
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "SDK version to validate/publish, for example 0.1.0"
|
||||
description: "SDK version to validate, for example 0.1.0"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
env:
|
||||
RUST_TOOLCHAIN: "1.95.0"
|
||||
|
||||
concurrency:
|
||||
group: sdk-publish-crates-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
@@ -29,6 +34,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate tag and package versions
|
||||
id: version
|
||||
@@ -37,13 +43,15 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
|
||||
[[ "$GITHUB_REF_NAME" =~ ^(mux|cmux)-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "tag must match mux-sdk-vX.Y.Z or cmux-sdk-vX.Y.Z" >&2
|
||||
[[ "$GITHUB_REF_NAME" =~ ^cmux-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "tag must match cmux-sdk-vX.Y.Z" >&2
|
||||
exit 1
|
||||
}
|
||||
version="${GITHUB_REF_NAME#cmux-sdk-v}"
|
||||
[[ "$DISPATCH_VERSION" == "$version" ]] || {
|
||||
echo "workflow_dispatch version $DISPATCH_VERSION does not match tag version $version" >&2
|
||||
exit 1
|
||||
}
|
||||
version="$GITHUB_REF_NAME"
|
||||
version="${version#mux-sdk-v}"
|
||||
version="${version#cmux-sdk-v}"
|
||||
else
|
||||
version="$DISPATCH_VERSION"
|
||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
@@ -51,6 +59,8 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
python3 cmux-tui/bindings/validate_release_version.py \
|
||||
--version "$version"
|
||||
python3 - "$version" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
@@ -72,7 +82,9 @@ jobs:
|
||||
raise SystemExit(1)
|
||||
print(f"All package versions match {expected}")
|
||||
PY
|
||||
python3 cmux-tui/bindings/check-versions.py --expected "$version"
|
||||
python3 cmux-tui/bindings/check-versions.py \
|
||||
--published-only \
|
||||
--expected "$version"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
bindings-e2e-rust:
|
||||
@@ -97,18 +109,63 @@ jobs:
|
||||
- name: Install zig
|
||||
run: ./scripts/install-zig-ci.sh
|
||||
|
||||
- name: Rust version
|
||||
- name: Install pinned Rust toolchain
|
||||
run: |
|
||||
rustc --version || true
|
||||
if ! command -v cargo >/dev/null; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
fi
|
||||
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal
|
||||
rustup default "$RUST_TOOLCHAIN"
|
||||
cargo --version
|
||||
rustc --version
|
||||
|
||||
- name: Build cmux-tui server
|
||||
working-directory: cmux-tui
|
||||
run: cargo build -p cmux-tui --bin cmux-tui --locked
|
||||
|
||||
- name: Test Rust SDK packages
|
||||
working-directory: cmux-tui
|
||||
env:
|
||||
CMUX_SDK_VERSION: ${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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-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-sdk-$CMUX_SDK_VERSION.crate" \
|
||||
-C "$verify_root"
|
||||
tar -xzf \
|
||||
"target/package/cmux-sidebar-$CMUX_SDK_VERSION.crate" \
|
||||
-C "$verify_root"
|
||||
cargo test \
|
||||
--manifest-path \
|
||||
"$verify_root/cmux-sidebar-$CMUX_SDK_VERSION/Cargo.toml" \
|
||||
--config \
|
||||
"patch.crates-io.cmux-sdk.path='$verify_root/cmux-sdk-$CMUX_SDK_VERSION'" \
|
||||
--all-targets
|
||||
|
||||
- name: Upload validated cmux-sdk crate
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cmux-rust-sdk-crate
|
||||
path: cmux-tui/target/package/cmux-sdk-${{ needs.version.outputs.version }}.crate
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
|
||||
- name: Upload validated cmux-sidebar crate
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cmux-rust-sidebar-crate
|
||||
path: cmux-tui/target/package/cmux-sidebar-${{ needs.version.outputs.version }}.crate
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
|
||||
- name: Rust SDK conformance
|
||||
run: |
|
||||
report="$RUNNER_TEMP/cmux-sdk-conformance-rust.txt"
|
||||
@@ -118,53 +175,3 @@ jobs:
|
||||
--cmux-tui-bin "$GITHUB_WORKSPACE/cmux-tui/target/debug/cmux-tui" |
|
||||
tee "$report"
|
||||
grep -Eq '^PASS +rust +live-creation-exit-restart-unix$' "$report"
|
||||
|
||||
publish:
|
||||
needs:
|
||||
- version
|
||||
- bindings-e2e-rust
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
environment:
|
||||
name: crates-io
|
||||
url: https://crates.io/crates/cmux-client
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Authenticate cmux-client with crates.io trusted publishing
|
||||
id: auth_client
|
||||
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
|
||||
|
||||
- name: Publish cmux-client
|
||||
working-directory: cmux-tui
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ steps.auth_client.outputs.token }}
|
||||
run: cargo publish -p cmux-client --locked
|
||||
|
||||
- name: Wait for cmux-client to reach the crates.io index
|
||||
env:
|
||||
CMUX_SDK_VERSION: ${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for _ in $(seq 1 30); do
|
||||
if cargo info "cmux-client@$CMUX_SDK_VERSION" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
echo "cmux-client@$CMUX_SDK_VERSION did not reach the crates.io index" >&2
|
||||
exit 1
|
||||
|
||||
- name: Authenticate cmux-sidebar with crates.io trusted publishing
|
||||
id: auth_sidebar
|
||||
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
|
||||
|
||||
- name: Publish cmux-sidebar
|
||||
working-directory: cmux-tui
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ steps.auth_sidebar.outputs.token }}
|
||||
run: cargo publish -p cmux-sidebar --locked
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
name: sdk publish go
|
||||
name: sdk validate go
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "mux-sdk-v*"
|
||||
- "cmux-sdk-v*"
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: "SDK version to validate or verify"
|
||||
required: true
|
||||
type: string
|
||||
verify_tag:
|
||||
description: "Resolve the coordinated public Go module tag"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
release_ref:
|
||||
description: "Exact coordinated Go module tag ref"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
@@ -29,28 +41,66 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate tag and package versions
|
||||
id: version
|
||||
env:
|
||||
CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
|
||||
DISPATCH_VERSION: ${{ inputs.version }}
|
||||
RELEASE_REF: ${{ inputs.release_ref }}
|
||||
VERIFY_TAG: ${{ inputs.verify_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
|
||||
[[ "$GITHUB_REF_NAME" =~ ^(mux|cmux)-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "tag must match mux-sdk-vX.Y.Z or cmux-sdk-vX.Y.Z" >&2
|
||||
[[ "$GITHUB_REF_NAME" =~ ^cmux-tui/bindings/go/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || {
|
||||
echo "tag must match cmux-tui/bindings/go/vX.Y.Z" >&2
|
||||
exit 1
|
||||
}
|
||||
version="${GITHUB_REF_NAME#cmux-tui/bindings/go/v}"
|
||||
[[ "$DISPATCH_VERSION" == "$version" ]] || {
|
||||
echo "requested version $DISPATCH_VERSION does not match tag version $version" >&2
|
||||
exit 1
|
||||
}
|
||||
version="$GITHUB_REF_NAME"
|
||||
version="${version#mux-sdk-v}"
|
||||
version="${version#cmux-sdk-v}"
|
||||
else
|
||||
version="$DISPATCH_VERSION"
|
||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
[[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || {
|
||||
echo "workflow_dispatch version must match X.Y.Z" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
python3 cmux-tui/bindings/validate_release_version.py \
|
||||
--version "$version"
|
||||
if [[ "$VERIFY_TAG" == "true" ]]; then
|
||||
expected_caller="$GITHUB_REPOSITORY/.github/workflows/sdk-release-cut.yml@$GITHUB_REF"
|
||||
[[ "$CALLER_WORKFLOW_REF" == "$expected_caller" ]] || {
|
||||
echo "Public Go tag verification is only available through sdk-release-cut.yml." >&2
|
||||
exit 1
|
||||
}
|
||||
tag="cmux-tui/bindings/go/v$version"
|
||||
expected_ref="refs/tags/$tag"
|
||||
[[ "$RELEASE_REF" == "$expected_ref" ]] || {
|
||||
echo "Refusing to verify Go ref $RELEASE_REF; expected $expected_ref." >&2
|
||||
exit 1
|
||||
}
|
||||
git fetch --force origin main --tags
|
||||
git tag --list 'cmux-sdk-v*' | \
|
||||
python3 cmux-tui/bindings/validate_release_version.py \
|
||||
--version "$version" \
|
||||
--require-latest-tag
|
||||
release_sha="$(git rev-parse "refs/tags/$tag^{commit}")" || {
|
||||
echo "release tag does not exist: $tag" >&2
|
||||
exit 1
|
||||
}
|
||||
git merge-base --is-ancestor "$release_sha" origin/main || {
|
||||
echo "release tag $tag is not an ancestor of protected main" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$release_sha" == "$GITHUB_SHA" ]] || {
|
||||
echo "release tag $tag resolves to $release_sha, expected workflow commit $GITHUB_SHA" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
python3 - "$version" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
@@ -71,10 +121,13 @@ jobs:
|
||||
raise SystemExit(1)
|
||||
print(f"All package versions match {expected}")
|
||||
PY
|
||||
python3 cmux-tui/bindings/check-versions.py --expected "$version"
|
||||
python3 cmux-tui/bindings/check-versions.py \
|
||||
--published-only \
|
||||
--expected "$version"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
bindings-e2e-go:
|
||||
if: inputs.verify_tag != true
|
||||
needs: version
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 40
|
||||
@@ -124,6 +177,7 @@ jobs:
|
||||
grep -Eq '^PASS +go +live-creation-exit-restart-unix$' "$report"
|
||||
|
||||
validate-go-module:
|
||||
if: inputs.verify_tag != true
|
||||
needs: bindings-e2e-go
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
permissions:
|
||||
@@ -141,5 +195,85 @@ jobs:
|
||||
- name: Validate Go module
|
||||
working-directory: cmux-tui/bindings/go
|
||||
run: |
|
||||
go test ./...
|
||||
go build ./...
|
||||
go vet ./...
|
||||
|
||||
verify-versioned-go-module:
|
||||
if: inputs.verify_tag == true
|
||||
needs: version
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 35
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
cache: false
|
||||
|
||||
- name: Resolve the public module tag from a clean consumer
|
||||
env:
|
||||
CMUX_SDK_VERSION: ${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
module="github.com/manaflow-ai/cmux/cmux-tui/bindings/go"
|
||||
expected="v$CMUX_SDK_VERSION"
|
||||
scratch="$(mktemp -d)"
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
export GOENV=off
|
||||
export GOFLAGS=""
|
||||
export GOINSECURE=""
|
||||
export GOPROXY=https://proxy.golang.org
|
||||
export GOSUMDB=sum.golang.org
|
||||
export GOPRIVATE=""
|
||||
export GONOPROXY=none
|
||||
export GONOSUMDB=none
|
||||
export GOMODCACHE="$scratch/modcache"
|
||||
export GOCACHE="$scratch/buildcache"
|
||||
export GOWORK=off
|
||||
mkdir "$scratch/consumer"
|
||||
cd "$scratch/consumer"
|
||||
go mod init cmux-release-consumer
|
||||
python3 "$GITHUB_WORKSPACE/cmux-tui/bindings/wait_for_go_module.py" \
|
||||
--module "$module" \
|
||||
--version "$expected" \
|
||||
--wait-seconds 1800 \
|
||||
--retry-seconds 30
|
||||
go get "$module@$expected"
|
||||
go mod download "$module@$expected"
|
||||
go mod verify
|
||||
resolved="$(go list -m -f '{{.Version}}' "$module")"
|
||||
[[ "$resolved" == "$expected" ]] || {
|
||||
echo "resolved $module@$resolved, expected $expected" >&2
|
||||
exit 1
|
||||
}
|
||||
module_dir="$(go list -m -f '{{.Dir}}' "$module")"
|
||||
python3 "$GITHUB_WORKSPACE/cmux-tui/bindings/verify_go_module_source.py" \
|
||||
--repository "$GITHUB_WORKSPACE" \
|
||||
--commit "$GITHUB_SHA" \
|
||||
--module-subdir cmux-tui/bindings/go \
|
||||
--downloaded-root "$module_dir"
|
||||
cat > release_test.go <<EOF
|
||||
package consumer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
cmux "$module"
|
||||
raw "$module/raw"
|
||||
)
|
||||
|
||||
func TestReleasedPackagesCompile(t *testing.T) {
|
||||
_ = cmux.ClientOptions{}
|
||||
_ = raw.Options{}
|
||||
}
|
||||
EOF
|
||||
gofmt -w release_test.go
|
||||
go test -mod=readonly ./...
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
name: sdk publish java
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "mux-sdk-v*"
|
||||
- "cmux-sdk-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
@@ -36,21 +32,11 @@ jobs:
|
||||
DISPATCH_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
|
||||
[[ "$GITHUB_REF_NAME" =~ ^(mux|cmux)-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "tag must match mux-sdk-vX.Y.Z or cmux-sdk-vX.Y.Z" >&2
|
||||
exit 1
|
||||
}
|
||||
version="$GITHUB_REF_NAME"
|
||||
version="${version#mux-sdk-v}"
|
||||
version="${version#cmux-sdk-v}"
|
||||
else
|
||||
version="$DISPATCH_VERSION"
|
||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "workflow_dispatch version must match X.Y.Z" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
version="$DISPATCH_VERSION"
|
||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "workflow_dispatch version must match X.Y.Z" >&2
|
||||
exit 1
|
||||
}
|
||||
python3 - "$version" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
name: sdk publish npm
|
||||
name: sdk preflight npm
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "mux-sdk-v*"
|
||||
- "cmux-sdk-v*"
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: "SDK version to validate"
|
||||
required: true
|
||||
type: string
|
||||
outputs:
|
||||
artifact_id:
|
||||
description: "Immutable ID of the validated npm artifact"
|
||||
value: ${{ jobs.bindings-e2e-typescript.outputs.artifact_id }}
|
||||
artifact_sha256:
|
||||
description: "SHA-256 of the validated npm tarball"
|
||||
value: ${{ jobs.bindings-e2e-typescript.outputs.artifact_sha256 }}
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "SDK version to validate/publish, for example 0.1.0"
|
||||
description: "SDK version to validate, for example 0.1.0"
|
||||
required: true
|
||||
type: string
|
||||
confirm_npm_cmux:
|
||||
description: "Set true only for the coordinated npm cmux SDK publish"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions: {}
|
||||
|
||||
@@ -37,6 +41,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate tag and package versions
|
||||
id: version
|
||||
@@ -45,13 +50,15 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
|
||||
[[ "$GITHUB_REF_NAME" =~ ^(mux|cmux)-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "tag must match mux-sdk-vX.Y.Z or cmux-sdk-vX.Y.Z" >&2
|
||||
[[ "$GITHUB_REF_NAME" =~ ^cmux-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "tag must match cmux-sdk-vX.Y.Z" >&2
|
||||
exit 1
|
||||
}
|
||||
version="${GITHUB_REF_NAME#cmux-sdk-v}"
|
||||
[[ "$DISPATCH_VERSION" == "$version" ]] || {
|
||||
echo "workflow_dispatch version $DISPATCH_VERSION does not match tag version $version" >&2
|
||||
exit 1
|
||||
}
|
||||
version="$GITHUB_REF_NAME"
|
||||
version="${version#mux-sdk-v}"
|
||||
version="${version#cmux-sdk-v}"
|
||||
else
|
||||
version="$DISPATCH_VERSION"
|
||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
@@ -59,6 +66,8 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
python3 cmux-tui/bindings/validate_release_version.py \
|
||||
--version "$version"
|
||||
python3 - "$version" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
@@ -79,7 +88,9 @@ jobs:
|
||||
raise SystemExit(1)
|
||||
print(f"All package versions match {expected}")
|
||||
PY
|
||||
python3 cmux-tui/bindings/check-versions.py --expected "$version"
|
||||
python3 cmux-tui/bindings/check-versions.py \
|
||||
--published-only \
|
||||
--expected "$version"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
bindings-e2e-typescript:
|
||||
@@ -88,6 +99,9 @@ jobs:
|
||||
timeout-minutes: 40
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
artifact_id: ${{ steps.upload.outputs.artifact-id }}
|
||||
artifact_sha256: ${{ steps.package.outputs.artifact_sha256 }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -119,7 +133,9 @@ jobs:
|
||||
|
||||
- name: Install TypeScript adapter dependencies
|
||||
working-directory: cmux-tui/bindings/typescript
|
||||
run: npm ci --no-audit --no-fund
|
||||
run: |
|
||||
npm ci --no-audit --no-fund
|
||||
npm test
|
||||
|
||||
- name: Build cmux-tui server
|
||||
working-directory: cmux-tui
|
||||
@@ -137,55 +153,27 @@ jobs:
|
||||
grep -Eq '^PASS +typescript +live-creation-exit-restart-unix$' "$report"
|
||||
grep -Eq '^PASS +typescript +live-creation-exit-restart-websocket$' "$report"
|
||||
|
||||
publish:
|
||||
# The npm package name "cmux" is currently a different live package
|
||||
# (the cloud-VM CLI). Publishing the SDK there is a coordinated breaking
|
||||
# action, so tag pushes never publish to npm and manual runs must opt in.
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
needs: bindings-e2e-typescript
|
||||
# npm --provenance rejects self-hosted runners; the attestation is only
|
||||
# verifiable from a GitHub-hosted runner. This one publish job must stay on
|
||||
# ubuntu-latest (github-hosted), unlike the routed self-hosted jobs above.
|
||||
runs-on: ubuntu-latest # github-hosted-required: npm provenance needs a github-hosted runner
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
environment:
|
||||
name: npm
|
||||
url: https://www.npmjs.com/package/cmux
|
||||
steps:
|
||||
- name: Require npm cmux confirmation
|
||||
if: inputs.confirm_npm_cmux != true
|
||||
run: |
|
||||
echo "Refusing to publish npm package cmux without confirm_npm_cmux=true." >&2
|
||||
exit 1
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22.14.0"
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Upgrade npm for OIDC trusted publishing
|
||||
# Node 22 bundles npm 10, which signs provenance but cannot
|
||||
# authenticate the publish via OIDC trusted publishing (the PUT is
|
||||
# unauthenticated and 404s). npm >= 11.5.1 performs the OIDC token
|
||||
# exchange for the publish itself.
|
||||
run: npm install -g npm@^11.5.1
|
||||
|
||||
- name: Build package
|
||||
- name: Pack the validated npm artifact
|
||||
id: package
|
||||
working-directory: cmux-tui/bindings/typescript
|
||||
run: |
|
||||
npm ci --no-audit --no-fund
|
||||
npm run build
|
||||
set -euo pipefail
|
||||
mkdir -p "$RUNNER_TEMP/cmux-npm-dist"
|
||||
npm pack --pack-destination "$RUNNER_TEMP/cmux-npm-dist"
|
||||
shopt -s nullglob
|
||||
packages=("$RUNNER_TEMP"/cmux-npm-dist/*.tgz)
|
||||
[[ "${#packages[@]}" == 1 ]] || {
|
||||
echo "expected one validated npm artifact" >&2
|
||||
exit 1
|
||||
}
|
||||
artifact_sha256="$(sha256sum "${packages[0]}" | cut -d ' ' -f 1)"
|
||||
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]] || exit 1
|
||||
echo "artifact_sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish package to npm
|
||||
working-directory: cmux-tui/bindings/typescript
|
||||
# The npm `cmux` name still serves the cloud-VM CLI on the `latest`
|
||||
# dist-tag (0.8.3). The SDK ships on its own `sdk` tag so installing
|
||||
# bare `cmux` keeps resolving the CLI; use `npm i cmux@sdk` for the SDK.
|
||||
run: npm publish --provenance --tag sdk
|
||||
- name: Upload the validated npm artifact
|
||||
id: upload
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cmux-npm-dist-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/cmux-npm-dist/*.tgz
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
name: sdk publish python
|
||||
name: sdk preflight python
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "mux-sdk-v*"
|
||||
- "cmux-sdk-v*"
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: "SDK version to validate"
|
||||
required: true
|
||||
type: string
|
||||
outputs:
|
||||
artifact_id:
|
||||
description: "Immutable ID of the validated Python distributions"
|
||||
value: ${{ jobs.build.outputs.artifact_id }}
|
||||
artifact_sha256:
|
||||
description: "SHA-256 of the validated distribution digest manifest"
|
||||
value: ${{ jobs.build.outputs.artifact_sha256 }}
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "SDK version to validate/publish, for example 0.1.0"
|
||||
description: "SDK version to validate, for example 0.1.0"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
@@ -32,6 +41,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate tag and package versions
|
||||
id: version
|
||||
@@ -40,13 +50,15 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
|
||||
[[ "$GITHUB_REF_NAME" =~ ^(mux|cmux)-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "tag must match mux-sdk-vX.Y.Z or cmux-sdk-vX.Y.Z" >&2
|
||||
[[ "$GITHUB_REF_NAME" =~ ^cmux-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "tag must match cmux-sdk-vX.Y.Z" >&2
|
||||
exit 1
|
||||
}
|
||||
version="${GITHUB_REF_NAME#cmux-sdk-v}"
|
||||
[[ "$DISPATCH_VERSION" == "$version" ]] || {
|
||||
echo "workflow_dispatch version $DISPATCH_VERSION does not match tag version $version" >&2
|
||||
exit 1
|
||||
}
|
||||
version="$GITHUB_REF_NAME"
|
||||
version="${version#mux-sdk-v}"
|
||||
version="${version#cmux-sdk-v}"
|
||||
else
|
||||
version="$DISPATCH_VERSION"
|
||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
@@ -54,6 +66,8 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
python3 cmux-tui/bindings/validate_release_version.py \
|
||||
--version "$version"
|
||||
python3 - "$version" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
@@ -74,7 +88,9 @@ jobs:
|
||||
raise SystemExit(1)
|
||||
print(f"All package versions match {expected}")
|
||||
PY
|
||||
python3 cmux-tui/bindings/check-versions.py --expected "$version"
|
||||
python3 cmux-tui/bindings/check-versions.py \
|
||||
--published-only \
|
||||
--expected "$version"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
bindings-e2e-python:
|
||||
@@ -88,6 +104,10 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.12.8"
|
||||
|
||||
- name: Init ghostty submodule
|
||||
run: git submodule update --init --depth 1 ghostty
|
||||
|
||||
@@ -109,6 +129,16 @@ jobs:
|
||||
working-directory: cmux-tui
|
||||
run: cargo build -p cmux-tui --bin cmux-tui --locked
|
||||
|
||||
- name: Install declared Python build backend
|
||||
run: |
|
||||
python3 -m pip install \
|
||||
--disable-pip-version-check \
|
||||
"setuptools==80.9.0"
|
||||
|
||||
- name: Test Python SDK package
|
||||
working-directory: cmux-tui/bindings/python
|
||||
run: PYTHONPATH=. python3 -m unittest discover -s tests -v
|
||||
|
||||
- name: Python SDK conformance
|
||||
run: |
|
||||
report="$RUNNER_TEMP/cmux-sdk-conformance-python.txt"
|
||||
@@ -124,42 +154,62 @@ jobs:
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
artifact_id: ${{ steps.upload.outputs.artifact-id }}
|
||||
artifact_sha256: ${{ steps.package.outputs.artifact_sha256 }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.12.8"
|
||||
|
||||
- name: Install pinned Python packaging tools
|
||||
run: |
|
||||
python3 -m pip install \
|
||||
--disable-pip-version-check \
|
||||
"build==1.3.0" \
|
||||
"setuptools==80.9.0" \
|
||||
"wheel==0.45.1"
|
||||
|
||||
- name: Build sdist and wheel
|
||||
working-directory: cmux-tui/bindings/python
|
||||
run: |
|
||||
python3 -m pip install --upgrade build
|
||||
python3 -m build --sdist --wheel
|
||||
set -euo pipefail
|
||||
SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
|
||||
export SOURCE_DATE_EPOCH
|
||||
python3 -m build --no-isolation --sdist --wheel
|
||||
python3 ../normalize_python_sdist.py \
|
||||
--archive dist/*.tar.gz \
|
||||
--epoch "$SOURCE_DATE_EPOCH"
|
||||
|
||||
- name: Test the exact Python distributions
|
||||
working-directory: cmux-tui/bindings/python
|
||||
env:
|
||||
CMUX_PYTHON_DIST_DIR: ${{ github.workspace }}/cmux-tui/bindings/python/dist
|
||||
run: PYTHONPATH=. python3 -m unittest tests.test_package_consumer -v
|
||||
|
||||
- name: Fingerprint the validated Python distributions
|
||||
id: package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd cmux-tui/bindings/python/dist
|
||||
shopt -s nullglob
|
||||
files=(*.whl *.tar.gz)
|
||||
[[ "${#files[@]}" == 2 ]] || {
|
||||
echo "expected one wheel and one source distribution" >&2
|
||||
exit 1
|
||||
}
|
||||
artifact_sha256="$(sha256sum "${files[@]}" | sort -k2 | sha256sum | cut -d ' ' -f 1)"
|
||||
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]] || exit 1
|
||||
echo "artifact_sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload distributions
|
||||
id: upload
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cmux-python-dist
|
||||
name: cmux-python-dist-${{ github.run_attempt }}
|
||||
path: cmux-tui/bindings/python/dist/*
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
needs: build
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/cmux
|
||||
steps:
|
||||
- name: Download distributions
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: cmux-python-dist
|
||||
path: dist
|
||||
|
||||
- name: Publish package distributions to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
||||
with:
|
||||
packages-dir: dist
|
||||
attestations: true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -59,28 +59,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD)
|
||||
BUILD_FLAVOR="crashsubdir-cmux-crash-v1"
|
||||
TAG="xcframework-$GHOSTTY_SHA-$BUILD_FLAVOR"
|
||||
URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz"
|
||||
echo "Downloading xcframework for ghostty $GHOSTTY_SHA"
|
||||
MAX_RETRIES=30
|
||||
RETRY_DELAY=20
|
||||
for i in $(seq 1 $MAX_RETRIES); do
|
||||
if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then
|
||||
echo "Download succeeded on attempt $i"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq "$MAX_RETRIES" ]; then
|
||||
echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..."
|
||||
sleep $RETRY_DELAY
|
||||
done
|
||||
tar xzf GhosttyKit.xcframework.tar.gz
|
||||
rm GhosttyKit.xcframework.tar.gz
|
||||
test -d GhosttyKit.xcframework
|
||||
./scripts/download-prebuilt-ghosttykit.sh
|
||||
|
||||
- name: Install zig
|
||||
run: |
|
||||
|
||||
@@ -180,28 +180,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD)
|
||||
BUILD_FLAVOR="crashsubdir-cmux-crash-v1"
|
||||
TAG="xcframework-$GHOSTTY_SHA-$BUILD_FLAVOR"
|
||||
URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz"
|
||||
echo "Downloading xcframework for ghostty $GHOSTTY_SHA"
|
||||
MAX_RETRIES=30
|
||||
RETRY_DELAY=20
|
||||
for i in $(seq 1 $MAX_RETRIES); do
|
||||
if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then
|
||||
echo "Download succeeded on attempt $i"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq "$MAX_RETRIES" ]; then
|
||||
echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..."
|
||||
sleep $RETRY_DELAY
|
||||
done
|
||||
tar xzf GhosttyKit.xcframework.tar.gz
|
||||
rm GhosttyKit.xcframework.tar.gz
|
||||
test -d GhosttyKit.xcframework
|
||||
./scripts/download-prebuilt-ghosttykit.sh
|
||||
|
||||
- name: Install zig
|
||||
run: |
|
||||
|
||||
@@ -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.
|
||||
@@ -401,6 +405,9 @@ jobs:
|
||||
xcrun simctl boot "$SIMULATOR_ID" >/dev/null 2>&1 || true
|
||||
xcrun simctl bootstatus "$SIMULATOR_ID" -b
|
||||
if xcodebuild "${XCODEBUILD_ARGS[@]}" 2>&1 | tee "$LOG_PATH"; then
|
||||
./scripts/ci/require_selected_test_execution.sh \
|
||||
"$LOG_PATH" \
|
||||
"${TEST_FILTER:-}"
|
||||
exit 0
|
||||
fi
|
||||
status="${PIPESTATUS[0]}"
|
||||
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: GhosttyKit.xcframework
|
||||
key: ghosttykit-${{ hashFiles('.gitmodules', 'ghostty/**') }}
|
||||
key: ghosttykit-sentry-off-v1-${{ hashFiles('.gitmodules', 'ghostty/**') }}
|
||||
|
||||
- name: Download pre-built GhosttyKit.xcframework
|
||||
if: steps.cache-ghosttykit.outputs.cache-hit != 'true'
|
||||
|
||||
@@ -3,36 +3,19 @@ name: cmux-tui publish npm
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_target:
|
||||
description: "Package contents to publish under the shared npm cmux name"
|
||||
required: true
|
||||
default: tui
|
||||
type: choice
|
||||
options:
|
||||
- tui
|
||||
- sdk
|
||||
version:
|
||||
description: "Package version to publish, for example 0.1.0"
|
||||
required: true
|
||||
type: string
|
||||
artifact_run_id:
|
||||
description: "Successful cmux-tui release run containing verified packages"
|
||||
required: false
|
||||
type: string
|
||||
sdk_verification_run_id:
|
||||
description: "SDK npm workflow run whose TypeScript end-to-end job passed"
|
||||
required: false
|
||||
required: true
|
||||
type: string
|
||||
confirm_tui_cmux:
|
||||
description: "Set true only for the coordinated npm cmux TUI publish"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
confirm_sdk_cmux:
|
||||
description: "Set true only for the coordinated npm cmux SDK publish"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions: {}
|
||||
|
||||
@@ -42,7 +25,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
validate-version:
|
||||
if: inputs.publish_target == 'tui'
|
||||
# This workflow's launcher publish deliberately omits --tag so the version
|
||||
# becomes npm `latest`. Only strict stable X.Y.Z may go through here; a
|
||||
# nightly-form version on latest would put a nightly in front of every
|
||||
@@ -153,109 +135,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
validate-sdk-version:
|
||||
if: inputs.publish_target == 'sdk'
|
||||
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
outputs:
|
||||
release_sha: ${{ steps.release.outputs.release_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Require protected main and verified SDK run
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DISPATCH_VERSION: ${{ inputs.version }}
|
||||
SDK_VERIFICATION_RUN_ID: ${{ inputs.sdk_verification_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$GITHUB_REF" == "refs/heads/main" ]] || {
|
||||
echo "Refusing to publish the SDK from $GITHUB_REF; dispatch this workflow on main." >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$DISPATCH_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "workflow_dispatch version must match X.Y.Z" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$SDK_VERIFICATION_RUN_ID" =~ ^[0-9]+$ ]] || {
|
||||
echo "sdk_verification_run_id must be a GitHub Actions run ID" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
git fetch --force origin main
|
||||
current_main="$(git rev-parse origin/main)"
|
||||
if ! git merge-base --is-ancestor "$GITHUB_SHA" "$current_main"; then
|
||||
echo "workflow commit $GITHUB_SHA is not contained in protected main $current_main" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 - "$DISPATCH_VERSION" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import tomllib
|
||||
|
||||
expected = sys.argv[1]
|
||||
root = pathlib.Path.cwd()
|
||||
versions = {
|
||||
"typescript package.json": json.loads((root / "cmux-tui/bindings/typescript/package.json").read_text())["version"],
|
||||
"python pyproject.toml": tomllib.loads((root / "cmux-tui/bindings/python/pyproject.toml").read_text())["project"]["version"],
|
||||
"rust Cargo.toml": tomllib.loads((root / "cmux-tui/bindings/rust/Cargo.toml").read_text())["package"]["version"],
|
||||
}
|
||||
mismatches = {name: got for name, got in versions.items() if got != expected}
|
||||
if mismatches:
|
||||
for name, got in mismatches.items():
|
||||
print(f"{name}: expected {expected}, got {got}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(f"All package versions match {expected}")
|
||||
PY
|
||||
|
||||
IFS=$'\t' read -r actual_path verified_sha event status <<<"$(
|
||||
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$SDK_VERIFICATION_RUN_ID" \
|
||||
--jq '[.path, .head_sha, .event, .status] | @tsv'
|
||||
)"
|
||||
if [[ "$actual_path" != ".github/workflows/sdk-publish-npm.yml" ]]; then
|
||||
echo "verification run $SDK_VERIFICATION_RUN_ID came from $actual_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$event" != "workflow_dispatch" || "$status" != "completed" ]]; then
|
||||
echo "verification run must be a completed workflow_dispatch run; got $event/$status" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IFS=$'\t' read -r job_count job_status job_conclusion <<<"$(
|
||||
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$SDK_VERIFICATION_RUN_ID/jobs" \
|
||||
--jq '[.jobs[] | select(.name == "bindings-e2e-typescript")] as $jobs |
|
||||
[($jobs | length), ($jobs[0].status // ""), ($jobs[0].conclusion // "")] | @tsv'
|
||||
)"
|
||||
if [[ "$job_count" != "1" || "$job_status" != "completed" || "$job_conclusion" != "success" ]]; then
|
||||
echo "verification run TypeScript end-to-end job is not a single completed success" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git merge-base --is-ancestor "$verified_sha" "$GITHUB_SHA" || {
|
||||
echo "verification commit $verified_sha is not an ancestor of $GITHUB_SHA" >&2
|
||||
exit 1
|
||||
}
|
||||
if ! git diff --quiet "$verified_sha" "$GITHUB_SHA" -- \
|
||||
cmux-tui \
|
||||
.github/workflows/sdk-publish-npm.yml \
|
||||
':(exclude)cmux-tui/bindings/RELEASING.md'; then
|
||||
echo "SDK sources or verification workflow changed after run $SDK_VERIFICATION_RUN_ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "release_sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
publish:
|
||||
if: inputs.publish_target == 'tui'
|
||||
needs: validate-version
|
||||
runs-on: ubuntu-latest # github-hosted-required: npm provenance needs a github-hosted runner
|
||||
permissions:
|
||||
@@ -336,46 +216,3 @@ jobs:
|
||||
# Deliberately do not pass --tag: this coordinated TUI publish takes
|
||||
# over the cmux latest dist-tag from the old 0.8.3 CLI when version > 0.8.3.
|
||||
npm publish --provenance dist/npm-packages/cmux
|
||||
|
||||
publish-sdk:
|
||||
if: inputs.publish_target == 'sdk'
|
||||
needs: validate-sdk-version
|
||||
runs-on: ubuntu-latest # github-hosted-required: npm provenance needs a github-hosted runner
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
environment:
|
||||
name: npm-tui
|
||||
url: https://www.npmjs.com/package/cmux
|
||||
steps:
|
||||
- name: Require npm cmux SDK confirmation
|
||||
if: inputs.confirm_sdk_cmux != true
|
||||
run: |
|
||||
echo "Refusing to publish npm package cmux for the SDK without confirm_sdk_cmux=true." >&2
|
||||
exit 1
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.validate-sdk-version.outputs.release_sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22.14.0"
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Install npm with OIDC support
|
||||
run: npm install -g [email protected]
|
||||
|
||||
- name: Build and test SDK package
|
||||
working-directory: cmux-tui/bindings/typescript
|
||||
run: |
|
||||
npm ci --no-audit --no-fund
|
||||
npm test
|
||||
|
||||
- name: Publish SDK package
|
||||
working-directory: cmux-tui/bindings/typescript
|
||||
# Keep the TUI launcher on `latest`; SDK consumers opt in with
|
||||
# `npm install cmux@sdk`.
|
||||
run: npm publish --provenance --tag sdk
|
||||
|
||||
@@ -2,6 +2,99 @@
|
||||
|
||||
All notable changes to cmux are documented here.
|
||||
|
||||
## [0.64.22] - 2026-08-03
|
||||
|
||||
### Fixed
|
||||
- Fix a crash seconds after launch on Intel Macs; cmux is now the only process-wide crash handler, and embedded GhosttyKit no longer links Ghostty's native Sentry initializer ([#9436](https://github.com/manaflow-ai/cmux/pull/9436))
|
||||
- Fix `cmux ssh <host>` failing immediately with a shell syntax error from the generated startup script ([#9425](https://github.com/manaflow-ai/cmux/pull/9425)) -- thanks @KousukeUchiyama for the report!
|
||||
- Clear Dock notifications when you focus the pane that raised them ([#9418](https://github.com/manaflow-ai/cmux/pull/9418))
|
||||
- Keep a restored Claude agent on its own account instead of falling back to the ambient one ([#9419](https://github.com/manaflow-ai/cmux/pull/9419)) -- thanks @seanyoungberg for the report!
|
||||
- Stop bash shell integration printing `cannot overwrite existing file` on every prompt under `set -o noclobber` ([#9420](https://github.com/manaflow-ai/cmux/pull/9420)) -- thanks @8bit-void for the report!
|
||||
- Fail closed when `close` or `respawn-pane` is given an explicit `--surface` that no longer exists, instead of acting on a different live surface ([#9422](https://github.com/manaflow-ai/cmux/pull/9422)) -- thanks @PhilipPinckaers for the report!
|
||||
|
||||
### Thanks to 5 contributors!
|
||||
|
||||
- [@8bit-void](https://github.com/8bit-void)
|
||||
- [@austinywang](https://github.com/austinywang)
|
||||
- [@KousukeUchiyama](https://github.com/KousukeUchiyama)
|
||||
- [@PhilipPinckaers](https://github.com/PhilipPinckaers)
|
||||
- [@seanyoungberg](https://github.com/seanyoungberg)
|
||||
|
||||
## [0.64.21] - 2026-08-02
|
||||
|
||||
### Added
|
||||
- Native iPhone and iPad Simulator panes, with their own commands and automation ([#7857](https://github.com/manaflow-ai/cmux/pull/7857))
|
||||
- First-class Mosh transport for remote workspaces ([#8442](https://github.com/manaflow-ai/cmux/pull/8442))
|
||||
- Workspace-wide terminal font zoom on Cmd+Ctrl+= / Cmd+Ctrl+- / Cmd+Ctrl+0 ([#8791](https://github.com/manaflow-ai/cmux/pull/8791)), and per-tab zoom now persists across restarts ([#8543](https://github.com/manaflow-ai/cmux/pull/8543))
|
||||
- Cmd+Shift+T reopens the last closed item ([#9132](https://github.com/manaflow-ai/cmux/pull/9132))
|
||||
- Cmd+[ and Cmd+] traverse global workspace focus history, and pane cycling becomes rebindable ([#9329](https://github.com/manaflow-ai/cmux/pull/9329)) -- thanks @azooz2003-bit! -- alongside a workspace-only focus history setting ([#8654](https://github.com/manaflow-ai/cmux/pull/8654))
|
||||
- Move active surfaces between panes with automatic directional splits ([#8764](https://github.com/manaflow-ai/cmux/pull/8764)); `goto_split:previous` and `goto_split:next` cycle through every pane with wrapping ([#2639](https://github.com/manaflow-ai/cmux/pull/2639)) -- thanks @mykmelez!
|
||||
- Dock panes persist across session restore ([#8690](https://github.com/manaflow-ai/cmux/pull/8690)), with full Dock surface runtime parity ([#8782](https://github.com/manaflow-ai/cmux/pull/8782))
|
||||
- Reopen closed workspaces with sticky repo identity ([#8841](https://github.com/manaflow-ai/cmux/pull/8841))
|
||||
- Target browser profiles from the CLI ([#8874](https://github.com/manaflow-ai/cmux/pull/8874)), and Command-clicked HTML files render in browser panes ([#9096](https://github.com/manaflow-ai/cmux/pull/9096))
|
||||
- Sidebar account and mobile pairing controls ([#8354](https://github.com/manaflow-ai/cmux/pull/8354)); sidebar metadata renders Markdown links ([#8663](https://github.com/manaflow-ai/cmux/pull/8663)) -- thanks @djova!
|
||||
- Notification feed read state is a leading swipe with mark-unread ([#8868](https://github.com/manaflow-ai/cmux/pull/8868)) -- thanks @azooz2003-bit!
|
||||
- Idle background agents hibernate under critical memory pressure even when routine Agent Hibernation is off ([#9090](https://github.com/manaflow-ai/cmux/pull/9090))
|
||||
- `cmux restore` runs without a shell ([#9265](https://github.com/manaflow-ai/cmux/pull/9265))
|
||||
- iOS (beta): stream Mac browser panes to the phone, interactive and pixel-perfect, with dialogs mirrored ([#8298](https://github.com/manaflow-ai/cmux/pull/8298)) -- thanks @azooz2003-bit!
|
||||
- iOS (beta): chronological notification feed ([#8210](https://github.com/manaflow-ai/cmux/pull/8210)) -- thanks @azooz2003-bit!
|
||||
- iOS (beta): launch agent workspaces straight from the task composer ([#7670](https://github.com/manaflow-ai/cmux/pull/7670))
|
||||
- iOS (beta): Tailscale connection method opt-in with QR-authorized pairing ([#9247](https://github.com/manaflow-ai/cmux/pull/9247)) -- thanks @azooz2003-bit!
|
||||
- iOS (beta): haptic feedback setting ([#8797](https://github.com/manaflow-ai/cmux/pull/8797)), Open Folders on Tap ([#8524](https://github.com/manaflow-ai/cmux/pull/8524)), unified animated toasts ([#8376](https://github.com/manaflow-ai/cmux/pull/8376)), and workspace identity customization ([#8636](https://github.com/manaflow-ai/cmux/pull/8636)) -- thanks @azooz2003-bit!
|
||||
|
||||
### Changed
|
||||
- Workspace initial commands launch through your login shell ([#8801](https://github.com/manaflow-ai/cmux/pull/8801)) -- thanks @azooz2003-bit! -- and auto-resume uses the normal terminal shell ([#8837](https://github.com/manaflow-ai/cmux/pull/8837))
|
||||
- iOS (beta): the phone-to-Mac transport is rebuilt on one connectivity authority, with authenticated discovery, named disconnect reasons, and relay-credential rollover ([#9284](https://github.com/manaflow-ai/cmux/pull/9284), [#8840](https://github.com/manaflow-ai/cmux/pull/8840), [#8716](https://github.com/manaflow-ai/cmux/pull/8716), [#8494](https://github.com/manaflow-ai/cmux/pull/8494)) -- thanks @azooz2003-bit!
|
||||
- iOS (beta): terminal scrolling is local and smooth on screen-anchored render grids ([#8860](https://github.com/manaflow-ai/cmux/pull/8860)) -- thanks @azooz2003-bit!
|
||||
- iOS (beta): state sync v2 replaces the invalidate-and-refetch loop with per-record deltas ([#8284](https://github.com/manaflow-ai/cmux/pull/8284)) -- thanks @azooz2003-bit!
|
||||
- iOS (beta): onboarding is rebuilt around a live agent handoff ([#8418](https://github.com/manaflow-ai/cmux/pull/8418)), as a swipeable tour ([#9158](https://github.com/manaflow-ai/cmux/pull/9158)) with a Game of Life backdrop on every page ([#8880](https://github.com/manaflow-ai/cmux/pull/8880)) -- thanks @azooz2003-bit!
|
||||
- iOS (beta): removing a Mac from a phone hides it for that phone only, instead of deleting it everywhere ([#8760](https://github.com/manaflow-ai/cmux/pull/8760), [#8778](https://github.com/manaflow-ai/cmux/pull/8778)) -- thanks @azooz2003-bit!
|
||||
|
||||
### Fixed
|
||||
- Fix leaked `openThread` loops burning ~90% of cmux idle CPU ([#8851](https://github.com/manaflow-ai/cmux/pull/8851))
|
||||
- Fix workspace-switch renderer freezes ([#8793](https://github.com/manaflow-ai/cmux/pull/8793)), reclaim hidden Ghostty renderer memory ([#8998](https://github.com/manaflow-ai/cmux/pull/8998)), and fix the Vault sidebar beachball at large session counts ([#8680](https://github.com/manaflow-ai/cmux/pull/8680))
|
||||
- Fix Vim Mode cursor and selection rendering ([#8995](https://github.com/manaflow-ai/cmux/pull/8995))
|
||||
- Fix TextBox IME composition rendering ([#8688](https://github.com/manaflow-ai/cmux/pull/8688))
|
||||
- Fix zsh prompt wrap spacer lines by letting Ghostty own prompt layout ([#8964](https://github.com/manaflow-ai/cmux/pull/8964))
|
||||
- Fix Settings and main window zombies under AeroSpace ([#8513](https://github.com/manaflow-ai/cmux/pull/8513)) -- thanks @fml09!
|
||||
- Fix a Debug-build crash on macOS 26.5 from non-finite sidebar divider coordinates ([#9156](https://github.com/manaflow-ai/cmux/pull/9156)) -- thanks @oscarbrey!
|
||||
- Fix Mermaid diagrams double-scaling under viewer zoom ([#8914](https://github.com/manaflow-ai/cmux/pull/8914)), restore the focused-read indicator after a surface-scoped mark-read ([#8927](https://github.com/manaflow-ai/cmux/pull/8927)), keep Pi launch arguments when resuming a restored session ([#8912](https://github.com/manaflow-ai/cmux/pull/8912)), and import appearance at Settings store init instead of live-applying it ([#8913](https://github.com/manaflow-ai/cmux/pull/8913)) -- thanks @ejc3!
|
||||
- Notify only after the Pi agent settles ([#8574](https://github.com/manaflow-ai/cmux/pull/8574)) -- thanks @mrohan-sq!
|
||||
- Tear down remote daemon PTY sessions once ([#8643](https://github.com/manaflow-ai/cmux/pull/8643)) -- thanks @ejc3! -- and support `respawn-pane` in the Go relay tmux compatibility layer ([#8660](https://github.com/manaflow-ai/cmux/pull/8660)) -- thanks @bencollins2!
|
||||
- Exclude `.attrib` from watched filesystem events ([#8659](https://github.com/manaflow-ai/cmux/pull/8659)) -- thanks @varomorf!
|
||||
- Preserve surface IDs in workstream events ([#8703](https://github.com/manaflow-ai/cmux/pull/8703)) -- thanks @revanthreddy-hai!
|
||||
- Stop the sidebar PR poller from re-downloading every repo's full PR list on each poll ([#8521](https://github.com/manaflow-ai/cmux/pull/8521)) -- thanks @joshfree!
|
||||
- Restore Codex ([#9370](https://github.com/manaflow-ai/cmux/pull/9370)), Kimi Code ([#8584](https://github.com/manaflow-ai/cmux/pull/8584)), Grok ([#9382](https://github.com/manaflow-ai/cmux/pull/9382)), and Pi ([#9399](https://github.com/manaflow-ai/cmux/pull/9399)) sessions across relaunch, and stop duplicate agent resumes ([#8619](https://github.com/manaflow-ai/cmux/pull/8619))
|
||||
- ssh-tmux: fix focus after single-pane promotion ([#9020](https://github.com/manaflow-ai/cmux/pull/9020)), named-key encoding for the remote `TERM` ([#9273](https://github.com/manaflow-ai/cmux/pull/9273)), and terminal replies leaking into reattached panes ([#9272](https://github.com/manaflow-ai/cmux/pull/9272)); fix workspace shortcuts from hosted tmux terminals ([#8621](https://github.com/manaflow-ai/cmux/pull/8621))
|
||||
- Fix SSH relay deadlock after app restart ([#9105](https://github.com/manaflow-ai/cmux/pull/9105)), stale SSH workspace connection status ([#9085](https://github.com/manaflow-ai/cmux/pull/9085)), remote PTY `PATH` inherited from cmuxd ([#8677](https://github.com/manaflow-ai/cmux/pull/8677)), and login-shell resolution before terminal spawn ([#8681](https://github.com/manaflow-ai/cmux/pull/8681))
|
||||
- Fix sidebar reopen cutoff render ([#8626](https://github.com/manaflow-ai/cmux/pull/8626)), row clipping during height-changing reorder ([#9189](https://github.com/manaflow-ai/cmux/pull/9189)), idle layout livelock ([#8532](https://github.com/manaflow-ai/cmux/pull/8532)), and status URL clicks ([#8528](https://github.com/manaflow-ai/cmux/pull/8528))
|
||||
- Fix Dock paste routing to the selected terminal ([#9112](https://github.com/manaflow-ai/cmux/pull/9112)), Dock terminal working-directory inheritance ([#8691](https://github.com/manaflow-ai/cmux/pull/8691)), and Cmd-click link opening in Dock terminals ([#8594](https://github.com/manaflow-ai/cmux/pull/8594))
|
||||
- Browser: fix navigation for terminal-wrapped URL pastes ([#8601](https://github.com/manaflow-ai/cmux/pull/8601)), automation recovery after load failures ([#8548](https://github.com/manaflow-ai/cmux/pull/8548)), partial blank screenshots ([#9281](https://github.com/manaflow-ai/cmux/pull/9281)), and blurred Google Sheets canvas rendering ([#8697](https://github.com/manaflow-ai/cmux/pull/8697))
|
||||
- Fix inline code escaping in the Markdown viewer ([#9274](https://github.com/manaflow-ai/cmux/pull/9274)) and composer attachment thumbnail re-rasterization ([#8817](https://github.com/manaflow-ai/cmux/pull/8817))
|
||||
- Fix renderer presentation for background-created surfaces ([#8540](https://github.com/manaflow-ai/cmux/pull/8540)) and stale semantic prompts duplicating inline TUI frames ([#9275](https://github.com/manaflow-ai/cmux/pull/9275))
|
||||
- Fix workspace group anchor numbering ([#9176](https://github.com/manaflow-ai/cmux/pull/9176)); closing a group's anchor keeps the group instead of scattering its members to the root ([#8925](https://github.com/manaflow-ai/cmux/pull/8925))
|
||||
- Preserve workspace IDs across session restore ([#8695](https://github.com/manaflow-ai/cmux/pull/8695)) and restored resume workspace titles ([#8687](https://github.com/manaflow-ai/cmux/pull/8687)); fit same-display restored windows to visible bounds ([#8675](https://github.com/manaflow-ai/cmux/pull/8675))
|
||||
- Fix a `DispatchWorkItem` chain stack overflow ([#8615](https://github.com/manaflow-ai/cmux/pull/8615)) and subprocess pipe descriptor leaks ([#9187](https://github.com/manaflow-ai/cmux/pull/9187))
|
||||
- iOS (beta): preserve terminal input ordering under fast typing ([#8682](https://github.com/manaflow-ai/cmux/pull/8682)), scroll position across mid-stream verified replays ([#9032](https://github.com/manaflow-ai/cmux/pull/9032)), and keyboard focus after the photo picker ([#9287](https://github.com/manaflow-ai/cmux/pull/9287)) -- thanks @azooz2003-bit!
|
||||
- iOS (beta): fix a startup crash from sentry-init racing environ mutation ([#9238](https://github.com/manaflow-ai/cmux/pull/9238)) and TestFlight crash paths ([#9034](https://github.com/manaflow-ai/cmux/pull/9034))
|
||||
- iOS (beta): fix workspace-list scroll stutter from live updates ([#9139](https://github.com/manaflow-ai/cmux/pull/9139)), and make the notification feed scroll fast with thousands of items ([#9141](https://github.com/manaflow-ai/cmux/pull/9141)) -- thanks @azooz2003-bit!
|
||||
|
||||
### Thanks to 13 contributors!
|
||||
|
||||
- [@austinywang](https://github.com/austinywang)
|
||||
- [@azooz2003-bit](https://github.com/azooz2003-bit)
|
||||
- [@bencollins2](https://github.com/bencollins2)
|
||||
- [@djova](https://github.com/djova)
|
||||
- [@ejc3](https://github.com/ejc3)
|
||||
- [@fml09](https://github.com/fml09)
|
||||
- [@joshfree](https://github.com/joshfree)
|
||||
- [@lawrencecchen](https://github.com/lawrencecchen)
|
||||
- [@mrohan-sq](https://github.com/mrohan-sq)
|
||||
- [@mykmelez](https://github.com/mykmelez)
|
||||
- [@oscarbrey](https://github.com/oscarbrey)
|
||||
- [@revanthreddy-hai](https://github.com/revanthreddy-hai)
|
||||
- [@varomorf](https://github.com/varomorf)
|
||||
|
||||
## [0.64.20] - 2026-07-19
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
+196
-34
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4947,13 +5005,35 @@ struct CMUXCLI {
|
||||
let csWsFlag = optionValue(commandArgs, name: "--workspace")
|
||||
let windowRaw = windowFromArgsOrOverride(commandArgs, windowOverride: windowId)
|
||||
let workspaceArg = csWsFlag ?? (windowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
|
||||
let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel") ?? (csWsFlag == nil && windowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
|
||||
let explicitSurfaceRaw = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel")
|
||||
let surfaceRaw = explicitSurfaceRaw ?? (csWsFlag == nil && windowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
|
||||
var params: [String: Any] = [:]
|
||||
let winId = try normalizeWindowHandle(windowRaw, client: client)
|
||||
if let winId { params["window_id"] = winId }
|
||||
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, windowHandle: winId)
|
||||
if let wsId { params["workspace_id"] = wsId }
|
||||
let sfId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: wsId, windowHandle: winId)
|
||||
let sfId: String?
|
||||
if let explicitSurfaceRaw {
|
||||
let explicitSurfaceHandle = explicitSurfaceRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !explicitSurfaceHandle.isEmpty else {
|
||||
throw CLIError(message: String(
|
||||
localized: "cli.surface.error.handleBlank",
|
||||
defaultValue: "Surface handle is blank"
|
||||
))
|
||||
}
|
||||
if let wsId {
|
||||
sfId = try resolveSurfaceId(explicitSurfaceHandle, workspaceId: wsId, client: client)
|
||||
} else if let winId {
|
||||
sfId = try normalizeSurfaceHandle(explicitSurfaceHandle, client: client, workspaceHandle: nil, windowHandle: winId)
|
||||
} else {
|
||||
throw CLIError(message: String(
|
||||
localized: "cli.closeSurface.error.explicitSurfaceRequiresWorkspaceOrWindow",
|
||||
defaultValue: "close-surface requires --workspace or --window with explicit --surface"
|
||||
))
|
||||
}
|
||||
} else {
|
||||
sfId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: wsId, windowHandle: winId)
|
||||
}
|
||||
if let sfId { params["surface_id"] = sfId }
|
||||
let payload = try client.sendV2(method: "surface.close", params: params)
|
||||
if let closedWorkspaceId = (payload["workspace_id"] as? String) ?? wsId,
|
||||
@@ -7071,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 --")
|
||||
}
|
||||
@@ -15370,26 +15461,51 @@ struct CMUXCLI {
|
||||
}
|
||||
|
||||
private func resolveSurfaceId(_ raw: String?, workspaceId: String, client: SocketClient) throws -> String {
|
||||
if let raw, isUUID(raw) {
|
||||
return raw
|
||||
}
|
||||
if let raw, isHandleRef(raw) {
|
||||
let listed = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId])
|
||||
let items = listed["surfaces"] as? [[String: Any]] ?? []
|
||||
for item in items where (item["ref"] as? String) == raw {
|
||||
if let id = item["id"] as? String { return id }
|
||||
}
|
||||
throw CLIError(message: "Surface ref not found: \(raw)")
|
||||
}
|
||||
|
||||
let listed = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId])
|
||||
let items = listed["surfaces"] as? [[String: Any]] ?? []
|
||||
|
||||
if let raw, let index = Int(raw) {
|
||||
if let raw {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
throw CLIError(message: String(
|
||||
localized: "cli.surface.error.handleBlank",
|
||||
defaultValue: "Surface handle is blank"
|
||||
))
|
||||
}
|
||||
if isUUID(trimmed) {
|
||||
for item in items where surfaceHandleMatches(trimmed, item: item) {
|
||||
if let id = item["id"] as? String { return id }
|
||||
}
|
||||
throw CLIError(message: localizedFormat(
|
||||
"cli.surface.error.notFound",
|
||||
defaultValue: "Surface not found: %@",
|
||||
trimmed
|
||||
))
|
||||
}
|
||||
if isHandleRef(trimmed) {
|
||||
for item in items where surfaceHandleMatches(trimmed, item: item) {
|
||||
if let id = item["id"] as? String { return id }
|
||||
}
|
||||
throw CLIError(message: localizedFormat(
|
||||
"cli.surface.error.refNotFound",
|
||||
defaultValue: "Surface ref not found: %@",
|
||||
trimmed
|
||||
))
|
||||
}
|
||||
guard let index = Int(trimmed) else {
|
||||
throw CLIError(message: localizedFormat(
|
||||
"cli.surface.error.invalidHandle",
|
||||
defaultValue: "Invalid surface handle: %@ (expected UUID, ref like surface:1, or index)",
|
||||
trimmed
|
||||
))
|
||||
}
|
||||
for item in items where intFromAny(item["index"]) == index {
|
||||
if let id = item["id"] as? String { return id }
|
||||
}
|
||||
throw CLIError(message: "Surface index not found")
|
||||
throw CLIError(message: String(
|
||||
localized: "cli.surface.error.indexNotFound",
|
||||
defaultValue: "Surface index not found"
|
||||
))
|
||||
}
|
||||
|
||||
if let focused = items.first(where: { ($0["focused"] as? Bool) == true }) {
|
||||
@@ -15528,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]
|
||||
|
||||
@@ -15540,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
|
||||
|
||||
@@ -22424,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
|
||||
}
|
||||
}
|
||||
@@ -30976,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
|
||||
}
|
||||
@@ -31856,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(
|
||||
@@ -32255,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:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user