@mcp-abap-adt/connection 2.0.0 → 3.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.
- package/CHANGELOG.md +70 -1
- package/README.md +11 -9
- package/dist/connection/AbstractAbapConnection.d.ts +32 -16
- package/dist/connection/AbstractAbapConnection.d.ts.map +1 -1
- package/dist/connection/AbstractAbapConnection.js +93 -51
- package/dist/index.js +4 -4
- package/dist/session/SessionLifecycle.d.ts +49 -62
- package/dist/session/SessionLifecycle.d.ts.map +1 -1
- package/dist/session/SessionLifecycle.js +56 -155
- package/docs/MIGRATION-2.0.md +22 -33
- package/docs/STATEFUL_SESSION_GUIDE.md +5 -4
- package/docs/USAGE.md +67 -71
- package/package.json +2 -2
|
@@ -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
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
67
|
-
|
|
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
|
|
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
|
-
*
|
|
97
|
-
*
|
|
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
|
-
|
|
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
|
|
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":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAEL,KAAK,mBAAmB,
|
|
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"}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
3
|
* Owns the session lifecycle: what state the session is in, who may use it, and
|
|
4
|
-
* when it
|
|
4
|
+
* when it stops being current.
|
|
5
5
|
*
|
|
6
6
|
* Deliberately knows nothing about SAP, HTTP, RFC, cookies or ADT. Identities
|
|
7
|
-
* are opaque maps of name → value that someone else computed
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* are opaque maps of name → value that someone else computed. That is what makes
|
|
8
|
+
* this unit testable without a server, and it is where the hard part lives:
|
|
9
|
+
* ordering, admission, and telling one session from the next.
|
|
10
10
|
*
|
|
11
|
-
* Design: docs/superpowers/specs/2026-07-
|
|
11
|
+
* Design: docs/superpowers/specs/2026-07-31-teardown-policy-design.md in
|
|
12
12
|
* @mcp-abap-adt/adt-clients.
|
|
13
13
|
*/
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -24,28 +24,14 @@ class SessionLifecycle {
|
|
|
24
24
|
state = 'disconnected';
|
|
25
25
|
fingerprint = new Map();
|
|
26
26
|
epoch = 0;
|
|
27
|
+
generation = 0;
|
|
27
28
|
teardownPending = false;
|
|
28
|
-
teardownLostSession = false;
|
|
29
|
-
teardownAt = null;
|
|
30
|
-
/** Set at expiry: admission is shut regardless of open windows. */
|
|
31
|
-
admissionForcedShut = false;
|
|
32
|
-
windows = new Map();
|
|
33
29
|
inFlight = 0;
|
|
34
30
|
tail = Promise.resolve();
|
|
35
31
|
tailKind = null;
|
|
36
32
|
tailPromise = null;
|
|
37
|
-
waiters = [];
|
|
38
|
-
ceilingMs;
|
|
39
|
-
now;
|
|
40
|
-
constructor(options = {}) {
|
|
41
|
-
this.ceilingMs = options.ceilingMs ?? 600_000;
|
|
42
|
-
this.now = options.now ?? (() => Date.now());
|
|
43
|
-
}
|
|
44
33
|
// ---------------------------------------------------------------- state ---
|
|
45
|
-
/**
|
|
46
|
-
* Whether a caller may start work. False throughout a teardown, including
|
|
47
|
-
* while a grandfathered window is still finishing: finishing is not starting.
|
|
48
|
-
*/
|
|
34
|
+
/** Whether a caller may start work. False throughout a pending teardown. */
|
|
49
35
|
get connected() {
|
|
50
36
|
return this.state === 'connected' && !this.teardownPending;
|
|
51
37
|
}
|
|
@@ -61,42 +47,55 @@ class SessionLifecycle {
|
|
|
61
47
|
get teardownEpoch() {
|
|
62
48
|
return this.epoch;
|
|
63
49
|
}
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Which session is current, counted from zero.
|
|
52
|
+
*
|
|
53
|
+
* Two counters answer two different questions, and one cannot do both:
|
|
54
|
+
*
|
|
55
|
+
* - `epoch` — did the CALLER ask to stop? It moves only on a caller-initiated
|
|
56
|
+
* teardown, because a recovery must not cancel itself.
|
|
57
|
+
* - `generation` — is this still the session you were issued against? It moves
|
|
58
|
+
* on every change of which session is current, however caused.
|
|
59
|
+
*
|
|
60
|
+
* A fence built on `epoch` misses every internal teardown: after a session
|
|
61
|
+
* loss and a successful recovery, requests from the dead session carry the
|
|
62
|
+
* same epoch as the new one and sail straight through.
|
|
63
|
+
*/
|
|
64
|
+
get sessionGeneration() {
|
|
65
|
+
return this.generation;
|
|
67
66
|
}
|
|
68
67
|
/**
|
|
69
68
|
* Publishes a freshly established session, which also ENDS any teardown that
|
|
70
69
|
* was pending: the flags describe the session being torn down, and this is a
|
|
71
70
|
* different one. Without this, `connect()` after `disconnect()` — which the
|
|
72
71
|
* 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
72
|
*/
|
|
79
73
|
markConnected(fingerprint = new Map()) {
|
|
80
74
|
this.state = 'connected';
|
|
81
75
|
this.fingerprint = new Map(fingerprint);
|
|
82
76
|
this.teardownPending = false;
|
|
83
|
-
this.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
77
|
+
this.generation += 1;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Drops the tracked identity without touching state or generation.
|
|
81
|
+
*
|
|
82
|
+
* For a session the caller discarded ON PURPOSE — a credential renewal, a
|
|
83
|
+
* cache invalidation. The next fingerprint then reads as `established` rather
|
|
84
|
+
* than `replaced`, which is the truth: nothing was taken from us.
|
|
85
|
+
*/
|
|
86
|
+
forgetIdentity() {
|
|
87
|
+
this.fingerprint.clear();
|
|
88
88
|
}
|
|
89
89
|
markDisconnected() {
|
|
90
90
|
this.state = 'disconnected';
|
|
91
91
|
this.fingerprint.clear();
|
|
92
|
-
this.wake();
|
|
93
92
|
}
|
|
94
93
|
/**
|
|
95
94
|
* Classifies a freshly observed fingerprint.
|
|
96
95
|
*
|
|
97
96
|
* Additive: a name appearing where none was tracked is `established`, never
|
|
98
97
|
* `replaced` — otherwise the identifier a LOCK response adds would read as a
|
|
99
|
-
* new session and
|
|
98
|
+
* new session and condemn the operation it just covered.
|
|
100
99
|
*/
|
|
101
100
|
observe(fingerprint) {
|
|
102
101
|
let established = false;
|
|
@@ -114,22 +113,8 @@ class SessionLifecycle {
|
|
|
114
113
|
return established ? 'established' : 'unchanged';
|
|
115
114
|
}
|
|
116
115
|
// ------------------------------------------------------------ admission ---
|
|
117
|
-
/** True while an already-open window is allowed to finish its work. */
|
|
118
|
-
get finishingWindowOpen() {
|
|
119
|
-
return (this.teardownPending &&
|
|
120
|
-
!this.teardownLostSession &&
|
|
121
|
-
!this.admissionForcedShut &&
|
|
122
|
-
this.windows.size > 0);
|
|
123
|
-
}
|
|
124
|
-
get admits() {
|
|
125
|
-
if (this.state !== 'connected')
|
|
126
|
-
return false;
|
|
127
|
-
if (!this.teardownPending)
|
|
128
|
-
return true;
|
|
129
|
-
return this.finishingWindowOpen;
|
|
130
|
-
}
|
|
131
116
|
assertUsable() {
|
|
132
|
-
if (
|
|
117
|
+
if (this.state !== 'connected' || this.teardownPending) {
|
|
133
118
|
throw sessionError(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED);
|
|
134
119
|
}
|
|
135
120
|
}
|
|
@@ -138,107 +123,52 @@ class SessionLifecycle {
|
|
|
138
123
|
this.assertUsable();
|
|
139
124
|
this.inFlight += 1;
|
|
140
125
|
const epoch = this.epoch;
|
|
126
|
+
const generation = this.generation;
|
|
141
127
|
let released = false;
|
|
142
128
|
return {
|
|
143
129
|
epoch,
|
|
130
|
+
generation,
|
|
144
131
|
release: () => {
|
|
145
132
|
if (released)
|
|
146
133
|
return;
|
|
147
134
|
released = true;
|
|
148
135
|
this.inFlight -= 1;
|
|
149
|
-
this.wake();
|
|
150
136
|
},
|
|
151
137
|
};
|
|
152
138
|
}
|
|
153
|
-
|
|
139
|
+
/** How many admitted requests have not settled. Diagnostics only — nothing waits on it. */
|
|
140
|
+
get requestsInFlight() {
|
|
141
|
+
return this.inFlight;
|
|
142
|
+
}
|
|
154
143
|
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
144
|
+
* Whether a lease may still touch shared state.
|
|
145
|
+
*
|
|
146
|
+
* A request outliving its session is ordinary now that a teardown does not
|
|
147
|
+
* wait: its response must not write cookies over a newer session's, and must
|
|
148
|
+
* not be read as a replacement — which would raise a session-lost teardown
|
|
149
|
+
* against a session that is perfectly healthy.
|
|
157
150
|
*/
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
throw sessionError(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED);
|
|
161
|
-
}
|
|
162
|
-
const token = Symbol(label);
|
|
163
|
-
this.windows.set(token, { label, abandoned: false });
|
|
164
|
-
return token;
|
|
165
|
-
}
|
|
166
|
-
/** A token matching no open window is ignored: double close, foreign token. */
|
|
167
|
-
endWindow(token) {
|
|
168
|
-
if (!this.windows.delete(token))
|
|
169
|
-
return;
|
|
170
|
-
this.wake();
|
|
151
|
+
isCurrent(lease) {
|
|
152
|
+
return lease.generation === this.generation;
|
|
171
153
|
}
|
|
172
154
|
// ------------------------------------------------------------- teardown ---
|
|
173
155
|
beginTeardown({ origin, sessionLost }) {
|
|
174
|
-
if (!this.teardownPending) {
|
|
175
|
-
this.teardownAt = this.now();
|
|
176
|
-
}
|
|
177
156
|
this.teardownPending = true;
|
|
178
157
|
if (origin === 'caller') {
|
|
179
158
|
this.epoch += 1;
|
|
180
159
|
}
|
|
160
|
+
// The moment a session stops being current is the moment leases against it
|
|
161
|
+
// go stale — whether or not a replacement exists yet. Counting only at
|
|
162
|
+
// markConnected() leaves the gap between a teardown and the next connect,
|
|
163
|
+
// where a late response would write into state just cleared.
|
|
164
|
+
this.generation += 1;
|
|
181
165
|
if (sessionLost) {
|
|
182
|
-
this.teardownLostSession = true;
|
|
183
|
-
// Nothing can finish over a session that is gone: give up on every window
|
|
184
|
-
// now, and drop the identity so a later comparison sees the change.
|
|
185
|
-
for (const entry of this.windows.values())
|
|
186
|
-
entry.abandoned = true;
|
|
187
166
|
this.fingerprint.clear();
|
|
188
167
|
}
|
|
189
|
-
this.wake();
|
|
190
168
|
}
|
|
191
169
|
get teardownRequested() {
|
|
192
170
|
return this.teardownPending;
|
|
193
171
|
}
|
|
194
|
-
/**
|
|
195
|
-
* Resolves when nothing is in flight and no window is still worth waiting
|
|
196
|
-
* for. Bounded by the ceiling measured from the teardown request — absolute,
|
|
197
|
-
* never extended by request activity.
|
|
198
|
-
*
|
|
199
|
-
* On expiry, before resolving and without yielding in between: shuts
|
|
200
|
-
* admission, gives up on the remaining windows, then waits once more for the
|
|
201
|
-
* already-admitted requests to settle.
|
|
202
|
-
*/
|
|
203
|
-
async drain() {
|
|
204
|
-
// `??`, not `||`: a teardown requested at timestamp 0 is a real teardown,
|
|
205
|
-
// and treating it as absent would restart the ceiling from drain() — making
|
|
206
|
-
// the deadline relative to the wrong event, which is the sliding behaviour
|
|
207
|
-
// this design rejects.
|
|
208
|
-
const deadline = (this.teardownAt ?? this.now()) + this.ceilingMs;
|
|
209
|
-
while (this.inFlight > 0 || this.liveWindows > 0) {
|
|
210
|
-
const remaining = deadline - this.now();
|
|
211
|
-
if (remaining <= 0)
|
|
212
|
-
break;
|
|
213
|
-
await this.changed(remaining);
|
|
214
|
-
}
|
|
215
|
-
if (this.inFlight === 0 && this.liveWindows === 0) {
|
|
216
|
-
return { abandonedWindows: this.abandonedLabels() };
|
|
217
|
-
}
|
|
218
|
-
// Expiry. Synchronous, before any await: no request may enter after this.
|
|
219
|
-
this.admissionForcedShut = true;
|
|
220
|
-
for (const entry of this.windows.values())
|
|
221
|
-
entry.abandoned = true;
|
|
222
|
-
while (this.inFlight > 0) {
|
|
223
|
-
await this.changed();
|
|
224
|
-
}
|
|
225
|
-
return { abandonedWindows: this.abandonedLabels() };
|
|
226
|
-
}
|
|
227
|
-
get liveWindows() {
|
|
228
|
-
let live = 0;
|
|
229
|
-
for (const entry of this.windows.values())
|
|
230
|
-
if (!entry.abandoned)
|
|
231
|
-
live += 1;
|
|
232
|
-
return live;
|
|
233
|
-
}
|
|
234
|
-
abandonedLabels() {
|
|
235
|
-
const labels = new Set();
|
|
236
|
-
for (const entry of this.windows.values()) {
|
|
237
|
-
if (entry.abandoned)
|
|
238
|
-
labels.add(entry.label);
|
|
239
|
-
}
|
|
240
|
-
return [...labels];
|
|
241
|
-
}
|
|
242
172
|
// ---------------------------------------------------------- transitions ---
|
|
243
173
|
/**
|
|
244
174
|
* Runs a transition on the serializing tail.
|
|
@@ -248,16 +178,11 @@ class SessionLifecycle {
|
|
|
248
178
|
* when nothing is queued behind it, so a join can never overtake a queued
|
|
249
179
|
* transition of another kind. `recover` and `cleanup` never join and are
|
|
250
180
|
* never joined: a recovery carries its own request's baseline, and an
|
|
251
|
-
* internal cleanup owes its result to nobody
|
|
252
|
-
* report.
|
|
181
|
+
* internal cleanup owes its result to nobody.
|
|
253
182
|
*/
|
|
254
183
|
transition(kind, run) {
|
|
255
184
|
const joinable = kind === 'connect' || kind === 'disconnect';
|
|
256
185
|
if (joinable && this.tailKind === kind && this.tailPromise) {
|
|
257
|
-
// Joining shares the ANSWER, not just the execution: a joiner that got a
|
|
258
|
-
// resolved promise carrying nothing would have to invent a result, and
|
|
259
|
-
// for a teardown that means reporting no abandoned locks when there were
|
|
260
|
-
// some. Callers of one kind ask the same question, so they get one reply.
|
|
261
186
|
return this.tailPromise;
|
|
262
187
|
}
|
|
263
188
|
const queued = this.tail.then(run, run);
|
|
@@ -273,29 +198,5 @@ class SessionLifecycle {
|
|
|
273
198
|
queued.then(settle, settle);
|
|
274
199
|
return queued;
|
|
275
200
|
}
|
|
276
|
-
// ----------------------------------------------------------- internals ---
|
|
277
|
-
wake() {
|
|
278
|
-
const waiters = this.waiters;
|
|
279
|
-
this.waiters = [];
|
|
280
|
-
for (const resolve of waiters)
|
|
281
|
-
resolve();
|
|
282
|
-
}
|
|
283
|
-
changed(timeoutMs) {
|
|
284
|
-
return new Promise((resolve) => {
|
|
285
|
-
let done = false;
|
|
286
|
-
const finish = () => {
|
|
287
|
-
if (done)
|
|
288
|
-
return;
|
|
289
|
-
done = true;
|
|
290
|
-
resolve();
|
|
291
|
-
};
|
|
292
|
-
this.waiters.push(finish);
|
|
293
|
-
if (timeoutMs !== undefined) {
|
|
294
|
-
const timer = setTimeout(finish, timeoutMs);
|
|
295
|
-
if (typeof timer.unref === 'function')
|
|
296
|
-
timer.unref();
|
|
297
|
-
}
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
201
|
}
|
|
301
202
|
exports.SessionLifecycle = SessionLifecycle;
|
package/docs/MIGRATION-2.0.md
CHANGED
|
@@ -46,50 +46,39 @@ later, on the first request, as an unrelated-looking 401.
|
|
|
46
46
|
|
|
47
47
|
### 3. Requests are refused after a teardown
|
|
48
48
|
|
|
49
|
-
`disconnect()` and `reset()` stop the connection from serving requests until
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
`disconnect()` and `reset()` stop the connection from serving requests until the
|
|
50
|
+
next `connect()`. An in-flight request is not cut off and not waited for either:
|
|
51
|
+
it runs to completion, and its result is fenced so it cannot touch a session
|
|
52
|
+
established since.
|
|
52
53
|
|
|
53
|
-
### 4. If you hold locks,
|
|
54
|
+
### 4. If you hold locks, do not let a timeout cut the span
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
The connection does **not** track your locks. It does not know one exists, what
|
|
57
|
+
object it covers, or what would release it — pairing every LOCK with its UNLOCK
|
|
58
|
+
belongs to `@mcp-abap-adt/adt-clients`, which holds the handles.
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
let lockHandle: string | undefined;
|
|
62
|
-
try {
|
|
63
|
-
lockHandle = await lock(connection, 'ZCL_MY_CLASS');
|
|
64
|
-
} catch (error) {
|
|
65
|
-
connection.endWindow(token); // confirmed NOT locked: nothing is held
|
|
66
|
-
throw error;
|
|
67
|
-
}
|
|
60
|
+
What this layer can do is not let a short timeout abort a request mid-span, which
|
|
61
|
+
would leave an operation whose outcome you cannot determine — a `LOCK` that may
|
|
62
|
+
have succeeded server-side, with a handle you never received:
|
|
68
63
|
|
|
64
|
+
```typescript
|
|
65
|
+
connection.beginCriticalSection();
|
|
69
66
|
try {
|
|
70
|
-
await
|
|
71
|
-
await
|
|
72
|
-
connection
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// back with an unknown outcome. Do NOT use a finally here.
|
|
76
|
-
throw error;
|
|
67
|
+
const handle = await lock(connection, 'ZCL_MY_CLASS');
|
|
68
|
+
await update(connection, 'ZCL_MY_CLASS', handle);
|
|
69
|
+
await unlock(connection, 'ZCL_MY_CLASS', handle);
|
|
70
|
+
} finally {
|
|
71
|
+
connection.endCriticalSection();
|
|
77
72
|
}
|
|
78
73
|
```
|
|
79
74
|
|
|
80
|
-
|
|
81
|
-
a lock is open, so it will not wait for your unlock and will not report the lock
|
|
82
|
-
if it gives up — it simply tears down, and the object stays locked on the server.
|
|
83
|
-
|
|
84
|
-
But do not open a window and forget to close it on success: every later teardown
|
|
85
|
-
then waits out `SAP_TIMEOUT_CRITICAL` before reporting it as abandoned.
|
|
75
|
+
This is not new in 2.x — it has been available since 1.9.0 and is unchanged.
|
|
86
76
|
|
|
87
77
|
### 5. A new error you should handle
|
|
88
78
|
|
|
89
|
-
`ADT_SESSION_REPLACED` means the SAP session is gone. It is raised
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
window. **Your lock handle is dead**: unlocking with it will not work, and the
|
|
79
|
+
`ADT_SESSION_REPLACED` means the SAP session is gone. It is raised whenever the
|
|
80
|
+
session identity changes under us, and whenever the server itself says the session
|
|
81
|
+
no longer exists. **Your lock handle is dead**: unlocking with it will not work, and the
|
|
93
82
|
object may still be locked on the server. The connector does not retry such
|
|
94
83
|
a request — retrying blindly is what produced further orphaned locks in the
|
|
95
84
|
field.
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
> **The header is not what tracks your lock.** `setSessionType('stateful')` sets
|
|
4
4
|
> a per-request header and nothing more — another handler can flip it back while
|
|
5
|
-
> your lock is genuinely open, and a batch never sets it at all.
|
|
6
|
-
>
|
|
7
|
-
>
|
|
8
|
-
>
|
|
5
|
+
> your lock is genuinely open, and a batch never sets it at all. Nothing in this
|
|
6
|
+
> layer tracks locks: that belongs to `@mcp-abap-adt/adt-clients`, which holds the
|
|
7
|
+
> handles and pairs each LOCK with its UNLOCK per object. What the connection
|
|
8
|
+
> offers is `beginCriticalSection()` / `endCriticalSection()`, so a short timeout
|
|
9
|
+
> cannot abort a request mid-span. See
|
|
9
10
|
> [USAGE.md — Session Lifecycle](./USAGE.md#session-lifecycle).
|
|
10
11
|
|
|
11
12
|
|