@qwen-code/channel-base 0.19.9 → 0.19.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
- import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
2
3
  import process from 'node:process';
3
4
  import { sanitizeLogText } from './sanitize.js';
4
5
  export class SessionRouter {
@@ -7,17 +8,22 @@ export class SessionRouter {
7
8
  toCwd = new Map(); // session ID → cwd
8
9
  creatingSessions = new Map();
9
10
  sessionLoadWindows = new Set();
11
+ liveSessionIds = new Set();
12
+ routeTokens = new Map();
13
+ lifecycleGeneration = 0;
10
14
  bridge;
11
15
  defaultCwd;
12
16
  defaultScope;
13
17
  channelScopes = new Map();
14
18
  channelApprovalModes = new Map();
15
19
  persistPath;
16
- constructor(bridge, defaultCwd, scope = 'user', persistPath) {
20
+ recoveryMode;
21
+ constructor(bridge, defaultCwd, scope = 'user', persistPath, options = {}) {
17
22
  this.bridge = bridge;
18
23
  this.defaultCwd = defaultCwd;
19
24
  this.defaultScope = scope;
20
25
  this.persistPath = persistPath;
26
+ this.recoveryMode = options.recoveryMode ?? 'eager';
21
27
  }
22
28
  /** Replace the bridge instance (used after crash recovery restart). */
23
29
  setBridge(bridge) {
@@ -53,65 +59,181 @@ export class SessionRouter {
53
59
  }
54
60
  async resolve(channelName, senderId, chatId, threadId, cwd, isGroup, options) {
55
61
  const key = this.routingKey(channelName, senderId, chatId, options?.routingThreadId ?? threadId);
56
- let failedCreateWaits = 0;
62
+ const input = {
63
+ channelName,
64
+ senderId,
65
+ chatId,
66
+ threadId,
67
+ cwd: cwd || this.defaultCwd,
68
+ isGroup,
69
+ };
70
+ let failedWaits = 0;
57
71
  for (;;) {
58
72
  const existing = this.toSession.get(key);
59
- if (existing) {
73
+ if (existing && this.isLive(existing)) {
60
74
  this.promoteTargetToGroup(existing, isGroup);
61
75
  return existing;
62
76
  }
63
77
  const creating = this.creatingSessions.get(key);
64
78
  if (creating) {
65
79
  try {
66
- const sessionId = await creating;
80
+ const sessionId = await creating.promise;
81
+ try {
82
+ this.assertOperationResultCurrent(key, sessionId, creating);
83
+ }
84
+ catch (error) {
85
+ this.scheduleDiscardInvalidatedSession(sessionId, creating);
86
+ throw error;
87
+ }
67
88
  this.promoteTargetToGroup(sessionId, isGroup);
68
89
  return sessionId;
69
90
  }
70
- catch (err) {
91
+ catch (error) {
92
+ if (creating.invalidationError) {
93
+ throw creating.invalidationError;
94
+ }
71
95
  if (this.creatingSessions.get(key) === creating) {
72
96
  this.creatingSessions.delete(key);
73
97
  }
74
- failedCreateWaits++;
75
- if (failedCreateWaits > 3) {
76
- throw err;
77
- }
98
+ this.releaseRouteToken(key, creating);
99
+ failedWaits++;
100
+ if (failedWaits > 3)
101
+ throw error;
78
102
  continue;
79
103
  }
80
104
  }
81
- // Register the in-flight route before starting newSession(), because a
82
- // bridge can emit sessionDied synchronously while creating the session.
83
- const created = Promise.resolve().then(async () => {
84
- const sessionCwd = cwd || this.defaultCwd;
85
- const loadWindow = this.beginSessionLoad();
105
+ const operation = this.createSessionOperation(key, {
106
+ channelName: input.channelName,
107
+ senderId: input.senderId,
108
+ chatId: input.chatId,
109
+ threadId: input.threadId,
110
+ isGroup: input.isGroup,
111
+ }, (currentOperation) => existing
112
+ ? this.loadOrReplaceSession(key, existing, input, currentOperation)
113
+ : this.createAndStoreSession(key, input, currentOperation));
114
+ this.creatingSessions.set(key, operation);
115
+ try {
116
+ const sessionId = await operation.promise;
86
117
  try {
87
- const sessionId = await this.createLiveSession(sessionCwd, loadWindow, key, this.sessionOptions(channelName));
88
- this.toSession.set(key, sessionId);
89
- this.toTarget.set(sessionId, {
90
- channelName,
91
- senderId,
92
- chatId,
93
- threadId,
94
- isGroup,
95
- });
96
- this.toCwd.set(sessionId, sessionCwd);
97
- this.persist();
98
- return sessionId;
118
+ this.assertOperationResultCurrent(key, sessionId, operation);
99
119
  }
100
- finally {
101
- this.endSessionLoad(loadWindow);
120
+ catch (error) {
121
+ this.scheduleDiscardInvalidatedSession(sessionId, operation);
122
+ throw error;
102
123
  }
103
- });
104
- this.creatingSessions.set(key, created);
105
- try {
106
- return await created;
124
+ this.promoteTargetToGroup(sessionId, isGroup);
125
+ return sessionId;
107
126
  }
108
127
  finally {
109
- if (this.creatingSessions.get(key) === created) {
128
+ if (this.creatingSessions.get(key) === operation) {
110
129
  this.creatingSessions.delete(key);
111
130
  }
131
+ this.releaseRouteToken(key, operation);
112
132
  }
113
133
  }
114
134
  }
135
+ isLive(sessionId) {
136
+ return this.recoveryMode === 'eager' || this.liveSessionIds.has(sessionId);
137
+ }
138
+ async createAndStoreSession(key, input, operation) {
139
+ const loadWindow = this.beginSessionLoad();
140
+ try {
141
+ const sessionId = await this.createLiveSession(input.cwd, loadWindow, key, this.sessionOptions(input.channelName), operation);
142
+ try {
143
+ this.assertOperationCurrent(operation);
144
+ }
145
+ catch (error) {
146
+ this.scheduleDiscardInvalidatedSession(sessionId, operation);
147
+ throw error;
148
+ }
149
+ this.toSession.set(key, sessionId);
150
+ this.toTarget.set(sessionId, {
151
+ channelName: input.channelName,
152
+ senderId: input.senderId,
153
+ chatId: input.chatId,
154
+ threadId: input.threadId,
155
+ isGroup: input.isGroup,
156
+ });
157
+ this.toCwd.set(sessionId, input.cwd);
158
+ this.liveSessionIds.add(sessionId);
159
+ this.persist();
160
+ return sessionId;
161
+ }
162
+ finally {
163
+ this.endSessionLoad(loadWindow);
164
+ }
165
+ }
166
+ async loadOrReplaceSession(key, savedSessionId, input, operation) {
167
+ const savedCwd = this.toCwd.get(savedSessionId) ?? input.cwd;
168
+ const loadWindow = this.beginSessionLoad();
169
+ try {
170
+ try {
171
+ const loadedSessionId = await this.bridge.loadSession(savedSessionId, savedCwd, this.sessionOptions(input.channelName), operation);
172
+ try {
173
+ this.assertOperationCurrent(operation);
174
+ if (this.toSession.get(key) !== savedSessionId) {
175
+ this.invalidateOperation(operation);
176
+ this.assertOperationCurrent(operation);
177
+ }
178
+ }
179
+ catch (error) {
180
+ this.scheduleDiscardInvalidatedSession(loadedSessionId, operation);
181
+ throw error;
182
+ }
183
+ if (typeof loadedSessionId !== 'string' ||
184
+ loadedSessionId.length === 0 ||
185
+ loadWindow.delete(loadedSessionId)) {
186
+ throw new Error('Invalid or dead restored session ID');
187
+ }
188
+ if (loadedSessionId !== savedSessionId) {
189
+ const target = this.toTarget.get(savedSessionId);
190
+ this.deleteByKey(key);
191
+ this.toSession.set(key, loadedSessionId);
192
+ if (target)
193
+ this.toTarget.set(loadedSessionId, target);
194
+ this.toCwd.set(loadedSessionId, savedCwd);
195
+ this.persist();
196
+ }
197
+ this.liveSessionIds.add(loadedSessionId);
198
+ return loadedSessionId;
199
+ }
200
+ catch (loadError) {
201
+ this.assertOperationCurrent(operation);
202
+ try {
203
+ const replacement = await this.createLiveSession(input.cwd, loadWindow, key, this.sessionOptions(input.channelName), operation);
204
+ try {
205
+ this.assertOperationCurrent(operation);
206
+ }
207
+ catch (error) {
208
+ this.scheduleDiscardInvalidatedSession(replacement, operation);
209
+ throw error;
210
+ }
211
+ this.deleteByKey(key);
212
+ this.toSession.set(key, replacement);
213
+ this.toTarget.set(replacement, {
214
+ channelName: input.channelName,
215
+ senderId: input.senderId,
216
+ chatId: input.chatId,
217
+ threadId: input.threadId,
218
+ isGroup: input.isGroup,
219
+ });
220
+ this.toCwd.set(replacement, input.cwd);
221
+ this.liveSessionIds.add(replacement);
222
+ this.persist();
223
+ process.stderr.write(`[SessionRouter] Replaced unavailable session ${sanitizeLogText(savedSessionId, 128)} for key ${sanitizeLogText(key, 256)} after load failed: ${sanitizeLogText(loadError instanceof Error ? loadError.message : String(loadError), 512)}\n`);
224
+ return replacement;
225
+ }
226
+ catch (createError) {
227
+ this.assertOperationCurrent(operation);
228
+ process.stderr.write(`[SessionRouter] Failed to load session ${sanitizeLogText(savedSessionId, 128)} for key ${sanitizeLogText(key, 256)} (${sanitizeLogText(loadError instanceof Error ? loadError.message : String(loadError), 512)}) and failed to create a replacement (${sanitizeLogText(createError instanceof Error ? createError.message : String(createError), 512)})\n`);
229
+ throw createError;
230
+ }
231
+ }
232
+ }
233
+ finally {
234
+ this.endSessionLoad(loadWindow);
235
+ }
236
+ }
115
237
  getTarget(sessionId) {
116
238
  return this.toTarget.get(sessionId);
117
239
  }
@@ -145,6 +267,7 @@ export class SessionRouter {
145
267
  const scope = this.channelScopes.get(channelName) || this.defaultScope;
146
268
  if (chatId) {
147
269
  const key = this.routingKey(channelName, senderId, chatId, threadId);
270
+ this.invalidateRouteOperation(key);
148
271
  const sessionId = this.deleteByKey(key);
149
272
  if (sessionId)
150
273
  removedIds.push(sessionId);
@@ -158,11 +281,18 @@ export class SessionRouter {
158
281
  const target = this.toTarget.get(mappedSessionId);
159
282
  if (target?.channelName === channelName &&
160
283
  target.senderId === senderId) {
284
+ this.invalidateRouteOperation(k);
161
285
  const sessionId = this.deleteByKey(k);
162
286
  if (sessionId)
163
287
  removedIds.push(sessionId);
164
288
  }
165
289
  }
290
+ for (const [key, operation] of [...this.creatingSessions]) {
291
+ if (operation.target.channelName === channelName &&
292
+ operation.target.senderId === senderId) {
293
+ this.invalidateRouteOperation(key);
294
+ }
295
+ }
166
296
  }
167
297
  if (removedIds.length > 0)
168
298
  this.persist();
@@ -173,6 +303,7 @@ export class SessionRouter {
173
303
  let removed = false;
174
304
  for (const [key, mappedSessionId] of [...this.toSession.entries()]) {
175
305
  if (mappedSessionId === sessionId) {
306
+ this.invalidateRouteOperation(key);
176
307
  this.toSession.delete(key);
177
308
  removed = true;
178
309
  }
@@ -183,6 +314,7 @@ export class SessionRouter {
183
314
  if (this.toCwd.delete(sessionId)) {
184
315
  removed = true;
185
316
  }
317
+ this.liveSessionIds.delete(sessionId);
186
318
  if (!removed && this.sessionLoadWindows.size > 0) {
187
319
  for (const loadWindow of this.sessionLoadWindows) {
188
320
  loadWindow.add(sessionId);
@@ -193,6 +325,17 @@ export class SessionRouter {
193
325
  }
194
326
  return removed;
195
327
  }
328
+ handleSessionDied(sessionId) {
329
+ if (this.recoveryMode === 'eager') {
330
+ return this.removeSessionId(sessionId);
331
+ }
332
+ const known = this.toTarget.has(sessionId);
333
+ this.liveSessionIds.delete(sessionId);
334
+ for (const loadWindow of this.sessionLoadWindows) {
335
+ loadWindow.add(sessionId);
336
+ }
337
+ return known;
338
+ }
196
339
  deleteByKey(key) {
197
340
  const sessionId = this.toSession.get(key);
198
341
  if (!sessionId)
@@ -200,6 +343,7 @@ export class SessionRouter {
200
343
  this.toSession.delete(key);
201
344
  this.toTarget.delete(sessionId);
202
345
  this.toCwd.delete(sessionId);
346
+ this.liveSessionIds.delete(sessionId);
203
347
  return sessionId;
204
348
  }
205
349
  promoteTargetToGroup(sessionId, isGroup) {
@@ -222,49 +366,72 @@ export class SessionRouter {
222
366
  }
223
367
  return entries;
224
368
  }
369
+ restoreRoutes() {
370
+ if (this.recoveryMode !== 'lazy') {
371
+ throw new Error('restoreRoutes requires lazy recovery mode');
372
+ }
373
+ const persisted = this.readPersistedEntries();
374
+ if (!persisted)
375
+ return { restored: 0, dropped: 0 };
376
+ this.dispose();
377
+ let restored = 0;
378
+ for (const [key, entry] of Object.entries(persisted.entries)) {
379
+ this.toSession.set(key, entry.sessionId);
380
+ this.toTarget.set(entry.sessionId, entry.target);
381
+ this.toCwd.set(entry.sessionId, entry.cwd);
382
+ restored++;
383
+ }
384
+ if (persisted.dropped > 0)
385
+ this.persist();
386
+ return { restored, dropped: persisted.dropped };
387
+ }
225
388
  /**
226
389
  * Restore session mappings from a previous bridge.
227
390
  * Called after bridge restart — attempts loadSession for each saved mapping.
228
391
  * Failed loads are dropped (new session on next message).
229
392
  */
230
393
  async restoreSessions() {
231
- const persistPath = this.persistPath;
232
- if (!persistPath || !existsSync(persistPath)) {
233
- return { restored: 0, failed: 0 };
234
- }
235
- let entries;
236
- try {
237
- entries = JSON.parse(readFileSync(persistPath, 'utf-8'));
238
- }
239
- catch (err) {
240
- const reason = err instanceof Error ? err.message : String(err);
241
- process.stderr.write(`[SessionRouter] Corrupted persist file at ${sanitizeLogText(persistPath, 1024)}: ${sanitizeLogText(reason, 512)}\n`);
394
+ const persisted = this.readPersistedEntries();
395
+ if (!persisted)
242
396
  return { restored: 0, failed: 0 };
243
- }
397
+ const entries = persisted.entries;
398
+ const restoreGeneration = this.lifecycleGeneration;
244
399
  let restored = 0;
245
400
  let failed = 0;
246
- let changed = false;
401
+ let changed = persisted.dropped > 0;
247
402
  const reservations = new Map();
403
+ for (const key of persisted.droppedKeys) {
404
+ this.deleteByKey(key);
405
+ }
248
406
  // Reserve every persisted key up front so inbound messages during restart
249
407
  // wait for restore instead of returning stale IDs or creating duplicates.
250
408
  for (const key of Object.keys(entries)) {
251
409
  this.deleteByKey(key);
252
410
  const reservation = this.createSessionReservation();
253
411
  reservation.promise.catch(() => undefined);
254
- this.creatingSessions.set(key, reservation.promise);
255
- reservations.set(key, reservation);
412
+ const operation = this.createSessionOperation(key, entries[key].target, () => reservation.promise);
413
+ operation.promise.catch(() => undefined);
414
+ this.creatingSessions.set(key, operation);
415
+ reservations.set(key, { reservation, operation });
256
416
  }
257
417
  const loadWindow = this.beginSessionLoad();
258
418
  try {
259
419
  for (const [key, entry] of Object.entries(entries)) {
260
- const reservation = reservations.get(key);
261
- if (!reservation)
420
+ const reserved = reservations.get(key);
421
+ if (!reserved)
262
422
  continue;
423
+ const { reservation, operation } = reserved;
263
424
  try {
425
+ this.assertOperationCurrent(operation);
264
426
  const options = this.sessionOptions(entry.target.channelName);
265
- const sessionId = options
266
- ? await this.bridge.loadSession(entry.sessionId, entry.cwd, options)
267
- : await this.bridge.loadSession(entry.sessionId, entry.cwd);
427
+ const sessionId = await this.bridge.loadSession(entry.sessionId, entry.cwd, options, operation);
428
+ try {
429
+ this.assertOperationCurrent(operation);
430
+ }
431
+ catch (error) {
432
+ this.scheduleDiscardInvalidatedSession(sessionId, operation);
433
+ throw error;
434
+ }
268
435
  if (typeof sessionId !== 'string' || sessionId.length === 0) {
269
436
  throw new Error('Invalid restored session ID');
270
437
  }
@@ -289,9 +456,10 @@ export class SessionRouter {
289
456
  changed = true;
290
457
  }
291
458
  finally {
292
- if (this.creatingSessions.get(key) === reservation.promise) {
459
+ if (this.creatingSessions.get(key) === operation) {
293
460
  this.creatingSessions.delete(key);
294
461
  }
462
+ this.releaseRouteToken(key, operation);
295
463
  }
296
464
  }
297
465
  }
@@ -299,18 +467,27 @@ export class SessionRouter {
299
467
  this.endSessionLoad(loadWindow);
300
468
  }
301
469
  // Update persist file to only include successfully restored sessions
302
- if (changed) {
470
+ if (changed && restoreGeneration === this.lifecycleGeneration) {
303
471
  this.persist();
304
472
  }
305
473
  return { restored, failed };
306
474
  }
307
- /** Clear in-memory state and delete persist file. Used on clean shutdown. */
308
- clearAll() {
475
+ dispose() {
476
+ this.lifecycleGeneration++;
477
+ for (const operation of this.creatingSessions.values()) {
478
+ this.invalidateOperation(operation);
479
+ }
309
480
  this.toSession.clear();
310
481
  this.toTarget.clear();
311
482
  this.toCwd.clear();
312
483
  this.creatingSessions.clear();
313
484
  this.sessionLoadWindows.clear();
485
+ this.liveSessionIds.clear();
486
+ this.routeTokens.clear();
487
+ }
488
+ /** Clear in-memory state and delete persist file. Used on clean shutdown. */
489
+ clearAll() {
490
+ this.dispose();
314
491
  if (this.persistPath && existsSync(this.persistPath)) {
315
492
  try {
316
493
  unlinkSync(this.persistPath);
@@ -320,34 +497,128 @@ export class SessionRouter {
320
497
  }
321
498
  }
322
499
  }
500
+ readPersistedEntries() {
501
+ const persistPath = this.persistPath;
502
+ if (!persistPath || !existsSync(persistPath))
503
+ return undefined;
504
+ let parsed;
505
+ try {
506
+ parsed = JSON.parse(readFileSync(persistPath, 'utf-8'));
507
+ }
508
+ catch (error) {
509
+ const quarantinePath = `${persistPath}.corrupt-${Date.now()}`;
510
+ try {
511
+ renameSync(persistPath, quarantinePath);
512
+ }
513
+ catch {
514
+ // Keep startup available even if quarantine itself fails.
515
+ }
516
+ process.stderr.write(`[SessionRouter] Corrupted persist file at ${sanitizeLogText(persistPath, 1024)}: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 512)}\n`);
517
+ return undefined;
518
+ }
519
+ if (typeof parsed !== 'object' ||
520
+ parsed === null ||
521
+ Array.isArray(parsed)) {
522
+ const quarantinePath = `${persistPath}.corrupt-${Date.now()}`;
523
+ try {
524
+ renameSync(persistPath, quarantinePath);
525
+ }
526
+ catch {
527
+ // Keep startup available even if quarantine itself fails.
528
+ }
529
+ process.stderr.write(`[SessionRouter] Invalid route store at ${sanitizeLogText(persistPath, 1024)}: expected an object\n`);
530
+ return undefined;
531
+ }
532
+ const entries = {};
533
+ const droppedKeys = [];
534
+ for (const [key, value] of Object.entries(parsed)) {
535
+ if (this.isPersistedEntry(value))
536
+ entries[key] = value;
537
+ else
538
+ droppedKeys.push(key);
539
+ }
540
+ return { entries, dropped: droppedKeys.length, droppedKeys };
541
+ }
542
+ isPersistedEntry(value) {
543
+ if (typeof value !== 'object' || value === null)
544
+ return false;
545
+ const entry = value;
546
+ const target = entry['target'];
547
+ if (typeof target !== 'object' || target === null)
548
+ return false;
549
+ const typedTarget = target;
550
+ return (typeof entry['sessionId'] === 'string' &&
551
+ entry['sessionId'].length > 0 &&
552
+ typeof entry['cwd'] === 'string' &&
553
+ entry['cwd'].length > 0 &&
554
+ typeof typedTarget['channelName'] === 'string' &&
555
+ typeof typedTarget['senderId'] === 'string' &&
556
+ typeof typedTarget['chatId'] === 'string' &&
557
+ (typedTarget['threadId'] === undefined ||
558
+ typeof typedTarget['threadId'] === 'string') &&
559
+ (typedTarget['isGroup'] === undefined ||
560
+ typeof typedTarget['isGroup'] === 'boolean'));
561
+ }
323
562
  persist() {
324
563
  if (!this.persistPath)
325
564
  return;
326
565
  const data = {};
327
566
  for (const [key, sessionId] of this.toSession) {
328
567
  const target = this.toTarget.get(sessionId);
329
- if (target) {
330
- data[key] = {
331
- sessionId,
332
- target,
333
- cwd: this.toCwd.get(sessionId) || this.defaultCwd,
334
- };
335
- }
568
+ if (!target)
569
+ continue;
570
+ data[key] = {
571
+ sessionId,
572
+ target,
573
+ cwd: this.toCwd.get(sessionId) ?? this.defaultCwd,
574
+ };
336
575
  }
576
+ const dir = dirname(this.persistPath);
577
+ const tempPath = join(dir, `${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`);
337
578
  try {
338
- writeFileSync(this.persistPath, JSON.stringify(data, null, 2), 'utf-8');
579
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
580
+ try {
581
+ chmodSync(dir, 0o700);
582
+ }
583
+ catch {
584
+ // Windows and some filesystems do not implement POSIX modes.
585
+ }
586
+ writeFileSync(tempPath, JSON.stringify(data, null, 2), {
587
+ encoding: 'utf-8',
588
+ mode: 0o600,
589
+ });
590
+ renameSync(tempPath, this.persistPath);
591
+ try {
592
+ chmodSync(this.persistPath, 0o600);
593
+ }
594
+ catch {
595
+ // Windows and some filesystems do not implement POSIX modes.
596
+ }
339
597
  }
340
- catch {
341
- // best-effort — don't break message flow for persistence failure
598
+ catch (error) {
599
+ process.stderr.write(`[SessionRouter] Failed to persist routes at ${sanitizeLogText(this.persistPath, 1024)}: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 512)}\n`);
600
+ }
601
+ finally {
602
+ try {
603
+ rmSync(tempPath, { force: true });
604
+ }
605
+ catch {
606
+ // best-effort temp cleanup
607
+ }
342
608
  }
343
609
  }
344
- async createLiveSession(cwd, loadWindow, routingKey, options) {
610
+ async createLiveSession(cwd, loadWindow, routingKey, options, operation) {
345
611
  const maxAttempts = 2;
346
612
  let lastDeadSessionId;
347
613
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
348
- const sessionId = options
349
- ? await this.bridge.newSession(cwd, options)
350
- : await this.bridge.newSession(cwd);
614
+ const sessionId = await this.bridge.newSession(cwd, options, operation);
615
+ try {
616
+ this.assertOperationCurrent(operation);
617
+ }
618
+ catch (error) {
619
+ this.scheduleDiscardInvalidatedSession(sessionId, operation);
620
+ throw error;
621
+ }
351
622
  if (typeof sessionId !== 'string' || sessionId.length === 0) {
352
623
  throw new Error('Invalid session ID from bridge');
353
624
  }
@@ -363,6 +634,73 @@ export class SessionRouter {
363
634
  this.sessionLoadWindows.add(loadWindow);
364
635
  return loadWindow;
365
636
  }
637
+ createSessionOperation(key, target, run) {
638
+ let routeToken = this.routeTokens.get(key);
639
+ if (!routeToken) {
640
+ routeToken = {};
641
+ this.routeTokens.set(key, routeToken);
642
+ }
643
+ const operation = {
644
+ promise: Promise.resolve(''),
645
+ target,
646
+ lifecycleGeneration: this.lifecycleGeneration,
647
+ routeToken,
648
+ };
649
+ operation.promise = Promise.resolve()
650
+ .then(() => run(operation))
651
+ .catch((error) => {
652
+ this.assertOperationCurrent(operation);
653
+ throw error;
654
+ });
655
+ return operation;
656
+ }
657
+ invalidateRouteOperation(key) {
658
+ this.routeTokens.delete(key);
659
+ const operation = this.creatingSessions.get(key);
660
+ if (!operation)
661
+ return;
662
+ this.invalidateOperation(operation);
663
+ this.creatingSessions.delete(key);
664
+ }
665
+ invalidateOperation(operation) {
666
+ operation.invalidationError ??= new Error('Session route operation was invalidated');
667
+ }
668
+ assertOperationCurrent(operation) {
669
+ if (operation.lifecycleGeneration !== this.lifecycleGeneration) {
670
+ this.invalidateOperation(operation);
671
+ }
672
+ if (operation.invalidationError) {
673
+ throw operation.invalidationError;
674
+ }
675
+ }
676
+ assertOperationResultCurrent(key, sessionId, operation) {
677
+ if (operation.routeToken !== this.routeTokens.get(key)) {
678
+ this.invalidateOperation(operation);
679
+ }
680
+ if (this.toSession.get(key) !== sessionId) {
681
+ this.invalidateOperation(operation);
682
+ }
683
+ this.assertOperationCurrent(operation);
684
+ }
685
+ releaseRouteToken(key, operation) {
686
+ if (this.routeTokens.get(key) === operation.routeToken &&
687
+ !this.toSession.has(key) &&
688
+ !this.creatingSessions.has(key)) {
689
+ this.routeTokens.delete(key);
690
+ }
691
+ }
692
+ scheduleDiscardInvalidatedSession(sessionId, operation) {
693
+ if ([...this.toSession.values()].includes(sessionId))
694
+ return;
695
+ try {
696
+ void this.bridge
697
+ .discardSession?.(sessionId, operation)
698
+ .catch(() => undefined);
699
+ }
700
+ catch {
701
+ // Best-effort cleanup must not replace the terminal invalidation.
702
+ }
703
+ }
366
704
  createSessionReservation() {
367
705
  let resolveReservation;
368
706
  let rejectReservation;