mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 16:45:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
930223c1ee |
@@ -142,6 +142,9 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Check clean recipe safety
|
||||||
|
run: bash scripts/check-just-clean-safety.sh
|
||||||
|
|
||||||
- name: Build iOS (simulator, no signing)
|
- name: Build iOS (simulator, no signing)
|
||||||
# Build both simulator architectures so CI validates every vendored
|
# Build both simulator architectures so CI validates every vendored
|
||||||
# Arti simulator slice and the configuration that ships.
|
# Arti simulator slice and the configuration that ships.
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ DEVELOPMENT_TEAM = ABC123
|
|||||||
|
|
||||||
// Unique bundle id to be able to register and run locally
|
// Unique bundle id to be able to register and run locally
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM)
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM)
|
||||||
|
|
||||||
|
// App and share extension must use an App Group registered to your team.
|
||||||
|
APP_GROUP_ID = group.chat.bitchat.$(DEVELOPMENT_TEAM)
|
||||||
|
|||||||
@@ -1,107 +1,66 @@
|
|||||||
# BitChat macOS Build Justfile
|
# BitChat developer commands
|
||||||
# Handles temporary modifications needed to build and run on macOS
|
#
|
||||||
|
# Builds use a repository-local, ignored DerivedData directory. No recipe
|
||||||
|
# patches, restores, or removes tracked project/configuration files.
|
||||||
|
|
||||||
|
project := "bitchat.xcodeproj"
|
||||||
|
macos_scheme := "bitchat (macOS)"
|
||||||
|
ios_scheme := "bitchat (iOS)"
|
||||||
|
derived_data := ".DerivedData"
|
||||||
|
|
||||||
# Default recipe - shows available commands
|
|
||||||
default:
|
default:
|
||||||
@echo "BitChat macOS Build Commands:"
|
@echo "BitChat developer commands:"
|
||||||
@echo " just run - Build and run the macOS app"
|
@echo " just run Build and run the macOS app"
|
||||||
@echo " just build - Build the macOS app only"
|
@echo " just build Build the macOS app without signing"
|
||||||
@echo " just clean - Clean build artifacts and restore original files"
|
@echo " just test Run the SwiftPM test suite"
|
||||||
@echo " just check - Check prerequisites"
|
@echo " just test-ios Run tests on the iPhone 17 simulator"
|
||||||
@echo ""
|
@echo " just clean Remove repo-local build artifacts only"
|
||||||
@echo "Original files are preserved - modifications are temporary for builds only"
|
@echo " just nuke Also remove nested package build caches"
|
||||||
|
@echo " just check Validate the development environment"
|
||||||
|
|
||||||
# Check prerequisites
|
# Static guard against reintroducing source-restoring or source-deleting clean
|
||||||
check:
|
# behavior. CI runs the same script directly.
|
||||||
|
check-clean-safety:
|
||||||
|
@bash scripts/check-just-clean-safety.sh
|
||||||
|
|
||||||
|
check: check-clean-safety
|
||||||
@echo "Checking prerequisites..."
|
@echo "Checking prerequisites..."
|
||||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
|
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install full Xcode." && exit 1)
|
||||||
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
@developer_dir="$$(xcode-select -p 2>/dev/null)"; case "$$developer_dir" in *.app/Contents/Developer) ;; *) echo "❌ Full Xcode is not selected. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; exit 1;; esac
|
||||||
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
|
@xcodebuild -version
|
||||||
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
@echo "✅ Development environment ready (a signing identity is not required for just build)"
|
||||||
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
|
||||||
@echo "✅ All prerequisites met"
|
|
||||||
|
|
||||||
# Backup original files
|
build: check
|
||||||
backup:
|
|
||||||
@echo "Backing up original project configuration..."
|
|
||||||
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
|
|
||||||
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
|
|
||||||
|
|
||||||
# Restore original files
|
|
||||||
restore:
|
|
||||||
@echo "Restoring original project configuration..."
|
|
||||||
@if [ -f project.yml.backup ]; then mv project.yml.backup project.yml; fi
|
|
||||||
@# Restore iOS-specific files
|
|
||||||
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
|
|
||||||
@# Use git to restore all modified files except Justfile
|
|
||||||
@git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Could not restore some files with git"
|
|
||||||
@# Remove any backup files
|
|
||||||
@rm -f bitchat.xcodeproj/project.pbxproj.backup bitchat/Info.plist.backup 2>/dev/null || true
|
|
||||||
|
|
||||||
# Apply macOS-specific modifications
|
|
||||||
patch-for-macos: backup
|
|
||||||
@echo "Temporarily hiding iOS-specific files for macOS build..."
|
|
||||||
@# Move iOS-specific files out of the way temporarily
|
|
||||||
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
|
|
||||||
|
|
||||||
# Build the macOS app
|
|
||||||
build: #check generate
|
|
||||||
@echo "Building BitChat for macOS..."
|
@echo "Building BitChat for macOS..."
|
||||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
@xcodebuild -project "{{project}}" -scheme "{{macos_scheme}}" -configuration Debug -derivedDataPath "{{derived_data}}" CODE_SIGNING_ALLOWED=NO build
|
||||||
|
|
||||||
# Run the macOS app
|
|
||||||
run: build
|
run: build
|
||||||
@echo "Launching BitChat..."
|
@app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$$app" || (echo "❌ Built app not found at $$app" && exit 1); open "$$app"
|
||||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
|
||||||
|
|
||||||
# Clean build artifacts and restore original files
|
# Backward-compatible alias for the old quick-run recipe.
|
||||||
clean: restore
|
dev-run: run
|
||||||
@echo "Cleaning build artifacts..."
|
|
||||||
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
|
|
||||||
@# Only remove the generated project if we have a backup, otherwise use git
|
|
||||||
@if [ -f bitchat.xcodeproj/project.pbxproj.backup ]; then \
|
|
||||||
rm -rf bitchat.xcodeproj; \
|
|
||||||
else \
|
|
||||||
git checkout -- bitchat.xcodeproj/project.pbxproj 2>/dev/null || echo "⚠️ Could not restore project.pbxproj"; \
|
|
||||||
fi
|
|
||||||
@rm -f project-macos.yml 2>/dev/null || true
|
|
||||||
@echo "✅ Cleaned and restored original files"
|
|
||||||
|
|
||||||
# Quick run without cleaning (for development)
|
test:
|
||||||
dev-run: check
|
@swift test
|
||||||
@echo "Quick development build..."
|
|
||||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
test-ios: check
|
||||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
@xcodebuild -project "{{project}}" -scheme "{{ios_scheme}}" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -derivedDataPath "{{derived_data}}" test
|
||||||
|
|
||||||
|
# Artifact-only cleanup. In particular, this recipe never invokes Git and
|
||||||
|
# never writes, moves, restores, or removes source/configuration files.
|
||||||
|
clean:
|
||||||
|
@echo "Cleaning repo-local build artifacts..."
|
||||||
|
@rm -rf -- "{{derived_data}}" ".build"
|
||||||
|
@echo "✅ Cleaned {{derived_data}} and .build; tracked files were untouched"
|
||||||
|
|
||||||
|
# Retain the familiar command, but keep it artifact-only as well.
|
||||||
|
nuke: clean
|
||||||
|
@echo "Cleaning nested package build caches..."
|
||||||
|
@find localPackages -type d -name .build -prune -exec rm -rf -- {} +
|
||||||
|
@rm -rf -- ".cache"
|
||||||
|
@echo "✅ Removed repository build caches; tracked files were untouched"
|
||||||
|
|
||||||
# Show app info
|
|
||||||
info:
|
info:
|
||||||
@echo "BitChat - Decentralized Mesh Messaging"
|
@echo "BitChat - decentralized mesh messaging"
|
||||||
@echo "======================================"
|
@echo "macOS 13+ and iOS 16+"
|
||||||
@echo "• Native macOS SwiftUI app"
|
@echo "Bluetooth mesh behavior requires physical Bluetooth-capable devices"
|
||||||
@echo "• Bluetooth LE mesh networking"
|
|
||||||
@echo "• End-to-end encryption"
|
|
||||||
@echo "• No internet required"
|
|
||||||
@echo "• Works offline with nearby devices"
|
|
||||||
@echo ""
|
|
||||||
@echo "Requirements:"
|
|
||||||
@echo "• macOS 13.0+ (Ventura)"
|
|
||||||
@echo "• Bluetooth LE capable Mac"
|
|
||||||
@echo "• Physical device (no simulator support)"
|
|
||||||
@echo ""
|
|
||||||
@echo "Usage:"
|
|
||||||
@echo "• Set nickname and start chatting"
|
|
||||||
@echo "• Use /join #channel for group chats"
|
|
||||||
@echo "• Use /msg @user for private messages"
|
|
||||||
@echo "• Triple-tap logo for emergency wipe"
|
|
||||||
|
|
||||||
# Force clean everything (nuclear option)
|
|
||||||
nuke:
|
|
||||||
@echo "🧨 Nuclear clean - removing all build artifacts and backups..."
|
|
||||||
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
|
|
||||||
@rm -rf bitchat.xcodeproj 2>/dev/null || true
|
|
||||||
@rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true
|
|
||||||
@rm -f bitchat/Info.plist.backup 2>/dev/null || true
|
|
||||||
@# Restore iOS-specific files if they were moved
|
|
||||||
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
|
|
||||||
@git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
|
|
||||||
@echo "✅ Nuclear clean complete"
|
|
||||||
|
|||||||
@@ -93,30 +93,62 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
|||||||
|
|
||||||
### Option 1: Using Xcode
|
### Option 1: Using Xcode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd bitchat
|
open bitchat.xcodeproj
|
||||||
open bitchat.xcodeproj
|
```
|
||||||
```
|
|
||||||
|
|
||||||
To run on a device there're a few steps to prepare the code:
|
For a signed device build, create your ignored local configuration and replace
|
||||||
- Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig`
|
the example team ID with your Apple Developer Team ID:
|
||||||
- Add your Developer Team ID into the newly created `Configs/Local.xcconfig`
|
|
||||||
- Bundle ID would be set to `chat.bitchat.<team_id>` (unless you set to something else)
|
```bash
|
||||||
- Entitlements need to be updated manually (TODO: Automate):
|
cp Configs/Local.xcconfig.example Configs/Local.xcconfig
|
||||||
- Search and replace `group.chat.bitchat` with `group.<your_bundle_id>` (e.g. `group.chat.bitchat.ABC123`)
|
```
|
||||||
|
|
||||||
|
`Local.xcconfig.example` derives unique app and App Group identifiers from that
|
||||||
|
team ID. The entitlement files already reference `$(APP_GROUP_ID)`, so tracked
|
||||||
|
project or entitlement files do not need to be edited.
|
||||||
|
|
||||||
|
Useful command-line checks from the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS Debug build without signing
|
||||||
|
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" \
|
||||||
|
-configuration Debug CODE_SIGNING_ALLOWED=NO build
|
||||||
|
|
||||||
|
# Full SwiftPM test suite
|
||||||
|
swift test
|
||||||
|
|
||||||
|
# iOS simulator tests
|
||||||
|
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" \
|
||||||
|
-sdk iphonesimulator \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17' test
|
||||||
|
```
|
||||||
|
|
||||||
|
If `iPhone 17` is unavailable, choose an installed simulator from:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild -showdestinations -project bitchat.xcodeproj -scheme "bitchat (iOS)"
|
||||||
|
```
|
||||||
|
|
||||||
### Option 2: Using `just`
|
### Option 2: Using `just`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install just
|
brew install just
|
||||||
```
|
just check
|
||||||
|
just run
|
||||||
|
```
|
||||||
|
|
||||||
Want to try this on macos: `just run` will set it up and run from source.
|
`just build` and `just run` use the current `bitchat (macOS)` scheme and keep
|
||||||
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
Xcode output in the ignored `.DerivedData/` directory. They never patch source,
|
||||||
|
project, configuration, or entitlement files.
|
||||||
|
|
||||||
|
`just clean` removes only `.DerivedData/` and `.build/`. It does not invoke Git
|
||||||
|
or restore tracked files, so uncommitted work is preserved. `just test` runs the
|
||||||
|
SwiftPM suite and `just test-ios` runs the iPhone 17 simulator suite.
|
||||||
|
|
||||||
## Localization
|
## Localization
|
||||||
|
|
||||||
- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.
|
- App localizations live in `bitchat/Localizable.xcstrings`.
|
||||||
- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.
|
- Share extension strings are separate in `bitchatShareExtension/Localization/Localizable.xcstrings`.
|
||||||
- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.
|
- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.
|
||||||
- Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.
|
- Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.
|
||||||
|
|||||||
@@ -39,17 +39,15 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
@Published private(set) var messages: [BitchatMessage] = []
|
@Published private(set) var messages: [BitchatMessage] = []
|
||||||
@Published private(set) var isUnread: Bool = false
|
@Published private(set) var isUnread: Bool = false
|
||||||
|
|
||||||
/// Incrementally-maintained message-ID → logical-index map for O(1)
|
/// Incrementally-maintained message-ID → index map for O(1) dedup and
|
||||||
/// dedup and delivery-status lookup. Logical indexes are physical array
|
/// delivery-status lookup. Kept in sync on every mutation:
|
||||||
/// indexes plus `indexOffset`; trimming from the head advances the offset
|
/// - tail append: single insert
|
||||||
/// instead of rewriting every surviving dictionary entry. This matters
|
/// - out-of-order insert: suffix reindex from the insertion point
|
||||||
/// after the 1337-message cap is reached, when every steady-state tail
|
/// - trim: full rebuild — `removeFirst(k)` is already O(n), so the
|
||||||
/// append evicts one old row.
|
/// rebuild does not change the asymptotics, and trim only happens once
|
||||||
///
|
/// the cap (1337) is reached. Simple and correct beats the
|
||||||
/// Out-of-order inserts and middle removals still reindex only the
|
/// offset-tracking alternative here.
|
||||||
/// affected suffix. Full filtering resets the offset while rebuilding.
|
|
||||||
private var indexByMessageID: [String: Int] = [:]
|
private var indexByMessageID: [String: Int] = [:]
|
||||||
private var indexOffset = 0
|
|
||||||
|
|
||||||
fileprivate init(id: ConversationID, cap: Int) {
|
fileprivate init(id: ConversationID, cap: Int) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -63,7 +61,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func message(withID messageID: String) -> BitchatMessage? {
|
func message(withID messageID: String) -> BitchatMessage? {
|
||||||
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
guard let index = indexByMessageID[messageID] else { return nil }
|
||||||
return messages[index]
|
return messages[index]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +101,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
reindex(from: index)
|
reindex(from: index)
|
||||||
} else {
|
} else {
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
indexByMessageID[message.id] = messages.count - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
||||||
@@ -113,7 +111,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// timeline position (in-place updates like media progress reuse the
|
/// timeline position (in-place updates like media progress reuse the
|
||||||
/// original timestamp); a new message goes through ordered insertion.
|
/// original timestamp); a new message goes through ordered insertion.
|
||||||
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
||||||
if let index = physicalIndex(forMessageID: message.id) {
|
if let index = indexByMessageID[message.id] {
|
||||||
messages[index] = message
|
messages[index] = message
|
||||||
return .updated
|
return .updated
|
||||||
}
|
}
|
||||||
@@ -127,7 +125,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
||||||
/// Returns `true` when the status was applied.
|
/// Returns `true` when the status was applied.
|
||||||
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
guard let index = indexByMessageID[messageID] else { return false }
|
||||||
let message = messages[index]
|
let message = messages[index]
|
||||||
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
||||||
|
|
||||||
@@ -144,7 +142,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// observers still need an @Published emission to re-render.
|
/// observers still need an @Published emission to re-render.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
||||||
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
guard let index = indexByMessageID[messageID] else { return false }
|
||||||
messages[index] = messages[index]
|
messages[index] = messages[index]
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -159,14 +157,10 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// Removes a single message by ID. Returns the removed message, or
|
/// Removes a single message by ID. Returns the removed message, or
|
||||||
/// `nil` when no message with that ID exists.
|
/// `nil` when no message with that ID exists.
|
||||||
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
||||||
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
guard let index = indexByMessageID[messageID] else { return nil }
|
||||||
let removed = messages.remove(at: index)
|
let removed = messages.remove(at: index)
|
||||||
indexByMessageID.removeValue(forKey: messageID)
|
indexByMessageID.removeValue(forKey: messageID)
|
||||||
if index == 0 {
|
reindex(from: index)
|
||||||
indexOffset += 1
|
|
||||||
} else {
|
|
||||||
reindex(from: index)
|
|
||||||
}
|
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +177,6 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
for id in removedIDs {
|
for id in removedIDs {
|
||||||
indexByMessageID.removeValue(forKey: id)
|
indexByMessageID.removeValue(forKey: id)
|
||||||
}
|
}
|
||||||
indexOffset = 0
|
|
||||||
reindex(from: 0)
|
reindex(from: 0)
|
||||||
return removedIDs
|
return removedIDs
|
||||||
}
|
}
|
||||||
@@ -191,7 +184,6 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
fileprivate func clearMessages() {
|
fileprivate func clearMessages() {
|
||||||
messages.removeAll()
|
messages.removeAll()
|
||||||
indexByMessageID.removeAll()
|
indexByMessageID.removeAll()
|
||||||
indexOffset = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Diagnostics
|
// MARK: Diagnostics
|
||||||
@@ -213,10 +205,9 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
let message = messages[position]
|
let message = messages[position]
|
||||||
// Count equality + every message resolving to its own position
|
// Count equality + every message resolving to its own position
|
||||||
// proves the index is exactly the inverse map (no stale extras).
|
// proves the index is exactly the inverse map (no stale extras).
|
||||||
if let logicalIndex = indexByMessageID[message.id] {
|
if let index = indexByMessageID[message.id] {
|
||||||
let expectedIndex = indexOffset + position
|
if index != position {
|
||||||
if logicalIndex != expectedIndex {
|
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
|
||||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(logicalIndex - indexOffset)")
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
||||||
@@ -278,17 +269,10 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
|
|
||||||
private func reindex(from start: Int) {
|
private func reindex(from start: Int) {
|
||||||
for index in start..<messages.count {
|
for index in start..<messages.count {
|
||||||
indexByMessageID[messages[index].id] = indexOffset + index
|
indexByMessageID[messages[index].id] = index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func physicalIndex(forMessageID messageID: String) -> Int? {
|
|
||||||
guard let logicalIndex = indexByMessageID[messageID] else { return nil }
|
|
||||||
let index = logicalIndex - indexOffset
|
|
||||||
guard messages.indices.contains(index) else { return nil }
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
||||||
private func trimIfNeeded() -> [String] {
|
private func trimIfNeeded() -> [String] {
|
||||||
guard messages.count > cap else { return [] }
|
guard messages.count > cap else { return [] }
|
||||||
@@ -298,7 +282,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
indexByMessageID.removeValue(forKey: id)
|
indexByMessageID.removeValue(forKey: id)
|
||||||
}
|
}
|
||||||
messages.removeFirst(overflow)
|
messages.removeFirst(overflow)
|
||||||
indexOffset += overflow
|
reindex(from: 0)
|
||||||
return trimmedIDs
|
return trimmedIDs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -860,8 +844,8 @@ extension Conversation {
|
|||||||
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
||||||
func _testCorruptIndexEntries() {
|
func _testCorruptIndexEntries() {
|
||||||
guard messages.count >= 2 else { return }
|
guard messages.count >= 2 else { return }
|
||||||
indexByMessageID[messages[0].id] = indexOffset + 1
|
indexByMessageID[messages[0].id] = 1
|
||||||
indexByMessageID[messages[1].id] = indexOffset
|
indexByMessageID[messages[1].id] = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops a message's index entry entirely (count mismatch + missing).
|
/// Drops a message's index entry entirely (count mismatch + missing).
|
||||||
@@ -875,8 +859,8 @@ extension Conversation {
|
|||||||
func _testCorruptOrderingPreservingIndex() {
|
func _testCorruptOrderingPreservingIndex() {
|
||||||
guard messages.count >= 2 else { return }
|
guard messages.count >= 2 else { return }
|
||||||
messages.swapAt(0, messages.count - 1)
|
messages.swapAt(0, messages.count - 1)
|
||||||
indexByMessageID[messages[0].id] = indexOffset
|
indexByMessageID[messages[0].id] = 0
|
||||||
indexByMessageID[messages[messages.count - 1].id] = indexOffset + messages.count - 1
|
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,7 +900,7 @@ extension ConversationStore {
|
|||||||
extension Conversation {
|
extension Conversation {
|
||||||
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
indexByMessageID[message.id] = messages.count - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -45,154 +45,6 @@ private func makeDirectConversationID(_ suffix: String) -> ConversationID {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deliberately simple O(n) model used to differentially test the store's
|
|
||||||
/// optimized logical-index bookkeeping. It models observable behavior only;
|
|
||||||
/// it has no offset or ID index and therefore cannot reproduce the same bug.
|
|
||||||
private struct ReferenceConversationTimeline {
|
|
||||||
struct Message: Equatable {
|
|
||||||
let id: String
|
|
||||||
let timestamp: Date
|
|
||||||
let content: String
|
|
||||||
var deliveryStatus: DeliveryStatus?
|
|
||||||
|
|
||||||
init(_ message: BitchatMessage) {
|
|
||||||
id = message.id
|
|
||||||
timestamp = message.timestamp
|
|
||||||
content = message.content
|
|
||||||
deliveryStatus = message.deliveryStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AppendResult {
|
|
||||||
let inserted: Bool
|
|
||||||
let trimmedCount: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
let cap: Int
|
|
||||||
private(set) var messages: [Message] = []
|
|
||||||
|
|
||||||
func contains(_ id: String) -> Bool {
|
|
||||||
messages.contains { $0.id == id }
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func append(_ message: BitchatMessage) -> AppendResult {
|
|
||||||
guard !contains(message.id) else {
|
|
||||||
return AppendResult(inserted: false, trimmedCount: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
let snapshot = Message(message)
|
|
||||||
var low = 0
|
|
||||||
var high = messages.count
|
|
||||||
while low < high {
|
|
||||||
let mid = (low + high) / 2
|
|
||||||
if messages[mid].timestamp <= snapshot.timestamp {
|
|
||||||
low = mid + 1
|
|
||||||
} else {
|
|
||||||
high = mid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
messages.insert(snapshot, at: low)
|
|
||||||
|
|
||||||
let overflow = max(0, messages.count - cap)
|
|
||||||
if overflow > 0 {
|
|
||||||
messages.removeFirst(overflow)
|
|
||||||
}
|
|
||||||
return AppendResult(inserted: true, trimmedCount: overflow)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func upsert(_ message: BitchatMessage) -> Int {
|
|
||||||
if let index = messages.firstIndex(where: { $0.id == message.id }) {
|
|
||||||
messages[index] = Message(message)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return append(message).trimmedCount
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func applyDeliveryStatus(_ status: DeliveryStatus, to id: String) -> Bool {
|
|
||||||
guard let index = messages.firstIndex(where: { $0.id == id }),
|
|
||||||
messages[index].deliveryStatus != status else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// The differential stream uses only unique `.delivered` values (or
|
|
||||||
// an exact repeat), so no-downgrade policy is intentionally outside
|
|
||||||
// this index-focused reference model.
|
|
||||||
messages[index].deliveryStatus = status
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func remove(at index: Int) -> Message {
|
|
||||||
messages.remove(at: index)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeAll(where predicate: (Message) -> Bool) {
|
|
||||||
messages.removeAll(where: predicate)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func clear() {
|
|
||||||
messages.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ConversationStoreDifferentialRNG {
|
|
||||||
private var state: UInt64
|
|
||||||
|
|
||||||
init(seed: UInt64) {
|
|
||||||
state = seed
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func next() -> UInt64 {
|
|
||||||
state &+= 0x9E37_79B9_7F4A_7C15
|
|
||||||
var value = state
|
|
||||||
value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
|
||||||
value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB
|
|
||||||
return value ^ (value >> 31)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func index(upperBound: Int) -> Int {
|
|
||||||
precondition(upperBound > 0)
|
|
||||||
return Int(next() % UInt64(upperBound))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func expectStore(
|
|
||||||
_ store: ConversationStore,
|
|
||||||
matches reference: ReferenceConversationTimeline,
|
|
||||||
issuedIDs: [String],
|
|
||||||
checkpoint: String
|
|
||||||
) {
|
|
||||||
let conversation = store.conversation(for: .mesh)
|
|
||||||
let actual = conversation.messages.map(ReferenceConversationTimeline.Message.init)
|
|
||||||
#expect(actual == reference.messages, "timeline mismatch at \(checkpoint)")
|
|
||||||
|
|
||||||
let lookupSnapshot = reference.messages.compactMap { expected in
|
|
||||||
conversation.message(withID: expected.id).map(ReferenceConversationTimeline.Message.init)
|
|
||||||
}
|
|
||||||
#expect(lookupSnapshot == reference.messages, "ID lookup mismatch at \(checkpoint)")
|
|
||||||
#expect(
|
|
||||||
Set(conversation.messageIDs) == Set(reference.messages.map(\.id)),
|
|
||||||
"per-conversation ID set mismatch at \(checkpoint)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if !reference.messages.isEmpty {
|
|
||||||
for index in Set([0, reference.messages.count / 2, reference.messages.count - 1]) {
|
|
||||||
let id = reference.messages[index].id
|
|
||||||
#expect(store.conversationIDs(forMessageID: id) == [.mesh], "store ID map mismatch at \(checkpoint)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let activeIDs = Set(reference.messages.map(\.id))
|
|
||||||
var checkedStaleIDs = 0
|
|
||||||
for id in issuedIDs.reversed() where !activeIDs.contains(id) {
|
|
||||||
#expect(conversation.message(withID: id) == nil, "stale conversation index entry at \(checkpoint)")
|
|
||||||
#expect(store.conversationIDs(forMessageID: id).isEmpty, "stale store ID map entry at \(checkpoint)")
|
|
||||||
checkedStaleIDs += 1
|
|
||||||
if checkedStaleIDs == 16 { break }
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(store.auditInvariants().isEmpty, "invariant audit failed at \(checkpoint)")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suite("ConversationStore")
|
@Suite("ConversationStore")
|
||||||
struct ConversationStoreTests {
|
struct ConversationStoreTests {
|
||||||
|
|
||||||
@@ -288,282 +140,6 @@ struct ConversationStoreTests {
|
|||||||
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("steady-state cap trimming keeps lookups exact across mixed mutations")
|
|
||||||
@MainActor
|
|
||||||
func steadyStateCapTrimmingKeepsLogicalIndexExact() {
|
|
||||||
let store = ConversationStore()
|
|
||||||
let conversation = store.conversation(for: .mesh)
|
|
||||||
let overflow = 64
|
|
||||||
|
|
||||||
for i in 0..<(conversation.cap + overflow) {
|
|
||||||
store.append(makeMessage(id: "m\(i)", timestamp: TimeInterval(i)), to: .mesh)
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(conversation.messages.first?.id == "m\(overflow)")
|
|
||||||
#expect(conversation.message(withID: "m\(overflow)")?.id == "m\(overflow)")
|
|
||||||
|
|
||||||
// Exercise a suffix reindex after the head offset has advanced, then
|
|
||||||
// trim the old head. The late row becomes the new first element.
|
|
||||||
let late = makeMessage(id: "late", timestamp: TimeInterval(overflow) + 0.5)
|
|
||||||
#expect(store.append(late, to: .mesh))
|
|
||||||
#expect(conversation.messages.first?.id == "late")
|
|
||||||
#expect(conversation.message(withID: "m\(overflow + 1)")?.id == "m\(overflow + 1)")
|
|
||||||
|
|
||||||
// Head and middle removals, an in-place upsert, and a status update
|
|
||||||
// must all resolve through the same logical index representation.
|
|
||||||
#expect(store.removeMessage(withID: "late", from: .mesh)?.id == "late")
|
|
||||||
let middleID = "m\(overflow + conversation.cap / 2)"
|
|
||||||
#expect(store.removeMessage(withID: middleID, from: .mesh)?.id == middleID)
|
|
||||||
|
|
||||||
let probeID = "m\(overflow + 10)"
|
|
||||||
store.upsertByID(
|
|
||||||
makeMessage(id: probeID, timestamp: TimeInterval(overflow + 10), content: "edited"),
|
|
||||||
in: .mesh
|
|
||||||
)
|
|
||||||
#expect(conversation.message(withID: probeID)?.content == "edited")
|
|
||||||
#expect(store.setDeliveryStatus(.sent, forMessageID: probeID, in: .mesh))
|
|
||||||
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
|
||||||
#expect(store.auditInvariants().isEmpty)
|
|
||||||
|
|
||||||
// Clearing resets the logical offset as well as the maps.
|
|
||||||
store.clear(.mesh)
|
|
||||||
#expect(store.append(makeMessage(id: "after-clear", timestamp: 10_000), to: .mesh))
|
|
||||||
#expect(conversation.message(withID: "after-clear")?.id == "after-clear")
|
|
||||||
#expect(store.auditInvariants().isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("logical index offset matches a reference model under adversarial mutations")
|
|
||||||
@MainActor
|
|
||||||
func logicalIndexOffsetDifferentialStress() async {
|
|
||||||
let store = ConversationStore()
|
|
||||||
let cap = store.conversation(for: .mesh).cap
|
|
||||||
var reference = ReferenceConversationTimeline(cap: cap)
|
|
||||||
var rng = ConversationStoreDifferentialRNG(seed: 0xC0FF_EE13_37CA_FE42)
|
|
||||||
var issuedIDs: [String] = []
|
|
||||||
var nextID = 0
|
|
||||||
var nextTailTimestamp: TimeInterval = 1_700_000_000
|
|
||||||
var trimmedCount = 0
|
|
||||||
|
|
||||||
var tailAppendCount = 0
|
|
||||||
var outOfOrderCount = 0
|
|
||||||
var duplicateOrReuseCount = 0
|
|
||||||
var headRemovalCount = 0
|
|
||||||
var middleRemovalCount = 0
|
|
||||||
var upsertCount = 0
|
|
||||||
var deliveryUpdateCount = 0
|
|
||||||
var filterCount = 0
|
|
||||||
var clearCount = 0
|
|
||||||
|
|
||||||
func issueMessage(timestamp: TimeInterval? = nil, tag: String) -> BitchatMessage {
|
|
||||||
let number = nextID
|
|
||||||
nextID += 1
|
|
||||||
let id = "diff-\(number)"
|
|
||||||
issuedIDs.append(id)
|
|
||||||
let resolvedTimestamp: TimeInterval
|
|
||||||
if let timestamp {
|
|
||||||
resolvedTimestamp = timestamp
|
|
||||||
} else {
|
|
||||||
resolvedTimestamp = nextTailTimestamp
|
|
||||||
nextTailTimestamp += 1
|
|
||||||
}
|
|
||||||
let dropMarker = number.isMultiple(of: 11) ? " [drop]" : ""
|
|
||||||
return makeMessage(
|
|
||||||
id: id,
|
|
||||||
timestamp: resolvedTimestamp,
|
|
||||||
content: "\(tag) \(number)\(dropMarker)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func appendAndCompare(_ message: BitchatMessage, checkpoint: String) -> ReferenceConversationTimeline.AppendResult {
|
|
||||||
let expected = reference.append(message)
|
|
||||||
let actual = store.append(message, to: .mesh)
|
|
||||||
#expect(actual == expected.inserted, "append result mismatch at \(checkpoint)")
|
|
||||||
trimmedCount += expected.trimmedCount
|
|
||||||
return expected
|
|
||||||
}
|
|
||||||
|
|
||||||
func refill(extra: Int, checkpoint: String) async {
|
|
||||||
let appendCount = max(0, cap - reference.messages.count) + extra
|
|
||||||
for index in 0..<appendCount {
|
|
||||||
appendAndCompare(
|
|
||||||
issueMessage(tag: "refill"),
|
|
||||||
checkpoint: "\(checkpoint)-\(index)"
|
|
||||||
)
|
|
||||||
if index.isMultiple(of: 64) {
|
|
||||||
await Task.yield()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expectStore(store, matches: reference, issuedIDs: issuedIDs, checkpoint: checkpoint)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start well into steady state so the offset is already non-zero
|
|
||||||
// before any mixed operations begin.
|
|
||||||
await refill(extra: 384, checkpoint: "initial steady-state fill")
|
|
||||||
|
|
||||||
for step in 0..<1_200 {
|
|
||||||
if step == 300 || step == 900 {
|
|
||||||
store.removeMessages(from: .mesh) { $0.content.contains("[drop]") }
|
|
||||||
reference.removeAll { $0.content.contains("[drop]") }
|
|
||||||
filterCount += 1
|
|
||||||
expectStore(
|
|
||||||
store,
|
|
||||||
matches: reference,
|
|
||||||
issuedIDs: issuedIDs,
|
|
||||||
checkpoint: "filter at step \(step)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if step == 600 {
|
|
||||||
store.clear(.mesh)
|
|
||||||
reference.clear()
|
|
||||||
clearCount += 1
|
|
||||||
expectStore(
|
|
||||||
store,
|
|
||||||
matches: reference,
|
|
||||||
issuedIDs: issuedIDs,
|
|
||||||
checkpoint: "clear at step \(step)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch rng.index(upperBound: 100) {
|
|
||||||
case 0..<35:
|
|
||||||
appendAndCompare(issueMessage(tag: "tail"), checkpoint: "tail append \(step)")
|
|
||||||
tailAppendCount += 1
|
|
||||||
|
|
||||||
case 35..<55:
|
|
||||||
if reference.messages.isEmpty {
|
|
||||||
appendAndCompare(issueMessage(tag: "tail-fallback"), checkpoint: "OOO fallback \(step)")
|
|
||||||
} else {
|
|
||||||
let target = reference.messages[rng.index(upperBound: reference.messages.count)]
|
|
||||||
let jitter = [-0.25, 0.0, 0.25][rng.index(upperBound: 3)]
|
|
||||||
let timestamp = target.timestamp.timeIntervalSince1970 + jitter
|
|
||||||
appendAndCompare(
|
|
||||||
issueMessage(timestamp: timestamp, tag: "out-of-order"),
|
|
||||||
checkpoint: "out-of-order append \(step)"
|
|
||||||
)
|
|
||||||
outOfOrderCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case 55..<65:
|
|
||||||
if issuedIDs.isEmpty {
|
|
||||||
appendAndCompare(issueMessage(tag: "reuse-fallback"), checkpoint: "reuse fallback \(step)")
|
|
||||||
} else {
|
|
||||||
let reusedID = issuedIDs[rng.index(upperBound: issuedIDs.count)]
|
|
||||||
let message = makeMessage(
|
|
||||||
id: reusedID,
|
|
||||||
timestamp: nextTailTimestamp,
|
|
||||||
content: "duplicate-or-trimmed-reuse \(step)"
|
|
||||||
)
|
|
||||||
nextTailTimestamp += 1
|
|
||||||
appendAndCompare(message, checkpoint: "duplicate or reuse \(step)")
|
|
||||||
duplicateOrReuseCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case 65..<73:
|
|
||||||
if !reference.messages.isEmpty {
|
|
||||||
let expected = reference.remove(at: 0)
|
|
||||||
let actual = store.removeMessage(withID: expected.id, from: .mesh)
|
|
||||||
.map(ReferenceConversationTimeline.Message.init)
|
|
||||||
#expect(actual == expected, "head removal mismatch at step \(step)")
|
|
||||||
headRemovalCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case 73..<81:
|
|
||||||
if !reference.messages.isEmpty {
|
|
||||||
let middleStart = reference.messages.count / 4
|
|
||||||
let middleWidth = max(1, reference.messages.count / 2)
|
|
||||||
let index = min(
|
|
||||||
reference.messages.count - 1,
|
|
||||||
middleStart + rng.index(upperBound: middleWidth)
|
|
||||||
)
|
|
||||||
let expected = reference.remove(at: index)
|
|
||||||
let actual = store.removeMessage(withID: expected.id, from: .mesh)
|
|
||||||
.map(ReferenceConversationTimeline.Message.init)
|
|
||||||
#expect(actual == expected, "middle removal mismatch at step \(step)")
|
|
||||||
middleRemovalCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case 81..<90:
|
|
||||||
let message: BitchatMessage
|
|
||||||
if step.isMultiple(of: 4) || reference.messages.isEmpty {
|
|
||||||
let timestamp = reference.messages.isEmpty
|
|
||||||
? nil
|
|
||||||
: reference.messages[rng.index(upperBound: reference.messages.count)]
|
|
||||||
.timestamp.timeIntervalSince1970
|
|
||||||
message = issueMessage(timestamp: timestamp, tag: "upsert-new")
|
|
||||||
} else {
|
|
||||||
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
|
|
||||||
message = makeMessage(
|
|
||||||
id: current.id,
|
|
||||||
timestamp: current.timestamp.timeIntervalSince1970,
|
|
||||||
content: "upsert-existing \(step)",
|
|
||||||
deliveryStatus: current.deliveryStatus
|
|
||||||
)
|
|
||||||
}
|
|
||||||
trimmedCount += reference.upsert(message)
|
|
||||||
store.upsertByID(message, in: .mesh)
|
|
||||||
upsertCount += 1
|
|
||||||
|
|
||||||
default:
|
|
||||||
let id: String
|
|
||||||
let repeatedStatus: DeliveryStatus?
|
|
||||||
if step.isMultiple(of: 6) || reference.messages.isEmpty {
|
|
||||||
id = "missing-\(step)"
|
|
||||||
repeatedStatus = nil
|
|
||||||
} else {
|
|
||||||
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
|
|
||||||
id = current.id
|
|
||||||
repeatedStatus = current.deliveryStatus
|
|
||||||
}
|
|
||||||
let status: DeliveryStatus
|
|
||||||
if step.isMultiple(of: 4), let repeatedStatus {
|
|
||||||
status = repeatedStatus
|
|
||||||
} else {
|
|
||||||
status = .delivered(
|
|
||||||
to: "peer",
|
|
||||||
at: Date(timeIntervalSince1970: 2_000_000_000 + Double(step))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
let expected = reference.applyDeliveryStatus(status, to: id)
|
|
||||||
let actual = store.setDeliveryStatus(status, forMessageID: id, in: .mesh)
|
|
||||||
#expect(actual == expected, "delivery update mismatch at step \(step)")
|
|
||||||
deliveryUpdateCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
expectStore(
|
|
||||||
store,
|
|
||||||
matches: reference,
|
|
||||||
issuedIDs: issuedIDs,
|
|
||||||
checkpoint: "mixed operation \(step)"
|
|
||||||
)
|
|
||||||
|
|
||||||
// This intentionally expensive MainActor stress test runs beside
|
|
||||||
// async audio/UI tests in SwiftPM's parallel phase. Cooperatively
|
|
||||||
// release the actor so their bounded waits can make progress.
|
|
||||||
await Task.yield()
|
|
||||||
|
|
||||||
if (step + 1).isMultiple(of: 100) {
|
|
||||||
await refill(extra: 32, checkpoint: "periodic refill after step \(step)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guarantee another long run of one-row evictions after every other
|
|
||||||
// mutation family has perturbed and rebuilt the offset/index state.
|
|
||||||
await refill(extra: 512, checkpoint: "final steady-state trim run")
|
|
||||||
|
|
||||||
#expect(trimmedCount > 1_200)
|
|
||||||
#expect(tailAppendCount > 300)
|
|
||||||
#expect(outOfOrderCount > 150)
|
|
||||||
#expect(duplicateOrReuseCount > 75)
|
|
||||||
#expect(headRemovalCount > 50)
|
|
||||||
#expect(middleRemovalCount > 50)
|
|
||||||
#expect(upsertCount > 75)
|
|
||||||
#expect(deliveryUpdateCount > 75)
|
|
||||||
#expect(filterCount == 2)
|
|
||||||
#expect(clearCount == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Upsert
|
// MARK: - Upsert
|
||||||
|
|
||||||
@Test("upsertByID replaces in place and appends when absent")
|
@Test("upsertByID replaces in place and appends when absent")
|
||||||
|
|||||||
@@ -501,62 +501,6 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
|
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 7b. ConversationStore append at the retention cap
|
|
||||||
|
|
||||||
/// Steady-state public timeline traffic after the 1337-message retention
|
|
||||||
/// cap has been reached. Every tail append evicts the oldest row, which is
|
|
||||||
/// the long-lived workload the cold `store.append` benchmark does not
|
|
||||||
/// exercise.
|
|
||||||
func testConversationStoreSteadyStateAppend() {
|
|
||||||
let store = ConversationStore()
|
|
||||||
let cap = TransportConfig.meshTimelineCap
|
|
||||||
let messagesPerPass = 500
|
|
||||||
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
|
||||||
|
|
||||||
for i in 0..<cap {
|
|
||||||
store.append(
|
|
||||||
BitchatMessage(
|
|
||||||
id: "perf-steady-seed-\(i)",
|
|
||||||
sender: "perfsender",
|
|
||||||
content: "steady-state seed \(i)",
|
|
||||||
timestamp: base.addingTimeInterval(Double(i)),
|
|
||||||
isRelay: false
|
|
||||||
),
|
|
||||||
to: .mesh
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var pass = 0
|
|
||||||
var samples: [TimeInterval] = []
|
|
||||||
measure {
|
|
||||||
let startIndex = cap + pass * messagesPerPass
|
|
||||||
let start = Date()
|
|
||||||
for offset in 0..<messagesPerPass {
|
|
||||||
let i = startIndex + offset
|
|
||||||
store.append(
|
|
||||||
BitchatMessage(
|
|
||||||
id: "perf-steady-\(i)",
|
|
||||||
sender: "perfsender",
|
|
||||||
content: "steady-state message \(i)",
|
|
||||||
timestamp: base.addingTimeInterval(Double(i)),
|
|
||||||
isRelay: false
|
|
||||||
),
|
|
||||||
to: .mesh
|
|
||||||
)
|
|
||||||
}
|
|
||||||
samples.append(Date().timeIntervalSince(start))
|
|
||||||
pass += 1
|
|
||||||
XCTAssertEqual(store.conversation(for: .mesh).messages.count, cap)
|
|
||||||
}
|
|
||||||
|
|
||||||
reportThroughput(
|
|
||||||
"store.steadyStateAppend",
|
|
||||||
samples: samples,
|
|
||||||
operations: messagesPerPass,
|
|
||||||
unit: "messages"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 8. ConversationStore invariant audit (field observability)
|
// MARK: - 8. ConversationStore invariant audit (field observability)
|
||||||
|
|
||||||
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
|
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
|
||||||
|
|||||||
@@ -30,10 +30,6 @@
|
|||||||
"store.append": 213201,
|
"store.append": 213201,
|
||||||
"store.audit": 362
|
"store.audit": 362
|
||||||
},
|
},
|
||||||
"_reference_local_numbers_2026_07": {
|
|
||||||
"store.steadyStateAppend_before": 2315,
|
|
||||||
"store.steadyStateAppend": 53976
|
|
||||||
},
|
|
||||||
"floors": {
|
"floors": {
|
||||||
"nostrInbound.fresh": 450,
|
"nostrInbound.fresh": 450,
|
||||||
"nostrInbound.duplicate": 250000,
|
"nostrInbound.duplicate": 250000,
|
||||||
@@ -45,7 +41,6 @@
|
|||||||
"pipeline.privateIngest": 3000,
|
"pipeline.privateIngest": 3000,
|
||||||
"pipeline.publicIngest": 2400,
|
"pipeline.publicIngest": 2400,
|
||||||
"store.append": 48000,
|
"store.append": 48000,
|
||||||
"store.steadyStateAppend": 10000,
|
|
||||||
"store.audit": 70
|
"store.audit": 70
|
||||||
},
|
},
|
||||||
"_slowest_observed_ci_numbers_2026_06": {
|
"_slowest_observed_ci_numbers_2026_06": {
|
||||||
@@ -61,4 +56,4 @@
|
|||||||
"store.append": 97423,
|
"store.append": 97423,
|
||||||
"store.audit": 140
|
"store.audit": 140
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
tracked_justfiles="$(git ls-files | awk 'tolower($0) == "justfile"')"
|
||||||
|
tracked_justfile_count="$(printf '%s\n' "$tracked_justfiles" | awk 'NF { count++ } END { print count + 0 }')"
|
||||||
|
if [[ $tracked_justfile_count -ne 1 || $tracked_justfiles != "Justfile" ]]; then
|
||||||
|
echo "Expected exactly one tracked canonical Justfile; found: ${tracked_justfiles:-none}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fxq 'clean:' Justfile; then
|
||||||
|
echo "Clean recipe must not depend on another recipe" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
clean_recipe="$({
|
||||||
|
awk '
|
||||||
|
/^clean:/ { in_clean = 1; next }
|
||||||
|
in_clean && /^[^[:space:]]/ { exit }
|
||||||
|
in_clean { print }
|
||||||
|
' Justfile
|
||||||
|
})"
|
||||||
|
|
||||||
|
if [[ -z ${clean_recipe//[[:space:]]/} ]]; then
|
||||||
|
echo "Justfile clean recipe is missing or empty" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fxq 'derived_data := ".DerivedData"' Justfile; then
|
||||||
|
echo "Derived data path must remain the ignored repo-local .DerivedData directory" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
clean_forbidden='git[[:space:]]+(checkout|restore|reset|clean)|(^|[[:space:]])(cp|mv)([[:space:]]|$)|bitchat\.xcodeproj|project\.pbxproj|Info\.plist|LaunchScreen|project\.yml|Configs/'
|
||||||
|
if grep -Eiq "$clean_forbidden" <<<"$clean_recipe"; then
|
||||||
|
echo "Unsafe source/configuration mutation found in the clean recipe:" >&2
|
||||||
|
grep -Ein "$clean_forbidden" <<<"$clean_recipe" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq 'rm -rf -- "{{derived_data}}" ".build"' <<<"$clean_recipe"; then
|
||||||
|
echo "Clean recipe must remain limited to the declared repo-local artifact paths" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
clean_rm_count="$(grep -Ec '^[[:space:]]*@?rm[[:space:]]+-rf([[:space:]]|$)' <<<"$clean_recipe" || true)"
|
||||||
|
if [[ $clean_rm_count -ne 1 ]]; then
|
||||||
|
echo "Clean recipe must contain exactly one recursive removal command" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
expected_clean_recipe=' @echo "Cleaning repo-local build artifacts..."
|
||||||
|
@rm -rf -- "{{derived_data}}" ".build"
|
||||||
|
@echo "✅ Cleaned {{derived_data}} and .build; tracked files were untouched"'
|
||||||
|
if [[ $clean_recipe != "$expected_clean_recipe" ]]; then
|
||||||
|
echo "Clean recipe contains commands outside the reviewed artifact-only implementation" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
file_forbidden='git[[:space:]]+(checkout|restore|reset|clean)|rm[[:space:]]+-rf[^#]*(bitchat\.xcodeproj|bitchat/|Configs/)|LaunchScreen\.storyboard\.ios|project\.pbxproj\.backup|Info\.plist\.backup'
|
||||||
|
if grep -Ein "$file_forbidden" Justfile; then
|
||||||
|
echo "Unsafe tracked-file recovery/deletion logic found in Justfile" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Justfile clean safety check passed"
|
||||||
Reference in New Issue
Block a user