@@ -134,6 +134,13 @@ class FetchGate {
134134 private _fetchResolve : ( ( r : Response ) => void ) | null = null
135135 private _onRequest : ( ( ) => void ) | null = null
136136
137+ /** Last two request URLs — used to verify no identity loops. */
138+ lastUrl : string | null = null
139+ prevUrl : string | null = null
140+
141+ /** Maximum URL length seen across all requests. */
142+ maxUrlLength = 0
143+
137144 get requestCount ( ) : number {
138145 return this . _requestCount
139146 }
@@ -143,12 +150,18 @@ class FetchGate {
143150 }
144151
145152 readonly fetchClient = async (
146- _input : RequestInfo | URL ,
153+ input : RequestInfo | URL ,
147154 init ?: RequestInit
148155 ) : Promise < Response > => {
149156 if ( init ?. signal ?. aborted ) return Response . error ( )
150157 this . _requestCount ++
151158
159+ // Track URLs for invariant checking
160+ const urlStr = input . toString ( )
161+ this . prevUrl = this . lastUrl
162+ this . lastUrl = urlStr
163+ if ( urlStr . length > this . maxUrlLength ) this . maxUrlLength = urlStr . length
164+
152165 if ( this . _onRequest ) {
153166 const cb = this . _onRequest
154167 this . _onRequest = null
@@ -189,6 +202,7 @@ class FetchGate {
189202
190203interface StreamReal {
191204 gate : FetchGate
205+ stream : ShapeStream
192206 subscriberError : Error | null
193207 currentHandle : string
194208 respond ( response : Response ) : Promise < void >
@@ -250,6 +264,7 @@ async function createStreamReal(): Promise<StreamReal> {
250264
251265 return {
252266 gate,
267+ stream,
253268 get subscriberError ( ) {
254269 return errorRef . error
255270 } ,
@@ -278,6 +293,24 @@ interface StreamModel {
278293}
279294
280295const MAX_CONSECUTIVE_ERROR_RETRIES = 50
296+ const MAX_URL_LENGTH = 2000
297+
298+ /**
299+ * Invariants checked after every command. These catch historical bugs
300+ * like URL identity loops (bug #1), `-next` suffix growth (bug #3),
301+ * and handle/offset mismatches (bug #10).
302+ */
303+ function assertGlobalInvariants ( r : StreamReal ) : void {
304+ // URL length is bounded (catches unbounded suffix growth like -next-next-next)
305+ expect ( r . gate . maxUrlLength ) . toBeLessThan ( MAX_URL_LENGTH )
306+
307+ // After any successful (non-error) response, isUpToDate state should be
308+ // consistent with what we observe (no silent stuck states)
309+ if ( ! r . subscriberError && r . stream . isUpToDate ) {
310+ // If the stream thinks it's up-to-date, it should have synced at some point
311+ expect ( r . stream . lastSyncedAt ( ) ) . toBeDefined ( )
312+ }
313+ }
281314
282315// ─── Commands ───────────────────────────────────────────────────────
283316//
@@ -295,6 +328,7 @@ class Respond200DataCmd implements fc.AsyncCommand<StreamModel, StreamReal> {
295328 m . consecutiveErrors = 0
296329 await r . respond ( make200WithData ( r . currentHandle ) )
297330 expect ( r . subscriberError ) . toBeNull ( )
331+ assertGlobalInvariants ( r )
298332 }
299333 toString ( ) : string {
300334 return `Respond200Data`
@@ -312,6 +346,7 @@ class Respond200UpToDateCmd
312346 m . consecutiveErrors = 0
313347 await r . respond ( make200UpToDate ( r . currentHandle ) )
314348 expect ( r . subscriberError ) . toBeNull ( )
349+ assertGlobalInvariants ( r )
315350 }
316351 toString ( ) : string {
317352 return `Respond200UpToDate`
@@ -327,9 +362,9 @@ class Respond200EmptyCmd implements fc.AsyncCommand<StreamModel, StreamReal> {
327362 return ! m . terminated
328363 }
329364 async run ( _m : StreamModel , r : StreamReal ) : Promise < void > {
330- // Empty batch → early return → counter unchanged
331365 await r . respond ( make200Empty ( r . currentHandle ) )
332366 expect ( r . subscriberError ) . toBeNull ( )
367+ assertGlobalInvariants ( r )
333368 }
334369 toString ( ) : string {
335370 return `Respond200Empty`
@@ -345,6 +380,7 @@ class Respond204Cmd implements fc.AsyncCommand<StreamModel, StreamReal> {
345380 m . consecutiveErrors = 0
346381 await r . respond ( make204 ( r . currentHandle ) )
347382 expect ( r . subscriberError ) . toBeNull ( )
383+ assertGlobalInvariants ( r )
348384 }
349385 toString ( ) : string {
350386 return `Respond204`
@@ -367,6 +403,7 @@ class Respond400Cmd implements fc.AsyncCommand<StreamModel, StreamReal> {
367403 expect ( r . subscriberError ) . not . toBeNull ( )
368404 } else {
369405 expect ( r . subscriberError ) . toBeNull ( )
406+ assertGlobalInvariants ( r )
370407 }
371408 }
372409 toString ( ) : string {
@@ -384,10 +421,17 @@ class Respond409Cmd implements fc.AsyncCommand<StreamModel, StreamReal> {
384421 return ! m . terminated
385422 }
386423 async run ( _m : StreamModel , r : StreamReal ) : Promise < void > {
424+ const prevUrl = r . gate . lastUrl
387425 const newHandle = `handle-${ nextSeq ( ) } `
388426 await r . respond ( make409 ( newHandle ) )
389427 r . currentHandle = newHandle
390428 expect ( r . subscriberError ) . toBeNull ( )
429+ assertGlobalInvariants ( r )
430+ // After 409, the retry URL must differ from the pre-409 URL
431+ // (catches identity-loop bugs like bug #1 and #6)
432+ if ( prevUrl ) {
433+ expect ( r . gate . lastUrl ) . not . toBe ( prevUrl )
434+ }
391435 }
392436 toString ( ) : string {
393437 return `Respond409`
@@ -412,6 +456,7 @@ class RespondMalformed200Cmd
412456 expect ( r . subscriberError ) . not . toBeNull ( )
413457 } else {
414458 expect ( r . subscriberError ) . toBeNull ( )
459+ assertGlobalInvariants ( r )
415460 }
416461 }
417462 toString ( ) : string {
0 commit comments