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

@@ -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. */