Skip to content

Commit b07fa85

Browse files
committed
Fix: test harness rewritten around a connection-owning HAP client, removing the axios dependency.
1 parent 570eb69 commit b07fa85

8 files changed

Lines changed: 686 additions & 851 deletions

File tree

package-lock.json

Lines changed: 0 additions & 334 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@
7373
"@types/plist": "^3.0.5",
7474
"@typescript-eslint/eslint-plugin": "^8.63.0",
7575
"@typescript-eslint/parser": "^8.63.0",
76-
"axios": "^1.18.1",
7776
"commander": "^15.0.0",
7877
"escape-html": "^1.0.3",
7978
"eslint": "^10.7.0",

src/lib/HAPServer.spec.ts

Lines changed: 240 additions & 245 deletions
Large diffs are not rendered by default.

src/lib/util/eventedhttp.spec.ts

Lines changed: 78 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,44 @@
1-
import axios, { AxiosResponse } from "axios";
2-
import { Agent, IncomingMessage, ServerResponse } from "http";
1+
import { IncomingMessage, ServerResponse } from "http";
32
import { HAPHTTPClient } from "../../test-utils/HAPHTTPClient";
43
import { HAPHTTPCode } from "../HAPServer";
54
import { EventedHTTPServer, EventedHTTPServerEvent, HAPConnection, HAPConnectionEvent } from "./eventedhttp";
65
import { awaitEventOnce, PromiseTimeout } from "./promise-utils";
76

87
describe("eventedhttp", () => {
9-
let httpAgent: Agent;
8+
let clients: HAPHTTPClient[];
109
let server: EventedHTTPServer;
11-
let baseUrl: string;
1210

1311
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1412
const defaultRequestHandler = (connection: any, request: any, response: ServerResponse) => {
1513
response.writeHead(HAPHTTPCode.OK, { "Content-Type": "plain/text" });
1614
response.end("Hello World", "ascii");
1715
};
1816

17+
// Creates a connected client and registers it for teardown. One client instance owns one TCP connection, which is
18+
// exactly one server-side HAPConnection.
19+
const connectClient = async (): Promise<HAPHTTPClient> => {
20+
const address = server.address();
21+
const client = new HAPHTTPClient(address.address, address.port);
22+
await client.connect();
23+
clients.push(client);
24+
return client;
25+
};
26+
1927
beforeEach(async () => {
20-
// used to do long living http connections without own tcp interface
21-
httpAgent = new Agent({
22-
keepAlive: true,
23-
});
28+
clients = [];
2429

2530
server = new EventedHTTPServer();
2631
// @ts-expect-error: private access
2732
server.tcpServer.unref();
2833

2934
server.listen(0);
3035
await awaitEventOnce(server, EventedHTTPServerEvent.LISTENING);
31-
32-
const address = server.address();
33-
baseUrl = `http://0.0.0.0:${address.port}`;
3436
});
3537

3638
afterEach(() => {
39+
for (const client of clients) {
40+
client.destroy();
41+
}
3742
server.stop();
3843
server.destroy();
3944
});
@@ -49,8 +54,10 @@ describe("eventedhttp", () => {
4954
response.end("Hello World", "ascii");
5055
});
5156

52-
const result: AxiosResponse<string> = await axios.get(`${baseUrl}/test?query=true`, { httpAgent });
53-
expect(result.data).toEqual("Hello World");
57+
const client = await connectClient();
58+
const result = await client.writeHTTPRequest("GET", "/test?query=true");
59+
expect(result.statusCode).toBe(HAPHTTPCode.OK);
60+
expect(result.body.toString()).toEqual("Hello World");
5461
const connection = await connectionOpened;
5562

5663
// we simulate the connection getting authenticated (e.g. through pair-verify)
@@ -62,7 +69,7 @@ describe("eventedhttp", () => {
6269
expect(connection.getLocalAddress("ipv4")).toBe("127.0.0.1");
6370
expect(connection.getLocalAddress("ipv6")).toBe("::1");
6471

65-
httpAgent.destroy(); // disconnect HAPConnection
72+
client.destroy(); // disconnect HAPConnection
6673

6774
await connectionClosed;
6875
});
@@ -74,20 +81,23 @@ describe("eventedhttp", () => {
7481
// that made the request must persist till the response is sent out!
7582

7683
const username = "XX:XX:XX:XX:XX:XX";
77-
const secondAgent = new Agent({ keepAlive: true });
7884

7985
// OPEN CONNECTIONS
8086
server.once(EventedHTTPServerEvent.REQUEST, defaultRequestHandler);
8187
const connectionOpened0: Promise<HAPConnection> = awaitEventOnce(server, EventedHTTPServerEvent.CONNECTION_OPENED);
82-
const result0: AxiosResponse<string> = await axios.get(baseUrl, { httpAgent });
83-
expect(result0.data).toEqual("Hello World");
88+
const client0 = await connectClient();
8489
const connection0 = await connectionOpened0;
90+
const result0 = await client0.writeHTTPRequest("GET", "/");
91+
expect(result0.statusCode).toBe(HAPHTTPCode.OK);
92+
expect(result0.body.toString()).toEqual("Hello World");
8593

8694
server.once(EventedHTTPServerEvent.REQUEST, defaultRequestHandler);
8795
const connectionOpened1: Promise<HAPConnection> = awaitEventOnce(server, EventedHTTPServerEvent.CONNECTION_OPENED);
88-
const result1: AxiosResponse<string> = await axios.get(baseUrl, { httpAgent: secondAgent });
89-
expect(result1.data).toEqual("Hello World");
96+
const client1 = await connectClient();
9097
const connection1 = await connectionOpened1;
98+
const result1 = await client1.writeHTTPRequest("GET", "/");
99+
expect(result1.statusCode).toBe(HAPHTTPCode.OK);
100+
expect(result1.body.toString()).toEqual("Hello World");
91101

92102
// AUTHENTICATE CONNECTIONS
93103
const authenticatedEvent0: Promise<string> = awaitEventOnce(connection0, HAPConnectionEvent.AUTHENTICATED);
@@ -100,42 +110,39 @@ describe("eventedhttp", () => {
100110

101111
// we make a request that we don't answer yet!
102112
const queuedRequestPromise: Promise<[HAPConnection, IncomingMessage, ServerResponse]> = awaitEventOnce(server, EventedHTTPServerEvent.REQUEST);
103-
const pendingRequest = axios.get(baseUrl, { httpAgent }); // simulate a "unpair" request!
113+
client0.write(client0.formatHTTPRequest("GET", "/")); // simulate a "unpair" request!
104114
const queuedResponse = (await queuedRequestPromise)[2];
105115

106116
// do the unpair!!
107117
const connectionClosed: Promise<HAPConnection> = awaitEventOnce(server, EventedHTTPServerEvent.CONNECTION_CLOSED);
108118
EventedHTTPServer.destroyExistingConnectionsAfterUnpair(connection0, username);
109119
await expect(connectionClosed).resolves.toBe(connection1);
110120

111-
await PromiseTimeout(200);
112-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
113-
expect((secondAgent as any).totalSocketCount <= 0).toBeTruthy(); // assert that the client side was closed!
114-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
115-
expect((httpAgent as any).totalSocketCount).toBe(1); // the other socket shouldn't be closed yet
121+
// the other connection's client side must observe the teardown, while the requesting connection stays open until
122+
// its response has been sent out
123+
await client1.waitForClose();
124+
expect(client0.isClosed).toBe(false);
116125

117126
// now finish the http request from above!
118127
defaultRequestHandler(undefined, undefined, queuedResponse!); // just reuse the request handler from above!
119-
const pendingRequestResult = await pendingRequest;
120-
expect(pendingRequestResult.data).toBe("Hello World");
128+
const pendingResponse = await client0.readHTTPResponse();
129+
expect(pendingResponse.statusCode).toBe(HAPHTTPCode.OK);
130+
expect(pendingResponse.body.toString()).toBe("Hello World");
121131

122-
await PromiseTimeout(200);
123-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
124-
expect((httpAgent as any).totalSocketCount <= 0).toBeTruthy(); // the other socket shall now be closed!
132+
// once the response is out the server tears the requesting connection down as well
133+
await client0.waitForClose();
125134
});
126135

127136
test("event notifications", async () => {
128-
const address = server.address();
129-
130137
server.once(EventedHTTPServerEvent.REQUEST, defaultRequestHandler);
131138

132139
const connectionOpened: Promise<HAPConnection> = awaitEventOnce(server, EventedHTTPServerEvent.CONNECTION_OPENED);
133-
const result: AxiosResponse<string> = await axios.get(`${baseUrl}/test?query=true`, { httpAgent });
134-
expect(result.data).toEqual("Hello World");
140+
const client = await connectClient();
135141
const connection = await connectionOpened;
136142

137-
const client = new HAPHTTPClient(httpAgent, address.address, address.port);
138-
await client.attachSocket(); // capture the free socket of the http agent!
143+
const result = await client.writeHTTPRequest("GET", "/test?query=true");
144+
expect(result.statusCode).toBe(HAPHTTPCode.OK);
145+
expect(result.body.toString()).toEqual("Hello World");
139146

140147
connection.enableEventNotifications(1, 1);
141148
// we implicitly test below that this event won't be delivered!
@@ -151,11 +158,14 @@ describe("eventedhttp", () => {
151158
connection.sendEvent(1, 1, "Hello Mars!");
152159
connection.sendEvent(1, 1, "Hello World!"); // should send
153160

154-
// no immediate delivery!
155-
await PromiseTimeout(50);
161+
// Regular events are coalesced: nothing may be delivered before the coalescing window elapses. We wait a fraction of
162+
// that window - comfortably less than it, since a timer never fires early - and assert nothing has arrived yet.
163+
await PromiseTimeout(HAPConnection.EVENT_COALESCING_DELAY / 5);
156164
expect(client.receiveBufferCount).toBe(0);
157165

158-
await PromiseTimeout(300);
166+
// Once the coalescing timer fires the batched events flush as a single notification. We await the actual arrival
167+
// rather than a fixed delay so the assertion holds regardless of how the runtime schedules the round-trip.
168+
await client.waitForData(1);
159169
expect(client.receiveBufferCount).toBe(1);
160170
expect(client.popReceiveBuffer().toString()).toBe("EVENT/1.0 200 OK\r\n" +
161171
"Content-Type: application/hap+json\r\n" +
@@ -169,9 +179,11 @@ describe("eventedhttp", () => {
169179

170180

171181
server.broadcastEvent(1, 1, "Hello Sun!", undefined, false);
172-
// event with immediate delivery shall send currently queued events. Nothing gets out of order!
182+
// An event flagged for immediate delivery flushes the currently queued events along with it. The wait is bounded well
183+
// below the coalescing window, so a regression to timer-based delivery fails loudly instead of passing late with the
184+
// same bytes.
173185
connection.sendEvent(1, 1, "Hello Mars!", true);
174-
await PromiseTimeout(10);
186+
await client.waitForData(1, HAPConnection.EVENT_COALESCING_DELAY / 2);
175187
expect(client.receiveBufferCount).toBe(1);
176188
expect(client.popReceiveBuffer().toString()).toBe("EVENT/1.0 200 OK\r\n" +
177189
"Content-Type: application/hap+json\r\n" +
@@ -180,23 +192,24 @@ describe("eventedhttp", () => {
180192
"{\"characteristics\":[{\"aid\":1,\"iid\":1,\"value\":\"Hello Mars!\"},{\"aid\":1,\"iid\":1,\"value\":\"Hello Sun!\"}]}");
181193

182194

195+
// The originating connection is excluded from its own broadcast, so it must receive nothing. Exclusion is decided
196+
// synchronously when the broadcast is dispatched, so a brief settle is enough to confirm non-delivery.
183197
server.broadcastEvent(1, 1, "Hello Sun!", connection, true);
184-
await PromiseTimeout(10);
198+
await PromiseTimeout(HAPConnection.EVENT_COALESCING_DELAY / 5);
185199
expect(client.receiveBufferCount).toBe(0);
186200

187201
// NOW we test event delivery when there is ongoing request
188202
const testEventDelivery = async (sendEvents: () => Promise<void>, assertResult: () => Promise<void>) => {
189203
const queuedRequestPromise: Promise<[HAPConnection, IncomingMessage, ServerResponse]> = awaitEventOnce(server, EventedHTTPServerEvent.REQUEST);
190-
// we can't use axios here, as our special EVENT http message is involved!
191-
204+
// the request is deliberately left unanswered for now, so we write the raw bytes and defer reading the response
192205
client.write(client.formatHTTPRequest("GET", "/"));
193206
const queuedResponse: ServerResponse = (await queuedRequestPromise)[2];
194207

195208
await sendEvents();
196209

197210
defaultRequestHandler(undefined, undefined, queuedResponse!); // just reuse the request handler from above!
198211

199-
await PromiseTimeout(20);
212+
// Each assertion awaits exactly the delivery it expects, so no fixed settle delay is needed here.
200213
await assertResult();
201214
};
202215

@@ -207,17 +220,11 @@ describe("eventedhttp", () => {
207220
connection.sendEvent(1, 1, "Hello Mars!");
208221
},
209222
async () => {
210-
expect(client.receiveBufferCount > 0).toBeTruthy();
211-
212-
let eventMessage = "";
213-
// sometimes the HTTP response message and the EVENT message are combined
214-
// into a single TCP segment, sometimes they get sent separately.
215-
while (client.receiveBufferCount > 0) {
216-
eventMessage = client.popReceiveBuffer().toString();
217-
}
218-
219-
expect(eventMessage.includes("EVENT/1.0")).toBeTruthy();
220-
const event = eventMessage.substring(eventMessage.indexOf("EVENT")); // splicing away the http response!
223+
// The immediate event forces the HTTP response and the EVENT message out together; they may share one TCP segment
224+
// or arrive as two, so we read until the EVENT marker is present rather than assuming a segment count. The read is
225+
// bounded well below the coalescing window so an event that wrongly arrives on the coalescing timer fails loudly.
226+
const received = await client.readUntil("EVENT/1.0", HAPConnection.EVENT_COALESCING_DELAY / 2);
227+
const event = received.substring(received.indexOf("EVENT")); // splicing away the http response!
221228
expect(event).toBe("EVENT/1.0 200 OK\r\n" +
222229
"Content-Type: application/hap+json\r\n" +
223230
"Content-Length: 102\r\n" +
@@ -232,11 +239,17 @@ describe("eventedhttp", () => {
232239
connection.sendEvent(1, 1, "Hello Sun!");
233240
},
234241
async () => {
235-
expect(client.receiveBufferCount).toBe(1);
242+
// These events are coalesced, so only the HTTP response returns with the request and it must not contain an EVENT.
243+
await client.waitForData(1);
236244
expect(client.popReceiveBuffer().toString().includes("EVENT")).toBeFalsy();
237245

238-
await PromiseTimeout(300);
239-
expect(client.receiveBufferCount).toBe(1);
246+
// Nothing further may arrive before the coalescing window elapses - a flush-with-response regression lands here
247+
// regardless of whether the runtime coalesced it into the response segment or sent it as its own.
248+
await PromiseTimeout(HAPConnection.EVENT_COALESCING_DELAY / 5);
249+
expect(client.receiveBufferCount).toBe(0);
250+
251+
// After the coalescing window the batched event is flushed as its own notification.
252+
await client.waitForData(1);
240253
expect(client.popReceiveBuffer().toString()).toBe("EVENT/1.0 200 OK\r\n" +
241254
"Content-Type: application/hap+json\r\n" +
242255
"Content-Length: 100\r\n" +
@@ -248,29 +261,21 @@ describe("eventedhttp", () => {
248261
await testEventDelivery(
249262
async () => {
250263
connection.sendEvent(1, 1, "Hello Mars!");
251-
await PromiseTimeout(300); // the timeout runs out while the request is still processed
264+
// Let the coalescing timer fire while the request is still open, exercising the path where queued events are
265+
// flushed together with the response instead of on their own timer.
266+
await PromiseTimeout(HAPConnection.EVENT_COALESCING_DELAY + 50);
252267
},
253268
async () => {
254-
expect(client.receiveBufferCount > 0).toBeTruthy();
255-
256-
let eventMessage = "";
257-
// sometimes the HTTP response message and the EVENT message are combined
258-
// into a single TCP segment, sometimes they get sent separately.
259-
while (client.receiveBufferCount > 0) {
260-
eventMessage = client.popReceiveBuffer().toString();
261-
}
262-
263-
expect(eventMessage.includes("EVENT/1.0")).toBeTruthy();
264-
const event = eventMessage.substring(eventMessage.indexOf("EVENT")); // splicing away the http response!
269+
// Response and coalesced event flush together and may or may not share a TCP segment, so read until the marker.
270+
const received = await client.readUntil("EVENT/1.0");
271+
const event = received.substring(received.indexOf("EVENT")); // splicing away the http response!
265272
expect(event).toBe("EVENT/1.0 200 OK\r\n" +
266273
"Content-Type: application/hap+json\r\n" +
267274
"Content-Length: 61\r\n" +
268275
"\r\n" +
269276
"{\"characteristics\":[{\"aid\":1,\"iid\":1,\"value\":\"Hello Mars!\"}]}");
270277
},
271278
);
272-
273-
client.releaseSocket();
274279
});
275280

276281
// TODO test events not delivered while in a request!

src/lib/util/eventedhttp.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,13 @@ export declare interface HAPConnection {
337337
*/
338338
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
339339
export class HAPConnection extends EventEmitter {
340+
/**
341+
* Delay in milliseconds before queued (non-immediate) characteristic event notifications are coalesced and flushed on
342+
* the connection. HAP batches regular events so a burst of value changes produces a single notification rather than one
343+
* per change; events flagged for immediate delivery bypass this window entirely.
344+
*/
345+
static readonly EVENT_COALESCING_DELAY = 250;
346+
340347
/**
341348
* @private file-private API
342349
*/
@@ -536,7 +543,7 @@ export class HAPConnection extends EventEmitter {
536543

537544
// if there is already a timer running we just add it in the queue.
538545
if (!this.eventsTimer) {
539-
this.eventsTimer = setTimeout(this.handleEventsTimeout.bind(this), 250);
546+
this.eventsTimer = setTimeout(this.handleEventsTimeout.bind(this), HAPConnection.EVENT_COALESCING_DELAY);
540547
this.eventsTimer.unref();
541548
}
542549
}

0 commit comments

Comments
 (0)