-
Notifications
You must be signed in to change notification settings - Fork 7
fix(brainbar): harden reliability under db contention #276
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import Darwin | ||
| import Foundation | ||
| import os | ||
|
|
||
| final class BrainBarInstanceLock: @unchecked Sendable { | ||
| enum AcquireError: Error, Equatable { | ||
| case alreadyRunning | ||
| case openFailed(String) | ||
| case lockFailed(String) | ||
| } | ||
|
|
||
| private let fd: Int32 | ||
| private let lockPath: String | ||
| private let releaseLock = OSAllocatedUnfairLock(initialState: false) | ||
|
|
||
| private init(fd: Int32, lockPath: String) { | ||
| self.fd = fd | ||
| self.lockPath = lockPath | ||
| } | ||
|
|
||
| deinit { | ||
| release() | ||
| } | ||
|
|
||
| static func acquire(lockPath: String) throws -> BrainBarInstanceLock { | ||
| let directory = URL(fileURLWithPath: lockPath).deletingLastPathComponent() | ||
| try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) | ||
|
|
||
| let fd = open(lockPath, O_RDWR | O_CREAT, 0o600) | ||
| guard fd >= 0 else { | ||
| throw AcquireError.openFailed(String(cString: strerror(errno))) | ||
| } | ||
|
|
||
| guard flock(fd, LOCK_EX | LOCK_NB) == 0 else { | ||
| let lockErrno = errno | ||
| let message = String(cString: strerror(lockErrno)) | ||
| close(fd) | ||
| if lockErrno == EWOULDBLOCK { | ||
| throw AcquireError.alreadyRunning | ||
| } | ||
| throw AcquireError.lockFailed(message) | ||
| } | ||
|
|
||
| var pidLine = "\(getpid())\n".data(using: .utf8) ?? Data() | ||
| ftruncate(fd, 0) | ||
| lseek(fd, 0, SEEK_SET) | ||
| _ = pidLine.withUnsafeMutableBytes { ptr in | ||
| write(fd, ptr.baseAddress, ptr.count) | ||
| } | ||
| fsync(fd) | ||
|
|
||
| return BrainBarInstanceLock(fd: fd, lockPath: lockPath) | ||
| } | ||
|
|
||
| func release() { | ||
| let shouldRelease = releaseLock.withLock { released -> Bool in | ||
| guard !released else { return false } | ||
| released = true | ||
| return true | ||
| } | ||
| guard shouldRelease else { return } | ||
| flock(fd, LOCK_UN) | ||
| close(fd) | ||
| try? FileManager.default.removeItem(atPath: lockPath) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎.
Comment on lines
+55
to
+64
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's find and examine the BrainBarInstanceLock.swift file
find . -name "BrainBarInstanceLock.swift" -type fRepository: EtanHey/brainlayer Length of output: 118 🏁 Script executed: # Search for the BrainBarInstanceLock file more broadly
fd -e swift -i brainbarinstancelockRepository: EtanHey/brainlayer Length of output: 116 🏁 Script executed: # List the brain-bar sources directory structure
fd . brain-bar/Sources/ -type f -name "*.swift" | head -20Repository: EtanHey/brainlayer Length of output: 233 🏁 Script executed: # Read the BrainBarInstanceLock.swift file
cat -n ./brain-bar/Sources/BrainBar/BrainBarInstanceLock.swiftRepository: EtanHey/brainlayer Length of output: 2504 🏁 Script executed: # Check for any related tests or usage patterns
rg "BrainBarInstanceLock" --type swift -A 3 -B 3Repository: EtanHey/brainlayer Length of output: 4530 🏁 Script executed: # Search for any initialization or creation logic that shows the lock design intent
rg "flock\|lockPath\|removeItem" brain-bar/Sources/BrainBar/BrainBarInstanceLock.swift -A 2 -B 2Repository: EtanHey/brainlayer Length of output: 44 🏁 Script executed: # Check if there are any issues, PRs, or comments about this race condition
rg "BrainBarInstanceLock|instance.*lock|race" --type swift brain-bar/ -i | head -30Repository: EtanHey/brainlayer Length of output: 2762 🏁 Script executed: # Look at the test more closely to see if it covers the race condition scenario
cat -n brain-bar/Tests/BrainBarTests/BrainBarReliabilityTests.swift | grep -A 20 "BrainBarInstanceLock"Repository: EtanHey/brainlayer Length of output: 1308 🏁 Script executed: # Search for any documentation or comments about why removeItem is there
rg "removeItem|cleanup|delete.*lock" brain-bar/Sources/BrainBar/ -B 3 -A 3Repository: EtanHey/brainlayer Length of output: 4156 Remove the lock file deletion to prevent a race condition that breaks single-instance guarantees. The 🔧 Suggested fix func release() {
let shouldRelease = releaseLock.withLock { released -> Bool in
guard !released else { return false }
released = true
return true
}
guard shouldRelease else { return }
flock(fd, LOCK_UN)
close(fd)
- try? FileManager.default.removeItem(atPath: lockPath)
}The lock file can safely remain on disk; it acts as a marker and causes no harm on subsequent acquisitions. 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -91,6 +91,7 @@ final class BrainBarServer: @unchecked Sendable { | |
| private let dbPath: String | ||
| private let providedDatabase: BrainDatabase? | ||
| private let databaseRecoveryPolicy: DatabaseRecoveryPolicy | ||
| private let instanceLockPath: String | ||
| private let queue = DispatchQueue(label: "com.brainlayer.brainbar.server", qos: .userInitiated) | ||
| private var listenFD: Int32 = -1 | ||
| private var listenSource: DispatchSourceRead? | ||
|
|
@@ -99,7 +100,10 @@ final class BrainBarServer: @unchecked Sendable { | |
| private var database: BrainDatabase! | ||
| private var databaseRetryWorkItem: DispatchWorkItem? | ||
| private var lastDatabaseRetryDelayMillis: UInt64? | ||
| private var databaseOpenInProgress = false | ||
| private var instanceLock: BrainBarInstanceLock? | ||
| var onDatabaseReady: (@Sendable (BrainDatabase) -> Void)? | ||
| var onStartRejected: (@Sendable (String) -> Void)? | ||
| /// Maximum EAGAIN retries before disconnecting a stalled client. | ||
| /// Each retry sleeps 1ms, so 10 retries = 10ms max blocking the serial queue. | ||
| static let maxWriteRetries = 10 | ||
|
|
@@ -137,12 +141,14 @@ final class BrainBarServer: @unchecked Sendable { | |
| socketPath: String? = nil, | ||
| dbPath: String? = nil, | ||
| database: BrainDatabase? = nil, | ||
| databaseRecoveryPolicy: DatabaseRecoveryPolicy = DatabaseRecoveryPolicy() | ||
| databaseRecoveryPolicy: DatabaseRecoveryPolicy = DatabaseRecoveryPolicy(), | ||
| instanceLockPath: String? = nil | ||
| ) { | ||
| self.socketPath = socketPath ?? Self.defaultSocketPath() | ||
| self.dbPath = dbPath ?? Self.defaultDBPath() | ||
| providedDatabase = database | ||
| self.databaseRecoveryPolicy = databaseRecoveryPolicy | ||
| self.instanceLockPath = instanceLockPath ?? Self.defaultInstanceLockPath(socketPath: self.socketPath) | ||
| } | ||
|
|
||
| static func defaultSocketPath() -> String { | ||
|
|
@@ -162,6 +168,10 @@ final class BrainBarServer: @unchecked Sendable { | |
| return "\(home)/.local/share/brainlayer/brainlayer.db" | ||
| } | ||
|
|
||
| static func defaultInstanceLockPath(socketPath: String = defaultSocketPath()) -> String { | ||
| "\(socketPath).lock" | ||
| } | ||
|
|
||
| func start() { | ||
| queue.async { [weak self] in | ||
| self?.startOnQueue() | ||
|
|
@@ -175,6 +185,18 @@ final class BrainBarServer: @unchecked Sendable { | |
| } | ||
|
|
||
| private func startOnQueue() { | ||
| do { | ||
| instanceLock = try BrainBarInstanceLock.acquire(lockPath: instanceLockPath) | ||
| } catch BrainBarInstanceLock.AcquireError.alreadyRunning { | ||
| NSLog("[BrainBar] Another BrainBar instance owns %@. Exiting this server.", instanceLockPath) | ||
| onStartRejected?("another BrainBar instance owns \(instanceLockPath)") | ||
| return | ||
| } catch { | ||
| NSLog("[BrainBar] Failed to acquire single-instance lock %@: %@", instanceLockPath, String(describing: error)) | ||
| onStartRejected?("failed to acquire single-instance lock \(instanceLockPath): \(error)") | ||
| return | ||
| } | ||
|
Comment on lines
+188
to
+198
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Release the singleton lock on every startup failure after this point. Once 🤖 Prompt for AI Agents |
||
|
|
||
| // 1. Create router FIRST (no DB dependency). | ||
| // initialize + tools/list work without a database. | ||
| router = MCPRouter() | ||
|
|
@@ -253,12 +275,36 @@ final class BrainBarServer: @unchecked Sendable { | |
| } | ||
|
|
||
| private func attemptDatabaseOpen() { | ||
| guard database == nil else { return } | ||
| guard database == nil, !databaseOpenInProgress else { return } | ||
|
|
||
| if let providedDatabase { | ||
| finishDatabaseOpen(providedDatabase) | ||
| return | ||
| } | ||
|
|
||
| databaseOpenInProgress = true | ||
| let dbPath = self.dbPath | ||
| let busyTimeoutMillis = databaseRecoveryPolicy.busyTimeoutMillis | ||
| DispatchQueue.global(qos: .userInitiated).async { [weak self] in | ||
| let db = BrainDatabase( | ||
| path: dbPath, | ||
| openConfiguration: .init(busyTimeoutMillis: busyTimeoutMillis) | ||
| ) | ||
| self?.queue.async { [weak self] in | ||
| self?.databaseOpenInProgress = false | ||
| self?.finishDatabaseOpen(db) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private func finishDatabaseOpen(_ db: BrainDatabase) { | ||
| guard listenSource != nil else { | ||
| if providedDatabase == nil { | ||
| db.close() | ||
| } | ||
| return | ||
| } | ||
|
|
||
| let db = providedDatabase ?? BrainDatabase( | ||
| path: dbPath, | ||
| openConfiguration: .init(busyTimeoutMillis: databaseRecoveryPolicy.busyTimeoutMillis) | ||
| ) | ||
| if db.isOpen { | ||
| databaseRetryWorkItem?.cancel() | ||
| databaseRetryWorkItem = nil | ||
|
|
@@ -496,6 +542,9 @@ final class BrainBarServer: @unchecked Sendable { | |
| database?.close() | ||
| } | ||
| database = nil | ||
| databaseOpenInProgress = false | ||
| instanceLock?.release() | ||
| instanceLock = nil | ||
| NSLog("[BrainBar] Server stopped") | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -168,6 +168,11 @@ final class BrainDatabase: @unchecked Sendable { | |
| let importance: Int | ||
| } | ||
|
|
||
| enum StoreWriteOutcome: Sendable { | ||
| case stored(StoredChunk) | ||
| case queued | ||
| } | ||
|
|
||
| struct PendingStoreItem: Codable, Sendable { | ||
| let content: String | ||
| let tags: [String] | ||
|
|
@@ -702,7 +707,9 @@ final class BrainDatabase: @unchecked Sendable { | |
| importance: Int, | ||
| source: String, | ||
| queueID: String? = nil, | ||
| refreshStatistics: Bool = true | ||
| refreshStatistics: Bool = true, | ||
| retries: Int = 3, | ||
| busyTimeoutMillis: Int32? = nil | ||
| ) throws -> StoredChunk { | ||
| guard let db else { throw DBError.notOpen } | ||
| let chunkID = "brainbar-\(UUID().uuidString.lowercased().prefix(12))" | ||
|
|
@@ -712,7 +719,17 @@ final class BrainDatabase: @unchecked Sendable { | |
| INSERT INTO chunks (id, content, metadata, source_file, tags, importance, source, content_type, char_count, preview_text) | ||
| VALUES (?, ?, ?, 'brainbar-store', ?, ?, ?, 'user_message', ?, ?) | ||
| """ | ||
| try runWriteStatement(on: db, sql: sql, retries: 3) { stmt in | ||
| let previousBusyTimeout = busyTimeoutMillis.flatMap { _ in queryPragma(db, name: "busy_timeout") } | ||
| if let busyTimeoutMillis { | ||
| try executeOnHandle(db, sql: "PRAGMA busy_timeout = \(max(1, busyTimeoutMillis))") | ||
| } | ||
| defer { | ||
| if let previousBusyTimeout { | ||
| try? executeOnHandle(db, sql: "PRAGMA busy_timeout = \(previousBusyTimeout)") | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Busy timeout permanently lowered if pragma query failsMedium Severity The Reviewed by Cursor Bugbot for commit d059427. Configure here.
Comment on lines
+722
to
+730
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's look at the BrainDatabase.swift file around the flagged lines
cat -n brain-bar/Sources/BrainBar/BrainDatabase.swift | sed -n '710,750p'Repository: EtanHey/brainlayer Length of output: 2224 🏁 Script executed: # Check the broader context of the store method and how db is used
cat -n brain-bar/Sources/BrainBar/BrainDatabase.swift | sed -n '700,780p'Repository: EtanHey/brainlayer Length of output: 3771 🏁 Script executed: # Look for the BrainDatabase class definition and how the db handle is initialized
cat -n brain-bar/Sources/BrainBar/BrainDatabase.swift | head -100Repository: EtanHey/brainlayer Length of output: 4353 🏁 Script executed: # Check if there are any synchronization primitives (locks, queues, etc.) around the db handle
rg -n "nonisolated\|DispatchQueue\|Lock\|Mutex\|serial" brain-bar/Sources/BrainBar/BrainDatabase.swift | head -30Repository: EtanHey/brainlayer Length of output: 44 🏁 Script executed: # Search for other callers of the store method to understand concurrency context
rg -n "\.store\(" brain-bar/Sources/BrainBar/ -A 2 | head -40Repository: EtanHey/brainlayer Length of output: 1921 🏁 Script executed: # Check if there's any synchronization around db handle access or SQLite connection management
cat -n brain-bar/Sources/BrainBar/BrainDatabase.swift | sed -n '150,250p'Repository: EtanHey/brainlayer Length of output: 3860 🏁 Script executed: # Look for the db property definition
rg -n "var db\|let db\|private.*db" brain-bar/Sources/BrainBar/BrainDatabase.swift | head -20Repository: EtanHey/brainlayer Length of output: 44 🏁 Script executed: # Check for any dispatch queue or serial access patterns in the class
cat -n brain-bar/Sources/BrainBar/BrainDatabase.swift | sed -n '1,150p' | tail -50Repository: EtanHey/brainlayer Length of output: 2375 🏁 Script executed: # Look at how storeAsync is implemented to understand threading model
cat -n brain-bar/Sources/BrainBar/BrainDatabase.swift | sed -n '778,800p'Repository: EtanHey/brainlayer Length of output: 1297 🏁 Script executed: # Check the open() method to see how db connection is established and managed
rg -n "func open\|private func open" brain-bar/Sources/BrainBar/BrainDatabase.swift -A 15 | head -40Repository: EtanHey/brainlayer Length of output: 44 🏁 Script executed: # Check for any actor or main thread enforcement
rg -n "@MainActor\|@globalActor\|nonisolated" brain-bar/Sources/BrainBar/BrainDatabase.swiftRepository: EtanHey/brainlayer Length of output: 44 🏁 Script executed: # Check if there's a serial queue or other synchronization I missed
cat -n brain-bar/Sources/BrainBar/BrainDatabase.swift | sed -n '239,300p'Repository: EtanHey/brainlayer Length of output: 3080 🏁 Script executed: # Look for any queue or lock protecting db access
rg -n "serialQueue\|dbQueue\|dbLock\|synchronized" brain-bar/Sources/BrainBar/BrainDatabase.swiftRepository: EtanHey/brainlayer Length of output: 44 🏁 Script executed: # Check the runWriteStatement implementation to see if it has synchronization
rg -n "func runWriteStatement\|private func runWriteStatement" brain-bar/Sources/BrainBar/BrainDatabase.swift -A 10 | head -30Repository: EtanHey/brainlayer Length of output: 44 🏁 Script executed: # Verify SQLite PRAGMA busy_timeout behavior
web_search
SQLite PRAGMA busy_timeout connection state per connection or globalRepository: EtanHey/brainlayer Length of output: 158 🏁 Script executed: # Check if there are any other store() calls that happen concurrently
rg -n "\.store\(|storeAsync|storeOrQueueWithinBudget" brain-bar/Sources/BrainBar/ --type swift | wc -lRepository: EtanHey/brainlayer Length of output: 65 🏁 Script executed: # Check the full threading model - is db protected by any queue?
cat -n brain-bar/Sources/BrainBar/BrainDatabase.swift | sed -n '239,400p'Repository: EtanHey/brainlayer Length of output: 7265 🏁 Script executed: # Check if runWriteStatement or similar has any protection
cat -n brain-bar/Sources/BrainBar/BrainDatabase.swift | sed -n '850,950p'Repository: EtanHey/brainlayer Length of output: 4988 🏁 Script executed: # Look for queryPragma and executeOnHandle to understand what they do
rg -n "func queryPragma|func executeOnHandle" brain-bar/Sources/BrainBar/BrainDatabase.swift -A 5Repository: EtanHey/brainlayer Length of output: 757 🏁 Script executed: # Verify that there's no synchronized access pattern I'm missing
rg -n "DispatchQueue\|serial\|queue\|Lock\|Mutex" brain-bar/Sources/BrainBar/BrainDatabase.swift | head -10Repository: EtanHey/brainlayer Length of output: 44 🌐 Web query:
💡 Result: Yes, the SQLite PRAGMA busy_timeout is connection-specific (per-database connection) and operates safely in thread-safe builds of SQLite. Key points: - It sets the busy handler for that specific database connection only. Each connection has its own independent busy handler/timeout setting. Setting it on one connection does not affect others. - Default is 0 (no timeout, immediate SQLITE_BUSY return). - Use: PRAGMA busy_timeout = milliseconds; (e.g., PRAGMA busy_timeout = 5000; for 5 seconds) - Equivalent C API: sqlite3_busy_timeout(sqlite3 *db, int ms); explicitly per connection. - Thread safety: Works in multi-threaded (SQLITE_THREADSAFE=2) and serialized (default, SQLITE_THREADSAFE=1) modes. In serialized mode, full mutex protection serializes access to the connection. In multi-thread, connections must not be shared across threads without external synchronization, but the timeout itself is a simple per-connection value. Compile with thread-safe options for multi-threaded use; single-thread mode (SQLITE_THREADSAFE=0) omits mutexes but still supports per-connection timeouts if not multi-threaded. - Must be set after opening each connection, as PRAGMAs are session/connection-specific and do not persist. - In WAL mode (recommended for concurrency), it helps handle lock contention between the single writer and multiple readers. Note: Timeouts may not apply in deadlocks (immediate SQLITE_BUSY) or certain recovery scenarios. Citations:
🏁 Script executed: # Check how storeAsync and other concurrent paths use the db
cat -n brain-bar/Sources/BrainBar/BrainDatabase.swift | sed -n '779,790p'Repository: EtanHey/brainlayer Length of output: 731 🏁 Script executed: # Verify there's truly no serial queue or synchronization by looking at the full class structure
rg -n "private.*queue\|private.*lock\|private.*serial" brain-bar/Sources/BrainBar/BrainDatabase.swiftRepository: EtanHey/brainlayer Length of output: 44 🏁 Script executed: # Check if any of the store callers use main thread enforcement
rg -n "db.store\|db.storeAsync\|db.storeOrQueue" brain-bar/Sources/BrainBar/QuickCaptureController.swift -B 3 -A 3 | head -20Repository: EtanHey/brainlayer Length of output: 44 🏁 Script executed: # Check configureConnection to see if busy_timeout is set during init
rg -n "func configureConnection|func openConnection" brain-bar/Sources/BrainBar/BrainDatabase.swift -A 15 | head -40Repository: EtanHey/brainlayer Length of output: 1836 🏁 Script executed: # Check if the initial OpenConfiguration.busyTimeoutMillis is applied at all
rg -n "openConfiguration\|busyTimeoutMillis" brain-bar/Sources/BrainBar/BrainDatabase.swift | head -20Repository: EtanHey/brainlayer Length of output: 44 🏁 Script executed: # Look at executeOnHandle more completely to confirm it's not synchronized
cat -n brain-bar/Sources/BrainBar/BrainDatabase.swift | sed -n '1642,1660p'Repository: EtanHey/brainlayer Length of output: 985 🏁 Script executed: # Let me check if there are any other clues about thread safety in the broader context
cat -n brain-bar/Sources/BrainBar/BrainDatabase.swift | sed -n '1,50p'Repository: EtanHey/brainlayer Length of output: 2346 🏁 Script executed: # Verify the SQLITE_OPEN_FULLMUTEX flag means SQLite serializes access internally
# But that doesn't prevent the caller from having race conditions in PRAGMA mutation
rg -n "FULLMUTEX\|Sendable" brain-bar/Sources/BrainBar/BrainDatabase.swift | head -10Repository: EtanHey/brainlayer Length of output: 44 Concurrent PRAGMA mutations on shared handle can cause operations to inherit wrong timeouts. The code mutates 🤖 Prompt for AI Agents |
||
|
|
||
| try runWriteStatement(on: db, sql: sql, retries: retries) { stmt in | ||
| bindText(chunkID, to: stmt, index: 1) | ||
| bindText(content, to: stmt, index: 2) | ||
| bindText(metadataJSON, to: stmt, index: 3) | ||
|
|
@@ -731,6 +748,33 @@ final class BrainDatabase: @unchecked Sendable { | |
| return StoredChunk(chunkID: chunkID, rowID: rowID) | ||
| } | ||
|
|
||
| func storeOrQueueWithinBudget( | ||
| content: String, | ||
| tags: [String], | ||
| importance: Int, | ||
| source: String, | ||
| busyTimeoutMillis: Int32 = 50 | ||
| ) throws -> StoreWriteOutcome { | ||
| do { | ||
| let stored = try store( | ||
| content: content, | ||
| tags: tags, | ||
| importance: importance, | ||
| source: source, | ||
| refreshStatistics: true, | ||
| retries: 0, | ||
| busyTimeoutMillis: busyTimeoutMillis | ||
| ) | ||
| return .stored(stored) | ||
| } catch { | ||
| guard shouldQueueStoreError(error) else { | ||
| throw error | ||
| } | ||
| try queuePendingStore(content: content, tags: tags, importance: importance, source: source) | ||
| return .queued | ||
| } | ||
| } | ||
|
|
||
| /// Async wrapper for store() — runs DB write off the main thread. | ||
| func storeAsync(content: String, tags: [String], importance: Int, source: String) async throws -> StoredChunk { | ||
| try await withCheckedThrowingContinuation { continuation in | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't start app side effects before startup acceptance is known.
server.start()returns before the lock check runs on the server queue, so a losing duplicate instance still installs the legacy surface, starts the collector, and registers the hotkey beforeonStartRejectedgets back onto the main actor. That briefly recreates the duplicate side effects this PR is trying to eliminate. Gate this work behind an explicit successful-start callback, or make startup acceptance synchronous.🤖 Prompt for AI Agents