// src/core/core-gatt-handles.ts import { BackendContractError, contractError } from '../backend-contract/errors' import type { BackendConnection, ConnectionLease, ConnectionState } from '../backend-contract/backend' import type { ConnectionLifecycleCause, ConnectionLifecycleEvent, ConnectionLifecycleTerminalCause } from '../backend-contract/connection-lifecycle' import type { CleanupRecord } from '../backend-contract/errors' import type { CharacteristicPath, ConnectionPath, DatabasePath, DescriptorPath, GattDatabaseChangedEvent, GattDatabase, GattDatabaseSnapshot } from '../backend-contract/gatt' import type { BackendIdentity } from '../backend-contract/identity' import type { LongWritePolicy, LongWriteReceipt, PublicOperationOptions, SubscriptionOptions, WriteMode, WritePolicy, WriteReceipt } from '../backend-contract/operations' import { capacity, type OwnedBytes } from '../backend-contract/primitives' import { utf8ByteLength } from '../backend-contract/serializable' import type { BoundedAsyncStream, StreamTerminalNotice } from '../backend-contract/streams' import { CoreBoundedStream } from './bounded-stream' import { assertBackendLifecycleTransition } from './connection-lifecycle-rules' import type { CoreSubscription } from './subscription-registry' import type { CoreDeadlineHandle, UnifiedBleCore } from './unified-ble-core' import type { ConnectionMaximumWriteLengthMeasurement, ConnectionPhyObservation, ConnectionPhyRequest, ConnectionPriority, ConnectionPriorityRequest, ConnectionWriteReadinessWatch, EffectiveMtuMeasurement, MtuNegotiation, PhyPreference, RssiMeasurement } from '../backend-contract/connection-controls' import type { CoreConnectionControls } from './core-connection-controls' import { connectionPathsEqual, databasePathsEqual } from './gatt-path-equality' type CurrentCharacteristicPath = CharacteristicPath< Attachment, string, string, string, string, 'current' > type CurrentDescriptorPath = DescriptorPath< Attachment, string, string, string, string, string, 'current' > /** * Budget for the per-connection lifecycle event stream. * * Fixed rather than caller-tunable because lifecycle events are control-plane * notices, not data: the stream carries a bounded set of connection state * transitions, and the reserved control capacity guarantees the terminal notice * is always deliverable even when the value window is full. A consumer that * needs a larger *data* budget sets `delivery` on the operation that produces * data; enlarging the lifecycle window would only retain stale transitions. */ const connectionLifecycleItemCapacity = 8 const connectionLifecycleReservedControlCapacity = 256 /** A generation-bound logical lease over one backend connection. */ export class CoreConnection> { private released = false private pendingDatabaseCleanup: CoreGattDatabase | null = null private readonly lifecycleStream: CoreBoundedStream> private lifecycleState: ConnectionState = 'connecting' private lifecycleFinished = false private nextLifecycleSequence = 1 private lastBackendIngressOrdinal: number | null = null database: CoreGattDatabase | null = null constructor( private readonly core: UnifiedBleCore, readonly lease: ConnectionLease, private readonly controls: CoreConnectionControls ) { const maximumEventBytes = maximumConnectionLifecycleEventByteLength(lease) this.lifecycleStream = new CoreBoundedStream>( Object.freeze({ itemCapacity: capacity(connectionLifecycleItemCapacity), byteCapacity: capacity( maximumEventBytes * connectionLifecycleItemCapacity + connectionLifecycleReservedControlCapacity ), reservedControlCapacity: capacity(connectionLifecycleReservedControlCapacity) }), 'drop-oldest' ) this.emitLifecycle('connected', 'connected', null) } get resource(): BackendConnection { return this.lease.connection } get events(): BoundedAsyncStream> { return this.lifecycleStream } async discover(options: PublicOperationOptions): Promise> { return this.core.discover(this, options) } rediscoverGatt( options: PublicOperationOptions, reason: Extract ): Promise> { return this.core.rediscoverGatt(this, options, reason) } release(): Promise { return this.core.releaseConnection(this, 'released') } disconnect(): Promise { return this.core.releaseConnection(this, 'requested-disconnect') } readRssi(options: PublicOperationOptions): Promise> { return this.controls.readRssi(this, options) } requestMtu(requestedMtu: number, options: PublicOperationOptions): Promise> { return this.controls.requestMtu(this, requestedMtu, options) } effectiveMtu(options: PublicOperationOptions): Promise> { return this.controls.effectiveMtu(this, options) } requestPriority( priority: ConnectionPriority, options: PublicOperationOptions ): Promise> { return this.controls.requestPriority(this, priority, options) } readPhy(options: PublicOperationOptions): Promise> { return this.controls.readPhy(this, options) } requestPhy( preference: PhyPreference, options: PublicOperationOptions ): Promise> { return this.controls.requestPhy(this, preference, options) } maximumWriteLength( mode: WriteMode, options: PublicOperationOptions ): Promise> { return this.controls.maximumWriteLength(this, mode, options) } writeWithoutResponseReadiness(options?: PublicOperationOptions): Promise> { return this.controls.writeWithoutResponseReadiness(this, options) } isCurrent(): boolean { return !this.released && this.resource.state === 'connected' } isReleased(): boolean { return this.released } assertCurrent(): void { if (!this.isCurrent()) { throw contractError('connection.stale', 'connection', 'core-connection.current') } } setDatabase(database: CoreGattDatabase): void { if (this.pendingDatabaseCleanup !== null) { throw contractError('lifecycle.invariant-violation', 'gatt', 'core-connection.pending-database-cleanup') } this.database = database } clearDatabase(database: CoreGattDatabase): void { if (this.database === database) { this.database = null } } invalidateDatabase( reason: 'connection-lost' | 'owner-released', changeReason: GattDatabaseChangedEvent['reason'] | null = null ): Promise { const database = this.database ?? this.pendingDatabaseCleanup if (database !== null) { return this.core.invalidateDatabase(database, reason, changeReason) } return Promise.resolve({ state: 'released', failures: [] }) } retainPendingDatabaseCleanup(database: CoreGattDatabase): boolean { if (this.pendingDatabaseCleanup === database) { return false } if (this.pendingDatabaseCleanup !== null) { throw contractError('lifecycle.invariant-violation', 'gatt', 'core-connection.multiple-pending-databases') } this.clearDatabase(database) this.pendingDatabaseCleanup = database return true } isPendingDatabaseCleanup(database: CoreGattDatabase): boolean { return this.pendingDatabaseCleanup === database } completeDatabaseCleanup(database: CoreGattDatabase): void { if (this.pendingDatabaseCleanup === database) { this.pendingDatabaseCleanup = null } } async cleanupChildren(reason: 'connection-lost' | 'owner-released'): Promise { return this.invalidateDatabase(reason) } markReleased(): void { this.released = true } applyBackendTransition( previous: ConnectionState, current: Exclude, backendIngressOrdinal: number ): void { if (this.lifecycleFinished) { return } assertBackendLifecycleTransition( this.lifecycleState, previous, current, backendIngressOrdinal, this.lastBackendIngressOrdinal ) this.acceptBackendIngressOrdinal(backendIngressOrdinal) this.emitLifecycle(current, 'backend-transition', backendIngressOrdinal) } finishBackendLifecycle( previous: ConnectionState, current: Extract, cause: ConnectionLifecycleTerminalCause, backendIngressOrdinal: number ): void { if (this.lifecycleFinished) { return } assertBackendLifecycleTransition( this.lifecycleState, previous, current, backendIngressOrdinal, this.lastBackendIngressOrdinal ) if (current !== lifecycleTerminalState(cause)) { throw contractError('lifecycle.invariant-violation', 'connection', 'connection-lifecycle.terminal-cause') } this.acceptBackendIngressOrdinal(backendIngressOrdinal) this.completeLifecycle(cause, backendIngressOrdinal) } finishLifecycle(cause: ConnectionLifecycleTerminalCause, backendIngressOrdinal: number | null): void { if (this.lifecycleFinished) { return } if (backendIngressOrdinal !== null) { this.acceptBackendIngressOrdinal(backendIngressOrdinal) } this.completeLifecycle(cause, backendIngressOrdinal) } private acceptBackendIngressOrdinal(backendIngressOrdinal: number): void { if (this.lastBackendIngressOrdinal !== null && backendIngressOrdinal <= this.lastBackendIngressOrdinal) { throw contractError('lifecycle.invariant-violation', 'connection', 'connection-lifecycle.ingress-order') } this.lastBackendIngressOrdinal = backendIngressOrdinal } private completeLifecycle(cause: ConnectionLifecycleTerminalCause, backendIngressOrdinal: number | null): void { const current = lifecycleTerminalState(cause) this.emitLifecycle(current, cause, backendIngressOrdinal) this.lifecycleFinished = true this.lifecycleStream.finishWithReason(lifecycleTerminalReason(current)) } isPathCurrent(path: CurrentCharacteristicPath): boolean { return ( this.isCurrent() && this.database !== null && this.database.isCurrent() && this.database.matchesDatabasePath(path) && path.validity === 'current' ) } matchesConnectionPath(path: DatabasePath | ConnectionPath): boolean { return connectionPathsEqual(path, this.connectionPath) } private get connectionPath(): ConnectionPath { return { attachment: this.resource.attachment, attachmentId: this.resource.attachmentId, peerId: this.resource.peerId, connectionId: this.resource.connectionId, connectionGeneration: this.resource.connectionGeneration, ownerLeaseId: this.lease.leaseId } } private emitLifecycle( current: ConnectionState, cause: ConnectionLifecycleCause, backendIngressOrdinal: number | null ): void { if (!Number.isSafeInteger(this.nextLifecycleSequence)) { throw contractError('lifecycle.invariant-violation', 'connection', 'connection-lifecycle.sequence') } const previous = this.lifecycleState this.lifecycleState = current const event: ConnectionLifecycleEvent = Object.freeze({ kind: 'connection-lifecycle', attachment: this.resource.attachment, attachmentId: this.resource.attachmentId, peerId: this.resource.peerId, connectionId: this.resource.connectionId, connectionGeneration: this.resource.connectionGeneration, ownerLeaseId: this.lease.leaseId, sequence: this.nextLifecycleSequence, backendIngressOrdinal, previous, current, cause }) this.nextLifecycleSequence += 1 this.lifecycleStream.emit(event, connectionLifecycleEventByteLength(event)) } } function connectionLifecycleEventByteLength( event: ConnectionLifecycleEvent ): number { const serialized = JSON.stringify(event) if (serialized === undefined) { throw contractError('lifecycle.invariant-violation', 'connection', 'connection-lifecycle.serialize') } return utf8ByteLength(serialized) } function maximumConnectionLifecycleEventByteLength( lease: ConnectionLease ): number { return connectionLifecycleEventByteLength({ kind: 'connection-lifecycle', attachment: lease.connection.attachment, attachmentId: lease.connection.attachmentId, peerId: lease.connection.peerId, connectionId: lease.connection.connectionId, connectionGeneration: lease.connection.connectionGeneration, ownerLeaseId: lease.leaseId, sequence: Number.MAX_SAFE_INTEGER, backendIngressOrdinal: Number.MAX_SAFE_INTEGER, previous: 'disconnecting', current: 'disconnected', cause: 'requested-disconnect' }) } function lifecycleTerminalState(cause: ConnectionLifecycleTerminalCause): 'disconnected' | 'lost' { if ( cause === 'peer-link-loss' || cause === 'adapter-loss' || cause === 'backend-restart' || cause === 'backend-failure' ) { return 'lost' } return 'disconnected' } function lifecycleTerminalReason(current: 'disconnected' | 'lost'): StreamTerminalNotice['reason'] { return current === 'lost' ? 'connection-lost' : 'owner-released' } /** A discovered database epoch; any invalidation requires a fresh discovery. */ export class CoreGattDatabase> { private valid = true private readonly changedStream = new CoreBoundedStream( { itemCapacity: capacity(4), byteCapacity: capacity(4096), reservedControlCapacity: capacity(1) }, 'drop-oldest' ) constructor( private readonly core: UnifiedBleCore, readonly connection: CoreConnection, readonly backendDatabase: GattDatabase ) {} get path(): DatabasePath { return this.backendDatabase.path } get changed(): BoundedAsyncStream { return this.changedStream } monotonicNow(): number { return this.core.monotonicNow() } scheduleDeadline(deadline: number, action: () => void): CoreDeadlineHandle { return this.core.scheduleDeadline(deadline, action) } async snapshot(): Promise> { this.assertCurrent() let snapshot: GattDatabaseSnapshot try { snapshot = await this.backendDatabase.snapshot() } catch (error) { if (error instanceof BackendContractError) { throw error } throw contractError('platform.failure', 'gatt', 'core-gatt-database.snapshot') } this.assertCurrent() this.assertSnapshot(snapshot) return snapshot } read(path: CurrentCharacteristicPath, options: PublicOperationOptions): Promise { return this.core.read(this, path, options) } write( path: CurrentCharacteristicPath, bytes: Readonly, options: WritePolicy ): Promise> { return this.core.write(this, path, bytes, options) } writeWhenReady( path: CurrentCharacteristicPath, bytes: Readonly, options: WritePolicy ): Promise> { return this.core.writeWhenReady(this, path, bytes, options) } maximumWriteLength( path: CurrentCharacteristicPath, mode: WriteMode ): Promise> { return this.core.maximumWriteLength(this, path, mode) } writeLong( path: CurrentCharacteristicPath, bytes: Readonly, options: LongWritePolicy ): Promise> { return this.core.writeLong(this, path, bytes, options) } readDescriptor(path: CurrentDescriptorPath, options: PublicOperationOptions): Promise { return this.core.readDescriptor(this, path, options) } writeDescriptor( path: CurrentDescriptorPath, bytes: Readonly, options: WritePolicy ): Promise> { return this.core.writeDescriptor(this, path, bytes, options) } subscribe( path: CurrentCharacteristicPath, options: SubscriptionOptions ): Promise> { return this.core.subscribe(this, path, options) } isCurrent(): boolean { return this.valid && this.connection.isCurrent() && this.connection.database === this } isAttached(): boolean { return this.valid && this.connection.database === this } assertCurrent(): void { if (!this.isCurrent()) { throw contractError('gatt.stale-handle', 'gatt', 'core-gatt-database.current') } } assertPath(path: CurrentCharacteristicPath): void { this.assertCurrent() if (!this.matchesDatabasePath(path) || path.validity !== 'current') { throw contractError('gatt.stale-handle', 'gatt', 'core-gatt-database.path') } } matchesDatabasePath(path: DatabasePath): boolean { return databasePathsEqual(this.path, path) } markInvalid(reason: GattDatabaseChangedEvent['reason'] | null = null): void { if (!this.valid) return this.valid = false if (reason !== null) { this.changedStream.emit( Object.freeze({ previousGeneration: String(this.path.databaseGeneration), reason, affectedHandleRange: null }), 128 ) } this.changedStream.finishWithReason('closed') } private assertSnapshot(snapshot: GattDatabaseSnapshot): void { this.assertSnapshotPath(snapshot.path) for (const service of snapshot.services) { this.assertSnapshotPath(service.path) } for (const characteristic of snapshot.characteristics) { this.assertSnapshotPath(characteristic.path) } for (const descriptor of snapshot.descriptors) { this.assertSnapshotPath(descriptor.path) } } private assertSnapshotPath(path: DatabasePath): void { if (!this.matchesDatabasePath(path)) { throw contractError('protocol.violation', 'gatt', 'core-gatt-database.snapshot-path') } } }