mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
* fix(runtime): register phone terminal subscriptions when the request arrives * fix(runtime): don't let an already-aborted terminal subscribe take the stream slot * fix(runtime): let the phone end a terminal stream by the request that opened it * fix(mobile): describe the relay sibling check accurately on this base
236 lines
7.3 KiB
TypeScript
236 lines
7.3 KiB
TypeScript
type SubscriptionCleanup = () => void | Promise<void>
|
|
|
|
type RequestAddress = { connectionId: string; requestId: string }
|
|
|
|
const requestKey = ({ connectionId, requestId }: RequestAddress): string =>
|
|
JSON.stringify([connectionId, requestId])
|
|
|
|
type SubscriptionEntry = {
|
|
cleanup: SubscriptionCleanup
|
|
version: number
|
|
request?: RequestAddress
|
|
}
|
|
|
|
export type SubscriptionRegistration = {
|
|
releaseIfCurrent(): void
|
|
}
|
|
|
|
export class RuntimeSubscriptionRegistry {
|
|
private readonly cleanups = new Map<string, SubscriptionEntry>()
|
|
private readonly cleanupPromises = new Map<
|
|
string,
|
|
{ entry: SubscriptionEntry; promise: Promise<void> }
|
|
>()
|
|
private readonly subscriptionsByConnection = new Map<string, Set<string>>()
|
|
private readonly connectionBySubscription = new Map<string, string>()
|
|
private readonly subscriptionsByRequest = new Map<
|
|
string,
|
|
{ subscriptionId: string; version: number }
|
|
>()
|
|
private registrationVersion = 0
|
|
|
|
getRegistrationVersion(): number {
|
|
return this.registrationVersion
|
|
}
|
|
|
|
register(
|
|
subscriptionId: string,
|
|
cleanup: SubscriptionCleanup,
|
|
connectionId?: string,
|
|
requestId?: string
|
|
): void {
|
|
const existing = this.cleanups.get(subscriptionId)
|
|
if (existing) {
|
|
this.removeConnectionIndex(subscriptionId)
|
|
this.removeRequestIndex(existing)
|
|
this.cleanup(subscriptionId)
|
|
}
|
|
const version = ++this.registrationVersion
|
|
const request = connectionId && requestId ? { connectionId, requestId } : undefined
|
|
this.cleanups.set(subscriptionId, { cleanup, version, request })
|
|
if (request) {
|
|
// Why: IPC reuses a request id after aborting its previous subscription, so the newer one owns the address.
|
|
this.subscriptionsByRequest.set(requestKey(request), { subscriptionId, version })
|
|
}
|
|
if (!connectionId) {
|
|
return
|
|
}
|
|
let set = this.subscriptionsByConnection.get(connectionId)
|
|
if (!set) {
|
|
set = new Set()
|
|
this.subscriptionsByConnection.set(connectionId, set)
|
|
}
|
|
set.add(subscriptionId)
|
|
this.connectionBySubscription.set(subscriptionId, connectionId)
|
|
}
|
|
|
|
registerOwned(
|
|
subscriptionId: string,
|
|
cleanup: SubscriptionCleanup,
|
|
connectionId?: string,
|
|
requestId?: string
|
|
): SubscriptionRegistration {
|
|
this.register(subscriptionId, cleanup, connectionId, requestId)
|
|
const version = this.registrationVersion
|
|
return { releaseIfCurrent: () => this.cleanupOwned(subscriptionId, version) }
|
|
}
|
|
|
|
/** Releases the registration a request created; an unknown, ended or replaced request is a no-op. */
|
|
releaseByRequest(connectionId: string | undefined, requestId: string): void {
|
|
// Why: some non-phone sockets carry no connection id, and a bare request id is not unique across sockets.
|
|
if (!connectionId) {
|
|
return
|
|
}
|
|
const target = this.subscriptionsByRequest.get(requestKey({ connectionId, requestId }))
|
|
if (target) {
|
|
this.cleanupOwned(target.subscriptionId, target.version)
|
|
}
|
|
}
|
|
|
|
cleanupIfOwnedByConnection(
|
|
subscriptionId: string,
|
|
connectionId?: string,
|
|
throughVersion?: number
|
|
): boolean {
|
|
const entry = this.cleanups.get(subscriptionId)
|
|
if (!entry) {
|
|
return true
|
|
}
|
|
if (throughVersion !== undefined && entry.version > throughVersion) {
|
|
return false
|
|
}
|
|
if (connectionId && this.connectionBySubscription.get(subscriptionId) !== connectionId) {
|
|
return false
|
|
}
|
|
this.cleanup(subscriptionId)
|
|
return true
|
|
}
|
|
|
|
cleanup(subscriptionId: string): void {
|
|
void this.cleanupAndWait(subscriptionId).catch((error) => {
|
|
console.error(`[runtime] subscription cleanup failed for ${subscriptionId}:`, error)
|
|
})
|
|
}
|
|
|
|
retryAfter(subscriptionId: string, cleanupOwner: SubscriptionCleanup, gate: Promise<void>): void {
|
|
const entry = this.cleanups.get(subscriptionId)
|
|
const failedGeneration = this.cleanupPromises.get(subscriptionId)
|
|
void gate.then(
|
|
async () => {
|
|
if (entry?.cleanup !== cleanupOwner) {
|
|
return
|
|
}
|
|
await (failedGeneration?.entry === entry
|
|
? failedGeneration.promise.catch(() => undefined)
|
|
: undefined)
|
|
while (this.cleanups.get(subscriptionId) === entry) {
|
|
const newerGeneration = this.cleanupPromises.get(subscriptionId)
|
|
if (newerGeneration?.entry === entry) {
|
|
await newerGeneration.promise.catch(() => undefined)
|
|
continue
|
|
}
|
|
this.cleanup(subscriptionId)
|
|
return
|
|
}
|
|
},
|
|
() => undefined
|
|
)
|
|
}
|
|
|
|
async cleanupAndWait(subscriptionId: string): Promise<void> {
|
|
const entry = this.cleanups.get(subscriptionId)
|
|
if (!entry) {
|
|
return
|
|
}
|
|
const inFlight = this.cleanupPromises.get(subscriptionId)
|
|
if (inFlight?.entry === entry) {
|
|
return inFlight.promise
|
|
}
|
|
let cleanupResult: void | Promise<void>
|
|
try {
|
|
cleanupResult = entry.cleanup()
|
|
} catch (error) {
|
|
cleanupResult = Promise.reject(error)
|
|
}
|
|
const promise = Promise.resolve(cleanupResult)
|
|
.then(() => {
|
|
if (this.cleanups.get(subscriptionId) !== entry) {
|
|
return
|
|
}
|
|
this.cleanups.delete(subscriptionId)
|
|
this.removeConnectionIndex(subscriptionId)
|
|
this.removeRequestIndex(entry)
|
|
})
|
|
.finally(() => {
|
|
if (this.cleanupPromises.get(subscriptionId)?.promise === promise) {
|
|
this.cleanupPromises.delete(subscriptionId)
|
|
}
|
|
})
|
|
this.cleanupPromises.set(subscriptionId, { entry, promise })
|
|
return promise
|
|
}
|
|
|
|
cleanupByPrefix(prefix: string, throughVersion?: number): void {
|
|
const ids = Array.from(this.cleanups.entries())
|
|
.filter(
|
|
([id, entry]) =>
|
|
id.startsWith(prefix) && (throughVersion === undefined || entry.version <= throughVersion)
|
|
)
|
|
.map(([id]) => id)
|
|
for (const id of ids) {
|
|
this.cleanup(id)
|
|
}
|
|
}
|
|
|
|
cleanupForConnection(connectionId: string): void {
|
|
const set = this.subscriptionsByConnection.get(connectionId)
|
|
if (!set) {
|
|
return
|
|
}
|
|
for (const id of Array.from(set)) {
|
|
if (this.connectionBySubscription.get(id) !== connectionId) {
|
|
set.delete(id)
|
|
continue
|
|
}
|
|
this.cleanup(id)
|
|
}
|
|
if (set.size === 0) {
|
|
this.subscriptionsByConnection.delete(connectionId)
|
|
}
|
|
}
|
|
|
|
private cleanupOwned(subscriptionId: string, expectedVersion: number): void {
|
|
if (this.cleanups.get(subscriptionId)?.version !== expectedVersion) {
|
|
return
|
|
}
|
|
this.cleanup(subscriptionId)
|
|
}
|
|
|
|
/** Compare-and-delete: a newer registration that reused the request id keeps its address. */
|
|
private removeRequestIndex(entry: SubscriptionEntry): void {
|
|
if (!entry.request) {
|
|
return
|
|
}
|
|
const key = requestKey(entry.request)
|
|
if (this.subscriptionsByRequest.get(key)?.version === entry.version) {
|
|
this.subscriptionsByRequest.delete(key)
|
|
}
|
|
}
|
|
|
|
private removeConnectionIndex(subscriptionId: string): void {
|
|
const connectionId = this.connectionBySubscription.get(subscriptionId)
|
|
if (!connectionId) {
|
|
return
|
|
}
|
|
this.connectionBySubscription.delete(subscriptionId)
|
|
const set = this.subscriptionsByConnection.get(connectionId)
|
|
if (!set) {
|
|
return
|
|
}
|
|
set.delete(subscriptionId)
|
|
if (set.size === 0) {
|
|
this.subscriptionsByConnection.delete(connectionId)
|
|
}
|
|
}
|
|
}
|