mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-09 04:02:35 +00:00
fix(connection): validate retry factors and clarify timeout recovery
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 .agents/notes/implemented/bug-fix/2026-09-05-continuous-client-recovery.md
|
||||
2026-09-05-continuous-client-recovery.md: 5c45d7512d1162f46a9e68c0eb244ef164fdec08
|
||||
2026-09-05-continuous-client-recovery.zh.md: 047c89607c3f684d9f688e00486c17d890badf6d
|
||||
2026-09-05-continuous-client-recovery.md: 3a017f69d78f9155dd513d44eb87e4bf0bdc1f9d
|
||||
2026-09-05-continuous-client-recovery.zh.md: bc8b12d2ae4143ddb397334a76cdd65b49c32d96
|
||||
|
||||
@@ -10,11 +10,11 @@ A generation source can remain pending without reporting readiness or carrier fa
|
||||
|
||||
## Decision
|
||||
|
||||
[`ConnectionController`](../../../../packages/client/connection/src/client/connection.ts) owns both the readiness deadline and the continuous retry schedule. A handshake reports a slow Host after three seconds and aborts after fifteen seconds by default. The warning gives early feedback without discarding a Host that needs several seconds to become ready; the deadline bounds each attempt. Cancellation reaches the generation source, which must release its resources and settle before another source starts. A cancelled source's late ready callback cannot establish a generation.
|
||||
[`ConnectionController`](../../../../packages/client/connection/src/client/connection.ts) owns both the readiness deadline and the continuous retry schedule. A handshake reports a slow Host after three seconds and aborts after fifteen seconds by default. The warning gives early feedback without discarding a Host that needs several seconds to become ready; the deadline bounds each attempt and logs the timeout when it cancels the generation. Warning and cancellation times are independent, so a shorter hard deadline does not require changing both fields; a warning scheduled after settlement is cancelled. Cancellation reaches the generation source, which must release its resources and settle before another source starts. A cancelled source's late ready callback cannot establish a generation.
|
||||
|
||||
Retry caps grow from 500ms through 1s, 2s, 4s, and 8s to 10s, with the existing 50–100% jitter. Failures at the maximum cap continue retrying. Separating a maximum delay from a retry-count limit follows the distinction in [Socket.IO's Client options](https://socket.io/docs/v4/client-options/#reconnectionattempts), while DSH retains its existing Remote stream protocol and single scheduler. Gateway replaces the physical socket once for each Controller-requested attempt. Both a pending WebSocket candidate and an open socket without an opening ready frame can recover this way.
|
||||
|
||||
The Host Connection plugin validates `recovery` in its configuration and injects the resolved, non-secret timing into each page through `webserver/index-inject`. The Client validates that bootstrap input before providing Connection; direct loop options may override it. Timer values must be positive integers within the browser timer range, and the backoff factor must be finite and at least one. A factor of one selects continuous fixed-cap retries. Changes to Host timing apply to subsequently loaded pages.
|
||||
The Host Connection plugin validates `recovery` in its configuration and injects the resolved, non-secret timing into each page through `webserver/index-inject`. The Client validates that bootstrap input before providing Connection; direct loop options may override it. Timer values must be positive integers within the browser timer range, and the backoff factor must be finite and at least one. The shared resolver explicitly rejects `NaN`, which range comparisons alone cannot exclude. A factor of one selects continuous fixed-cap retries. Changes to Host timing apply to subsequently loaded pages.
|
||||
|
||||
The Settings indicator labels active recovery **Reconnecting** and keeps **Reconnect now** available. This decision supersedes the terminal retry policy in [Web connection recovery control](../feature/2026-08-28-web-connection-recovery-control.md). That note still owns manual recovery, browser offline suspension, the single-scheduler rule, and indicator presentation. A fresh `$events` ready frame alone establishes connectivity; domain streams retain their own baseline and cursor recovery.
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ generation source 可能一直挂起,既不报告就绪,也不报告载体
|
||||
|
||||
## 决策
|
||||
|
||||
[`ConnectionController`](../../../../packages/client/connection/src/client/connection.ts) 同时拥有握手就绪期限和持续重试调度。默认情况下,握手在三秒后报告 Host 响应缓慢,在十五秒后中止。告警提供早期反馈,同时保留需要数秒才能就绪的 Host;硬期限限制每次尝试的时长。取消会传到 generation source,后者必须释放资源并结束,下一 source 才能启动。已取消 source 迟到的 ready 回调不能建立 generation。
|
||||
[`ConnectionController`](../../../../packages/client/connection/src/client/connection.ts) 同时拥有握手就绪期限和持续重试调度。默认情况下,握手在三秒后报告 Host 响应缓慢,在十五秒后中止。告警提供早期反馈,同时保留需要数秒才能就绪的 Host;硬期限限制每次尝试的时长,并在取消 generation 时记录超时。告警与取消时间相互独立,因此缩短硬期限不需要同时修改两个字段;排定在握手结束之后的告警会被取消。取消会传到 generation source,后者必须释放资源并结束,下一 source 才能启动。已取消 source 迟到的 ready 回调不能建立 generation。
|
||||
|
||||
重试上限从 500ms 开始,经过 1s、2s、4s、8s 增长到 10s,保留现有 50–100% 抖动。达到最大上限后失败仍继续重试。把最大延迟与重试次数上限分开,与 [Socket.IO Client 选项](https://socket.io/docs/v4/client-options/#reconnectionattempts)的区分一致;DSH 保留自身的 Remote stream 协议和唯一调度器。Controller 每要求一次尝试,Gateway 就替换一次物理 socket。仍在等待打开的 WebSocket 候选,以及已打开但未收到首个 ready 帧的 socket,都能通过此路径恢复。
|
||||
|
||||
Host Connection 插件校验配置中的 `recovery`,通过 `webserver/index-inject` 把已解析且不含秘密的时序数据注入每个页面。Client 在提供 Connection 之前校验启动输入;直接传给循环的选项可覆盖这些值。定时器值必须是浏览器定时器范围内的正整数,退避因子必须是至少为一的有限数。一表示以固定上限持续重试。Host 时序配置的变更适用于随后加载的页面。
|
||||
Host Connection 插件校验配置中的 `recovery`,通过 `webserver/index-inject` 把已解析且不含秘密的时序数据注入每个页面。Client 在提供 Connection 之前校验启动输入;直接传给循环的选项可覆盖这些值。定时器值必须是浏览器定时器范围内的正整数,退避因子必须是至少为一的有限数。共享解析器显式拒绝仅靠范围比较无法排除的 `NaN`。一表示以固定上限持续重试。Host 时序配置的变更适用于随后加载的页面。
|
||||
|
||||
Settings 指示器把活动恢复标为**自动重连中**,并始终提供**立即重连**。本决策取代[Web 连接恢复控制](../feature/2026-08-28-web-connection-recovery-control.zh.md)中的终态重试策略。该记录仍拥有手动恢复、浏览器离线暂停、唯一调度器规则与指示器展示。只有新的 `$events` ready 帧才能建立连接状态;各域的 stream 保留各自的 baseline 与 cursor 恢复方式。
|
||||
|
||||
|
||||
@@ -299,11 +299,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
let holdConnections = false
|
||||
await recoveryPage.routeWebSocket('**/api/remote.mux', (route) => {
|
||||
sockets.push(route)
|
||||
if (rejectConnections) {
|
||||
void route.close({ code: 4001, reason: 'connection recovery test' })
|
||||
return
|
||||
}
|
||||
if (holdConnections) return
|
||||
if (rejectConnections || holdConnections) return
|
||||
route.connectToServer()
|
||||
})
|
||||
onTestFailed(() => saveFailureShot(recoveryPage, 'web-e2e-connection-recovery'))
|
||||
@@ -339,6 +335,13 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
for (let count = 2; count <= 9; count++) {
|
||||
await recoveryPage.clock.fastForward(10_000)
|
||||
await expect.poll(() => sockets.length).toBe(count)
|
||||
if (count === 2) {
|
||||
await recoveryPage.clock.fastForward(1_000)
|
||||
expect(sockets).toHaveLength(count)
|
||||
}
|
||||
await sockets.at(-1)!.close({ code: 4001, reason: 'connection recovery test' })
|
||||
// Drain the close event's promise continuations before advancing the next retry timer.
|
||||
await recoveryPage.evaluate(() => {})
|
||||
}
|
||||
const indicator = connecting
|
||||
expect(await connectionIndicatorGeometry(indicator)).toEqual(connectingGeometry)
|
||||
|
||||
@@ -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 docs/config-catalog.md
|
||||
config-catalog.md: cc707840ca5a647d5721720deae7daf6a9355370
|
||||
config-catalog.zh.md: 429efa412aa399acdac476455e3ecf92e8848336
|
||||
config-catalog.md: 9bbdc705c14c0397e490ff63464021ab66154935
|
||||
config-catalog.zh.md: 2b3ae8417b0bf404301d12cabc203c16cc817cd3
|
||||
|
||||
@@ -341,11 +341,14 @@ export interface ConnectionConfig {
|
||||
export interface ConnectionRecoveryConfig {
|
||||
/** First-retry delay cap in ms; actual delay is 50–100% of the cap. Default: 500. */
|
||||
backoffBaseMs?: number
|
||||
/** Growth factor per failed attempt; 1 keeps a fixed cap. Default: 2. */
|
||||
/** Finite growth factor of at least 1 per failed attempt; 1 keeps a fixed cap. Default: 2. */
|
||||
backoffFactor?: number
|
||||
/** Maximum retry delay cap in ms; retries continue at this cap. Default: 10000. */
|
||||
backoffMaxMs?: number
|
||||
/** Delay before reporting a slow handshake, without cancelling it. Default: 3000. */
|
||||
/**
|
||||
* Delay before reporting a slow handshake, without cancelling it. Default: 3000.
|
||||
* Omitted when readiness, failure, cancellation, or the hard deadline occurs first.
|
||||
*/
|
||||
generationReadyWarnMs?: number
|
||||
/** Deadline in ms for readiness, including physical connection setup. Default: 15000. */
|
||||
generationReadyTimeoutMs?: number
|
||||
|
||||
@@ -343,11 +343,14 @@ export interface ConnectionConfig {
|
||||
export interface ConnectionRecoveryConfig {
|
||||
/** First-retry delay cap in ms; actual delay is 50–100% of the cap. Default: 500. */
|
||||
backoffBaseMs?: number
|
||||
/** Growth factor per failed attempt; 1 keeps a fixed cap. Default: 2. */
|
||||
/** Finite growth factor of at least 1 per failed attempt; 1 keeps a fixed cap. Default: 2. */
|
||||
backoffFactor?: number
|
||||
/** Maximum retry delay cap in ms; retries continue at this cap. Default: 10000. */
|
||||
backoffMaxMs?: number
|
||||
/** Delay before reporting a slow handshake, without cancelling it. Default: 3000. */
|
||||
/**
|
||||
* Delay before reporting a slow handshake, without cancelling it. Default: 3000.
|
||||
* Omitted when readiness, failure, cancellation, or the hard deadline occurs first.
|
||||
*/
|
||||
generationReadyWarnMs?: number
|
||||
/** Deadline in ms for readiness, including physical connection setup. Default: 15000. */
|
||||
generationReadyTimeoutMs?: number
|
||||
|
||||
@@ -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/client/connection/README.md
|
||||
README.md: 14b004382f2840871a5949d565799940d7dbd5eb
|
||||
README.zh.md: a8ca61eb636c05103f0bee2340f093a819ab6148
|
||||
README.md: b9f986a409b501bda644e2f230b0da51974c13aa
|
||||
README.zh.md: 4912259242f4032563019d26f0d5e6810cc13c7b
|
||||
|
||||
@@ -43,11 +43,11 @@ Before authentication, every request still passes `src/api-request-trust.ts`. It
|
||||
|
||||
API Gateway Client registers the internal `$events` logical stream as the sole generation source, independently of whether any `$on` listener exists. The Host attaches all incremental listeners in the API Remotes source factory, then sends one `{ type: 'ready', clientId, host: { home } }` item before events. `ConnectionController` publishes that generation and calls `onConnected` only after the ready item arrives, so baseline acquisition cannot race ahead of incremental observation.
|
||||
|
||||
An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. A pending handshake logs a slow-Host warning after 3 seconds and aborts after 15 seconds by default, including time spent waiting for the physical socket. The source must stop delivery, release resources, and settle after cancellation before a replacement starts; late readiness from a cancelled source cannot publish a generation. While the browser reports network availability, the controller publishes `connecting` and retries with 50%–100% jitter under caps of 500ms, 1s, 2s, 4s, 8s, and 10s, continuing at the final cap until recovery. Every retry asks Gateway to replace the physical WebSocket once and reopens `$events`. The [continuous recovery decision](../../../.agents/notes/implemented/bug-fix/2026-09-05-continuous-client-recovery.md) owns the deadlines and retry policy.
|
||||
An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. A pending handshake logs a slow-Host warning after 3 seconds and logs the readiness timeout and aborts after 15 seconds by default, including time spent waiting for the physical socket. The source must stop delivery, release resources, and settle after cancellation before a replacement starts; late readiness from a cancelled source cannot publish a generation. While the browser reports network availability, the controller publishes `connecting` and retries with 50%–100% jitter under caps of 500ms, 1s, 2s, 4s, 8s, and 10s, continuing at the final cap until recovery. Every retry asks Gateway to replace the physical WebSocket once and reopens `$events`. The [continuous recovery decision](../../../.agents/notes/implemented/bug-fix/2026-09-05-continuous-client-recovery.md) owns the deadlines and retry policy.
|
||||
|
||||
`ctx.connection.reconnect()` interrupts active work, resets the sequence, and starts retry 1 immediately. Browser `offline` aborts active work, publishes `disconnected`, and suspends automatic attempts; the next `online` transition resets the sequence and starts at the 500ms tier. Only a ready item publishes `connected`. Gateway mux owns no independent retry schedule.
|
||||
|
||||
Set the Host Connection row's `config.recovery` to override retry caps, the growth factor, or handshake warning and cancellation times; the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-client-connection) lists accepted fields. The Host validates these values and injects them into each served page. The Client validates the bootstrap data before providing Connection and uses those defaults when Gateway starts its loop; explicit `start()` timing overrides take precedence. Reload the page after changing Host recovery configuration.
|
||||
Set the Host Connection row's `config.recovery` to override retry caps, the growth factor, or handshake warning and cancellation times; the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-client-connection) lists accepted fields. The Host validates these values and injects them into each served page. The Client validates the bootstrap data before providing Connection and uses those defaults when Gateway starts its loop; explicit `start()` timing overrides take precedence. The growth factor must be finite and at least one. Readiness, failure, cancellation, or a hard deadline that occurs before the warning cancels that warning. Reload the page after changing Host recovery configuration.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
@@ -43,11 +43,11 @@ cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-sessi
|
||||
|
||||
API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation source,与有无 `$on` 订阅无关。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready', clientId, host: { home } }` 项,再发送事件。`ConnectionController` 仅在收到该 ready 项后发布 generation 并调用 `onConnected`,因此 baseline 不会跑在增量 listener 前面。
|
||||
|
||||
`$events` 结束、返回 Remote stream error、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。默认情况下,挂起的握手在 3 秒后记录 Host 响应缓慢告警,在 15 秒后中止,包含等待物理 socket 的时间。取消后,source 必须停止投递、释放资源并结束,替换 source 才能启动;已取消 source 迟到的 ready 不能发布 generation。浏览器报告网络可用时,Controller 发布 `connecting`,并在 500ms、1s、2s、4s、8s 与 10s 上限内采用 50%–100% 抖动重试,达到终档后继续尝试直到恢复。每次重试都要求 Gateway 替换一次物理 WebSocket,再重开 `$events`。[持续恢复决策](../../../.agents/notes/implemented/bug-fix/2026-09-05-continuous-client-recovery.zh.md)规定握手期限与重试策略。
|
||||
`$events` 结束、返回 Remote stream error、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。默认情况下,挂起的握手在 3 秒后记录 Host 响应缓慢告警,在 15 秒后记录就绪超时并中止,包含等待物理 socket 的时间。取消后,source 必须停止投递、释放资源并结束,替换 source 才能启动;已取消 source 迟到的 ready 不能发布 generation。浏览器报告网络可用时,Controller 发布 `connecting`,并在 500ms、1s、2s、4s、8s 与 10s 上限内采用 50%–100% 抖动重试,达到终档后继续尝试直到恢复。每次重试都要求 Gateway 替换一次物理 WebSocket,再重开 `$events`。[持续恢复决策](../../../.agents/notes/implemented/bug-fix/2026-09-05-continuous-client-recovery.zh.md)规定握手期限与重试策略。
|
||||
|
||||
`ctx.connection.reconnect()` 会中断活动工作、重置序列,并立即开始 retry 1。浏览器 `offline` 会中断活动工作、发布 `disconnected` 并暂停自动尝试;下一次 `online` 转换会重置序列并从 500ms 档开始。只有 ready 项会发布 `connected`。Gateway mux 不拥有独立重试调度。
|
||||
|
||||
可通过 Host Connection 行的 `config.recovery` 覆盖重试上限、增长因子或握手告警与取消时间;[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-client-connection)列出接受的字段。Host 校验这些值并注入每个已服务页面。Client 在提供 Connection 前校验启动数据,并在 Gateway 启动循环时采用这些默认值;显式传给 `start()` 的时序覆盖优先。修改 Host 恢复配置后需重新加载页面。
|
||||
可通过 Host Connection 行的 `config.recovery` 覆盖重试上限、增长因子或握手告警与取消时间;[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-client-connection)列出接受的字段。Host 校验这些值并注入每个已服务页面。Client 在提供 Connection 前校验启动数据,并在 Gateway 启动循环时采用这些默认值;显式传给 `start()` 的时序覆盖优先。增长因子必须是至少为一的有限数。若就绪、失败、取消或硬期限先于告警发生,该告警会被取消。修改 Host 恢复配置后需重新加载页面。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
@@ -296,7 +296,9 @@ function waitForReady<T>(
|
||||
console.warn(`[connection] generation is still not ready after ${String(config.generationReadyWarnMs)}ms`)
|
||||
}, config.generationReadyWarnMs)
|
||||
const timeout = setTimeout(() => {
|
||||
finish({ error: new Error(`connection generation was not ready within ${String(config.generationReadyTimeoutMs)}ms`) })
|
||||
const error = new Error(`connection generation was not ready within ${String(config.generationReadyTimeoutMs)}ms`)
|
||||
console.warn(`[connection] ${error.message}; cancelling generation`)
|
||||
finish({ error })
|
||||
}, config.generationReadyTimeoutMs)
|
||||
const aborted = (): void => {
|
||||
finish({ error: new Error('connection generation aborted', { cause: signal.reason }) })
|
||||
|
||||
@@ -135,7 +135,7 @@ export interface ConnectionHandle {
|
||||
* Start the connect/reconnect loop with the consumer's state callbacks.
|
||||
* API Gateway owns the loop; a second call throws.
|
||||
* @param sinks - connection-state callbacks.
|
||||
* @param config - reconnect timing tunables.
|
||||
* @param config - explicit timing overrides; omitted fields use Host bootstrap timing.
|
||||
* @returns lifecycle controls for the loop.
|
||||
*/
|
||||
start(sinks: ConnectionSinks, config?: ConnectionRecoveryConfig): ConnectionLoop
|
||||
|
||||
@@ -10,7 +10,7 @@ import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts'
|
||||
import { assertTrustedAuthority } from './api-request-trust.ts'
|
||||
import { BrowserAuth } from './browser-auth.ts'
|
||||
import { HostConnectionService } from './rpc-host.ts'
|
||||
import { ConnectionConfigSchema, resolveConnectionConfig, type ConnectionRecoveryConfig } from './recovery-config.ts'
|
||||
import { ConnectionRecoveryConfigSchema, resolveConnectionConfig, type ConnectionRecoveryConfig } from './recovery-config.ts'
|
||||
|
||||
export type {
|
||||
ConnectionFetchMethod,
|
||||
@@ -88,7 +88,7 @@ export interface ConnectionConfig {
|
||||
}
|
||||
|
||||
export const Config: z<ConnectionConfig> = z.object({
|
||||
recovery: ConnectionConfigSchema.default({}),
|
||||
recovery: ConnectionRecoveryConfigSchema.default({}),
|
||||
trustedHosts: z.array(String).default([]),
|
||||
cookieMaxAgeDays: z.natural().min(1).default(30),
|
||||
maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES),
|
||||
|
||||
@@ -5,11 +5,14 @@ import z from '@deepseek-ai/schemastery'
|
||||
export interface ConnectionRecoveryConfig {
|
||||
/** First-retry delay cap in ms; actual delay is 50–100% of the cap. Default: 500. */
|
||||
backoffBaseMs?: number
|
||||
/** Growth factor per failed attempt; 1 keeps a fixed cap. Default: 2. */
|
||||
/** Finite growth factor of at least 1 per failed attempt; 1 keeps a fixed cap. Default: 2. */
|
||||
backoffFactor?: number
|
||||
/** Maximum retry delay cap in ms; retries continue at this cap. Default: 10000. */
|
||||
backoffMaxMs?: number
|
||||
/** Delay before reporting a slow handshake, without cancelling it. Default: 3000. */
|
||||
/**
|
||||
* Delay before reporting a slow handshake, without cancelling it. Default: 3000.
|
||||
* Omitted when readiness, failure, cancellation, or the hard deadline occurs first.
|
||||
*/
|
||||
generationReadyWarnMs?: number
|
||||
/** Deadline in ms for readiness, including physical connection setup. Default: 15000. */
|
||||
generationReadyTimeoutMs?: number
|
||||
@@ -19,7 +22,7 @@ export interface ConnectionRecoveryConfig {
|
||||
const MAX_TIMER_MS = 2_147_483_647
|
||||
|
||||
/** Schema shared by the Host plugin and the Client's recovery input parser. */
|
||||
export const ConnectionConfigSchema: z<ConnectionRecoveryConfig> = z.object({
|
||||
export const ConnectionRecoveryConfigSchema: z<ConnectionRecoveryConfig> = z.object({
|
||||
backoffBaseMs: z.natural().min(1).max(MAX_TIMER_MS).default(500),
|
||||
backoffFactor: z.number().min(1).max(Number.MAX_VALUE).default(2),
|
||||
backoffMaxMs: z.natural().min(1).max(MAX_TIMER_MS).default(10_000),
|
||||
@@ -33,5 +36,9 @@ export const ConnectionConfigSchema: z<ConnectionRecoveryConfig> = z.object({
|
||||
* @returns validated, complete recovery timing.
|
||||
*/
|
||||
export function resolveConnectionConfig(config: unknown = {}): Required<ConnectionRecoveryConfig> {
|
||||
return ConnectionConfigSchema(config as ConnectionRecoveryConfig) as Required<ConnectionRecoveryConfig>
|
||||
const resolved = ConnectionRecoveryConfigSchema(config as ConnectionRecoveryConfig) as Required<ConnectionRecoveryConfig>
|
||||
if (!Number.isFinite(resolved.backoffFactor)) {
|
||||
throw new RangeError('connection recovery backoffFactor must be finite')
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -97,13 +97,25 @@ describe('connection client apply', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects malformed bootstrap recovery before publishing the service', () => {
|
||||
vi.stubGlobal('__DSH_CONNECTION_RECOVERY__', { generationReadyTimeoutMs: 0 })
|
||||
it.each([{ generationReadyTimeoutMs: 0 }, { backoffFactor: NaN }])('rejects malformed bootstrap recovery before publishing the service: %j', (recovery) => {
|
||||
vi.stubGlobal('__DSH_CONNECTION_RECOVERY__', recovery)
|
||||
const ctx = new Context()
|
||||
expect(() => { apply(ctx) }).toThrow()
|
||||
expect(ctx.get('connection')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a NaN start override without acquiring the generation source', async () => {
|
||||
const handle = await mount()
|
||||
const source = vi.fn<ConnectionGenerationSource>()
|
||||
const unregister = handle.registerGenerationSource(source)
|
||||
try {
|
||||
expect(() => handle.start({}, { backoffFactor: NaN })).toThrow(/backoffFactor.*finite/)
|
||||
expect(source).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
unregister()
|
||||
}
|
||||
})
|
||||
|
||||
it('treats a runtime without browser location as local', async () => {
|
||||
delete (globalThis as Win).location
|
||||
expect((await mount()).isLoopback).toBe(true)
|
||||
|
||||
@@ -612,6 +612,7 @@ describe('connection lifecycle', () => {
|
||||
expect(connected).toBe(1)
|
||||
expect(source.activeCount).toBe(1)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
controller.stop()
|
||||
warnSpy.mockRestore()
|
||||
@@ -619,7 +620,7 @@ describe('connection lifecycle', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('cancels an unready generation, waits for cleanup, and ignores its late ready', async () => {
|
||||
it.each([20, 100, 200])('cancels an unready generation with warn=%i ms, waits for cleanup, and ignores late ready', async (generationReadyWarnMs) => {
|
||||
vi.useFakeTimers()
|
||||
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
@@ -639,16 +640,22 @@ describe('connection lifecycle', () => {
|
||||
const controller = new ConnectionController(source, {
|
||||
onConnected: connected,
|
||||
onStateChange: state => states.push(state),
|
||||
}, { ...FAST, generationReadyTimeoutMs: 100 })
|
||||
}, { ...FAST, generationReadyWarnMs, generationReadyTimeoutMs: 100 })
|
||||
controller.start()
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(signals[0]?.aborted).toBe(true)
|
||||
expect(signals[0]?.reason).toMatchObject({ message: 'connection generation was not ready within 100ms' })
|
||||
const warnings = generationReadyWarnMs <= 100
|
||||
? [[`[connection] generation is still not ready after ${String(generationReadyWarnMs)}ms`]]
|
||||
: []
|
||||
warnings.push(['[connection] connection generation was not ready within 100ms; cancelling generation'])
|
||||
expect(warnSpy.mock.calls).toEqual(warnings)
|
||||
report[0]!({ home: '/stale' })
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(signals).toHaveLength(1)
|
||||
expect(connected).not.toHaveBeenCalled()
|
||||
expect(warnSpy.mock.calls).toEqual(warnings)
|
||||
cleanup.resolve(undefined)
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
expect(signals).toHaveLength(2)
|
||||
@@ -667,7 +674,7 @@ describe('connection lifecycle', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['stop', 'reconnect'] as const)('clears a pending handshake deadline on %s', async (action) => {
|
||||
it.each(['stop', 'reconnect', 'failure'] as const)('clears a pending handshake deadline on %s', async (action) => {
|
||||
vi.useFakeTimers()
|
||||
const source = new FakeGenerationSource()
|
||||
source.holdReady = true
|
||||
@@ -680,7 +687,8 @@ describe('connection lifecycle', () => {
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
source.holdReady = false
|
||||
controller[action]()
|
||||
if (action === 'failure') source.fail(new Error('carrier failed'))
|
||||
else controller[action]()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
source.releaseReady()
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
@@ -688,6 +696,7 @@ describe('connection lifecycle', () => {
|
||||
expect(connected).toHaveBeenCalledTimes(action === 'stop' ? 0 : 1)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(warnSpy.mock.calls.some(([message]) => String(message).includes('still not ready'))).toBe(false)
|
||||
expect(warnSpy.mock.calls.some(([message]) => String(message).includes('cancelling generation'))).toBe(false)
|
||||
} finally {
|
||||
controller.stop()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
@@ -138,9 +138,12 @@ describe('connection node half', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid recovery timing before acquiring Host resources', async () => {
|
||||
it.each([
|
||||
{ recovery: { backoffBaseMs: 0 }, error: /backoffBaseMs/ },
|
||||
{ recovery: { backoffFactor: NaN }, error: /backoffFactor.*finite/ },
|
||||
])('rejects invalid recovery timing before acquiring Host resources: $recovery', async ({ recovery, error }) => {
|
||||
const ctx = new Context()
|
||||
await expect(apply(ctx, { recovery: { backoffBaseMs: 0 } })).rejects.toThrow()
|
||||
await expect(apply(ctx, { recovery })).rejects.toThrow(error)
|
||||
expect(ctx.get('connection')).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('connection recovery configuration', () => {
|
||||
{ backoffBaseMs: 0 },
|
||||
{ backoffFactor: 0.5 },
|
||||
{ backoffFactor: Infinity },
|
||||
{ backoffFactor: NaN },
|
||||
{ backoffMaxMs: -1 },
|
||||
{ generationReadyWarnMs: NaN },
|
||||
{ generationReadyTimeoutMs: 2_147_483_648 },
|
||||
@@ -24,4 +25,8 @@ describe('connection recovery configuration', () => {
|
||||
])('rejects timing that could disable recovery or overflow a timer: %j', (config) => {
|
||||
expect(() => resolveConnectionConfig(config)).toThrow()
|
||||
})
|
||||
|
||||
it('preserves a finite fractional growth factor', () => {
|
||||
expect(resolveConnectionConfig({ backoffFactor: 1.5 }).backoffFactor).toBe(1.5)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user