1- import axios , { AxiosResponse } from "axios" ;
2- import { Agent , IncomingMessage , ServerResponse } from "http" ;
1+ import { IncomingMessage , ServerResponse } from "http" ;
32import { HAPHTTPClient } from "../../test-utils/HAPHTTPClient" ;
43import { HAPHTTPCode } from "../HAPServer" ;
54import { EventedHTTPServer , EventedHTTPServerEvent , HAPConnection , HAPConnectionEvent } from "./eventedhttp" ;
65import { awaitEventOnce , PromiseTimeout } from "./promise-utils" ;
76
87describe ( "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!
0 commit comments