@@ -7,7 +7,6 @@ import { HAPHTTPCode, HAPPairingHTTPCode } from "../lib/HAPServer";
77import { PairingInformation , PermissionTypes } from "../lib/model/AccessoryInfo" ;
88import { HAPEncryption , HAPUsername } from "../lib/util/eventedhttp" ;
99import * as hapCrypto from "../lib/util/hapCrypto" ;
10- import { awaitEventOnce } from "../lib/util/promise-utils" ;
1110import * as tlv from "../lib/util/tlv" ;
1211import {
1312 AccessoriesResponse ,
@@ -65,6 +64,9 @@ export class HAPHTTPClient {
6564 private currentSocket ?: Socket ;
6665 private encryption ?: HAPEncryption ;
6766 private socketClosed = false ;
67+ // Settles when the underlying socket has fully closed. Captured at connect() so close observation stays valid for the
68+ // client's entire lifetime, even after destroy() has dropped the socket reference.
69+ private socketClosePromise ?: Promise < void > ;
6870 private everConnected = false ;
6971
7072 private currentDataListener ?: ( data : Buffer ) => void ;
@@ -95,8 +97,11 @@ export class HAPHTTPClient {
9597 // A permanent error listener must exist for the socket's lifetime: without one, a late ECONNRESET (e.g. the server
9698 // tearing down first in afterEach) would crash the process. Failures surface loudly through receive timeouts instead.
9799 socket . on ( "error" , ( ) => { } ) ;
98- socket . on ( "close" , ( ) => {
99- this . socketClosed = true ;
100+ this . socketClosePromise = new Promise ( resolve => {
101+ socket . once ( "close" , ( ) => {
102+ this . socketClosed = true ;
103+ resolve ( ) ;
104+ } ) ;
100105 } ) ;
101106
102107 this . currentDataListener = data => {
@@ -133,15 +138,24 @@ export class HAPHTTPClient {
133138 }
134139
135140 /**
136- * Resolves once the underlying socket has fully closed, rejecting after {@link timeoutMs} if it never does. The direct
137- * observation primitive for tests asserting that the server tears a connection down.
141+ * Resolves once the underlying socket has fully closed, rejecting after {@link timeoutMs} if it never does. Close
142+ * observation is anchored to the promise captured at {@link connect}, so it is valid at any point in the client's
143+ * lifetime - including immediately after {@link destroy} - regardless of which side initiates the teardown.
138144 */
139145 async waitForClose ( timeoutMs = HAPHTTPClient . RECEIVE_TIMEOUT ) : Promise < void > {
140- if ( this . socketClosed ) {
141- return ;
146+ expect ( this . socketClosePromise ) . toBeDefined ( ) ;
147+
148+ let timeoutId : NodeJS . Timeout | undefined = undefined ;
149+
150+ const timeout = new Promise < void > ( ( _resolve , reject ) => {
151+ timeoutId = setTimeout ( ( ) => reject ( new Error ( "Timed out awaiting the connection to close." ) ) , timeoutMs ) ;
152+ } ) ;
153+
154+ try {
155+ await Promise . race ( [ this . socketClosePromise , timeout ] ) ;
156+ } finally {
157+ clearTimeout ( timeoutId ) ;
142158 }
143- expect ( this . currentSocket ) . toBeDefined ( ) ;
144- await awaitEventOnce ( this . currentSocket ! , "close" , timeoutMs ) ;
145159 }
146160
147161 get receiveBufferCount ( ) : number {
@@ -179,6 +193,12 @@ export class HAPHTTPClient {
179193 private awaitIncomingData ( deadline : number ) : Promise < void > {
180194 expect ( this . currentSocket ) . toBeDefined ( ) ;
181195
196+ // A connection that has already closed can never deliver the awaited bytes - reject immediately rather than riding
197+ // out the deadline only to fail with the less specific timeout error.
198+ if ( this . socketClosed ) {
199+ return Promise . reject ( new Error ( "Connection closed while awaiting incoming data." ) ) ;
200+ }
201+
182202 const socket = this . currentSocket ! ;
183203
184204 return new Promise ( ( resolve , reject ) => {
0 commit comments