@@ -10,42 +10,92 @@ import { WebSocketServer } from 'ws';
1010export interface AdapterOptions {
1111 restPort ?: number ;
1212 wsPort ?: number ;
13+ /**
14+ * How long (ms) the adapter waits for the browser entity to answer a command
15+ * before failing the REST request. Prevents a single unanswered command from
16+ * wedging the adapter (and therefore the test harness) indefinitely.
17+ * Override with ADAPTER_REQUEST_TIMEOUT_MS. Defaults to 30s.
18+ */
19+ requestTimeoutMs ?: number ;
20+ }
21+
22+ /**
23+ * Tracks an outstanding command sent to the browser entity, awaiting its
24+ * response over the WebSocket.
25+ */
26+ interface Waiter {
27+ resolve : ( data : any ) => void ;
28+ reject : ( err : Error ) => void ;
29+ timer : ReturnType < typeof setTimeout > ;
1330}
1431
1532let server : http . Server | undefined ;
1633
1734export function startAdapter ( options ?: AdapterOptions ) {
1835 const restPort = options ?. restPort ?? 8000 ;
1936 const wsPort = options ?. wsPort ?? 8001 ;
37+ const requestTimeoutMs =
38+ options ?. requestTimeoutMs ?? Number ( process . env . ADAPTER_REQUEST_TIMEOUT_MS ?? 30000 ) ;
2039
2140 const wss = new WebSocketServer ( { port : wsPort } ) ;
22- const waiters : Record < string , ( data : unknown ) => void > = { } ;
41+ const waiters : Record < string , Waiter > = { } ;
2342
2443 console . log ( 'Running contract test harness adapter.' ) ;
2544 wss . on ( 'connection' , async ( ws ) => {
2645 ws . on ( 'error' , console . error ) ;
2746
2847 ws . on ( 'message' , ( stringData : string ) => {
2948 const data = JSON . parse ( stringData ) ;
30- if ( Object . prototype . hasOwnProperty . call ( waiters , data . reqId ) ) {
31- waiters [ data . reqId ] ( data ) ;
49+ const waiter = waiters [ data . reqId ] ;
50+ if ( waiter ) {
51+ clearTimeout ( waiter . timer ) ;
3252 delete waiters [ data . reqId ] ;
53+ waiter . resolve ( data ) ;
3354 } else {
3455 console . error ( 'Did not find outstanding request' , data . reqId ) ;
3556 }
3657 } ) ;
3758
38- // eslint-disable-next-line @typescript-eslint/no-explicit-any
39- const send = ( data : { [ key : string ] : unknown ; reqId : string } ) : Promise < any > => {
40- let resolver : ( data : unknown ) => void ;
41- const waiter = new Promise ( ( resolve ) => {
42- resolver = resolve ;
59+ // When the entity's socket drops, fail every outstanding command instead
60+ // of leaving REST requests (and the harness) hanging forever.
61+ ws . on ( 'close' , ( ) => {
62+ Object . keys ( waiters ) . forEach ( ( reqId ) => {
63+ const waiter = waiters [ reqId ] ;
64+ clearTimeout ( waiter . timer ) ;
65+ delete waiters [ reqId ] ;
66+ waiter . reject ( new Error ( 'WebSocket connection to entity closed before a response was received.' ) ) ;
4367 } ) ;
44- // @ts -expect-error The body of the above assignment runs sequentially.
45- waiters [ data . reqId ] = resolver ;
46- ws . send ( JSON . stringify ( data ) ) ;
47- return waiter ;
48- } ;
68+ } ) ;
69+
70+ const send = ( data : { [ key : string ] : unknown ; reqId : string } ) : Promise < any > =>
71+ new Promise ( ( resolve , reject ) => {
72+ const timer = setTimeout ( ( ) => {
73+ delete waiters [ data . reqId ] ;
74+ reject (
75+ new Error (
76+ `Adapter timed out after ${ requestTimeoutMs } ms awaiting entity response to '${ data . command } ' (reqId ${ data . reqId } ).` ,
77+ ) ,
78+ ) ;
79+ } , requestTimeoutMs ) ;
80+ waiters [ data . reqId ] = { resolve, reject, timer } ;
81+ ws . send ( JSON . stringify ( data ) ) ;
82+ } ) ;
83+
84+ // Wrap async route handlers so an unanswered/timed-out command produces a
85+ // 500 for that single request rather than an unhandled rejection that
86+ // leaves the request — and the harness — hanging.
87+ const guard =
88+ (
89+ handler : ( req : express . Request , res : express . Response ) => Promise < void > ,
90+ ) : express . RequestHandler =>
91+ ( req , res ) => {
92+ handler ( req , res ) . catch ( ( err ) => {
93+ console . error ( 'Adapter request failed:' , err ) ;
94+ if ( ! res . headersSent ) {
95+ res . status ( 500 ) . send ( String ( err ) ) ;
96+ }
97+ } ) ;
98+ } ;
4999
50100 if ( server ) {
51101 await util . promisify ( server . close ) . call ( server ) ;
@@ -63,54 +113,71 @@ export function startAdapter(options?: AdapterOptions) {
63113 ) ;
64114 app . use ( bodyParser . json ( ) ) ;
65115
66- app . get ( '/' , async ( _req , res ) => {
67- const commandResult = await send ( { command : 'getCapabilities' , reqId : randomUUID ( ) } ) ;
68- res . header ( 'Content-Type' , 'application/json' ) ;
69- res . json ( commandResult ) ;
70- } ) ;
116+ app . get (
117+ '/' ,
118+ guard ( async ( _req , res ) => {
119+ const commandResult = await send ( { command : 'getCapabilities' , reqId : randomUUID ( ) } ) ;
120+ res . header ( 'Content-Type' , 'application/json' ) ;
121+ res . json ( commandResult ) ;
122+ } ) ,
123+ ) ;
71124
72125 app . delete ( '/' , ( ) => {
73126 process . exit ( ) ;
74127 } ) ;
75128
76- app . post ( '/' , async ( req , res ) => {
77- const commandResult = await send ( {
78- command : 'createClient' ,
79- body : req . body ,
80- reqId : randomUUID ( ) ,
81- } ) ;
82- if ( commandResult . resourceUrl ) {
83- res . set ( 'Location' , commandResult . resourceUrl ) ;
84- }
85- if ( commandResult . status ) {
86- res . status ( commandResult . status ) ;
87- }
88- res . send ( ) ;
89- } ) ;
129+ app . post (
130+ '/' ,
131+ guard ( async ( req , res ) => {
132+ const commandResult = await send ( {
133+ command : 'createClient' ,
134+ body : req . body ,
135+ reqId : randomUUID ( ) ,
136+ } ) ;
137+ if ( commandResult . resourceUrl ) {
138+ res . set ( 'Location' , commandResult . resourceUrl ) ;
139+ }
140+ if ( commandResult . status ) {
141+ res . status ( commandResult . status ) ;
142+ }
143+ res . send ( ) ;
144+ } ) ,
145+ ) ;
90146
91- app . post ( '/clients/:id' , async ( req , res ) => {
92- const commandResult = await send ( {
93- command : 'runCommand' ,
94- id : req . params . id ,
95- body : req . body ,
96- reqId : randomUUID ( ) ,
97- } ) ;
98- if ( commandResult . status ) {
99- res . status ( commandResult . status ) ;
100- }
101- if ( commandResult . body ) {
102- res . write ( JSON . stringify ( commandResult . body ) ) ;
103- }
104- res . send ( ) ;
105- } ) ;
147+ app . post (
148+ '/clients/:id' ,
149+ guard ( async ( req , res ) => {
150+ const commandResult = await send ( {
151+ command : 'runCommand' ,
152+ id : req . params . id ,
153+ body : req . body ,
154+ reqId : randomUUID ( ) ,
155+ } ) ;
156+ if ( commandResult . status ) {
157+ res . status ( commandResult . status ) ;
158+ }
159+ if ( commandResult . body ) {
160+ res . write ( JSON . stringify ( commandResult . body ) ) ;
161+ }
162+ res . send ( ) ;
163+ } ) ,
164+ ) ;
106165
107- app . delete ( '/clients/:id' , async ( req , res ) => {
108- await send ( { command : 'deleteClient' , id : req . params . id , reqId : randomUUID ( ) } ) ;
109- res . send ( ) ;
110- } ) ;
166+ app . delete (
167+ '/clients/:id' ,
168+ guard ( async ( req , res ) => {
169+ await send ( { command : 'deleteClient' , id : req . params . id , reqId : randomUUID ( ) } ) ;
170+ res . send ( ) ;
171+ } ) ,
172+ ) ;
111173
112174 server = app . listen ( restPort , ( ) => {
113175 console . log ( 'Listening on port %d' , restPort ) ;
114176 } ) ;
177+ // Surface bind failures (e.g. EADDRINUSE) instead of silently never
178+ // logging "Listening on port".
179+ server . on ( 'error' , ( err ) => {
180+ console . error ( `REST server error on port ${ restPort } :` , err ) ;
181+ } ) ;
115182 } ) ;
116183}
0 commit comments