@@ -137,6 +137,8 @@ export class ZhubPublication {
137137 private connections = new Map < string , { client_manifest : Record < string , unknown > | null } > ( ) ;
138138 private stopped = false ;
139139 private stopResolvers : Array < ( ) => void > = [ ] ;
140+ private stopSleepResolver : ( ( ) => void ) | null = null ;
141+ private task : Promise < void > | null = null ;
140142
141143 constructor ( opts : PublishOptions , manifest : Manifest ) {
142144 this . name = opts . name ;
@@ -188,8 +190,14 @@ export class ZhubPublication {
188190 async stop ( ) : Promise < void > {
189191 this . stopped = true ;
190192 this . ws ?. close ( ) ;
193+ this . stopSleepResolver ?.( ) ;
191194 const resolvers = this . stopResolvers . splice ( 0 ) ;
192195 for ( const r of resolvers ) r ( ) ;
196+ const task = this . task ;
197+ this . task = null ;
198+ if ( task ) {
199+ try { await task ; } catch { /* drained */ }
200+ }
193201 }
194202
195203 /** Block until stop() is called — mirror of Python's ZhubPublication.run_forever(). */
@@ -202,7 +210,25 @@ export class ZhubPublication {
202210
203211 /** Internal — start the connect loop. Called by the public publish() helper. */
204212 start ( ) : void {
205- void this . runReconnectLoop ( ) ;
213+ this . task = this . runReconnectLoop ( ) ;
214+ }
215+
216+ private sleep ( ms : number ) : Promise < void > {
217+ // Mirror Python's `asyncio.wait_for(stop_event.wait(), timeout=backoff)`:
218+ // stop() must interrupt the in-flight backoff sleep, otherwise a JS
219+ // caller shutting down during a reconnect wait would keep an unbounded
220+ // Node timer alive and block a clean process exit for up to 60s.
221+ return new Promise < void > ( ( resolve ) => {
222+ const t = setTimeout ( ( ) => {
223+ this . stopSleepResolver = null ;
224+ resolve ( ) ;
225+ } , ms ) ;
226+ this . stopSleepResolver = ( ) => {
227+ clearTimeout ( t ) ;
228+ this . stopSleepResolver = null ;
229+ resolve ( ) ;
230+ } ;
231+ } ) ;
206232 }
207233
208234 private async runReconnectLoop ( ) : Promise < void > {
@@ -219,7 +245,7 @@ export class ZhubPublication {
219245 // any other error → reconnect after backoff
220246 }
221247 if ( this . stopped ) return ;
222- await new Promise ( ( r ) => setTimeout ( r , backoff * 1000 ) ) ;
248+ await this . sleep ( backoff * 1000 ) ;
223249 backoff = Math . min ( backoff * 2 , 60 ) ;
224250 }
225251 }
@@ -505,6 +531,8 @@ export class ZhubConnection {
505531 private streams = new Map < string , ( chunk : Record < string , unknown > ) => void > ( ) ;
506532 private stopped = false ;
507533 private stopResolvers : Array < ( ) => void > = [ ] ;
534+ private stopSleepResolver : ( ( ) => void ) | null = null ;
535+ private task : Promise < void > | null = null ;
508536
509537 constructor ( opts : ConnectOptions ) {
510538 this . aiName = opts . aiName ;
@@ -619,8 +647,14 @@ export class ZhubConnection {
619647 async stop ( ) : Promise < void > {
620648 this . stopped = true ;
621649 this . ws ?. close ( ) ;
650+ this . stopSleepResolver ?.( ) ;
622651 const resolvers = this . stopResolvers . splice ( 0 ) ;
623652 for ( const r of resolvers ) r ( ) ;
653+ const task = this . task ;
654+ this . task = null ;
655+ if ( task ) {
656+ try { await task ; } catch { /* drained */ }
657+ }
624658 }
625659
626660 /** Block until stop() is called — mirror of Python's ZhubConnection.run_forever(). */
@@ -633,7 +667,21 @@ export class ZhubConnection {
633667
634668 /** Internal — call from connect(). */
635669 start ( ) : void {
636- void this . runReconnectLoop ( ) ;
670+ this . task = this . runReconnectLoop ( ) ;
671+ }
672+
673+ private sleep ( ms : number ) : Promise < void > {
674+ return new Promise < void > ( ( resolve ) => {
675+ const t = setTimeout ( ( ) => {
676+ this . stopSleepResolver = null ;
677+ resolve ( ) ;
678+ } , ms ) ;
679+ this . stopSleepResolver = ( ) => {
680+ clearTimeout ( t ) ;
681+ this . stopSleepResolver = null ;
682+ resolve ( ) ;
683+ } ;
684+ } ) ;
637685 }
638686
639687 private async runReconnectLoop ( ) : Promise < void > {
@@ -646,7 +694,7 @@ export class ZhubConnection {
646694 if ( err instanceof AuthError ) return ;
647695 }
648696 if ( this . stopped ) return ;
649- await new Promise ( ( r ) => setTimeout ( r , backoff * 1000 ) ) ;
697+ await this . sleep ( backoff * 1000 ) ;
650698 backoff = Math . min ( backoff * 2 , 60 ) ;
651699 }
652700 }
@@ -791,6 +839,8 @@ export class ZhubExposure {
791839 private ws : WebSocket | null = null ;
792840 private stopped = false ;
793841 private stopResolvers : Array < ( ) => void > = [ ] ;
842+ private stopSleepResolver : ( ( ) => void ) | null = null ;
843+ private task : Promise < void > | null = null ;
794844
795845 constructor ( opts : ExposeOptions , manifest : Manifest ) {
796846 this . name = opts . name ;
@@ -806,8 +856,14 @@ export class ZhubExposure {
806856 async stop ( ) : Promise < void > {
807857 this . stopped = true ;
808858 this . ws ?. close ( ) ;
859+ this . stopSleepResolver ?.( ) ;
809860 const resolvers = this . stopResolvers . splice ( 0 ) ;
810861 for ( const r of resolvers ) r ( ) ;
862+ const task = this . task ;
863+ this . task = null ;
864+ if ( task ) {
865+ try { await task ; } catch { /* drained */ }
866+ }
811867 }
812868
813869 /** Block until stop() is called — mirror of Python's ZhubExposure.run_forever(). */
@@ -820,7 +876,21 @@ export class ZhubExposure {
820876
821877 /** Internal — call from expose(). */
822878 start ( ) : void {
823- void this . runReconnectLoop ( ) ;
879+ this . task = this . runReconnectLoop ( ) ;
880+ }
881+
882+ private sleep ( ms : number ) : Promise < void > {
883+ return new Promise < void > ( ( resolve ) => {
884+ const t = setTimeout ( ( ) => {
885+ this . stopSleepResolver = null ;
886+ resolve ( ) ;
887+ } , ms ) ;
888+ this . stopSleepResolver = ( ) => {
889+ clearTimeout ( t ) ;
890+ this . stopSleepResolver = null ;
891+ resolve ( ) ;
892+ } ;
893+ } ) ;
824894 }
825895
826896 private async runReconnectLoop ( ) : Promise < void > {
@@ -833,7 +903,7 @@ export class ZhubExposure {
833903 if ( err instanceof AuthError ) return ;
834904 }
835905 if ( this . stopped ) return ;
836- await new Promise ( ( r ) => setTimeout ( r , backoff * 1000 ) ) ;
906+ await this . sleep ( backoff * 1000 ) ;
837907 backoff = Math . min ( backoff * 2 , 60 ) ;
838908 }
839909 }
0 commit comments