fix(stage-tamagotchi): should handle EADDRINUSE, bug of srvx, robust for server channel restart, injeca fix

This commit is contained in:
Neko Ayaka
2026-03-10 23:47:39 +08:00
parent e6ac3ff46e
commit a2da08f2ce
14 changed files with 432 additions and 652 deletions
+2 -1
View File
@@ -87,6 +87,7 @@
"@xsai/utils-chat": "catalog:",
"alien-signals": "catalog:",
"animejs": "^4.3.6",
"async-mutex": "catalog:",
"colorjs.io": "^0.6.1",
"crossws": "^0.4.4",
"culori": "^4.0.2",
@@ -98,7 +99,7 @@
"electron-click-drag-plugin": "^2.0.2",
"electron-updater": "^6.8.3",
"es-toolkit": "^1.44.0",
"h3": "2.0.1-rc.5",
"h3": "^2.0.1-rc.14",
"injeca": "catalog:",
"jszip": "^3.10.1",
"localforage": "^1.10.0",
+3 -3
View File
@@ -11,7 +11,7 @@ import { Format, LogLevel, setGlobalFormat, setGlobalHookPostLog, setGlobalLogLe
import { initScreenCaptureForMain } from '@proj-airi/electron-screen-capture/main'
import { app, ipcMain } from 'electron'
import { noop } from 'es-toolkit'
import { createLoggLogger, injeca } from 'injeca'
import { createLoggLogger, injeca, lifecycle } from 'injeca'
import { isLinux } from 'std-env'
import icon from '../../resources/icon.png?asset'
@@ -106,8 +106,8 @@ app.whenReady().then(async () => {
})
const serverChannel = injeca.provide('modules:channel-server', {
dependsOn: { app: electronApp },
build: async () => setupServerChannel(),
dependsOn: { app: electronApp, lifecycle },
build: async ({ dependsOn }) => setupServerChannel(dependsOn),
})
const mcpStdioManager = injeca.provide('modules:mcp-stdio-manager', {
@@ -1,134 +1,66 @@
import type { ElectronServerChannelTlsConfig } from '../../../../shared/eventa'
import type { Server, ServerOptions } from '@proj-airi/server-runtime/server'
import type { Lifecycle } from 'injeca'
import { X509Certificate } from 'node:crypto'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { isIP } from 'node:net'
import { networkInterfaces } from 'node:os'
import { join } from 'node:path'
import { env, platform } from 'node:process'
import { useLogg } from '@guiiai/logg'
import { defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { createServer, getLocalIPs } from '@proj-airi/server-runtime/server'
import { Mutex } from 'async-mutex'
import { app, ipcMain } from 'electron'
import { createCA, createCert } from 'mkcert'
import { x } from 'tinyexec'
import { nullable, object, record, string, unknown } from 'valibot'
import { nullable, object, optional, string } from 'valibot'
import { z } from 'zod'
import {
electronApplyServerChannelConfig,
electronGetServerChannelConfig,
} from '../../../../shared/eventa'
import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle'
import { createConfig } from '../../../libs/electron/persistence'
interface ServerInstance { close: (closeActiveConnections?: boolean) => Promise<void> }
interface ServerChannelOptions { websocketTlsConfig?: ElectronServerChannelTlsConfig | null }
export interface ServerChannel {
start: () => Promise<void>
stop: () => Promise<void>
restart: () => Promise<void>
updateConfig: (newOptions: ServerChannelOptions) => void
}
let isServerQuitHookRegistered = false
const channelServerConfigSchema = object({
websocketTlsConfig: nullable(record(string(), unknown())),
tlsConfig: optional(nullable(object({
cert: optional(string()),
key: optional(string()),
passphrase: optional(string()),
}))),
})
const channelServerInvokeConfigSchema = z.object({
websocketTlsConfig: z.record(z.string(), z.unknown()).nullable().optional(),
tlsConfig: z.object({ }).nullable().optional(),
}).strict()
const channelServerConfigStore = createConfig('server-channel', 'config.json', channelServerConfigSchema, {
default: {
websocketTlsConfig: null,
tlsConfig: null,
},
autoHeal: true,
})
function getChannelServerConfig() {
return channelServerConfigStore.get() ?? { websocketTlsConfig: null }
async function getChannelServerConfig(): Promise<ServerOptions> {
return channelServerConfigStore.get() || { tlsConfig: null }
}
function normalizeChannelServerOptions(
payload: unknown,
fallback = getChannelServerConfig(),
) {
async function normalizeChannelServerOptions(payload: unknown, fallback?: ServerOptions) {
if (!fallback) {
fallback = await getChannelServerConfig()
}
const parsed = channelServerInvokeConfigSchema.safeParse(payload)
if (!parsed.success) {
return fallback
}
return {
websocketTlsConfig: typeof parsed.data.websocketTlsConfig === 'undefined' ? null : parsed.data.websocketTlsConfig,
tlsConfig: typeof parsed.data.tlsConfig === 'undefined' ? null : parsed.data.tlsConfig,
}
}
function registerServerQuitHook(getServerChannel: () => ServerChannel | null) {
if (isServerQuitHookRegistered)
return
isServerQuitHookRegistered = true
onAppBeforeQuit(async () => {
const log = useLogg('main/server-runtime').useGlobalConfig()
const serverChannel = getServerChannel()
if (!serverChannel) {
return
}
try {
await serverChannel.stop()
log.log('WebSocket server closed')
}
catch (error) {
log.withError(error).error('Error closing WebSocket server')
}
})
}
function getLocalIPs(): string[] {
const interfaces = networkInterfaces()
const addresses: string[] = []
const VIRTUAL_INTERFACE_PREFIXES = [
'vboxnet',
'vmnet',
'docker',
'br-',
'veth',
'utun',
'wg',
'tap',
'tun',
]
const isVirtualInterface = (name: string) =>
VIRTUAL_INTERFACE_PREFIXES.some(prefix => name.startsWith(prefix))
for (const [name, entries] of Object.entries(interfaces)) {
if (!entries)
continue
if (isVirtualInterface(name))
continue
for (const entry of entries) {
const rawAddress = entry.address
if (!rawAddress)
continue
const address = rawAddress.includes('%') ? rawAddress.split('%')[0] : rawAddress
if (isIP(address))
addresses.push(address)
}
}
return addresses
}
function getCertificateDomains(): string[] {
const localIPs = getLocalIPs()
const hostname = env.SERVER_RUNTIME_HOSTNAME
@@ -264,170 +196,132 @@ async function getOrCreateCertificate() {
return { cert, key }
}
export function createServerChannel(initialOptions: ServerChannelOptions = getChannelServerConfig()): ServerChannel {
const log = useLogg('main/server-runtime').useGlobalConfig()
let serverInstance: ServerInstance | null = null
let options = initialOptions
export async function setupServerChannel(params: { lifecycle: Lifecycle }): Promise<Server> {
channelServerConfigStore.setup()
log.withFields({ hasTlsConfig: !!options.websocketTlsConfig }).log('creating server channel')
const storedConfig = await getChannelServerConfig()
async function closeServer(closeActiveConnections = false) {
if (!serverInstance || typeof serverInstance.close !== 'function') {
return
}
const serverChannel = createServer({
...storedConfig,
port: env.PORT ? Number.parseInt(env.PORT) : 6121,
hostname: env.SERVER_RUNTIME_HOSTNAME || '0.0.0.0',
tlsConfig: storedConfig.tlsConfig ? await getOrCreateCertificate() : null,
})
const mutex = new Mutex()
params.lifecycle.appHooks.onStart(async () => {
const release = await mutex.acquire()
const log = useLogg('main/server-runtime').useGlobalConfig()
try {
if (closeActiveConnections) {
log.log('closing existing server instance')
}
await serverInstance.close(closeActiveConnections)
if (closeActiveConnections) {
log.log('existing server instance closed')
}
await serverChannel.start()
log.log('WebSocket server started')
}
catch (error) {
const nodejsError = error as NodeJS.ErrnoException
if ('code' in nodejsError && nodejsError.code === 'ERR_SERVER_NOT_RUNNING') {
return
}
if (!closeActiveConnections) {
log.withError(error).error('Error closing WebSocket server')
}
log.withError(error).error('Error starting WebSocket server')
}
finally {
serverInstance = null
release()
}
}
})
params.lifecycle.appHooks.onStop(async () => {
const release = await mutex.acquire()
async function start() {
if (serverInstance) {
const log = useLogg('main/server-runtime').useGlobalConfig()
if (!serverChannel) {
return
}
const secureEnabled = options?.websocketTlsConfig != null
try {
const serverRuntime = await import('@proj-airi/server-runtime')
const { plugin: ws } = await import('crossws/server')
const { serve } = await import('h3')
const h3App = serverRuntime.setupApp()
const port = env.PORT ? Number(env.PORT) : 6121
const hostname = env.SERVER_RUNTIME_HOSTNAME || '0.0.0.0'
// FIXME: should prompt user to grant permission to save certificate files on macOS
const tls = secureEnabled ? await getOrCreateCertificate() : undefined
const instance = serve(h3App.app, {
// @ts-expect-error - the .crossws property wasn't extended in types
plugins: [ws({ resolve: async req => (await h3App.app.fetch(req)).crossws })],
port,
hostname,
tls,
reusePort: true,
silent: true,
manual: true,
gracefulShutdown: {
forceTimeout: 0.5,
gracefulTimeout: 0.5,
},
})
serverInstance = {
close: async (closeActiveConnections = false) => {
log.log('closing all peers')
h3App.closeAllPeers()
log.log('closing server instance')
await instance.close(closeActiveConnections)
log.log('server instance closed')
},
}
const servePromise = instance.serve()
if (servePromise instanceof Promise) {
servePromise.catch((error) => {
const nodejsError = error as NodeJS.ErrnoException
if ('code' in nodejsError && nodejsError.code === 'EADDRINUSE') {
log.withError(error).warn('Port already in use, assuming server is already running')
return
}
log.withError(error).error('Error serving WebSocket server')
})
}
const protocol = secureEnabled ? 'wss' : 'ws'
if (hostname === '0.0.0.0') {
const ips = getLocalIPs().filter(ip => ip !== '127.0.0.1' && ip !== '::1')
const targets = ips.length > 0 ? ips.join(', ') : 'localhost'
log.log(`@proj-airi/server-runtime started on ${protocol}://0.0.0.0:${port} (reachable via: ${targets})`)
}
else {
log.log(`@proj-airi/server-runtime started on ${protocol}://${hostname}:${port}`)
}
await serverChannel.stop()
log.log('WebSocket server closed')
}
catch (error) {
log.withError(error).error('failed to start WebSocket server')
log.withError(error).error('Error closing WebSocket server')
}
}
async function stop() {
await closeServer()
}
async function restart() {
log.log('restarting server channel', { options })
await closeServer(true)
await start()
}
async function updateConfig(newOptions: ServerChannelOptions) {
options = { ...options, ...newOptions }
}
finally {
release()
}
})
return {
start,
stop,
restart,
updateConfig,
getConnectionHost() {
return serverChannel.getConnectionHost()
},
async start() {
const release = await mutex.acquire()
try {
await serverChannel.start()
}
finally {
release()
}
},
async restart() {
const release = await mutex.acquire()
try {
await serverChannel.stop()
await serverChannel.start()
}
finally {
release()
}
},
async stop() {
const release = await mutex.acquire()
try {
await serverChannel.stop()
}
finally {
release()
}
},
async updateConfig(config) {
const release = await mutex.acquire()
try {
await serverChannel.updateConfig(config)
}
finally {
release()
}
},
}
}
export async function setupServerChannel() {
channelServerConfigStore.setup()
const serverChannel = createServerChannel(getChannelServerConfig())
registerServerQuitHook(() => serverChannel)
// Start the server during module initialization so startup is bound to injeca lifecycle.
await serverChannel.start()
return serverChannel
}
export async function createServerChannelService(params: { serverChannel: ServerChannel }) {
export async function createServerChannelService(params: { serverChannel: Server }) {
const { context } = createContext(ipcMain)
defineInvokeHandler(context, electronGetServerChannelConfig, async () => {
return getChannelServerConfig()
return await getChannelServerConfig()
})
defineInvokeHandler(context, electronApplyServerChannelConfig, async (req) => {
const current = getChannelServerConfig()
const next = normalizeChannelServerOptions(req, current)
const changed = JSON.stringify(next.websocketTlsConfig) !== JSON.stringify(current.websocketTlsConfig)
try {
const current = await getChannelServerConfig()
const next = await normalizeChannelServerOptions(req, current)
const changed = JSON.stringify(next.tlsConfig) !== JSON.stringify(current.tlsConfig)
channelServerConfigStore.update(next)
channelServerConfigStore.update(next)
if (changed) {
await params.serverChannel.stop()
await params.serverChannel.updateConfig(next)
await params.serverChannel.start()
if (changed) {
await params.serverChannel.stop()
await params.serverChannel.updateConfig({
port: env.PORT ? Number.parseInt(env.PORT) : 6121,
hostname: env.SERVER_RUNTIME_HOSTNAME || '0.0.0.0',
tlsConfig: next.tlsConfig ? await getOrCreateCertificate() : null,
})
await params.serverChannel.start()
}
else {
await params.serverChannel.start()
}
return next
}
else {
await params.serverChannel.start()
catch (error) {
useLogg('main/server-runtime').withError(error).error('Failed to apply server channel configuration')
}
return next
})
}
@@ -1,5 +1,3 @@
import type { ElectronServerChannelTlsConfig } from '../../../shared/eventa'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useAsyncState, useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
@@ -8,7 +6,7 @@ import { watch } from 'vue'
import { electronApplyServerChannelConfig, electronGetServerChannelConfig } from '../../../shared/eventa'
export const useServerChannelSettingsStore = defineStore('tamagotchi-server-channel-settings', () => {
const websocketTlsConfig = useLocalStorage<ElectronServerChannelTlsConfig | null>('settings/server-channel/websocket-tls-config', null)
const websocketTlsConfig = useLocalStorage<{ cert?: string, key?: string, passphrase?: string } | null>('settings/server-channel/websocket-tls-config', null)
const getServerChannelConfig = useElectronEventaInvoke(electronGetServerChannelConfig)
const applyServerChannelConfig = useElectronEventaInvoke(electronApplyServerChannelConfig)
@@ -17,11 +15,11 @@ export const useServerChannelSettingsStore = defineStore('tamagotchi-server-chan
watch(websocketTlsConfig, async (newValue) => {
websocketTlsConfig.value = newValue
await applyServerChannelConfig({ websocketTlsConfig: newValue ? {} : null })
await applyServerChannelConfig({ tlsConfig: newValue ? {} : null })
})
watch(serverChannelConfig.state, (newConfig) => {
websocketTlsConfig.value = newConfig?.websocketTlsConfig
websocketTlsConfig.value = newConfig?.tlsConfig
})
return {
+2 -5
View File
@@ -1,4 +1,5 @@
import type { Locale } from '@intlify/core'
import type { ServerOptions } from '@proj-airi/server-runtime/server'
import { defineEventa, defineInvokeEventa } from '@moeru/eventa'
@@ -11,12 +12,8 @@ export const electronOpenChat = defineInvokeEventa('eventa:invoke:electron:windo
export const electronOpenSettingsDevtools = defineInvokeEventa('eventa:invoke:electron:windows:settings:devtools:open')
export const electronOpenDevtoolsWindow = defineInvokeEventa<void, { route?: string }>('eventa:invoke:electron:windows:devtools:open')
export interface ElectronServerChannelTlsConfig {
[key: string]: unknown
}
export interface ElectronServerChannelConfig {
websocketTlsConfig: ElectronServerChannelTlsConfig | null
tlsConfig?: ServerOptions['tlsConfig'] | null
}
export const electronGetServerChannelConfig = defineInvokeEventa<ElectronServerChannelConfig>('eventa:invoke:electron:server-channel:get-config')
export const electronApplyServerChannelConfig = defineInvokeEventa<ElectronServerChannelConfig, Partial<ElectronServerChannelConfig>>('eventa:invoke:electron:server-channel:apply-config')