Skip to content

Commit 782f1c1

Browse files
authored
Merge branch 'main' into peter/wip
2 parents e7d6b1c + cd94674 commit 782f1c1

47 files changed

Lines changed: 2974 additions & 795 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/actions/typecheck/action.yml

Lines changed: 0 additions & 12 deletions
This file was deleted.

.github/workflows/pipeline.yml

Lines changed: 74 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -26,73 +26,14 @@ jobs:
2626
name: exo
2727
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
2828

29-
- name: Configure git user
30-
run: |
31-
git config --local user.email "github-actions@users.noreply.github.com"
32-
git config --local user.name "github-actions bot"
33-
shell: bash
34-
35-
- name: Pull LFS files
36-
run: |
37-
echo "Pulling Git LFS files..."
38-
git lfs pull
39-
shell: bash
40-
41-
- name: Setup Nix Environment
42-
run: |
43-
echo "Checking for nix installation..."
44-
45-
# Check if nix binary exists directly
46-
if [ -f /nix/var/nix/profiles/default/bin/nix ]; then
47-
echo "Found nix binary at /nix/var/nix/profiles/default/bin/nix"
48-
export PATH="/nix/var/nix/profiles/default/bin:$PATH"
49-
echo "PATH=$PATH" >> $GITHUB_ENV
50-
nix --version
51-
elif [ -f /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh ]; then
52-
echo "Found nix profile script, sourcing..."
53-
source /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
54-
nix --version
55-
elif command -v nix >/dev/null 2>&1; then
56-
echo "Nix already in PATH"
57-
nix --version
58-
else
59-
echo "Nix not found. Debugging info:"
60-
echo "Contents of /nix/var/nix/profiles/default/:"
61-
ls -la /nix/var/nix/profiles/default/ 2>/dev/null || echo "Directory not found"
62-
echo "Contents of /nix/var/nix/profiles/default/bin/:"
63-
ls -la /nix/var/nix/profiles/default/bin/ 2>/dev/null || echo "Directory not found"
64-
exit 1
65-
fi
66-
shell: bash
29+
- name: Load nix develop environment
30+
run: nix run github:nicknovitski/nix-develop/v1
6731

68-
- name: Configure basedpyright include for local MLX
69-
run: |
70-
RUNNER_LABELS='${{ toJSON(runner.labels) }}'
71-
if echo "$RUNNER_LABELS" | grep -q "local_mlx"; then
72-
if [ -d "/Users/Shared/mlx" ]; then
73-
echo "Updating [tool.basedpyright].include to use /Users/Shared/mlx"
74-
awk '
75-
BEGIN { in=0 }
76-
/^\[tool\.basedpyright\]/ { in=1; print; next }
77-
in && /^\[/ { in=0 } # next section
78-
in && /^[ \t]*include[ \t]*=/ {
79-
print "include = [\"/Users/Shared/mlx\"]"
80-
next
81-
}
82-
{ print }
83-
' pyproject.toml > pyproject.toml.tmp && mv pyproject.toml.tmp pyproject.toml
84-
85-
echo "New [tool.basedpyright] section:"
86-
sed -n '/^\[tool\.basedpyright\]/,/^\[/p' pyproject.toml | sed '$d' || true
87-
else
88-
echo "local_mlx tag present but /Users/Shared/mlx not found; leaving pyproject unchanged."
89-
fi
90-
else
91-
echo "Runner does not have 'local_mlx' tag; leaving pyproject unchanged."
92-
fi
93-
shell: bash
32+
- name: Sync dependencies
33+
run: uv sync --all-packages
9434

95-
- uses: ./.github/actions/typecheck
35+
- name: Run type checker
36+
run: uv run basedpyright --project pyproject.toml
9637

9738
nix:
9839
name: Build and check (${{ matrix.system }})
@@ -123,6 +64,63 @@ jobs:
12364
name: exo
12465
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
12566

67+
- name: Build Metal packages (macOS only)
68+
if: runner.os == 'macOS'
69+
run: |
70+
# Try to build metal-toolchain first (may succeed via cachix cache hit)
71+
if nix build .#metal-toolchain 2>/dev/null; then
72+
echo "metal-toolchain built successfully (likely cache hit)"
73+
else
74+
echo "metal-toolchain build failed, extracting from Xcode..."
75+
76+
NAR_HASH="sha256-ayR5mXN4sZAddwKEG2OszGRF93k9ZFc7H0yi2xbylQw="
77+
NAR_NAME="metal-toolchain-17C48.nar"
78+
79+
# Use RUNNER_TEMP to avoid /tmp symlink issues on macOS
80+
WORK_DIR="${RUNNER_TEMP}/metal-work"
81+
mkdir -p "$WORK_DIR"
82+
83+
# Download the Metal toolchain component
84+
xcodebuild -downloadComponent MetalToolchain
85+
86+
# Find and mount the DMG
87+
DMG_PATH=$(find /System/Library/AssetsV2/com_apple_MobileAsset_MetalToolchain -name '*.dmg' 2>/dev/null | head -1)
88+
if [ -z "$DMG_PATH" ]; then
89+
echo "Error: Could not find Metal toolchain DMG"
90+
exit 1
91+
fi
92+
93+
echo "Found DMG at: $DMG_PATH"
94+
hdiutil attach "$DMG_PATH" -mountpoint "${WORK_DIR}/metal-dmg"
95+
96+
# Copy the toolchain
97+
cp -R "${WORK_DIR}/metal-dmg/Metal.xctoolchain" "${WORK_DIR}/metal-export"
98+
hdiutil detach "${WORK_DIR}/metal-dmg"
99+
100+
# Create NAR and add to store
101+
nix nar pack "${WORK_DIR}/metal-export" > "${WORK_DIR}/${NAR_NAME}"
102+
STORE_PATH=$(nix store add --mode flat "${WORK_DIR}/${NAR_NAME}")
103+
echo "Added NAR to store: $STORE_PATH"
104+
105+
# Verify the hash matches
106+
ACTUAL_HASH=$(nix hash file "${WORK_DIR}/${NAR_NAME}")
107+
if [ "$ACTUAL_HASH" != "$NAR_HASH" ]; then
108+
echo "Warning: NAR hash mismatch!"
109+
echo "Expected: $NAR_HASH"
110+
echo "Actual: $ACTUAL_HASH"
111+
echo "The metal-toolchain.nix may need updating"
112+
fi
113+
114+
# Clean up
115+
rm -rf "$WORK_DIR"
116+
117+
# Retry the build now that NAR is in store
118+
nix build .#metal-toolchain
119+
fi
120+
121+
# Build mlx (depends on metal-toolchain)
122+
nix build .#mlx
123+
126124
- name: Build all Nix outputs
127125
run: |
128126
nix flake show --json | jq -r '
@@ -134,3 +132,14 @@ jobs:
134132
135133
- name: Run nix flake check
136134
run: nix flake check
135+
136+
- name: Run pytest (macOS only)
137+
if: runner.os == 'macOS'
138+
run: |
139+
# Build the test environment (requires relaxed sandbox for uv2nix on macOS)
140+
TEST_ENV=$(nix build '.#exo-test-env' --option sandbox relaxed --print-out-paths)
141+
142+
# Run pytest outside sandbox (needs GPU access for MLX)
143+
export HOME="$RUNNER_TEMP"
144+
export EXO_TESTS=1
145+
$TEST_ENV/bin/python -m pytest src -m "not slow" --import-mode=importlib

README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<img alt="exo logo" src="/docs/imgs/exo-logo-transparent.png" width="50%" height="50%">
66
</picture>
77

8-
exo: Run your own AI cluster at home with everyday devices. Maintained by [exo labs](https://x.com/exolabs).
8+
exo: Run frontier AI locally. Maintained by [exo labs](https://x.com/exolabs).
99

1010
<p align="center">
1111
<a href="https://discord.gg/TJ4P57arEm" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
@@ -107,6 +107,10 @@ uv run exo
107107

108108
This starts the exo dashboard and API at http://localhost:52415/
109109

110+
111+
*Please view the section on RDMA to enable this feature on MacOS >=26.2!*
112+
113+
110114
### Run from Source (Linux)
111115

112116
**Prerequisites:**
@@ -267,7 +271,7 @@ This removes:
267271

268272
RDMA is a new capability added to macOS 26.2. It works on any Mac with Thunderbolt 5 (M4 Pro Mac Mini, M4 Max Mac Studio, M4 Max MacBook Pro, M3 Ultra Mac Studio).
269273

270-
Note that on Mac Studio, you cannot use the Thunderbolt 5 port next to the Ethernet port.
274+
Please refer to the caveats for immediate troubleshooting.
271275

272276
To enable RDMA on macOS, follow these steps:
273277

@@ -284,6 +288,14 @@ To enable RDMA on macOS, follow these steps:
284288

285289
After that, RDMA will be enabled in macOS and exo will take care of the rest.
286290

291+
**Important Caveats**
292+
293+
1. Devices that wish to be part of an RDMA cluster must be connected to all other devices in the cluster.
294+
2. The cables must support TB5.
295+
3. On a Mac Studio, you cannot use the Thunderbolt 5 port next to the Ethernet port.
296+
4. If running from source, please use the script found at `tmp/set_rdma_network_config.sh`, which will disable Thunderbolt Bridge and set dhcp on each RDMA port.
297+
5. RDMA ports may be unable to discover each other on different versions of MacOS. Please ensure that OS versions match exactly (even beta version numbers) on all devices.
298+
287299
---
288300

289301
### Using the API

app/EXO/EXO.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,8 @@
342342
SDKROOT = macosx;
343343
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
344344
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
345+
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES;
346+
GCC_TREAT_WARNINGS_AS_ERRORS = YES;
345347
};
346348
name = Debug;
347349
};
@@ -397,6 +399,8 @@
397399
MTL_FAST_MATH = YES;
398400
SDKROOT = macosx;
399401
SWIFT_COMPILATION_MODE = wholemodule;
402+
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES;
403+
GCC_TREAT_WARNINGS_AS_ERRORS = YES;
400404
};
401405
name = Release;
402406
};

app/EXO/EXO/EXOApp.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ private final class ExoUpdaterDelegate: NSObject, SPUUpdaterDelegate {
225225
}
226226
}
227227

228-
private func showNotification(title: String, body: String) {
228+
nonisolated private func showNotification(title: String, body: String) {
229229
let center = UNUserNotificationCenter.current()
230230
let content = UNMutableNotificationContent()
231231
content.title = title

app/EXO/EXO/Services/NetworkSetupHelper.swift

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ enum NetworkSetupHelper {
1818
1919
set -euo pipefail
2020
21+
# Wait for macOS to finish network setup after boot
22+
sleep 20
23+
2124
PREFS="/Library/Preferences/SystemConfiguration/preferences.plist"
2225
2326
# Remove bridge0 interface
@@ -80,7 +83,7 @@ enum NetworkSetupHelper {
8083
let alert = NSAlert()
8184
alert.messageText = "EXO Network Configuration"
8285
alert.informativeText =
83-
"EXO needs to install a system service to automatically disable Thunderbolt Bridge on startup. This prevents network loops when connecting multiple Macs via Thunderbolt.\n\nYou will be prompted for your administrator password."
86+
"EXO needs to install a system service to configure local networking. This will disable Thunderbolt Bridge (preventing packet storms) and install a Network Location.\n\nYou will be prompted for your password."
8487
alert.alertStyle = .informational
8588
alert.addButton(withTitle: "Install")
8689
alert.addButton(withTitle: "Not Now")
@@ -241,11 +244,11 @@ enum NetworkSetupHelper {
241244
rm -f "$LOG_OUT" "$LOG_ERR"
242245
243246
# Switch back to Automatic network location
244-
networksetup -switchtolocation Automatic 2>/dev/null || true
247+
networksetup -switchtolocation Automatic >/dev/null 2>&1 || true
245248
246249
# Delete the exo network location if it exists
247-
networksetup -listlocations | grep -q '^exo$' && {
248-
networksetup -deletelocation exo 2>/dev/null || true
250+
networksetup -listlocations 2>/dev/null | grep -q '^exo$' && {
251+
networksetup -deletelocation exo >/dev/null 2>&1 || true
249252
} || true
250253
251254
# Re-enable any Thunderbolt Bridge service if it exists
@@ -255,12 +258,12 @@ enum NetworkSetupHelper {
255258
tb_devices=$(networksetup -listallhardwareports 2>/dev/null | awk '
256259
/^Hardware Port:/ { port = tolower(substr($0, 16)) }
257260
/^Device:/ { if (port ~ /thunderbolt/) print substr($0, 9) }
258-
')
261+
') || true
259262
[ -z "$tb_devices" ] && return 0
260263
261264
# For each bridge device, check if it contains Thunderbolt interfaces
262265
for bridge in bridge0 bridge1 bridge2; do
263-
members=$(ifconfig "$bridge" 2>/dev/null | awk '/member:/ {print $2}')
266+
members=$(ifconfig "$bridge" 2>/dev/null | awk '/member:/ {print $2}') || true
264267
[ -z "$members" ] && continue
265268
266269
for tb_dev in $tb_devices; do
@@ -269,16 +272,17 @@ enum NetworkSetupHelper {
269272
service_name=$(networksetup -listnetworkserviceorder 2>/dev/null | awk -v dev="$bridge" '
270273
/^\\([0-9*]/ { gsub(/^\\([0-9*]+\\) /, ""); svc = $0 }
271274
/Device:/ && $0 ~ dev { print svc; exit }
272-
')
275+
') || true
273276
if [ -n "$service_name" ]; then
274277
networksetup -setnetworkserviceenabled "$service_name" on 2>/dev/null || true
275278
return 0
276279
fi
277280
fi
278281
done
279282
done
283+
return 0
280284
}
281-
find_and_enable_thunderbolt_bridge
285+
find_and_enable_thunderbolt_bridge || true
282286
283287
echo "EXO network components removed successfully"
284288
"""

app/EXO/EXO/Services/ThunderboltBridgeService.swift

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -127,21 +127,24 @@ final class ThunderboltBridgeService: ObservableObject {
127127

128128
// 2. Request specific network configuration rights
129129
let rightName = "system.services.systemconfiguration.network"
130-
var item = AuthorizationItem(
131-
name: rightName,
132-
valueLength: 0,
133-
value: nil,
134-
flags: 0
135-
)
136-
var rights = AuthorizationRights(count: 1, items: &item)
137-
138-
status = AuthorizationCopyRights(
139-
authRef,
140-
&rights,
141-
nil,
142-
[.extendRights, .interactionAllowed],
143-
nil
144-
)
130+
status = rightName.withCString { nameCString in
131+
var item = AuthorizationItem(
132+
name: nameCString,
133+
valueLength: 0,
134+
value: nil,
135+
flags: 0
136+
)
137+
return withUnsafeMutablePointer(to: &item) { itemPointer in
138+
var rights = AuthorizationRights(count: 1, items: itemPointer)
139+
return AuthorizationCopyRights(
140+
authRef,
141+
&rights,
142+
nil,
143+
[.extendRights, .interactionAllowed],
144+
nil
145+
)
146+
}
147+
}
145148
guard status == errAuthorizationSuccess else {
146149
if status == errAuthorizationCanceled {
147150
throw ThunderboltBridgeError.authorizationCanceled

0 commit comments

Comments
 (0)