From 7401d0872b86dac5a992c22e83f59974f676af51 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Tue, 25 Nov 2025 09:21:21 -0500 Subject: [PATCH 1/3] fix: resolve watchOS test timing issues and update dependency versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix race condition in stream tests by moving assertions outside confirmation blocks - Update Package.swift to use SundialKit 2.0.0-alpha.1 semantic version - Update documentation to reference alpha versions (SundialKit 2.0.0-alpha.1, SundialKitStream 1.0.0-alpha.1) - Add todo rule to disabled SwiftLint rules The stream tests (pathStatusStream, isExpensiveStream, isConstrainedStream) were failing on watchOS with Xcode 16.4 due to timing issues. Tests now wait for confirmation to complete before verifying captured values, eliminating the race condition that caused "Index out of range" fatal errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .swiftlint.yml | 3 +- Package.resolved | 6 +-- Package.swift | 2 +- README.md | 4 +- .../SundialKitStream.docc/Documentation.md | 4 +- .../NetworkObserverTests+Stream.swift | 45 ++++++++++--------- 6 files changed, 34 insertions(+), 30 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index dcc5def..326f480 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -129,4 +129,5 @@ disabled_rules: - closure_parameter_position - trailing_comma - opening_brace - - pattern_matching_keywords \ No newline at end of file + - pattern_matching_keywords + - todo \ No newline at end of file diff --git a/Package.resolved b/Package.resolved index 0825e70..6a0c3f6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "62a1e254ecceb2245632e6577add8f3f87ad832c4486064376cd6b1b7fd217ab", + "originHash" : "bb26fa541c8043161a229c70d629895a66f222ad1353a6f5a22506d5b8fa4241", "pins" : [ { "identity" : "sundialkit", "kind" : "remoteSourceControl", "location" : "https://github.com/brightdigit/SundialKit.git", "state" : { - "branch" : "v2.0.0", - "revision" : "8f45f90709976bcd13e63c00dfd20b2f7ad98400" + "revision" : "ff0e3f28e61107d26405c05ec1fa9637dbce05ed", + "version" : "2.0.0-alpha.1" } } ], diff --git a/Package.swift b/Package.swift index c7a9427..5b09d31 100644 --- a/Package.swift +++ b/Package.swift @@ -59,7 +59,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/brightdigit/SundialKit.git", branch: "v2.0.0") + .package(url: "https://github.com/brightdigit/SundialKit.git", from: "2.0.0-alpha.1") ], targets: [ .target( diff --git a/README.md b/README.md index 8e5b1d1..a9097d3 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,8 @@ let package = Package( name: "YourPackage", platforms: [.iOS(.v16), .watchOS(.v9), .tvOS(.v16), .macOS(.v13)], dependencies: [ - .package(url: "https://github.com/brightdigit/SundialKit.git", from: "2.0.0"), - .package(url: "https://github.com/brightdigit/SundialKitStream.git", from: "1.0.0") + .package(url: "https://github.com/brightdigit/SundialKit.git", from: "2.0.0-alpha.1"), + .package(url: "https://github.com/brightdigit/SundialKitStream.git", from: "1.0.0-alpha.1") ], targets: [ .target( diff --git a/Sources/SundialKitStream/SundialKitStream.docc/Documentation.md b/Sources/SundialKitStream/SundialKitStream.docc/Documentation.md index f65390c..727584e 100644 --- a/Sources/SundialKitStream/SundialKitStream.docc/Documentation.md +++ b/Sources/SundialKitStream/SundialKitStream.docc/Documentation.md @@ -38,8 +38,8 @@ Add SundialKit to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/brightdigit/SundialKit.git", from: "2.0.0"), - .package(url: "https://github.com/brightdigit/SundialKitStream.git", from: "1.0.0") + .package(url: "https://github.com/brightdigit/SundialKit.git", from: "2.0.0-alpha.1"), + .package(url: "https://github.com/brightdigit/SundialKitStream.git", from: "1.0.0-alpha.1") ], targets: [ .target( diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift b/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift index 3298962..d72dbb5 100644 --- a/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift +++ b/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift @@ -77,9 +77,9 @@ extension NetworkObserverTests { await observer.start(queue: .global()) - try await confirmation("Received path status", expectedCount: 2) { receivedStatus in - let capture = TestValueCapture() + let capture = TestValueCapture() + try await confirmation("Received path status", expectedCount: 2) { receivedStatus in Task { @Sendable in let stream = await observer.pathStatusStream for await status in stream { @@ -99,12 +99,13 @@ extension NetworkObserverTests { // Give time for async delivery try await Task.sleep(for: .milliseconds(50)) - - let statuses = await capture.pathStatuses - #expect(statuses.count == 2) - #expect(statuses[0] == .satisfied(.wiredEthernet)) - #expect(statuses[1] == .unsatisfied(.localNetworkDenied)) } + + // Verify values after confirmation completes + let statuses = await capture.pathStatuses + #expect(statuses.count == 2) + #expect(statuses[0] == .satisfied(.wiredEthernet)) + #expect(statuses[1] == .unsatisfied(.localNetworkDenied)) } @Test("isExpensiveStream tracks expensive status") @@ -114,9 +115,9 @@ extension NetworkObserverTests { await observer.start(queue: .global()) - try await confirmation("Received expensive status", expectedCount: 2) { receivedValue in - let capture = TestValueCapture() + let capture = TestValueCapture() + try await confirmation("Received expensive status", expectedCount: 2) { receivedValue in Task { @Sendable in let stream = await observer.isExpensiveStream for await value in stream { @@ -136,12 +137,13 @@ extension NetworkObserverTests { // Give time for async delivery try await Task.sleep(for: .milliseconds(50)) - - let values = await capture.boolValues - #expect(values.count == 2) - #expect(values[0] == false) - #expect(values[1] == true) } + + // Verify values after confirmation completes + let values = await capture.boolValues + #expect(values.count == 2) + #expect(values[0] == false) + #expect(values[1] == true) } @Test("isConstrainedStream tracks constrained status") @@ -151,9 +153,9 @@ extension NetworkObserverTests { await observer.start(queue: .global()) - try await confirmation("Received constrained status", expectedCount: 2) { receivedValue in - let capture = TestValueCapture() + let capture = TestValueCapture() + try await confirmation("Received constrained status", expectedCount: 2) { receivedValue in Task { @Sendable in let stream = await observer.isConstrainedStream for await value in stream { @@ -173,12 +175,13 @@ extension NetworkObserverTests { // Give time for async delivery try await Task.sleep(for: .milliseconds(50)) - - let values = await capture.boolValues - #expect(values.count == 2) - #expect(values[0] == false) - #expect(values[1] == true) } + + // Verify values after confirmation completes + let values = await capture.boolValues + #expect(values.count == 2) + #expect(values[0] == false) + #expect(values[1] == true) } // MARK: - Multiple Subscribers Tests From c2c715c9c1650b63da32b884d072f5f346b59fdd Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Tue, 25 Nov 2025 09:22:32 -0500 Subject: [PATCH 2/3] docs: add CLAUDE.md with project development guidelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive development documentation for Claude Code including: - Project overview and architecture details - Build, test, and linting commands - Code organization and style guidelines - Testing patterns and requirements - Swift 6.1 concurrency safety guidelines 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ec763d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,198 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +SundialKitStream is a modern Swift 6.1+ async/await observation plugin for SundialKit, providing actor-based observers with AsyncStream APIs for network monitoring and WatchConnectivity communication. The package prioritizes strict concurrency safety, avoiding `@unchecked Sendable` conformances in favor of proper actor isolation. + +## Development Commands + +### Building + +```bash +# Build the package +swift build + +# Build including tests +swift build --build-tests + +# Run tests +swift test +``` + +### Linting + +The project uses a comprehensive linting setup with SwiftLint, swift-format, and periphery (dead code detection): + +```bash +# Run all linting and formatting +./Scripts/lint.sh + +# Format only (skip linting checks) +FORMAT_ONLY=1 ./Scripts/lint.sh + +# Strict mode (used in CI) +LINT_MODE=STRICT ./Scripts/lint.sh +``` + +**Important**: Linting requires `mise` to be installed. The lint script uses mise to manage tool versions via `.mise.toml`. + +### Testing + +```bash +# Run all tests +swift test + +# Run a specific test target +swift test --filter SundialKitStreamTests + +# Run a specific test class +swift test --filter NetworkObserverTests + +# Run a specific test method +swift test --filter NetworkObserverTests.testStreamUpdates +``` + +## Code Architecture + +### Layer Architecture + +SundialKitStream follows a three-layer architecture: + +1. **Core Protocols** (Dependencies from SundialKit package): + - `SundialKitCore` - Base protocols and types (`ActivationState`, `ConnectivityError`, etc.) + - `SundialKitNetwork` - Network monitoring protocols (`PathMonitor`, `NetworkPing`, `PathStatus`) + - `SundialKitConnectivity` - WatchConnectivity protocols (`ConnectivitySession`, `Messagable`) + +2. **Observer Layer** (This package - SundialKitStream): + - Actor-based observers: `NetworkObserver`, `ConnectivityObserver` + - AsyncStream-based state delivery + - Manages continuations and distributes updates to multiple subscribers + +### Core Observers + +#### NetworkObserver + +Actor-based network connectivity monitoring with AsyncStream APIs: + +- **Generic over**: `PathMonitor` (typically `NWPathMonitorAdapter`) and `NetworkPing` +- **Streams**: `pathStatusStream`, `isExpensiveStream`, `isConstrainedStream` +- **Thread Safety**: Actor isolation ensures safe concurrent access +- **Location**: Sources/SundialKitStream/NetworkObserver.swift + +#### ConnectivityObserver + +Actor-based WatchConnectivity communication with AsyncStream APIs: + +- **Protocols**: Conforms to `ConnectivitySessionDelegate`, `StateHandling`, `MessageHandling` +- **Key Components**: + - `ConnectivityStateManager` - Manages activation, reachability, and pairing state + - `StreamContinuationManager` - Centralized continuation management for all stream types + - `MessageDistributor` - Handles incoming message distribution to subscribers + - `MessageRouter` - Routes outgoing messages through appropriate transports +- **Streams**: `activationStates()`, `activationCompletionStream()`, `reachabilityStream()`, `messageStream()`, `typedMessageStream()` +- **Location**: Sources/SundialKitStream/ConnectivityObserver.swift + +### Key Architectural Patterns + +#### AsyncStream Continuation Management + +The codebase uses a centralized continuation management pattern via `StreamContinuationManager`: + +- Each stream type has its own continuation dictionary keyed by UUID +- Registration asserts prevent duplicate continuations +- Removal asserts catch programming errors +- Yielding iterates all registered continuations for fan-out distribution +- **Location**: Sources/SundialKitStream/StreamContinuationManager.swift + +#### State Management + +`ConnectivityStateManager` coordinates state updates with stream notifications: + +- Maintains `ConnectivityState` with activation, reachability, and pairing information +- Synchronizes state updates with continuation notifications +- Provides read-only snapshot accessors for current state +- **Location**: Sources/SundialKitStream/ConnectivityStateManager.swift + +#### Message Handling + +Message flow uses a routing and distribution pattern: + +- `MessageRouter` - Selects appropriate transport (interactive message, application context, etc.) +- `MessageDispatcher` - Transforms protocol-level callbacks into async operations +- `MessageDistributor` - Distributes received messages to all active stream subscribers +- Supports both untyped (`[String: any Sendable]`) and typed (`Messagable`) messages + +## Swift Version and Compiler Settings + +This package requires **Swift 6.1+** and enables extensive experimental features: + +- **Swift 6.2 Upcoming Features**: ExistentialAny, InternalImportsByDefault, MemberImportVisibility, FullTypedThrows +- **Experimental Features**: BitwiseCopyable, BorrowingSwitch, NoncopyableGenerics, TransferringArgsAndResults, VariadicGenerics, and many more (see Package.swift:8-34) +- **Strict Concurrency**: The project operates in Swift 6 strict concurrency mode + +When writing new code, ensure: +- All public types properly declare their concurrency characteristics (`actor`, `@MainActor`, `Sendable`) +- No `@unchecked Sendable` conformances are added +- AsyncStream continuations are managed through `StreamContinuationManager` + +## File Organization + +Source files use functional organization with extensions: + +- Main type definition: `TypeName.swift` +- Extensions by functionality: `TypeName+Functionality.swift` +- Example: `ConnectivityObserver.swift`, `ConnectivityObserver+Lifecycle.swift`, `ConnectivityObserver+Messaging.swift`, `ConnectivityObserver+Streams.swift` + +Tests follow a similar pattern with hierarchical organization: + +- Test suites: `TypeName.swift`, `TypeName+Category.swift` +- Individual test files: `TypeName.Category.SpecificTests.swift` +- Example: `ConnectivityStateManager.swift`, `ConnectivityStateManager.State.swift`, `ConnectivityStateManager.State.UpdateTests.swift` + +## Code Style and Linting + +The project enforces strict code quality standards: + +- **File Length**: Warning at 225 lines, error at 300 lines +- **Function Body Length**: Warning at 50 lines, error at 76 lines +- **Line Length**: Warning at 108 characters, error at 200 characters +- **Cyclomatic Complexity**: Warning at 6, error at 12 +- **Indentation**: 2 spaces (configured in .swiftlint.yml:120) +- **Access Control**: Explicit access levels required (`explicit_acl`, `explicit_top_level_acl`) +- **File Headers**: All source files require proper copyright headers (enforced by Scripts/header.sh) + +Disabled rules: +- `nesting`, `implicit_getter`, `switch_case_alignment`, `closure_parameter_position`, `trailing_comma`, `opening_brace`, `pattern_matching_keywords`, `todo` + +## Dependencies + +This package depends on SundialKit v2.0.0+ which provides three products: + +- `SundialKitCore` - Core protocols and types +- `SundialKitNetwork` - Network monitoring abstractions over Apple's Network framework +- `SundialKitConnectivity` - WatchConnectivity abstractions + +## Platform Support + +- iOS 16+ +- watchOS 9+ +- tvOS 16+ +- macOS 13+ + +## Testing Patterns + +Tests use mock implementations for protocol-based abstractions: + +- `MockPathMonitor` - Simulates network path changes +- `MockNetworkPing` - Simulates ping operations +- `MockConnectivitySession` - Simulates WatchConnectivity behavior +- `TestValueCapture` - Captures async stream values for testing + +When writing tests: +- Use actor-isolated test methods when testing actors +- Capture stream values with async task groups or `TestValueCapture` +- Test both success and error paths for `Result`-based streams (e.g., `activationCompletionStream()`) +- In order to run the builds and tests for iOS or watchOS, use xcodebuild. +- Don't use swift package generate-xcodeproj \ No newline at end of file From 305b5cec9a46d0dcb005428b25eb0bd1847b89a7 Mon Sep 17 00:00:00 2001 From: leogdion Date: Tue, 25 Nov 2025 09:49:34 -0500 Subject: [PATCH 3/3] reverting fix --- .../NetworkObserverTests+Stream.swift | 45 +++++++++---------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift b/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift index d72dbb5..3298962 100644 --- a/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift +++ b/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift @@ -77,9 +77,9 @@ extension NetworkObserverTests { await observer.start(queue: .global()) - let capture = TestValueCapture() - try await confirmation("Received path status", expectedCount: 2) { receivedStatus in + let capture = TestValueCapture() + Task { @Sendable in let stream = await observer.pathStatusStream for await status in stream { @@ -99,13 +99,12 @@ extension NetworkObserverTests { // Give time for async delivery try await Task.sleep(for: .milliseconds(50)) - } - // Verify values after confirmation completes - let statuses = await capture.pathStatuses - #expect(statuses.count == 2) - #expect(statuses[0] == .satisfied(.wiredEthernet)) - #expect(statuses[1] == .unsatisfied(.localNetworkDenied)) + let statuses = await capture.pathStatuses + #expect(statuses.count == 2) + #expect(statuses[0] == .satisfied(.wiredEthernet)) + #expect(statuses[1] == .unsatisfied(.localNetworkDenied)) + } } @Test("isExpensiveStream tracks expensive status") @@ -115,9 +114,9 @@ extension NetworkObserverTests { await observer.start(queue: .global()) - let capture = TestValueCapture() - try await confirmation("Received expensive status", expectedCount: 2) { receivedValue in + let capture = TestValueCapture() + Task { @Sendable in let stream = await observer.isExpensiveStream for await value in stream { @@ -137,13 +136,12 @@ extension NetworkObserverTests { // Give time for async delivery try await Task.sleep(for: .milliseconds(50)) - } - // Verify values after confirmation completes - let values = await capture.boolValues - #expect(values.count == 2) - #expect(values[0] == false) - #expect(values[1] == true) + let values = await capture.boolValues + #expect(values.count == 2) + #expect(values[0] == false) + #expect(values[1] == true) + } } @Test("isConstrainedStream tracks constrained status") @@ -153,9 +151,9 @@ extension NetworkObserverTests { await observer.start(queue: .global()) - let capture = TestValueCapture() - try await confirmation("Received constrained status", expectedCount: 2) { receivedValue in + let capture = TestValueCapture() + Task { @Sendable in let stream = await observer.isConstrainedStream for await value in stream { @@ -175,13 +173,12 @@ extension NetworkObserverTests { // Give time for async delivery try await Task.sleep(for: .milliseconds(50)) - } - // Verify values after confirmation completes - let values = await capture.boolValues - #expect(values.count == 2) - #expect(values[0] == false) - #expect(values[1] == true) + let values = await capture.boolValues + #expect(values.count == 2) + #expect(values[0] == false) + #expect(values[1] == true) + } } // MARK: - Multiple Subscribers Tests