/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { Abort, CanceledError, causedBy, CrashedDependencyError, DestroyedDependencyError, Diagnostic, Logger, MatterAggregateError, Millis, Seconds, } from "@matter/general"; import { CommissioningError, PasscodeMismatchError, PeerCommunicationError } from "@matter/protocol"; import { Discovery } from "./Discovery.js"; import { DeviceNotDiscoveredError, DiscoveryAggregateError, DiscoveryIncompleteError } from "./DiscoveryError.js"; const logger = Logger.get("ParallelPaseDiscovery"); /** * Delay between PASE attempts to DIFFERENT discovered devices. Kept short because cross-device attempts do * not share a responder state — this stagger only avoids a burst of simultaneous mDNS-triggered PASE starts * when many devices respond at once. Per-address stagger for a single device lives in CommissioningConnection. */ const CROSS_DEVICE_STAGGER_DELAY = Seconds(1); /** * Base class for discovery flows that run parallel PASE establishments with a first-to-win race gate. * * Subclasses override {@link onDiscovered} to launch their specific PASE-time action via * {@link registerAttempt}, providing: * - a {@code factory} that creates the PASE attempt and accepts the {@code winOnPase} gate callback, * - an {@code extractWinner} to pull the result value from the settled promise. * * Attempts are staggered: the first starts immediately, each subsequent one waits an additional * {@link CROSS_DEVICE_STAGGER_DELAY}. The internal abort signal fires once the race is decided: when a candidate * wins, when discovery is interrupted before a win, or when discovery ends without a win. From then on no further * attempt starts and {@code winOnPase} refuses every attempt. */ export abstract class ParallelPaseDiscovery extends Discovery { #paseWon = false; #pending = new Set>(); #abort = new AbortController(); #winner?: W; #winnerAttempt?: Promise; #winnerError?: unknown; #extractWinner?: (result: unknown) => W | undefined; #attemptErrors = new Array(); #attemptCount = 0; #startedCount = 0; protected get abortSignal() { return this.#abort.signal; } protected get paseWon() { return this.#paseWon; } /** Label used in the aggregate error when awaiting candidate cleanup. */ protected abstract get cleanupLabel(): string; /** Error message prefix used when no winner was established. */ protected abstract get failureMessage(): string; /** * Registers a parallel PASE attempt for the given node. * * @param factory Produces the attempt promise. Receives a {@code winOnPase} callback that the implementation * passes as the {@code continueXxx} option to the underlying PASE/commissioning call. When invoked, it * performs the race-gate logic and returns true if this attempt wins, false if the race is already decided: * another attempt won, or discovery ended or was interrupted. * @param extractWinner Extracts the winner value from the settled attempt result. Called only for the * winning attempt after its promise resolves. */ protected registerAttempt( factory: (winOnPase: () => boolean) => R | PromiseLike, extractWinner: (result: R) => W | undefined, ): void { let attempt!: Promise; let isWinner = false; const winOnPase = () => { if (this.#abort.signal.aborted) { return false; } this.#paseWon = true; isWinner = true; this.stop(); this.#abort.abort(); this.#pending.delete(attempt); this.#winnerAttempt = attempt; this.#extractWinner = extractWinner as (result: unknown) => W | undefined; return true; }; const attemptIndex = this.#attemptCount++; const stagger = Millis(attemptIndex * CROSS_DEVICE_STAGGER_DELAY); const startFactory = () => { this.#startedCount++; return factory(winOnPase); }; const startAfter = stagger > 0 ? Abort.sleep("PASE stagger", this.#abort.signal, stagger) : Promise.resolve(); attempt = startAfter .then(() => { if (!this.#abort.signal.aborted) { return startFactory(); } }) .catch(error => { if (isWinner) { // Winner's error is meaningful — capture it for onComplete to rethrow this.#winnerError = error; return undefined; } // Loser: resolve to prevent unhandled rejection. // Collect the error for the final failure message unless it was a cancellation // triggered by our own abort (another candidate won, or discovery ended or was interrupted). if (causedBy(error, CanceledError)) { logger.debug("Canceled parallel commissioning attempt:", Diagnostic.errorMessage(error)); } else if ( causedBy( error, CommissioningError, PasscodeMismatchError, PeerCommunicationError, DestroyedDependencyError, CrashedDependencyError, ) ) { this.#attemptErrors.push(error); logger.debug("Failed parallel commissioning attempt:", Diagnostic.errorMessage(error)); } else { this.#attemptErrors.push(error); logger.warn("Unexpected error from parallel commissioning attempt:", error); } return undefined; }) .finally(() => { this.#pending.delete(attempt); }); this.#pending.add(attempt); } /** * Ending discovery before a candidate has won, also through the timeout, closes the race at once so no attempt can * win while the scan winds down. */ override stop() { if (!this.#paseWon) { this.#abort.abort(); } super.stop(); } /** * Before a candidate has won, an interruption aborts every attempt so none can win afterwards. After the win the * winner's operation is not interrupted, and the discovery completes with the winner or the winner's error. */ protected override onInterrupt() { if (this.#paseWon) { return false; } this.#abort.abort(); return true; } protected override async onComplete(): Promise { if (!this.#paseWon) { // Discovery ended without any candidate winning PASE — cancel any remaining attempts. this.#abort.abort(); } try { // Await winner's full operation (e.g. commissioning). If the winner captured an error // during its .catch handler, rethrow it here so the caller sees a meaningful failure. if (this.#winnerAttempt !== undefined) { const result = await this.#winnerAttempt; if (this.#winnerError !== undefined) { throw this.#winnerError; } this.#winner = this.#extractWinner!(result); } } finally { // Await loser cleanup (canceled PASE sessions, etc.). All losers resolve to undefined // so rejections here would indicate an unexpected bug — log but don't mask the winner error. await MatterAggregateError.allSettled([...this.#pending], this.cleanupLabel).catch(error => { logger.warn("Unexpected error during parallel attempt cleanup:", error); }); } if (this.#winner === undefined) { if (this.#attemptCount === 0) { throw new DeviceNotDiscoveredError(`${this} failed: No commissionable device was discovered`); } if (this.#attemptErrors.length > 0) { throw new DiscoveryAggregateError( this.#attemptErrors, `${this} failed: ${this.failureMessage} (${this.#attemptErrors.length} of ${this.#startedCount} started attempt(s) failed, ${this.#attemptCount} discovered)`, ); } throw new DiscoveryIncompleteError( `${this} failed: ${this.failureMessage} (${this.#startedCount} attempt(s) started of ${this.#attemptCount} discovered, all canceled or timed out)`, ); } return this.#winner; } }