feat(typert): propagate Remote cancellation
This commit is contained in:
@@ -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>()
|
||||
|
||||
@@ -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}>`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}` }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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 } }],
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 协议
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user