Merge branch 'worktree/ci-native-windows-20260808' into worktree/ci-native-windows-coverage-20260808
This commit is contained in:
@@ -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/util/timeout/README.md
|
||||
README.md: 0ff5550ef7ea6b8315a6a529a4b8b503162a8f12
|
||||
README.zh.md: 79d7ee674209b0324ae9428c1a04fda8a2547db5
|
||||
README.md: e0d3595e7771fd19c808e89d440e69bca40da286
|
||||
README.zh.md: 99b7f281c2af7746fc2a105ee08df870fd3a9583
|
||||
@@ -18,7 +18,7 @@ import { clampTimeout, deadline, idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf, Ti
|
||||
|---|---|
|
||||
| `clampTimeout(requested, def, max, name?)` | Validate the caller's optional positive-finite hint, fill from `def`, cap at `max`. Throws (with `name`) on a non-positive/non-finite hint. |
|
||||
| `deadline(upstream, timeoutMs, code)` | Fuse `upstream` cancellation with a timeout into one `AbortSignal` (`AbortSignal.any`); the timeout carries a `TimeoutReason`. `[Symbol.dispose]` clears the timer. |
|
||||
| `idleWatchdog(upstream, timeoutMs, code)` | Keep one stable fused signal and arm only while its guarded async-iterator `next()` is outstanding. Resolution disarms; later demand rearms; disposal clears; concurrent demand rejects. |
|
||||
| `idleWatchdog(upstream, timeoutMs, code)` | Keep one stable fused signal and arm only while its guarded async-iterator `next()` is outstanding. Resolution disarms; later demand or `pulse()` activity rearms; disposal clears; concurrent demand rejects. |
|
||||
| `MAX_TIMER_DELAY_MS` | Largest delay Node schedules without clamping it to one millisecond (`2_147_483_647`). Timer-owning config must not exceed it. |
|
||||
| `timeoutOf(signal \| { reason }, code?)` | Recover the `TimeoutReason` from an aborted signal/error, else `undefined` — the timeout-vs-cancel classifier. Pass `code` to match only THIS deadline's timer (see nesting below). |
|
||||
| `TimeoutReason` | The internal reason (`code` + `timeoutMs`) stamped on a timeout abort. Not a public error — providers translate it into their own error/field. |
|
||||
@@ -48,7 +48,7 @@ The signal only *notifies* — the caller MUST attach its own termination (`d.si
|
||||
|
||||
Pass your own `code` to `timeoutOf` so classification composes under nesting. When `upstream` is itself a deadline signal, `AbortSignal.any` preserves its `TimeoutReason` if that timer fires first. Scoping to your code makes a foreign timeout read as an ordinary upstream cancel instead of claiming that the local timer expired.
|
||||
|
||||
For a streamed transport, create one `idleWatchdog`, pass its stable `signal` into the transport, and call `watchdog.next(iterator)` for each provider read. The interval must be positive, finite, and no greater than `MAX_TIMER_DELAY_MS`; Node otherwise clamps it to one millisecond. It measures only outstanding demand, so no timer runs while downstream code renders or otherwise waits before asking for the next chunk. The primitive still only notifies, so the transport must observe the stable signal; the DeepSeek and pi-ai adapters prove that timeout closes their real response body or SDK request.
|
||||
For a streamed transport, create one `idleWatchdog`, pass its stable `signal` into the transport, and call `watchdog.next(iterator)` for each provider read. Call `watchdog.pulse()` when transport activity does not yield an iterator value. The interval must be positive, finite, and no greater than `MAX_TIMER_DELAY_MS`; Node otherwise clamps it to one millisecond. It measures only outstanding demand, so no timer runs while downstream code renders or otherwise waits before asking for the next chunk. The primitive still only notifies, so the transport must observe the stable signal; the DeepSeek and pi-ai adapters prove that timeout closes their real response body or SDK request.
|
||||
|
||||
## What does NOT get a timeout
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { clampTimeout, deadline, idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf, Ti
|
||||
|---|---|
|
||||
| `clampTimeout(requested, def, max, name?)` | 验证调用方可选的、值为正且有限的提示,从 `def` 填充,并限制在 `max` 以内。如果提示为非正数或非有限数,则抛出错误(包含 `name`)。 |
|
||||
| `deadline(upstream, timeoutMs, code)` | 将 `upstream` 取消与超时融合为一个 `AbortSignal`(`AbortSignal.any`);超时携带 `TimeoutReason`。`[Symbol.dispose]` 清除 timer。 |
|
||||
| `idleWatchdog(upstream, timeoutMs, code)` | 保持一个稳定的融合信号,并且只在受保护的异步迭代器 `next()` 尚未完成时启动 timer。完成后停止 timer;后续需求重新启动 timer;dispose(资源释放)时清除;并发需求被拒绝。 |
|
||||
| `idleWatchdog(upstream, timeoutMs, code)` | 保持一个稳定的融合信号,并且只在受保护的异步迭代器 `next()` 尚未完成时启动 timer。完成后停止 timer;后续需求或 `pulse()` 活动会重新启动 timer;dispose(资源释放)时清除;并发需求被拒绝。 |
|
||||
| `MAX_TIMER_DELAY_MS` | Node 在不将延迟限制为 1 毫秒时可调度的最大延迟(`2_147_483_647`)。负责 timer 的配置不得超过该值。 |
|
||||
| `timeoutOf(signal \| { reason }, code?)` | 从已中止的信号/错误中恢复 `TimeoutReason`,否则返回 `undefined`,即超时与取消的分类器。传入 `code` 可仅匹配这个 deadline 的 timer(见下文的嵌套)。 |
|
||||
| `TimeoutReason` | 标记在超时中止上的内部原因(`code` + `timeoutMs`)。它不是公开错误;提供方将其转换为自己的错误/字段。 |
|
||||
@@ -48,7 +48,7 @@ export async function runWithDeadline(upstream: AbortSignal | undefined, timeout
|
||||
|
||||
将你自己的 `code` 传给 `timeoutOf`,使分类可在嵌套场景中正确组合。当 `upstream` 本身是 deadline 信号时,如果该 timer 先触发,`AbortSignal.any` 会保留它的 `TimeoutReason`。将匹配范围限定为你的 code,会把外部超时视为普通的 upstream 取消,而不会声称本地 timer 已到期。
|
||||
|
||||
对于流式传输,创建一个 `idleWatchdog`,将其稳定的 `signal` 传给传输层,并为提供方的每次读取调用 `watchdog.next(iterator)`。间隔必须为正有限数,且不得超过 `MAX_TIMER_DELAY_MS`;否则 Node 会将其限制为 1 毫秒。它只对尚未完成的读取请求计时,因此当下游代码进行渲染或在请求下一个分片前以其他方式等待时,timer 不会运行。该原语仍然只会通知,因此传输层必须观察稳定信号;DeepSeek 和 pi-ai 适配器证明,超时会关闭它们的真实响应正文或 SDK 请求。
|
||||
对于流式传输,创建一个 `idleWatchdog`,将其稳定的 `signal` 传给传输层,并为提供方的每次读取调用 `watchdog.next(iterator)`。当传输活动不产生迭代器值时,调用 `watchdog.pulse()`。间隔必须为正有限数,且不得超过 `MAX_TIMER_DELAY_MS`;否则 Node 会将其限制为 1 毫秒。它只对尚未完成的读取请求计时,因此当下游代码进行渲染或在请求下一个分片前以其他方式等待时,timer 不会运行。该原语仍然只会通知,因此传输层必须观察稳定信号;DeepSeek 和 pi-ai 适配器证明,超时会关闭它们的真实响应正文或 SDK 请求。
|
||||
|
||||
## 哪些操作不设置超时
|
||||
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface IdleWatchdog {
|
||||
* @returns the iterator's next result.
|
||||
*/
|
||||
next<T>(iterator: AsyncIterator<T>): Promise<IteratorResult<T>>
|
||||
/** Rearm an outstanding demand after transport activity that yields no iterator value; otherwise a no-op. */
|
||||
pulse(): void
|
||||
/** Clear an armed timer; safe to call once at the owning stream's exit. */
|
||||
[Symbol.dispose](): void
|
||||
}
|
||||
@@ -135,15 +137,20 @@ export function idleWatchdog(
|
||||
let outstanding = false
|
||||
let disposed = false
|
||||
|
||||
const arm = (): void => {
|
||||
if (timer !== undefined) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
timeout.abort(new TimeoutReason(code, timeoutMs))
|
||||
}, timeoutMs)
|
||||
}
|
||||
|
||||
return {
|
||||
signal,
|
||||
async next<T>(iterator: AsyncIterator<T>): Promise<IteratorResult<T>> {
|
||||
if (disposed) throw new Error('idleWatchdog is disposed')
|
||||
if (outstanding) throw new Error('idleWatchdog next is already outstanding')
|
||||
outstanding = true
|
||||
timer = setTimeout(() => {
|
||||
timeout.abort(new TimeoutReason(code, timeoutMs))
|
||||
}, timeoutMs)
|
||||
arm()
|
||||
try {
|
||||
return await iterator.next()
|
||||
} finally {
|
||||
@@ -152,6 +159,10 @@ export function idleWatchdog(
|
||||
outstanding = false
|
||||
}
|
||||
},
|
||||
pulse(): void {
|
||||
if (disposed || !outstanding) return
|
||||
arm()
|
||||
},
|
||||
[Symbol.dispose](): void {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
|
||||
@@ -229,6 +229,28 @@ describe('idleWatchdog', () => {
|
||||
await expect(secondNext).rejects.toBe(stableSignal.reason)
|
||||
})
|
||||
|
||||
it('rearms outstanding demand on an out-of-band activity pulse', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pending = Promise.withResolvers<IteratorResult<number>>()
|
||||
const watchdog = idleWatchdog(undefined, 100, 'LLM_STREAM_IDLE_TIMEOUT')
|
||||
watchdog.pulse()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(watchdog.signal.aborted).toBe(false)
|
||||
|
||||
const next = watchdog.next({ next: () => pending.promise })
|
||||
await vi.advanceTimersByTimeAsync(99)
|
||||
watchdog.pulse()
|
||||
await vi.advanceTimersByTimeAsync(99)
|
||||
expect(watchdog.signal.aborted).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT')).toMatchObject({ timeoutMs: 100 })
|
||||
pending.reject(watchdog.signal.reason)
|
||||
await expect(next).rejects.toBe(watchdog.signal.reason)
|
||||
|
||||
watchdog[Symbol.dispose]()
|
||||
watchdog.pulse()
|
||||
})
|
||||
|
||||
it('keeps an earlier upstream abort distinct from its own timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
const upstream = new AbortController()
|
||||
|
||||
Reference in New Issue
Block a user