mirror of
https://github.com/aaif-goose/goose.git
synced 2026-06-02 06:14:27 +02:00
fix: lazy init local inference runtime in router instead of app start (#8656)
This commit is contained in:
@@ -9,6 +9,10 @@ use goose_server::tls::setup_tls;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tracing::info;
|
||||
|
||||
fn boot_marker(message: &str) {
|
||||
eprintln!("GOOSED_BOOT: {message}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn shutdown_signal() {
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
@@ -35,6 +39,7 @@ pub async fn run() -> Result<()> {
|
||||
#[cfg(feature = "rustls-tls")]
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
boot_marker("main entered");
|
||||
crate::logging::setup_logging(Some("goosed"))?;
|
||||
|
||||
let settings = configuration::Settings::new()?;
|
||||
@@ -42,6 +47,7 @@ pub async fn run() -> Result<()> {
|
||||
let secret_key = std::env::var("GOOSE_SERVER__SECRET_KEY")
|
||||
.unwrap_or_else(|_| hex::encode(rand::random::<[u8; 32]>()));
|
||||
|
||||
boot_marker("appstate init start");
|
||||
let app_state = state::AppState::new(settings.tls).await?;
|
||||
|
||||
// Share the server secret with the tunnel manager so it uses the same
|
||||
@@ -78,6 +84,7 @@ pub async fn run() -> Result<()> {
|
||||
if settings.tls {
|
||||
#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
|
||||
{
|
||||
boot_marker("tls setup start");
|
||||
let tls_setup = setup_tls(
|
||||
settings.tls_cert_path.as_deref(),
|
||||
settings.tls_key_path.as_deref(),
|
||||
@@ -92,6 +99,7 @@ pub async fn run() -> Result<()> {
|
||||
});
|
||||
|
||||
info!("listening on https://{}", addr);
|
||||
boot_marker("listening");
|
||||
|
||||
#[cfg(feature = "rustls-tls")]
|
||||
axum_server::bind_rustls(addr, tls_setup.config)
|
||||
@@ -114,9 +122,11 @@ pub async fn run() -> Result<()> {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
boot_marker("tcp bind start");
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
|
||||
info!("listening on http://{}", addr);
|
||||
boot_marker("listening");
|
||||
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async { shutdown_signal().await })
|
||||
|
||||
@@ -9,6 +9,7 @@ mod state;
|
||||
mod tunnel;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::{backtrace::Backtrace, panic::PanicHookInfo};
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use goose::agents::validate_extensions;
|
||||
@@ -42,9 +43,42 @@ enum Commands {
|
||||
},
|
||||
}
|
||||
|
||||
fn boot_marker(message: &str) {
|
||||
eprintln!("GOOSED_BOOT: {message}");
|
||||
}
|
||||
|
||||
fn install_panic_hook() {
|
||||
let default_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |panic_info: &PanicHookInfo<'_>| {
|
||||
let location = panic_info
|
||||
.location()
|
||||
.map(|location| format!("{}:{}", location.file(), location.line()))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let payload = panic_info
|
||||
.payload()
|
||||
.downcast_ref::<&str>()
|
||||
.map(|msg| (*msg).to_string())
|
||||
.or_else(|| panic_info.payload().downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "unknown panic payload".to_string());
|
||||
|
||||
eprintln!("GOOSED_BOOT: panic at {location}: {payload}");
|
||||
eprintln!("GOOSED_BOOT: backtrace:\n{}", Backtrace::force_capture());
|
||||
|
||||
default_hook(panic_info);
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
install_panic_hook();
|
||||
boot_marker("main entered");
|
||||
|
||||
let cli = Cli::parse();
|
||||
boot_marker(&format!(
|
||||
"command parsed: {:?}",
|
||||
std::mem::discriminant(&cli.command)
|
||||
));
|
||||
|
||||
match cli.command {
|
||||
Commands::Agent => {
|
||||
|
||||
@@ -230,7 +230,8 @@ pub async fn sync_featured_models() -> Result<StatusCode, ErrorResponse> {
|
||||
pub async fn list_local_models(
|
||||
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
|
||||
) -> Result<Json<Vec<LocalModelResponse>>, ErrorResponse> {
|
||||
let recommended_id = recommend_local_model(&state.inference_runtime);
|
||||
let runtime = state.get_inference_runtime()?;
|
||||
let recommended_id = recommend_local_model(&runtime);
|
||||
|
||||
let registry = get_registry()
|
||||
.lock()
|
||||
@@ -360,7 +361,8 @@ pub async fn get_repo_files(
|
||||
.await
|
||||
.map_err(|e| ErrorResponse::internal(format!("Failed to fetch repo files: {}", e)))?;
|
||||
|
||||
let available_memory = available_inference_memory_bytes(&state.inference_runtime);
|
||||
let runtime = state.get_inference_runtime()?;
|
||||
let available_memory = available_inference_memory_bytes(&runtime);
|
||||
let recommended_index = hf_models::recommend_variant(&variants, available_memory);
|
||||
|
||||
let downloaded_quants = {
|
||||
|
||||
@@ -5,7 +5,7 @@ use goose::scheduler_trait::SchedulerTrait;
|
||||
use goose::session::SessionManager;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
@@ -28,7 +28,7 @@ pub struct AppState {
|
||||
pub gateway_manager: Arc<GatewayManager>,
|
||||
pub extension_loading_tasks: ExtensionLoadingTasks,
|
||||
#[cfg(feature = "local-inference")]
|
||||
pub inference_runtime: Arc<InferenceRuntime>,
|
||||
inference_runtime: Arc<OnceLock<Arc<InferenceRuntime>>>,
|
||||
session_buses: Arc<Mutex<HashMap<String, Arc<SessionEventBus>>>>,
|
||||
}
|
||||
|
||||
@@ -48,11 +48,31 @@ impl AppState {
|
||||
gateway_manager,
|
||||
extension_loading_tasks: Arc::new(Mutex::new(HashMap::new())),
|
||||
#[cfg(feature = "local-inference")]
|
||||
inference_runtime: InferenceRuntime::get_or_init(),
|
||||
inference_runtime: Arc::new(OnceLock::new()),
|
||||
session_buses: Arc::new(Mutex::new(HashMap::new())),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(feature = "local-inference")]
|
||||
pub fn get_inference_runtime(&self) -> anyhow::Result<Arc<InferenceRuntime>> {
|
||||
if let Some(runtime) = self.inference_runtime.get() {
|
||||
return Ok(runtime.clone());
|
||||
}
|
||||
|
||||
let runtime = InferenceRuntime::get_or_init()?;
|
||||
|
||||
// Another thread may win the race to cache the runtime in AppState.
|
||||
// In that case, return the already-initialized cached runtime.
|
||||
match self.inference_runtime.set(runtime.clone()) {
|
||||
Ok(()) => Ok(runtime),
|
||||
Err(_) => Ok(self
|
||||
.inference_runtime
|
||||
.get()
|
||||
.expect("inference runtime initialized by another thread")
|
||||
.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_extension_loading_task(
|
||||
&self,
|
||||
session_id: String,
|
||||
|
||||
@@ -62,10 +62,10 @@ pub struct InferenceRuntime {
|
||||
static RUNTIME: StdMutex<Weak<InferenceRuntime>> = StdMutex::new(Weak::new());
|
||||
|
||||
impl InferenceRuntime {
|
||||
pub fn get_or_init() -> Arc<Self> {
|
||||
pub fn get_or_init() -> Result<Arc<Self>> {
|
||||
let mut guard = RUNTIME.lock().expect("runtime lock poisoned");
|
||||
if let Some(runtime) = guard.upgrade() {
|
||||
return runtime;
|
||||
return Ok(runtime);
|
||||
}
|
||||
// Safety invariant: the Weak::upgrade() check and LlamaBackend::init()
|
||||
// both execute inside this same mutex guard, so there is no window where
|
||||
@@ -80,7 +80,10 @@ impl InferenceRuntime {
|
||||
the mutex guard prevents concurrent re-init"
|
||||
)
|
||||
}
|
||||
Err(e) => panic!("Failed to init llama backend: {}", e),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to initialize local inference runtime");
|
||||
return Err(anyhow::anyhow!("Failed to init llama backend: {}", e));
|
||||
}
|
||||
};
|
||||
llama_cpp_2::send_logs_to_tracing(LogOptions::default());
|
||||
let runtime = Arc::new(Self {
|
||||
@@ -88,7 +91,7 @@ impl InferenceRuntime {
|
||||
backend,
|
||||
});
|
||||
*guard = Arc::downgrade(&runtime);
|
||||
runtime
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
pub fn backend(&self) -> &LlamaBackend {
|
||||
@@ -357,7 +360,7 @@ pub struct LocalInferenceProvider {
|
||||
|
||||
impl LocalInferenceProvider {
|
||||
pub async fn from_env(model: ModelConfig, _extensions: Vec<ExtensionConfig>) -> Result<Self> {
|
||||
let runtime = InferenceRuntime::get_or_init();
|
||||
let runtime = InferenceRuntime::get_or_init()?;
|
||||
let model_slot = runtime.get_or_create_model_slot(&model.model_name);
|
||||
Ok(Self {
|
||||
runtime,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: Debug Desktop Startup Failures
|
||||
sidebar_label: Debug Desktop Startup Failures
|
||||
description: Find the desktop startup diagnostics log, understand the key fields, and share the right artifacts when goose fails to start.
|
||||
---
|
||||
|
||||
When goose Desktop fails before the backend becomes ready, the normal server log may be empty or incomplete. In that case, the most useful artifact is the startup diagnostics JSON written by the desktop app.
|
||||
|
||||
## Find the Startup Diagnostics Log
|
||||
|
||||
goose Desktop writes one startup diagnostics file per launch attempt.
|
||||
|
||||
Typical locations:
|
||||
|
||||
- macOS: `~/Library/Application Support/Goose/logs/startup/`
|
||||
- Windows: `%APPDATA%\Goose\logs\startup\`
|
||||
- Linux: `~/.config/Goose/logs/startup/`
|
||||
|
||||
The files are named like:
|
||||
|
||||
```text
|
||||
goosed-startup-2026-04-21T01-24-03.149Z-23416.json
|
||||
```
|
||||
|
||||
If several files exist, use the newest one.
|
||||
|
||||
## What To Share
|
||||
|
||||
When reporting a desktop startup failure, share:
|
||||
|
||||
- the newest `goosed-startup-*.json`
|
||||
- your goose version
|
||||
- your operating system and version
|
||||
|
||||
For Windows native crashes, also attach the Windows crash report for `goosed.exe` if available.
|
||||
|
||||
Common places to find the Windows crash report:
|
||||
|
||||
- Event Viewer: `Windows Logs` → `Application`
|
||||
- Reliability Monitor: `View technical details`
|
||||
- WER files on disk:
|
||||
- `%LOCALAPPDATA%\Microsoft\Windows\WER\ReportArchive\`
|
||||
- `%LOCALAPPDATA%\Microsoft\Windows\WER\ReportQueue\`
|
||||
|
||||
Look for a `Report.wer` related to `goosed.exe`.
|
||||
|
||||
If you are filing a GitHub issue or asking for support, this is usually enough:
|
||||
|
||||
- the newest `goosed-startup-*.json`
|
||||
- your goose version
|
||||
- your operating system and version
|
||||
- on Windows, `Report.wer` for `goosed.exe` if Windows created one
|
||||
|
||||
## What The Startup Log Contains
|
||||
|
||||
In most cases, sharing the newest startup log is enough.
|
||||
|
||||
If you want a quick high-level read, focus on these fields:
|
||||
|
||||
- `childExitCode` or `childExitSignal`
|
||||
Shows whether the backend process exited during startup.
|
||||
- `certFingerprintSeen`
|
||||
Shows whether the backend reached the TLS startup stage.
|
||||
- `healthCheckSucceeded`
|
||||
Shows whether the desktop app ever observed the backend as ready.
|
||||
- `stderrTail`
|
||||
Shows the most recent startup output captured from the backend, including major startup stage markers when available.
|
||||
- `events`
|
||||
Shows the order of major startup steps like process spawn, health check, and child exit.
|
||||
|
||||
## Related Diagnostics
|
||||
|
||||
For session or in-app issues after goose has started, use the normal diagnostics bundle described in [Diagnostics and Reporting](/docs/troubleshooting/diagnostics-and-reporting).
|
||||
@@ -20,6 +20,11 @@ import styles from '@site/src/components/Card/styles.module.css';
|
||||
description="Use built-in diagnostics, report bugs, and request new features. Includes step-by-step guides for generating troubleshooting data."
|
||||
link="/docs/troubleshooting/diagnostics-and-reporting"
|
||||
/>
|
||||
<Card
|
||||
title="Debug Desktop Startup Failures"
|
||||
description="Find the desktop startup diagnostics log, understand the key fields, and attach the right artifacts when goose fails before the backend becomes ready."
|
||||
link="/docs/troubleshooting/desktop-startup-debugging"
|
||||
/>
|
||||
<Card
|
||||
title="Known Issues"
|
||||
description="Comprehensive troubleshooting guide covering common problems, error messages, and platform-specific issues with step-by-step solutions."
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"test:integration:providers-code-exec": "vitest run --config vitest.integration.config.ts tests/integration/test_providers_code_exec.test.ts",
|
||||
"test:integration:watch": "vitest --config vitest.integration.config.ts",
|
||||
"test:integration:debug": "DEBUG=1 vitest run --config vitest.integration.config.ts",
|
||||
"i18n:extract": "formatjs extract 'src/**/*.{ts,tsx}' --out-file src/i18n/messages/en.json --flatten && pnpm run i18n:compile",
|
||||
"i18n:extract": "formatjs extract 'src/**/*.{ts,tsx}' --ignore '**/*.d.ts' --out-file src/i18n/messages/en.json --flatten && pnpm run i18n:compile",
|
||||
"i18n:check": "node scripts/i18n-check.js",
|
||||
"i18n:compile": "node scripts/i18n-compile.js"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,16 @@ const tmpFile = path.join(os.tmpdir(), 'en.i18n-check.json');
|
||||
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[formatjs, 'extract', 'src/**/*.{ts,tsx}', '--out-file', tmpFile, '--flatten'],
|
||||
[
|
||||
formatjs,
|
||||
'extract',
|
||||
'src/**/*.{ts,tsx}',
|
||||
'--ignore',
|
||||
'**/*.d.ts',
|
||||
'--out-file',
|
||||
tmpFile,
|
||||
'--flatten',
|
||||
],
|
||||
{ stdio: 'inherit', cwd: projectDir }
|
||||
);
|
||||
|
||||
|
||||
@@ -92,7 +92,8 @@ const i18n = defineMessages({
|
||||
},
|
||||
tooManyTools: {
|
||||
id: 'chatInput.tooManyTools',
|
||||
defaultMessage: 'Too many tools can degrade performance.\nTool count: {toolCount} (recommend: {recommended})',
|
||||
defaultMessage:
|
||||
'Too many tools can degrade performance.\nTool count: {toolCount} (recommend: {recommended})',
|
||||
},
|
||||
viewExtensions: {
|
||||
id: 'chatInput.viewExtensions',
|
||||
@@ -572,7 +573,10 @@ export default function ChatInput({
|
||||
if (toolCount !== null && toolCount > TOOLS_MAX_SUGGESTED) {
|
||||
addAlert({
|
||||
type: AlertType.Warning,
|
||||
message: intl.formatMessage(i18n.tooManyTools, { toolCount, recommended: TOOLS_MAX_SUGGESTED }),
|
||||
message: intl.formatMessage(i18n.tooManyTools, {
|
||||
toolCount,
|
||||
recommended: TOOLS_MAX_SUGGESTED,
|
||||
}),
|
||||
action: {
|
||||
text: intl.formatMessage(i18n.viewExtensions),
|
||||
onClick: () => setView('extensions'),
|
||||
@@ -1567,7 +1571,9 @@ export default function ChatInput({
|
||||
<p className="text-sm text-text-primary truncate" title={file.name}>
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">{file.type || intl.formatMessage(i18n.unknownType)}</p>
|
||||
<p className="text-xs text-text-secondary">
|
||||
{file.type || intl.formatMessage(i18n.unknownType)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1683,7 +1689,9 @@ export default function ChatInput({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{recipe ? intl.formatMessage(i18n.viewEditRecipe) : intl.formatMessage(i18n.createRecipeFromSession)}
|
||||
{recipe
|
||||
? intl.formatMessage(i18n.viewEditRecipe)
|
||||
: intl.formatMessage(i18n.createRecipeFromSession)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -68,7 +68,9 @@ const CustomProviderCard = memo(function CustomProviderCard({ onClick }: { onCli
|
||||
<Plus className="w-8 h-8 text-gray-400 mb-2" />
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 text-center">
|
||||
<div className="font-medium">{intl.formatMessage(i18n.addProvider)}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{intl.formatMessage(i18n.fromTemplateOrManual)}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{intl.formatMessage(i18n.fromTemplateOrManual)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -277,7 +279,9 @@ function ProviderCards({
|
||||
|
||||
const editable = editingProvider ? editingProvider.isEditable : true;
|
||||
const title = editingProvider
|
||||
? (editable ? intl.formatMessage(i18n.editProvider) : intl.formatMessage(i18n.configureProvider))
|
||||
? editable
|
||||
? intl.formatMessage(i18n.editProvider)
|
||||
: intl.formatMessage(i18n.configureProvider)
|
||||
: intl.formatMessage(i18n.addProviderTitle);
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -6,6 +6,11 @@ import { createServer } from 'net';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { status } from './api';
|
||||
import { Client, createClient, createConfig } from './api/client';
|
||||
import {
|
||||
appendTail,
|
||||
createStartupDiagnostics,
|
||||
type StartupDiagnostics,
|
||||
} from './startupDiagnostics';
|
||||
|
||||
export interface Logger {
|
||||
info: (...args: unknown[]) => void;
|
||||
@@ -77,24 +82,36 @@ export const findGoosedBinaryPath = (options: FindBinaryOptions = {}): string =>
|
||||
);
|
||||
};
|
||||
|
||||
export const checkServerStatus = async (client: Client, errorLog: string[]): Promise<boolean> => {
|
||||
export interface CheckServerStatusOptions {
|
||||
onEvent?: (name: string, details?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export const checkServerStatus = async (
|
||||
client: Client,
|
||||
errorLog: string[],
|
||||
options: CheckServerStatusOptions = {}
|
||||
): Promise<boolean> => {
|
||||
const timeout = 30000;
|
||||
const interval = 100;
|
||||
const maxAttempts = Math.ceil(timeout / interval);
|
||||
options.onEvent?.('healthcheck_start', { timeoutMs: timeout, intervalMs: interval });
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
if (errorLog.some(isFatalError)) {
|
||||
options.onEvent?.('healthcheck_fatal_error', { attempt });
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await status({ client, throwOnError: true });
|
||||
options.onEvent?.('healthcheck_success', { attempt });
|
||||
return true;
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
}
|
||||
|
||||
options.onEvent?.('healthcheck_timeout', { timeoutMs: timeout });
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -153,6 +170,7 @@ export interface StartGoosedOptions {
|
||||
isPackaged?: boolean;
|
||||
resourcesPath?: string;
|
||||
logger?: Logger;
|
||||
diagnosticsDir?: string;
|
||||
}
|
||||
|
||||
export interface GoosedResult {
|
||||
@@ -164,6 +182,9 @@ export interface GoosedResult {
|
||||
cleanup: () => Promise<void>;
|
||||
client: Client;
|
||||
certFingerprint: string | null;
|
||||
startupDiagnosticsPath: string | null;
|
||||
getStartupDiagnostics: () => StartupDiagnostics | null;
|
||||
recordStartupEvent: (name: string, details?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const goosedClientForUrlAndSecret = (url: string, secret: string): Client => {
|
||||
@@ -187,14 +208,19 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
env: additionalEnv = {},
|
||||
externalGoosed,
|
||||
logger = defaultLogger,
|
||||
diagnosticsDir,
|
||||
} = options;
|
||||
|
||||
const errorLog: string[] = [];
|
||||
const workingDir = dir || os.homedir();
|
||||
const startupTrace = createStartupDiagnostics(diagnosticsDir, workingDir);
|
||||
|
||||
if (externalGoosed?.enabled && externalGoosed.url) {
|
||||
const url = externalGoosed.url.replace(/\/$/, '');
|
||||
logger.info(`Using external goosed backend at ${url}`);
|
||||
if (startupTrace) {
|
||||
startupTrace.diagnostics.baseUrl = url;
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: url,
|
||||
@@ -207,6 +233,9 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
},
|
||||
client: goosedClientForUrlAndSecret(url, serverSecret),
|
||||
certFingerprint: null,
|
||||
startupDiagnosticsPath: startupTrace?.diagnosticsPath ?? null,
|
||||
getStartupDiagnostics: () => startupTrace?.diagnostics ?? null,
|
||||
recordStartupEvent: (name, details) => startupTrace?.record(name, details),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -214,6 +243,9 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
const port = process.env.GOOSE_PORT || '3000';
|
||||
const url = `https://127.0.0.1:${port}`;
|
||||
logger.info(`Using external goosed backend from env at ${url}`);
|
||||
if (startupTrace) {
|
||||
startupTrace.diagnostics.baseUrl = url;
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: url,
|
||||
@@ -226,6 +258,9 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
},
|
||||
client: goosedClientForUrlAndSecret(url, serverSecret),
|
||||
certFingerprint: null,
|
||||
startupDiagnosticsPath: startupTrace?.diagnosticsPath ?? null,
|
||||
getStartupDiagnostics: () => startupTrace?.diagnostics ?? null,
|
||||
recordStartupEvent: (name, details) => startupTrace?.record(name, details),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,6 +270,11 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
logger.info(`Starting goosed from: ${goosedPath} on port ${port} in dir ${workingDir}`);
|
||||
|
||||
const baseUrl = `https://127.0.0.1:${port}`;
|
||||
if (startupTrace) {
|
||||
startupTrace.diagnostics.goosedPath = goosedPath;
|
||||
startupTrace.diagnostics.baseUrl = baseUrl;
|
||||
startupTrace.record('spawn_start', { goosedPath, port, workingDir });
|
||||
}
|
||||
|
||||
const spawnEnv: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
@@ -273,6 +313,10 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
logger.info('Spawn options:', JSON.stringify(safeSpawnOptions, null, 2));
|
||||
|
||||
const goosedProcess = spawn(spawnCommand, spawnArgs, spawnOptions);
|
||||
if (startupTrace) {
|
||||
startupTrace.diagnostics.pid = goosedProcess.pid ?? null;
|
||||
startupTrace.record('spawn_success', { pid: goosedProcess.pid ?? null });
|
||||
}
|
||||
|
||||
let certFingerprint: string | null = null;
|
||||
const fingerprintReady = new Promise<string | null>((resolve) => {
|
||||
@@ -288,6 +332,10 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
if (line.startsWith(FINGERPRINT_PREFIX)) {
|
||||
certFingerprint = line.slice(FINGERPRINT_PREFIX.length).trim();
|
||||
logger.info(`Pinned cert fingerprint: ${certFingerprint}`);
|
||||
if (startupTrace) {
|
||||
startupTrace.diagnostics.certFingerprintSeen = true;
|
||||
startupTrace.record('fingerprint_received', { certFingerprint });
|
||||
}
|
||||
resolved = true;
|
||||
resolve(certFingerprint);
|
||||
break;
|
||||
@@ -319,6 +367,8 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
|
||||
const onStderrData = (data: Buffer) => {
|
||||
const lines = data.toString().split('\n');
|
||||
const nonEmptyLines = lines.filter((line) => line.trim());
|
||||
appendTail(startupTrace?.diagnostics.stderrTail ?? [], nonEmptyLines);
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
errorLog.push(line);
|
||||
@@ -334,13 +384,19 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
goosedProcess.stderr?.off('data', onStderrData);
|
||||
};
|
||||
|
||||
goosedProcess.on('exit', (code) => {
|
||||
goosedProcess.on('exit', (code, signal) => {
|
||||
logger.info(`goosed process exited with code ${code} for port ${port} and dir ${workingDir}`);
|
||||
if (startupTrace) {
|
||||
startupTrace.diagnostics.childExitCode = code;
|
||||
startupTrace.diagnostics.childExitSignal = signal;
|
||||
startupTrace.record('child_exit', { code, signal });
|
||||
}
|
||||
});
|
||||
|
||||
goosedProcess.on('error', (err) => {
|
||||
logger.error(`Failed to start goosed on port ${port} and dir ${workingDir}`, err);
|
||||
errorLog.push(err.message);
|
||||
startupTrace?.record('spawn_error', { message: err.message, name: err.name });
|
||||
});
|
||||
|
||||
const cleanup = async (): Promise<void> => {
|
||||
@@ -387,5 +443,8 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
cleanup,
|
||||
client: goosedClientForUrlAndSecret(baseUrl, serverSecret),
|
||||
certFingerprint,
|
||||
startupDiagnosticsPath: startupTrace?.diagnosticsPath ?? null,
|
||||
getStartupDiagnostics: () => startupTrace?.diagnostics ?? null,
|
||||
recordStartupEvent: (name, details) => startupTrace?.record(name, details),
|
||||
};
|
||||
};
|
||||
|
||||
+25
-2
@@ -61,6 +61,7 @@ function shouldSetupUpdater(): boolean {
|
||||
|
||||
// Settings management
|
||||
const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json');
|
||||
const STARTUP_LOGS_DIR = path.join(app.getPath('userData'), 'logs', 'startup');
|
||||
|
||||
function getSettings(): Settings {
|
||||
if (fsSync.existsSync(SETTINGS_FILE)) {
|
||||
@@ -617,6 +618,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
|
||||
isPackaged: app.isPackaged,
|
||||
resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
|
||||
logger: log,
|
||||
diagnosticsDir: STARTUP_LOGS_DIR,
|
||||
});
|
||||
|
||||
// For locally-spawned goosed, pin using the fingerprint from stdout.
|
||||
@@ -637,6 +639,9 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
|
||||
process: goosedProcess,
|
||||
errorLog,
|
||||
stopErrorLogCollection,
|
||||
startupDiagnosticsPath,
|
||||
getStartupDiagnostics,
|
||||
recordStartupEvent,
|
||||
} = goosedResult;
|
||||
|
||||
const mainWindowState = windowStateKeeper({
|
||||
@@ -705,9 +710,27 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
|
||||
);
|
||||
goosedClients.set(mainWindow.id, goosedClient);
|
||||
|
||||
const serverReady = await checkServerStatus(goosedClient, errorLog);
|
||||
const serverReady = await checkServerStatus(goosedClient, errorLog, {
|
||||
onEvent: recordStartupEvent,
|
||||
});
|
||||
if (!serverReady) {
|
||||
const isUsingExternalBackend = settings.externalGoosed?.enabled;
|
||||
const diagnostics = getStartupDiagnostics();
|
||||
const stderrTail = diagnostics?.stderrTail ?? [];
|
||||
const failureDetailParts = [
|
||||
diagnostics?.childExitCode !== null || diagnostics?.childExitSignal
|
||||
? `Child exit: code=${diagnostics?.childExitCode ?? 'null'} signal=${diagnostics?.childExitSignal ?? 'null'}`
|
||||
: 'Child exit: unavailable',
|
||||
diagnostics?.certFingerprintSeen
|
||||
? 'TLS fingerprint observed: yes'
|
||||
: 'TLS fingerprint observed: no',
|
||||
diagnostics?.healthCheckSucceeded
|
||||
? 'Health check observed: yes'
|
||||
: 'Health check observed: no',
|
||||
startupDiagnosticsPath ? `Startup diagnostics: ${startupDiagnosticsPath}` : '',
|
||||
errorLog.length > 0 ? `Startup errors:\n${errorLog.join('\n')}` : '',
|
||||
stderrTail.length > 0 ? `Captured startup stderr:\n${stderrTail.join('\n')}` : '',
|
||||
].filter(Boolean);
|
||||
|
||||
if (isUsingExternalBackend) {
|
||||
const response = dialog.showMessageBoxSync({
|
||||
@@ -734,7 +757,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
|
||||
type: 'error',
|
||||
title: 'Goose Failed to Start',
|
||||
message: 'The backend server failed to start.',
|
||||
detail: errorLog.join('\n'),
|
||||
detail: failureDetailParts.join('\n\n'),
|
||||
buttons: ['OK'],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface StartupTraceEvent {
|
||||
name: string;
|
||||
at: string;
|
||||
elapsedMs: number;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface StartupDiagnostics {
|
||||
attemptId: string;
|
||||
startedAt: string;
|
||||
goosedPath: string | null;
|
||||
workingDir: string;
|
||||
baseUrl: string | null;
|
||||
pid: number | null;
|
||||
certFingerprintSeen: boolean;
|
||||
healthCheckSucceeded: boolean;
|
||||
childExitCode: number | null;
|
||||
childExitSignal: string | null;
|
||||
stderrTail: string[];
|
||||
events: StartupTraceEvent[];
|
||||
}
|
||||
|
||||
export interface StartupTrace {
|
||||
diagnosticsPath: string;
|
||||
diagnostics: StartupDiagnostics;
|
||||
record: (name: string, details?: Record<string, unknown>) => void;
|
||||
flush: () => void;
|
||||
}
|
||||
|
||||
const STARTUP_TAIL_LIMIT = 80;
|
||||
const STARTUP_LOGS_TO_KEEP = 20;
|
||||
|
||||
export const appendTail = (target: string[], lines: string[]) => {
|
||||
target.push(...lines.filter((line) => line.trim()));
|
||||
if (target.length > STARTUP_TAIL_LIMIT) {
|
||||
target.splice(0, target.length - STARTUP_TAIL_LIMIT);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupStartupDiagnostics = (diagnosticsDir: string) => {
|
||||
const startupLogs = fs
|
||||
.readdirSync(diagnosticsDir, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() && entry.name.startsWith('goosed-startup-') && entry.name.endsWith('.json')
|
||||
)
|
||||
.map((entry) => {
|
||||
const filePath = path.join(diagnosticsDir, entry.name);
|
||||
return {
|
||||
filePath,
|
||||
modifiedMs: fs.statSync(filePath).mtimeMs,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.modifiedMs - a.modifiedMs);
|
||||
|
||||
for (const startupLog of startupLogs.slice(STARTUP_LOGS_TO_KEEP)) {
|
||||
fs.unlinkSync(startupLog.filePath);
|
||||
}
|
||||
};
|
||||
|
||||
export const createStartupDiagnostics = (
|
||||
diagnosticsDir: string | undefined,
|
||||
workingDir: string
|
||||
): StartupTrace | null => {
|
||||
if (!diagnosticsDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
fs.mkdirSync(diagnosticsDir, { recursive: true });
|
||||
cleanupStartupDiagnostics(diagnosticsDir);
|
||||
const startedAt = new Date();
|
||||
const attemptId = `goosed-startup-${startedAt.toISOString().replace(/:/g, '-')}-${process.pid}.json`;
|
||||
const diagnosticsPath = path.join(diagnosticsDir, attemptId);
|
||||
const monotonicStart = Date.now();
|
||||
|
||||
const diagnostics: StartupDiagnostics = {
|
||||
attemptId,
|
||||
startedAt: startedAt.toISOString(),
|
||||
goosedPath: null,
|
||||
workingDir,
|
||||
baseUrl: null,
|
||||
pid: null,
|
||||
certFingerprintSeen: false,
|
||||
healthCheckSucceeded: false,
|
||||
childExitCode: null,
|
||||
childExitSignal: null,
|
||||
stderrTail: [],
|
||||
events: [],
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}\n`);
|
||||
};
|
||||
|
||||
const record = (name: string, details?: Record<string, unknown>) => {
|
||||
if (name === 'healthcheck_success') {
|
||||
diagnostics.healthCheckSucceeded = true;
|
||||
}
|
||||
diagnostics.events.push({
|
||||
name,
|
||||
at: new Date().toISOString(),
|
||||
elapsedMs: Date.now() - monotonicStart,
|
||||
...(details ? { details } : {}),
|
||||
});
|
||||
flush();
|
||||
};
|
||||
|
||||
flush();
|
||||
|
||||
return {
|
||||
diagnosticsPath,
|
||||
diagnostics,
|
||||
record,
|
||||
flush,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user