Skip to content
Closed
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
240 changes: 240 additions & 0 deletions src/core/process/ProcessRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import * as vscode from "vscode"
import { ChildProcess } from "child_process"
import psTree from "ps-tree"

/**
* Interface for tracking spawned processes
*/
export interface TrackedProcess {
/** The child process instance */
process: ChildProcess
/** Unique identifier for the process */
id: string
/** Optional description of what this process is for */
description?: string
/** Debug session ID if this process is associated with a debug session */
debugSessionId?: string
/** Timestamp when the process was registered */
registeredAt: number
}

/**
* Central registry for tracking all spawned processes to ensure proper cleanup
* during debug session termination and extension deactivation.
*/
export class ProcessRegistry implements vscode.Disposable {
private processes = new Map<string, TrackedProcess>()
private debugSessionProcesses = new Map<string, Set<string>>()
private disposables: vscode.Disposable[] = []

constructor() {
// Register for debug session events to track process lifecycle
// Only register if vscode.debug is available (not in test environment)
try {
if (vscode.debug && vscode.debug.onDidStartDebugSession) {
this.disposables.push(
vscode.debug.onDidStartDebugSession(this.onDebugSessionStart.bind(this)),
vscode.debug.onDidTerminateDebugSession(this.onDebugSessionTerminate.bind(this)),
)
}
} catch (error) {
// Ignore errors in test environment where vscode.debug might not be available
}
}

/**
* Register a process for tracking
*/
register(
process: ChildProcess,
id: string,
options?: {
description?: string
debugSessionId?: string
},
): void {
const trackedProcess: TrackedProcess = {
process,
id,
description: options?.description,
debugSessionId: options?.debugSessionId,
registeredAt: Date.now(),
}

this.processes.set(id, trackedProcess)

// Track debug session association
if (options?.debugSessionId) {
if (!this.debugSessionProcesses.has(options.debugSessionId)) {
this.debugSessionProcesses.set(options.debugSessionId, new Set())
}
this.debugSessionProcesses.get(options.debugSessionId)!.add(id)
}

// Clean up when process exits naturally
process.on("exit", () => {
this.unregister(id)
})
}

/**
* Unregister a process from tracking
*/
unregister(id: string): void {
const trackedProcess = this.processes.get(id)
if (trackedProcess) {
// Remove from debug session tracking
if (trackedProcess.debugSessionId) {
const sessionProcesses = this.debugSessionProcesses.get(trackedProcess.debugSessionId)
if (sessionProcesses) {
sessionProcesses.delete(id)
if (sessionProcesses.size === 0) {
this.debugSessionProcesses.delete(trackedProcess.debugSessionId)
}
}
}
this.processes.delete(id)
}
}

/**
* Kill a specific process and its children
*/
async killProcess(id: string, signal: NodeJS.Signals = "SIGTERM"): Promise<void> {
const trackedProcess = this.processes.get(id)
if (!trackedProcess || !trackedProcess.process.pid) {
return
}

try {
await this.killProcessTree(trackedProcess.process.pid, signal)
} catch (error) {
console.warn(`Failed to kill process ${id}:`, error)
} finally {
this.unregister(id)
}
}

/**
* Kill all processes associated with a debug session
*/
async killDebugSessionProcesses(debugSessionId: string): Promise<void> {
const processIds = this.debugSessionProcesses.get(debugSessionId)
if (!processIds) {
return
}

const killPromises = Array.from(processIds).map((id) => this.killProcess(id))
await Promise.allSettled(killPromises)
}

/**
* Kill all tracked processes
*/
async killAllProcesses(): Promise<void> {
const killPromises = Array.from(this.processes.keys()).map((id) => this.killProcess(id))
await Promise.allSettled(killPromises)
}

/**
* Get information about all tracked processes
*/
getTrackedProcesses(): TrackedProcess[] {
return Array.from(this.processes.values())
}

/**
* Get processes associated with a specific debug session
*/
getDebugSessionProcesses(debugSessionId: string): TrackedProcess[] {
const processIds = this.debugSessionProcesses.get(debugSessionId)
if (!processIds) {
return []
}

return Array.from(processIds)
.map((id) => this.processes.get(id))
.filter((process): process is TrackedProcess => process !== undefined)
}

private onDebugSessionStart(session: vscode.DebugSession): void {
// Debug session started - we'll track processes as they're spawned
console.log(`Debug session started: ${session.id}`)
}

private async onDebugSessionTerminate(session: vscode.DebugSession): Promise<void> {
// Debug session terminated - clean up all associated processes
console.log(`Debug session terminated: ${session.id}, cleaning up processes`)
await this.killDebugSessionProcesses(session.id)
}

/**
* Kill a process tree using ps-tree with timeout-based escalation
*/
private async killProcessTree(pid: number, signal: NodeJS.Signals = "SIGTERM"): Promise<void> {
return new Promise((resolve) => {
// First, try to get the process tree
psTree(pid, (err, children) => {
if (err) {
// Process might already be dead
resolve()
return
}

// Kill all child processes first
const childPids = children.map((p) => parseInt(p.PID))
childPids.forEach((childPid) => {
try {
process.kill(childPid, signal)
} catch (error) {
// Process might already be dead
}
})

// Kill the main process
try {
process.kill(pid, signal)
} catch (error) {
// Process might already be dead
}

// If using SIGTERM, set up escalation to SIGKILL after timeout
if (signal === "SIGTERM") {
setTimeout(() => {
// Escalate to SIGKILL if process is still alive
try {
process.kill(pid, "SIGKILL")
childPids.forEach((childPid) => {
try {
process.kill(childPid, "SIGKILL")
} catch (error) {
// Process might already be dead
}
})
} catch (error) {
// Process is already dead
}
resolve()
}, 5000) // 5 second timeout before escalating to SIGKILL
} else {
resolve()
}
})
})
}

dispose(): void {
// Clean up all processes and event listeners
this.killAllProcesses().catch((error) => {
console.warn("Error during ProcessRegistry disposal:", error)
})

this.disposables.forEach((disposable) => disposable.dispose())
this.disposables = []
this.processes.clear()
this.debugSessionProcesses.clear()
}
}

// Global instance for the extension
export const processRegistry = new ProcessRegistry()
17 changes: 17 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,25 @@ vi.mock("vscode", () => {
stat: vi.fn().mockResolvedValue({ type: 1 }), // FileType.File = 1
},
onDidSaveTextDocument: vi.fn(() => mockDisposable),
onDidChangeWorkspaceFolders: vi.fn(() => mockDisposable),
getConfiguration: vi.fn(() => ({ get: (key: string, defaultValue: any) => defaultValue })),
},
Uri: {
file: vi.fn().mockImplementation((path: string) => ({
scheme: "file",
authority: "",
path: path,
query: "",
fragment: "",
fsPath: path,
with: vi.fn(),
toJSON: vi.fn(),
})),
},
RelativePattern: vi.fn().mockImplementation((base: string, pattern: string) => ({
base,
pattern,
})),
env: {
uriScheme: "vscode",
language: "en",
Expand Down
12 changes: 12 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { MdmService } from "./services/mdm/MdmService"
import { migrateSettings } from "./utils/migrateSettings"
import { autoImportSettings } from "./utils/autoImportSettings"
import { API } from "./extension/api"
import { processRegistry } from "./core/process/ProcessRegistry"

import {
handleUri,
Expand Down Expand Up @@ -214,6 +215,17 @@ export async function activate(context: vscode.ExtensionContext) {
// This method is called when your extension is deactivated.
export async function deactivate() {
outputChannel.appendLine(`${Package.name} extension deactivated`)

// Clean up all tracked processes first to prevent leaks
try {
outputChannel.appendLine("Cleaning up tracked processes...")
await processRegistry.killAllProcesses()
processRegistry.dispose()
outputChannel.appendLine("Process cleanup completed")
} catch (error) {
outputChannel.appendLine(`Error during process cleanup: ${error}`)
}

await McpServerManager.cleanup(extensionContext)
TelemetryService.instance.shutdown()
TerminalRegistry.cleanup()
Expand Down
34 changes: 33 additions & 1 deletion src/integrations/terminal/ExecaTerminalProcess.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { execa, ExecaError } from "execa"
import psTree from "ps-tree"
import process from "process"
import * as vscode from "vscode"

import type { RooTerminal } from "./types"
import { BaseTerminalProcess } from "./BaseTerminalProcess"
import { processRegistry } from "../../core/process/ProcessRegistry"

export class ExecaTerminalProcess extends BaseTerminalProcess {
private terminalRef: WeakRef<RooTerminal>
private aborted = false
private pid?: number
private processId?: string

constructor(terminal: RooTerminal) {
super()
Expand Down Expand Up @@ -49,6 +52,22 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
})`${command}`

this.pid = subprocess.pid

// Register the process with the ProcessRegistry for cleanup tracking
if (subprocess.pid) {
this.processId = `execa-${subprocess.pid}-${Date.now()}`
try {
const currentDebugSession = vscode.debug.activeDebugSession
processRegistry.register(subprocess, this.processId, {
description: `Terminal command: ${command}`,
debugSessionId: currentDebugSession?.id,
})
} catch (error) {
// In test environment, vscode.debug might not be available
console.warn(`[ExecaTerminalProcess] Failed to register process: ${error}`)
}
}

const stream = subprocess.iterable({ from: "all", preserveNewlines: true })
this.terminal.setActiveStream(stream, subprocess.pid)

Expand Down Expand Up @@ -111,6 +130,13 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
this.terminal.setActiveStream(undefined)
this.emitRemainingBufferIfListening()
this.stopHotTimer()

// Unregister the process from the ProcessRegistry
if (this.processId) {
processRegistry.unregister(this.processId)
this.processId = undefined
}

this.emit("completed", this.fullOutput)
this.emit("continue")
}
Expand All @@ -124,7 +150,13 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
public override abort() {
this.aborted = true

if (this.pid) {
// Use ProcessRegistry for cleanup if available, otherwise fall back to manual cleanup
if (this.processId) {
processRegistry.killProcess(this.processId, "SIGINT").catch((error) => {
console.warn(`[ExecaTerminalProcess] Failed to kill process via registry: ${error}`)
})
} else if (this.pid) {
// Fallback to manual cleanup for processes not registered
psTree(this.pid, async (err, children) => {
if (!err) {
const pids = children.map((p) => parseInt(p.PID))
Expand Down
Loading
Loading