Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/issues/startup-warning-cleanup/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Startup Warning Cleanup Plan

## Implementation

- Add a lifecycle delay normalization helper and use it before the development-only hook delay timer.
- Add `EventBus.sendToRendererIfAvailable(...)`, sharing the existing renderer dispatch behavior while
returning `false` silently when `WindowPresenter` is unavailable.
- Update `EventBus.send(...)` to emit to main listeners first and then use the optional renderer send.
- Route ACP/session-list refresh notifications through the optional renderer send when no renderer may
exist yet.

## Tests

- Unit-test lifecycle delay normalization for missing, empty, invalid, negative, fractional, and valid
values.
- Extend EventBus tests to cover optional renderer delivery, direct renderer warnings, and main-process
delivery without a renderer.
20 changes: 20 additions & 0 deletions docs/issues/startup-warning-cleanup/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Startup Warning Cleanup Spec

## Goal

Startup should not print misleading warning noise when development lifecycle settings are omitted
or when expected early main-process events fire before any renderer window exists.

## Acceptance Criteria

- Missing, empty, invalid, non-finite, and negative `VITE_APP_LIFECYCLE_HOOK_DELAY` values produce a
`0` millisecond hook delay and never trigger `TimeoutNaNWarning`.
- `EventBus.send(...)` still emits to main-process listeners when no `WindowPresenter` is registered.
- `EventBus.send(...)` does not warn when renderer delivery is unavailable during startup.
- Direct `EventBus.sendToRenderer(...)` keeps warning when called without a `WindowPresenter`.
- Startup ACP/session-list notifications use the quiet renderer path when renderer delivery is optional.

## Non-Goals

- No IPC, preload, renderer UI, database, or migration changes.
- No changes to event payload shapes.
7 changes: 7 additions & 0 deletions docs/issues/startup-warning-cleanup/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Startup Warning Cleanup Tasks

- [x] Add lifecycle hook delay normalization.
- [x] Add optional EventBus renderer send.
- [x] Route startup-safe config notifications through optional renderer delivery.
- [x] Add focused unit tests.
- [x] Run targeted tests and required formatting/i18n/lint checks.
42 changes: 33 additions & 9 deletions src/main/eventbus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,44 @@ export class EventBus extends EventEmitter {
return
}

this.dispatchToRenderer(this.windowPresenter, eventName, target, ...args)
}

/**
* 向渲染进程发送事件(如果窗口展示器已可用)
* @returns 是否已发送到渲染进程
*/
sendToRendererIfAvailable(
eventName: string,
target: SendTarget = SendTarget.ALL_WINDOWS,
...args: unknown[]
): boolean {
if (!this.windowPresenter) {
return false
}

this.dispatchToRenderer(this.windowPresenter, eventName, target, ...args)
return true
}

private dispatchToRenderer(
windowPresenter: IWindowPresenter,
eventName: string,
target: SendTarget = SendTarget.ALL_WINDOWS,
...args: unknown[]
) {
switch (target) {
case SendTarget.ALL_WINDOWS:
this.windowPresenter.sendToAllWindows(eventName, ...args)
windowPresenter.sendToAllWindows(eventName, ...args)
break
case SendTarget.DEFAULT_WINDOW:
windowPresenter.sendToDefaultWindow(eventName, true, ...args)
break
case SendTarget.DEFAULT_TAB:
if (typeof this.windowPresenter.sendToDefaultWindow === 'function') {
this.windowPresenter.sendToDefaultWindow(eventName, true, ...args)
} else {
this.windowPresenter.sendToDefaultTab(eventName, true, ...args)
}
windowPresenter.sendToDefaultTab(eventName, true, ...args)
break
default:
this.windowPresenter.sendToAllWindows(eventName, ...args)
windowPresenter.sendToAllWindows(eventName, ...args)
}
}

Expand All @@ -70,8 +94,8 @@ export class EventBus extends EventEmitter {
// 发送到主进程
this.sendToMain(eventName, ...args)

// 发送到渲染进程
this.sendToRenderer(eventName, target, ...args)
// 发送到渲染进程(启动早期没有窗口时静默跳过)
this.sendToRendererIfAvailable(eventName, target, ...args)
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/main/presenter/configPresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2603,7 +2603,7 @@ export class ConfigPresenter implements IConfigPresenter {
console.log('[ACP] notifyAcpAgentsChanged: sending MODEL_LIST_CHANGED event for provider "acp"')
eventBus.send(CONFIG_EVENTS.MODEL_LIST_CHANGED, SendTarget.ALL_WINDOWS, 'acp')
eventBus.send(CONFIG_EVENTS.AGENTS_CHANGED, SendTarget.ALL_WINDOWS, { agentIds })
eventBus.sendToRenderer(SESSION_EVENTS.LIST_UPDATED, SendTarget.ALL_WINDOWS)
eventBus.sendToRendererIfAvailable(SESSION_EVENTS.LIST_UPDATED, SendTarget.ALL_WINDOWS)
}

// Provide getMcpConfHelper method to get MCP configuration helper
Expand Down
5 changes: 4 additions & 1 deletion src/main/presenter/lifecyclePresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from './types'
import { is } from '@electron-toolkit/utils'
import { presenter } from '@/presenter'
import { normalizeLifecycleHookDelayMs } from './lifecycleDelay'

export { registerCoreHooks } from './coreHooks'

Expand Down Expand Up @@ -379,7 +380,9 @@ export class LifecycleManager implements ILifecycleManager {
const result = await hook.execute(context)

if (is.dev) {
const hookDelay = Number(import.meta.env.VITE_APP_LIFECYCLE_HOOK_DELAY)
const hookDelay = normalizeLifecycleHookDelayMs(
import.meta.env.VITE_APP_LIFECYCLE_HOOK_DELAY
)
await new Promise((resolve) => setTimeout(resolve, hookDelay))
}

Expand Down
12 changes: 12 additions & 0 deletions src/main/presenter/lifecyclePresenter/lifecycleDelay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function normalizeLifecycleHookDelayMs(value: unknown): number {
if (value === undefined || value === null || value === '') {
return 0
}

const delayMs = Number(value)
if (!Number.isFinite(delayMs) || delayMs < 0) {
return 0
}

return delayMs
}
52 changes: 52 additions & 0 deletions test/main/eventbus/eventbus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ describe('EventBus 事件总线', () => {
)
})

it('应该能够发送事件到默认标签页', () => {
const eventName = 'renderer:default-tab'
const testData = { message: 'default tab' }

eventBus.sendToRenderer(eventName, SendTarget.DEFAULT_TAB, testData)

expect(mockWindowPresenter.sendToDefaultTab).toHaveBeenCalledWith(eventName, true, testData)
expect(mockWindowPresenter.sendToDefaultWindow).not.toHaveBeenCalled()
})

it('当WindowPresenter未设置时应该显示警告', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const newEventBus = new EventBus()
Expand All @@ -136,6 +146,32 @@ describe('EventBus 事件总线', () => {

consoleSpy.mockRestore()
})

it('当WindowPresenter未设置时应该静默跳过可选渲染发送', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const newEventBus = new EventBus()

const sent = newEventBus.sendToRendererIfAvailable(
'test:event',
SendTarget.ALL_WINDOWS,
'data'
)

expect(sent).toBe(false)
expect(consoleSpy).not.toHaveBeenCalled()

consoleSpy.mockRestore()
})

it('应该能够通过可选渲染发送路径发送到所有窗口', () => {
const eventName = 'renderer:optional'
const testData = { message: 'optional renderer' }

const sent = eventBus.sendToRendererIfAvailable(eventName, SendTarget.ALL_WINDOWS, testData)

expect(sent).toBe(true)
expect(mockWindowPresenter.sendToAllWindows).toHaveBeenCalledWith(eventName, testData)
})
})

describe('同时发送到主进程和渲染进程', () => {
Expand Down Expand Up @@ -167,6 +203,22 @@ describe('EventBus 事件总线', () => {

expect(mockWindowPresenter.sendToAllWindows).toHaveBeenCalledWith(eventName, testData)
})

it('当WindowPresenter未设置时仍应发送到主进程且不警告', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const newEventBus = new EventBus()
const eventName = 'both:no-renderer'
const testData = { message: 'main only' }
const mockListener = vi.fn()
newEventBus.on(eventName, mockListener)

newEventBus.send(eventName, SendTarget.ALL_WINDOWS, testData)

expect(mockListener).toHaveBeenCalledWith(testData)
expect(consoleSpy).not.toHaveBeenCalled()

consoleSpy.mockRestore()
})
})

describe('webContents 路由相关功能', () => {
Expand Down
22 changes: 22 additions & 0 deletions test/main/presenter/lifecyclePresenter/lifecycleDelay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { normalizeLifecycleHookDelayMs } from '@/presenter/lifecyclePresenter/lifecycleDelay'

describe('normalizeLifecycleHookDelayMs', () => {
it('defaults missing and empty values to zero', () => {
expect(normalizeLifecycleHookDelayMs(undefined)).toBe(0)
expect(normalizeLifecycleHookDelayMs(null)).toBe(0)
expect(normalizeLifecycleHookDelayMs('')).toBe(0)
})

it('defaults invalid, non-finite, and negative values to zero', () => {
expect(normalizeLifecycleHookDelayMs('invalid')).toBe(0)
expect(normalizeLifecycleHookDelayMs(Number.NaN)).toBe(0)
expect(normalizeLifecycleHookDelayMs(Number.POSITIVE_INFINITY)).toBe(0)
expect(normalizeLifecycleHookDelayMs('-1')).toBe(0)
})

it('preserves fractional and valid delay values', () => {
expect(normalizeLifecycleHookDelayMs('1.5')).toBe(1.5)
expect(normalizeLifecycleHookDelayMs(25)).toBe(25)
})
})