@@ -122,6 +122,7 @@ beforeEach(() => {
122122 injectTraceContextIntoHeaders . mockClear ( ) ;
123123 writeSpans . length = 0 ;
124124 delete process . env . WORKFLOW_STREAMS_TRANSPORT ;
125+ delete process . env . WORKFLOW_REQUEST_TIMEOUT_MS ;
125126} ) ;
126127
127128afterEach ( ( ) => {
@@ -131,17 +132,27 @@ afterEach(() => {
131132} ) ;
132133
133134function makeSession (
134- config : { token ?: string } | undefined = { token : 'token' }
135+ config : { token ?: string } | undefined = { token : 'token' } ,
136+ connectAfterFirstWrite = false
135137) {
136- const writeHttp = vi . fn ( ) . mockResolvedValue ( undefined ) ;
138+ const writeHttp = vi . fn (
139+ async (
140+ _chunks : ( string | Uint8Array ) [ ] ,
141+ _attributes ?: Record < string , unknown > ,
142+ onRequestDispatched ?: ( ) => void
143+ ) => {
144+ onRequestDispatched ?.( ) ;
145+ }
146+ ) ;
137147 const closeHttp = vi . fn ( ) . mockResolvedValue ( undefined ) ;
138148 const session = createStreamWriteSession (
139149 'wrun_1' ,
140150 'stream/1' ,
141151 writerId ,
142152 config ,
143153 writeHttp ,
144- closeHttp
154+ closeHttp ,
155+ connectAfterFirstWrite
145156 ) ;
146157 activeSessions . push ( session ) ;
147158 return { session, writeHttp, closeHttp } ;
@@ -158,6 +169,124 @@ describe('v1 stream WebSocket writer lifecycle', () => {
158169 expect ( closeHttp ) . toHaveBeenCalledTimes ( 1 ) ;
159170 } ) ;
160171
172+ it ( 'starts WS during the second HTTP group and keeps writing HTTP until OPEN' , async ( ) => {
173+ process . env . WORKFLOW_STREAMS_TRANSPORT = 'ws' ;
174+ let releaseSecond : ( ( ) => void ) | undefined ;
175+ const secondPending = new Promise < void > ( ( resolve ) => {
176+ releaseSecond = resolve ;
177+ } ) ;
178+ const { session, writeHttp } = makeSession ( { token : 'token' } , true ) ;
179+
180+ await session . write ( 0 , [ 'one' ] ) ;
181+ expect ( sockets ) . toHaveLength ( 0 ) ;
182+ expect ( writeHttp ) . toHaveBeenNthCalledWith (
183+ 1 ,
184+ [ 'one' ] ,
185+ expect . objectContaining ( {
186+ 'workflow.stream.ws.session_first_write' : true ,
187+ 'workflow.stream.ws.http_group_ordinal' : 1 ,
188+ } )
189+ ) ;
190+
191+ writeHttp . mockImplementationOnce (
192+ async ( _chunks , _attributes , dispatched ) => {
193+ dispatched ?.( ) ;
194+ await secondPending ;
195+ }
196+ ) ;
197+ const second = session . write ( 1 , [ 'two' ] ) ;
198+ await vi . waitFor ( ( ) => expect ( sockets ) . toHaveLength ( 1 ) ) ;
199+ expect ( writeHttp ) . toHaveBeenNthCalledWith (
200+ 2 ,
201+ [ 'two' ] ,
202+ expect . objectContaining ( {
203+ 'workflow.stream.ws.connect_after_http_group' : 2 ,
204+ } ) ,
205+ expect . any ( Function )
206+ ) ;
207+ expect ( sockets [ 0 ] . sent ) . toHaveLength ( 0 ) ;
208+ releaseSecond ?.( ) ;
209+ await second ;
210+
211+ const third = session . write ( 2 , [ 'three' ] ) ;
212+ await third ;
213+ expect ( writeHttp . mock . calls [ 2 ] ?. [ 0 ] ) . toEqual ( [ 'three' ] ) ;
214+ sockets [ 0 ] . open ( ) ;
215+
216+ const fourth = session . write ( 3 , [ 'four' ] ) ;
217+ await vi . waitFor ( ( ) => expect ( sockets [ 0 ] . sent ) . toHaveLength ( 1 ) ) ;
218+ expect ( ( await decodeOne ( sockets [ 0 ] . sent [ 0 ] ) ) . meta ) . toMatchObject ( {
219+ type : 'write' ,
220+ chunkSeq : 3 ,
221+ numChunks : 1 ,
222+ } ) ;
223+ sockets [ 0 ] . reply (
224+ encodeFrame ( { type : 'write_ack' , reqId : 1 } , new Uint8Array ( ) )
225+ ) ;
226+ await fourth ;
227+ } ) ;
228+
229+ it ( 'closes over HTTP without waiting for the background socket' , async ( ) => {
230+ process . env . WORKFLOW_STREAMS_TRANSPORT = 'ws' ;
231+ const { session, closeHttp } = makeSession ( { token : 'token' } , true ) ;
232+ await session . write ( 0 , [ 'one' ] ) ;
233+ await session . write ( 1 , [ 'two' ] ) ;
234+ await vi . waitFor ( ( ) => expect ( sockets ) . toHaveLength ( 1 ) ) ;
235+
236+ await session . close ( ) ;
237+ expect ( closeHttp ) . toHaveBeenCalledTimes ( 1 ) ;
238+ expect ( sockets [ 0 ] . closed ) . toContainEqual ( [ 1000 , 'stream closed over HTTP' ] ) ;
239+ } ) ;
240+
241+ it ( 'retires a provisional socket after an ambiguous second HTTP outcome' , async ( ) => {
242+ process . env . WORKFLOW_STREAMS_TRANSPORT = 'ws' ;
243+ const error = new Error ( 'second HTTP outcome unknown' ) ;
244+ const { session, writeHttp } = makeSession ( { token : 'token' } , true ) ;
245+ await session . write ( 0 , [ 'one' ] ) ;
246+ let rejectSecond : ( ( error : Error ) => void ) | undefined ;
247+ const secondPending = new Promise < void > ( ( _resolve , reject ) => {
248+ rejectSecond = reject ;
249+ } ) ;
250+ writeHttp . mockImplementationOnce (
251+ async ( _chunks , _attributes , dispatched ) => {
252+ dispatched ?.( ) ;
253+ await secondPending ;
254+ }
255+ ) ;
256+
257+ const second = session . write ( 1 , [ 'two' ] ) ;
258+ await vi . waitFor ( ( ) => expect ( sockets ) . toHaveLength ( 1 ) ) ;
259+ rejectSecond ?.( error ) ;
260+ await expect ( second ) . rejects . toBe ( error ) ;
261+ await expect ( session . write ( 2 , [ 'three' ] ) ) . rejects . toBe ( error ) ;
262+ expect ( sockets [ 0 ] . sent ) . toHaveLength ( 0 ) ;
263+ expect ( sockets [ 0 ] . closed ) . toContainEqual ( [
264+ 1011 ,
265+ 'unknown stream write outcome' ,
266+ ] ) ;
267+ } ) ;
268+
269+ it ( 'does not start a socket after an ambiguous first HTTP outcome' , async ( ) => {
270+ process . env . WORKFLOW_STREAMS_TRANSPORT = 'ws' ;
271+ const error = new Error ( 'HTTP outcome unknown' ) ;
272+ const { session, writeHttp } = makeSession ( { token : 'token' } , true ) ;
273+ writeHttp . mockRejectedValueOnce ( error ) ;
274+
275+ await expect ( session . write ( 0 , [ 'one' ] ) ) . rejects . toBe ( error ) ;
276+ await expect ( session . write ( 1 , [ 'two' ] ) ) . rejects . toBe ( error ) ;
277+ expect ( sockets ) . toHaveLength ( 0 ) ;
278+ } ) ;
279+
280+ it ( 'closes a one-group stream without starting a socket' , async ( ) => {
281+ process . env . WORKFLOW_STREAMS_TRANSPORT = 'ws' ;
282+ const { session, closeHttp } = makeSession ( { token : 'token' } , true ) ;
283+
284+ await session . write ( 0 , [ 'only' ] ) ;
285+ await session . close ( ) ;
286+ expect ( closeHttp ) . toHaveBeenCalledTimes ( 1 ) ;
287+ expect ( sockets ) . toHaveLength ( 0 ) ;
288+ } ) ;
289+
161290 it ( 'sends immediately over HTTP while the initial socket connects, then switches to WS' , async ( ) => {
162291 process . env . WORKFLOW_STREAMS_TRANSPORT = 'ws' ;
163292 let releaseHttp : ( ( ) => void ) | undefined ;
@@ -239,27 +368,33 @@ describe('v1 stream WebSocket writer lifecycle', () => {
239368 } ) ;
240369 } ) ;
241370
242- it ( 'tombstones to HTTP when the background connect budget expires ' , async ( ) => {
371+ it ( 'bounds the background connect attempt without delaying HTTP writes ' , async ( ) => {
243372 vi . useFakeTimers ( ) ;
244373 process . env . WORKFLOW_STREAMS_TRANSPORT = 'ws' ;
245- const { session, writeHttp } = makeSession ( ) ;
374+ process . env . WORKFLOW_REQUEST_TIMEOUT_MS = '10000' ;
375+ const { session, writeHttp } = makeSession ( { token : 'token' } , true ) ;
246376 const first = session . write ( 0 , [ 'one' ] ) ;
247- await vi . advanceTimersByTimeAsync ( 0 ) ;
248- expect ( writeHttp ) . toHaveBeenCalledWith (
249- [ 'one' ] ,
250- expect . objectContaining ( {
251- 'workflow.stream.ws.session_first_write' : true ,
252- 'workflow.stream.ws.connecting_at_write' : true ,
253- } )
377+ await vi . waitFor ( ( ) =>
378+ expect ( writeHttp ) . toHaveBeenCalledWith (
379+ [ 'one' ] ,
380+ expect . objectContaining ( {
381+ 'workflow.stream.ws.session_first_write' : true ,
382+ 'workflow.stream.ws.connecting_at_write' : false ,
383+ 'workflow.stream.ws.connect_deferred_at_write' : true ,
384+ } )
385+ )
254386 ) ;
255387 await first ;
388+ expect ( sockets ) . toHaveLength ( 0 ) ;
389+ await session . write ( 1 , [ 'two' ] ) ;
390+ await vi . waitFor ( ( ) => expect ( sockets ) . toHaveLength ( 1 ) ) ;
256391
257- await vi . advanceTimersByTimeAsync ( 250 ) ;
258- const second = session . write ( 1 , [ 'two' ] ) ;
259- await second ;
260- expect ( writeHttp ) . toHaveBeenCalledTimes ( 2 ) ;
392+ await vi . advanceTimersByTimeAsync ( 10_000 ) ;
393+ await session . write ( 2 , [ 'three' ] ) ;
394+ expect ( writeHttp ) . toHaveBeenCalledTimes ( 3 ) ;
261395 expect ( writeHttp . mock . calls [ 0 ] ?. [ 0 ] ) . toEqual ( [ 'one' ] ) ;
262- expect ( writeHttp . mock . calls [ 1 ] ) . toEqual ( [ [ 'two' ] ] ) ;
396+ expect ( writeHttp . mock . calls [ 1 ] ?. [ 0 ] ) . toEqual ( [ 'two' ] ) ;
397+ expect ( writeHttp . mock . calls [ 2 ] ) . toEqual ( [ [ 'three' ] ] ) ;
263398 expect ( sockets [ 0 ] . sent ) . toHaveLength ( 0 ) ;
264399 expect ( sockets [ 0 ] . closed ) . toContainEqual ( [ 1000 , 'connect budget expired' ] ) ;
265400 sockets [ 0 ] . open ( ) ;
@@ -766,15 +901,16 @@ describe('v1 stream WebSocket writer lifecycle', () => {
766901 expect ( sockets ) . toHaveLength ( 0 ) ;
767902 } ) ;
768903
769- it ( 'forwards streamer-wrapper disposal after session materialization ' , async ( ) => {
904+ it ( 'keeps streamer-wrapper disposal socket-free before its first write ' , async ( ) => {
770905 process . env . WORKFLOW_STREAMS_TRANSPORT = 'ws' ;
771906 const { createStreamer } = await import ( './streamer.js' ) ;
772907 const session = createStreamer ( {
773908 token : 'token' ,
774909 } ) . streams . createWriteSession ?.( 'wrun_1' , 'stream/1' , { writerId } ) ;
775- await vi . waitFor ( ( ) => expect ( sockets ) . toHaveLength ( 1 ) ) ;
910+ await new Promise ( ( resolve ) => setTimeout ( resolve , 0 ) ) ;
911+ expect ( sockets ) . toHaveLength ( 0 ) ;
776912 await session ?. dispose ?.( ) ;
777- expect ( sockets [ 0 ] . closed ) . toContainEqual ( [ 1000 , 'stream writer disposed' ] ) ;
913+ expect ( sockets ) . toHaveLength ( 0 ) ;
778914 } ) ;
779915
780916 it ( 'disposes transport without sending protocol close' , async ( ) => {
0 commit comments