// src/backends/bluez/bluez-property-waiters.ts import { BackendContractError, contractError } from '../../backend-contract/errors' import type { PublicOperationOptions } from '../../backend-contract/operations' import type { BluezBackendRuntime } from './bluez-backend-runtime' import type { BluezConnectionRecord, BluezPropertyWaiter } from './bluez-runtime-types' /** * Safety bound on a native D-Bus release call during teardown. * * Only a default: `awaitBluezNativePromise` takes an explicit `timeoutMs`, and * every waiter in this module that serves a live operation derives its deadline * from the caller's `PublicOperationOptions` instead. Cleanup is the one path * that must not inherit the caller's deadline -- it runs after that deadline has * usually already expired -- so it gets an independent bound. Matches the * CoreBluetooth and WinRT cleanup bounds. */ export const BLUEZ_NATIVE_CLEANUP_TIMEOUT_MS = 1_000 export function isBluezCallerTerminal(error: unknown): boolean { return ( error instanceof BackendContractError && (error.normalized.code === 'operation.aborted' || error.normalized.code === 'operation.timed-out') ) } export async function awaitBluezNativePromise( nativePromise: Promise, now: () => number, operation: string, timeoutMs = BLUEZ_NATIVE_CLEANUP_TIMEOUT_MS ): Promise { let timer: ReturnType | null = null const deadline = now() + timeoutMs try { await Promise.race([ nativePromise, new Promise((_resolve, reject) => { timer = setTimeout( () => reject(contractError('operation.timed-out', 'cleanup', operation)), Math.max(0, deadline - now()) ) }) ]) } finally { if (timer !== null) { clearTimeout(timer) } } } export function waitForBluezBoolean( runtime: BluezBackendRuntime, path: string, interfaceName: string, property: string, expected: boolean, options: PublicOperationOptions ): Promise { if (runtime.store.optionalBooleanProperty(path, interfaceName, property) === expected) { return Promise.resolve() } return new Promise((resolve, reject) => { const waiter: BluezPropertyWaiter = { path, interfaceName, property, expected, resolve: () => { removeBluezWaiter(runtime, waiter) resolve() }, reject: error => { removeBluezWaiter(runtime, waiter) reject(error) }, signal: options.signal, abort: () => { waiter.reject(contractError('operation.aborted', 'core', `bluez.wait.${property}`)) }, timer: null } if (options.signal?.aborted === true) { waiter.reject(contractError('operation.aborted', 'core', `bluez.wait.${property}`)) return } options.signal?.addEventListener('abort', waiter.abort, { once: true }) if (options.deadline !== null) { waiter.timer = setTimeout( () => waiter.reject(contractError('operation.timed-out', 'core', `bluez.wait.${property}`)), Math.max(0, options.deadline - runtime.now()) ) } runtime.waiters.add(waiter) }) } export function resolveBluezWaiters(runtime: BluezBackendRuntime): void { for (const waiter of [...runtime.waiters]) { if (!runtime.store.hasInterface(waiter.path, waiter.interfaceName)) { continue } if ( waiter.kind === 'interface-presence' || runtime.store.optionalBooleanProperty(waiter.path, waiter.interfaceName, waiter.property) === waiter.expected ) { waiter.resolve() } } } /** Resolves once an interface exists at a path, e.g. a Device1 object materializing. */ export function waitForBluezInterfacePresence( runtime: BluezBackendRuntime, path: string, interfaceName: string, options: PublicOperationOptions ): Promise { if (runtime.store.hasInterface(path, interfaceName)) { return Promise.resolve() } return new Promise((resolve, reject) => { const waiter: BluezPropertyWaiter = { kind: 'interface-presence', path, interfaceName, property: 'interface-presence', expected: true, resolve: () => { removeBluezWaiter(runtime, waiter) resolve() }, reject: error => { removeBluezWaiter(runtime, waiter) reject(error) }, signal: options.signal, abort: () => { waiter.reject(contractError('operation.aborted', 'core', 'bluez.wait.interface-presence')) }, timer: null } if (options.signal?.aborted === true) { waiter.reject(contractError('operation.aborted', 'core', 'bluez.wait.interface-presence')) return } options.signal?.addEventListener('abort', waiter.abort, { once: true }) if (options.deadline !== null) { waiter.timer = setTimeout( () => waiter.reject(contractError('operation.timed-out', 'core', 'bluez.wait.interface-presence')), Math.max(0, options.deadline - runtime.now()) ) } runtime.waiters.add(waiter) }) } export function rejectRemovedBluezObjectWaiters(runtime: BluezBackendRuntime, path: string): void { rejectBluezPathWaiters(runtime, path, 'operation.reset') } export function rejectBluezPathWaiters( runtime: BluezBackendRuntime, path: string, code: 'operation.disconnected' | 'operation.reset' ): void { for (const waiter of [...runtime.waiters]) { if (waiter.path === path) { waiter.reject(contractError(code, 'core', `bluez.wait.${waiter.property}`)) } } } /** Rejects every property confirmation owned by a device and its GATT descendants. */ export function rejectBluezPathTreeWaiters( runtime: BluezBackendRuntime, rootPath: string, code: 'operation.disconnected' | 'operation.reset' ): void { for (const waiter of [...runtime.waiters]) { if (waiter.path === rootPath || waiter.path.startsWith(`${rootPath}/`)) { waiter.reject(contractError(code, 'core', `bluez.wait.${waiter.property}`)) } } } /** Releases every in-flight D-Bus confirmation while the backend is being destroyed. */ export function rejectAllBluezWaiters(runtime: BluezBackendRuntime): void { for (const waiter of [...runtime.waiters]) { waiter.reject(contractError('operation.cancelled-by-destroy', 'core', `bluez.destroy.${waiter.property}`)) } } export function removeBluezWaiter(runtime: BluezBackendRuntime, waiter: BluezPropertyWaiter): void { if (!runtime.waiters.delete(waiter)) { return } waiter.signal?.removeEventListener('abort', waiter.abort) if (waiter.timer !== null) { clearTimeout(waiter.timer) waiter.timer = null } } export function awaitSharedBluezTransition( transition: Promise | null, options: PublicOperationOptions, now: () => number, operation: string ): Promise { if (transition === null) { return Promise.reject(contractError('lifecycle.invariant-violation', 'connection', operation)) } return new Promise((resolve, reject) => { let terminal = false let timer: ReturnType | null = null const clear = (): void => { options.signal?.removeEventListener('abort', abort) if (timer !== null) { clearTimeout(timer) timer = null } } const abort = (): void => { if (terminal) return terminal = true clear() reject(contractError('operation.aborted', 'core', operation)) } if (options.signal?.aborted === true) { abort() return } options.signal?.addEventListener('abort', abort, { once: true }) if (options.deadline !== null) { timer = setTimeout( () => { if (terminal) return terminal = true clear() reject(contractError('operation.timed-out', 'core', operation)) }, Math.max(0, options.deadline - now()) ) } transition.then( () => { if (terminal) return terminal = true clear() resolve() }, error => { if (terminal) return terminal = true clear() reject(error) } ) }) } export function scheduleOrphanedBluezConnectionCleanup( runtime: BluezBackendRuntime, record: BluezConnectionRecord ): void { if ( runtime.connectionRecords.get(record.devicePath) !== record || record.pendingConnectors > 0 || record.leases.size > 0 || record.orphanCleanupScheduled ) { return } record.orphanCleanupScheduled = true const transition = record.transition if (transition === null) { record.orphanCleanupScheduled = false return } transition .then(async () => { record.orphanCleanupScheduled = false if ( record.pendingConnectors === 0 && record.leases.size === 0 && record.active && runtime.connectionRecords.get(record.devicePath) === record ) { await runtime.disconnect(record) } }) .catch(error => { record.orphanCleanupScheduled = false console.error('[scheduleOrphanedBluezConnectionCleanup] Shared transition cleanup failed:', error) }) }