refactor(cordis): reuse TimerService in Client runner

This commit is contained in:
Shigma committed 2026-10-16 21:30:57 +08:00
1 parent f10be1f63a
commit e821bc31d6
17 files changed
+79 -307

No files matched your search

@@ -9,8 +9,8 @@
en: 8b8de9b0fc2e3fc3
zh: ffa20b7ffa7540ef
/timer-wrapper-return-types-and-disposal/change:
en: 978657a23b852a1e
zh: 1402cc87410cfc7a
en: 6ecd1dc8667a321d
zh: 150f620b06934133
/timer-wrapper-return-types-and-disposal/migration:
en: c07c4d66ff9fd1ff
zh: ecebc12b08a9be47
@@ -9,7 +9,7 @@ English | [中文](guide.zh.md)
## Change
The functions returned by `ctx.throttle()` and `ctx.debounce()` now declare a `void` return type. They previously inherited the callback's return type, although they already returned `undefined` at runtime. TypeScript code that uses the wrapper's return value or assigns it to the callback's type may need updating. This applies to Host Timer and browser ClientTimerService.
The functions returned by `ctx.throttle()` and `ctx.debounce()` now declare a `void` return type. They previously inherited the callback's return type, although they already returned `undefined` at runtime. TypeScript code that uses the wrapper's return value or assigns it to the callback's type may need updating. Host and browser use the same `TimerService` implementation.
Calling `.dispose()` or disposing the owning fiber cancels pending work and makes later wrapper calls inert. A disposed throttle wrapper previously could still execute a leading call; debounce wrappers already ignored calls after disposal.
@@ -9,7 +9,7 @@ description: "Timer 包装函数的 TypeScript 返回类型为 void,释放后
## 变更
`ctx.throttle()` 和 `ctx.debounce()` 返回的函数现在声明 `void` 返回类型。此前这些函数继承回调的返回类型,但运行时已经返回 `undefined`。使用包装函数返回值或将包装函数赋给回调类型的 TypeScript 代码可能需要更新。此变更适用于 Host Timer 和浏览器 ClientTimerService。
`ctx.throttle()` 和 `ctx.debounce()` 返回的函数现在声明 `void` 返回类型。此前这些函数继承回调的返回类型,但运行时已经返回 `undefined`。使用包装函数返回值或将包装函数赋给回调类型的 TypeScript 代码可能需要更新。Host 与浏览器使用同一份 `TimerService` 实现。
调用 `.dispose()` 或释放所属 fiber 会取消待执行的工作,并使后续包装函数调用不再执行。此前已释放的 throttle 包装函数仍可能执行前沿调用;debounce 包装函数在释放后已经会忽略调用。
+1 -1
View File
@@ -70,7 +70,7 @@ export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|
* identity to share — the framework itself is a requested module-table row
* (external), while these are ordinary libraries a browser bundle inlines.
*/
const VENDORED_LIBRARY = /^@deepseek-ai\/(cosmokit|schemastery)(\/|$)/
const VENDORED_LIBRARY = /^@deepseek-ai\/(?:(?:cosmokit|schemastery)(?:\/|$)|cordis-plugin-timer$)/
/** Generated descriptor/codec contribution with no shared runtime identity. */
const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
@@ -33,8 +33,8 @@
en: df635b29eaef982c
zh: ddaa4bd98359bd30
/deepseek-ai-dsh-cordis-client-runner/understand-the-implementation/source-map:
en: f69f511aa85127f2
zh: 651ea63d81bbd648
en: 49412a5fd3800d54
zh: 9ba50539d19c0e86
/deepseek-ai-dsh-cordis-client-runner/understand-the-implementation/inspecting-slot-inputs:
en: 33f14a7fff7835e6
zh: 53838ac8d4a79600
@@ -55,6 +55,8 @@ The browser half is built on one principle: a dynamic package must ride the same
### Source map
The entry installs the vendored Cordis `TimerService`, shared with the Host, in the runner's context. The browser bundle inlines Timer and uses the module table's Cordis instance. Timer calls belong to the calling plugin's fiber; disposing it cancels pending work. `ClientTimerService` is an export alias of `TimerService`.
| File | Role |
|---|---|
| [`src/client/index.ts`](src/client/index.ts) | Plugin entry: runner, orchestrator, inspect registry, forwarded-event subscription |
@@ -64,7 +66,6 @@ The browser half is built on one principle: a dynamic package must ride the same
| [`src/client/guard.ts`](src/client/guard.ts) | The whitelisting `ctx` façade for loaded browser halves |
| [`src/client/inspect-registry.ts`](src/client/inspect-registry.ts) | Client Inspect Providers and the pending-query router |
| [`src/client/providers.ts`](src/client/providers.ts) | First-party client Inspect Providers (slots, theme, events) |
| [`src/client/timer.ts`](src/client/timer.ts) | Browser timer Service matching the Host Timer API |
### Inspecting slot inputs
@@ -55,6 +55,8 @@ kind: "package-reference"
### 源码地图
入口在 runner 的上下文中安装与 Host 共用的 vendored Cordis `TimerService`。浏览器包内联 Timer,并使用模块表中的 Cordis 实例。定时任务归调用插件的 fiber 所有,释放该 fiber 会取消尚未执行的任务。`ClientTimerService` 是 `TimerService` 的导出别名。
| 文件 | 职责 |
|---|---|
| [`src/client/index.ts`](src/client/index.ts) | 插件入口:runner、编排器、inspect 注册表、转发事件订阅 |
@@ -64,7 +66,6 @@ kind: "package-reference"
| [`src/client/guard.ts`](src/client/guard.ts) | 已装载浏览器半收到的白名单 `ctx` façade |
| [`src/client/inspect-registry.ts`](src/client/inspect-registry.ts) | Client Inspect Provider 与待答查询路由器 |
| [`src/client/providers.ts`](src/client/providers.ts) | 第一方 client Inspect Provider(slots、theme、events) |
| [`src/client/timer.ts`](src/client/timer.ts) | 与 Host Timer API 一致的浏览器定时器 Service |
### 查询槽位输入
@@ -47,6 +47,7 @@
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
"@deepseek-ai/cordis-plugin-timer": "workspace:~",
"@deepseek-ai/dsh-api-remotes": "workspace:*",
"@deepseek-ai/dsh-client-modules": "workspace:*",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
@@ -11,6 +11,7 @@
*/
import type { Context } from '@deepseek-ai/cordis'
import { TimerService } from '@deepseek-ai/cordis-plugin-timer'
import type {
ApprovalRequestId, CordisDynamicPluginId, DynamicCordisInvokeResult,
DynamicCordisInventoryRow,
@@ -26,7 +27,6 @@ import { DynamicCordisPackageRunner } from './runtime.ts'
import { CordisRunOrchestrator } from './orchestrator.ts'
import { ClientCordisInspectRegistry, provideClientCordisInspect } from './inspect-registry.ts'
import { clientInspectProviders } from './providers.ts'
import { provideClientTimer } from './timer.ts'
import type { CordisRunActivity, CordisRunFailure, CordisUserRunRequest } from './orchestrator.ts'
import type { CordisObservable, DynamicCordisRenderFailure } from './runtime.ts'
@@ -49,7 +49,7 @@ export { DynamicCordisStyles, evaluateClientHalf, isDynamicCordisPlugin } from '
export type { DynamicCordisClosureEnv, DynamicCordisEvaluatedPlugin } from './evaluator.ts'
export { dynamicCordisContext } from './guard.ts'
export type { DynamicCordisGuardEnv, DynamicCordisSlotLedgerRow } from './guard.ts'
export { ClientTimerService } from './timer.ts'
export { TimerService as ClientTimerService } from '@deepseek-ai/cordis-plugin-timer'
// Re-exported so consumers of the service face and the two events can name
// their subjects without reaching into the wire contract themselves.
export type {
@@ -186,7 +186,7 @@ export const inject = ['loader', 'modules', 'slots', 'remote', 'remote.dynamicCo
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
provideClientTimer(ctx)
new TimerService(ctx)
const inspect = new ClientCordisInspectRegistry({
sync: async (providers) => {
const answered = await ctx.remote.dynamicCordisRunner.syncInspectManifest(providers)
@@ -1,222 +0,0 @@
/** Browser implementation of the Cordis timer Service. */
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
/*
* The browser Service preserves the vendored Host TimerService's erased callback tuples and arbitrary
* async-iterator return and rejection values, so narrowing these positions would change the public API.
*/
/* oxlint-disable typescript/no-explicit-any -- Exact Host TimerService API compatibility; see above. */
/* oxlint-disable typescript/no-unsafe-argument -- The erased callback tuples pass through unchanged. */
/* oxlint-disable typescript/no-unsafe-assignment -- The erased callback tuples pass through unchanged. */
/* oxlint-disable typescript/no-unsafe-member-access -- The returned wrapper retains its dispose property. */
/* oxlint-disable typescript/no-unsafe-return -- The erased generic return values pass through unchanged. */
/* oxlint-disable typescript/prefer-promise-reject-errors -- Async iterators preserve arbitrary throw reasons. */
declare module '@deepseek-ai/cordis' {
interface Context extends Pick<ClientTimerService, 'interval' | 'timeout' | 'throttle' | 'debounce' | 'setTimeout' | 'setInterval'> {
/** Browser timer Service used by the mixed-in Context helpers. */
timer: ClientTimerService
}
}
type Scheduled<F extends (...args: any[]) => any> = ((...args: Parameters<F>) => void) & { dispose: () => void }
// These `any` positions mirror the Host TimerService's overload erasure: generic callback tuples and async-iterator
// return/rejection values must pass through without narrowing them to one caller's invocation.
/** Browser timer Service with the same public API as the Host Cordis TimerService. */
export class ClientTimerService extends Service {
/** Register the Service and mix its lifecycle-safe helpers onto Context. */
constructor(ctx: Context) {
super(ctx, 'timer')
ctx.mixin('timer', ['timeout', 'interval', 'throttle', 'debounce', 'setTimeout', 'setInterval'])
}
/**
* Run a callback once through {@link timeout}.
* @param callback - Work to run after the delay.
* @param delay - Delay in milliseconds.
* @returns Disposer that cancels the pending callback early.
* @deprecated Use `ctx.timeout()` instead.
*/
setTimeout(callback: () => void, delay: number): () => void {
return this.timeout(callback, delay)
}
/**
* Run a callback repeatedly through {@link interval}.
* @param callback - Work to run on each tick.
* @param delay - Interval in milliseconds.
* @returns Disposer that stops the interval early.
* @deprecated Use `ctx.interval()` instead.
*/
setInterval(callback: () => void, delay: number): () => void {
return this.interval(callback, delay)
}
/**
* Run a callback once after a delay.
* @param callback - work to run.
* @param delay - delay in milliseconds.
* @returns disposer that cancels the callback.
*/
timeout(callback: () => void, delay: number): () => void
/**
* Wait for a delay.
* @param delay - delay in milliseconds.
* @returns promise resolved after the delay.
*/
timeout(delay: number): Promise<void>
timeout(...args: any[]): any {
const callback = typeof args[0] === 'function' ? args.shift() as () => void : undefined
const delay = args[0] as number
if (callback !== undefined) {
const dispose = this.ctx.effect(() => {
const timer = globalThis.setTimeout(() => {
void dispose()
callback()
}, delay)
return () => { globalThis.clearTimeout(timer) }
}, 'ctx.timeout()')
return dispose
}
const { promise, resolve, reject } = Promise.withResolvers<void>()
const dispose = this.ctx.effect(() => {
const timer = globalThis.setTimeout(resolve, delay)
return () => {
globalThis.clearTimeout(timer)
reject(new Error('Context has been disposed'))
}
}, 'ctx.timeout()')
return promise.finally(() => { void dispose() })
}
/**
* Run a callback repeatedly.
* @param callback - work to run on each tick.
* @param delay - interval in milliseconds.
* @returns disposer that stops the interval.
*/
interval(callback: () => void, delay: number): () => void
/**
* Iterate over timer ticks.
* @param delay - interval in milliseconds.
* @returns Iterator completing concurrent reads in order, one per tick; disposal rejects all pending reads.
*/
interval<R = any>(delay: number): AsyncIterableIterator<void, R, void>
interval(...args: any[]): any {
const callback = typeof args[0] === 'function' ? args.shift() as () => void : undefined
const delay = args[0] as number
if (callback !== undefined) {
return this.ctx.effect(() => {
const timer = globalThis.setInterval(callback, delay)
return () => { globalThis.clearInterval(timer) }
}, 'ctx.interval()')
}
let done: { kind: 'return'; value: any } | { kind: 'throw'; reason: any } | undefined
const nextTasks: PromiseWithResolvers<IteratorResult<void>>[] = []
const dispose = this.ctx.effect(() => {
const timer = globalThis.setInterval(() => {
nextTasks.shift()?.resolve({ done: false, value: undefined })
}, delay)
return () => {
globalThis.clearInterval(timer)
if (done !== undefined) return
done = { kind: 'throw', reason: new Error('Context has been disposed') }
for (const task of nextTasks.splice(0)) task.reject(done.reason)
}
}, 'ctx.interval()')
return {
next: () => {
if (done === undefined) {
const task = Promise.withResolvers<IteratorResult<void>>()
nextTasks.push(task)
return task.promise
}
if (done.kind === 'return') return Promise.resolve({ done: true, value: done.value })
return Promise.reject(done.reason)
},
return: (value: any) => {
if (done === undefined) done = { kind: 'return', value }
for (const task of nextTasks.splice(0)) task.resolve({ done: true, value })
void dispose()
return Promise.resolve({ done: true, value })
},
throw: (reason: any) => {
if (done === undefined) done = { kind: 'throw', reason }
for (const task of nextTasks.splice(0)) task.reject(reason)
void dispose()
return Promise.resolve({ done: true, value: undefined })
},
[Symbol.asyncIterator]() {
return this
},
} satisfies AsyncIterableIterator<void>
}
/** Build a delayed wrapper whose pending callback belongs to the calling Fiber. */
private schedule(label: string, trigger: (args: any[]) => number | undefined): any {
let timer: number | undefined
let disposed = false
const dispose = this.ctx.effect(() => () => {
disposed = true
globalThis.clearTimeout(timer)
timer = undefined
}, label)
const wrapper: any = (...args: any[]): void => {
if (disposed) return
globalThis.clearTimeout(timer)
timer = trigger(args)
}
wrapper.dispose = dispose
return wrapper
}
/**
* Return a throttled function whose timer is disposed with the calling Fiber.
* @param callback - Function to throttle.
* @param delay - Minimum interval between calls in milliseconds.
* @param noTrailing - Whether to suppress a delayed trailing call.
* @returns Void-returning wrapper; disposal cancels pending work and makes later calls inert.
*/
throttle<F extends (...args: any[]) => any>(callback: F, delay: number, noTrailing?: boolean): Scheduled<F> {
let lastCall = -Infinity
const execute = (...args: Parameters<F>): void => {
lastCall = Date.now()
callback(...args)
}
return this.schedule('ctx.throttle()', (args) => {
const remaining = delay - Date.now() + lastCall
if (remaining <= 0) {
execute(...args as Parameters<F>)
} else if (!noTrailing) {
return globalThis.setTimeout(execute, remaining, ...args)
}
})
}
/**
* Return a debounced function whose timer is disposed with the calling Fiber.
* @param callback - Function to debounce.
* @param delay - Quiet period in milliseconds.
* @returns Void-returning wrapper; disposal cancels pending work and makes later calls inert.
*/
debounce<F extends (...args: any[]) => any>(callback: F, delay: number): Scheduled<F> {
return this.schedule('ctx.debounce()', (args) => {
return globalThis.setTimeout(callback, delay, ...args)
})
}
}
/**
* Install the browser timer Service on one Client composition.
* @param ctx - Client context that owns the Service and mixed-in helpers.
* @returns Nothing after registering the Service.
*/
export function provideClientTimer(ctx: Context): void {
new ClientTimerService(ctx)
}
@@ -9,6 +9,7 @@
/* oxlint-disable typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. */
import { Context } from '@deepseek-ai/cordis'
import { TimerService } from '@deepseek-ai/cordis-plugin-timer'
import { describe, expect, it, vi } from 'vitest'
import type {
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
@@ -68,8 +69,7 @@ interface Bench {
}
/** Mount the browser half over a module table and a loader standing on real fibers. */
async function boot(): Promise<Bench> {
const ctx = new Context()
async function boot(ctx = new Context()): Promise<Bench> {
await ctx.plugin(SlotRegistry)
const factories = new Map<string, () => unknown>()
const fibers = new Map<string, { fiber: unknown }>()
@@ -194,6 +194,50 @@ async function boot(): Promise<Bench> {
}
describe('browser half', () => {
it('installs the shared Timer and disposes scheduled work with its consumer', async () => {
const moduleLoader = Object.getOwnPropertyDescriptor(globalThis, '__ModuleLoader__')
const ctx = new Context()
vi.useFakeTimers()
try {
const bench = await boot(ctx)
expect(bench.ctx.timer).toBeInstanceOf(TimerService)
expect(ClientHalf.ClientTimerService).toBe(TimerService)
const timeout = vi.fn()
const interval = vi.fn()
const delayed = vi.fn()
const consumer = bench.ctx.plugin({
inject: ['timer'],
apply(ctx: Context) {
ctx.timeout(timeout, 20)
ctx.interval(interval, 20)
ctx.debounce(delayed, 40)()
},
})
await consumer
await vi.advanceTimersByTimeAsync(20)
expect(timeout).toHaveBeenCalledOnce()
expect(interval).toHaveBeenCalledOnce()
expect(delayed).not.toHaveBeenCalled()
await consumer.dispose()
await vi.advanceTimersByTimeAsync(40)
expect(interval).toHaveBeenCalledOnce()
expect(delayed).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
expect(bench.ctx.timer).toBeInstanceOf(TimerService)
await bench.dispose()
expect(bench.ctx.get('timer')).toBeUndefined()
expect(bench.ctx.timeout).toBeUndefined()
} finally {
try {
await ctx.fiber.dispose()
} finally {
vi.useRealTimers()
if (moduleLoader) Object.defineProperty(globalThis, '__ModuleLoader__', moduleLoader)
else Reflect.deleteProperty(globalThis, '__ModuleLoader__')
}
}
})
it('provides the load engine as the page run-state face', async () => {
const bench = await boot()
expect(bench.ctx.dynamicCordisRunner.getSnapshot()).toEqual([])
@@ -1,69 +0,0 @@
/** @vitest-environment jsdom */
import { Context } from '@deepseek-ai/cordis'
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
import { ClientTimerService } from '../src/client/timer.ts'
describe('client Timer', () => {
let ctx: Context
beforeEach(async () => {
vi.useFakeTimers()
ctx = new Context()
await ctx.plugin(ClientTimerService)
})
afterEach(async () => {
try {
await ctx.fiber.dispose()
} finally {
vi.useRealTimers()
}
})
it('delivers concurrent reads in order and rejects the remaining reads on teardown', async () => {
const ticks = ctx.interval(20)
const outcomes: string[] = []
for (let index = 0; index < 3; index++) {
void ticks.next().then(() => outcomes.push(`tick ${index}`), () => outcomes.push(`disposed ${index}`))
}
await vi.advanceTimersByTimeAsync(20)
expect(outcomes).toEqual(['tick 0'])
await ctx.fiber.dispose()
expect(outcomes).toEqual(['tick 0', 'disposed 1', 'disposed 2'])
expect(vi.getTimerCount()).toBe(0)
})
it('keeps wrapper calls void and inert after disposal', async () => {
const work = vi.fn((value: string) => value.length)
const throttle = ctx.throttle(work, 20)
const debounce = ctx.debounce(work, 20)
expectTypeOf(throttle).returns.toBeVoid()
expectTypeOf(debounce).returns.toBeVoid()
throttle('leading')
throttle('trailing')
debounce('delayed')
throttle.dispose()
debounce.dispose()
await vi.advanceTimersByTimeAsync(40)
throttle('disposed')
debounce('disposed')
expect(work).toHaveBeenCalledExactlyOnceWith('leading')
expect(vi.getTimerCount()).toBe(0)
})
it('retains leading calls and suppresses trailing calls with noTrailing', async () => {
const work = vi.fn()
const throttle = ctx.throttle(work, 20, true)
throttle('first')
throttle('suppressed')
await vi.advanceTimersByTimeAsync(20)
expect(work).toHaveBeenCalledExactlyOnceWith('first')
throttle('next window')
expect(work.mock.calls).toEqual([['first'], ['next window']])
throttle.dispose()
await vi.advanceTimersByTimeAsync(20)
throttle('disposed')
expect(work).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -14,6 +14,9 @@
{
"path": "../../../vendor/loader"
},
{
"path": "../../../vendor/timer"
},
{
"path": "../../api/remotes/tsconfig.client.json"
},
+3
View File
@@ -9332,6 +9332,9 @@ importers:
'@deepseek-ai/cordis-plugin-loader':
specifier: workspace:~
version: link:../../../vendor/loader
'@deepseek-ai/cordis-plugin-timer':
specifier: workspace:~
version: link:../../../vendor/timer
'@deepseek-ai/dsh-api-remotes':
specifier: workspace:*
version: link:../../api/remotes
+11
View File
@@ -184,6 +184,17 @@ describe('client bundle purity gate', () => {
expect(() => resolveId('@deepseek-ai/dsh-spill-policy/notice/internal')).toThrow(/purity/)
})
it('inlines the browser-safe Timer entry while sharing Cordis through the module table', () => {
const timer = '@deepseek-ai/cordis-plugin-timer'
expect(resolveId(timer)).toBeNull()
expect(() => resolveId(`${timer}/src/index.ts`)).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/cordis-plugin-hmr')).toThrow(/purity/)
const config = clientConfigs(packageDir('extensions/cordis-client-runner'))[0]
const deps = config?.deps as { neverBundle: (specifier: string) => boolean }
expect(deps.neverBundle(timer)).toBe(false)
expect(deps.neverBundle('@deepseek-ai/cordis')).toBe(true)
})
it('lets exact generated Remote contributions inline without admitting their package implementation', () => {
expect(resolveId('@deepseek-ai/dsh-goal/remote')).toBeNull()
expect(() => resolveId('@deepseek-ai/dsh-goal')).toThrow(/purity/)
-1
View File
@@ -200,7 +200,6 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the API',
sessionLogDownload: 'client-side browser download controller — packages/session-query/session-log-export/README.md owns the API',
inputTriggers: 'client-side interface-typed browser service — packages/client/ui-input-trigger/README.md owns the API',
timer: 'client-side dynamic-package timer service — packages/extensions/cordis-client-runner/README.md owns the API',
slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API',
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API',
workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API',
+1 -1
View File
@@ -261,7 +261,7 @@ const EXACT_EDITS: readonly ExactEdit[] = [
* identity to share — the framework itself is a requested module-table row
* (external), while these are ordinary libraries a browser bundle inlines.
*/
const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/
const VENDORED_LIBRARY = /^@deepseek-ai\\/(?:(?:cosmokit|schemastery)(?:\\/|$)|cordis-plugin-timer$)/
/** Generated descriptor/codec contribution with no shared runtime identity. */`,
expect: 1,