@mcp-abap-adt/connection 2.0.0 → 4.0.0

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,18 +1,66 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.JwtAbapConnection = void 0;
4
+ const node_async_hooks_1 = require("node:async_hooks");
5
+ const interfaces_1 = require("@mcp-abap-adt/interfaces");
4
6
  const axios_1 = require("axios");
7
+ const SessionLifecycle_js_1 = require("../session/SessionLifecycle.js");
5
8
  const AbstractAbapConnection_js_1 = require("./AbstractAbapConnection.js");
9
+ const csrfConfig_js_1 = require("./csrfConfig.js");
10
+ /**
11
+ * Is this worth refreshing a token for?
12
+ *
13
+ * 401 only. A 403 means the server authenticated the caller and refused the
14
+ * action anyway — a new token is the same caller, so refreshing answers a
15
+ * question nobody asked and, worse, used to end with the original error
16
+ * replaced by "JWT token has expired". See issue #30.
17
+ */
18
+ function isTokenExpiryCandidate(error) {
19
+ return error instanceof axios_1.AxiosError && error.response?.status === 401;
20
+ }
6
21
  /**
7
22
  * JWT Authentication connection for SAP BTP Cloud systems
8
23
  *
9
24
  * Supports automatic token refresh via ITokenRefresher injection:
10
- * - If tokenRefresher is provided, 401/403 errors trigger automatic token refresh
11
- * - If tokenRefresher is not provided, 401/403 errors throw an error (legacy behavior)
25
+ * - a **401** triggers a token refresh when a tokenRefresher is available;
26
+ * - without one, or when the refresh does not help, the server's own error is
27
+ * thrown unchanged — status and body intact;
28
+ * - a **403** is never a token problem. It propagates as it arrived, because a
29
+ * new token is the same caller and cannot change a permissions answer.
12
30
  */
13
31
  class JwtAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnection {
14
32
  tokenRefresher;
15
33
  currentToken;
34
+ /** Bumped by any token refresh, token-only ones included. */
35
+ tokenGeneration = 0;
36
+ /** Single-flight over the token fetch alone. Touches no session state. */
37
+ tokenRefreshInFlight;
38
+ /**
39
+ * The `tokenGeneration` a completed session recovery was built for. Only
40
+ * `performRenewal` moves it, and only after `recoverSession` resolves.
41
+ *
42
+ * Separate from `tokenGeneration` because a token-only refresh — which
43
+ * `fetchCsrfToken` does, from inside an establishment — bumps that counter
44
+ * without rebuilding anything. Reading it as "the session is settled" made
45
+ * the outer handler retry a request whose session was never rebuilt.
46
+ */
47
+ recoveredGeneration = 0;
48
+ /**
49
+ * Single-flight over the whole credential renewal, session included.
50
+ *
51
+ * Sharing only the token fetch leaves the expensive half racing: `recover`
52
+ * and `cleanup` never join (`SessionLifecycle.transition`), so two concurrent
53
+ * renewals queue as cleanup A → recover A → cleanup B → recover B, and A's
54
+ * retry meets a session B has just torn down.
55
+ */
56
+ renewalInFlight;
57
+ /**
58
+ * Per connection, deliberately NOT static. `baseline` is compared against
59
+ * `this.tokenGeneration`, which is instance state — a store shared between
60
+ * instances would let one connection's operation hand its baseline to
61
+ * another's, and the comparison would be between unrelated counters.
62
+ */
63
+ recoveryScope = new node_async_hooks_1.AsyncLocalStorage();
16
64
  constructor(config, logger, sessionId, tokenRefresher) {
17
65
  JwtAbapConnection.validateConfig(config);
18
66
  super(config, logger || null, sessionId);
@@ -31,18 +79,90 @@ class JwtAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnecti
31
79
  return `Bearer ${this.currentToken}`;
32
80
  }
33
81
  /**
34
- * Refresh the JWT token using the injected tokenRefresher
35
- * @returns true if token was refreshed, false if no refresher available
82
+ * For the public entry point: this call is its own operation, always.
83
+ *
84
+ * A re-entrant `makeAdtRequest` — from a logger or a refresher callback the
85
+ * connection itself invokes while a scope is live — is a new caller-visible
86
+ * operation. Inheriting there would hand it a baseline from somebody else's
87
+ * refresh, which reads as "already refreshed for me" and skips a refresh it
88
+ * needs.
89
+ */
90
+ inNewRecoveryScope(fn) {
91
+ const scope = {
92
+ baseline: this.tokenGeneration,
93
+ active: true,
94
+ };
95
+ return this.recoveryScope.run(scope, async () => {
96
+ try {
97
+ return await fn();
98
+ }
99
+ finally {
100
+ scope.active = false;
101
+ }
102
+ });
103
+ }
104
+ /** For the inner levels: join the operation in progress, or start one. */
105
+ inRecoveryScope(fn) {
106
+ const inherited = this.recoveryScope.getStore();
107
+ // Only a scope that is still running. A store reached through an async
108
+ // resource that outlived its operation is stale, and its baseline describes
109
+ // a credential state that has since moved.
110
+ if (inherited?.active)
111
+ return fn();
112
+ return this.inNewRecoveryScope(fn);
113
+ }
114
+ /**
115
+ * The baseline this operation is reasoning from.
116
+ *
117
+ * The `active` check here and the one in `inRecoveryScope` OVERLAP: each
118
+ * compensates for the other, and the stale-context test only fails when both
119
+ * are removed. Kept as two because they answer different questions — "may I
120
+ * join this scope" and "may I trust this baseline" — and a reader who finds
121
+ * one redundant would be deleting half a guarantee.
122
+ */
123
+ currentBaseline() {
124
+ const scope = this.recoveryScope.getStore();
125
+ // No live scope means a caller reached a handler by a path that does not
126
+ // open one, or through a stale async context. Either way, treat it as its
127
+ // own operation rather than trusting a baseline nobody is standing behind.
128
+ return scope?.active ? scope.baseline : this.tokenGeneration;
129
+ }
130
+ /**
131
+ * Fetch a new token, unless somebody already did for this operation.
132
+ *
133
+ * Single-flighted so two concurrent handlers — including two nested
134
+ * `fetchCsrfToken` calls, which is a level `renewalInFlight` cannot reach —
135
+ * share one network call instead of racing and leaving `currentToken` as
136
+ * whichever settled last.
137
+ *
138
+ * @returns true when the caller may retry.
36
139
  */
37
- async tryRefreshToken() {
140
+ refreshTokenOnce(baseline) {
141
+ if (this.tokenGeneration > baseline)
142
+ return Promise.resolve(true);
143
+ if (this.tokenRefreshInFlight)
144
+ return this.tokenRefreshInFlight;
38
145
  if (!this.tokenRefresher) {
39
146
  this.logger?.debug(`[DEBUG] JwtAbapConnection - No tokenRefresher available, cannot refresh token`);
40
- return false;
147
+ return Promise.resolve(false);
41
148
  }
149
+ // Identity-checked clear, as SessionLifecycle.transition does with its
150
+ // tail: a joiner settling late must not clear a fetch somebody started
151
+ // after it.
152
+ const inFlight = this.performTokenRefresh().finally(() => {
153
+ if (this.tokenRefreshInFlight === inFlight) {
154
+ this.tokenRefreshInFlight = undefined;
155
+ }
156
+ });
157
+ this.tokenRefreshInFlight = inFlight;
158
+ return inFlight;
159
+ }
160
+ async performTokenRefresh() {
42
161
  try {
43
162
  this.logger?.debug(`[DEBUG] JwtAbapConnection - Refreshing token via tokenRefresher...`);
44
- const newToken = await this.tokenRefresher.refreshToken();
45
- this.currentToken = newToken;
163
+ // biome-ignore lint/style/noNonNullAssertion: refreshTokenOnce checks it
164
+ this.currentToken = await this.tokenRefresher.refreshToken();
165
+ this.tokenGeneration += 1;
46
166
  this.logger?.debug(`[DEBUG] JwtAbapConnection - Token refreshed successfully`);
47
167
  return true;
48
168
  }
@@ -51,6 +171,68 @@ class JwtAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnecti
51
171
  return false;
52
172
  }
53
173
  }
174
+ /**
175
+ * Renew the credential and the session it belongs to, once, shared.
176
+ */
177
+ renewCredential(baselineEpoch, baseline) {
178
+ if (this.renewalInFlight)
179
+ return this.renewalInFlight;
180
+ // Identity-checked clear, as SessionLifecycle.transition does with its
181
+ // tail: a joiner settling late must not clear a renewal somebody started
182
+ // after it.
183
+ const inFlight = this.performRenewal(baselineEpoch, baseline).finally(() => {
184
+ if (this.renewalInFlight === inFlight)
185
+ this.renewalInFlight = undefined;
186
+ });
187
+ this.renewalInFlight = inFlight;
188
+ return inFlight;
189
+ }
190
+ async performRenewal(baselineEpoch, baseline) {
191
+ // Shared with the token-only path, and a no-op when the token is already
192
+ // newer than the one that failed — then what is missing is the session, and
193
+ // a second fetch answers a question nobody asked.
194
+ if (!(await this.refreshTokenOnce(baseline)))
195
+ return false;
196
+ this.logger?.debug(`[DEBUG] JwtAbapConnection - Recovering session after token refresh...`);
197
+ // The renewed credential cannot keep the old ABAP session, so this is a
198
+ // session-lost teardown — internal, or it would cancel the very recovery it
199
+ // is setting up. reset() would be the caller-origin one.
200
+ this.discardSession();
201
+ // Re-establish before retrying: the retry goes through admission, and a
202
+ // discarded session admits nothing.
203
+ await this.recoverSession(baselineEpoch);
204
+ // Only here: a session now exists that was built with this token.
205
+ this.recoveredGeneration = this.tokenGeneration;
206
+ return true;
207
+ }
208
+ /**
209
+ * May this operation retry?
210
+ *
211
+ * The order of the checks is the design, not style.
212
+ */
213
+ async ensureRecovered(baselineEpoch) {
214
+ // 1. A renewal in flight is joined REGARDLESS of generation. A newer token
215
+ // is no use while the session it belongs to is still being rebuilt:
216
+ // retrying now is how a caller meets a closed admission door.
217
+ //
218
+ // This overlaps check 2 as the counters stand — `recoveredGeneration`
219
+ // only moves after `recoverSession` resolves, so check 2 cannot report a
220
+ // rebuild that has not finished, and removing either guard alone leaves
221
+ // the concurrency test green. Both are kept on purpose: this one states
222
+ // the rule ("never decide anything while a renewal is running") without
223
+ // depending on when some other counter happens to move, and it is what
224
+ // keeps the invariant true if check 2's counter is ever changed.
225
+ if (this.renewalInFlight)
226
+ return this.renewalInFlight;
227
+ const baseline = this.currentBaseline();
228
+ // 2. A full renewal COMPLETED since this operation began — token, and the
229
+ // session built with it. Not `tokenGeneration`: that moves on a
230
+ // token-only refresh, which rebuilds nothing.
231
+ if (this.recoveredGeneration > baseline)
232
+ return true;
233
+ // 3. Nobody has. Renew, and let everyone else join.
234
+ return this.renewCredential(baselineEpoch, baseline);
235
+ }
54
236
  /**
55
237
  * Establishes the session for this auth type. Called by
56
238
  * AbstractAbapConnection.connect(), which owns the lifecycle around it.
@@ -61,7 +243,7 @@ class JwtAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnecti
61
243
  this.logger?.debug(`[DEBUG] JwtAbapConnection - Connecting to SAP system: ${discoveryUrl}`);
62
244
  try {
63
245
  // Try to get CSRF token (this will also get cookies)
64
- const token = await this.fetchCsrfToken(discoveryUrl, 3, 1000);
246
+ const token = await this.fetchCsrfToken(discoveryUrl);
65
247
  this.setCsrfToken(token);
66
248
  this.logger?.debug('Successfully connected to SAP system', {
67
249
  hasCsrfToken: !!this.getCsrfToken(),
@@ -70,31 +252,9 @@ class JwtAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnecti
70
252
  });
71
253
  }
72
254
  catch (error) {
73
- // Handle JWT auth errors (401/403) during connect
74
- if (error instanceof axios_1.AxiosError &&
75
- (error.response?.status === 401 || error.response?.status === 403)) {
76
- // Check if this is really an auth error, not a permissions error
77
- const responseData = error.response?.data;
78
- const responseText = typeof responseData === 'string'
79
- ? responseData
80
- : JSON.stringify(responseData || '');
81
- // Don't retry on "No Access" errors
82
- if (responseText.includes('ExceptionResourceNoAccess') ||
83
- responseText.includes('No authorization') ||
84
- responseText.includes('Missing authorization')) {
85
- throw error;
86
- }
87
- // Try to refresh token if tokenRefresher is available
88
- if (await this.tryRefreshToken()) {
89
- // Retry the ESTABLISHMENT, not connect(): connect() runs this as a
90
- // joinable transition, so a nested call joins the transition already
91
- // in flight — this one — and waits for itself forever.
92
- this.logger?.debug(`[DEBUG] JwtAbapConnection - Retrying establishment after token refresh...`);
93
- return this.establishSession();
94
- }
95
- throw new Error('JWT token has expired. Please re-authenticate.');
255
+ if (isTokenExpiryCandidate(error)) {
256
+ this.logger?.error('[ERROR] JwtAbapConnection.establishSession - 401 while establishing; fetchCsrfToken has already refreshed and retried for this');
96
257
  }
97
- // Re-throw other errors
98
258
  throw error;
99
259
  }
100
260
  }
@@ -102,6 +262,10 @@ class JwtAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnecti
102
262
  * Override makeAdtRequest to handle JWT auth errors with automatic token refresh
103
263
  */
104
264
  async makeAdtRequest(options) {
265
+ // A public call is its own operation, whatever scope it starts in.
266
+ return this.inNewRecoveryScope(() => this.attemptRequest(options));
267
+ }
268
+ async attemptRequest(options) {
105
269
  // Captured before the attempt: a recovery asks "has the caller asked to
106
270
  // stop since this request began", not since some later bookkeeping step.
107
271
  const baselineEpoch = this.teardownEpoch;
@@ -113,34 +277,32 @@ class JwtAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnecti
113
277
  }
114
278
  catch (error) {
115
279
  this.logger?.debug(`[DEBUG] JwtAbapConnection.makeAdtRequest - Request failed: ${error instanceof Error ? error.message : String(error)}`);
116
- // Handle JWT auth errors (401/403)
117
- if (error instanceof axios_1.AxiosError &&
118
- (error.response?.status === 401 || error.response?.status === 403)) {
119
- this.logger?.debug(`[DEBUG] JwtAbapConnection.makeAdtRequest - Got ${error.response.status}, attempting token refresh...`);
120
- // Check if this is really an auth error, not a permissions error
121
- const responseData = error.response?.data;
122
- const responseText = typeof responseData === 'string'
123
- ? responseData
124
- : JSON.stringify(responseData || '');
125
- // Don't retry on "No Access" errors - these are permission issues, not auth issues
126
- if (responseText.includes('ExceptionResourceNoAccess') ||
127
- responseText.includes('No authorization') ||
128
- responseText.includes('Missing authorization')) {
129
- throw error;
130
- }
131
- // Try to refresh token if tokenRefresher is available
132
- if (await this.tryRefreshToken()) {
133
- this.logger?.debug(`[DEBUG] JwtAbapConnection.makeAdtRequest - Recovering session after token refresh...`);
134
- // The renewed credential cannot keep the old ABAP session, so this is
135
- // a session-lost teardown — internal, or it would cancel the very
136
- // recovery it is setting up. reset() would be the caller-origin one.
137
- this.discardSession();
138
- // Re-establish before retrying: the retry goes through admission, and
139
- // a discarded session admits nothing.
140
- await this.recoverSession(baselineEpoch);
141
- return super.makeAdtRequest(options);
280
+ if (isTokenExpiryCandidate(error)) {
281
+ this.logger?.debug(`[DEBUG] JwtAbapConnection.makeAdtRequest - Got 401, attempting token refresh...`);
282
+ if (await this.ensureRecovered(baselineEpoch)) {
283
+ // A renewal this operation joined was fenced against the STARTER's
284
+ // baseline, not this one's, and establishAndCommit's own epoch checks
285
+ // belong to the establishment that ran. Between the last of them and
286
+ // this retry there is a gap nothing else watches: the connection can
287
+ // be torn down by its caller and made usable again, and a retry would
288
+ // then go out on a session that caller discarded.
289
+ if (this.teardownEpoch !== baselineEpoch) {
290
+ throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'Retry abandoned: a teardown was requested for this connection');
291
+ }
292
+ try {
293
+ return await super.makeAdtRequest(options);
294
+ }
295
+ catch (retryError) {
296
+ // A 401 that survived a renewal is the case the deleted message
297
+ // was about, and it only shows up here — the log below fires when
298
+ // the renewal never happened, which is a different fact.
299
+ if (isTokenExpiryCandidate(retryError)) {
300
+ this.logger?.error('[ERROR] JwtAbapConnection.makeAdtRequest - 401 persists after a credential renewal; the credential may need re-authentication');
301
+ }
302
+ throw retryError;
303
+ }
142
304
  }
143
- throw new Error('JWT token has expired. Please re-authenticate.');
305
+ this.logger?.error('[ERROR] JwtAbapConnection.makeAdtRequest - 401 persists and the token could not be refreshed; the credential may need re-authentication');
144
306
  }
145
307
  throw error;
146
308
  }
@@ -148,33 +310,37 @@ class JwtAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnecti
148
310
  /**
149
311
  * Override fetchCsrfToken to handle JWT auth errors with automatic token refresh
150
312
  */
151
- async fetchCsrfToken(url, retryCount = 3, retryDelay = 1000) {
313
+ async fetchCsrfToken(url, retryCount = csrfConfig_js_1.CSRF_CONFIG.RETRY_COUNT, retryDelay = csrfConfig_js_1.CSRF_CONFIG.RETRY_DELAY,
314
+ /** Fences the response effects; omitted during connect(), which has no lease. */
315
+ generation) {
316
+ // An inner level: join the operation in progress, or start one when
317
+ // reached directly — a bare connect() is still an operation with a
318
+ // baseline.
319
+ return this.inRecoveryScope(() => this.attemptCsrfToken(url, retryCount, retryDelay, generation));
320
+ }
321
+ async attemptCsrfToken(url, retryCount, retryDelay, generation) {
152
322
  try {
153
323
  // Try to fetch CSRF token using parent implementation
154
- return await super.fetchCsrfToken(url, retryCount, retryDelay);
324
+ return await super.fetchCsrfToken(url, retryCount, retryDelay, generation);
155
325
  }
156
326
  catch (error) {
157
- // Handle JWT auth errors (401/403) during CSRF token fetch
158
- if (error instanceof axios_1.AxiosError &&
159
- (error.response?.status === 401 || error.response?.status === 403)) {
160
- // Check if this is really an auth error, not a permissions error
161
- const responseData = error.response?.data;
162
- const responseText = typeof responseData === 'string'
163
- ? responseData
164
- : JSON.stringify(responseData || '');
165
- // Don't retry on "No Access" errors
166
- if (responseText.includes('ExceptionResourceNoAccess') ||
167
- responseText.includes('No authorization') ||
168
- responseText.includes('Missing authorization')) {
169
- throw error;
170
- }
171
- // Try to refresh token if tokenRefresher is available
172
- if (await this.tryRefreshToken()) {
327
+ // A 401 here may be an expired token; anything else is not ours to
328
+ // interpret — a 403 least of all, since a new token is the same caller.
329
+ if (isTokenExpiryCandidate(error)) {
330
+ if (await this.refreshTokenOnce(this.currentBaseline())) {
173
331
  // Retry CSRF token fetch with new token
174
332
  this.logger?.debug(`[DEBUG] JwtAbapConnection.fetchCsrfToken - Retrying after token refresh...`);
175
- return super.fetchCsrfToken(url, retryCount, retryDelay);
333
+ try {
334
+ return await super.fetchCsrfToken(url, retryCount, retryDelay, generation);
335
+ }
336
+ catch (retryError) {
337
+ if (isTokenExpiryCandidate(retryError)) {
338
+ this.logger?.error('[ERROR] JwtAbapConnection.fetchCsrfToken - 401 persists after a token refresh; the credential may need re-authentication');
339
+ }
340
+ throw retryError;
341
+ }
176
342
  }
177
- throw new Error('JWT token has expired. Please re-authenticate.');
343
+ this.logger?.error('[ERROR] JwtAbapConnection.fetchCsrfToken - 401 persists and the token could not be refreshed; the credential may need re-authentication');
178
344
  }
179
345
  // Re-throw other errors
180
346
  throw error;
@@ -29,7 +29,7 @@ class SamlAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnect
29
29
  const discoveryUrl = `${baseUrl}/sap/bc/adt/discovery`;
30
30
  this.logger?.debug(`[DEBUG] SamlAbapConnection - Connecting to SAP system: ${discoveryUrl}`);
31
31
  try {
32
- const token = await this.fetchCsrfToken(discoveryUrl, 3, 1000);
32
+ const token = await this.fetchCsrfToken(discoveryUrl);
33
33
  this.setCsrfToken(token);
34
34
  this.logger?.debug('Successfully connected to SAP system', {
35
35
  hasCsrfToken: !!this.getCsrfToken(),
package/dist/index.js CHANGED
@@ -7,10 +7,10 @@ Object.defineProperty(exports, "FileCertificateMaterialLoader", { enumerable: tr
7
7
  // Config utilities
8
8
  var sapConfig_js_1 = require("./config/sapConfig.js");
9
9
  Object.defineProperty(exports, "sapConfigSignature", { enumerable: true, get: function () { return sapConfig_js_1.sapConfigSignature; } });
10
- // The session lifecycle vocabulary — ISessionLifecycleAware, ILockWindowAware,
11
- // ITeardownReport, WindowToken, ADT_SESSION_ERROR — is deliberately NOT exported
12
- // here. It lives in @mcp-abap-adt/interfaces, and a consumer imports it from
13
- // there: re-exporting a contract type gives it two names and lets the two drift.
10
+ // The session lifecycle vocabulary — ISessionLifecycleAware, ADT_SESSION_ERROR —
11
+ // is deliberately NOT exported here. It lives in @mcp-abap-adt/interfaces, and a
12
+ // consumer imports it from there: re-exporting a contract type gives it two
13
+ // names and lets the two drift.
14
14
  // Connection classes - only final implementations
15
15
  // Deprecated aliases for backward compatibility
16
16
  var BaseAbapConnection_js_1 = require("./connection/BaseAbapConnection.js");
@@ -1,118 +1,108 @@
1
1
  /**
2
2
  * Owns the session lifecycle: what state the session is in, who may use it, and
3
- * when it is safe to tear it down.
3
+ * when it stops being current.
4
4
  *
5
5
  * Deliberately knows nothing about SAP, HTTP, RFC, cookies or ADT. Identities
6
- * are opaque maps of name → value that someone else computed; windows are
7
- * opaque labels. That is what makes this unit testable without a server, and it
8
- * is where the hard part of the design lives: ordering, admission, deadlines.
6
+ * are opaque maps of name → value that someone else computed. That is what makes
7
+ * this unit testable without a server, and it is where the hard part lives:
8
+ * ordering, admission, and telling one session from the next.
9
9
  *
10
- * Design: docs/superpowers/specs/2026-07-27-session-lifecycle-design.md in
10
+ * Design: docs/superpowers/specs/2026-07-31-teardown-policy-design.md in
11
11
  * @mcp-abap-adt/adt-clients.
12
12
  */
13
- import { type AdtSessionErrorCode, type WindowToken } from '@mcp-abap-adt/interfaces';
13
+ import { type AdtSessionErrorCode } from '@mcp-abap-adt/interfaces';
14
14
  export type TransitionKind = 'connect' | 'disconnect' | 'recover' | 'cleanup';
15
15
  export interface RequestLease {
16
16
  /** Teardown epoch at admission — the baseline a recovery compares against. */
17
17
  readonly epoch: number;
18
+ /**
19
+ * Session generation at admission — the baseline a RESPONSE compares against
20
+ * before touching shared state. Distinct from `epoch` on purpose: see
21
+ * `sessionGeneration`.
22
+ */
23
+ readonly generation: number;
18
24
  /** Call once the request settles. Safe to call twice. */
19
25
  release(): void;
20
26
  }
21
- export interface DrainResult {
22
- /** Labels of windows still open when the wait gave up, deduplicated. */
23
- abandonedWindows: string[];
24
- }
25
27
  export interface BeginTeardownOptions {
26
28
  /** Decides the epoch: a caller's request cancels recoveries, an internal one must not. */
27
29
  origin: 'caller' | 'internal';
28
- /** Decides admission: a lost session cannot let an open window finish. */
30
+ /** A lost session cannot be spoken to again; its identity is dropped at once. */
29
31
  sessionLost: boolean;
30
32
  }
31
33
  export declare function sessionError(code: AdtSessionErrorCode, message?: string): Error & {
32
34
  code: AdtSessionErrorCode;
33
35
  };
34
- export interface SessionLifecycleOptions {
35
- /** Ceiling for the window wait, from the moment a teardown is requested. */
36
- ceilingMs?: number;
37
- /** Injected clock, so tests need no real time. */
38
- now?: () => number;
39
- }
40
36
  export declare class SessionLifecycle {
41
37
  private state;
42
38
  private fingerprint;
43
39
  private epoch;
40
+ private generation;
44
41
  private teardownPending;
45
- private teardownLostSession;
46
- private teardownAt;
47
- /** Set at expiry: admission is shut regardless of open windows. */
48
- private admissionForcedShut;
49
- private readonly windows;
50
42
  private inFlight;
51
43
  private tail;
52
44
  private tailKind;
53
45
  private tailPromise;
54
- private waiters;
55
- private readonly ceilingMs;
56
- private readonly now;
57
- constructor(options?: SessionLifecycleOptions);
58
- /**
59
- * Whether a caller may start work. False throughout a teardown, including
60
- * while a grandfathered window is still finishing: finishing is not starting.
61
- */
46
+ /** Whether a caller may start work. False throughout a pending teardown. */
62
47
  get connected(): boolean;
63
48
  /** Derived from the tracked fingerprint; null when nothing is tracked. */
64
49
  get identity(): string | null;
65
50
  get teardownEpoch(): number;
66
- /** One entry per open window, abandoned ones included. */
67
- get openWindows(): readonly string[];
51
+ /**
52
+ * Which session is current, counted from zero.
53
+ *
54
+ * Two counters answer two different questions, and one cannot do both:
55
+ *
56
+ * - `epoch` — did the CALLER ask to stop? It moves only on a caller-initiated
57
+ * teardown, because a recovery must not cancel itself.
58
+ * - `generation` — is this still the session you were issued against? It moves
59
+ * on every change of which session is current, however caused.
60
+ *
61
+ * A fence built on `epoch` misses every internal teardown: after a session
62
+ * loss and a successful recovery, requests from the dead session carry the
63
+ * same epoch as the new one and sail straight through.
64
+ */
65
+ get sessionGeneration(): number;
68
66
  /**
69
67
  * Publishes a freshly established session, which also ENDS any teardown that
70
68
  * was pending: the flags describe the session being torn down, and this is a
71
69
  * different one. Without this, `connect()` after `disconnect()` — which the
72
70
  * design allows explicitly — leaves the lifecycle permanently unusable.
73
- *
74
- * Windows are dropped for the same reason: they belonged to the old session,
75
- * nothing here can close them, and a teardown that gave up on them has
76
- * already carried their labels out in its report. Keeping them would make
77
- * every future drain re-report locks from a session that no longer exists.
78
71
  */
79
72
  markConnected(fingerprint?: ReadonlyMap<string, string>): void;
73
+ /**
74
+ * Drops the tracked identity without touching state or generation.
75
+ *
76
+ * For a session the caller discarded ON PURPOSE — a credential renewal, a
77
+ * cache invalidation. The next fingerprint then reads as `established` rather
78
+ * than `replaced`, which is the truth: nothing was taken from us.
79
+ */
80
+ forgetIdentity(): void;
80
81
  markDisconnected(): void;
81
82
  /**
82
83
  * Classifies a freshly observed fingerprint.
83
84
  *
84
85
  * Additive: a name appearing where none was tracked is `established`, never
85
86
  * `replaced` — otherwise the identifier a LOCK response adds would read as a
86
- * new session and blow up the window it just opened.
87
+ * new session and condemn the operation it just covered.
87
88
  */
88
89
  observe(fingerprint: ReadonlyMap<string, string>): 'unchanged' | 'established' | 'replaced';
89
- /** True while an already-open window is allowed to finish its work. */
90
- private get finishingWindowOpen();
91
- private get admits();
92
90
  assertUsable(): void;
93
91
  /** Asserts usability and counts the request in, in one synchronous step. */
94
92
  admitRequest(): RequestLease;
93
+ /** How many admitted requests have not settled. Diagnostics only — nothing waits on it. */
94
+ get requestsInFlight(): number;
95
95
  /**
96
- * A lock outlives the request that takes it, so it is the one thing a pending
97
- * teardown refuses outright.
96
+ * Whether a lease may still touch shared state.
97
+ *
98
+ * A request outliving its session is ordinary now that a teardown does not
99
+ * wait: its response must not write cookies over a newer session's, and must
100
+ * not be read as a replacement — which would raise a session-lost teardown
101
+ * against a session that is perfectly healthy.
98
102
  */
99
- beginWindow(label: string): WindowToken;
100
- /** A token matching no open window is ignored: double close, foreign token. */
101
- endWindow(token: WindowToken): void;
103
+ isCurrent(lease: Pick<RequestLease, 'generation'>): boolean;
102
104
  beginTeardown({ origin, sessionLost }: BeginTeardownOptions): void;
103
105
  get teardownRequested(): boolean;
104
- /**
105
- * Resolves when nothing is in flight and no window is still worth waiting
106
- * for. Bounded by the ceiling measured from the teardown request — absolute,
107
- * never extended by request activity.
108
- *
109
- * On expiry, before resolving and without yielding in between: shuts
110
- * admission, gives up on the remaining windows, then waits once more for the
111
- * already-admitted requests to settle.
112
- */
113
- drain(): Promise<DrainResult>;
114
- private get liveWindows();
115
- private abandonedLabels;
116
106
  /**
117
107
  * Runs a transition on the serializing tail.
118
108
  *
@@ -121,11 +111,8 @@ export declare class SessionLifecycle {
121
111
  * when nothing is queued behind it, so a join can never overtake a queued
122
112
  * transition of another kind. `recover` and `cleanup` never join and are
123
113
  * never joined: a recovery carries its own request's baseline, and an
124
- * internal cleanup owes its result to nobody while a caller's teardown owes a
125
- * report.
114
+ * internal cleanup owes its result to nobody.
126
115
  */
127
116
  transition<T>(kind: TransitionKind, run: () => Promise<T>): Promise<T>;
128
- private wake;
129
- private changed;
130
117
  }
131
118
  //# sourceMappingURL=SessionLifecycle.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"SessionLifecycle.d.ts","sourceRoot":"","sources":["../../src/session/SessionLifecycle.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"SessionLifecycle.d.ts","sourceRoot":"","sources":["../../src/session/SessionLifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAEL,KAAK,mBAAmB,EACzB,MAAM,0BAA0B,CAAC;AAElC,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,YAAY,GAAG,SAAS,GAAG,SAAS,CAAC;AAE9E,MAAM,WAAW,YAAY;IAC3B,8EAA8E;IAC9E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,yDAAyD;IACzD,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,oBAAoB;IACnC,0FAA0F;IAC1F,MAAM,EAAE,QAAQ,GAAG,UAAU,CAAC;IAC9B,iFAAiF;IACjF,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,wBAAgB,YAAY,CAC1B,IAAI,EAAE,mBAAmB,EACzB,OAAO,CAAC,EAAE,MAAM,GACf,KAAK,GAAG;IAAE,IAAI,EAAE,mBAAmB,CAAA;CAAE,CAMvC;AAED,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,KAAK,CAAgD;IAC7D,OAAO,CAAC,WAAW,CAA6B;IAChD,OAAO,CAAC,KAAK,CAAK;IAClB,OAAO,CAAC,UAAU,CAAK;IAEvB,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAK;IAErB,OAAO,CAAC,IAAI,CAAuC;IACnD,OAAO,CAAC,QAAQ,CAA+B;IAC/C,OAAO,CAAC,WAAW,CAAiC;IAIpD,4EAA4E;IAC5E,IAAI,SAAS,IAAI,OAAO,CAEvB;IAED,0EAA0E;IAC1E,IAAI,QAAQ,IAAI,MAAM,GAAG,IAAI,CAM5B;IAED,IAAI,aAAa,IAAI,MAAM,CAE1B;IAED;;;;;;;;;;;;;OAaG;IACH,IAAI,iBAAiB,IAAI,MAAM,CAE9B;IAED;;;;;OAKG;IACH,aAAa,CAAC,WAAW,GAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAa,GAAG,IAAI;IAOzE;;;;;;OAMG;IACH,cAAc,IAAI,IAAI;IAItB,gBAAgB,IAAI,IAAI;IAKxB;;;;;;OAMG;IACH,OAAO,CACL,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,GACvC,WAAW,GAAG,aAAa,GAAG,UAAU;IAiB3C,YAAY,IAAI,IAAI;IAMpB,4EAA4E;IAC5E,YAAY,IAAI,YAAY;IAiB5B,2FAA2F;IAC3F,IAAI,gBAAgB,IAAI,MAAM,CAE7B;IAED;;;;;;;OAOG;IACH,SAAS,CAAC,KAAK,EAAE,IAAI,CAAC,YAAY,EAAE,YAAY,CAAC,GAAG,OAAO;IAM3D,aAAa,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE,EAAE,oBAAoB,GAAG,IAAI;IAelE,IAAI,iBAAiB,IAAI,OAAO,CAE/B;IAID;;;;;;;;;OASG;IACH,UAAU,CAAC,CAAC,EAAE,IAAI,EAAE,cAAc,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;CAuBvE"}