Skip to content

Commit a413adb

Browse files
committed
Improvement: lifetime-valid close observation, fast closed-connection rejection, and portable loopback dialing in the test harness.
1 parent b07fa85 commit a413adb

2 files changed

Lines changed: 33 additions & 11 deletions

File tree

src/lib/util/eventedhttp.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ describe("eventedhttp", () => {
1515
};
1616

1717
// Creates a connected client and registers it for teardown. One client instance owns one TCP connection, which is
18-
// exactly one server-side HAPConnection.
18+
// exactly one server-side HAPConnection. The server binds the unspecified address, which is not a portable connect
19+
// destination, so we dial the loopback address of the bound family instead.
1920
const connectClient = async (): Promise<HAPHTTPClient> => {
2021
const address = server.address();
21-
const client = new HAPHTTPClient(address.address, address.port);
22+
const host = ["0.0.0.0", "::"].includes(address.address) ? ((address.family === "IPv6") ? "::1" : "127.0.0.1") : address.address;
23+
const client = new HAPHTTPClient(host, address.port);
2224
await client.connect();
2325
clients.push(client);
2426
return client;

src/test-utils/HAPHTTPClient.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { HAPHTTPCode, HAPPairingHTTPCode } from "../lib/HAPServer";
77
import { PairingInformation, PermissionTypes } from "../lib/model/AccessoryInfo";
88
import { HAPEncryption, HAPUsername } from "../lib/util/eventedhttp";
99
import * as hapCrypto from "../lib/util/hapCrypto";
10-
import { awaitEventOnce } from "../lib/util/promise-utils";
1110
import * as tlv from "../lib/util/tlv";
1211
import {
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

Comments
 (0)