Merge remote-tracking branch 'origin/master' into dshw/pr-2250
This commit is contained in:
422 files changed
+15607
-1120
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
|
||||
import type { HostDescription, IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
|
||||
|
||||
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; these become the
|
||||
* future `ctx.connection` plugin's Config). All fields optional; defaults below. */
|
||||
@@ -45,7 +45,7 @@ export interface ConnectionSinks {
|
||||
onMuxEnvelope?: (envelope: RpcRequest<MuxFrame>) => void
|
||||
onHostEnvelope?: (envelope: RpcRequest<HostFrame>) => void
|
||||
/** After each connection generation is established (both streams open + describe succeeded), first connect included. */
|
||||
onConnected?: () => void
|
||||
onConnected?: (description: HostDescription) => void
|
||||
/** 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. */
|
||||
onStateChange?: (state: ConnectionState) => void
|
||||
@@ -99,6 +99,11 @@ export class ConnectionController {
|
||||
return this.running
|
||||
}
|
||||
|
||||
/** Re-read both mutable liveness guards after a potentially reentrant sink. */
|
||||
private isGenerationActive(controller: AbortController): boolean {
|
||||
return this.isRunning() && !controller.signal.aborted
|
||||
}
|
||||
|
||||
private async loop(): Promise<void> {
|
||||
while (this.running) {
|
||||
const gen = ++this.generation
|
||||
@@ -143,7 +148,11 @@ export class ConnectionController {
|
||||
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
|
||||
this.attempt = 0
|
||||
this.emitState('connected')
|
||||
this.callSink(this.sinks.onConnected)
|
||||
// A state sink may synchronously stop this controller. Do not publish
|
||||
// a description for a generation that no longer exists afterward.
|
||||
if (this.isGenerationActive(ac)) {
|
||||
this.callSink(() => { this.sinks.onConnected?.(descriptionResult.value) })
|
||||
}
|
||||
} catch {
|
||||
// Transport failure: treat as generation failure, fall through to the shared backoff.
|
||||
if (!ac.signal.aborted) ac.abort()
|
||||
@@ -183,8 +192,7 @@ export class ConnectionController {
|
||||
}
|
||||
|
||||
/** Sink exception isolation: a business-layer throw is logged only, never affecting pump or reconnect semantics. */
|
||||
private callSink(fn: (() => void) | undefined): void {
|
||||
if (fn === undefined) return
|
||||
private callSink(fn: () => void): void {
|
||||
try {
|
||||
fn()
|
||||
} catch (error) {
|
||||
|
||||
@@ -2305,7 +2305,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
interrupt: request => Promise.resolve(ok(request, { accepted: true as const })),
|
||||
},
|
||||
host: {
|
||||
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
|
||||
describe: request => ok(request, {
|
||||
version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, canOpenPath: true,
|
||||
}),
|
||||
// Deterministic native pick: the keyless lanes drive the full
|
||||
// pick-then-adopt path without an OS chooser (design-mock content,
|
||||
// same tree the browse primitives serve).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* controller with its sinks.
|
||||
*/
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { IApiClient } from './api.ts'
|
||||
import type { HostDescription, IApiClient } from './api.ts'
|
||||
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
|
||||
import { FixtureApiClient } from './fixture.ts'
|
||||
import { WebApiClient } from './web-api-client.ts'
|
||||
@@ -41,6 +41,13 @@ export {
|
||||
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
|
||||
export type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
/** Observable Host description published by each completed connection handshake. */
|
||||
export interface HostDescriptionSource {
|
||||
/** Latest connected-generation description; absent before connect and while reconnecting. */
|
||||
getSnapshot(): HostDescription | undefined
|
||||
/** Subscribe to description replacement and connection loss. */
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
|
||||
/** Required services (none — this is the wire root). */
|
||||
export const inject: string[] = []
|
||||
@@ -55,6 +62,8 @@ export interface ConnectionHandle {
|
||||
readonly api: IApiClient
|
||||
/** Whether the current page authority is loopback; non-browser contexts default to true. */
|
||||
readonly isLoopback: boolean
|
||||
/** Generation-scoped Host facts, including native path-open capability. */
|
||||
readonly hostDescription: HostDescriptionSource
|
||||
/** Generic logical RPC channels over the same Connection transport. */
|
||||
readonly rpc: ClientConnectionRpc
|
||||
/**
|
||||
@@ -79,16 +88,56 @@ export function apply(ctx: Context): void {
|
||||
const api: IApiClient = fixtureClient ?? new WebApiClient()
|
||||
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc()
|
||||
let started = false
|
||||
let description: HostDescription | undefined
|
||||
const descriptionListeners = new Set<() => void>()
|
||||
const publishDescription = (next: HostDescription | undefined): void => {
|
||||
if (Object.is(description, next)) return
|
||||
description = next
|
||||
for (const listener of [...descriptionListeners]) {
|
||||
try {
|
||||
listener()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] host-description listener threw:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
|
||||
hostDescription: {
|
||||
getSnapshot: () => description,
|
||||
subscribe: (listener) => {
|
||||
descriptionListeners.add(listener)
|
||||
return () => { descriptionListeners.delete(listener) }
|
||||
},
|
||||
},
|
||||
rpc,
|
||||
start(sinks, config) {
|
||||
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
|
||||
started = true
|
||||
const controller = new ConnectionController(api, sinks, config ?? {})
|
||||
const controller = new ConnectionController(api, {
|
||||
...sinks,
|
||||
onConnected: (next) => {
|
||||
publishDescription(next)
|
||||
// A description subscriber may synchronously stop the loop. In that
|
||||
// case publishDescription(undefined) has already retracted this
|
||||
// generation, so do not leak its stale connected notification to
|
||||
// the consumer sink afterward.
|
||||
if (!Object.is(description, next)) return
|
||||
sinks.onConnected?.(next)
|
||||
},
|
||||
onStateChange: (state) => {
|
||||
if (state === 'reconnecting') publishDescription(undefined)
|
||||
sinks.onStateChange?.(state)
|
||||
},
|
||||
}, config ?? {})
|
||||
controller.start()
|
||||
return { stop: () => { controller.stop() } }
|
||||
return {
|
||||
stop: () => {
|
||||
controller.stop()
|
||||
publishDescription(undefined)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
ctx.provide('connection', handle)
|
||||
|
||||
Reference in New Issue
Block a user