mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
refactor(connection): carry Host facts with generations
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
import type { Context } from '@deepseek-ai/cordis'
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
import type {
|
import type {
|
||||||
ConnectionGenerationSource,
|
ConnectionGenerationSource,
|
||||||
|
ConnectionHostInfo,
|
||||||
ConnectionHandle,
|
ConnectionHandle,
|
||||||
} from '@deepseek-ai/dsh-client-connection/client'
|
} from '@deepseek-ai/dsh-client-connection/client'
|
||||||
import type {
|
import type {
|
||||||
@@ -118,7 +119,10 @@ export class ClientRemoteEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Run one Connection generation over the forwarded-event logical stream. */
|
/** Run one Connection generation over the forwarded-event logical stream. */
|
||||||
private async pumpEvents(signal: AbortSignal, ready: () => void): Promise<void> {
|
private async pumpEvents(
|
||||||
|
signal: AbortSignal,
|
||||||
|
ready: (host: ConnectionHostInfo) => void,
|
||||||
|
): Promise<void> {
|
||||||
let clientId: RemoteEventClientId | undefined
|
let clientId: RemoteEventClientId | undefined
|
||||||
const failed = new AbortController()
|
const failed = new AbortController()
|
||||||
const generationSignal = AbortSignal.any([signal, failed.signal])
|
const generationSignal = AbortSignal.any([signal, failed.signal])
|
||||||
@@ -134,8 +138,9 @@ export class ClientRemoteEvents {
|
|||||||
try {
|
try {
|
||||||
for await (const value of source) {
|
for await (const value of source) {
|
||||||
if (clientId === undefined) {
|
if (clientId === undefined) {
|
||||||
clientId = parseRemoteEventReady(value)
|
const opening = parseRemoteEventReady(value)
|
||||||
ready()
|
clientId = opening.clientId
|
||||||
|
ready(opening.host)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const frame = parseRemoteEventFrame(value)
|
const frame = parseRemoteEventFrame(value)
|
||||||
@@ -251,15 +256,21 @@ export class ClientRemoteEvents {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate and return the Client identity from one generation's opening item. */
|
/** Validate and return one generation's Client identity and Host facts. */
|
||||||
function parseRemoteEventReady(value: unknown): RemoteEventClientId {
|
function parseRemoteEventReady(value: unknown): {
|
||||||
|
readonly clientId: RemoteEventClientId
|
||||||
|
readonly host: ConnectionHostInfo
|
||||||
|
} {
|
||||||
if (!isRemoteEventRecord(value)
|
if (!isRemoteEventRecord(value)
|
||||||
|| !hasExactRemoteEventKeys(value, ['type', 'clientId'])
|
|| !hasExactRemoteEventKeys(value, ['type', 'clientId', 'host'])
|
||||||
|| value.type !== 'ready'
|
|| value.type !== 'ready'
|
||||||
|| !isRemoteEventClientId(value.clientId)) {
|
|| !isRemoteEventClientId(value.clientId)
|
||||||
|
|| !isRemoteEventRecord(value.host)
|
||||||
|
|| !hasExactRemoteEventKeys(value.host, ['home'])
|
||||||
|
|| typeof value.host.home !== 'string') {
|
||||||
throw new TypeError('client api: forwarded Remote event stream did not begin with ready')
|
throw new TypeError('client api: forwarded Remote event stream did not begin with ready')
|
||||||
}
|
}
|
||||||
return value.clientId
|
return { clientId: value.clientId, host: { home: value.host.home } }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate one untrusted value from the Gateway-internal forwarded-event stream. */
|
/** Validate one untrusted value from the Gateway-internal forwarded-event stream. */
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export class RemoteStream<Item> implements AsyncIterable<RemoteStreamItem<Item>>
|
|||||||
* @param options - domain stream opener, end classification, and diagnostics.
|
* @param options - domain stream opener, end classification, and diagnostics.
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private readonly connection: Pick<ConnectionHandle, 'hostDescription'>,
|
private readonly connection: Pick<ConnectionHandle, 'generation'>,
|
||||||
private readonly options: RemoteStreamOptions<Item>,
|
private readonly options: RemoteStreamOptions<Item>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -157,13 +157,13 @@ export class RemoteStream<Item> implements AsyncIterable<RemoteStreamItem<Item>>
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function waitForRemoteStreamRetry(
|
async function waitForRemoteStreamRetry(
|
||||||
connection: Pick<ConnectionHandle, 'hostDescription'>,
|
connection: Pick<ConnectionHandle, 'generation'>,
|
||||||
error: RemoteStreamCarrierError,
|
error: RemoteStreamCarrierError,
|
||||||
attempt: number,
|
attempt: number,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
signal.throwIfAborted()
|
signal.throwIfAborted()
|
||||||
if (connection.hostDescription.getSnapshot() !== undefined) {
|
if (connection.generation.getSnapshot() !== undefined) {
|
||||||
if (attempt === 1) return
|
if (attempt === 1) return
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -181,12 +181,12 @@ async function waitForRemoteStreamRetry(
|
|||||||
else reject(failure)
|
else reject(failure)
|
||||||
}
|
}
|
||||||
const inspect = (): void => {
|
const inspect = (): void => {
|
||||||
if (connection.hostDescription.getSnapshot() !== undefined) finish()
|
if (connection.generation.getSnapshot() !== undefined) finish()
|
||||||
}
|
}
|
||||||
const aborted = (): void => {
|
const aborted = (): void => {
|
||||||
finish(new Error('Remote stream retry aborted', { cause: signal.reason }))
|
finish(new Error('Remote stream retry aborted', { cause: signal.reason }))
|
||||||
}
|
}
|
||||||
const dispose = connection.hostDescription.subscribe(inspect)
|
const dispose = connection.generation.subscribe(inspect)
|
||||||
subscription.dispose = dispose
|
subscription.dispose = dispose
|
||||||
if (subscription.finished) dispose()
|
if (subscription.finished) dispose()
|
||||||
signal.addEventListener('abort', aborted, { once: true })
|
signal.addEventListener('abort', aborted, { once: true })
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
type RemoteEventCancellationFrame,
|
type RemoteEventCancellationFrame,
|
||||||
type RemoteEventClientId,
|
type RemoteEventClientId,
|
||||||
type RemoteEventEmitFrame,
|
type RemoteEventEmitFrame,
|
||||||
|
type RemoteEventHostInfo,
|
||||||
type RemoteEventId,
|
type RemoteEventId,
|
||||||
type RemoteEventInvocationFrame,
|
type RemoteEventInvocationFrame,
|
||||||
type RemoteEventReadyFrame,
|
type RemoteEventReadyFrame,
|
||||||
@@ -66,6 +67,7 @@ export type {
|
|||||||
TypertRemoteEventOutcome,
|
TypertRemoteEventOutcome,
|
||||||
TypertRemoteEventSource,
|
TypertRemoteEventSource,
|
||||||
} from './types.ts'
|
} from './types.ts'
|
||||||
|
export type { RemoteEventHostInfo } from './stream-protocol.ts'
|
||||||
|
|
||||||
interface GatewayErrorOptions {
|
interface GatewayErrorOptions {
|
||||||
readonly cause?: unknown
|
readonly cause?: unknown
|
||||||
@@ -88,6 +90,7 @@ interface PreparedInvocation {
|
|||||||
interface RegisteredRemoteEventSource {
|
interface RegisteredRemoteEventSource {
|
||||||
readonly lifetime: AbortController
|
readonly lifetime: AbortController
|
||||||
readonly done: Promise<void>
|
readonly done: Promise<void>
|
||||||
|
readonly host: RemoteEventHostInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RemoteEventClient {
|
interface RemoteEventClient {
|
||||||
@@ -233,9 +236,13 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
|||||||
/**
|
/**
|
||||||
* Register the sole application-selected forwarded-event source.
|
* Register the sole application-selected forwarded-event source.
|
||||||
* @param source - stream factory installed by the Remote assembly.
|
* @param source - stream factory installed by the Remote assembly.
|
||||||
|
* @param host - stable Host facts included in each Client generation's opening frame.
|
||||||
* @returns disposer removing this source and cancelling its active streams.
|
* @returns disposer removing this source and cancelling its active streams.
|
||||||
*/
|
*/
|
||||||
registerRemoteEvents(source: TypertRemoteEventSource): () => Promise<void> {
|
registerRemoteEvents(
|
||||||
|
source: TypertRemoteEventSource,
|
||||||
|
host: RemoteEventHostInfo,
|
||||||
|
): () => Promise<void> {
|
||||||
if (this.remoteEvents !== undefined) {
|
if (this.remoteEvents !== undefined) {
|
||||||
throw new Error('typert gateway: forwarded Remote event source is already registered')
|
throw new Error('typert gateway: forwarded Remote event source is already registered')
|
||||||
}
|
}
|
||||||
@@ -247,7 +254,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
|||||||
this.remoteEvents = undefined
|
this.remoteEvents = undefined
|
||||||
lifetime.abort(error)
|
lifetime.abort(error)
|
||||||
})
|
})
|
||||||
const registration: RegisteredRemoteEventSource = { lifetime, done }
|
const registration: RegisteredRemoteEventSource = { lifetime, done, host: { home: host.home } }
|
||||||
this.remoteEvents = registration
|
this.remoteEvents = registration
|
||||||
return async () => {
|
return async () => {
|
||||||
if (this.remoteEvents === registration) {
|
if (this.remoteEvents === registration) {
|
||||||
@@ -417,7 +424,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
|||||||
this.remoteEventClients.set(clientId, client)
|
this.remoteEventClients.set(clientId, client)
|
||||||
for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client)
|
for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client)
|
||||||
try {
|
try {
|
||||||
yield { ...REMOTE_EVENT_STREAM_READY, clientId }
|
yield { ...REMOTE_EVENT_STREAM_READY, clientId, host: registration.host }
|
||||||
yield* client.queue.iterate(lifetime)
|
yield* client.queue.iterate(lifetime)
|
||||||
} finally {
|
} finally {
|
||||||
this.removeRemoteEventClient(client)
|
this.removeRemoteEventClient(client)
|
||||||
|
|||||||
@@ -23,10 +23,18 @@ export type RemoteEventClientId = Branded<'RemoteEventClientId'>
|
|||||||
/** Opaque correlation id for one pending Host-to-Client Remote Event. */
|
/** Opaque correlation id for one pending Host-to-Client Remote Event. */
|
||||||
export type RemoteEventId = Branded<'RemoteEventId'>
|
export type RemoteEventId = Branded<'RemoteEventId'>
|
||||||
|
|
||||||
|
/** Stable Host facts published with every established Client event generation. */
|
||||||
|
export interface RemoteEventHostInfo {
|
||||||
|
/** Host account home used only to abbreviate displayed filesystem paths. */
|
||||||
|
readonly home: string
|
||||||
|
}
|
||||||
|
|
||||||
/** Opening item that binds later HTTP results to this active event stream. */
|
/** Opening item that binds later HTTP results to this active event stream. */
|
||||||
export interface RemoteEventReadyFrame {
|
export interface RemoteEventReadyFrame {
|
||||||
readonly type: 'ready'
|
readonly type: 'ready'
|
||||||
readonly clientId: RemoteEventClientId
|
readonly clientId: RemoteEventClientId
|
||||||
|
/** Stable Host facts attached to this connection generation. */
|
||||||
|
readonly host: RemoteEventHostInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opaque Agent identity carried by one scoped Remote Event. */
|
/** Opaque Agent identity carried by one scoped Remote Event. */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Context } from '@deepseek-ai/cordis'
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
|
import type { RemoteEventHostInfo } from './stream-protocol.ts'
|
||||||
|
|
||||||
/** One Remote method request after a carrier has decoded its envelope. */
|
/** One Remote method request after a carrier has decoded its envelope. */
|
||||||
export interface InvokeRemoteRequest {
|
export interface InvokeRemoteRequest {
|
||||||
@@ -124,9 +125,13 @@ export interface TypertGateway {
|
|||||||
/**
|
/**
|
||||||
* Register the application-selected forwarded-event source.
|
* Register the application-selected forwarded-event source.
|
||||||
* @param source - stream factory installed by the Remote assembly.
|
* @param source - stream factory installed by the Remote assembly.
|
||||||
|
* @param host - stable Host facts included in each Client generation's opening frame.
|
||||||
* @returns disposer removing this exact source and cancelling its active streams.
|
* @returns disposer removing this exact source and cancelling its active streams.
|
||||||
*/
|
*/
|
||||||
registerRemoteEvents(source: TypertRemoteEventSource): () => Promise<void>
|
registerRemoteEvents(
|
||||||
|
source: TypertRemoteEventSource,
|
||||||
|
host: RemoteEventHostInfo,
|
||||||
|
): () => Promise<void>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invoke one live Remote method without assuming a carrier or response envelope.
|
* Invoke one live Remote method without assuming a carrier or response envelope.
|
||||||
|
|||||||
@@ -5,23 +5,17 @@ import {
|
|||||||
RemoteStream,
|
RemoteStream,
|
||||||
} from '../src/client/index.ts'
|
} from '../src/client/index.ts'
|
||||||
|
|
||||||
const DESCRIPTION = {
|
const GENERATION = { id: 1, host: { home: '/home/fixture' } }
|
||||||
version: 'fixture',
|
|
||||||
cwd: '/fixture',
|
|
||||||
attachedSessions: 0,
|
|
||||||
home: '/home/fixture',
|
|
||||||
canOpenPath: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
function hostSource(initiallyAvailable: boolean): {
|
function hostSource(initiallyAvailable: boolean): {
|
||||||
connection: Pick<ConnectionHandle, 'hostDescription'>
|
connection: Pick<ConnectionHandle, 'generation'>
|
||||||
publish(available: boolean): void
|
publish(available: boolean): void
|
||||||
} {
|
} {
|
||||||
let current = initiallyAvailable ? DESCRIPTION : undefined
|
let current = initiallyAvailable ? GENERATION : undefined
|
||||||
const listeners = new Set<() => void>()
|
const listeners = new Set<() => void>()
|
||||||
return {
|
return {
|
||||||
connection: {
|
connection: {
|
||||||
hostDescription: {
|
generation: {
|
||||||
getSnapshot: () => current,
|
getSnapshot: () => current,
|
||||||
subscribe: (listener) => {
|
subscribe: (listener) => {
|
||||||
listeners.add(listener)
|
listeners.add(listener)
|
||||||
@@ -30,7 +24,7 @@ function hostSource(initiallyAvailable: boolean): {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
publish: (available) => {
|
publish: (available) => {
|
||||||
current = available ? DESCRIPTION : undefined
|
current = available ? GENERATION : undefined
|
||||||
for (const listener of listeners) listener()
|
for (const listener of listeners) listener()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -67,7 +61,7 @@ function scripted<Item>(generations: Generation<Item>[], opened?: () => void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function supervisor<Item>(
|
function supervisor<Item>(
|
||||||
connection: Pick<ConnectionHandle, 'hostDescription'>,
|
connection: Pick<ConnectionHandle, 'generation'>,
|
||||||
generations: Generation<Item>[],
|
generations: Generation<Item>[],
|
||||||
carrierFailed?: (error: RemoteStreamCarrierError) => void,
|
carrierFailed?: (error: RemoteStreamCarrierError) => void,
|
||||||
): RemoteStream<Item> {
|
): RemoteStream<Item> {
|
||||||
@@ -122,8 +116,8 @@ describe('RemoteStream', () => {
|
|||||||
let listener: (() => void) | undefined
|
let listener: (() => void) | undefined
|
||||||
const subscribed = Promise.withResolvers<undefined>()
|
const subscribed = Promise.withResolvers<undefined>()
|
||||||
const connection = {
|
const connection = {
|
||||||
hostDescription: {
|
generation: {
|
||||||
getSnapshot: () => available ? DESCRIPTION : undefined,
|
getSnapshot: () => available ? GENERATION : undefined,
|
||||||
subscribe: (value: () => void) => {
|
subscribe: (value: () => void) => {
|
||||||
listener = value
|
listener = value
|
||||||
subscribed.resolve(undefined)
|
subscribed.resolve(undefined)
|
||||||
@@ -177,8 +171,8 @@ describe('RemoteStream', () => {
|
|||||||
let reads = 0
|
let reads = 0
|
||||||
let disposed = 0
|
let disposed = 0
|
||||||
const connection = {
|
const connection = {
|
||||||
hostDescription: {
|
generation: {
|
||||||
getSnapshot: () => reads++ === 0 ? undefined : DESCRIPTION,
|
getSnapshot: () => reads++ === 0 ? undefined : GENERATION,
|
||||||
subscribe: (listener: () => void) => {
|
subscribe: (listener: () => void) => {
|
||||||
listener()
|
listener()
|
||||||
return () => { disposed++ }
|
return () => { disposed++ }
|
||||||
@@ -261,7 +255,7 @@ describe('RemoteStream', () => {
|
|||||||
const holder: { stream?: RemoteStream<string> } = {}
|
const holder: { stream?: RemoteStream<string> } = {}
|
||||||
let subscriptions = 0
|
let subscriptions = 0
|
||||||
const connection = {
|
const connection = {
|
||||||
hostDescription: {
|
generation: {
|
||||||
getSnapshot: () => undefined,
|
getSnapshot: () => undefined,
|
||||||
subscribe: () => {
|
subscribe: () => {
|
||||||
subscriptions++
|
subscriptions++
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ vi.mock('node:crypto', async (importOriginal) => {
|
|||||||
|
|
||||||
const randomUuid = vi.mocked(randomUUID)
|
const randomUuid = vi.mocked(randomUUID)
|
||||||
const browserCookies = new WeakMap<Context, string>()
|
const browserCookies = new WeakMap<Context, string>()
|
||||||
|
const REMOTE_HOST = { home: '/home/fixture' } as const
|
||||||
type AgentWireId = TypertContextWire<TypertContextMap['agent']>
|
type AgentWireId = TypertContextWire<TypertContextMap['agent']>
|
||||||
const agentId = (value: string): AgentWireId => value as AgentWireId
|
const agentId = (value: string): AgentWireId => value as AgentWireId
|
||||||
|
|
||||||
@@ -386,8 +387,8 @@ describe('Typert Remote streams', () => {
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
const unregister = ctx.typertGateway.registerRemoteEvents(source)
|
const unregister = ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST)
|
||||||
expect(() => { ctx.typertGateway.registerRemoteEvents(source) })
|
expect(() => { ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) })
|
||||||
.toThrow('forwarded Remote event source is already registered')
|
.toThrow('forwarded Remote event source is already registered')
|
||||||
|
|
||||||
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
|
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
|
||||||
@@ -402,7 +403,7 @@ describe('Typert Remote streams', () => {
|
|||||||
const eventFrames = frames.filter(frame => frame.streamId === 'events')
|
const eventFrames = frames.filter(frame => frame.streamId === 'events')
|
||||||
expect(eventFrames).toHaveLength(1)
|
expect(eventFrames).toHaveLength(1)
|
||||||
expect(eventFrames[0]).toMatchObject({
|
expect(eventFrames[0]).toMatchObject({
|
||||||
type: 'item', streamId: 'events', value: { type: 'ready' },
|
type: 'item', streamId: 'events', value: { type: 'ready', host: REMOTE_HOST },
|
||||||
})
|
})
|
||||||
expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string')
|
expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string')
|
||||||
})
|
})
|
||||||
@@ -411,7 +412,7 @@ describe('Typert Remote streams', () => {
|
|||||||
const eventFrames = frames.filter(frame => frame.streamId === 'events').slice(0, 2)
|
const eventFrames = frames.filter(frame => frame.streamId === 'events').slice(0, 2)
|
||||||
expect(eventFrames).toHaveLength(2)
|
expect(eventFrames).toHaveLength(2)
|
||||||
expect(eventFrames[0]).toMatchObject({
|
expect(eventFrames[0]).toMatchObject({
|
||||||
type: 'item', streamId: 'events', value: { type: 'ready' },
|
type: 'item', streamId: 'events', value: { type: 'ready', host: REMOTE_HOST },
|
||||||
})
|
})
|
||||||
expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string')
|
expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string')
|
||||||
expect(eventFrames[1]).toEqual({
|
expect(eventFrames[1]).toEqual({
|
||||||
@@ -429,9 +430,9 @@ describe('Typert Remote streams', () => {
|
|||||||
expect(frames).toContainEqual({ type: 'end', streamId: 'events' })
|
expect(frames).toContainEqual({ type: 'end', streamId: 'events' })
|
||||||
})
|
})
|
||||||
|
|
||||||
const unregisterReplacement = ctx.typertGateway.registerRemoteEvents(source)
|
const unregisterReplacement = ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST)
|
||||||
await unregister()
|
await unregister()
|
||||||
expect(() => { ctx.typertGateway.registerRemoteEvents(source) })
|
expect(() => { ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) })
|
||||||
.toThrow('forwarded Remote event source is already registered')
|
.toThrow('forwarded Remote event source is already registered')
|
||||||
await unregisterReplacement()
|
await unregisterReplacement()
|
||||||
socket.close()
|
socket.close()
|
||||||
@@ -446,7 +447,7 @@ describe('Typert Remote streams', () => {
|
|||||||
await publish.promise
|
await publish.promise
|
||||||
yield pending.dispatch
|
yield pending.dispatch
|
||||||
})()
|
})()
|
||||||
const unregister = ctx.typertGateway.registerRemoteEvents(source)
|
const unregister = ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST)
|
||||||
const rejected = expect(pending.outcome).rejects.toThrow(
|
const rejected = expect(pending.outcome).rejects.toThrow(
|
||||||
'forwarded Remote event source was removed',
|
'forwarded Remote event source was removed',
|
||||||
)
|
)
|
||||||
@@ -479,7 +480,7 @@ describe('Typert Remote streams', () => {
|
|||||||
else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||||
})
|
})
|
||||||
throw new Error('fixture source rejected during removal')
|
throw new Error('fixture source rejected during removal')
|
||||||
})())
|
})(), REMOTE_HOST)
|
||||||
const client = await openEventClient(ctx, 'events-removal')
|
const client = await openEventClient(ctx, 'events-removal')
|
||||||
await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() })
|
await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() })
|
||||||
|
|
||||||
@@ -496,7 +497,7 @@ describe('Typert Remote streams', () => {
|
|||||||
it('delegates unavailable Contexts and rejects malformed scoped invocations', async () => {
|
it('delegates unavailable Contexts and rejects malformed scoped invocations', async () => {
|
||||||
const { ctx } = await setup(false)
|
const { ctx } = await setup(false)
|
||||||
const source = new RemoteEventSourceProbe()
|
const source = new RemoteEventSourceProbe()
|
||||||
const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
|
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
||||||
|
|
||||||
for (const event of [42, ''] as const) {
|
for (const event of [42, ''] as const) {
|
||||||
const invalidName = pendingInvocation(ctx)
|
const invalidName = pendingInvocation(ctx)
|
||||||
@@ -579,7 +580,7 @@ describe('Typert Remote streams', () => {
|
|||||||
return (async function* () {
|
return (async function* () {
|
||||||
yield frame as unknown as TypertRemoteEventDispatch
|
yield frame as unknown as TypertRemoteEventDispatch
|
||||||
})()
|
})()
|
||||||
})
|
}, REMOTE_HOST)
|
||||||
await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) })
|
await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) })
|
||||||
const reason: unknown = sourceSignal?.reason
|
const reason: unknown = sourceSignal?.reason
|
||||||
if (!(reason instanceof Error)) throw new Error('Remote event source did not fail with an Error')
|
if (!(reason instanceof Error)) throw new Error('Remote event source did not fail with an Error')
|
||||||
@@ -591,7 +592,7 @@ describe('Typert Remote streams', () => {
|
|||||||
it('retries a colliding Remote event id before publishing the second waterfall', async () => {
|
it('retries a colliding Remote event id before publishing the second waterfall', async () => {
|
||||||
const { ctx } = await setup(false)
|
const { ctx } = await setup(false)
|
||||||
const source = new RemoteEventSourceProbe()
|
const source = new RemoteEventSourceProbe()
|
||||||
const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
|
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
||||||
const agent = ctx.extend()
|
const agent = ctx.extend()
|
||||||
ctx.typert.contexts.registerHost('agent', {
|
ctx.typert.contexts.registerHost('agent', {
|
||||||
wire: 'agentId',
|
wire: 'agentId',
|
||||||
@@ -626,7 +627,7 @@ describe('Typert Remote streams', () => {
|
|||||||
it('retries a colliding Remote event Client id before opening the second generation', async () => {
|
it('retries a colliding Remote event Client id before opening the second generation', async () => {
|
||||||
const { ctx } = await setup(true)
|
const { ctx } = await setup(true)
|
||||||
const source = new RemoteEventSourceProbe()
|
const source = new RemoteEventSourceProbe()
|
||||||
const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
|
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
||||||
const firstId = '00000000-0000-4000-8000-000000000011' as ReturnType<typeof randomUUID>
|
const firstId = '00000000-0000-4000-8000-000000000011' as ReturnType<typeof randomUUID>
|
||||||
const secondId = '00000000-0000-4000-8000-000000000012' as ReturnType<typeof randomUUID>
|
const secondId = '00000000-0000-4000-8000-000000000012' as ReturnType<typeof randomUUID>
|
||||||
randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId)
|
randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId)
|
||||||
@@ -645,7 +646,7 @@ describe('Typert Remote streams', () => {
|
|||||||
it('fans one scoped waterfall out and accepts the first Client result', async () => {
|
it('fans one scoped waterfall out and accepts the first Client result', async () => {
|
||||||
const { ctx } = await setup(true)
|
const { ctx } = await setup(true)
|
||||||
const source = new RemoteEventSourceProbe()
|
const source = new RemoteEventSourceProbe()
|
||||||
const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
|
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
||||||
const agent = ctx.extend()
|
const agent = ctx.extend()
|
||||||
ctx.typert.contexts.registerHost('agent', {
|
ctx.typert.contexts.registerHost('agent', {
|
||||||
wire: 'agentId',
|
wire: 'agentId',
|
||||||
@@ -699,7 +700,7 @@ describe('Typert Remote streams', () => {
|
|||||||
it('rejects the Host waterfall with the first Client listener rejection', async () => {
|
it('rejects the Host waterfall with the first Client listener rejection', async () => {
|
||||||
const { ctx } = await setup(true)
|
const { ctx } = await setup(true)
|
||||||
const source = new RemoteEventSourceProbe()
|
const source = new RemoteEventSourceProbe()
|
||||||
const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
|
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
||||||
const agent = ctx.extend()
|
const agent = ctx.extend()
|
||||||
ctx.typert.contexts.registerHost('agent', {
|
ctx.typert.contexts.registerHost('agent', {
|
||||||
wire: 'agentId',
|
wire: 'agentId',
|
||||||
@@ -739,7 +740,7 @@ describe('Typert Remote streams', () => {
|
|||||||
it('delegates to the Host only after every active Client returns next', async () => {
|
it('delegates to the Host only after every active Client returns next', async () => {
|
||||||
const { ctx } = await setup(true)
|
const { ctx } = await setup(true)
|
||||||
const source = new RemoteEventSourceProbe()
|
const source = new RemoteEventSourceProbe()
|
||||||
const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
|
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
||||||
const agent = ctx.extend()
|
const agent = ctx.extend()
|
||||||
ctx.typert.contexts.registerHost('agent', {
|
ctx.typert.contexts.registerHost('agent', {
|
||||||
wire: 'agentId',
|
wire: 'agentId',
|
||||||
@@ -772,7 +773,7 @@ describe('Typert Remote streams', () => {
|
|||||||
it('delivers a pending waterfall to the first Client that connects', async () => {
|
it('delivers a pending waterfall to the first Client that connects', async () => {
|
||||||
const { ctx } = await setup(true)
|
const { ctx } = await setup(true)
|
||||||
const source = new RemoteEventSourceProbe()
|
const source = new RemoteEventSourceProbe()
|
||||||
const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
|
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
||||||
const agent = ctx.extend()
|
const agent = ctx.extend()
|
||||||
ctx.typert.contexts.registerHost('agent', {
|
ctx.typert.contexts.registerHost('agent', {
|
||||||
wire: 'agentId',
|
wire: 'agentId',
|
||||||
@@ -805,7 +806,7 @@ describe('Typert Remote streams', () => {
|
|||||||
it('replays a pending event id to a replacement Client generation', async () => {
|
it('replays a pending event id to a replacement Client generation', async () => {
|
||||||
const { ctx } = await setup(true)
|
const { ctx } = await setup(true)
|
||||||
const source = new RemoteEventSourceProbe()
|
const source = new RemoteEventSourceProbe()
|
||||||
const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
|
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
||||||
const agent = ctx.extend()
|
const agent = ctx.extend()
|
||||||
ctx.typert.contexts.registerHost('agent', {
|
ctx.typert.contexts.registerHost('agent', {
|
||||||
wire: 'agentId',
|
wire: 'agentId',
|
||||||
@@ -839,7 +840,7 @@ describe('Typert Remote streams', () => {
|
|||||||
it('cancels pending deliveries when the Host signal or Context ends', async () => {
|
it('cancels pending deliveries when the Host signal or Context ends', async () => {
|
||||||
const { ctx } = await setup(true)
|
const { ctx } = await setup(true)
|
||||||
const source = new RemoteEventSourceProbe()
|
const source = new RemoteEventSourceProbe()
|
||||||
const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
|
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
||||||
const signalAgent = ctx.extend()
|
const signalAgent = ctx.extend()
|
||||||
const contextFiber = ctx.plugin(() => {})
|
const contextFiber = ctx.plugin(() => {})
|
||||||
await contextFiber
|
await contextFiber
|
||||||
@@ -929,7 +930,7 @@ describe('Typert Remote streams', () => {
|
|||||||
const unregister = ctx.typertGateway.registerRemoteEvents(() => {
|
const unregister = ctx.typertGateway.registerRemoteEvents(() => {
|
||||||
sourceCalls += 1
|
sourceCalls += 1
|
||||||
return (async function *(): AsyncIterable<never> {})()
|
return (async function *(): AsyncIterable<never> {})()
|
||||||
})
|
}, REMOTE_HOST)
|
||||||
const invalidPayloads: readonly unknown[] = [
|
const invalidPayloads: readonly unknown[] = [
|
||||||
null,
|
null,
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -483,7 +483,7 @@ class RemoteEventCarrier {
|
|||||||
const abort = (): void => { connection.wake?.() }
|
const abort = (): void => { connection.wake?.() }
|
||||||
signal.addEventListener('abort', abort, { once: true })
|
signal.addEventListener('abort', abort, { once: true })
|
||||||
try {
|
try {
|
||||||
yield { type: 'ready', clientId }
|
yield { type: 'ready', clientId, host: { home: '/home/fixture' } }
|
||||||
while (!signal.aborted) {
|
while (!signal.aborted) {
|
||||||
while (connection.items.length > 0) {
|
while (connection.items.length > 0) {
|
||||||
const item = connection.items.shift() as EventStreamItem
|
const item = connection.items.shift() as EventStreamItem
|
||||||
@@ -1769,7 +1769,7 @@ describe('Client Typert API', () => {
|
|||||||
socket.receive({
|
socket.receive({
|
||||||
type: 'item',
|
type: 'item',
|
||||||
streamId: opened.streamId,
|
streamId: opened.streamId,
|
||||||
value: { type: 'ready', clientId: 'browser-client' },
|
value: { type: 'ready', clientId: 'browser-client', host: { home: '/home/browser' } },
|
||||||
})
|
})
|
||||||
await run.ready
|
await run.ready
|
||||||
socket.receive({
|
socket.receive({
|
||||||
@@ -1825,6 +1825,7 @@ describe('Client Typert API', () => {
|
|||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(connection.hostDescription.getSnapshot()?.home).toBe('/home/fixture')
|
expect(connection.hostDescription.getSnapshot()?.home).toBe('/home/fixture')
|
||||||
|
expect(connection.generation.getSnapshot()?.host.home).toBe('/home/fixture')
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
@@ -1841,6 +1842,10 @@ describe('Client Typert API', () => {
|
|||||||
{ type: 'ready' },
|
{ type: 'ready' },
|
||||||
{ type: 'ready', clientId: '' },
|
{ type: 'ready', clientId: '' },
|
||||||
{ type: 'ready', clientId: 'client', extra: true },
|
{ type: 'ready', clientId: 'client', extra: true },
|
||||||
|
{ type: 'ready', clientId: 'client', host: null },
|
||||||
|
{ type: 'ready', clientId: 'client', host: {} },
|
||||||
|
{ type: 'ready', clientId: 'client', host: { home: 1 } },
|
||||||
|
{ type: 'ready', clientId: 'client', host: { home: '/home', extra: true } },
|
||||||
{ type: 'emit', event: 'fixture/changed', args: ['too early'] },
|
{ type: 'emit', event: 'fixture/changed', args: ['too early'] },
|
||||||
])('rejects malformed forwarded-event readiness item %#', async (opening) => {
|
])('rejects malformed forwarded-event readiness item %#', async (opening) => {
|
||||||
const open: NonNullable<ConnectionHandle['rpc']['open']> = () => (async function *() {
|
const open: NonNullable<ConnectionHandle['rpc']['open']> = () => (async function *() {
|
||||||
|
|||||||
@@ -1084,11 +1084,14 @@ describe('TypertGatewayService', () => {
|
|||||||
if (signal.aborted) resolve()
|
if (signal.aborted) resolve()
|
||||||
else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||||
})
|
})
|
||||||
})())
|
})(), { home: '/home/fixture' })
|
||||||
const carrier = new AbortController()
|
const carrier = new AbortController()
|
||||||
const events = rawGatewayEventHarness(ctx).openRemoteEvents({ args: {} }, carrier.signal)
|
const events = rawGatewayEventHarness(ctx).openRemoteEvents({ args: {} }, carrier.signal)
|
||||||
const opening = await events.next()
|
const opening = await events.next()
|
||||||
expect(opening).toMatchObject({ done: false, value: { type: 'ready' } })
|
expect(opening).toMatchObject({
|
||||||
|
done: false,
|
||||||
|
value: { type: 'ready', host: { home: '/home/fixture' } },
|
||||||
|
})
|
||||||
if (opening.done) throw new Error('Remote event stream ended before ready')
|
if (opening.done) throw new Error('Remote event stream ended before ready')
|
||||||
const clientId: unknown = Reflect.get(opening.value as object, 'clientId')
|
const clientId: unknown = Reflect.get(opening.value as object, 'clientId')
|
||||||
if (typeof clientId !== 'string') throw new Error('Remote event stream omitted its Client id')
|
if (typeof clientId !== 'string') throw new Error('Remote event stream omitted its Client id')
|
||||||
|
|||||||
@@ -42,10 +42,8 @@ interface Generation {
|
|||||||
type PageSource = Page | Promise<Page> | ((signal: AbortSignal) => Promise<Page>)
|
type PageSource = Page | Promise<Page> | ((signal: AbortSignal) => Promise<Page>)
|
||||||
|
|
||||||
const AVAILABLE_CONNECTION = {
|
const AVAILABLE_CONNECTION = {
|
||||||
hostDescription: {
|
generation: {
|
||||||
getSnapshot: () => ({
|
getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }),
|
||||||
version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true,
|
|
||||||
}),
|
|
||||||
subscribe: () => () => {},
|
subscribe: () => () => {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** Host BFF entry and Loader shell for the Remote contribution assembly. */
|
/** Host BFF entry and Loader shell for the Remote contribution assembly. */
|
||||||
|
|
||||||
|
import { homedir } from 'node:os'
|
||||||
import type { Context } from '@deepseek-ai/cordis'
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
import type {
|
import type {
|
||||||
TypertRemoteEventDispatch,
|
TypertRemoteEventDispatch,
|
||||||
@@ -35,7 +36,7 @@ export const inject = ['typertGateway']
|
|||||||
/** Host plugin body registering this application's selected Cordis event source. */
|
/** Host plugin body registering this application's selected Cordis event source. */
|
||||||
export function apply(ctx: Context): void {
|
export function apply(ctx: Context): void {
|
||||||
ctx.effect(
|
ctx.effect(
|
||||||
() => ctx.typertGateway.registerRemoteEvents(remoteEventSource(ctx)),
|
() => ctx.typertGateway.registerRemoteEvents(remoteEventSource(ctx), { home: homedir() }),
|
||||||
'api-remotes: forwarded Cordis event source',
|
'api-remotes: forwarded Cordis event source',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Context } from '@deepseek-ai/cordis'
|
import { Context } from '@deepseek-ai/cordis'
|
||||||
import type { Fiber } from '@deepseek-ai/cordis'
|
import type { Fiber } from '@deepseek-ai/cordis'
|
||||||
import type {
|
import type {
|
||||||
|
RemoteEventHostInfo,
|
||||||
TypertRemoteEventInvocation,
|
TypertRemoteEventInvocation,
|
||||||
TypertRemoteEventSource,
|
TypertRemoteEventSource,
|
||||||
} from '@deepseek-ai/dsh-api-gateway'
|
} from '@deepseek-ai/dsh-api-gateway'
|
||||||
@@ -10,8 +11,12 @@ import { apply, inject } from '../src/index.ts'
|
|||||||
|
|
||||||
interface GatewayProbe {
|
interface GatewayProbe {
|
||||||
source: TypertRemoteEventSource | undefined
|
source: TypertRemoteEventSource | undefined
|
||||||
|
host: RemoteEventHostInfo | undefined
|
||||||
removals: number
|
removals: number
|
||||||
registerRemoteEvents(source: TypertRemoteEventSource): () => Promise<void>
|
registerRemoteEvents(
|
||||||
|
source: TypertRemoteEventSource,
|
||||||
|
host: RemoteEventHostInfo,
|
||||||
|
): () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setup(): Promise<{
|
async function setup(): Promise<{
|
||||||
@@ -22,12 +27,15 @@ async function setup(): Promise<{
|
|||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
const gateway: GatewayProbe = {
|
const gateway: GatewayProbe = {
|
||||||
source: undefined,
|
source: undefined,
|
||||||
|
host: undefined,
|
||||||
removals: 0,
|
removals: 0,
|
||||||
registerRemoteEvents(source) {
|
registerRemoteEvents(source, host) {
|
||||||
gateway.source = source
|
gateway.source = source
|
||||||
|
gateway.host = host
|
||||||
return async () => {
|
return async () => {
|
||||||
if (gateway.source !== source) return
|
if (gateway.source !== source) return
|
||||||
gateway.source = undefined
|
gateway.source = undefined
|
||||||
|
gateway.host = undefined
|
||||||
gateway.removals += 1
|
gateway.removals += 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -71,6 +79,14 @@ function invocationOf(value: unknown): TypertRemoteEventInvocation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Remote event Host source', () => {
|
describe('Remote event Host source', () => {
|
||||||
|
it('registers the Host home used by Client connection generations', async () => {
|
||||||
|
const { gateway, fiber } = await setup()
|
||||||
|
expect(gateway.host?.home).toBeTypeOf('string')
|
||||||
|
expect(gateway.host?.home.length).toBeGreaterThan(0)
|
||||||
|
await fiber.dispose()
|
||||||
|
expect(gateway.host).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('gives each Client stream an independent allowlisted event queue', async () => {
|
it('gives each Client stream an independent allowlisted event queue', async () => {
|
||||||
const { ctx, gateway, fiber } = await setup()
|
const { ctx, gateway, fiber } = await setup()
|
||||||
const firstAbort = new AbortController()
|
const firstAbort = new AbortController()
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export function apply(ctx: Context): void {
|
|||||||
})
|
})
|
||||||
control.start()
|
control.start()
|
||||||
ctx.on('connection/reset', () => { sessions.handleConnected() })
|
ctx.on('connection/reset', () => { sessions.handleConnected() })
|
||||||
if (connection.hostDescription.getSnapshot() !== undefined) sessions.handleConnected()
|
if (connection.generation.getSnapshot() !== undefined) sessions.handleConnected()
|
||||||
ctx.typert.contexts.registerClient('agent', {
|
ctx.typert.contexts.registerClient('agent', {
|
||||||
identity: candidate => sessions.scopeOf(candidate),
|
identity: candidate => sessions.scopeOf(candidate),
|
||||||
resolve: sessionId => sessions.resolveAgentScope(sessionId),
|
resolve: sessionId => sessions.resolveAgentScope(sessionId),
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ async function mount(initialHost?: HostDescription): Promise<Bench> {
|
|||||||
return () => { hostListeners.delete(listener) }
|
return () => { hostListeners.delete(listener) }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
generation: {
|
||||||
|
getSnapshot: () => host === undefined
|
||||||
|
? undefined
|
||||||
|
: { id: 1, host: { home: host.home } },
|
||||||
|
subscribe: (listener) => {
|
||||||
|
hostListeners.add(listener)
|
||||||
|
return () => { hostListeners.delete(listener) }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
generation: {
|
||||||
|
getSnapshot: () => host === undefined
|
||||||
|
? undefined
|
||||||
|
: { id: 1, host: { home: host.home } },
|
||||||
|
subscribe: (listener) => {
|
||||||
|
hostListeners.add(listener)
|
||||||
|
return () => { hostListeners.delete(listener) }
|
||||||
|
},
|
||||||
|
},
|
||||||
rpc: {
|
rpc: {
|
||||||
call: () => Promise.reject(new Error('unexpected generic RPC call')),
|
call: () => Promise.reject(new Error('unexpected generic RPC call')),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,10 +32,8 @@ import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
|
|||||||
import { historyRecordLastSeq } from '../src/client/sessions/history-records.ts'
|
import { historyRecordLastSeq } from '../src/client/sessions/history-records.ts'
|
||||||
|
|
||||||
const AVAILABLE_STREAM_CONNECTION = {
|
const AVAILABLE_STREAM_CONNECTION = {
|
||||||
hostDescription: {
|
generation: {
|
||||||
getSnapshot: () => ({
|
getSnapshot: () => ({ id: 1, host: { home: '/h' } }),
|
||||||
version: 'fixture', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true,
|
|
||||||
}),
|
|
||||||
subscribe: () => () => {},
|
subscribe: () => () => {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,8 @@ type SessionTransportRemote = Pick<SessionRemote, 'control' | 'follow' | 'page'>
|
|||||||
|
|
||||||
const ADDRESS: SessionAddress = { kind: 'session', sessionId: 'session-1' as never }
|
const ADDRESS: SessionAddress = { kind: 'session', sessionId: 'session-1' as never }
|
||||||
const AVAILABLE_CONNECTION = {
|
const AVAILABLE_CONNECTION = {
|
||||||
hostDescription: {
|
generation: {
|
||||||
getSnapshot: () => ({
|
getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }),
|
||||||
version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true,
|
|
||||||
}),
|
|
||||||
subscribe: () => () => {},
|
subscribe: () => () => {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,17 +36,15 @@ import type {
|
|||||||
} from '../src/types.ts'
|
} from '../src/types.ts'
|
||||||
|
|
||||||
const AVAILABLE_CONNECTION = {
|
const AVAILABLE_CONNECTION = {
|
||||||
hostDescription: {
|
generation: {
|
||||||
getSnapshot: () => ({
|
getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }),
|
||||||
version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true,
|
|
||||||
}),
|
|
||||||
subscribe: () => () => {},
|
subscribe: () => () => {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function workspaceClient(
|
function workspaceClient(
|
||||||
remote: WorkspaceRemote,
|
remote: WorkspaceRemote,
|
||||||
connection: Pick<ConnectionHandle, 'hostDescription'> = AVAILABLE_CONNECTION,
|
connection: Pick<ConnectionHandle, 'generation'> = AVAILABLE_CONNECTION,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
workspace: remote,
|
workspace: remote,
|
||||||
@@ -211,6 +209,7 @@ function provideClientServices(ctx: Context, remote: WorkspaceRemote): void {
|
|||||||
}),
|
}),
|
||||||
subscribe: () => () => {},
|
subscribe: () => () => {},
|
||||||
},
|
},
|
||||||
|
generation: AVAILABLE_CONNECTION.generation,
|
||||||
rpc: {
|
rpc: {
|
||||||
call: () => Promise.reject(new Error('unexpected generic RPC call')),
|
call: () => Promise.reject(new Error('unexpected generic RPC call')),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import type { HostDescription, IApiClient } from './api.ts'
|
import type { HostDescription, IApiClient } from './api.ts'
|
||||||
|
|
||||||
|
/** Stable Host facts delivered by one established Remote event generation. */
|
||||||
|
export interface ConnectionHostInfo {
|
||||||
|
/** Host account home used only to abbreviate displayed filesystem paths. */
|
||||||
|
readonly home: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One successfully established Host generation. */
|
||||||
|
export interface ConnectionGeneration {
|
||||||
|
/** Monotone generation number within this Client runtime. */
|
||||||
|
readonly id: number
|
||||||
|
/** Host facts carried by this generation's opening frame. */
|
||||||
|
readonly host: ConnectionHostInfo
|
||||||
|
}
|
||||||
|
|
||||||
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; these become the
|
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; these become the
|
||||||
* future `ctx.connection` plugin's Config). All fields optional; defaults below. */
|
* future `ctx.connection` plugin's Config). All fields optional; defaults below. */
|
||||||
export interface ConnectionConfig {
|
export interface ConnectionConfig {
|
||||||
@@ -39,7 +53,7 @@ export type ConnectionState = 'connected' | 'reconnecting'
|
|||||||
/** Connection-generation callbacks owned by API Gateway. */
|
/** Connection-generation callbacks owned by API Gateway. */
|
||||||
export interface ConnectionSinks {
|
export interface ConnectionSinks {
|
||||||
/** After the generation source is ready and host.describe succeeds, first connect included. */
|
/** After the generation source is ready and host.describe succeeds, first connect included. */
|
||||||
onConnected?: (description: HostDescription) => void
|
onConnected?: (description: HostDescription, host: ConnectionHostInfo) => void
|
||||||
/** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect
|
/** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect
|
||||||
* span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */
|
* span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */
|
||||||
onStateChange?: (state: ConnectionState) => void
|
onStateChange?: (state: ConnectionState) => void
|
||||||
@@ -55,7 +69,7 @@ export interface ConnectionSinks {
|
|||||||
*/
|
*/
|
||||||
export type ConnectionGenerationSource = (
|
export type ConnectionGenerationSource = (
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
ready: () => void,
|
ready: (host: ConnectionHostInfo) => void,
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,19 +131,20 @@ export class ConnectionController {
|
|||||||
this.current = ac
|
this.current = ac
|
||||||
|
|
||||||
let sourceReady = false
|
let sourceReady = false
|
||||||
let resolveReady!: () => void
|
let resolveReady!: (host: ConnectionHostInfo) => void
|
||||||
let rejectReady!: (error: Error) => void
|
let rejectReady!: (error: Error) => void
|
||||||
let rejectSourceLost!: (error: Error) => void
|
let rejectSourceLost!: (error: Error) => void
|
||||||
const ready = new Promise<void>((resolve, reject) => {
|
const ready = new Promise<ConnectionHostInfo>((resolve, reject) => {
|
||||||
resolveReady = resolve
|
resolveReady = resolve
|
||||||
rejectReady = reject
|
rejectReady = reject
|
||||||
})
|
})
|
||||||
const sourceLost = new Promise<never>((_resolve, reject) => {
|
const sourceLost = new Promise<never>((_resolve, reject) => {
|
||||||
rejectSourceLost = reject
|
rejectSourceLost = reject
|
||||||
})
|
})
|
||||||
const reportReady = (): void => {
|
const reportReady = (host: ConnectionHostInfo): void => {
|
||||||
|
if (sourceReady) return
|
||||||
sourceReady = true
|
sourceReady = true
|
||||||
resolveReady()
|
resolveReady(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
const failed = new Promise<void>((resolve) => {
|
const failed = new Promise<void>((resolve) => {
|
||||||
@@ -161,7 +176,7 @@ export class ConnectionController {
|
|||||||
// The source reports ready only after its incremental listeners exist;
|
// The source reports ready only after its incremental listeners exist;
|
||||||
// describe may complete in parallel, but consumers see neither result
|
// describe may complete in parallel, but consumers see neither result
|
||||||
// until both sides of the baseline-plus-increment handshake are ready.
|
// until both sides of the baseline-plus-increment handshake are ready.
|
||||||
const [description] = await Promise.race([
|
const [description, host] = await Promise.race([
|
||||||
Promise.all([
|
Promise.all([
|
||||||
this.api.host.describe({}, ac.signal),
|
this.api.host.describe({}, ac.signal),
|
||||||
waitForReady(ready, this.config.generationReadyTimeoutMs, ac.signal),
|
waitForReady(ready, this.config.generationReadyTimeoutMs, ac.signal),
|
||||||
@@ -178,7 +193,7 @@ export class ConnectionController {
|
|||||||
// A state sink may synchronously stop this controller. Do not publish
|
// A state sink may synchronously stop this controller. Do not publish
|
||||||
// a description for a generation that no longer exists afterward.
|
// a description for a generation that no longer exists afterward.
|
||||||
if (this.isGenerationActive(ac)) {
|
if (this.isGenerationActive(ac)) {
|
||||||
this.callSink(() => { this.sinks.onConnected?.(descriptionResult.value) })
|
this.callSink(() => { this.sinks.onConnected?.(descriptionResult.value, host) })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Transport failure: treat as generation failure, fall through to the shared backoff.
|
// Transport failure: treat as generation failure, fall through to the shared backoff.
|
||||||
@@ -213,28 +228,28 @@ export class ConnectionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Await source readiness without letting a stalled carrier wedge startup forever. */
|
/** Await source readiness without letting a stalled carrier wedge startup forever. */
|
||||||
function waitForReady(ready: Promise<void>, timeoutMs: number, signal: AbortSignal): Promise<void> {
|
function waitForReady<T>(ready: Promise<T>, timeoutMs: number, signal: AbortSignal): Promise<T> {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
let settled = false
|
let settled = false
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
finish(new Error(`connection generation was not ready within ${String(timeoutMs)}ms`))
|
finish({ error: new Error(`connection generation was not ready within ${String(timeoutMs)}ms`) })
|
||||||
}, timeoutMs)
|
}, timeoutMs)
|
||||||
const aborted = (): void => {
|
const aborted = (): void => {
|
||||||
finish(new Error('connection generation aborted', { cause: signal.reason }))
|
finish({ error: new Error('connection generation aborted', { cause: signal.reason }) })
|
||||||
}
|
}
|
||||||
const finish = (error?: Error): void => {
|
const finish = (outcome: { readonly value: T } | { readonly error: Error }): void => {
|
||||||
if (settled) return
|
if (settled) return
|
||||||
settled = true
|
settled = true
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
signal.removeEventListener('abort', aborted)
|
signal.removeEventListener('abort', aborted)
|
||||||
if (error === undefined) resolve()
|
if ('error' in outcome) reject(outcome.error)
|
||||||
else reject(error)
|
else resolve(outcome.value)
|
||||||
}
|
}
|
||||||
signal.addEventListener('abort', aborted, { once: true })
|
signal.addEventListener('abort', aborted, { once: true })
|
||||||
void ready.then(
|
void ready.then(
|
||||||
() => { finish() },
|
(value) => { finish({ value }) },
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
finish(error as Error)
|
finish({ error: error as Error })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ interface FixtureRemoteEventResult {
|
|||||||
interface FixtureRemoteEventReadyFrame {
|
interface FixtureRemoteEventReadyFrame {
|
||||||
readonly type: 'ready'
|
readonly type: 'ready'
|
||||||
readonly clientId: string
|
readonly clientId: string
|
||||||
|
readonly host: { readonly home: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FixtureProjectionFrame {
|
interface FixtureProjectionFrame {
|
||||||
@@ -3161,7 +3162,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
|||||||
if (gamma !== undefined) setRunning(gamma.sessionId, !gamma.running)
|
if (gamma !== undefined) setRunning(gamma.sessionId, !gamma.running)
|
||||||
}, 5000)
|
}, 5000)
|
||||||
try {
|
try {
|
||||||
yield { type: 'ready', clientId }
|
yield { type: 'ready', clientId, host: { home: FIXTURE_HOME } }
|
||||||
if (approvalPending) yield approvalInvocation()
|
if (approvalPending) yield approvalInvocation()
|
||||||
if (questionPending) yield questionInvocation()
|
if (questionPending) yield questionInvocation()
|
||||||
yield* conn.drain(signal)
|
yield* conn.drain(signal)
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import type { HostDescription, IApiClient } from './api.ts'
|
|||||||
import {
|
import {
|
||||||
ConnectionController,
|
ConnectionController,
|
||||||
type ConnectionConfig,
|
type ConnectionConfig,
|
||||||
|
type ConnectionGeneration,
|
||||||
type ConnectionGenerationSource,
|
type ConnectionGenerationSource,
|
||||||
type ConnectionSinks,
|
type ConnectionSinks,
|
||||||
type ConnectionState,
|
|
||||||
} from './connection.ts'
|
} from './connection.ts'
|
||||||
import { FixtureApiClient } from './fixture.ts'
|
import { FixtureApiClient } from './fixture.ts'
|
||||||
import { WebApiClient } from './web-api-client.ts'
|
import { WebApiClient } from './web-api-client.ts'
|
||||||
@@ -45,7 +45,14 @@ export {
|
|||||||
|
|
||||||
// Connection loop types are public through ConnectionHandle.start; the
|
// Connection loop types are public through ConnectionHandle.start; the
|
||||||
// controller remains package-internal.
|
// controller remains package-internal.
|
||||||
export type { ConnectionConfig, ConnectionGenerationSource, ConnectionSinks, ConnectionState }
|
export type {
|
||||||
|
ConnectionConfig,
|
||||||
|
ConnectionGeneration,
|
||||||
|
ConnectionGenerationSource,
|
||||||
|
ConnectionHostInfo,
|
||||||
|
ConnectionSinks,
|
||||||
|
ConnectionState,
|
||||||
|
} from './connection.ts'
|
||||||
export type {
|
export type {
|
||||||
ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult,
|
ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult,
|
||||||
} from '../rpc.ts'
|
} from '../rpc.ts'
|
||||||
@@ -59,6 +66,14 @@ export interface HostDescriptionSource {
|
|||||||
subscribe(listener: () => void): () => void
|
subscribe(listener: () => void): () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Observable identity and Host facts for the active connection generation. */
|
||||||
|
export interface ConnectionGenerationState {
|
||||||
|
/** Active generation, or undefined before readiness and while reconnecting. */
|
||||||
|
getSnapshot(): ConnectionGeneration | undefined
|
||||||
|
/** Subscribe to generation establishment, replacement, and loss. */
|
||||||
|
subscribe(listener: () => void): () => void
|
||||||
|
}
|
||||||
|
|
||||||
/** Required services (none — this is the wire root). */
|
/** Required services (none — this is the wire root). */
|
||||||
export const inject: string[] = []
|
export const inject: string[] = []
|
||||||
|
|
||||||
@@ -113,6 +128,8 @@ export interface ConnectionHandle {
|
|||||||
readonly isLoopback: boolean
|
readonly isLoopback: boolean
|
||||||
/** Generation-scoped Host facts, including the account home and native path-open capability. */
|
/** Generation-scoped Host facts, including the account home and native path-open capability. */
|
||||||
readonly hostDescription: HostDescriptionSource
|
readonly hostDescription: HostDescriptionSource
|
||||||
|
/** Current Remote event generation and the Host facts carried by its opening frame. */
|
||||||
|
readonly generation: ConnectionGenerationState
|
||||||
/** Generic logical RPC channels over the same Connection transport. */
|
/** Generic logical RPC channels over the same Connection transport. */
|
||||||
readonly rpc: ClientConnectionRpc
|
readonly rpc: ClientConnectionRpc
|
||||||
/**
|
/**
|
||||||
@@ -151,6 +168,9 @@ export function apply(ctx: Context): void {
|
|||||||
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream)
|
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream)
|
||||||
let generationSource: ConnectionGenerationSource | undefined
|
let generationSource: ConnectionGenerationSource | undefined
|
||||||
let owner: ConnectionOwner | undefined
|
let owner: ConnectionOwner | undefined
|
||||||
|
let generationId = 0
|
||||||
|
let generation: ConnectionGeneration | undefined
|
||||||
|
const generationListeners = new Set<() => void>()
|
||||||
let description: HostDescription | undefined
|
let description: HostDescription | undefined
|
||||||
const descriptionListeners = new Set<() => void>()
|
const descriptionListeners = new Set<() => void>()
|
||||||
const publishDescription = (next: HostDescription | undefined): void => {
|
const publishDescription = (next: HostDescription | undefined): void => {
|
||||||
@@ -164,10 +184,22 @@ export function apply(ctx: Context): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const publishGeneration = (next: ConnectionGeneration | undefined): void => {
|
||||||
|
if (Object.is(generation, next)) return
|
||||||
|
generation = next
|
||||||
|
for (const listener of [...generationListeners]) {
|
||||||
|
try {
|
||||||
|
listener()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[connection] generation listener threw:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const releaseOwner = (current: ConnectionOwner): void => {
|
const releaseOwner = (current: ConnectionOwner): void => {
|
||||||
if (owner !== current) return
|
if (owner !== current) return
|
||||||
owner = undefined
|
owner = undefined
|
||||||
current.controller.stop()
|
current.controller.stop()
|
||||||
|
publishGeneration(undefined)
|
||||||
publishDescription(undefined)
|
publishDescription(undefined)
|
||||||
}
|
}
|
||||||
const handle: ConnectionHandle = {
|
const handle: ConnectionHandle = {
|
||||||
@@ -180,6 +212,13 @@ export function apply(ctx: Context): void {
|
|||||||
return () => { descriptionListeners.delete(listener) }
|
return () => { descriptionListeners.delete(listener) }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
generation: {
|
||||||
|
getSnapshot: () => generation,
|
||||||
|
subscribe: (listener) => {
|
||||||
|
generationListeners.add(listener)
|
||||||
|
return () => { generationListeners.delete(listener) }
|
||||||
|
},
|
||||||
|
},
|
||||||
rpc,
|
rpc,
|
||||||
registerGenerationSource(source) {
|
registerGenerationSource(source) {
|
||||||
if (generationSource !== undefined) {
|
if (generationSource !== undefined) {
|
||||||
@@ -201,17 +240,23 @@ export function apply(ctx: Context): void {
|
|||||||
const ownsGeneration = (): boolean => owner?.token === token
|
const ownsGeneration = (): boolean => owner?.token === token
|
||||||
const controller = new ConnectionController(api, source, {
|
const controller = new ConnectionController(api, source, {
|
||||||
...sinks,
|
...sinks,
|
||||||
onConnected: (next) => {
|
onConnected: (next, host) => {
|
||||||
|
const nextGeneration = { id: ++generationId, host }
|
||||||
|
publishGeneration(nextGeneration)
|
||||||
|
if (!ownsGeneration() || !Object.is(generation, nextGeneration)) return
|
||||||
publishDescription(next)
|
publishDescription(next)
|
||||||
// A description subscriber may synchronously stop the loop. In that
|
// A description subscriber may synchronously stop the loop. In that
|
||||||
// case publishDescription(undefined) has already retracted this
|
// case publishDescription(undefined) has already retracted this
|
||||||
// generation, so do not leak its stale connected notification to
|
// generation, so do not leak its stale connected notification to
|
||||||
// the consumer sink afterward.
|
// the consumer sink afterward.
|
||||||
if (!ownsGeneration() || !Object.is(description, next)) return
|
if (!ownsGeneration() || !Object.is(description, next)) return
|
||||||
sinks.onConnected?.(next)
|
sinks.onConnected?.(next, host)
|
||||||
},
|
},
|
||||||
onStateChange: (state) => {
|
onStateChange: (state) => {
|
||||||
if (state === 'reconnecting') publishDescription(undefined)
|
if (state === 'reconnecting') {
|
||||||
|
publishGeneration(undefined)
|
||||||
|
publishDescription(undefined)
|
||||||
|
}
|
||||||
if (!ownsGeneration()) return
|
if (!ownsGeneration()) return
|
||||||
sinks.onStateChange?.(state)
|
sinks.onStateChange?.(state)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class GenerationProbe {
|
|||||||
}
|
}
|
||||||
this.active.add(finish)
|
this.active.add(finish)
|
||||||
signal.addEventListener('abort', finish, { once: true })
|
signal.addEventListener('abort', finish, { once: true })
|
||||||
ready()
|
ready({ home: '/h' })
|
||||||
if (signal.aborted) finish()
|
if (signal.aborted) finish()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ describe('connection lifecycle', () => {
|
|||||||
const controller = new ConnectionController(api, (signal, ready) => {
|
const controller = new ConnectionController(api, (signal, ready) => {
|
||||||
sourceCalls++
|
sourceCalls++
|
||||||
if (sourceCalls === 1) return fail()
|
if (sourceCalls === 1) return fail()
|
||||||
ready()
|
ready({ home: '/h' })
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -95,7 +95,10 @@ export class FakeApiClient implements IApiClient {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
private async openGeneration(signal: AbortSignal, onOpen: () => void): Promise<void> {
|
private async openGeneration(
|
||||||
|
signal: AbortSignal,
|
||||||
|
onOpen: (host: { readonly home: string }) => void,
|
||||||
|
): Promise<void> {
|
||||||
const inbox: StreamItem[] = []
|
const inbox: StreamItem[] = []
|
||||||
let wake: (() => void) | null = null
|
let wake: (() => void) | null = null
|
||||||
const conn: StreamConn = {
|
const conn: StreamConn = {
|
||||||
@@ -105,8 +108,9 @@ export class FakeApiClient implements IApiClient {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
this.generationConns.push(conn)
|
this.generationConns.push(conn)
|
||||||
if (this.holdGenerationReady) this.heldOpens.push(onOpen)
|
const ready = (): void => { onOpen({ home: '/h' }) }
|
||||||
else if (!this.suppressGenerationReady) onOpen()
|
if (this.holdGenerationReady) this.heldOpens.push(ready)
|
||||||
|
else if (!this.suppressGenerationReady) ready()
|
||||||
try {
|
try {
|
||||||
while (!signal.aborted) {
|
while (!signal.aborted) {
|
||||||
while (inbox.length > 0) {
|
while (inbox.length > 0) {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Context } from '@deepseek-ai/cordis'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
apply,
|
||||||
|
type ConnectionGenerationSource,
|
||||||
|
type ConnectionHandle,
|
||||||
|
} from '../src/client/index.ts'
|
||||||
|
|
||||||
|
type BrowserGlobal = {
|
||||||
|
location?: { hostname: string; search: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const contexts = new Set<Context>()
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
delete (globalThis as BrowserGlobal).location
|
||||||
|
await Promise.all([...contexts].map(async ctx => ctx.fiber.dispose()))
|
||||||
|
contexts.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function mount(): Promise<ConnectionHandle> {
|
||||||
|
;(globalThis as BrowserGlobal).location = { hostname: 'localhost', search: '?fixture' }
|
||||||
|
const ctx = new Context()
|
||||||
|
contexts.add(ctx)
|
||||||
|
await ctx.plugin({ apply, inject: [] })
|
||||||
|
const connection = ctx.get('connection') as ConnectionHandle | undefined
|
||||||
|
if (connection === undefined) throw new Error('fixture did not provide Connection')
|
||||||
|
return connection
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Connection generation facts', () => {
|
||||||
|
it('publishes ready-frame Host facts and retracts them when the loop stops', async () => {
|
||||||
|
const connection = await mount()
|
||||||
|
const source: ConnectionGenerationSource = (signal, ready) => {
|
||||||
|
ready({ home: '/home/from-ready' })
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
if (signal.aborted) resolve()
|
||||||
|
else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
connection.registerGenerationSource(source)
|
||||||
|
const seen: Array<string | undefined> = []
|
||||||
|
const stopListening = connection.generation.subscribe(() => {
|
||||||
|
seen.push(connection.generation.getSnapshot()?.host.home)
|
||||||
|
})
|
||||||
|
const loop = connection.start({}, {
|
||||||
|
backoffBaseMs: 1,
|
||||||
|
backoffFactor: 1,
|
||||||
|
backoffMaxMs: 1,
|
||||||
|
generationReadyTimeoutMs: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(connection.generation.getSnapshot()).toEqual({
|
||||||
|
id: 1,
|
||||||
|
host: { home: '/home/from-ready' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
loop.stop()
|
||||||
|
expect(connection.generation.getSnapshot()).toBeUndefined()
|
||||||
|
expect(seen).toEqual(['/home/from-ready', undefined])
|
||||||
|
stopListening()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user