// src/backends/bluez/bluez-backend-runtime.ts import type { AdapterBackend, BackendEvent, ConnectionBackend, GattBackend, PeerAddressDescriptor, ResourceCounters, ScannerBackend } from '../../backend-contract/backend' import { contractError, type CleanupFailure, type CleanupRecord } from '../../backend-contract/errors' import type { CharacteristicPath, ConnectionPath, DescriptorPath } from '../../backend-contract/gatt' import type { AdapterDescriptor, AdapterStateSnapshot, AdapterStateWatch, AttachmentRecord } from '../../backend-contract/identity' import type { BackendOperationDispatch, OperationOptions, OperationTerminalRecord, PublicOperationOptions, SubscribeRequest, SubscriptionOptions, WritePolicy, WriteReceipt } from '../../backend-contract/operations' import { byteLimit, canonicalBleAddress, createAttachmentBoundIdFactory, monotonicTimestamp, opaqueId, ownBytes, resourceCount, type AttachmentBinding, type BackendInstanceId, type ClientId, type LeaseId, type MonotonicTimestamp, type OperationCorrelation, type OwnedBytes, type PeerId } from '../../backend-contract/primitives' import { CoreBoundedStream } from '../../core/bounded-stream' import { OwnedCoreBoundedStream } from '../../core/owned-bounded-stream' import { BLUEZ_ADAPTER_INTERFACE, BLUEZ_DEVICE_INTERFACE, BLUEZ_GATT_CHARACTERISTIC_INTERFACE, BLUEZ_GATT_DESCRIPTOR_INTERFACE, BLUEZ_NO_AUTHORIZATION_CONCEPT_REASON, bluezSafeReason, isBluezGattTopologyInterface, type BluezBusKind, type BluezDbusBoundary, type BluezInterfacesAdded, type BluezInterfacesRemoved, type BluezPropertiesChanged } from './bluez-dbus-contract' import type { BluezObjectStoreObserver } from './bluez-object-store' import { BluezObjectStore } from './bluez-object-store' import { BluezOperationDispatcher, type BluezOperationDispatch } from './bluez-operation-dispatcher' import { connectBluezConnection, destroyBluezAddressAcquisitions, disconnectBluezConnection } from './bluez-connection-runtime' import { dispatchBluezCharacteristicRead, dispatchBluezCharacteristicWrite, dispatchBluezDescriptorRead, dispatchBluezDescriptorWrite, readBluezValue, writeBluezValue } from './bluez-gatt-operations' import { BluezBackendSubscription, BluezConnection, BluezConnectionLease, BluezGattDatabase, releasedBluezCleanup } from './bluez-backend-handles' import { advertisementSize, captureCleanup, cleanupFailure, createGattSnapshot, createObservation, matchesScan, requireOwnerLease, requireRecordConnection, successfulTerminal } from './bluez-runtime-models' import { beginBluezPhysicalRemoval, removeBluezSubscription, subscribeBluez } from './bluez-subscription-runtime' import { destroyBluezScan, joinBluezScan, startBluezScan, stopBluezScan } from './bluez-scan-runtime' import type { BluezAddressAcquisition, BluezConnectionRecord, BluezPhysicalSubscription, BluezPropertyWaiter, BluezScanConsumer, BluezScanGroup, BluezSubscriptionRecord } from './bluez-runtime-types' import { bluezEventLimits, bluezStateLimits } from './bluez-runtime-types' import { awaitBluezNativePromise, rejectAllBluezWaiters, rejectBluezPathTreeWaiters, rejectRemovedBluezObjectWaiters, resolveBluezWaiters, waitForBluezBoolean } from './bluez-property-waiters' import { BluezSecurityBackend } from './bluez-security' import { type BluezPairingGenerationController } from './bluez-pairing-generation' import { diagnosticBluezScanPlan } from './bluez-scan-planner' const maximumOperationBytes = byteLimit(512 * 1024) export interface BluezBackendRuntimeConstruction { readonly boundary: BluezDbusBoundary readonly store: BluezObjectStore readonly adapter: AdapterDescriptor readonly now: () => number /** * Host-supplied privileged operation for selecting the LE pairing generation. * Absent by default: without it `secureConnections: 'require' | 'disallow'` * keeps failing closed, which is this backend's unchanged posture. */ readonly pairingGeneration?: BluezPairingGenerationController readonly busKind: BluezBusKind readonly backendInstanceId: BackendInstanceId } export interface BackendStreamOwnershipSnapshot { readonly stateWatchers: number readonly eventStreams: number } const bluezStreamOwnershipInspectors = new WeakMap BackendStreamOwnershipSnapshot>() export function inspectBluezRuntimeStreamOwnershipForTests( backend: BluezBackendRuntime ): BackendStreamOwnershipSnapshot { const inspect = bluezStreamOwnershipInspectors.get(backend) if (inspect === undefined) { throw new Error('bluez stream ownership inspector is missing') } return inspect() } /** BlueZ mechanics behind the contract-v1 backend. */ export class BluezBackendRuntime implements BluezObjectStoreObserver { readonly busKind readonly adapter: AdapterBackend readonly scanner: ScannerBackend readonly connections: ConnectionBackend readonly gatt: GattBackend readonly security: BluezSecurityBackend readonly boundary readonly store readonly selectedAdapter readonly now readonly pairingGeneration: BluezPairingGenerationController | null private readonly backendInstanceId readonly dispatcher private readonly observer private readonly resetListener private readonly eventStreams = new Set>>() private readonly stateStreams = new Set>>() readonly connectionRecords = new Map() readonly physicalSubscriptions = new Map() readonly addressAcquisitions = new Map() private readonly peerPaths = new Map() private readonly peerHandles = new Map>() private readonly addressTargets = new Map() /** Latched after the daemon rejects Adapter1.ConnectDevice (non-experimental bluetoothd). */ connectDeviceUnavailable = false readonly waiters = new Set() scanGroup: BluezScanGroup | null = null private backendGeneration = 1 private adapterGeneration = 1 private adapterStateUpdatedAt: MonotonicTimestamp nextScan = 1 nextConnection = 1 nextLease = 1 nextSubscription = 1 private nextDatabaseOperation = 1 private nextPeer = 1 private ingressOrdinal = 1 private destroyed = false private destroyResult: Promise | null = null constructor(construction: BluezBackendRuntimeConstruction) { this.busKind = construction.busKind this.boundary = construction.boundary this.store = construction.store this.selectedAdapter = construction.adapter this.now = construction.now this.pairingGeneration = construction.pairingGeneration ?? null this.adapterStateUpdatedAt = monotonicTimestamp(this.now()) this.backendInstanceId = construction.backendInstanceId this.dispatcher = new BluezOperationDispatcher(this.now) this.observer = this.store.addObserver(this) this.resetListener = this.boundary.onReset(reason => this.advanceBackendGeneration(reason)) this.adapter = { currentState: async () => this.adapterState(), watchState: async () => this.watchAdapterState() } this.scanner = { plan: query => diagnosticBluezScanPlan(query), start: async (options, clientId) => startBluezScan(this, options, clientId), join: async (leaseId, shareToken, clientId) => joinBluezScan(this, leaseId, shareToken, clientId) } this.connections = { connect: async (peerId, clientId, options) => this.connect(peerId, clientId, options), peerFromAddress: descriptor => this.peerFromAddress(descriptor) } this.gatt = { discover: async (connection, options) => this.discover(connection, options), read: (path, request) => dispatchBluezCharacteristicRead(this, path, request), write: (path, request) => dispatchBluezCharacteristicWrite(this, path, request), readDescriptor: (path, request) => dispatchBluezDescriptorRead(this, path, request), writeDescriptor: (path, request) => dispatchBluezDescriptorWrite(this, path, request), subscribe: (path, request) => this.subscribeDispatch(path, request), unsubscribe: (subscription, operation) => this.unsubscribeDispatch(subscription, operation) } this.security = new BluezSecurityBackend(this) bluezStreamOwnershipInspectors.set(this, () => ({ stateWatchers: this.stateStreams.size, eventStreams: this.eventStreams.size })) } attachment(): AttachmentRecord { const backendGeneration = opaqueId(String(this.backendGeneration), 'backend-generation', 'bluez') return Object.freeze({ attachmentId: opaqueId( `${String(this.backendInstanceId)}:${this.backendGeneration}:${String(this.selectedAdapter.adapterId)}:${this.adapterGeneration}`, 'attachment', 'bluez' ), backendInstanceId: this.backendInstanceId, backendGeneration, adapter: Object.freeze({ ...this.selectedAdapter, state: Object.freeze({ ...this.adapterState(), backendGeneration }), adapterGeneration: opaqueId( String(this.adapterGeneration), 'adapter-generation', `bluez:${String(this.selectedAdapter.adapterId)}` ) }) }) } identifiers() { const attachment = this.attachment() const binding: AttachmentBinding = { attachmentId: attachment.attachmentId, backendInstanceId: attachment.backendInstanceId, backendGeneration: attachment.backendGeneration, adapterId: attachment.adapter.adapterId, adapterGeneration: attachment.adapter.adapterGeneration } return createAttachmentBoundIdFactory(binding) } assertUsable(operation: string): void { if (this.destroyed) { throw contractError('lifecycle.destroyed', 'core', operation) } } isDestroying(): boolean { return this.destroyed } allocateDatabaseCorrelation(operation: string) { const correlation = opaqueId(`${operation}-${this.nextDatabaseOperation}`, 'core-operation', 'bluez:database') this.nextDatabaseOperation += 1 return correlation } events(): CoreBoundedStream> { this.assertUsable('bluez.events') const stream = new OwnedCoreBoundedStream>(bluezEventLimits, 'error', () => { this.eventStreams.delete(stream) }) this.eventStreams.add(stream) return stream } resourceCounters(): ResourceCounters { let subscriptionConsumers = 0 let retainedByteBuffers = 0 for (const physical of this.physicalSubscriptions.values()) { subscriptionConsumers += physical.consumers.size + physical.pendingRemovals.size for (const consumer of physical.consumers) { retainedByteBuffers += consumer.stream.retainedPayloadBytes() } } return { activeScanControllers: resourceCount(this.scanGroup === null ? 0 : 1), scanConsumers: resourceCount(this.scanGroup?.consumers.size ?? 0), chooserSessions: resourceCount(0), connectionLeases: resourceCount( [...this.connectionRecords.values()].reduce((total, record) => total + record.leases.size, 0) ), physicalLinks: resourceCount([...this.connectionRecords.values()].filter(record => record.active).length), databaseSnapshots: resourceCount( [...this.connectionRecords.values()].reduce((total, record) => total + record.databases.size, 0) ), physicalCccdEnablements: resourceCount(this.physicalSubscriptions.size), subscriptionConsumers: resourceCount(subscriptionConsumers), queuedOperations: resourceCount(0), dispatchedOperations: resourceCount(this.dispatcher.activeCount()), retainedByteBuffers: resourceCount(retainedByteBuffers), restorationRecords: resourceCount(0), orphanedIpcOwners: resourceCount(0) } } async destroy(): Promise { if (this.destroyResult !== null) { return this.destroyResult } this.destroyResult = this.destroyInternal().then( cleanup => { if (cleanup.state === 'release-failed') { this.destroyResult = null } return cleanup }, error => { this.destroyResult = null throw error } ) return this.destroyResult } interfacesAdded(event: BluezInterfacesAdded): void { resolveBluezWaiters(this) if (event.interfaces.some(entry => entry.name === BLUEZ_DEVICE_INTERFACE)) { this.emitAdvertisementForPath(event.path) } if (event.interfaces.some(entry => isBluezGattTopologyInterface(entry.name))) { this.invalidateGattDatabasePath(event.path) } if (event.path === String(this.selectedAdapter.adapterId)) { this.adapterGeneration += 1 this.refreshAdapterStateUpdatedAt() this.broadcastAdapterState() } } interfacesRemoved(event: BluezInterfacesRemoved): void { if (event.interfaces.includes(BLUEZ_DEVICE_INTERFACE)) { this.security.peerRemoved(event.path) this.invalidateConnectionPath(event.path, 'connection.lost') const peerId = this.peerHandles.get(event.path) if (peerId !== undefined) { this.peerHandles.delete(event.path) this.peerPaths.delete(String(peerId)) } } if (event.path === String(this.selectedAdapter.adapterId) && event.interfaces.includes(BLUEZ_ADAPTER_INTERFACE)) { this.advanceBackendGeneration('BlueZ adapter object was removed') } if (event.interfaces.some(isBluezGattTopologyInterface)) { this.invalidateGattDatabasePath(event.path) } rejectRemovedBluezObjectWaiters(this, event.path) } propertiesChanged(event: BluezPropertiesChanged): void { resolveBluezWaiters(this) if (event.interfaceName === BLUEZ_DEVICE_INTERFACE) { this.security.propertiesChanged(event) this.emitAdvertisementForPath(event.path) if (event.changed.ServicesResolved?.signature === 'b' && event.changed.ServicesResolved.value === false) { this.invalidateGattDatabasePath(event.path) } if (event.changed.Connected?.signature === 'b' && event.changed.Connected.value === false) { this.invalidateConnectionPath(event.path, 'connection.lost') } } if (event.interfaceName === BLUEZ_GATT_CHARACTERISTIC_INTERFACE && event.changed.Value?.signature === 'ay') { this.emitNotification(event.path, event.changed.Value.value) } if (event.path === String(this.selectedAdapter.adapterId) && event.interfaceName === BLUEZ_ADAPTER_INTERFACE) { this.refreshAdapterStateUpdatedAt() this.broadcastAdapterState() if (event.changed.Powered?.signature === 'b' && event.changed.Powered.value === false) { this.advanceBackendGeneration('BlueZ adapter powered off') } } } async stopScan(consumer: BluezScanConsumer): Promise { if (consumer.stopped !== null) { return consumer.stopped } const stop = stopBluezScan(this, consumer) consumer.stopped = stop try { const cleanup = await stop if (cleanup.state === 'release-failed') { consumer.stopped = null } return cleanup } catch (error) { consumer.stopped = null throw error } } async releaseConnectionLease(lease: BluezConnectionLease): Promise { const record = lease.record if (record.leases.size > 1) { const cleanup = await this.releaseLeaseSubscriptions(lease.leaseId) if (cleanup.state === 'release-failed') { return cleanup } if (record.ownerLeaseId === lease.leaseId) { this.invalidateRecordDatabases(record) } record.leases.delete(lease) if (record.ownerLeaseId === lease.leaseId) { record.ownerLeaseId = record.leases.values().next().value?.leaseId ?? null } return releasedBluezCleanup } const cleanup = await this.disconnect(record) if (cleanup.state === 'released') { record.leases.delete(lease) if (record.ownerLeaseId === lease.leaseId) { record.ownerLeaseId = null } } return cleanup } private async releaseLeaseSubscriptions(leaseId: LeaseId): Promise { const failures: CleanupFailure[] = [] for (const physical of this.physicalSubscriptions.values()) { for (const subscription of [...physical.consumers, ...physical.pendingRemovals]) { if (subscription.ownerLeaseId !== leaseId) { continue } try { failures.push(...(await removeBluezSubscription(this, subscription)).failures) } catch { failures.push(cleanupFailure('subscription', 'bluez.gatt.stop-notify')) } } } return failures.length === 0 ? releasedBluezCleanup : Object.freeze({ state: 'release-failed', failures: Object.freeze(failures) }) } async disconnect(record: BluezConnectionRecord): Promise { return disconnectBluezConnection(this, record, () => this.invalidateConnection(record, 'operation.disconnected')) } trackConnectionOperation( record: BluezConnectionRecord, dispatch: BluezOperationDispatch, operationName: string ): BluezOperationDispatch { const pending = { operationName, physicalSettlement: dispatch.physicalSettlement } record.pendingOperations.set(dispatch.handle, pending) dispatch.physicalSettlement.then(() => { if (record.pendingOperations.get(dispatch.handle) === pending) { record.pendingOperations.delete(dispatch.handle) } }) return dispatch } connectionRecordForConnectionId(connectionId: string): BluezConnectionRecord | null { for (const record of this.connectionRecords.values()) { if (record.connection !== null && String(record.connection.connectionId) === connectionId) { return record } } return null } trackConnectionOperationForPeer( peerId: string, dispatch: BluezOperationDispatch, operationName: string ): BluezOperationDispatch { const record = [...this.connectionRecords.values()].find(candidate => String(candidate.peerId) === peerId) return record === undefined ? dispatch : this.trackConnectionOperation(record, dispatch, operationName) } trackConnectionOperationForPath( path: | CharacteristicPath | DescriptorPath, dispatch: BluezOperationDispatch, operationName: string ): BluezOperationDispatch { return this.trackConnectionOperation( this.requireDatabaseForPath(path, operationName).record, dispatch, operationName ) } async readCharacteristic( database: BluezGattDatabase, path: CharacteristicPath, options: PublicOperationOptions ): Promise { return readBluezValue( this, database, database.resolveCharacteristicPath(path, 'bluez.gatt.read'), BLUEZ_GATT_CHARACTERISTIC_INTERFACE, options, 'bluez.gatt.read' ) } async writeCharacteristic( database: BluezGattDatabase, path: CharacteristicPath, value: Uint8Array, options: WritePolicy ): Promise> { return writeBluezValue( this, database, database.resolveCharacteristicPath(path, 'bluez.gatt.write'), BLUEZ_GATT_CHARACTERISTIC_INTERFACE, value, options, 'bluez.gatt.write' ) } async readDescriptor( database: BluezGattDatabase, path: DescriptorPath, options: PublicOperationOptions ): Promise { return readBluezValue( this, database, database.resolveDescriptorPath(path, 'bluez.gatt.read-descriptor'), BLUEZ_GATT_DESCRIPTOR_INTERFACE, options, 'bluez.gatt.read-descriptor' ) } async writeDescriptor( database: BluezGattDatabase, path: DescriptorPath, value: Uint8Array, options: WritePolicy ): Promise> { return writeBluezValue( this, database, database.resolveDescriptorPath(path, 'bluez.gatt.write-descriptor'), BLUEZ_GATT_DESCRIPTOR_INTERFACE, value, options, 'bluez.gatt.write-descriptor' ) } async subscribe( database: BluezGattDatabase, path: CharacteristicPath, options: SubscriptionOptions, requestCorrelation: OperationCorrelation | null = null ): Promise { database.assertCurrent('bluez.gatt.subscribe') return subscribeBluez(this, path, options, requestCorrelation) } async removeSubscription(record: BluezSubscriptionRecord): Promise { return removeBluezSubscription(this, record) } assertDatabaseCurrent(database: BluezGattDatabase, operation: string): void { this.assertUsable(operation) if (!database.record.active || database.record.currentDatabase !== database) { this.throwStale(operation) } if (database.path.attachmentId !== this.attachment().attachmentId) { this.throwStale(operation) } } throwStale(operation: string): never { throw contractError('gatt.stale-handle', 'gatt', operation) } resolveCharacteristicPath( path: CharacteristicPath, operation: string ): string { return this.requireDatabaseForPath(path, operation).resolveCharacteristicPath(path, operation) } resolveDescriptorPath( path: DescriptorPath, operation: string ): string { return this.requireDatabaseForPath(path, operation).resolveDescriptorPath(path, operation) } private adapterState(): AdapterStateSnapshot { const adapterPath = String(this.selectedAdapter.adapterId) const present = this.store.hasInterface(adapterPath, BLUEZ_ADAPTER_INTERFACE) const powered = present ? this.store.optionalBooleanProperty(adapterPath, BLUEZ_ADAPTER_INTERFACE, 'Powered') : null return Object.freeze({ availability: present ? 'available' : 'unavailable', authorization: 'unknown', power: powered === true ? 'on' : powered === false ? 'off' : 'unknown', backendGeneration: opaqueId(String(this.backendGeneration), 'backend-generation', 'bluez'), updatedAt: this.adapterStateUpdatedAt, safeReason: bluezSafeReason([ present ? (powered === false ? 'BlueZ adapter is powered off' : null) : 'BlueZ adapter object unavailable', BLUEZ_NO_AUTHORIZATION_CONCEPT_REASON ]) }) } private async watchAdapterState(): Promise> { const stream = new OwnedCoreBoundedStream>(bluezStateLimits, 'latest', () => { this.stateStreams.delete(stream) }) this.stateStreams.add(stream) return { initial: this.adapterState(), transitions: stream } } private async connect( peerId: PeerId, clientId: ClientId, options: PublicOperationOptions ): Promise { return connectBluezConnection(this, peerId, clientId, options) } private async discover( connection: import('../../backend-contract/backend').BackendConnection, options: PublicOperationOptions ): Promise { const record = this.requireConnection(connection, 'bluez.gatt.discover') const attachmentId = this.attachment().attachmentId await waitForBluezBoolean(this, record.devicePath, BLUEZ_DEVICE_INTERFACE, 'ServicesResolved', true, options) this.assertAttachmentCurrent(attachmentId, 'bluez.gatt.discover.after-services-resolved') if (!record.active || record.state !== 'connected') { throw contractError('operation.disconnected', 'gatt', 'bluez.gatt.discover.after-services-resolved') } const snapshot = createGattSnapshot(this.store.snapshot(), record.devicePath) if (record.currentDatabase !== null) { this.invalidateDatabase(record, record.currentDatabase) } const ids = this.identifiers() const generation = record.nextDatabaseGeneration record.nextDatabaseGeneration += 1 const path = Object.freeze({ attachment: this.attachment(), attachmentId: this.attachment().attachmentId, peerId: record.peerId, connectionId: requireRecordConnection(record).connectionId, ownerLeaseId: requireOwnerLease(record), connectionGeneration: requireRecordConnection(record).connectionGeneration, databaseId: ids.databaseId(`bluez-database-${generation}`), databaseGeneration: opaqueId( String(generation), 'database-generation', `${String(requireRecordConnection(record).connectionId)}:${record.devicePath}` ) }) const database = new BluezGattDatabase(this, record, path, snapshot) record.databases.add(database) record.currentDatabase = database return database } assertAttachmentCurrent( attachmentId: import('../../backend-contract/primitives').AttachmentId, operation: string ): void { this.assertUsable(operation) if (this.attachment().attachmentId !== attachmentId || this.adapterState().power !== 'on') { throw contractError('operation.reset', 'core', operation) } } private subscribeDispatch( path: CharacteristicPath, request: SubscribeRequest ): BackendOperationDispatch { const database = this.requireDatabaseForPath(path, 'bluez.gatt.subscribe') const dispatch = this.dispatcher.dispatch(request.operation, 'bluez.gatt.subscribe', () => this.subscribe(database, path, request.options, request.operation.correlation) ) return this.trackConnectionOperation(database.record, dispatch, 'bluez.gatt.subscribe') } private unsubscribeDispatch( subscription: import('../../backend-contract/backend').BackendSubscription, operation: OperationOptions ): BackendOperationDispatch> { const dispatch = this.dispatcher.dispatch(operation, 'bluez.gatt.unsubscribe', async () => { if (!(subscription instanceof BluezBackendSubscription)) { throw contractError('ownership.denied', 'gatt', 'bluez.gatt.unsubscribe') } await subscription.remove() return successfulTerminal(operation) }) if (!(subscription instanceof BluezBackendSubscription) || !subscription.isOwnedBy(this)) { return dispatch } const record = this.connectionRecordForConnectionId(String(subscription.path.connectionId)) return record === null ? dispatch : this.trackConnectionOperation(record, dispatch, 'bluez.gatt.unsubscribe') } emitAdvertisementForPath(path: string): void { const group = this.scanGroup if (group === null || !this.store.hasInterface(path, BLUEZ_DEVICE_INTERFACE)) { return } const owner = group.consumers.get(String(group.ownerLeaseId)) if (owner === undefined) { throw contractError('lifecycle.invariant-violation', 'scan', 'bluez.advertisement.scan-owner') } const observation = createObservation( this.store, path, this.peerIdForPath(path), this.attachment().backendInstanceId, owner.scanSessionId, this.now(), this.ingressOrdinal ) this.ingressOrdinal += 1 for (const consumer of group.consumers.values()) { if (!matchesScan(consumer.options, observation)) { continue } const result = consumer.stream.emit(observation, advertisementSize(observation), String(observation.device.id)) if (result.terminated) { observeBluezCleanup( this.stopScan(consumer), '[BluezBackendRuntime.emitAdvertisementForPath] Overflow cleanup failed:' ) } } } private emitNotification(objectPath: string, value: Uint8Array): void { const physical = this.physicalSubscriptions.get(objectPath) if (physical === undefined || physical.state !== 'ready') { return } for (const consumer of [...physical.consumers]) { const owned = ownBytes(value, maximumOperationBytes) const result = consumer.stream.emit({ value: owned, indication: false }, owned.byteLength, null, owned.byteLength) if (result.terminated) { observeBluezCleanup( this.removeSubscription(consumer), '[BluezBackendRuntime.emitNotification] Overflow cleanup failed:' ) } } } private requireConnection( connection: import('../../backend-contract/backend').BackendConnection, operation: string ): BluezConnectionRecord { if ( !(connection instanceof BluezConnection) || connection.record.connection !== connection || !connection.record.active ) { throw contractError('connection.stale', 'connection', operation) } return connection.record } private requireDatabaseForPath( path: | CharacteristicPath | DescriptorPath, operation: string ): BluezGattDatabase { for (const record of this.connectionRecords.values()) { const database = record.currentDatabase if (database !== null && database.path.databaseId === path.databaseId) { database.assertCurrent(operation) return database } } this.throwStale(operation) } private invalidateConnectionPath(path: string, cause: import('../../backend-contract/errors').BleErrorCode): void { const record = this.connectionRecords.get(path) if (record !== undefined) { this.invalidateConnection(record, cause) } } private invalidateConnection( record: BluezConnectionRecord, cause: import('../../backend-contract/errors').BleErrorCode ): void { const connection = cause === 'connection.lost' ? this.connectionPathFor(record) : null record.active = false record.physicalLinkMayExist = false record.state = cause === 'connection.lost' ? 'lost' : 'disconnected' this.invalidateRecordDatabases(record) rejectBluezPathTreeWaiters(this, record.devicePath, 'operation.disconnected') for (const [objectPath, physical] of [...this.physicalSubscriptions]) { if (objectPath.startsWith(`${record.devicePath}/`)) { for (const consumer of physical.consumers) { consumer.stream.closeWithReason('connection-lost') if (!this.isDestroying()) { consumer.removed = true } } if (!this.isDestroying()) { this.physicalSubscriptions.delete(objectPath) } } } if (this.connectionRecords.get(record.devicePath) === record) { this.connectionRecords.delete(record.devicePath) } if (connection !== null) { this.broadcastEvent({ attachment: connection.attachment, attachmentId: connection.attachmentId, kind: 'connection-lost', connection, ingressOrdinal: this.ingressOrdinal }) this.ingressOrdinal += 1 } } private invalidateGattDatabasePath(path: string): void { for (const record of this.connectionRecords.values()) { if (record.active && (path === record.devicePath || path.startsWith(`${record.devicePath}/`))) { this.invalidateGattDatabase(record) } } } private invalidateGattDatabase(record: BluezConnectionRecord): void { const database = record.currentDatabase if (database === null) { return } this.invalidateDatabase(record, database) rejectBluezPathTreeWaiters(this, record.devicePath, 'operation.disconnected') for (const [objectPath, physical] of [...this.physicalSubscriptions]) { if (!objectPath.startsWith(`${record.devicePath}/`)) { continue } for (const consumer of physical.consumers) { consumer.stream.closeWithReason('connection-lost') consumer.removed = true } physical.consumers.clear() if (physical.removal === null) { observeBluezCleanup( beginBluezPhysicalRemoval(this, physical), '[BluezBackendRuntime.invalidateGattDatabase] BlueZ notification cleanup failed:' ) } } this.broadcastEvent({ attachment: database.path.attachment, attachmentId: database.path.attachmentId, kind: 'database-changed', database: database.path, ingressOrdinal: this.ingressOrdinal }) this.ingressOrdinal += 1 } private invalidateDatabase(record: BluezConnectionRecord, database: BluezGattDatabase): void { database.invalidate() record.databases.delete(database) if (record.currentDatabase === database) { record.currentDatabase = null } } private invalidateRecordDatabases(record: BluezConnectionRecord): void { for (const database of [...record.databases]) { database.invalidate() record.databases.delete(database) } record.currentDatabase = null } private connectionPathFor(record: BluezConnectionRecord): ConnectionPath | null { const connection = record.connection const ownerLeaseId = record.ownerLeaseId if (connection === null || ownerLeaseId === null) { return null } const attachment = this.attachment() return Object.freeze({ attachment, attachmentId: attachment.attachmentId, peerId: connection.peerId, connectionId: connection.connectionId, ownerLeaseId, connectionGeneration: connection.connectionGeneration }) } private advanceBackendGeneration(_reason: string): void { this.backendGeneration += 1 this.adapterGeneration += 1 this.refreshAdapterStateUpdatedAt() this.security.reset() this.peerPaths.clear() this.peerHandles.clear() this.addressTargets.clear() const resettingScanGroup = this.scanGroup if (resettingScanGroup !== null && !resettingScanGroup.startupComplete) { const owner = resettingScanGroup.consumers.get(String(resettingScanGroup.ownerLeaseId)) for (const consumer of resettingScanGroup.consumers.values()) { if (consumer.abort !== null) { consumer.options.signal?.removeEventListener('abort', consumer.abort) } if (consumer.deadlineTimer !== null) { clearTimeout(consumer.deadlineTimer) consumer.deadlineTimer = null } consumer.stream.closeWithReason('source-failed') } resettingScanGroup.stopRequested = true resettingScanGroup.resetRequested = true if (owner !== undefined) { resettingScanGroup.startupSettled.then(() => { if (this.scanGroup === resettingScanGroup) { observeBluezCleanup(this.stopScan(owner), '[BluezBackendRuntime.reset] Startup scan cleanup failed:') } }) } } else if (resettingScanGroup !== null) { for (const consumer of resettingScanGroup.consumers.values()) { consumer.stream.closeWithReason('source-failed') } resettingScanGroup.stopRequested = true resettingScanGroup.resetRequested = true const owner = resettingScanGroup.consumers.get(String(resettingScanGroup.ownerLeaseId)) if (owner !== undefined) { observeBluezCleanup(this.stopScan(owner), '[BluezBackendRuntime.reset] Scan cleanup failed:') } } for (const physical of this.physicalSubscriptions.values()) { for (const consumer of physical.consumers) { consumer.stream.closeWithReason('source-failed') } for (const consumer of physical.pendingRemovals) { consumer.stream.closeWithReason('source-failed') } const cleanup = physical.removal ?? beginBluezPhysicalRemoval(this, physical) observeBluezCleanup( cleanup.then(result => { if (result.state === 'released' && this.physicalSubscriptions.get(physical.objectPath) === physical) { this.physicalSubscriptions.delete(physical.objectPath) } return result }), '[BluezBackendRuntime.reset] Subscription cleanup failed:' ) } for (const waiter of [...this.waiters]) { waiter.reject(contractError('operation.reset', 'core', `bluez.restart.${waiter.property}`)) } for (const record of [...this.connectionRecords.values()]) { this.invalidateConnection(record, 'operation.reset') } const attachment = this.attachment() this.broadcastEvent({ attachment, attachmentId: attachment.attachmentId, kind: 'backend-restarted', ingressOrdinal: this.ingressOrdinal }) this.ingressOrdinal += 1 } private peerIdForPath(path: string): PeerId { const existing = this.peerHandles.get(path) if (existing !== undefined) return existing const peerId = opaqueId( `bluez-peer-${this.backendGeneration}-${this.nextPeer}`, 'peer', `bluez:${String(this.attachment().attachmentId)}` ) this.nextPeer += 1 this.peerHandles.set(path, peerId) this.peerPaths.set(String(peerId), path) return peerId } /** * peer:address-targeting seam. Mints a stable peer handle for a canonical out-of-band * radio address; the connect path then owns materialization and pending semantics. */ peerFromAddress(descriptor: PeerAddressDescriptor): PeerId { this.assertUsable('bluez.peer-from-address') let address: string try { address = canonicalBleAddress(descriptor.address) } catch { throw contractError('argument.invalid', 'connection', 'bluez.peer-from-address') } if ( address !== descriptor.address || (descriptor.addressType !== 'public' && descriptor.addressType !== 'random') ) { throw contractError('argument.invalid', 'connection', 'bluez.peer-from-address') } const devicePath = `${String(this.selectedAdapter.adapterId)}/dev_${address.replaceAll(':', '_')}` this.addressTargets.set(devicePath, Object.freeze({ address, addressType: descriptor.addressType })) return this.peerIdForPath(devicePath) } addressTargetForPath(path: string): PeerAddressDescriptor | undefined { return this.addressTargets.get(path) } devicePathForPeer(peerId: string): string { const path = this.peerPaths.get(String(peerId)) if (path === undefined) { throw contractError('connection.not-found', 'connection', 'bluez.connect.peer-handle') } return path } peerIdForPathIfKnown(path: string): string | null { const peerId = this.peerHandles.get(path) return peerId === undefined ? null : String(peerId) } removePeerPath(peerId: string): void { const path = this.peerPaths.get(peerId) if (path === undefined) return this.invalidateConnectionPath(path, 'operation.reset') this.peerPaths.delete(peerId) this.peerHandles.delete(path) } private broadcastAdapterState(): void { const state = this.adapterState() for (const stream of [...this.stateStreams]) { if (stream.emit(state, 64, String(state.backendGeneration)).terminated) { this.stateStreams.delete(stream) } } const attachment = this.attachment() this.broadcastEvent({ attachment, attachmentId: attachment.attachmentId, kind: 'adapter-state', ingressOrdinal: this.ingressOrdinal }) this.ingressOrdinal += 1 } private refreshAdapterStateUpdatedAt(): void { this.adapterStateUpdatedAt = monotonicTimestamp(this.now()) } private broadcastEvent(event: BackendEvent): void { for (const stream of [...this.eventStreams]) { if (stream.emit(event, 128).terminated) { this.eventStreams.delete(stream) } } } private async destroyInternal(): Promise { this.destroyed = true this.security.close() this.dispatcher.cancelAll() rejectAllBluezWaiters(this) try { await awaitBluezNativePromise(this.dispatcher.waitForIdle(), this.now, 'bluez.destroy.dispatcher-idle') } catch { return Object.freeze({ state: 'release-failed', failures: Object.freeze([cleanupFailure('operation-quarantine', 'bluez.destroy.dispatcher-idle')]) }) } const failures: CleanupFailure[] = [] failures.push(...(await captureCleanup(destroyBluezScan(this), 'scan', 'bluez.destroy.scan')).failures) for (const physical of [...this.physicalSubscriptions.values()]) { const records = [...new Set([...physical.consumers, ...physical.pendingRemovals])] for (const consumer of records) { consumer.stream.closeWithReason('owner-released') } const cleanup = await captureCleanup( physical.removal ?? beginBluezPhysicalRemoval(this, physical), 'subscription', 'bluez.destroy.subscription' ) failures.push(...cleanup.failures) if (cleanup.state === 'released') { for (const consumer of records) { consumer.removed = true } physical.consumers.clear() physical.pendingRemovals.clear() } } for (const record of this.connectionRecords.values()) { if (record.active || record.physicalLinkMayExist) { failures.push( ...(await captureCleanup(this.disconnect(record), 'connection', 'bluez.destroy.connection')).failures ) } } failures.push(...(await destroyBluezAddressAcquisitions(this)).failures) if (failures.length > 0) { return Object.freeze({ state: 'release-failed', failures: Object.freeze(failures) }) } for (const waiter of [...this.waiters]) { waiter.reject(contractError('operation.cancelled-by-destroy', 'core', `bluez.destroy.${waiter.property}`)) } for (const stream of [...this.eventStreams]) { stream.closeWithReason('owner-released') } this.eventStreams.clear() for (const stream of [...this.stateStreams]) { stream.closeWithReason('owner-released') } this.stateStreams.clear() try { this.observer.remove() } catch (error) { console.error('[BluezBackendRuntime.destroy] Failed to remove ObjectManager observer:', error) failures.push(cleanupFailure('object-manager-observer', 'bluez.destroy.observer')) } try { this.resetListener.remove() } catch (error) { console.error('[BluezBackendRuntime.destroy] Failed to remove D-Bus reset listener:', error) failures.push(cleanupFailure('dbus-reset-listener', 'bluez.destroy.reset-listener')) } this.store.close() try { await this.boundary.close() } catch (error) { console.error('[BluezBackendRuntime.destroy] Failed to close D-Bus boundary:', error) failures.push(cleanupFailure('dbus-boundary', 'bluez.destroy.boundary')) } return failures.length === 0 ? releasedBluezCleanup : Object.freeze({ state: 'release-failed', failures: Object.freeze(failures) }) } } function observeBluezCleanup(cleanup: Promise, context: string): void { cleanup.catch(error => { console.error(context, error) }) }