feat(typert): propagate Remote cancellation

This commit is contained in:
imccyu
2026-08-06 18:13:15 +08:00
parent 9b63d72c94
commit 22bec5e63f
28 changed files with 280 additions and 66 deletions

View File

@@ -2097,7 +2097,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'InvocationDescriptor',
declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypeRTCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly result: TypeRTCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}',
declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypeRTCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly cancellation?: {\n readonly parameter: \'signal\';\n };\n readonly result: TypeRTCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}',
},
{
name: 'InvocationParameterDescriptor',
@@ -2109,7 +2109,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'InvokeRemoteRequest',
declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly<Record<string, unknown>>;\n}',
declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly<Record<string, unknown>>;\n readonly signal?: AbortSignal;\n}',
},
{
name: 'JsonSchemaNode',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/api-gateway/README.md
README.md: cc80bb19fec15414aa0857154a8a36fb4f642672
README.zh.md: 6febb1cfe4fc7fa4c5a17e1e4f6a21e2ee03e295
README.md: 9cb6e7e1c0a23789ab4ab2c999b5a6c2d4cd32f9
README.zh.md: 609580ceb77649ba8df6103093a72092c9ccc8a1

View File

@@ -12,11 +12,13 @@ Strict mode reads generated invocation descriptors from `ctx.typert.local`. Look
The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs.
A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type.
## Client service: `ClientApi` (ctx key: `api`)
`ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable.
Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject.
Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject.
Generated declaration merges provide the TypeScript API. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy.

View File

@@ -12,11 +12,13 @@
Connection 可用时Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridgehandler 将已认领 endpoint 分发给 Gateway未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。
支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数Connection 将它提供给 GatewayGateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。
## Client 服务:`ClientApi`ctx key`api`
`ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。
每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。
每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。
生成的声明合并提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。

View File

@@ -223,9 +223,13 @@ class ClientApiService extends Service implements ClientApi {
const endpoint = endpointOf(descriptor)
if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`)
const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1)
if (values.length !== expected) {
const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1
if (values.length !== expected && !hasCallerSignal) {
const contract = descriptor.cancellation === undefined
? `${String(expected)} argument(s)`
: `${String(expected)} business argument(s) plus an optional AbortSignal`
throw new Error(
`client api: ${endpoint} expected ${String(expected)} argument(s), got ${String(values.length)}`,
`client api: ${endpoint} expected ${contract}, got ${String(values.length)}`,
)
}
const args: Record<string, unknown> = {}
@@ -248,7 +252,11 @@ class ClientApiService extends Service implements ClientApi {
})
const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`)
const result = await connection.rpc.call('/api', endpoint, { args }, token.abort.signal)
const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined
const signal = callerSignal === undefined
? token.abort.signal
: AbortSignal.any([token.abort.signal, callerSignal])
const result = await connection.rpc.call('/api', endpoint, { args }, signal)
if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`)
if (!result.ok) throw remoteFailure(endpoint, result.error)
return parse(descriptor.result, result.value, endpoint, 'result')

View File

@@ -36,6 +36,7 @@ interface ResolvedBinding {
}
type ConnectionRpcResult = Awaited<ReturnType<ConnectionRpcHandler>>
const NEVER_ABORTED_SIGNAL = new AbortController().signal
/** Dispatch failure produced outside the invoked business method. */
export class TypertGatewayError extends Error {
@@ -129,6 +130,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
}
validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint)
const args = descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint))
if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL)
const implementation = descriptor.implementation ?? descriptor.method
const method = Reflect.get(receiver, implementation) as unknown
if (typeof method !== 'function') {
@@ -146,13 +148,12 @@ export class TypertGatewayService extends Service implements TypertGateway {
private async dispatchRpc(
endpoint: string,
payload: unknown,
_signal: AbortSignal,
signal: AbortSignal,
): Promise<ConnectionRpcResult> {
// Remote methods have no cancellation parameter yet, so disconnects do not cancel business work.
return this.invokeRpc(endpoint, payload)
return this.invokeRpc(endpoint, payload, signal)
}
private async invokeRpc(endpoint: string, payload: unknown): Promise<ConnectionRpcResult> {
private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise<ConnectionRpcResult> {
try {
const segments = endpoint.split('/')
if (segments.length !== 2 || segments[0] === '' || segments[1] === '') {
@@ -171,6 +172,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
namespace,
method,
args: payload.args,
signal,
})
return { ok: true, value }
} catch (error) {
@@ -226,9 +228,22 @@ export class TypertGatewayService extends Service implements TypertGateway {
endpoint: string,
): InvocationDescriptor {
const names = methodParameterNames(binding.service, marker.method, endpoint)
const signalIndex = names.indexOf('signal')
if (signalIndex >= 0 && signalIndex !== names.length - 1) {
throw new TypertGatewayError(
'signature-invalid',
endpoint,
'SRC cancellation parameter signal must be the final parameter',
{ field: 'signal' },
)
}
const cancellation = signalIndex >= 0
? { parameter: 'signal' as const }
: undefined
const businessNames = cancellation === undefined ? names : names.slice(0, -1)
const parameters: InvocationParameterDescriptor[] = []
const wires = new Set<string>()
for (const name of names) {
for (const name of businessNames) {
const matches = this.ctx.typert.lookups.definitions()
.filter(definition => definition.parameter === name)
if (matches.length > 1) {
@@ -295,6 +310,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
...(marker.method === method ? {} : { implementation: marker.method }),
invocation: receiver,
parameters,
...(cancellation === undefined ? {} : { cancellation }),
result: { mode: 'src-json' },
}
}

View File

@@ -11,6 +11,8 @@ export interface InvokeRemoteRequest {
readonly method: string
/** Named wire values; fields must exactly match the descriptor. */
readonly args: Readonly<Record<string, unknown>>
/** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */
readonly signal?: AbortSignal
}
/** Stable infrastructure and boundary failures emitted before or after business execution. */

View File

@@ -17,11 +17,18 @@ declare module '@deepseek-ai/dsh-type-meta' {
}
interface TypeRTRemoteMap {
'goals/create': (agentId: string, request: { readonly objective: string }) => Promise<{ readonly ref: string }>
'goals/create': (
agentId: string,
request: { readonly objective: string },
signal?: AbortSignal,
) => Promise<{ readonly ref: string }>
}
interface TypeRTRemoteContextMap {
'fixture:goals/create': (request: { readonly objective: string }) => Promise<{ readonly ref: string }>
'fixture:goals/create': (
request: { readonly objective: string },
signal?: AbortSignal,
) => Promise<{ readonly ref: string }>
'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }>
}
@@ -58,6 +65,7 @@ function directDescriptor(): InvocationDescriptor {
source: 'json',
codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema },
}],
cancellation: { parameter: 'signal' },
result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema },
}
}
@@ -114,6 +122,19 @@ describe('Client TypeRT API', () => {
{ args: { agentId: 'agent-1', request: { objective: 'ship' } } },
expect.any(AbortSignal),
)
const callerAbort = new AbortController()
await expect(ctx.api.goals.create(
'agent-1',
{ objective: 'cancel me' },
callerAbort.signal,
)).resolves.toEqual({ ref: 'goal-1' })
const combinedSignal = call.mock.calls.at(-1)?.[3]
expect(combinedSignal).toBeInstanceOf(AbortSignal)
expect(combinedSignal).not.toBe(callerAbort.signal)
const cancellation = new Error('caller cancelled')
callerAbort.abort(cancellation)
expect(combinedSignal?.aborted).toBe(true)
expect(combinedSignal?.reason).toBe(cancellation)
await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } })
@@ -299,10 +320,18 @@ describe('Client TypeRT API', () => {
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
const ctx = await bench(call)
const descriptor = directDescriptor()
const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [descriptor] })
const dispose = ctx.api.mount({
package: '@fixture/goals',
descriptors: [descriptor, contextDescriptor()],
})
const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise<unknown>
const goals = (ctx as FixtureContext).goals
const rename = goals.rename as unknown as (...args: unknown[]) => Promise<unknown>
await expect(create('agent-1')).rejects.toThrow('expected 2 argument(s), got 1')
await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1')
await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra'))
.rejects.toThrow('got 4')
await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0')
await expect((ctx as FixtureContext).goals.create({ objective: 'ship' }))
.rejects.toThrow('no Client Context binder')

View File

@@ -45,6 +45,7 @@ const emptyModel: TypertContribution['model'] = {
class GoalService extends Service {
readonly typertGateway = bindTypeRTGateway(this, 'goals')
readonly calls: string[] = []
lastSignal: AbortSignal | undefined
nextResult: unknown = undefined
businessError: Error | undefined
@@ -53,8 +54,9 @@ class GoalService extends Service {
}
@Remote
create(agent: FixtureAgent, request: { readonly title: string }): unknown {
create(agent: FixtureAgent, request: { readonly title: string }, signal: AbortSignal): unknown {
this.calls.push('create')
this.lastSignal = signal
return {
agentId: agent.id,
title: request.title,
@@ -224,6 +226,19 @@ class RestParameterService extends Service {
}
}
class NonFinalSignalService extends Service {
readonly typertGateway = bindTypeRTGateway(this, 'nonFinalSignal', { namespace: 'invalid-signal' })
constructor(ctx: Context) {
super(ctx, 'nonFinalSignal')
}
@Remote
run(signal: AbortSignal, value: string): string {
return signal.aborted ? '' : value
}
}
class WrongBindingService extends Service {
readonly typertGateway = bindTypeRTGateway(this, 'notWrongBinding', { namespace: 'wrong-binding' })
@@ -334,13 +349,24 @@ describe('TypertGatewayService', () => {
registerAgentLookup(ctx, agent)
registerStrict(ctx, [createDescriptor()])
const caller = ctx.extend({ fixtureScope: 'direct-caller' })
const abort = new AbortController()
await expect(caller.typertGateway.invoke({
namespace: 'goals',
method: 'create',
args: { agentId: 'agent-1', request: { title: ' ship ' } },
signal: abort.signal,
})).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-caller' })
expect(service.calls).toEqual(['create'])
expect(service.lastSignal).toBe(abort.signal)
await expect(caller.typertGateway.invoke({
namespace: 'goals',
method: 'create',
args: { agentId: 'agent-1', request: { title: 'again' } },
})).resolves.toEqual({ agentId: 'agent-1', title: 'again', scope: 'direct-caller' })
expect(service.lastSignal).toBeInstanceOf(AbortSignal)
expect(service.lastSignal?.aborted).toBe(false)
})
it('resolves strict Remote Context identity without adding a business argument', async () => {
@@ -358,16 +384,19 @@ describe('TypertGatewayService', () => {
})
it('derives SRC direct lookup and JSON parameters from marker and parameter names', async () => {
const { ctx } = await setup()
const { ctx, service } = await setup()
const agent = { id: 'agent-1' }
registerAgentLookup(ctx, agent)
const caller = ctx.extend({ fixtureScope: 'direct-src' })
const abort = new AbortController()
await expect(caller.typertGateway.invoke({
namespace: 'goals',
method: 'create',
args: { agentId: 'agent-1', request: { title: 'ship' } },
signal: abort.signal,
})).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-src' })
expect(service.lastSignal).toBe(abort.signal)
})
it('does not downgrade an observed SRC lookup after its provider unloads', async () => {
@@ -605,6 +634,7 @@ describe('TypertGatewayService', () => {
{ plugin: DefaultParameterService, namespace: 'invalid-default', args: { value: 'x' } },
{ plugin: DestructuredParameterService, namespace: 'invalid-destructure', args: { value: { value: 'x' } } },
{ plugin: RestParameterService, namespace: 'invalid-rest', args: { values: ['x'] } },
{ plugin: NonFinalSignalService, namespace: 'invalid-signal', args: { value: 'x' } },
] as const
for (const testCase of cases) {
const ctx = await setupGateway()
@@ -874,7 +904,8 @@ describe('TypertGatewayService', () => {
expect(connection.matches?.('goals')).toBe(false)
expect(connection.matches?.('goals/missing')).toBe(false)
expect(connection.matches?.('legacy/list')).toBe(false)
const signal = new AbortController().signal
const abort = new AbortController()
const signal = abort.signal
const handler = connection.handler
if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor')
await expect(handler('goals/create', {
@@ -883,6 +914,10 @@ describe('TypertGatewayService', () => {
ok: true,
value: { agentId: 'agent-1', title: 'ship', scope: 'rpc-caller' },
})
const service = rawGoalService(ctx)
expect(service.lastSignal).toBe(signal)
abort.abort(new Error('client disconnected'))
expect(service.lastSignal?.aborted).toBe(true)
const invalid = await handler('goals/create', { invalid: true }, signal)
expect(invalid).toMatchObject({
ok: false,
@@ -904,7 +939,6 @@ describe('TypertGatewayService', () => {
expect(result.error.message).toContain('plain-object args field')
}
const service = rawGoalService(ctx)
service.businessError = 'non-error failure' as unknown as Error
await expect(handler('goals/fail', { args: { request: null } }, signal)).resolves.toEqual({
ok: false,
@@ -1099,6 +1133,7 @@ function createDescriptor(): InvocationDescriptor {
})),
},
],
cancellation: { parameter: 'signal' },
result: strictCodec('@fixture/gateway#CreateResult', z.object({
agentId: z.string(),
title: z.string(),

View File

@@ -963,8 +963,9 @@ class FaceAnalyzer {
const lookups = this.lookupDeclarations()
const lookupByHost = new Map(lookups.map(lookup => [lookup.hostSymbol, lookup]))
const parameters: InvocationParameterModel[] = []
let cancellation: InvocationModel['cancellation']
const wires = new Set<string>()
for (const parameter of method.parameters) {
for (const [parameterIndex, parameter] of method.parameters.entries()) {
if (!ts.isIdentifier(parameter.name)) {
this.fail(parameter, 'Remote parameters must use identifier bindings')
}
@@ -973,6 +974,18 @@ class FaceAnalyzer {
if (parameter.questionToken !== undefined) this.fail(parameter, 'Remote parameters cannot be optional')
if (parameter.name.text === 'this') this.fail(parameter, 'Remote methods cannot declare an explicit this parameter')
const authoredType = this.requiredType(parameter, parameter.type, 'parameter')
const cancellationName = parameter.name.text === 'signal'
const cancellationType = this.isGlobalAbortSignal(authoredType)
if (cancellationName || cancellationType) {
if (!cancellationName || !cancellationType) {
this.fail(parameter, 'Remote cancellation must use a parameter named signal with the global AbortSignal type')
}
if (parameterIndex !== method.parameters.length - 1) {
this.fail(parameter, 'Remote cancellation signal must be the final parameter')
}
cancellation = { parameter: 'signal' }
continue
}
const hostSymbol = this.symbolAtType(authoredType)
const lookup = hostSymbol === undefined ? undefined : lookupByHost.get(this.symbolId(hostSymbol))
let modeled: InvocationParameterModel
@@ -1065,6 +1078,7 @@ class FaceAnalyzer {
invocation: receiver,
...(scope === undefined ? {} : { scope }),
parameters,
...(cancellation === undefined ? {} : { cancellation }),
result: this.remoteBoundary(
resultType,
`${registration.name}#${binding.namespace}/${exportedMethod}:result`,
@@ -1181,6 +1195,13 @@ class FaceAnalyzer {
return resultType
}
private isGlobalAbortSignal(type: ts.TypeNode): boolean {
const symbol = this.symbolAtType(type)
if (symbol?.name !== 'AbortSignal') return false
return symbol.declarations?.some(declaration =>
isStandardLibraryFile(declaration.getSourceFile().fileName)) === true
}
private lookupDeclarations(): readonly StaticLookupDeclaration[] {
if (this.staticLookups !== undefined) return this.staticLookups
const byKey = new Map<string, StaticLookupDeclaration>()

View File

@@ -315,6 +315,9 @@ export class FaceModelEmitter {
lines.push(' },')
})
lines.push(' ],')
if (invocation.cancellation !== undefined) {
lines.push(" cancellation: { parameter: 'signal' },")
}
lines.push(` result: ${indent(strictCodec(
invocation.result,
schemas.boundary(resultBoundaryKey(invocation)),
@@ -459,6 +462,7 @@ export class FaceModelEmitter {
const parameters = invocation.parameters.filter(parameter =>
!scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter =>
`${safeIdentifier(parameter.wire)}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`)
if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal')
const result = this.renderer.renderType(invocation.result.type, referenceNames)
return `(${parameters.join(', ')}) => Promise<${result}>`
}

View File

@@ -140,6 +140,9 @@ export interface InvocationModel {
readonly wire: string
}
readonly parameters: readonly InvocationParameterModel[]
readonly cancellation?: {
readonly parameter: 'signal'
}
readonly result: RemoteBoundaryModel
readonly location: SourceLocation
}

View File

@@ -12,7 +12,8 @@ export class GoalService {
readonly typertGateway = bindTypeRTGateway(this, 'goals')
@Remote
async create(agent: Agent, request: CreateGoalRequest): Promise<CreateGoalResult> {
async create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise<CreateGoalResult> {
signal.throwIfAborted()
return { ref: `${agent.id}:${request.title}` }
}

View File

@@ -16,6 +16,7 @@ interface RuntimeSchema {
interface RuntimeDescriptor {
readonly id: string
readonly cancellation?: { readonly parameter: 'signal' }
readonly parameters: readonly {
readonly wire: string
readonly codec: { readonly schema: RuntimeSchema }
@@ -83,6 +84,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
boundary: { typeSymbol: '@fixture/remote/types#CreateGoalRequest' },
},
],
cancellation: { parameter: 'signal' },
result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' },
})
expect(model.invocations[1]).toMatchObject({
@@ -107,12 +109,12 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
expect(artifact?.js).toContain('invocations: [')
expect(artifact?.remote?.dts).toContain(
"'goals/create': (agentId: AgentId, request: CreateGoalRequest) => Promise<CreateGoalResult>",
"'goals/create': (agentId: AgentId, request: CreateGoalRequest, signal?: AbortSignal) => Promise<CreateGoalResult>",
)
expect(artifact?.remote?.dts).toContain('interface TypeRTRemoteNamespace$676f616c73 {\n create:')
expect(artifact?.remote?.dts).toContain("'goals': TypeRTRemoteNamespace$676f616c73")
expect(artifact?.remote?.dts).toContain(
"'agent:goals/create': (request: CreateGoalRequest) => Promise<CreateGoalResult>",
"'agent:goals/create': (request: CreateGoalRequest, signal?: AbortSignal) => Promise<CreateGoalResult>",
)
expect(artifact?.remote?.dts).toContain(
"'agent:goals/rename': (request: RenameGoalRequest) => Promise<RenameGoalResult>",
@@ -124,6 +126,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule
expect(generated.TYPERT_REMOTE.package).toBe('@fixture/remote')
const create = generated.TYPERT_REMOTE.descriptors[0]
expect(create?.cancellation).toEqual({ parameter: 'signal' })
expect(create?.parameters[1]?.codec.schema.safeParse({ title: 'ship' }).success).toBe(true)
expect(create?.parameters[1]?.codec.schema.safeParse({ title: 1 }).success).toBe(false)
expect(create?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true)
@@ -234,8 +237,8 @@ export type GenericResult = {
edit: (source: string) => source
.replace('export class GoalService', 'export abstract class GoalService')
.replace(
' async create(agent: Agent, request: CreateGoalRequest): Promise<CreateGoalResult> {\n return { ref: `${agent.id}:${request.title}` }\n }',
' abstract create(agent: Agent, request: CreateGoalRequest): Promise<CreateGoalResult>',
' async create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise<CreateGoalResult> {\n signal.throwIfAborted()\n return { ref: `${agent.id}:${request.title}` }\n }',
' abstract create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise<CreateGoalResult>',
),
message: 'Remote methods must have a concrete implementation',
},
@@ -267,6 +270,24 @@ export type GenericResult = {
edit: (source: string) => source.replace('request: CreateGoalRequest', 'request?: CreateGoalRequest'),
message: 'Remote parameters cannot be optional',
},
{
name: 'wrong cancellation type',
edit: (source: string) => source.replace('signal: AbortSignal', 'signal: string'),
message: 'cancellation must use a parameter named signal with the global AbortSignal type',
},
{
name: 'wrong cancellation name',
edit: (source: string) => source.replace('signal: AbortSignal', 'abort: AbortSignal'),
message: 'cancellation must use a parameter named signal with the global AbortSignal type',
},
{
name: 'non-final cancellation',
edit: (source: string) => source.replace(
'agent: Agent, request: CreateGoalRequest, signal: AbortSignal',
'agent: Agent, signal: AbortSignal, request: CreateGoalRequest',
),
message: 'cancellation signal must be the final parameter',
},
])('rejects $name', ({ edit, message }) => {
const root = copyFixture()
editFile(root, 'packages/remote/src/index.ts', edit)
@@ -399,12 +420,14 @@ declare const create: TypeRTRemoteMap['goals/create']
declare const createScoped: TypeRTRemoteContextMap['agent:goals/create']
declare const rename: TypeRTRemoteContextMap['agent:goals/rename']
const created: Promise<CreateGoalResult> = create('agent-1', { title: 'ship' })
const cancellable: Promise<CreateGoalResult> = create('agent-1', { title: 'ship' }, new AbortController().signal)
const createdScoped: Promise<CreateGoalResult> = createScoped({ title: 'ship' })
const renamed: Promise<RenameGoalResult> = rename({ ref: 'goal-1', title: 'land' })
declare const ctx: { api: TypeRTRemoteNamespaceMap }
const navigated: Promise<CreateGoalResult> = ctx.api.goals.create('agent-1', { title: 'navigate' })
void contribution
void created
void cancellable
void createdScoped
void renamed
void navigated

View File

@@ -226,6 +226,12 @@ function requireInvocation(pkgName: string, value: unknown): void {
parameters.set(wire, parameter)
requireStrictCodec(pkgName, parameter.codec, `invocation "${id}" parameter codec`)
}
if (invocation.cancellation !== undefined) {
const cancellation = requireObject(pkgName, invocation.cancellation, `invocation "${id}" cancellation`)
if (cancellation.parameter !== 'signal') {
throw new Error(`typert-loader: ${pkgName} invocation "${id}" cancellation parameter must be "signal"`)
}
}
if (invocation.scope !== undefined) {
if (receiver.kind !== 'direct') {
throw new Error(`typert-loader: ${pkgName} invocation "${id}" Context receiver cannot declare a direct scope projection`)

View File

@@ -83,6 +83,7 @@ function invocationTypertSource(pkgName: string): string {
' name: \'request\', wire: \'request\', source: \'json\',',
` codec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', schema: Text },`,
' }],',
" cancellation: { parameter: 'signal' },",
` result: { mode: 'strict', typeSymbol: '${pkgName}/types#Result', schema: Text },`,
' sourceLocation: { file: \'src/index.ts\', line: 8, column: 3 },',
' }],',
@@ -157,6 +158,7 @@ describe('typert loader', () => {
id: '@fixture/invocation#goals/create',
invocation: { kind: 'direct' },
parameters: [{ wire: 'request', source: 'json' }],
cancellation: { parameter: 'signal' },
sourceLocation: { file: 'src/index.ts', line: 8, column: 3 },
})
expect(descriptor?.parameters[0]?.codec.mode).toBe('strict')
@@ -502,6 +504,9 @@ describe('validateTypertManifest', () => {
const descriptor = strictInvocation()
const manifest = { ...base, invocations: [descriptor] }
expect(validateTypertManifest('pkg', manifest)).toBe(manifest)
const cancellable = { ...descriptor, cancellation: { parameter: 'signal' } }
expect(validateTypertManifest('pkg', { ...base, invocations: [cancellable] }).invocations)
.toEqual([cancellable])
const scoped = {
...descriptor,
scope: { context: 'agent', wire: 'agentId' },
@@ -526,6 +531,14 @@ describe('validateTypertManifest', () => {
...base,
invocations: [{ ...descriptor, result: { mode: 'src-json' } }],
})).toThrow('result codec must use a strict codec')
expect(() => validateTypertManifest('pkg', {
...base,
invocations: [{ ...descriptor, cancellation: null }],
})).toThrow('cancellation must be an object')
expect(() => validateTypertManifest('pkg', {
...base,
invocations: [{ ...descriptor, cancellation: { parameter: 'abort' } }],
})).toThrow('cancellation parameter must be "signal"')
expect(() => validateTypertManifest('pkg', {
...base,
invocations: [{ ...descriptor, result: { mode: 'strict', typeSymbol: 'pkg#Result', schema: zodish } }],

View File

@@ -562,6 +562,9 @@ function validateInvocation(descriptor: InvocationDescriptor): void {
}
validateCodec(parameter.codec, `${descriptor.id} parameter ${parameter.name}`)
}
if (descriptor.cancellation !== undefined && descriptor.cancellation.parameter !== 'signal') {
throw new Error(`typert: invocation "${descriptor.id}" cancellation parameter must be "signal"`)
}
if (descriptor.scope !== undefined) {
if (descriptor.invocation.kind !== 'direct') {
throw new Error(`typert: invocation "${descriptor.id}" Context receiver cannot declare a direct scope projection`)

View File

@@ -411,6 +411,7 @@ describe('TypertRegistry', () => {
...invocation('@fixture/remote#strict'),
implementation: 'remoteExportCreate',
parameters: [{ name: 'request', wire: 'request', source: 'json', codec: strict }],
cancellation: { parameter: 'signal' },
result: strict,
}
const dispose = ctx.typert.remotes.register({ package: '@fixture/strict', descriptors: [strictInvocation] })
@@ -420,6 +421,10 @@ describe('TypertRegistry', () => {
[{ ...invocation(), id: '' }, 'invocation id'],
[{ ...invocation(), namespace: 'bad/name' }, 'namespace'],
[{ ...invocation(), implementation: 'bad/name' }, 'implementation method'],
[{
...invocation(),
cancellation: { parameter: 'abort' } as unknown as { readonly parameter: 'signal' },
}, 'cancellation parameter'],
[{
...invocation(),
parameters: [

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/typert/type-meta/README.md
README.md: 9dd8dadd07b219c7471c8851262958d4d9e96a43
README.zh.md: 5716f56d988c6d2dd9cd237346c3b02ec9ae7c4e
README.md: 95716446c01c7fd510cdf55a82509b5b8af6f3ae
README.zh.md: 0d30b3122265d9bb3caa289345f843fe67377be3

View File

@@ -11,6 +11,8 @@ Compiler-independent declarations shared by business packages, generated TypeRT
- `bindTypeRTGateway(this, serviceKey, options?)` creates the visible, frozen binding between a Service instance, its exact Cordis key, and its wire namespace.
- `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback.
A Host method opts into cooperative cancellation by declaring `signal: AbortSignal` as its final parameter. `InvocationDescriptor.cancellation` records that reserved injection point; the signal never becomes a JSON parameter or lookup field. SRC recognizes the final parameter name, while strict generation also verifies the global `AbortSignal` type.
Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. The Service opts in explicitly through its `typertGateway` binding field.
## TypeRT protocol

View File

@@ -11,6 +11,8 @@
- `bindTypeRTGateway(this, serviceKey, options?)` 在服务实例、其准确的 Cordis key 与协议命名空间之间创建可见且冻结的绑定。
- `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。
Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用协作式取消。`InvocationDescriptor.cancellation` 记录这个保留的注入点signal 绝不会成为 JSON 参数或 lookup 字段。SRC 识别末位参数名,严格生成还会校验它是否具有全局 `AbortSignal` 类型。
装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol也不会添加 prototype 属性、参数元数据或运行时反射字段。服务通过自身的 `typertGateway` 绑定字段显式接入。
## TypeRT 协议

View File

@@ -157,6 +157,11 @@ export interface InvocationDescriptor {
}
/** Ordered business parameters. */
readonly parameters: readonly InvocationParameterDescriptor[]
/** Transport cancellation injected after business parameters instead of entering wire args. */
readonly cancellation?: {
/** Reserved final Host method parameter. */
readonly parameter: 'signal'
}
/** Codec for the resolved method result. */
readonly result: TypeRTCodec
/** Source declaration used only for diagnostics. */