From 885b0dcad486f88eb0be4f14687bf5bce48eebef Mon Sep 17 00:00:00 2001 From: rezon-aki Date: Fri, 18 Sep 2026 23:46:48 +0800 Subject: [PATCH 1/2] fix(host): release the pricing file source with the fiber, not at apply time ctx.effect(() => fileSource.dispose(), ...) calls dispose while the fiber is being built and registers undefined as the disposer: the file source is dead before its first read, so pricing.json is never applied and the change watcher never fires. Return a disposer instead. Test mocks now follow cordis semantics (run setup, keep the returned disposer, release it in after()) and a /pricing route regression test covers the override path - it fails against the old form. --- src/index.ts | 3 ++- tests/core.test.mjs | 10 ++++++--- tests/pricing.test.mjs | 46 ++++++++++++++++++++++++++++++++++++++++-- tests/rounds.test.mjs | 13 ++++++++---- 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5ef5eb6..eee6186 100644 --- a/src/index.ts +++ b/src/index.ts @@ -364,7 +364,8 @@ export function apply(ctx: Context, config: Config = {}): void { const pricingFile = config.pricingFile?.trim() || defaultPricingFile() const fileSource = filePricingSource(pricingFile) const resolver = createPricingResolver(fileSource) - ctx.effect(() => fileSource.dispose(), 'dsh-usage-chart: pricing file watcher') + // 注意返回的是「返回 disposer 的函数」:写成 () => fileSource.dispose() 会在注册时立刻销毁文件源。 + ctx.effect(() => () => fileSource.dispose(), 'dsh-usage-chart: pricing file watcher') ctx.effect(() => ctx.webServer.register({ kind: 'exact', diff --git a/tests/core.test.mjs b/tests/core.test.mjs index 00ca057..30d3b1e 100644 --- a/tests/core.test.mjs +++ b/tests/core.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import test from 'node:test' +import test, { after } from 'node:test' import { DEFAULT_CNY_PER_USD, @@ -16,6 +16,10 @@ import { normalizeCurrency, } from '../lib/index.js' +/** cordis 语义:effect(setup) 立即执行 setup,把返回的 disposer 留到 fiber 销毁时再调用。 */ +const disposers = [] +after(() => { for (const dispose of disposers) if (typeof dispose === 'function') dispose() }) + function responseRecorder() { return { headers: {}, @@ -30,7 +34,7 @@ function responseRecorder() { function mountedRoutes(events = [], config = {}) { const routes = new Map() apply({ - effect(setup) { setup() }, + effect(setup) { disposers.push(setup()) }, get() { return undefined }, webServer: { register(route) { routes.set(route.path, route); return () => {} }, @@ -126,7 +130,7 @@ test('mounted Host routes enforce method and browser-origin guards', async () => async function balanceResponse(extraCtx = {}, config = {}) { const routes = new Map() apply({ - effect(setup) { setup() }, + effect(setup) { disposers.push(setup()) }, get(name) { return name === 'credentials' ? extraCtx.credentials : undefined }, webServer: { register(route) { routes.set(route.path, route); return () => {} } }, sessions: { get() { return undefined } }, diff --git a/tests/pricing.test.mjs b/tests/pricing.test.mjs index f3201f0..e1f9cbf 100644 --- a/tests/pricing.test.mjs +++ b/tests/pricing.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { mkdtemp, writeFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import test from 'node:test' +import test, { after } from 'node:test' import { BUILTIN_VERIFIED_AT, @@ -19,6 +19,10 @@ import { tierAt, } from '../lib/index.js' +/** cordis 语义:effect(setup) 立即执行 setup,把返回的 disposer 留到 fiber 销毁时再调用。 */ +const disposers = [] +after(() => { for (const dispose of disposers) if (typeof dispose === 'function') dispose() }) + /** 2026-09-10 内置刊例价(双币种 /1M,高峰/空闲)。 */ const FLASH_OFF_PEAK = { cny: { cacheMissInput: 1, cacheHitInput: 0.02, output: 4 }, @@ -264,7 +268,7 @@ function responseRecorder() { test('/pricing route exposes builtin + fallback + models snapshot', async () => { const routes = new Map() apply({ - effect(setup) { setup() }, + effect(setup) { disposers.push(setup()) }, get() { return undefined }, webServer: { register(route) { routes.set(route.path, route); return () => {} } }, sessions: { get() { return undefined } }, @@ -286,3 +290,41 @@ test('/pricing route exposes builtin + fallback + models snapshot', async () => assert.ok(models.includes('deepseek-v4-pro')) assert.ok(models.includes('deepseek-v4-flash-vision-exp')) }) + +test('/pricing route serves the user pricing.json override (file source survives startup)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'duc-pricing-')) + const pricingFile = join(dir, 'pricing.json') + await writeFile(pricingFile, JSON.stringify({ + models: { + 'deepseek-flash': { + peak: { cny: { cacheMissInput: 19.99, cacheHitInput: 0.04, output: 8 }, usd: { cacheMissInput: 2.99, cacheHitInput: 0.006, output: 1.2 } }, + offPeak: { cny: { cacheMissInput: 9.99, cacheHitInput: 0.02, output: 4 }, usd: { cacheMissInput: 1.49, cacheHitInput: 0.003, output: 0.6 } }, + }, + }, + })) + const routes = new Map() + const disposers = [] + try { + apply({ + // cordis 语义:effect(setup) 立即执行 setup,把返回的 disposer 留到 fiber 销毁时才调用。 + // 写成 () => source.dispose() 会在这里当场销毁文件源 → 覆盖文件永不生效(本测试即为此回归)。 + effect(setup) { disposers.push(setup()) }, + get() { return undefined }, + webServer: { register(route) { routes.set(route.path, route); return () => {} } }, + sessions: { get() { return undefined } }, + }, { pricingFile }) + await new Promise((resolve) => setTimeout(resolve, 50)) + + const recorder = responseRecorder() + await routes.get('/dsh-usage-chart/pricing').handler( + { method: 'GET', url: '/dsh-usage-chart/pricing', headers: { host: 'localhost:3000' } }, + recorder, + ) + const flash = JSON.parse(recorder.body).models.find((m) => m.model === 'deepseek-flash') + assert.equal(flash.source, 'file') + assert.equal(flash.pricing.offPeak.cny.cacheMissInput, 9.99) + } finally { + for (const dispose of disposers) if (typeof dispose === 'function') dispose() + await rm(dir, { recursive: true, force: true }) + } +}) diff --git a/tests/rounds.test.mjs b/tests/rounds.test.mjs index 3b57d17..3308751 100644 --- a/tests/rounds.test.mjs +++ b/tests/rounds.test.mjs @@ -1,8 +1,13 @@ import assert from 'node:assert/strict' -import test from 'node:test' +import test, { after } from 'node:test' import { apply, foldRounds, foldTurnUsage, tierAt } from '../lib/index.js' +/** cordis 语义:effect(setup) 立即执行 setup,把返回的 disposer 留到 fiber 销毁时再调用。 */ +const disposers = [] +after(() => { for (const dispose of disposers) if (typeof dispose === 'function') dispose() }) + + /** 2026-09-10 内置刊例价(双币种 /1M):flash 高峰/空闲。 */ const FLASH_PEAK = { cny: { cacheMissInput: 2, cacheHitInput: 0.04, output: 8 }, @@ -206,7 +211,7 @@ test('/usage route returns rounds with cost and timing', async () => { ] const routes = new Map() apply({ - effect(setup) { setup() }, + effect(setup) { disposers.push(setup()) }, get() { return undefined }, webServer: { register(route) { routes.set(route.path, route); return () => {} } }, sessions: { get(id) { return id === 'session-test' ? { id, events } : undefined } }, @@ -247,7 +252,7 @@ test('/usage route prefers snapshotEvents() when the new API is present', async ] const routes = new Map() apply({ - effect(setup) { setup() }, + effect(setup) { disposers.push(setup()) }, get() { return undefined }, webServer: { register(route) { routes.set(route.path, route); return () => {} } }, // 有 snapshotEvents、无 events —— 必须走 snapshotEvents() 分支,且绝不触碰 events 属性。 @@ -273,7 +278,7 @@ test('/usage route prefers snapshotEvents() when the new API is present', async const both = { id: 'session-test', events: [], snapshotEvents: () => events } const bothRoutes = new Map() apply({ - effect(setup) { setup() }, + effect(setup) { disposers.push(setup()) }, get() { return undefined }, webServer: { register(route) { bothRoutes.set(route.path, route); return () => {} } }, sessions: { get(id) { return id === 'session-test' ? both : undefined } }, From 8699388644eed56dd616d2eeb94e77a1bb6d00e9 Mon Sep 17 00:00:00 2001 From: Miles <364475182@qq.com> Date: Sat, 19 Sep 2026 13:15:20 +0800 Subject: [PATCH 2/2] chore: tag 1.1.6 --- CHANGELOG.md | 9 +++++++++ CHANGELOG_ZH.md | 9 +++++++++ README.md | 2 +- README_ZH.md | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 24 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e3e63a..c4628bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/). 中文版见 [CHANGELOG_ZH.md](./CHANGELOG_ZH.md). +## [1.1.6] - 2026-09-19 + +### Fixed + +- **Host pricing file source lifecycle (`pricing.json` override & change watcher)** — [PR #10](https://github.com/Max-Samson/dsh-usage-chart/pull/10): + - Fixed premature disposal of `filePricingSource` during plugin registration: `ctx.effect(() => fileSource.dispose(), ...)` called `dispose()` synchronously while building the fiber and registered `undefined` as the cleanup handler, aborting initial asynchronous file loading and disabling the file change watcher. Resolved by returning a disposer function: `ctx.effect(() => () => fileSource.dispose(), ...)`. + - Updated test mocks in `tests/core.test.mjs`, `tests/pricing.test.mjs`, and `tests/rounds.test.mjs` to properly track and execute Cordis effect disposers in `after()`. + - Added regression test covering the `/pricing` route with user `pricing.json` overrides enabled at startup. + ## [1.1.5] - 2026-09-12 ### Changed diff --git a/CHANGELOG_ZH.md b/CHANGELOG_ZH.md index 774017d..60d43b4 100644 --- a/CHANGELOG_ZH.md +++ b/CHANGELOG_ZH.md @@ -2,6 +2,15 @@ 本文件记录本项目所有重要变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。英文版见 [CHANGELOG.md](./CHANGELOG.md)。 +## [1.1.6] - 2026-09-19 + +### 修复 + +- **宿主自定义价格文件源生命周期早释问题(`pricing.json` 覆盖与变更监听)**——[PR #10](https://github.com/Max-Samson/dsh-usage-chart/pull/10): + - **修复 `filePricingSource` 启动即被销毁的 Bug**:此前 `ctx.effect(() => fileSource.dispose(), ...)` 在挂载期立即执行了清理调用,导致文件数据源在首次异步读取完成前被截断关闭,致使用户配置的 `pricing.json` 覆盖及变更监听均无法生效。现已修正为返回高阶清理函数 `ctx.effect(() => () => fileSource.dispose(), ...)`,使文件源在 fiber 卸载时才被释放。 + - **纠正单测 Cordis effect Mock 语义**:在 `tests/core.test.mjs`、`tests/pricing.test.mjs` 与 `tests/rounds.test.mjs` 中对齐 Cordis 真实生命周期契约,在 `after()` 中统一执行清理函数。 + - **补充端到端回归用例**:新增 `/pricing route serves the user pricing.json override (file source survives startup)`,验证插件挂载自定义 `pricing.json` 时价格覆盖正常生效且来源正确标记为 `file`。 + ## [1.1.5] - 2026-09-12 diff --git a/README.md b/README.md index 5ae1ec2..bb45e8c 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ To update (upgrade to a new version): pnpm may print `Already up to date` when a ```sh # Option ①: pin the target version explicitly -dsh plugin --profile web add dsh-usage-chart@1.1.5 +dsh plugin --profile web add dsh-usage-chart@1.1.6 # Option ②: remove, then re-add (back to latest) dsh plugin --profile web remove dsh-usage-chart dsh plugin --profile web add dsh-usage-chart diff --git a/README_ZH.md b/README_ZH.md index 7133e12..19aa7d6 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -78,7 +78,7 @@ dsh web --profile web # 启动 DSH Web(已在运行 ```sh # 方式①:显式指定目标版本 -dsh plugin --profile web add dsh-usage-chart@1.1.5 +dsh plugin --profile web add dsh-usage-chart@1.1.6 # 方式②:先移除再重装(回到最新版) dsh plugin --profile web remove dsh-usage-chart dsh plugin --profile web add dsh-usage-chart @@ -91,7 +91,7 @@ dsh plugin --profile web add dsh-usage-chart > ⚠️ **未全局安装 dsh(报 `dsh: command not found` / PowerShell `无法将“dsh”项识别为…`)? > 把上面每条 `dsh` 都写成 `npx --yes @deepseek-ai/dsh`**,例如 -> `npx --yes @deepseek-ai/dsh plugin --profile web add dsh-usage-chart@1.1.5` +> `npx --yes @deepseek-ai/dsh plugin --profile web add dsh-usage-chart@1.1.6` > (原因与解法见 [FAQ](#常见问题faq) 第一条)。 ### 方式二:从 GitHub 安装(源码构建) diff --git a/package-lock.json b/package-lock.json index dcea77b..33d0f6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dsh-usage-chart", - "version": "1.1.5", + "version": "1.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dsh-usage-chart", - "version": "1.1.5", + "version": "1.1.6", "license": "MIT", "devDependencies": { "@deepseek-ai/cordis": "^4.0.1", diff --git a/package.json b/package.json index 9d7a47f..d3b90d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-usage-chart", - "version": "1.1.5", + "version": "1.1.6", "description": "DeepSeek 用量 / 成本 / 余额仪表盘 for DSH Web:输入框下方实时指标指示器 + 零依赖 SVG 用量可视化图表(含每轮成本视角、耗时叠加、异常标记、成本徽章、上下文压力条),token 数据来自官方 adapter 上报,成本按官方刊例价 + 用户覆盖 pricing.json 解析,余额来自官方 /user/balance 接口。", "type": "module", "sideEffects": false,