mirror of
https://github.com/cline/cline.git
synced 2026-09-12 00:50:27 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e21a466ae |
Generated
+446
-220
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -409,14 +409,16 @@
|
||||
"@vscode/test-cli": "^0.0.10",
|
||||
"@vscode/test-electron": "^2.5.2",
|
||||
"@vscode/vsce": "^3.6.0",
|
||||
"archiver": "^7.0.1",
|
||||
"c8": "^10.1.3",
|
||||
"chai": "^4.3.10",
|
||||
"chalk": "5.6.2",
|
||||
"esbuild": "^0.25.0",
|
||||
"glob": "^11.0.3",
|
||||
"grpc-tools": "^1.13.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.0",
|
||||
"minimatch": "^3.0.3",
|
||||
"minimatch": "^3.1.2",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prebuild-install": "^7.1.3",
|
||||
"protoc-gen-ts": "^0.8.7",
|
||||
@@ -424,6 +426,7 @@
|
||||
"rimraf": "^6.0.1",
|
||||
"should": "^13.2.3",
|
||||
"sinon": "^19.0.2",
|
||||
"tar": "^7.5.1",
|
||||
"tree-kill": "^1.2.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-proto": "^2.6.1",
|
||||
@@ -456,8 +459,8 @@
|
||||
"@streamparser/json": "^0.0.22",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"archiver": "^7.0.1",
|
||||
"axios": "^1.12.0",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"cheerio": "^1.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"chrome-launcher": "^1.1.2",
|
||||
|
||||
@@ -19,6 +19,7 @@ service StateService {
|
||||
rpc updateTelemetrySetting(TelemetrySettingRequest) returns (Empty);
|
||||
rpc setWelcomeViewCompleted(BooleanRequest) returns (Empty);
|
||||
rpc updateInfoBannerVersion(Int64Request) returns (Empty);
|
||||
rpc getProcessInfo(EmptyRequest) returns (ProcessInfo);
|
||||
}
|
||||
message DictationSettings {
|
||||
bool feature_enabled = 1;
|
||||
@@ -302,3 +303,10 @@ message Viewport {
|
||||
message UpdateTerminalConnectionTimeoutResponse {
|
||||
optional int32 timeout_ms = 1;
|
||||
}
|
||||
|
||||
|
||||
message ProcessInfo {
|
||||
int32 process_id = 1;
|
||||
optional string version = 2;
|
||||
optional int64 uptime_ms = 3;
|
||||
}
|
||||
@@ -31,6 +31,9 @@ service EnvService {
|
||||
|
||||
// Returns events when the telemetry settings change.
|
||||
rpc subscribeToTelemetrySettings(cline.EmptyRequest) returns (stream TelemetrySettingsEvent);
|
||||
|
||||
// Initiates a graceful shutdown of the host bridge service.
|
||||
rpc shutdown(cline.EmptyRequest) returns (cline.Empty);
|
||||
}
|
||||
|
||||
message GetHostVersionResponse {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { ProcessInfo } from "@shared/proto/cline/state"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Gets process information including PID, version, and uptime
|
||||
* @param controller The controller instance
|
||||
* @param request Empty request
|
||||
* @returns ProcessInfo with process details
|
||||
*/
|
||||
export async function getProcessInfo(controller: Controller, request: EmptyRequest): Promise<ProcessInfo> {
|
||||
// Get the current state to access the version (same source as webview)
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
|
||||
return ProcessInfo.create({
|
||||
processId: process.pid,
|
||||
version: state.version || "unknown",
|
||||
uptimeMs: Math.floor(process.uptime() * 1000), // Convert seconds to milliseconds
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import Database from "better-sqlite3"
|
||||
import { existsSync, mkdirSync, unlinkSync } from "fs"
|
||||
import * as path from "path"
|
||||
import type { InstanceLockData, SqliteLockManagerOptions } from "./types"
|
||||
|
||||
export class SqliteLockManager {
|
||||
private db!: Database.Database
|
||||
private instanceAddress: string
|
||||
private dbPath: string
|
||||
private readonly STALE_LOCK_TIMEOUT = 1 * 60 * 1000 // 1 minute in milliseconds
|
||||
|
||||
constructor(options: SqliteLockManagerOptions) {
|
||||
this.instanceAddress = options.instanceAddress
|
||||
this.dbPath = options.dbPath
|
||||
|
||||
// Ensure the directory exists before creating the database
|
||||
const dbDir = path.dirname(this.dbPath)
|
||||
try {
|
||||
mkdirSync(dbDir, { recursive: true })
|
||||
} catch (error) {
|
||||
console.error(`CRITICAL ERROR: Failed to create SQLite database directory ${dbDir}:`, error)
|
||||
throw new Error(`Failed to create SQLite database directory: ${error}`)
|
||||
}
|
||||
|
||||
try {
|
||||
this.initializeDatabaseWithLockSync()
|
||||
} catch (error) {
|
||||
console.error(`CRITICAL ERROR: Failed to initialize SQLite database at ${this.dbPath}:`, error)
|
||||
throw new Error(`Failed to initialize SQLite database: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
private initializeDatabaseWithLockSync(): void {
|
||||
const lockFile = `${this.dbPath}.lock`
|
||||
|
||||
// Clean up stale lock files first
|
||||
this.cleanupStaleLockSync(lockFile)
|
||||
|
||||
try {
|
||||
// Try to acquire exclusive file lock for database creation
|
||||
const fs = require("fs")
|
||||
let fd: number | null = null
|
||||
|
||||
try {
|
||||
fd = fs.openSync(lockFile, "wx") // Exclusive creation - fails if file exists
|
||||
|
||||
// Write timestamp to lock file for stale lock detection
|
||||
fs.writeFileSync(fd, Date.now().toString())
|
||||
|
||||
// Check if database already exists
|
||||
const dbExists = existsSync(this.dbPath)
|
||||
|
||||
if (!dbExists) {
|
||||
// Database doesn't exist, create it
|
||||
this.db = new Database(this.dbPath)
|
||||
this.initializeDatabase()
|
||||
} else {
|
||||
// Database exists, just open it
|
||||
this.db = new Database(this.dbPath)
|
||||
}
|
||||
} finally {
|
||||
// Always clean up the lock file
|
||||
if (fd !== null) {
|
||||
fs.closeSync(fd)
|
||||
}
|
||||
try {
|
||||
unlinkSync(lockFile)
|
||||
} catch {} // Ignore errors if file was already deleted
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === "EEXIST") {
|
||||
// Another process is initializing the database, wait and retry
|
||||
const delay = 100 + Math.random() * 100 // Add jitter
|
||||
this.sleepSync(delay)
|
||||
this.initializeDatabaseWithLockSync()
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private sleepSync(ms: number) {
|
||||
// Non-spinning, synchronous sleep using Atomics.wait
|
||||
// Works in Node main thread (since v12.16+) and worker threads.
|
||||
const sab = new SharedArrayBuffer(4)
|
||||
const ia = new Int32Array(sab)
|
||||
Atomics.wait(ia, 0, 0, Math.max(0, Math.floor(ms)))
|
||||
}
|
||||
|
||||
private cleanupStaleLockSync(lockFile: string): void {
|
||||
try {
|
||||
if (!existsSync(lockFile)) {
|
||||
return // Lock file doesn't exist, nothing to clean up
|
||||
}
|
||||
|
||||
const fs = require("fs")
|
||||
|
||||
try {
|
||||
const timestampStr = fs.readFileSync(lockFile, "utf8").trim()
|
||||
const timestamp = parseInt(timestampStr, 10)
|
||||
|
||||
if (isNaN(timestamp) || Date.now() - timestamp > this.STALE_LOCK_TIMEOUT) {
|
||||
// Stale lock, remove it
|
||||
unlinkSync(lockFile)
|
||||
console.warn(`Removed stale database lock file: ${lockFile}`)
|
||||
}
|
||||
} catch (readError) {
|
||||
// If we can't read the timestamp, assume it's stale
|
||||
unlinkSync(lockFile)
|
||||
console.warn(`Removed unreadable database lock file: ${lockFile}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code !== "ENOENT") {
|
||||
// Lock file doesn't exist, which is fine
|
||||
console.warn(`Error checking lock file ${lockFile}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private initializeDatabase() {
|
||||
// Create the locks table with the unified schema (matches cli/pkg/common/schema.go)
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS locks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
held_by TEXT NOT NULL,
|
||||
lock_type TEXT NOT NULL CHECK (lock_type IN ('file', 'instance', 'folder')),
|
||||
lock_target TEXT NOT NULL,
|
||||
locked_at INTEGER NOT NULL,
|
||||
UNIQUE(lock_type, lock_target)
|
||||
);
|
||||
`)
|
||||
|
||||
// Create indexes for performance (matches cli/pkg/common/schema.go)
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_held_by ON locks(held_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_type ON locks(lock_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_target ON locks(lock_target);
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this instance in the locks table
|
||||
*/
|
||||
async registerInstance(data: {
|
||||
corePort: number
|
||||
hostPort: number
|
||||
version?: string
|
||||
status?: InstanceLockData["status"]
|
||||
}): Promise<void> {
|
||||
const now = Date.now()
|
||||
const hostAddress = `localhost:${data.hostPort}`
|
||||
|
||||
// Create instance lock entry
|
||||
const insertLock = this.db.prepare(`
|
||||
INSERT OR REPLACE INTO locks (held_by, lock_type, lock_target, locked_at)
|
||||
VALUES (?, 'instance', ?, ?)
|
||||
`)
|
||||
|
||||
insertLock.run(this.instanceAddress, hostAddress, now)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the timestamp for this instance (touch)
|
||||
*/
|
||||
touchInstance(): void {
|
||||
const now = Date.now()
|
||||
const updateLock = this.db.prepare(`
|
||||
UPDATE locks
|
||||
SET locked_at = ?
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`)
|
||||
|
||||
updateLock.run(now, this.instanceAddress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove this instance from the locks table
|
||||
*/
|
||||
unregisterInstance(): void {
|
||||
const deleteLock = this.db.prepare(`
|
||||
DELETE FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`)
|
||||
|
||||
deleteLock.run(this.instanceAddress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the registry for any instance registered on the given port
|
||||
*/
|
||||
getInstanceByPort(port: number): { instanceAddress: string; hostAddress: string } | null {
|
||||
const query = this.db.prepare(`
|
||||
SELECT held_by, lock_target
|
||||
FROM locks
|
||||
WHERE lock_type = 'instance'
|
||||
AND (held_by LIKE '%:' || ? OR lock_target LIKE '%:' || ?)
|
||||
`)
|
||||
|
||||
const result = query.get(port, port) as { held_by: string; lock_target: string } | undefined
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
instanceAddress: result.held_by,
|
||||
hostAddress: result.lock_target,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific instance entry from the registry
|
||||
*/
|
||||
removeInstanceByAddress(instanceAddress: string): void {
|
||||
const deleteLock = this.db.prepare(`
|
||||
DELETE FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`)
|
||||
|
||||
deleteLock.run(instanceAddress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
close(): void {
|
||||
this.db.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type LockType = "file" | "instance" | "folder"
|
||||
|
||||
export type LockStatus = "starting" | "healthy" | "unhealthy"
|
||||
|
||||
export interface LockRow {
|
||||
id: number
|
||||
held_by: string // address:port of instance holding the lock
|
||||
lock_type: LockType
|
||||
lock_target: string // varies by type: file path, host address, or folder path
|
||||
locked_at: number // timestamp when lock was acquired
|
||||
}
|
||||
|
||||
export interface InstanceLockData {
|
||||
address: string
|
||||
core_port: number
|
||||
host_port: number
|
||||
status: LockStatus
|
||||
last_seen: string
|
||||
process_pid: number
|
||||
version?: string
|
||||
created_at: string
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface SqliteLockManagerOptions {
|
||||
dbPath: string
|
||||
instanceAddress: string // host:port format
|
||||
}
|
||||
+240
-18
@@ -2,37 +2,200 @@ import { ExternalDiffViewProvider } from "@hosts/external/ExternalDiffviewProvid
|
||||
import { ExternalWebviewProvider } from "@hosts/external/ExternalWebviewProvider"
|
||||
import { ExternalHostBridgeClientManager } from "@hosts/external/host-bridge-client-manager"
|
||||
import { WebviewProviderType } from "@shared/webview/types"
|
||||
import * as path from "path"
|
||||
import { retryOperation } from "@utils/retry"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { initialize, tearDown } from "@/common"
|
||||
import { SqliteLockManager } from "@/core/locks/SqliteLockManager"
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
import { AuthHandler } from "@/hosts/external/AuthHandler"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
|
||||
import { waitForHostBridgeReady } from "./hostbridge-client"
|
||||
import { startProtobusService } from "./protobus-service"
|
||||
import { log } from "./utils"
|
||||
import { DATA_DIR, EXTENSION_DIR, extensionContext } from "./vscode-context"
|
||||
import { checkPortAvailability } from "./port-checker"
|
||||
import { startProtobusService, waitForHostBridgeReady } from "./protobus-service"
|
||||
import { log, SETTINGS_SUBFOLDER } from "./utils"
|
||||
import { createExtensionContext } from "./vscode-context"
|
||||
|
||||
// Default ports
|
||||
export const DEFAULT_PROTOBUS_PORT = 26040
|
||||
export const DEFAULT_HOSTBRIDGE_PORT = 26041
|
||||
|
||||
// Parse command line arguments
|
||||
interface CliArgs {
|
||||
port?: number
|
||||
hostBridgePort?: number
|
||||
config?: string
|
||||
help?: boolean
|
||||
}
|
||||
|
||||
function parseArgs(): CliArgs {
|
||||
const args: CliArgs = {}
|
||||
const argv = process.argv.slice(2)
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]
|
||||
switch (arg) {
|
||||
case "--port":
|
||||
case "-p":
|
||||
args.port = parseInt(argv[++i], 10)
|
||||
break
|
||||
case "--host-bridge-port":
|
||||
args.hostBridgePort = parseInt(argv[++i], 10)
|
||||
break
|
||||
case "--config":
|
||||
case "-c":
|
||||
args.config = argv[++i]
|
||||
break
|
||||
case "--help":
|
||||
case "-h":
|
||||
args.help = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
Cline Core - Standalone Server
|
||||
|
||||
Usage: node cline-core.js [options]
|
||||
|
||||
Options:
|
||||
-p, --port <port> Port for the main gRPC service (default: ${DEFAULT_PROTOBUS_PORT})
|
||||
--host-bridge-port <port> Port for the host bridge service (default: ${DEFAULT_HOSTBRIDGE_PORT})
|
||||
-c, --config <path> Directory for Cline data storage (default: ~/.cline or CLINE_DIR env var)
|
||||
-h, --help Show this help message
|
||||
|
||||
Environment Variables:
|
||||
PROTOBUS_ADDRESS Override the main service address (format: host:port)
|
||||
HOSTBRIDGE_ADDRESS Override the host bridge address (format: host:port)
|
||||
CLINE_DIR Default Cline data directory (overridden by --config flag)
|
||||
`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Parse command line arguments
|
||||
const args = parseArgs()
|
||||
|
||||
// Show help if requested
|
||||
if (args.help) {
|
||||
showHelp()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Configure ports from arguments or env vars
|
||||
let protobusPort = DEFAULT_PROTOBUS_PORT
|
||||
let hostBridgePort = DEFAULT_HOSTBRIDGE_PORT
|
||||
|
||||
if (args.port) {
|
||||
protobusPort = args.port
|
||||
// If only port is specified, calculate hostbridge port as port + 1000
|
||||
if (!args.hostBridgePort) {
|
||||
hostBridgePort = protobusPort + 1000
|
||||
}
|
||||
}
|
||||
if (args.hostBridgePort) {
|
||||
hostBridgePort = args.hostBridgePort
|
||||
}
|
||||
|
||||
// Set environment variables for the services to use
|
||||
if (!process.env.PROTOBUS_ADDRESS) {
|
||||
process.env.PROTOBUS_ADDRESS = `localhost:${protobusPort}`
|
||||
}
|
||||
if (!process.env.HOSTBRIDGE_ADDRESS) {
|
||||
process.env.HOSTBRIDGE_ADDRESS = `localhost:${hostBridgePort}`
|
||||
}
|
||||
|
||||
// Configure Cline directory from CLI args, env var, or default
|
||||
// Priority: --config flag > CLINE_DIR env var > ~/.cline default
|
||||
const clineDir = args.config || process.env.CLINE_DIR || `${os.homedir()}/.cline`
|
||||
|
||||
log("\n\n\nStarting cline-core service...\n\n\n")
|
||||
log(`Using Protobus port: ${protobusPort}`)
|
||||
log(`Using Host Bridge port: ${hostBridgePort}`)
|
||||
log(`Using Cline directory: ${clineDir}`)
|
||||
|
||||
await waitForHostBridgeReady()
|
||||
// Initialize SQLite lock manager for instance registration
|
||||
const dbPath = `${clineDir}/${SETTINGS_SUBFOLDER}/locks.db`
|
||||
// Use host:port everywhere (no scheme)
|
||||
const fullAddress = `localhost:${protobusPort}`
|
||||
let lockManager: SqliteLockManager | undefined
|
||||
try {
|
||||
lockManager = new SqliteLockManager({
|
||||
dbPath,
|
||||
instanceAddress: fullAddress,
|
||||
})
|
||||
|
||||
// The host bridge should be available before creating the host provider because it depends on the host bridge.
|
||||
setupHostProvider()
|
||||
// Check port availability before proceeding
|
||||
log(`Checking port availability for ${protobusPort}...`)
|
||||
const portCheck = await checkPortAvailability(protobusPort, lockManager)
|
||||
|
||||
if (!portCheck.canProceed) {
|
||||
log(`STARTUP BLOCKED: ${portCheck.error}`)
|
||||
lockManager.close()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await lockManager.registerInstance({
|
||||
corePort: protobusPort,
|
||||
hostPort: hostBridgePort,
|
||||
version: process.env.CLINE_VERSION,
|
||||
status: "starting",
|
||||
})
|
||||
log(`Registered instance in SQLite locks: ${fullAddress}`)
|
||||
} catch (err) {
|
||||
log(`CRITICAL ERROR: Failed to register instance in SQLite locks: ${String(err)}`)
|
||||
log(`This is a fatal error - cline-core cannot start without proper instance registration`)
|
||||
if (lockManager) {
|
||||
try {
|
||||
lockManager.close()
|
||||
} catch {}
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForHostBridgeReady()
|
||||
log("HostBridge is serving; continuing startup")
|
||||
} catch (err) {
|
||||
log(`ERROR: HostBridge error: ${String(err)}`)
|
||||
// Cleanup lock manager entry if startup fails
|
||||
if (lockManager) {
|
||||
try {
|
||||
lockManager.unregisterInstance()
|
||||
lockManager.close()
|
||||
} catch {}
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Create extension context with the configured directory
|
||||
const extensionContext = createExtensionContext(clineDir)
|
||||
|
||||
// Get EXTENSION_DIR and DATA_DIR from the extension context for use by HostProvider
|
||||
const EXTENSION_DIR = extensionContext.extensionPath
|
||||
const DATA_DIR = extensionContext.globalStoragePath
|
||||
|
||||
setupHostProvider(extensionContext, EXTENSION_DIR, DATA_DIR)
|
||||
|
||||
// Set up global error handlers to prevent process crashes
|
||||
setupGlobalErrorHandlers()
|
||||
setupGlobalErrorHandlers(lockManager)
|
||||
|
||||
const webviewProvider = await initialize(extensionContext)
|
||||
|
||||
// Enable the localhost HTTP server that handles auth redirects.
|
||||
AuthHandler.getInstance().setEnabled(true)
|
||||
|
||||
startProtobusService(webviewProvider.controller)
|
||||
|
||||
// Mark instance healthy after services are up
|
||||
try {
|
||||
lockManager?.touchInstance()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function setupHostProvider() {
|
||||
function setupHostProvider(extensionContext: any, extensionDir: string, dataDir: string) {
|
||||
const createWebview = (_: WebviewProviderType): WebviewProvider => {
|
||||
return new ExternalWebviewProvider(extensionContext, WebviewProviderType.SIDEBAR)
|
||||
}
|
||||
@@ -52,8 +215,8 @@ function setupHostProvider() {
|
||||
log,
|
||||
getCallbackUrl,
|
||||
getBinaryLocation,
|
||||
EXTENSION_DIR,
|
||||
DATA_DIR,
|
||||
extensionDir,
|
||||
dataDir,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,7 +224,7 @@ function setupHostProvider() {
|
||||
* Sets up global error handlers to prevent the process from crashing
|
||||
* on unhandled exceptions and promise rejections
|
||||
*/
|
||||
function setupGlobalErrorHandlers() {
|
||||
function setupGlobalErrorHandlers(lockManager?: SqliteLockManager) {
|
||||
// Handle unhandled exceptions
|
||||
process.on("uncaughtException", (error: Error) => {
|
||||
log(`ERROR: Uncaught exception: ${error.message}`)
|
||||
@@ -86,15 +249,74 @@ function setupGlobalErrorHandlers() {
|
||||
// Graceful shutdown handlers
|
||||
process.on("SIGINT", () => {
|
||||
log("Received SIGINT, shutting down gracefully...")
|
||||
process.exit(0)
|
||||
shutdownGracefully(lockManager)
|
||||
})
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
log("Received SIGTERM, shutting down gracefully...")
|
||||
tearDown()
|
||||
|
||||
process.exit(0)
|
||||
shutdownGracefully(lockManager)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Request host bridge shutdown with retry logic and timeout handling.
|
||||
* Uses best-effort approach - logs failures but doesn't block shutdown.
|
||||
*/
|
||||
async function requestHostBridgeShutdown(): Promise<void> {
|
||||
try {
|
||||
await retryOperation(3, 2000, async () => {
|
||||
await HostProvider.env.shutdown({})
|
||||
})
|
||||
log("Host bridge shutdown requested successfully")
|
||||
} catch (error) {
|
||||
log(`Warning: Failed to request host bridge shutdown: ${error}`)
|
||||
log("Proceeding with cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully shutdown the cline-core process by:
|
||||
* 1. Calling shutdown RPC on the paired host bridge
|
||||
* 2. Cleaning up the lock manager entry
|
||||
* 3. Tearing down services
|
||||
* 4. Exiting the process
|
||||
*/
|
||||
async function shutdownGracefully(lockManager?: SqliteLockManager) {
|
||||
try {
|
||||
// Step 1: Tell the paired host bridge to shut down
|
||||
log("Requesting host bridge shutdown...")
|
||||
if (HostProvider.isInitialized()) {
|
||||
await requestHostBridgeShutdown()
|
||||
} else {
|
||||
log("Warning: HostProvider not initialized, cannot request shutdown")
|
||||
}
|
||||
|
||||
// Step 2: Clean up lock manager entry
|
||||
log("Cleaning up lock manager entry...")
|
||||
try {
|
||||
lockManager?.unregisterInstance()
|
||||
lockManager?.close()
|
||||
log("Lock manager entry cleaned up successfully")
|
||||
} catch (error) {
|
||||
log(`Warning: Failed to clean up lock manager: ${error}`)
|
||||
}
|
||||
|
||||
// Step 3: Tear down services
|
||||
log("Tearing down services...")
|
||||
try {
|
||||
tearDown()
|
||||
log("Services torn down successfully")
|
||||
} catch (error) {
|
||||
log(`Warning: Failed to tear down services: ${error}`)
|
||||
}
|
||||
|
||||
log("Graceful shutdown completed")
|
||||
} catch (error) {
|
||||
log(`Error during graceful shutdown: ${error}`)
|
||||
} finally {
|
||||
// Step 4: Exit the process
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import * as grpc from "@grpc/grpc-js"
|
||||
import * as protoLoader from "@grpc/proto-loader"
|
||||
import * as health from "grpc-health-check"
|
||||
import { SqliteLockManager } from "@/core/locks/SqliteLockManager"
|
||||
import { log } from "./utils"
|
||||
|
||||
const SERVING_STATUS = 1
|
||||
|
||||
interface PortCheckResult {
|
||||
canProceed: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface RegistryEntry {
|
||||
instanceAddress: string
|
||||
hostAddress: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a gRPC health client for the given address
|
||||
*/
|
||||
function createHealthClient(address: string): any {
|
||||
const healthDef = protoLoader.loadSync(health.protoPath)
|
||||
const grpcObj = grpc.loadPackageDefinition(healthDef) as unknown as any
|
||||
const Health = grpcObj.grpc.health.v1.Health
|
||||
return new Health(address, grpc.credentials.createInsecure())
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a single health check on the given address
|
||||
*/
|
||||
async function checkHealthOnce(address: string): Promise<{ success: boolean; status?: number; error?: Error }> {
|
||||
const client = createHealthClient(address)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
try {
|
||||
client.close?.()
|
||||
} catch {}
|
||||
resolve({ success: false, error: new Error("Health check timeout") })
|
||||
}, 5000) // 5 second timeout
|
||||
|
||||
client.check({ service: "" }, (err: unknown, resp: any) => {
|
||||
clearTimeout(timeout)
|
||||
try {
|
||||
client.close?.()
|
||||
} catch {}
|
||||
|
||||
if (err) {
|
||||
resolve({ success: false, error: err as Error })
|
||||
} else {
|
||||
resolve({ success: true, status: resp?.status })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to shut down a host bridge instance
|
||||
*/
|
||||
async function shutdownHostBridge(hostAddress: string): Promise<boolean> {
|
||||
try {
|
||||
log(`Attempting to shutdown host bridge at ${hostAddress}`)
|
||||
|
||||
// This would need to be implemented - we need a way to send shutdown to a specific host
|
||||
// For now, we'll just log that we would do this
|
||||
log(`Would send shutdown command to host bridge at ${hostAddress}`)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
log(`Failed to shutdown host bridge at ${hostAddress}: ${error}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a port is available for binding, following the registry-first approach
|
||||
*/
|
||||
export async function checkPortAvailability(port: number, lockManager: SqliteLockManager): Promise<PortCheckResult> {
|
||||
log(`Checking port availability for port ${port}`)
|
||||
|
||||
// Step 1: Check registry first
|
||||
const registryEntry = lockManager.getInstanceByPort(port)
|
||||
|
||||
if (!registryEntry) {
|
||||
log(`No registry entry found for port ${port}, free to bind`)
|
||||
return { canProceed: true }
|
||||
}
|
||||
|
||||
log(`Found registry entry for port ${port}: instance=${registryEntry.instanceAddress}, host=${registryEntry.hostAddress}`)
|
||||
|
||||
// Step 2: Perform health check on the registered instance
|
||||
const coreAddress = registryEntry.instanceAddress
|
||||
|
||||
const performHealthCheck = async (): Promise<{ success: boolean; status?: number; error?: Error }> => {
|
||||
return await checkHealthOnce(coreAddress)
|
||||
}
|
||||
|
||||
// First health check attempt
|
||||
let healthResult = await performHealthCheck()
|
||||
|
||||
if (!healthResult.success) {
|
||||
// Health check ERROR - not our process
|
||||
log(`Health check failed for ${coreAddress}: ${healthResult.error?.message}`)
|
||||
log(`This indicates a non-Cline process is using port ${port}`)
|
||||
|
||||
// Attempt to shutdown the registered host bridge
|
||||
const shutdownSuccess = await shutdownHostBridge(registryEntry.hostAddress)
|
||||
if (shutdownSuccess) {
|
||||
log(`Successfully requested shutdown of host bridge ${registryEntry.hostAddress}`)
|
||||
}
|
||||
|
||||
// Remove from registry
|
||||
lockManager.removeInstanceByAddress(registryEntry.instanceAddress)
|
||||
log(`Removed stale registry entry for ${registryEntry.instanceAddress}`)
|
||||
|
||||
return {
|
||||
canProceed: false,
|
||||
error: `Port ${port} is occupied by a non-Cline process. Registry has been cleaned up.`,
|
||||
}
|
||||
}
|
||||
|
||||
// Health check succeeded - it's our process
|
||||
if (healthResult.status === SERVING_STATUS) {
|
||||
// Healthy Cline instance already running
|
||||
log(`Healthy Cline instance already running on port ${port}`)
|
||||
return {
|
||||
canProceed: false,
|
||||
error: `A healthy Cline instance is already running on port ${port}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Health check succeeded but status is not SERVING - unhealthy Cline instance
|
||||
log(`Unhealthy Cline instance detected on port ${port} (status: ${healthResult.status}), retrying in 1 second`)
|
||||
|
||||
// Wait 1 second and retry
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
// Second health check attempt
|
||||
healthResult = await performHealthCheck()
|
||||
|
||||
if (!healthResult.success) {
|
||||
// Now it's erroring - something changed
|
||||
log(`Health check now failing after retry for ${coreAddress}: ${healthResult.error?.message}`)
|
||||
|
||||
// Clean up registry since the instance is no longer responding
|
||||
lockManager.removeInstanceByAddress(registryEntry.instanceAddress)
|
||||
log(`Removed non-responsive registry entry for ${registryEntry.instanceAddress}`)
|
||||
|
||||
return {
|
||||
canProceed: false,
|
||||
error: `Port ${port} had an unhealthy Cline instance that is now non-responsive. Registry cleaned up.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (healthResult.status === SERVING_STATUS) {
|
||||
// Instance recovered
|
||||
log(`Cline instance on port ${port} has recovered and is now healthy`)
|
||||
return {
|
||||
canProceed: false,
|
||||
error: `Cline instance on port ${port} has recovered and is now serving`,
|
||||
}
|
||||
}
|
||||
|
||||
// Still unhealthy after retry
|
||||
log(`Cline instance on port ${port} remains unhealthy after retry (status: ${healthResult.status})`)
|
||||
return {
|
||||
canProceed: false,
|
||||
error: `Cline instance on port ${port} is unhealthy and did not recover after retry`,
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,14 @@ import { Controller } from "@core/controller"
|
||||
import { StreamingResponseHandler } from "@core/controller/grpc-handler"
|
||||
import { addProtobusServices } from "@generated/hosts/standalone/protobus-server-setup"
|
||||
import * as grpc from "@grpc/grpc-js"
|
||||
|
||||
import * as protoLoader from "@grpc/proto-loader"
|
||||
import { ReflectionService } from "@grpc/reflection"
|
||||
import { GrpcHandler, GrpcStreamingResponseHandler } from "@hosts/external/grpc-types"
|
||||
import * as health from "grpc-health-check"
|
||||
import { getPackageDefinition, log } from "./utils"
|
||||
|
||||
export const PROTOBUS_PORT = 26040
|
||||
export const DEFAULT_PROTOBUS_PORT = 26040
|
||||
export const DEFAULT_HOSTBRIDGE_PORT = 26041
|
||||
|
||||
export function startProtobusService(controller: Controller) {
|
||||
const server = new grpc.Server()
|
||||
@@ -28,7 +29,7 @@ export function startProtobusService(controller: Controller) {
|
||||
reflection.addToServer(server)
|
||||
|
||||
// Start the server.
|
||||
const host = process.env.PROTOBUS_ADDRESS || `127.0.0.1:${PROTOBUS_PORT}`
|
||||
const host = process.env.PROTOBUS_ADDRESS || `127.0.0.1:${DEFAULT_PROTOBUS_PORT}`
|
||||
server.bindAsync(host, grpc.ServerCredentials.createInsecure(), (err) => {
|
||||
if (err) {
|
||||
log(`Could not start ProtoBus service: Failed to bind to ${host}, port may be unavailable. ${err.message}`)
|
||||
@@ -109,3 +110,46 @@ function wrapStreamingResponseHandler<TRequest, TResponse>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side health check for the hostbridge service (kept at bottom for clarity)
|
||||
const SERVING_STATUS = 1
|
||||
function createHealthClient(address?: string) {
|
||||
const healthDef = protoLoader.loadSync(health.protoPath)
|
||||
const grpcObj = grpc.loadPackageDefinition(healthDef) as unknown as any
|
||||
const Health = grpcObj.grpc.health.v1.Health
|
||||
const target = address || process.env.HOSTBRIDGE_ADDRESS || `localhost:${DEFAULT_HOSTBRIDGE_PORT}`
|
||||
return new Health(target, grpc.credentials.createInsecure())
|
||||
}
|
||||
|
||||
async function checkHealthOnce(client: any): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
client.check({ service: "" }, (err: unknown, resp: any) => {
|
||||
if (err) {
|
||||
return resolve(false)
|
||||
}
|
||||
return resolve(resp?.status === SERVING_STATUS)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function waitForHostBridgeReady(timeoutMs = 60000, intervalMs = 500, address?: string): Promise<void> {
|
||||
const client = createHealthClient(address)
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const ok = await checkHealthOnce(client)
|
||||
if (ok) {
|
||||
try {
|
||||
client.close?.()
|
||||
} catch {}
|
||||
return
|
||||
}
|
||||
log("Waiting for hostbridge to be ready...")
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((r) => setTimeout(r, intervalMs))
|
||||
}
|
||||
try {
|
||||
client.close?.()
|
||||
} catch {}
|
||||
throw new Error("HostBridge health check timed out")
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import * as fs from "fs"
|
||||
import * as health from "grpc-health-check"
|
||||
import { StreamingCallbacks } from "@/hosts/host-provider-types"
|
||||
|
||||
// WE WILL HAVE TO MIGRATE THIS FROM DATA TO v1 LATER
|
||||
const SETTINGS_SUBFOLDER = "data"
|
||||
|
||||
const log = (...args: unknown[]) => {
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
@@ -51,4 +54,4 @@ async function asyncIteratorToCallbacks<T>(stream: AsyncIterable<T>, callbacks:
|
||||
}
|
||||
}
|
||||
|
||||
export { getPackageDefinition, log, asyncIteratorToCallbacks }
|
||||
export { getPackageDefinition, log, asyncIteratorToCallbacks, SETTINGS_SUBFOLDER }
|
||||
|
||||
@@ -8,62 +8,74 @@ import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { log } from "./utils"
|
||||
import { EnvironmentVariableCollection, MementoStore, readJson, SecretStore } from "./vscode-context-utils"
|
||||
|
||||
log("Running standalone cline", ExtensionRegistryInfo.version)
|
||||
log(`CLINE_ENVIRONMENT: ${process.env.CLINE_ENVIRONMENT}`)
|
||||
|
||||
export const CLINE_DIR = process.env.CLINE_DIR || `${os.homedir()}/.cline`
|
||||
export const DATA_DIR = path.join(CLINE_DIR, "data")
|
||||
const INSTALL_DIR = process.env.INSTALL_DIR || __dirname
|
||||
const WORKSPACE_STORAGE_DIR = process.env.WORKSPACE_STORAGE_DIR || path.join(DATA_DIR, "workspace")
|
||||
|
||||
mkdirSync(DATA_DIR, { recursive: true })
|
||||
mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true })
|
||||
log("Using settings dir:", DATA_DIR)
|
||||
|
||||
export const EXTENSION_DIR = path.join(INSTALL_DIR, "extension")
|
||||
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
|
||||
|
||||
const extension: Extension<void> = {
|
||||
id: ExtensionRegistryInfo.id,
|
||||
isActive: true,
|
||||
extensionPath: EXTENSION_DIR,
|
||||
extensionUri: URI.file(EXTENSION_DIR),
|
||||
packageJSON: readJson(path.join(EXTENSION_DIR, "package.json")),
|
||||
exports: undefined, // There are no API exports in the standalone version.
|
||||
activate: async () => {},
|
||||
extensionKind: ExtensionKind.UI,
|
||||
function getPackageVersion(): string {
|
||||
// Use build-time injected version (only method)
|
||||
return process.env.CLINE_VERSION || "unknown" // todo: sarah wanted to change the way we get the extension version
|
||||
}
|
||||
|
||||
const extensionContext: ExtensionContext = {
|
||||
extension: extension,
|
||||
extensionMode: EXTENSION_MODE,
|
||||
const VERSION = getPackageVersion() // todo: sarah wanted to change the way we get the extension version
|
||||
log("Running standalone cline ", VERSION)
|
||||
|
||||
// Set up KV stores.
|
||||
globalState: new MementoStore(path.join(DATA_DIR, "globalState.json")),
|
||||
secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")),
|
||||
log(`CLINE_ENVIRONMENT: ${process.env.CLINE_ENVIRONMENT}`)
|
||||
|
||||
// Set up URIs.
|
||||
storageUri: URI.file(WORKSPACE_STORAGE_DIR),
|
||||
storagePath: WORKSPACE_STORAGE_DIR, // Deprecated, not used in cline.
|
||||
globalStorageUri: URI.file(DATA_DIR),
|
||||
globalStoragePath: DATA_DIR, // Deprecated, not used in cline.
|
||||
// Accept clineDir parameter, but fall back to env variable, then default
|
||||
function createExtensionContext(clineDir?: string): ExtensionContext {
|
||||
const CLINE_DIR = clineDir || process.env.CLINE_DIR || `${os.homedir()}/.cline`
|
||||
const DATA_DIR = path.join(CLINE_DIR, "data")
|
||||
const INSTALL_DIR = process.env.INSTALL_DIR || __dirname
|
||||
const WORKSPACE_STORAGE_DIR = process.env.WORKSPACE_STORAGE_DIR || path.join(DATA_DIR, "workspace")
|
||||
|
||||
// Logs are global per extension, not per workspace.
|
||||
logUri: URI.file(DATA_DIR),
|
||||
logPath: DATA_DIR, // Deprecated, not used in cline.
|
||||
mkdirSync(DATA_DIR, { recursive: true })
|
||||
mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true })
|
||||
log("Using settings dir:", DATA_DIR)
|
||||
|
||||
extensionUri: URI.file(EXTENSION_DIR),
|
||||
extensionPath: EXTENSION_DIR, // Deprecated, not used in cline.
|
||||
asAbsolutePath: (relPath: string) => path.join(EXTENSION_DIR, relPath),
|
||||
const EXTENSION_DIR = path.join(INSTALL_DIR, "extension")
|
||||
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
|
||||
|
||||
subscriptions: [], // These need to be destroyed when the extension is deactivated.
|
||||
const extension: Extension<void> = {
|
||||
id: ExtensionRegistryInfo.id,
|
||||
isActive: true,
|
||||
extensionPath: EXTENSION_DIR,
|
||||
extensionUri: URI.file(EXTENSION_DIR),
|
||||
packageJSON: readJson(path.join(EXTENSION_DIR, "package.json")),
|
||||
exports: undefined,
|
||||
activate: async () => {},
|
||||
extensionKind: ExtensionKind.UI,
|
||||
}
|
||||
|
||||
environmentVariableCollection: new EnvironmentVariableCollection(),
|
||||
const extensionContext: ExtensionContext = {
|
||||
extension: extension,
|
||||
extensionMode: EXTENSION_MODE,
|
||||
|
||||
// Workspace state is per project/workspace when WORKSPACE_STORAGE_DIR is provided by the host.
|
||||
workspaceState: new MementoStore(path.join(WORKSPACE_STORAGE_DIR, "workspaceState.json")),
|
||||
// Set up KV stores.
|
||||
globalState: new MementoStore(path.join(DATA_DIR, "globalState.json")),
|
||||
secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")),
|
||||
|
||||
// Set up URIs.
|
||||
storageUri: URI.file(WORKSPACE_STORAGE_DIR),
|
||||
storagePath: WORKSPACE_STORAGE_DIR,
|
||||
globalStorageUri: URI.file(DATA_DIR),
|
||||
globalStoragePath: DATA_DIR,
|
||||
|
||||
// Logs are global per extension, not per workspace.
|
||||
logUri: URI.file(DATA_DIR),
|
||||
logPath: DATA_DIR,
|
||||
|
||||
extensionUri: URI.file(EXTENSION_DIR),
|
||||
extensionPath: EXTENSION_DIR,
|
||||
asAbsolutePath: (relPath: string) => path.join(EXTENSION_DIR, relPath),
|
||||
|
||||
subscriptions: [],
|
||||
|
||||
environmentVariableCollection: new EnvironmentVariableCollection(),
|
||||
|
||||
// Workspace state is per project/workspace when WORKSPACE_STORAGE_DIR is provided by the host.
|
||||
workspaceState: new MementoStore(path.join(WORKSPACE_STORAGE_DIR, "workspaceState.json")),
|
||||
}
|
||||
|
||||
return extensionContext
|
||||
}
|
||||
|
||||
console.log("Finished loading vscode context...")
|
||||
|
||||
export { extensionContext }
|
||||
export { createExtensionContext }
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* TypeScript equivalent of the Go common.RetryOperation utility
|
||||
* Performs an operation with retry logic and timeout handling
|
||||
*/
|
||||
export async function retryOperation<T>(maxRetries: number, timeoutPerAttempt: number, operation: () => Promise<T>): Promise<T> {
|
||||
let lastError: Error | undefined
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
// Create a timeout promise
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Operation timeout")), timeoutPerAttempt),
|
||||
)
|
||||
|
||||
// Race the operation against timeout
|
||||
const result = await Promise.race([operation(), timeoutPromise])
|
||||
return result // Success - return result
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
// Brief delay before retry
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Operation failed after ${maxRetries} attempts: ${lastError?.message}`)
|
||||
}
|
||||
Reference in New Issue
Block a user