@@ -48,6 +48,7 @@ export class PythinkerRuntime {
4848 private readonly log : PythinkerRuntimeOptions [ "log" ] ;
4949 private readonly sessions = new Map < string , SessionRuntime > ( ) ;
5050 private readonly sessionByView = new Map < string , string > ( ) ;
51+ private readonly viewChains = new Map < string , Promise < void > > ( ) ;
5152 private readonly pendingPermissionByView = new Map < string , PermissionMode > ( ) ;
5253 private closed = false ;
5354
@@ -101,6 +102,10 @@ export class PythinkerRuntime {
101102 }
102103
103104 async openSession ( options : OpenSessionOptions ) : Promise < SessionRuntime > {
105+ return this . serializeView ( options . webviewId , ( ) => this . openSessionInner ( options ) ) ;
106+ }
107+
108+ private async openSessionInner ( options : OpenSessionOptions ) : Promise < SessionRuntime > {
104109 this . ensureOpen ( ) ;
105110 const current = this . getSessionForView ( options . webviewId ) ;
106111 const requestedId = options . sessionId ?? current ?. id ;
@@ -119,7 +124,7 @@ export class PythinkerRuntime {
119124 if ( runtime !== undefined ) {
120125 assertSessionWorkDir ( runtime . session , options . workDir ) ;
121126 await applySessionPermission ( runtime . session , runtime . permissionMode ) ;
122- await this . detachView ( options . webviewId ) ;
127+ await this . detachViewInner ( options . webviewId ) ;
123128 } else {
124129 const seedMode = defaultPermissionMode ( options . yoloMode ) ;
125130 const session =
@@ -135,7 +140,7 @@ export class PythinkerRuntime {
135140 try {
136141 assertSessionWorkDir ( session , options . workDir ) ;
137142 const mode = await restorePermissionMode ( session , seedMode ) ;
138- await this . detachView ( options . webviewId ) ;
143+ await this . detachViewInner ( options . webviewId ) ;
139144 runtime = this . wrapSession ( session , mode ) ;
140145 } catch ( error ) {
141146 await session . close ( ) . catch ( ( closeError : unknown ) => {
@@ -156,14 +161,24 @@ export class PythinkerRuntime {
156161 webviewId : string ,
157162 session : Session ,
158163 yoloModeSetting = false ,
164+ ) : Promise < SessionRuntime > {
165+ return this . serializeView ( webviewId , ( ) =>
166+ this . attachResumedSessionInner ( webviewId , session , yoloModeSetting ) ,
167+ ) ;
168+ }
169+
170+ private async attachResumedSessionInner (
171+ webviewId : string ,
172+ session : Session ,
173+ yoloModeSetting : boolean ,
159174 ) : Promise < SessionRuntime > {
160175 const existing = this . sessions . get ( session . id ) ;
161176 if ( existing !== undefined && this . sessionByView . get ( webviewId ) === session . id ) {
162177 existing . subscribe ( webviewId ) ;
163178 await existing . announceStatus ( webviewId ) ;
164179 return existing ;
165180 }
166- await this . detachView ( webviewId ) ;
181+ await this . detachViewInner ( webviewId ) ;
167182 let runtime = existing ?? this . sessions . get ( session . id ) ;
168183 if ( runtime === undefined ) {
169184 try {
@@ -184,6 +199,10 @@ export class PythinkerRuntime {
184199 }
185200
186201 async detachView ( webviewId : string ) : Promise < void > {
202+ return this . serializeView ( webviewId , ( ) => this . detachViewInner ( webviewId ) ) ;
203+ }
204+
205+ private async detachViewInner ( webviewId : string ) : Promise < void > {
187206 const id = this . sessionByView . get ( webviewId ) ;
188207 if ( id === undefined ) return ;
189208 this . sessionByView . delete ( webviewId ) ;
@@ -196,6 +215,23 @@ export class PythinkerRuntime {
196215 }
197216 }
198217
218+ // A view attaches to at most one session, so opens/detaches for one view
219+ // must never overlap: concurrent callers that both miss `this.sessions`
220+ // would wrap the same SDK session twice and double every streamed event.
221+ private serializeView < T > ( webviewId : string , work : ( ) => Promise < T > ) : Promise < T > {
222+ const prev = this . viewChains . get ( webviewId ) ?? Promise . resolve ( ) ;
223+ const run = prev . then ( work , work ) ;
224+ const next = run . then (
225+ ( ) => undefined ,
226+ ( ) => undefined ,
227+ ) ;
228+ this . viewChains . set ( webviewId , next ) ;
229+ void next . finally ( ( ) => {
230+ if ( this . viewChains . get ( webviewId ) === next ) this . viewChains . delete ( webviewId ) ;
231+ } ) ;
232+ return run ;
233+ }
234+
199235 async closeSession ( id : string ) : Promise < void > {
200236 const runtime = this . sessions . get ( id ) ;
201237 if ( runtime === undefined ) {
0 commit comments