fix(session): contain post-commit observers
This commit is contained in:
@@ -64,7 +64,12 @@ declare module 'cordis' {
|
||||
'session/disposed'(this: Scoped<Session>, session: Session): void
|
||||
/**
|
||||
* An event was appended to a session log (sync, fire-and-forget). This is
|
||||
* the per-append feed a UI or invariant plugin tails.
|
||||
* the per-append feed a UI or invariant plugin tails. The log push is the
|
||||
* commit point; synchronous throws and returned-promise rejections from
|
||||
* observers are logged and contained per listener, so they cannot make a
|
||||
* committed append appear to fail or starve later listeners. The exact
|
||||
* callback list and Cordis internal-dispatch checks resolve before the push;
|
||||
* callbacks themselves run only after it.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
|
||||
* session's owner scope, captured when the session was ENTERED (an agent's
|
||||
* session is entered through `agent.ctx`, so its events dispatch in that
|
||||
@@ -279,7 +284,62 @@ function renderThrown(value: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
const appendObservers = new WeakMap<Session, (event: SessionEvent) => void>()
|
||||
/** Best-effort reporting that cannot re-expose an already-contained failure. */
|
||||
function warnContained(ctx: Context, message: string): void {
|
||||
try {
|
||||
ctx.logger.warn(message)
|
||||
} catch {
|
||||
// contained: logger failure must not turn an observe-only callback failure
|
||||
// back into a caller-visible error or an unhandled promise rejection.
|
||||
}
|
||||
}
|
||||
|
||||
type SessionCallback = (...args: unknown[]) => unknown
|
||||
|
||||
/** Resolve one listener snapshot, including Cordis's internal dispatch checks. */
|
||||
function collectSessionCallbacks(ctx: Context, args: unknown[]): SessionCallback[] {
|
||||
return [...ctx.events.dispatch('emit', args)] as SessionCallback[]
|
||||
}
|
||||
|
||||
/** Reject pre-commit dispatch instrumentation that substituted accepted values. */
|
||||
function assertDispatchTuple(name: string, actual: unknown[], expected: unknown[]): void {
|
||||
if (actual.length !== expected.length || actual.some((value, index) => value !== expected[index])) {
|
||||
throw new Error(`${name} internal dispatch replaced the accepted callback tuple`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Invoke one resolved observe-only listener snapshot with per-listener containment. */
|
||||
function invokeContainedSessionObservers(
|
||||
ctx: Context,
|
||||
name: 'session/event' | 'session/disposed',
|
||||
id: SessionId,
|
||||
args: unknown[],
|
||||
callbacks: SessionCallback[],
|
||||
): void {
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
warnContained(ctx, `session "${id}": ${name} listener rejected: ${renderThrown(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
warnContained(ctx, `session "${id}": ${name} listener threw: ${renderThrown(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SessionAppendHooks {
|
||||
/** Keep the store attachment live through acceptance and publication. */
|
||||
begin(): void
|
||||
/** Resolve the exact observer list before commit; returns its contained publisher. */
|
||||
prepareObservation(event: SessionEvent): () => void
|
||||
/** Release the attachment barrier and honor a deferred detach. */
|
||||
end(): void
|
||||
}
|
||||
|
||||
const appendHooks = new WeakMap<Session, SessionAppendHooks>()
|
||||
/** Identity token replaced on every store attachment or detachment. */
|
||||
const attachmentEpochs = new WeakMap<Session, object>()
|
||||
|
||||
/**
|
||||
* An event-sourced session: an append-only log of {@link SessionEvent}s.
|
||||
@@ -289,6 +349,8 @@ const appendObservers = new WeakMap<Session, (event: SessionEvent) => void>()
|
||||
*/
|
||||
export class Session {
|
||||
private log: SessionEvent[] = []
|
||||
/** True throughout one event's materialization, validation, commit, and publication. */
|
||||
private appendInProgress = false
|
||||
|
||||
/**
|
||||
* Derived surface — a cached linked list of message-producing events.
|
||||
@@ -393,8 +455,11 @@ export class Session {
|
||||
|
||||
/**
|
||||
* Append one typed event to the log and synchronously notify observers via
|
||||
* the store-owned, module-private append observer. The hot path never blocks
|
||||
* on I/O — persistence plugins buffer asynchronously.
|
||||
* the store-owned, module-private publication hooks. The hot path never blocks
|
||||
* on I/O — persistence plugins buffer asynchronously. Once the event enters
|
||||
* the log, the append is committed: observer failures are logged and
|
||||
* contained per listener, so they do not change the return value or prevent
|
||||
* later listeners from observing the same accepted event.
|
||||
*
|
||||
* @param type - The event type (key of {@link SessionEventMap}).
|
||||
* @param data - The event payload; must be JSON-serializable.
|
||||
@@ -416,7 +481,9 @@ export class Session {
|
||||
* copies each nested value once, so a stateful getter cannot supply one value
|
||||
* to validation and another to storage. The event log is the durable source
|
||||
* of truth, so a bad event fails at the append site rather than later during
|
||||
* a backend flush.
|
||||
* a backend flush. A synchronous internal dispatch validation failure or an
|
||||
* append reentered while this acceptance/publication boundary is open also
|
||||
* rejects before the log changes.
|
||||
*/
|
||||
append<T extends SessionEventType>(
|
||||
type: T,
|
||||
@@ -426,60 +493,87 @@ export class Session {
|
||||
if (typeof type !== 'string') {
|
||||
throw new TypeError('session event type must be a string')
|
||||
}
|
||||
const surfaceOpts: SurfaceIntent | undefined = opts[0]
|
||||
const sourceEventSeqs = surfaceOpts?.sourceEventSeqs
|
||||
const surfaceOp = surfaceOpts?.surfaceOp
|
||||
// Surface-eligible events MUST carry a surfaceOp marker — the surface is the
|
||||
// sole source of derived history, so a marker-less message event would be
|
||||
// logged yet vanish from deriveMessages(). The typed `opts` overload makes
|
||||
// the marker mandatory only when `T` is a SPECIFIC SurfaceEventType literal;
|
||||
// when `T` widens to the SessionEventType union (a caller iterating raw
|
||||
// events: `for (const e of log) append(e.type, e.data)`), the conditional
|
||||
// rest collapses to optional and the compiler stops enforcing it. Re-check
|
||||
// at runtime so that loophole can't silently drop history.
|
||||
const surfaceMetadata = {
|
||||
...sourceEventSeqs !== undefined ? { sourceEventSeqs } : {},
|
||||
...surfaceOp !== undefined ? { surfaceOp } : {},
|
||||
if (this.appendInProgress) {
|
||||
throw new Error('session append cannot reenter while another append is being accepted or published')
|
||||
}
|
||||
// The caller still owns the data and metadata objects and could mutate them
|
||||
// after append. Materialize each accepted value exactly once while checking
|
||||
// its JSON vocabulary, so the log cannot drift and a stateful getter cannot
|
||||
// show one value to validation and another to a prototype-erasing clone. The
|
||||
// returned event carries these SAME snapshots.
|
||||
//
|
||||
// Surface metadata accessors are read once into one plain record; the
|
||||
// recursive snapshot then reads each nested value once as it copies it.
|
||||
// Build the event shape with conditional surface fields via spreading.
|
||||
// The result is cast through `unknown` because the conditional spreads
|
||||
// produce an intersection type that the assignability checker can't
|
||||
// narrow to a specific discriminated-union member when T is generic.
|
||||
// This is a safe internal boundary: data and surface metadata are
|
||||
// materialized below before the event enters the log.
|
||||
const dataSnapshot = snapshotJsonValue(data)
|
||||
if (dataSnapshot === undefined) {
|
||||
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
|
||||
const hooks = appendHooks.get(this)
|
||||
const attachmentEpoch = attachmentEpochs.get(this)
|
||||
this.appendInProgress = true
|
||||
try {
|
||||
// Start before reading caller-owned fields: a getter may request detach
|
||||
// or try to append reentrantly. The attachment and sequence boundary stay
|
||||
// stable until this exact acceptance attempt has either failed or reached
|
||||
// every post-commit observer.
|
||||
hooks?.begin()
|
||||
const surfaceOpts: SurfaceIntent | undefined = opts[0]
|
||||
const sourceEventSeqs = surfaceOpts?.sourceEventSeqs
|
||||
const surfaceOp = surfaceOpts?.surfaceOp
|
||||
// Surface-eligible events MUST carry a surfaceOp marker — the surface is the
|
||||
// sole source of derived history, so a marker-less message event would be
|
||||
// logged yet vanish from deriveMessages(). The typed `opts` overload makes
|
||||
// the marker mandatory only when `T` is a SPECIFIC SurfaceEventType literal;
|
||||
// when `T` widens to the SessionEventType union (a caller iterating raw
|
||||
// events: `for (const e of log) append(e.type, e.data)`), the conditional
|
||||
// rest collapses to optional and the compiler stops enforcing it. Re-check
|
||||
// at runtime so that loophole can't silently drop history.
|
||||
const surfaceMetadata = {
|
||||
...sourceEventSeqs !== undefined ? { sourceEventSeqs } : {},
|
||||
...surfaceOp !== undefined ? { surfaceOp } : {},
|
||||
}
|
||||
// The caller still owns the data and metadata objects and could mutate them
|
||||
// after append. Materialize each accepted value exactly once while checking
|
||||
// its JSON vocabulary, so the log cannot drift and a stateful getter cannot
|
||||
// show one value to validation and another to a prototype-erasing clone. The
|
||||
// returned event carries these SAME snapshots.
|
||||
//
|
||||
// Surface metadata accessors are read once into one plain record; the
|
||||
// recursive snapshot then reads each nested value once as it copies it.
|
||||
// Build the event shape with conditional surface fields via spreading.
|
||||
// The result is cast through `unknown` because the conditional spreads
|
||||
// produce an intersection type that the assignability checker can't
|
||||
// narrow to a specific discriminated-union member when T is generic.
|
||||
// This is a safe internal boundary: data and surface metadata are
|
||||
// materialized below before the event enters the log.
|
||||
const dataSnapshot = snapshotJsonValue(data)
|
||||
if (dataSnapshot === undefined) {
|
||||
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
|
||||
}
|
||||
const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)
|
||||
if (surfaceMetadataSnapshot === undefined) {
|
||||
throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
|
||||
}
|
||||
assertSurfaceMetadataShape(
|
||||
type,
|
||||
(surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp,
|
||||
(surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs,
|
||||
)
|
||||
if (appendHooks.get(this) !== hooks || attachmentEpochs.get(this) !== attachmentEpoch) {
|
||||
throw new Error('session attachment changed while append input was being accepted')
|
||||
}
|
||||
const event = {
|
||||
type,
|
||||
seq: this.log.length,
|
||||
time: Date.now(),
|
||||
data: dataSnapshot,
|
||||
...surfaceMetadataSnapshot,
|
||||
} as unknown as SessionEvent<T>
|
||||
const acceptedEvent = deepFreeze(event)
|
||||
// Resolve dispatch before the log push. Cordis runs internal/dispatch
|
||||
// while producing this list; if instrumentation rejects the carrier, the
|
||||
// append still fails before commit. The resolved callbacks themselves are
|
||||
// observe-only and run with per-listener containment after the push.
|
||||
const publish = hooks?.prepareObservation(acceptedEvent as unknown as SessionEvent)
|
||||
this.log.push(acceptedEvent as unknown as SessionEvent)
|
||||
this.eventsSnapshot = undefined
|
||||
publish?.()
|
||||
return acceptedEvent
|
||||
} finally {
|
||||
try {
|
||||
hooks?.end()
|
||||
} finally {
|
||||
this.appendInProgress = false
|
||||
}
|
||||
}
|
||||
const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)
|
||||
if (surfaceMetadataSnapshot === undefined) {
|
||||
throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
|
||||
}
|
||||
assertSurfaceMetadataShape(
|
||||
type,
|
||||
(surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp,
|
||||
(surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs,
|
||||
)
|
||||
const event = {
|
||||
type,
|
||||
seq: this.log.length,
|
||||
time: Date.now(),
|
||||
data: dataSnapshot,
|
||||
...surfaceMetadataSnapshot,
|
||||
} as unknown as SessionEvent<T>
|
||||
const acceptedEvent = deepFreeze(event)
|
||||
this.log.push(acceptedEvent as unknown as SessionEvent)
|
||||
this.eventsSnapshot = undefined
|
||||
appendObservers.get(this)?.(acceptedEvent as unknown as SessionEvent)
|
||||
return acceptedEvent
|
||||
}
|
||||
|
||||
/** Cached fold of the request-header events — see {@link requestHeader}. */
|
||||
@@ -674,7 +768,9 @@ export class SessionStore extends Service {
|
||||
private announced = new WeakSet<Session>()
|
||||
/** Entries currently dispatching `session/created`; detach waits for dispatch to unwind. */
|
||||
private announcing = new WeakSet<Session>()
|
||||
/** A detach requested reentrantly from `session/created`. */
|
||||
/** Entries accepting or publishing an append; detach waits for the boundary to unwind. */
|
||||
private appending = new WeakSet<Session>()
|
||||
/** A detach requested reentrantly from creation or append publication. */
|
||||
private pendingDetach = new WeakSet<Session>()
|
||||
/** Unpublished identities held across factory load/setup transactions. */
|
||||
private reservations = new Map<SessionId, SessionRegistrationReservation>()
|
||||
@@ -746,7 +842,7 @@ export class SessionStore extends Service {
|
||||
* fills `version`/`id`/`createdAt`).
|
||||
*
|
||||
* For an agent whose session must be torn down IN ORDER with its loop (so the
|
||||
* loop's final flush is captured before the store-owned observer detaches), do NOT use this
|
||||
* loop's final flush is captured before the store attachment ends), do NOT use this
|
||||
* — fold the session lifecycle into the agent's own effect via
|
||||
* {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s
|
||||
* `startOwned`).
|
||||
@@ -763,7 +859,7 @@ export class SessionStore extends Service {
|
||||
// Single effect owned by the calling fiber. Yield the detach BEFORE
|
||||
// announcing so a throwing `session/created` listener rolls the attach back
|
||||
// (the generator effect disposes already-yielded disposers on a throw)
|
||||
// instead of leaking the store entry + append observer.
|
||||
// instead of leaking the store entry and its publication hooks.
|
||||
this.ctx.effect(function* (this: SessionStore) {
|
||||
yield this.enter(session)
|
||||
this.announce(session)
|
||||
@@ -777,7 +873,7 @@ export class SessionStore extends Service {
|
||||
* Pairs with {@link enter} + {@link announce}: a caller that owns a composite
|
||||
* `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
|
||||
* effect so a fiber unload tears the session + agent down as a single ORDERED
|
||||
* chain rather than as racing sibling effects — which would detach the append observer
|
||||
* chain rather than as racing sibling effects — which would remove the publication hooks
|
||||
* before the loop's closing `session/flush`, dropping the closing events.
|
||||
*
|
||||
* @param id - the session id; omitted, the store mints `session-<n>`.
|
||||
@@ -827,9 +923,9 @@ export class SessionStore extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter a {@link prepare}d session into the store: wire the module-private
|
||||
* append observer to `session/event` and add it to the store. Returns the
|
||||
* DETACH disposer (observer + store removal). Does NOT emit `session/created` —
|
||||
* Enter a {@link prepare}d session into the store: install the module-private
|
||||
* append publication hooks and add it to the store. Returns the DETACH
|
||||
* disposer (hooks + store removal). Does NOT emit `session/created` —
|
||||
* the caller yields this disposer inside its effect and THEN calls
|
||||
* {@link announce}, so a throwing `session/created` listener rolls the attach
|
||||
* back instead of leaking it.
|
||||
@@ -845,7 +941,7 @@ export class SessionStore extends Service {
|
||||
* @param session - a {@link prepare}d session not yet in the store.
|
||||
* @param reservation - the exact unpublished-id capability when a factory
|
||||
* reserved this session across setup.
|
||||
* @returns the detach disposer (observer + store removal). When called from
|
||||
* @returns the detach disposer (publication hooks + store removal). When called from
|
||||
* a synchronous `session/created` listener, removal and disposal wait until
|
||||
* that creation dispatch unwinds.
|
||||
* @throws if a session with this id is already in the store.
|
||||
@@ -863,7 +959,7 @@ export class SessionStore extends Service {
|
||||
if (this.store.has(id) || this.enteringIds.has(id)) {
|
||||
throw new Error(`session "${id}" already exists`)
|
||||
}
|
||||
if (appendObservers.has(session)) throw new Error(`session "${id}" is already attached to a store`)
|
||||
if (appendHooks.has(session)) throw new Error(`session "${id}" is already attached to a store`)
|
||||
this.enteringIds.add(id)
|
||||
// The carrier is decided HERE, once, from the ENTERING context's scope tag
|
||||
// (`this.ctx` is the caller's context — the tracker mechanism): every
|
||||
@@ -889,20 +985,39 @@ export class SessionStore extends Service {
|
||||
}
|
||||
/* v8 ignore next 1 -- enteringIds prevents a same-store commit during carrier construction */
|
||||
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
|
||||
if (appendObservers.has(session)) throw new Error(`session "${id}" is already attached to a store`)
|
||||
if (appendHooks.has(session)) throw new Error(`session "${id}" is already attached to a store`)
|
||||
this.carriers.set(session, carrier)
|
||||
const emitCtx = this.ctx
|
||||
appendObservers.set(session, (event) => { emitCtx.emit(carrier, 'session/event', session, event) })
|
||||
appendHooks.set(session, {
|
||||
begin: () => { this.appending.add(session) },
|
||||
prepareObservation(event) {
|
||||
// Cordis removes carrier/name in place and exposes the remaining array
|
||||
// to internal/dispatch. Resolve with a throwaway array so an internal
|
||||
// checker cannot replace the tuple later observers receive.
|
||||
const dispatchArgs: unknown[] = [carrier, 'session/event', session, event]
|
||||
const callbackArgs: unknown[] = [session, event]
|
||||
const callbacks = collectSessionCallbacks(emitCtx, dispatchArgs)
|
||||
assertDispatchTuple('session/event', dispatchArgs, callbackArgs)
|
||||
return () => { invokeContainedSessionObservers(emitCtx, 'session/event', id, callbackArgs, callbacks) }
|
||||
},
|
||||
end: () => {
|
||||
this.appending.delete(session)
|
||||
if (this.pendingDetach.has(session) && !this.announcing.has(session)) {
|
||||
this.detachEntered(session, id, carrier)
|
||||
}
|
||||
},
|
||||
})
|
||||
attachmentEpochs.set(session, {})
|
||||
this.acceptedIds.set(session, id)
|
||||
this.store.set(id, session)
|
||||
let entered = true
|
||||
const detach = (): void => {
|
||||
if (!entered) return
|
||||
entered = false
|
||||
// A creation listener may own the advanced detach capability. Keep the
|
||||
// entry and its event observer live until the synchronous creation
|
||||
// dispatch unwinds, then publish the paired disposal edge.
|
||||
if (this.announcing.has(session)) {
|
||||
// A lifecycle listener may own the advanced detach capability. Keep the
|
||||
// entry and its publication hooks live until synchronous creation or append
|
||||
// publication unwinds, then publish the paired disposal edge.
|
||||
if (this.announcing.has(session) || this.appending.has(session)) {
|
||||
this.pendingDetach.add(session)
|
||||
return
|
||||
}
|
||||
@@ -920,7 +1035,8 @@ export class SessionStore extends Service {
|
||||
* remains the exact-identity backstop against future mutation paths */
|
||||
if (this.store.get(id) !== session || this.acceptedIds.get(session) !== id) return
|
||||
const wasAnnounced = this.announced.delete(session)
|
||||
appendObservers.delete(session)
|
||||
appendHooks.delete(session)
|
||||
attachmentEpochs.set(session, {})
|
||||
this.acceptedIds.delete(session)
|
||||
this.carriers.delete(session)
|
||||
this.store.delete(id)
|
||||
@@ -943,38 +1059,40 @@ export class SessionStore extends Service {
|
||||
// throw. Rollback must still pair that partial creation with disposal, and
|
||||
// a listener cannot recursively create a second lifecycle edge.
|
||||
this.announced.add(session)
|
||||
const args: unknown[] = [carrier, 'session/created', session]
|
||||
const dispatchArgs: unknown[] = [carrier, 'session/created', session]
|
||||
const callbackArgs: unknown[] = [session]
|
||||
this.announcing.add(session)
|
||||
try {
|
||||
for (const callback of this.ctx.events.dispatch('emit', args)) {
|
||||
const callbacks = collectSessionCallbacks(this.ctx, dispatchArgs)
|
||||
assertDispatchTuple('session/created', dispatchArgs, callbackArgs)
|
||||
for (const callback of callbacks) {
|
||||
// Synchronous throws intentionally propagate and veto publication; the
|
||||
// yielded detach then emits the paired disposal edge. An async function
|
||||
// is nevertheless assignable to a void listener, so observe its returned
|
||||
// promise: rejection is too late to roll back and must be logged instead
|
||||
// of becoming unhandled.
|
||||
const returned: unknown = callback(...args)
|
||||
const returned: unknown = callback(...callbackArgs)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`session "${id}": session/created listener rejected: ${renderThrown(error)}`)
|
||||
warnContained(this.ctx, `session "${id}": session/created listener rejected: ${renderThrown(error)}`)
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
this.announcing.delete(session)
|
||||
if (this.pendingDetach.has(session)) this.detachEntered(session, id, carrier)
|
||||
if (this.pendingDetach.has(session) && !this.appending.has(session)) {
|
||||
this.detachEntered(session, id, carrier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Emit the paired teardown notification with per-listener containment. */
|
||||
private emitDisposed(session: Session, carrier: Scoped<Session>, id: SessionId): void {
|
||||
const args: unknown[] = [carrier, 'session/disposed', session]
|
||||
for (const callback of this.ctx.events.dispatch('emit', args)) {
|
||||
try {
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`session "${id}": session/disposed listener rejected: ${renderThrown(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`session "${id}": session/disposed listener threw: ${renderThrown(error)}`)
|
||||
}
|
||||
const dispatchArgs: unknown[] = [carrier, 'session/disposed', session]
|
||||
const callbackArgs: unknown[] = [session]
|
||||
try {
|
||||
const callbacks = collectSessionCallbacks(this.ctx, dispatchArgs)
|
||||
invokeContainedSessionObservers(this.ctx, 'session/disposed', id, callbackArgs, callbacks)
|
||||
} catch (error: unknown) {
|
||||
warnContained(this.ctx, `session "${id}": session/disposed dispatch threw: ${renderThrown(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -986,10 +1104,27 @@ export class SessionStore extends Service {
|
||||
* raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the
|
||||
* scoped-dispatch invariant can pin it.
|
||||
* @param session - the session whose buffered events must reach durable storage.
|
||||
* @returns resolves when every flush listener has settled; rejects if one rejects.
|
||||
* @returns resolves when every flush listener has settled; after all settle,
|
||||
* rejects with the first registered listener failure if any listener failed.
|
||||
*/
|
||||
async flush(session: Session): Promise<void> {
|
||||
await this.ctx.parallel(this.liveEntryFor(session).carrier, 'session/flush', session)
|
||||
const { carrier } = this.liveEntryFor(session)
|
||||
const dispatchArgs: unknown[] = [carrier, 'session/flush', session]
|
||||
const callbackArgs: unknown[] = [session]
|
||||
const callbacks = collectSessionCallbacks(this.ctx, dispatchArgs)
|
||||
assertDispatchTuple('session/flush', dispatchArgs, callbackArgs)
|
||||
const results = await Promise.allSettled(callbacks.map((callback) => {
|
||||
try {
|
||||
return callback(...callbackArgs)
|
||||
} catch (error: unknown) {
|
||||
// Preserve the listener's exact rejection value; flush is a caller-owned
|
||||
// failure boundary, and Cordis listeners may throw arbitrary values.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}))
|
||||
const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
if (failure !== undefined) throw failure.reason
|
||||
}
|
||||
|
||||
/** Return the exact live session's accepted id and carrier; detached/prepared objects reject. */
|
||||
|
||||
Reference in New Issue
Block a user