From 7618ccec69717a06dec0d2ecbe250b05f5a73cd1 Mon Sep 17 00:00:00 2001 From: Shigma Date: Wed, 14 Oct 2026 15:01:18 +0800 Subject: [PATCH] =?UTF-8?q?chore(cordis):=20=E5=90=8C=E6=AD=A5=20Timer=201?= =?UTF-8?q?.1.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cordis-client-runner/README.i18n.yaml | 4 +- .../extensions/cordis-client-runner/README.md | 2 +- .../cordis-client-runner/README.zh.md | 2 +- .../src/client/api-catalog.ts | 4 +- .../cordis-client-runner/src/client/timer.ts | 44 ++++---- .../tests/timer.client.spec.ts | 53 +++++++++ .../extensions/tool-cordis/src/api-catalog.ts | 4 +- scripts/cordis-timer.spec.ts | 101 ++++++++++++++++++ scripts/gen-cordis-catalog.ts | 4 +- vendor/README.md | 4 +- vendor/timer/README.md | 10 +- vendor/timer/src/index.ts | 38 ++++--- 12 files changed, 219 insertions(+), 51 deletions(-) create mode 100644 packages/extensions/cordis-client-runner/tests/timer.client.spec.ts create mode 100644 scripts/cordis-timer.spec.ts diff --git a/packages/extensions/cordis-client-runner/README.i18n.yaml b/packages/extensions/cordis-client-runner/README.i18n.yaml index f68b557073..7352fc1585 100644 --- a/packages/extensions/cordis-client-runner/README.i18n.yaml +++ b/packages/extensions/cordis-client-runner/README.i18n.yaml @@ -33,8 +33,8 @@ en: df635b29eaef982c zh: ddaa4bd98359bd30 /deepseek-ai-dsh-cordis-client-runner/understand-the-implementation/source-map: - en: cbd46fb5821cfcc7 - zh: 392e5cace2db6194 + en: 22a43e93fd123200 + zh: f8305186a748369b /deepseek-ai-dsh-cordis-client-runner/understand-the-implementation/inspecting-slot-inputs: en: 33f14a7fff7835e6 zh: 53838ac8d4a79600 diff --git a/packages/extensions/cordis-client-runner/README.md b/packages/extensions/cordis-client-runner/README.md index 07b371528a..90dfd95b97 100644 --- a/packages/extensions/cordis-client-runner/README.md +++ b/packages/extensions/cordis-client-runner/README.md @@ -64,7 +64,7 @@ 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) | The client timer service dynamic packages inject | +| [`src/client/timer.ts`](src/client/timer.ts) | Client timers with ordered concurrent interval reads and void-returning throttle/debounce wrappers that become inert on disposal | ### Inspecting slot inputs diff --git a/packages/extensions/cordis-client-runner/README.zh.md b/packages/extensions/cordis-client-runner/README.zh.md index 3753a9beca..c4eea99ce7 100644 --- a/packages/extensions/cordis-client-runner/README.zh.md +++ b/packages/extensions/cordis-client-runner/README.zh.md @@ -64,7 +64,7 @@ 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) | 动态包注入的 client 定时器服务 | +| [`src/client/timer.ts`](src/client/timer.ts) | client 定时器按顺序完成并发 interval 读取;throttle/debounce 包装函数返回 void,释放后不再执行 | ### 查询槽位输入 diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index c4887547ab..e8b3248d47 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -319,12 +319,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], }, { - signature: 'throttle void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }', + signature: 'throttle any>(callback: F, delay: number, noTrailing?: boolean): ((...args: Parameters) => void) & { dispose: () => void }', description: 'Return a throttled function whose timer is disposed with the current fiber.', parameters: [], }, { - signature: 'debounce void>(callback: F, delay: number): F & { dispose: () => void }', + signature: 'debounce any>(callback: F, delay: number): ((...args: Parameters) => void) & { dispose: () => void }', description: 'Return a debounced function whose timer is disposed with the current fiber.', parameters: [], }, diff --git a/packages/extensions/cordis-client-runner/src/client/timer.ts b/packages/extensions/cordis-client-runner/src/client/timer.ts index e2b07c8472..2c87fb978e 100644 --- a/packages/extensions/cordis-client-runner/src/client/timer.ts +++ b/packages/extensions/cordis-client-runner/src/client/timer.ts @@ -21,7 +21,7 @@ declare module '@deepseek-ai/cordis' { } } -type WithDispose = T & { dispose: () => void } +type Scheduled any> = ((...args: Parameters) => 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. @@ -104,7 +104,7 @@ export class ClientTimerService extends Service { /** * Iterate over timer ticks. * @param delay - interval in milliseconds. - * @returns async iterator of ticks. + * @returns Iterator completing concurrent reads in order, one per tick; disposal rejects all pending reads. */ interval(delay: number): AsyncIterableIterator interval(...args: any[]): any { @@ -118,33 +118,37 @@ export class ClientTimerService extends Service { } let done: { kind: 'return'; value: any } | { kind: 'throw'; reason: any } | undefined - let nextTask: PromiseWithResolvers> | undefined + const nextTasks: PromiseWithResolvers>[] = [] const dispose = this.ctx.effect(() => { const timer = globalThis.setInterval(() => { - nextTask?.resolve({ done: false, value: undefined }) + 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') } - nextTask?.reject(done.reason) + for (const task of nextTasks.splice(0)) task.reject(done.reason) } }, 'ctx.interval()') return { next: () => { - if (done === undefined) return (nextTask = Promise.withResolvers()).promise + if (done === undefined) { + const task = Promise.withResolvers>() + 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 } - nextTask?.resolve({ done: true, 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 } - nextTask?.reject(reason) + for (const task of nextTasks.splice(0)) task.reject(reason) void dispose() return Promise.resolve({ done: true, value: undefined }) }, @@ -155,15 +159,18 @@ export class ClientTimerService extends Service { } /** Build a delayed wrapper whose pending callback belongs to the calling Fiber. */ - private schedule(label: string, trigger: (args: any[], disposed: boolean) => number | undefined, disposed = false): any { + 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, disposed) + timer = trigger(args) } wrapper.dispose = dispose return wrapper @@ -174,33 +181,32 @@ export class ClientTimerService extends Service { * @param callback - Function to throttle. * @param delay - Minimum interval between calls in milliseconds. * @param noTrailing - Whether to suppress a delayed trailing call. - * @returns Throttled function with an early disposer. + * @returns Void-returning wrapper; disposal cancels pending work and makes later calls inert. */ - throttle void>(callback: F, delay: number, noTrailing?: boolean): WithDispose { + throttle any>(callback: F, delay: number, noTrailing?: boolean): Scheduled { let lastCall = -Infinity const execute = (...args: Parameters): void => { lastCall = Date.now() callback(...args) } - return this.schedule('ctx.throttle()', (args, disposed) => { + return this.schedule('ctx.throttle()', (args) => { const remaining = delay - Date.now() + lastCall if (remaining <= 0) { execute(...args as Parameters) - } else if (!disposed) { + } else if (!noTrailing) { return globalThis.setTimeout(execute, remaining, ...args) } - }, noTrailing) + }) } /** * Return a debounced function whose timer is disposed with the calling Fiber. * @param callback - Function to debounce. * @param delay - Quiet period in milliseconds. - * @returns Debounced function with an early disposer. + * @returns Void-returning wrapper; disposal cancels pending work and makes later calls inert. */ - debounce void>(callback: F, delay: number): WithDispose { - return this.schedule('ctx.debounce()', (args, disposed) => { - if (disposed) return + debounce any>(callback: F, delay: number): Scheduled { + return this.schedule('ctx.debounce()', (args) => { return globalThis.setTimeout(callback, delay, ...args) }) } diff --git a/packages/extensions/cordis-client-runner/tests/timer.client.spec.ts b/packages/extensions/cordis-client-runner/tests/timer.client.spec.ts new file mode 100644 index 0000000000..9ad26bccc7 --- /dev/null +++ b/packages/extensions/cordis-client-runner/tests/timer.client.spec.ts @@ -0,0 +1,53 @@ +/** @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) + }) +}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 798e310219..402286ff4c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3305,12 +3305,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], }, { - signature: 'throttle void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }', + signature: 'throttle any>(callback: F, delay: number, noTrailing?: boolean): ((...args: Parameters) => void) & { dispose: () => void }', description: 'Return a throttled function whose timer is disposed with the current fiber.', parameters: [], }, { - signature: 'debounce void>(callback: F, delay: number): F & { dispose: () => void }', + signature: 'debounce any>(callback: F, delay: number): ((...args: Parameters) => void) & { dispose: () => void }', description: 'Return a debounced function whose timer is disposed with the current fiber.', parameters: [], }, diff --git a/scripts/cordis-timer.spec.ts b/scripts/cordis-timer.spec.ts new file mode 100644 index 0000000000..6f6822853f --- /dev/null +++ b/scripts/cordis-timer.spec.ts @@ -0,0 +1,101 @@ +/** Timer iterator ordering and cancellation on the vendored Cordis lifecycle. */ +import { Context } from '@deepseek-ai/cordis' +import Timer from '@deepseek-ai/cordis-plugin-timer' +import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' + +describe('Cordis Timer', () => { + let ctx: Context + + beforeEach(async () => { + vi.useFakeTimers() + ctx = new Context() + await ctx.plugin(Timer) + }) + + afterEach(async () => { + try { + await ctx.fiber.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('completes concurrent interval reads in order, one per tick', async () => { + const iterator = ctx.interval(10) + const completed: number[] = [] + const reads = [0, 1, 2].map(index => iterator.next().then( + (value) => { completed.push(index); return value }, + () => undefined, + )) + for (let index = 0; index < reads.length; index++) { + await vi.advanceTimersByTimeAsync(10) + expect(completed).toEqual(Array.from({ length: index + 1 }, (_, i) => i)) + } + expect(await Promise.all(reads)).toEqual(Array(3).fill({ done: false, value: undefined })) + await iterator.return!() + expect(vi.getTimerCount()).toBe(0) + }) + + it.each(['return', 'throw', 'dispose'] as const)('settles all pending interval reads on %s', async (operation) => { + const iterator = ctx.interval(10) + const completed: PromiseSettledResult>[] = [] + for (let index = 0; index < 3; index++) { + void iterator.next().then( + value => completed.push({ status: 'fulfilled', value }), + (reason: unknown) => completed.push({ status: 'rejected', reason }), + ) + } + const error = new Error('stopped') + if (operation === 'return') await iterator.return!('finished') + else if (operation === 'throw') await iterator.throw!(error) + else await ctx.fiber.dispose() + await vi.advanceTimersByTimeAsync(0) + expect(completed).toHaveLength(3) + if (operation === 'return') { + expect(completed).toEqual(Array(3).fill({ status: 'fulfilled', value: { done: true, value: 'finished' } })) + await expect(iterator.next()).resolves.toEqual({ done: true, value: 'finished' }) + } else { + const reason = operation === 'throw' ? error : new Error('Context has been disposed') + expect(completed).toEqual(Array(3).fill({ status: 'rejected', reason })) + await expect(iterator.next()).rejects.toThrow(reason.message) + } + expect(vi.getTimerCount()).toBe(0) + }) + + it.each([false, true])('stops throttle calls and trailing work after disposal (noTrailing=%s)', async (noTrailing) => { + const callback = vi.fn((value: string) => value.length) + const throttled = ctx.throttle(callback, 10, noTrailing) + expectTypeOf(throttled).parameters.toEqualTypeOf<[string]>() + expectTypeOf(throttled).returns.toBeVoid() + throttled('first') + throttled('second') + await vi.advanceTimersByTimeAsync(10) + expect(callback.mock.calls).toEqual(noTrailing ? [['first']] : [['first'], ['second']]) + throttled('pending') + const calls = callback.mock.calls.length + throttled.dispose() + await vi.advanceTimersByTimeAsync(20) + throttled('after disposal') + expect(callback).toHaveBeenCalledTimes(calls) + expect(vi.getTimerCount()).toBe(0) + }) + + it('cancels debounce work and makes both wrappers inert after owner disposal', async () => { + const callback = vi.fn((value: string) => value.length) + const debounced = ctx.debounce(callback, 10) + const throttled = ctx.throttle(callback, 10) + expectTypeOf(debounced).parameters.toEqualTypeOf<[string]>() + expectTypeOf(debounced).returns.toBeVoid() + debounced('first') + debounced('last') + await vi.advanceTimersByTimeAsync(10) + expect(callback).toHaveBeenCalledExactlyOnceWith('last') + debounced('pending') + await ctx.fiber.dispose() + debounced('disposed') + throttled('disposed') + await vi.advanceTimersByTimeAsync(10) + expect(callback).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index d734035f5a..25bfc749c9 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -1007,11 +1007,11 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = { jsDoc: '/** Return an async iterator of timer ticks. */', }, { - signature: 'throttle void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }', + signature: 'throttle any>(callback: F, delay: number, noTrailing?: boolean): ((...args: Parameters) => void) & { dispose: () => void }', jsDoc: '/** Return a throttled function whose timer is disposed with the current fiber. */', }, { - signature: 'debounce void>(callback: F, delay: number): F & { dispose: () => void }', + signature: 'debounce any>(callback: F, delay: number): ((...args: Parameters) => void) & { dispose: () => void }', jsDoc: '/** Return a debounced function whose timer is disposed with the current fiber. */', }, ], diff --git a/vendor/README.md b/vendor/README.md index 654276201d..b076fb4443 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -20,7 +20,7 @@ Upstream workspace: `cordis-workspace` (local checkout: `~/repos/cordis-workspac | `loader/` | `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | 1.0.0-rc.5 | https://github.com/cordiverse/cordis (`packages/loader`) | `56b3d4f725681cf4556c1a8695a709cc3b6eed74` | | `include/` | `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | 1.0.4 | https://github.com/deepseek-harness/cordis (`packages/include`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | | `group/` | `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/group`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | -| `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | 1.1.2 | https://github.com/deepseek-harness/cordis (`packages/timer`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | +| `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | 1.1.4 | https://github.com/cordiverse/cordis (`packages/timer`) | `0b96d9fd517b998e44e2209560c9e5f0671ce933` | | `hmr/` | `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | 1.0.15 | https://github.com/deepseek-harness/cordis (`packages/hmr`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | | `logger-console/` | `@deepseek-ai/cordis-plugin-logger-console` | `@cordisjs/plugin-logger-console` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/logger-console`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | @@ -38,7 +38,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. Type-only dependencies use `import type` or inline `type` modifiers so ESM output does not retain erased interfaces. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. -7. **Cordis and Loader JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. +7. **Cordis, Loader and Timer JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Timer retains its local class and helper summaries. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **`include/src/index.ts` resilient file refresh and patch reapplication**: [#514](https://github.com/deepseek-harness/deepseek-harness/pull/514) validates a top-level entry array before caching parsed content, logs refresh failures, reapplies patches after file or Include-config edits, updates the Include config when it vetoes a restart, and uses `initial` only after `ENOENT`. These parse protections retain the running tree after an invalid file; plugin activation failures can leave a partially applied tree. Loader entry/group/tree mutations follow the pinned eager, non-transactional implementation and do not restore previous plugins or options. Covered by `packages/boot/app-boot/tests/{config-reload,user-patches}.spec.ts`. 9. **`hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/hmr/src/watch-config.ts`. Covered by `packages/boot/hmr/tests/watch-config.spec.ts`. 10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`. Conditional exports select `.mjs` for import and `.cjs` for require; without them, concurrent imports under module-hook hosts reach the CJS entry and can race its require of ESM Cosmokit (`ERR_REQUIRE_ESM_RACE_CONDITION`). diff --git a/vendor/timer/README.md b/vendor/timer/README.md index 3a1dcb1533..9735030a27 100644 --- a/vendor/timer/README.md +++ b/vendor/timer/README.md @@ -18,8 +18,7 @@ const dispose = root.timeout(() => { dispose() ``` -Timer handles are registered on the current fiber, so they are cleared -automatically when the plugin that created them is disposed. +Timer handles are registered on the current fiber, so they are cleared automatically when the plugin that created them is disposed. ## API @@ -32,5 +31,8 @@ automatically when the plugin that created them is disposed. | `ctx.throttle(callback, delay, noTrailing?)` | Return a throttled function with `.dispose()`. | | `ctx.debounce(callback, delay)` | Return a debounced function with `.dispose()`. | -`ctx.setTimeout()` and `ctx.setInterval()` are kept as deprecated aliases for -`ctx.timeout()` and `ctx.interval()`. +`ctx.setTimeout()` and `ctx.setInterval()` are kept as deprecated aliases for `ctx.timeout()` and `ctx.interval()`. + +Concurrent `next()` calls on an interval iterator complete in request order, one per tick. Returning from or throwing into the iterator settles every pending read; disposing the owning fiber rejects every pending read. Ticks without a pending read are discarded. + +Throttle and debounce wrappers return `void`, regardless of the callback return type. Calling `.dispose()` or disposing the owning fiber cancels pending work and makes later calls inert. `noTrailing` suppresses delayed throttle calls while retaining leading calls until disposal. diff --git a/vendor/timer/src/index.ts b/vendor/timer/src/index.ts index 009d14a5d4..8d133e5c7f 100644 --- a/vendor/timer/src/index.ts +++ b/vendor/timer/src/index.ts @@ -6,7 +6,7 @@ declare module '@deepseek-ai/cordis' { } } -type WithDispose = T & { dispose: () => void } +type Scheduled any> = ((...args: Parameters) => void) & { dispose: () => void } /** Disposable timer helpers mixed into Cordis contexts. */ export class TimerService extends Service { @@ -66,33 +66,37 @@ export class TimerService extends Service { }, 'ctx.interval()') } else { let done: { kind: 'return'; value: any } | { kind: 'throw'; reason: any } | undefined - let nextTask: PromiseWithResolvers> | undefined + const nextTasks: PromiseWithResolvers>[] = [] const dispose = this.ctx.effect(() => { const timer = setInterval(() => { - nextTask?.resolve({ done: false, value: undefined }) + nextTasks.shift()?.resolve({ done: false, value: undefined }) }, delay) return () => { clearInterval(timer) if (done) return done = { kind: 'throw', reason: new Error('Context has been disposed') } - nextTask?.reject(done.reason) + for (const task of nextTasks.splice(0)) task.reject(done.reason) } }, 'ctx.interval()') return { next: () => { - if (!done) return (nextTask = Promise.withResolvers()).promise + if (!done) { + const task = Promise.withResolvers>() + 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) => { if (!done) done = { kind: 'return', value } - nextTask?.resolve({ done: true, value }) + for (const task of nextTasks.splice(0)) task.resolve({ done: true, value }) dispose() return Promise.resolve({ done: true, value }) }, throw: (reason) => { if (!done) done = { kind: 'throw', reason } - nextTask?.reject(reason) + for (const task of nextTasks.splice(0)) task.reject(reason) dispose() return Promise.resolve({ done: true, value: undefined }) }, @@ -103,42 +107,44 @@ export class TimerService extends Service { } } - private _schedule(label: string, trigger: (args: any[], isDisposed: boolean) => any, isDisposed = false) { + private _schedule(label: string, trigger: (args: any[]) => number | NodeJS.Timeout | undefined) { let timer: number | NodeJS.Timeout | undefined + let isDisposed = false const dispose = this.ctx.effect(() => () => { isDisposed = true clearTimeout(timer) + timer = undefined }, label) const wrapper: any = (...args: any[]) => { + if (isDisposed) return clearTimeout(timer) - timer = trigger(args, isDisposed) + timer = trigger(args) } wrapper.dispose = dispose return wrapper } /** Return a throttled function whose timer is disposed with the current fiber. */ - throttle void>(callback: F, delay: number, noTrailing?: boolean): WithDispose { + throttle any>(callback: F, delay: number, noTrailing?: boolean): Scheduled { let lastCall = -Infinity const execute = (...args: any[]) => { lastCall = Date.now() callback(...args) } - return this._schedule('ctx.throttle()', (args, isDisposed) => { + return this._schedule('ctx.throttle()', (args) => { const now = Date.now() const remaining = delay - now + lastCall if (remaining <= 0) { execute(...args) - } else if (!isDisposed) { + } else if (!noTrailing) { return setTimeout(execute, remaining, ...args) } - }, noTrailing) + }) } /** Return a debounced function whose timer is disposed with the current fiber. */ - debounce void>(callback: F, delay: number): WithDispose { - return this._schedule('ctx.debounce()', (args, isDisposed) => { - if (isDisposed) return + debounce any>(callback: F, delay: number): Scheduled { + return this._schedule('ctx.debounce()', (args) => { return setTimeout(callback, delay, ...args) }) }