@zhin.js/adapter 1.1.5 → 1.1.8

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,12 +1,15 @@
1
1
  import {
2
2
  DisposeStack,
3
+ GenerationCompensationError,
4
+ createGenerationAdmissionGate,
5
+ generationAdmissionSource,
3
6
  type CapabilityId,
4
7
  type CapabilitySlot,
8
+ type GenerationAdmissionGate,
5
9
  type PluginId,
6
10
  type RuntimeSnapshot,
7
11
  } from '@zhin.js/plugin-runtime';
8
12
  import { createCapabilityContext } from '@zhin.js/feature-kit';
9
- import { formatCompact, getLogger } from '@zhin.js/logger';
10
13
  import type {
11
14
  AdapterCapability,
12
15
  AdapterDefinition,
@@ -20,8 +23,6 @@ import {
20
23
  } from './endpoint-management.js';
21
24
  import { assertDeclaredEndpointOperations } from './endpoint-control.js';
22
25
 
23
- const logger = getLogger('Adapter');
24
-
25
26
  export interface AdapterDescriptor {
26
27
  readonly id: CapabilityId;
27
28
  readonly owner: PluginId;
@@ -39,89 +40,64 @@ export interface AdapterEndpointSummary extends AdapterDescriptor {
39
40
  }
40
41
 
41
42
  export type AdapterEndpointPhase =
42
- 'pending' | 'starting' | 'online' | 'failed' | 'unconfigured';
43
+ 'pending' | 'starting' | 'online';
43
44
 
44
45
  interface AdapterRecord extends AdapterDescriptor {
45
46
  readonly endpoint: EndpointInstance;
46
47
  readonly segments?: AdapterSegmentPolicy;
47
- readonly unconfigured: boolean;
48
48
  started: boolean;
49
49
  open: boolean;
50
50
  stopped: boolean;
51
- /** Start rejected or was given up on — distinguishes 'failed' from 'unconfigured'. */
52
- failed: boolean;
53
- /** start() was invoked at least once (may still be in flight). */
51
+ stopping?: Promise<void>;
54
52
  startAttempted: boolean;
55
53
  }
56
54
 
57
55
  export class AdapterIndex {
58
56
  readonly $projection = 'zhin.adapter-index/1' as const;
59
57
  readonly #records = new Map<CapabilityId, AdapterRecord>();
58
+ readonly [generationAdmissionSource]: readonly GenerationAdmissionGate[];
60
59
  readonly #order: readonly AdapterRecord[];
61
- /** True after `open()` until `close()` / `stop()` — late starts may open themselves. */
62
- #admissionOpen = false;
63
- readonly #startTimeoutMs: number;
64
- /** Final give-up budget for deferred starts (never-settling start promises). */
65
- readonly #deferredGiveUpMs: number;
66
60
 
67
61
  private constructor(
68
62
  records: readonly AdapterRecord[],
69
- startTimeoutMs: number,
70
- deferredGiveUpMs: number,
63
+ admission: GenerationAdmissionGate,
71
64
  ) {
72
65
  this.#order = Object.freeze([...records]);
73
- this.#startTimeoutMs = startTimeoutMs;
74
- this.#deferredGiveUpMs = deferredGiveUpMs;
66
+ this[generationAdmissionSource] = Object.freeze([admission]);
75
67
  for (const record of records) this.#records.set(record.id, record);
76
68
  }
77
69
 
78
70
  static async create(
79
71
  slots: readonly Readonly<CapabilitySlot<AdapterDefinition>>[],
80
72
  snapshot: RuntimeSnapshot,
81
- options: {
82
- readonly startTimeoutMs?: number;
83
- readonly deferredGiveUpMs?: number;
84
- } = {},
73
+ signal: AbortSignal,
85
74
  ): Promise<AdapterIndex> {
86
75
  const records: AdapterRecord[] = [];
87
- const unconfigured: string[] = [];
76
+ const admission = createGenerationAdmissionGate();
88
77
  try {
89
78
  for (const slot of [...slots].sort((left, right) => left.id.localeCompare(right.id))) {
79
+ signal.throwIfAborted();
90
80
  for (const expansion of expandEndpointConfigs(slot, snapshot)) {
91
- const endpoint = await createEndpointSoft(slot, snapshot, expansion);
92
- if (endpoint.unconfigured) unconfigured.push(expansion.name);
81
+ const endpoint = await createEndpoint(slot, snapshot, admission, signal, expansion);
82
+ signal.throwIfAborted();
93
83
  records.push({
94
84
  id: expansion.id,
95
85
  owner: slot.owner,
96
- // 展开模式下 record name 即 endpoint 名(entry.name),
97
- // 保证 Console 展示与 resolve/instance 按 entry name 命中唯一 record
98
- name: expansion.name,
86
+ // 展开模式下 record name 即 endpoint id(entry.id),
87
+ // 保证 Console 展示与 resolve/instance 按 entry id 命中唯一 record
88
+ name: expansion.endpointId,
99
89
  source: slot.source,
100
90
  capabilities: slot.definition.capabilities,
101
- endpoint: endpoint.instance,
91
+ endpoint,
102
92
  ...(slot.definition.segments ? { segments: slot.definition.segments } : {}),
103
- unconfigured: endpoint.unconfigured,
104
93
  started: false,
105
94
  open: false,
106
- failed: false,
107
95
  startAttempted: false,
108
- // Unconfigured stubs skip start/open so kitchen-sink Roots stay quiet.
109
- stopped: endpoint.unconfigured,
96
+ stopped: false,
110
97
  });
111
98
  }
112
99
  }
113
- if (unconfigured.length > 0) {
114
- logger.info(formatCompact({
115
- op: 'adapters_unconfigured',
116
- count: unconfigured.length,
117
- names: unconfigured.join(','),
118
- }));
119
- }
120
- return new AdapterIndex(
121
- records,
122
- options.startTimeoutMs ?? 3_000,
123
- options.deferredGiveUpMs ?? 60_000,
124
- );
100
+ return new AdapterIndex(records, admission);
125
101
  } catch (error) {
126
102
  await stopRecords(records, error);
127
103
  throw error;
@@ -129,8 +105,8 @@ export class AdapterIndex {
129
105
  }
130
106
 
131
107
  list(): readonly AdapterDescriptor[] {
132
- return this.#order.map(({ endpoint: _endpoint, unconfigured: _unconfigured,
133
- started: _started, open: _open, stopped: _stopped, failed: _failed,
108
+ return this.#order.map(({ endpoint: _endpoint,
109
+ started: _started, open: _open, stopped: _stopped, stopping: _stopping,
134
110
  startAttempted: _startAttempted, segments: _segments,
135
111
  ...descriptor }) => Object.freeze(descriptor));
136
112
  }
@@ -155,21 +131,21 @@ export class AdapterIndex {
155
131
  * Resolve a Console `$adapter` + `$endpoint` pair to a capability id.
156
132
  * Matches local name, capability id, or owner path segments.
157
133
  */
158
- resolve(adapter: string, endpointId: string): CapabilityId | undefined {
134
+ resolve(adapter: string, endpointKey: string): CapabilityId | undefined {
159
135
  const matches = this.#order.filter((record) =>
160
- matchesEndpoint(record, adapter, endpointId));
136
+ matchesEndpoint(record, adapter, endpointKey));
161
137
  if (matches.length === 1) return matches[0]?.id;
162
138
  if (matches.length === 0) return undefined;
163
- // Prefer exact localName === endpointId when ambiguous.
164
- const exact = matches.find((record) => record.name === endpointId);
139
+ // Prefer exact localName === endpointKey when ambiguous.
140
+ const exact = matches.find((record) => record.name === endpointKey);
165
141
  return exact?.id ?? matches[0]?.id;
166
142
  }
167
143
 
168
144
  /**
169
145
  * Resolve a live EndpointInstance for Host-side side channels (reactions, etc.).
170
146
  */
171
- instance(adapter: string, endpointId: string): EndpointInstance | undefined {
172
- const id = this.resolve(adapter, endpointId);
147
+ instance(adapter: string, endpointKey: string): EndpointInstance | undefined {
148
+ const id = this.resolve(adapter, endpointKey);
173
149
  if (!id) return undefined;
174
150
  return this.#records.get(id)?.endpoint;
175
151
  }
@@ -188,103 +164,59 @@ export class AdapterIndex {
188
164
  return this.#records.get(id)?.segments;
189
165
  }
190
166
 
191
- async start(): Promise<void> {
192
- // Soft-start in parallel with a short wait so kitchen-sink Roots do not
193
- // stall generation. Configured platforms that need longer (QQ auth, Slack
194
- // socket, GitHub verify) stay in-flight instead of being stop()'d mid-connect.
195
- const startTimeoutMs = this.#startTimeoutMs;
196
- await Promise.all(this.#order.map(async (record) => {
197
- if (record.started || record.stopped) return;
198
- record.startAttempted = true;
199
- const startPromise = (async () => record.endpoint.start?.())();
200
- try {
201
- await withTimeout(
202
- startPromise,
203
- startTimeoutMs,
204
- `Adapter start timed out after ${startTimeoutMs}ms`,
205
- );
206
- if (record.stopped) return;
207
- record.started = true;
208
- } catch (error) {
209
- const message = error instanceof Error ? error.message : String(error);
210
- if (message.includes('timed out after')) {
211
- logger.info(formatCompact({
212
- op: 'adapter_start_deferred',
213
- id: record.id,
214
- name: record.name,
215
- waitMs: startTimeoutMs,
216
- }));
217
- // Final backstop: a deferred start promise that never settles must
218
- // not keep the Endpoint in limbo forever.
219
- const giveUp = setTimeout(() => {
220
- if (record.stopped || record.started) return;
221
- record.stopped = true;
222
- record.failed = true;
223
- // Swallow a late rejection so it does not become unhandled.
224
- void startPromise.catch(() => undefined);
225
- logger.warn(formatCompact({
226
- op: 'adapter_start_give_up',
227
- id: record.id,
228
- name: record.name,
229
- waitMs: this.#deferredGiveUpMs,
230
- }));
231
- }, this.#deferredGiveUpMs);
232
- giveUp.unref?.();
233
- void startPromise.then(
234
- () => {
235
- clearTimeout(giveUp);
236
- if (record.stopped || record.started) return;
237
- record.started = true;
238
- if (this.#admissionOpen && !record.open) {
239
- try {
240
- record.endpoint.open?.();
241
- record.open = true;
242
- } catch (openError) {
243
- logger.warn(formatCompact({
244
- op: 'adapter_open_after_deferred_fail',
245
- id: record.id,
246
- name: record.name,
247
- error: openError instanceof Error ? openError.message : String(openError),
248
- }));
249
- }
250
- }
251
- },
252
- (startError) => {
253
- clearTimeout(giveUp);
254
- if (record.stopped) return;
255
- record.stopped = true;
256
- record.failed = true;
257
- logger.warn(formatCompact({
258
- op: 'adapter_start_soft_fail',
259
- id: record.id,
260
- name: record.name,
261
- error: startError instanceof Error ? startError.message : String(startError),
262
- stack: startError instanceof Error ? startError.stack : undefined,
263
- }));
264
- },
265
- );
266
- return;
167
+ async start(signal: AbortSignal = new AbortController().signal): Promise<void> {
168
+ try {
169
+ // Sequential readiness gives the candidate one owned in-flight start at a
170
+ // time. A sibling failure can therefore never leave an un-awaited start
171
+ // promise mutating resources after rollback has returned.
172
+ for (const record of this.#order) {
173
+ if (record.started || record.stopped) continue;
174
+ record.startAttempted = true;
175
+ signal.throwIfAborted();
176
+ let stopOnAbort!: () => void;
177
+ const aborted = new Promise<never>((_resolve, reject) => {
178
+ stopOnAbort = () => {
179
+ void stopRecord(record).then(
180
+ () => reject(signal.reason ?? new Error('Adapter Endpoint start aborted')),
181
+ (cleanupError) => reject(new GenerationCompensationError(
182
+ [signal.reason, cleanupError],
183
+ 'Adapter Endpoint cancellation cleanup failed',
184
+ { cause: cleanupError },
185
+ )),
186
+ );
187
+ };
188
+ });
189
+ signal.addEventListener('abort', stopOnAbort, { once: true });
190
+ try {
191
+ await Promise.race([
192
+ Promise.resolve(record.endpoint.start?.(signal)),
193
+ aborted,
194
+ ]);
195
+ } finally {
196
+ signal.removeEventListener('abort', stopOnAbort);
267
197
  }
268
- record.stopped = true;
269
- record.failed = true;
270
- void startPromise.catch(() => undefined);
271
- // Startup connect failures are logged once here (with stack); Endpoint
272
- // implementations must NOT re-log them at error level.
273
- logger.warn(formatCompact({
274
- op: 'adapter_start_soft_fail',
275
- id: record.id,
276
- name: record.name,
277
- error: message,
278
- stack: error instanceof Error ? error.stack : undefined,
279
- }));
280
- // No endpoint.stop() here: adapter Endpoints self-stop in their start()
281
- // catch by convention (verified across icqq/qq/slack/… endpoints).
198
+ signal.throwIfAborted();
199
+ if (record.stopped) throw new Error(`Adapter Endpoint stopped during start: ${record.id}`);
200
+ record.started = true;
282
201
  }
283
- }));
202
+ } catch (error) {
203
+ await stopRecords(this.#order, error);
204
+ throw error;
205
+ }
206
+ }
207
+
208
+ /** Required readiness boundary: no generation can publish a partial Endpoint set. */
209
+ async activate(signal: AbortSignal): Promise<void> {
210
+ try {
211
+ await this.start(signal);
212
+ this.open();
213
+ } catch (error) {
214
+ await stopRecords(this.#order, error);
215
+ throw error;
216
+ }
284
217
  }
285
218
 
286
219
  open(): void {
287
- this.#admissionOpen = true;
288
220
  const errors: unknown[] = [];
289
221
  for (const record of this.#order) {
290
222
  if (!record.started || record.open || record.stopped) continue;
@@ -299,7 +231,6 @@ export class AdapterIndex {
299
231
  }
300
232
 
301
233
  async close(): Promise<void> {
302
- this.#admissionOpen = false;
303
234
  const stack = new DisposeStack();
304
235
  for (const record of this.#order) {
305
236
  if (!record.open || record.stopped) continue;
@@ -341,7 +272,7 @@ export function isAdapterIndex(value: unknown): value is AdapterIndex {
341
272
  function matchesEndpoint(
342
273
  record: AdapterRecord,
343
274
  adapter: string,
344
- endpointId: string,
275
+ endpointKey: string,
345
276
  ): boolean {
346
277
  // 消息上的 $adapter 是 CapabilityId 的 localName 段(多 endpoint 展开后形如
347
278
  // `icqq~8596238`)。CapabilityId 段分隔符是 \0(owner\0feature\0localName),
@@ -357,10 +288,10 @@ function matchesEndpoint(
357
288
  // activity-feedback resolve with that id; slot.localName alone is not enough
358
289
  // when multiple plugin instances share localName "icqq".
359
290
  const liveName = endpointLiveName(record.endpoint);
360
- const endpointOk = record.name === endpointId
361
- || record.id === endpointId
362
- || record.id.endsWith(`/${endpointId}`)
363
- || (liveName !== undefined && liveName === endpointId);
291
+ const endpointOk = record.name === endpointKey
292
+ || record.id === endpointKey
293
+ || record.id.endsWith(`/${endpointKey}`)
294
+ || (liveName !== undefined && liveName === endpointKey);
364
295
  return adapterOk && endpointOk;
365
296
  }
366
297
 
@@ -370,10 +301,8 @@ function endpointLiveName(endpoint: EndpointInstance): string | undefined {
370
301
  }
371
302
 
372
303
  function endpointPhase(record: AdapterRecord): AdapterEndpointPhase {
373
- if (record.unconfigured) return 'unconfigured';
374
- if (record.failed) return 'failed';
375
304
  if (record.open && !record.stopped) return 'online';
376
- if (record.startAttempted) return 'starting';
305
+ if (record.startAttempted && !record.started) return 'starting';
377
306
  return 'pending';
378
307
  }
379
308
 
@@ -383,26 +312,15 @@ function assertEndpoint(value: unknown, id: CapabilityId): asserts value is Endp
383
312
  }
384
313
  }
385
314
 
386
- /**
387
- * Adapter `resolveXxxConfig` helpers report missing config/credentials as
388
- * TypeError("… requires …") by convention; only those are expected failures.
389
- */
390
- function isUnconfiguredError(error: unknown): boolean {
391
- return (
392
- error instanceof TypeError
393
- && /requires|not configured|missing|未配置|缺少/i.test(error.message)
394
- );
395
- }
396
-
397
315
  /** 单个实例配置展开的 endpoint 描述(多账号适配器经 `endpoints` 数组声明)。 */
398
316
  interface EndpointExpansion {
399
317
  readonly id: CapabilityId;
400
- readonly name: string;
318
+ readonly endpointId: string;
401
319
  readonly config?: Readonly<Record<string, unknown>>;
402
320
  }
403
321
 
404
322
  /**
405
- * 实例配置的 endpoint 展开:插件实例 config 含非空 `endpoints: [{name, ...覆盖}]` 时
323
+ * 实例配置的 endpoint 展开:插件实例 config 含非空 `endpoints: [{id, ...覆盖}]` 时
406
324
  * 按数组一一创建 endpoint(基础配置为实例 config 去掉 `endpoints` 键,逐项合并),
407
325
  * 否则按实例 config 创建单个 endpoint(历史行为)。
408
326
  */
@@ -414,137 +332,64 @@ function expandEndpointConfigs(
414
332
  | { endpoints?: unknown }
415
333
  | undefined;
416
334
  const raw = config?.endpoints;
417
- const entries = Array.isArray(raw)
418
- ? raw.filter((entry): entry is Record<string, unknown> & { name: string } =>
419
- !!entry && typeof entry === 'object'
420
- && typeof (entry as { name?: unknown }).name === 'string'
421
- && (entry as { name: string }).name.length > 0)
422
- : [];
335
+ if (raw !== undefined && !Array.isArray(raw)) {
336
+ throw new TypeError(`Adapter ${slot.id} endpoints must be an array`);
337
+ }
338
+ const entries = (raw ?? []) as readonly unknown[];
423
339
  if (entries.length === 0) {
424
- if (Array.isArray(raw) && raw.length > 0) {
425
- logger.warn(formatCompact({
426
- op: 'adapter_endpoints_entries_dropped',
427
- id: slot.id,
428
- reason: 'every endpoints entry is missing a non-empty string name',
429
- }));
430
- }
431
- return Object.freeze([{ id: slot.id, name: slot.localName }]);
340
+ return Object.freeze([{ id: slot.id, endpointId: slot.localName }]);
432
341
  }
433
- // `~` record id 的分隔符、\0 是 CapabilityId 的分隔符,混入会破坏解析
434
- const valid = entries.filter((entry) => {
435
- if (/[~\0]/u.test(entry.name)) {
436
- logger.warn(formatCompact({
437
- op: 'adapter_endpoint_name_invalid',
438
- id: slot.id,
439
- name: entry.name,
440
- }));
441
- return false;
342
+ const normalized = entries.map((entry, index) => {
343
+ if (!entry || typeof entry !== 'object'
344
+ || typeof (entry as { id?: unknown }).id !== 'string'
345
+ || (entry as { id: string }).id.length === 0) {
346
+ throw new TypeError(`Adapter ${slot.id} endpoints[${index}].id must be a non-empty string`);
442
347
  }
443
- return true;
348
+ return entry as Record<string, unknown> & { id: string };
444
349
  });
445
- // 重名会让 #records 覆盖与 #order/resolve 三者不一致;保留首个并告警
350
+ // `~` record id 的分隔符、\0 是 CapabilityId 的分隔符,混入会破坏解析。
351
+ for (const entry of normalized) {
352
+ if (/[~\0]/u.test(entry.id)) {
353
+ throw new TypeError(`Adapter ${slot.id} endpoint id contains a reserved delimiter: ${entry.id}`);
354
+ }
355
+ }
446
356
  const seen = new Set<string>();
447
- const deduped = valid.filter((entry) => {
448
- if (seen.has(entry.name)) {
449
- logger.warn(formatCompact({
450
- op: 'adapter_endpoint_name_duplicate',
451
- id: slot.id,
452
- name: entry.name,
453
- }));
454
- return false;
357
+ for (const entry of normalized) {
358
+ if (seen.has(entry.id)) {
359
+ throw new TypeError(`Adapter ${slot.id} endpoint id is duplicated: ${entry.id}`);
455
360
  }
456
- seen.add(entry.name);
457
- return true;
458
- });
459
- if (deduped.length === 0) {
460
- return Object.freeze([{ id: slot.id, name: slot.localName }]);
361
+ seen.add(entry.id);
461
362
  }
462
363
  const { endpoints: _drop, ...base } = (config ?? {}) as Record<string, unknown>;
463
- return Object.freeze(deduped.map((entry) => Object.freeze({
464
- id: `${slot.id}~${entry.name}` as CapabilityId,
465
- name: entry.name,
466
- config: Object.freeze({ ...base, ...entry, name: entry.name }),
364
+ return Object.freeze(normalized.map((entry) => Object.freeze({
365
+ id: `${slot.id}~${entry.id}` as CapabilityId,
366
+ endpointId: entry.id,
367
+ config: Object.freeze({ ...base, ...entry, id: entry.id }),
467
368
  })));
468
369
  }
469
370
 
470
- async function createEndpointSoft(
371
+ async function createEndpoint(
471
372
  slot: Readonly<CapabilitySlot<AdapterDefinition>>,
472
373
  snapshot: RuntimeSnapshot,
374
+ admission: GenerationAdmissionGate,
375
+ signal: AbortSignal,
473
376
  expansion?: EndpointExpansion,
474
- ): Promise<{ readonly instance: EndpointInstance; readonly unconfigured: boolean }> {
475
- let endpoint: unknown;
476
- try {
477
- endpoint = await slot.definition.create(
478
- Object.freeze({
479
- ...createCapabilityContext(snapshot, slot.owner),
480
- ...(expansion?.config ? { config: expansion.config } : {}),
481
- id: expansion?.id ?? slot.id,
482
- name: slot.localName,
483
- }),
484
- );
485
- } catch (error) {
486
- // Missing config / credentials: degrade to an inert stub so the rest of
487
- // the generation still boots. Anything else (network failures, bugs in
488
- // create()) is unexpected — keep the stub but surface a warning instead
489
- // of silently swallowing it at debug level.
490
- const message = error instanceof Error ? error.message : String(error);
491
- const log = isUnconfiguredError(error) ? logger.debug.bind(logger) : logger.warn.bind(logger);
492
- log(formatCompact({
493
- op: 'adapter_create_soft_fail',
377
+ ): Promise<EndpointInstance> {
378
+ const endpoint = await slot.definition.create(
379
+ Object.freeze({
380
+ ...createCapabilityContext(snapshot, slot.owner, admission, signal),
381
+ ...(expansion?.config ? { config: expansion.config } : {}),
494
382
  id: expansion?.id ?? slot.id,
495
- name: expansion?.name ?? slot.localName,
496
- error: message,
497
- }));
498
- return {
499
- instance: createUnconfiguredEndpoint(message),
500
- unconfigured: true,
501
- };
502
- }
503
- // Programming errors (create() did not return an Endpoint) must surface:
504
- // they propagate to AdapterIndex.create's catch, which disposes the records
505
- // created so far instead of hiding the bug behind an unconfigured stub.
383
+ name: slot.localName,
384
+ }),
385
+ );
506
386
  assertEndpoint(endpoint, expansion?.id ?? slot.id);
507
387
  assertDeclaredEndpointOperations(
508
388
  endpoint,
509
389
  slot.definition.operations,
510
390
  String(expansion?.id ?? slot.id),
511
391
  );
512
- return { instance: endpoint, unconfigured: false };
513
- }
514
-
515
- function createUnconfiguredEndpoint(reason: string): EndpointInstance {
516
- return Object.freeze({
517
- start() {
518
- throw new Error(`Adapter unconfigured: ${reason}`);
519
- },
520
- open() {},
521
- close() {},
522
- stop() {},
523
- send() {
524
- throw new Error(`Adapter unconfigured: ${reason}`);
525
- },
526
- });
527
- }
528
-
529
- function withTimeout<T>(
530
- promise: Promise<T> | T | undefined,
531
- ms: number,
532
- message: string,
533
- ): Promise<T | undefined> {
534
- if (promise === undefined) return Promise.resolve(undefined);
535
- return new Promise<T | undefined>((resolve, reject) => {
536
- const timer = setTimeout(() => reject(new Error(message)), ms);
537
- Promise.resolve(promise).then(
538
- (value) => {
539
- clearTimeout(timer);
540
- resolve(value);
541
- },
542
- (error) => {
543
- clearTimeout(timer);
544
- reject(error);
545
- },
546
- );
547
- });
392
+ return endpoint;
548
393
  }
549
394
 
550
395
  async function stopRecords(
@@ -554,17 +399,13 @@ async function stopRecords(
554
399
  const stack = new DisposeStack();
555
400
  for (const record of records) {
556
401
  if (record.stopped) continue;
557
- stack.add(async () => {
558
- record.stopped = true;
559
- record.open = false;
560
- await record.endpoint.stop?.();
561
- });
402
+ stack.add(() => stopRecord(record));
562
403
  }
563
404
  try {
564
405
  await stack.dispose();
565
406
  } catch (stopError) {
566
407
  if (primaryError !== undefined) {
567
- throw new AggregateError(
408
+ throw new GenerationCompensationError(
568
409
  [primaryError, stopError],
569
410
  'Adapter prepare and Endpoint cleanup both failed',
570
411
  { cause: stopError },
@@ -573,3 +414,17 @@ async function stopRecords(
573
414
  throw stopError;
574
415
  }
575
416
  }
417
+
418
+ function stopRecord(record: AdapterRecord): Promise<void> {
419
+ if (record.stopped) return Promise.resolve();
420
+ if (record.stopping) return record.stopping;
421
+ const stopping = Promise.resolve(record.endpoint.stop?.()).then(() => {
422
+ record.stopped = true;
423
+ record.open = false;
424
+ });
425
+ record.stopping = stopping;
426
+ void stopping.catch(() => undefined).finally(() => {
427
+ if (!record.stopped && record.stopping === stopping) record.stopping = undefined;
428
+ });
429
+ return stopping;
430
+ }
package/src/definition.ts CHANGED
@@ -32,9 +32,9 @@ export interface EndpointInstance<TResult = unknown> {
32
32
  readonly management?: EndpointManagement;
33
33
  /** Optional platform-neutral control surface for existing messages. */
34
34
  readonly control?: EndpointControl;
35
- /** Allocates transport resources but must not admit inbound events yet. */
36
- start?(): void | Promise<void>;
37
- /** Opens admission after the candidate generation has committed. */
35
+ /** Required readiness; must observe abort and settle before rollback returns. */
36
+ start?(signal: AbortSignal): void | Promise<void>;
37
+ /** Opens Endpoint-local flow behind the candidate generation admission gate. */
38
38
  open?(): void;
39
39
  /** Stops new inbound events while preserving in-flight work. */
40
40
  close?(): void | Promise<void>;