From 885b0dcad486f88eb0be4f14687bf5bce48eebef Mon Sep 17 00:00:00 2001 From: rezon-aki Date: Fri, 18 Sep 2026 23:46:48 +0800 Subject: [PATCH] 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 } },