Add structured logging and ensure CLI shuts down cleanly

This commit is contained in:
Shantur Rathore
2025-11-17 20:21:39 +00:00
parent 08d81f8bb5
commit 719a9c9c74
13 changed files with 255 additions and 55 deletions

View File

@@ -5,12 +5,14 @@ import { BinaryRegistry } from "../config/binaries"
import { FileSystemBrowser } from "../filesystem/browser"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime } from "./runtime"
import { Logger } from "../logger"
interface WorkspaceManagerOptions {
rootDir: string
configStore: ConfigStore
binaryRegistry: BinaryRegistry
eventBus: EventBus
logger: Logger
}
interface WorkspaceRecord extends WorkspaceDescriptor {}
@@ -20,7 +22,7 @@ export class WorkspaceManager {
private readonly runtime: WorkspaceRuntime
constructor(private readonly options: WorkspaceManagerOptions) {
this.runtime = new WorkspaceRuntime(this.options.eventBus)
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
}
list(): WorkspaceDescriptor[] {
@@ -53,6 +55,8 @@ export class WorkspaceManager {
const binary = this.options.binaryRegistry.resolveDefault()
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: binary.path }, "Creating workspace")
const descriptor: WorkspaceRecord = {
id,
path: workspacePath,
@@ -84,12 +88,14 @@ export class WorkspaceManager {
descriptor.status = "ready"
descriptor.updatedAt = new Date().toISOString()
this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor })
this.options.logger.info({ workspaceId: id, port }, "Workspace ready")
return descriptor
} catch (error) {
descriptor.status = "error"
descriptor.error = error instanceof Error ? error.message : String(error)
descriptor.updatedAt = new Date().toISOString()
this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor })
this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start")
throw error
}
}
@@ -98,9 +104,12 @@ export class WorkspaceManager {
const workspace = this.workspaces.get(id)
if (!workspace) return undefined
this.options.logger.info({ workspaceId: id }, "Stopping workspace")
const wasRunning = Boolean(workspace.pid)
if (wasRunning) {
await this.runtime.stop(id).catch(() => {})
await this.runtime.stop(id).catch((error) => {
this.options.logger.warn({ workspaceId: id, err: error }, "Failed to stop workspace process cleanly")
})
}
this.workspaces.delete(id)
@@ -111,12 +120,19 @@ export class WorkspaceManager {
}
async shutdown() {
for (const [id] of this.workspaces) {
if (this.workspaces.get(id)?.pid) {
await this.runtime.stop(id).catch(() => {})
this.options.logger.info("Shutting down all workspaces")
for (const [id, workspace] of this.workspaces) {
if (workspace.pid) {
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
await this.runtime.stop(id).catch((error) => {
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
})
} else {
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
}
}
this.workspaces.clear()
this.options.logger.info("All workspaces cleared")
}
private requireWorkspace(id: string): WorkspaceRecord {
@@ -131,6 +147,8 @@ export class WorkspaceManager {
const workspace = this.workspaces.get(workspaceId)
if (!workspace) return
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
workspace.pid = undefined
workspace.port = undefined
workspace.updatedAt = new Date().toISOString()

View File

@@ -3,6 +3,7 @@ import { existsSync, statSync } from "fs"
import path from "path"
import { EventBus } from "../events/bus"
import { LogLevel, WorkspaceLogEntry } from "../api-types"
import { Logger } from "../logger"
interface LaunchOptions {
workspaceId: string
@@ -27,7 +28,7 @@ interface ManagedProcess {
export class WorkspaceRuntime {
private processes = new Map<string, ManagedProcess>()
constructor(private readonly eventBus: EventBus) {}
constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {}
async launch(options: LaunchOptions): Promise<{ pid: number; port: number }> {
this.validateFolder(options.folder)
@@ -36,6 +37,7 @@ export class WorkspaceRuntime {
const env = { ...process.env, ...(options.environment ?? {}) }
return new Promise((resolve, reject) => {
this.logger.info({ workspaceId: options.workspaceId, folder: options.folder }, "Launching OpenCode process")
const child = spawn(options.binaryPath, args, {
cwd: options.folder,
env,
@@ -49,22 +51,36 @@ export class WorkspaceRuntime {
let stderrBuffer = ""
let portFound = false
const timeout = setTimeout(() => {
child.kill("SIGKILL")
reject(new Error("Server startup timeout (10s exceeded)"))
}, 10000)
let warningTimer: NodeJS.Timeout | null = null
const cleanup = () => {
clearTimeout(timeout)
const startWarningTimer = () => {
warningTimer = setInterval(() => {
this.logger.warn({ workspaceId: options.workspaceId }, "Workspace runtime has not reported a port yet")
}, 10000)
}
const stopWarningTimer = () => {
if (warningTimer) {
clearInterval(warningTimer)
warningTimer = null
}
}
startWarningTimer()
const cleanupStreams = () => {
stopWarningTimer()
child.stdout?.removeAllListeners()
child.stderr?.removeAllListeners()
child.removeListener("error", handleError)
}
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
this.logger.info({ workspaceId: options.workspaceId, code, signal }, "OpenCode process exited")
this.processes.delete(options.workspaceId)
cleanupStreams()
child.removeListener("error", handleError)
child.removeListener("exit", handleExit)
if (!portFound) {
cleanup()
const reason = stderrBuffer || `Process exited with code ${code}`
reject(new Error(reason))
} else {
@@ -73,9 +89,10 @@ export class WorkspaceRuntime {
}
const handleError = (error: Error) => {
cleanup()
this.processes.delete(options.workspaceId)
cleanupStreams()
child.removeListener("exit", handleExit)
this.processes.delete(options.workspaceId)
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error")
reject(error)
}
@@ -96,8 +113,11 @@ export class WorkspaceRuntime {
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
if (portMatch) {
portFound = true
cleanup()
resolve({ pid: child.pid!, port: parseInt(portMatch[1], 10) })
cleanupStreams()
child.removeListener("error", handleError)
const port = parseInt(portMatch[1], 10)
this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port")
resolve({ pid: child.pid!, port })
}
}
}
@@ -114,16 +134,6 @@ export class WorkspaceRuntime {
this.emitLog(options.workspaceId, "error", line)
}
})
child.on("exit", (code, signal) => {
this.processes.delete(options.workspaceId)
if (!portFound) {
cleanup()
const reason = stderrBuffer || `Process exited with code ${code}`
reject(new Error(reason))
}
options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop })
})
})
}
@@ -133,24 +143,48 @@ export class WorkspaceRuntime {
managed.requestedStop = true
const child = managed.child
this.logger.info({ workspaceId }, "Stopping OpenCode process")
await new Promise<void>((resolve, reject) => {
const onExit = () => {
const cleanup = () => {
child.removeListener("exit", onExit)
child.removeListener("error", onError)
}
const onExit = () => {
cleanup()
resolve()
}
const onError = (error: Error) => {
child.removeListener("exit", onExit)
cleanup()
reject(error)
}
const resolveIfAlreadyExited = () => {
if (child.exitCode !== null || child.signalCode !== null) {
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
cleanup()
resolve()
return true
}
return false
}
child.once("exit", onExit)
child.once("error", onError)
if (resolveIfAlreadyExited()) {
return
}
this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process")
child.kill("SIGTERM")
setTimeout(() => {
if (!child.killed) {
this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing")
child.kill("SIGKILL")
} else {
this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout")
}
}, 2000)
})