Skip to content
Open
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
161 changes: 64 additions & 97 deletions src/cdp-monitor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ChildProcess, spawn } from "child_process"
import { type ChildProcess, execSync, spawn, spawnSync } from "child_process"
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
import { tmpdir } from "os"
import { dirname, join } from "path"
Expand Down Expand Up @@ -72,82 +72,6 @@ export class CDPMonitor {
}
}

private async runCommand(
command: string,
args: string[]
): Promise<{ stdout: string; stderr: string; code: number | null }> {
return await new Promise((resolve) => {
const proc = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] })
let stdout = ""
let stderr = ""
proc.stdout?.on("data", (data) => {
stdout += data.toString()
})
proc.stderr?.on("data", (data) => {
stderr += data.toString()
})
proc.on("error", (error) => {
resolve({ stdout, stderr: `${stderr}${error.message}`, code: 1 })
})
proc.on("close", (code) => {
resolve({ stdout, stderr, code })
})
})
}

private async listProcesses(): Promise<Array<{ pid: number; command: string }>> {
if (process.platform === "win32") {
const result = await this.runCommand("powershell", [
"-NoProfile",
"-Command",
"Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"
])
if (result.code !== 0) {
this.debugLog(`Failed to list processes via PowerShell: ${result.stderr.trim()}`)
return []
}
const raw = result.stdout.trim()
if (!raw) return []
try {
const parsed = JSON.parse(raw) as
| Array<{ ProcessId?: number; CommandLine?: string }>
| { ProcessId?: number; CommandLine?: string }
const items = Array.isArray(parsed) ? parsed : [parsed]
return items
.map((item) => ({
pid: Number(item.ProcessId),
command: item.CommandLine ?? ""
}))
.filter((item) => Number.isFinite(item.pid))
} catch (error) {
this.debugLog(`Failed to parse PowerShell process list: ${String(error)}`)
return []
}
}

let result = await this.runCommand("ps", ["-ax", "-o", "pid=", "-o", "command="])
if (result.code !== 0) {
result = await this.runCommand("ps", ["-eo", "pid=,command="])
}
if (result.code !== 0) {
this.debugLog(`Failed to list processes via ps: ${result.stderr.trim()}`)
return []
}

const lines = result.stdout.split("\n")
const processes: Array<{ pid: number; command: string }> = []
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
const match = trimmed.match(/^(\d+)\s+(.*)$/)
if (!match) continue
const pid = Number(match[1])
if (!Number.isFinite(pid)) continue
processes.push({ pid, command: match[2] })
}
return processes
}

/**
* Check if a URL should be monitored (i.e., it's from the user's app server, not dev3000's tools service or external sites)
*/
Expand Down Expand Up @@ -211,8 +135,26 @@ export class CDPMonitor {

private async discoverChromePids(): Promise<void> {
try {
const processes = await this.listProcesses()
const pids = processes.filter((proc) => proc.command.includes(this.profileDir)).map((proc) => proc.pid)
const { spawn } = await import("child_process")

// Find all Chrome processes with our profile directory
const profileDirEscaped = this.profileDir.replace(/'/g, "'\\''")
const pidsOutput = await new Promise<string>((resolve) => {
const proc = spawn("sh", ["-c", `pgrep -f '${profileDirEscaped}'`], {
stdio: "pipe"
})
let output = ""
proc.stdout?.on("data", (data) => {
output += data.toString()
})
proc.on("exit", () => resolve(output.trim()))
})

const pids = pidsOutput
.split("\n")
.filter(Boolean)
.map((pid) => parseInt(pid.trim(), 10))
.filter((pid) => !Number.isNaN(pid))

// Add main browser PID if we have it
if (this.browser?.pid) {
Expand Down Expand Up @@ -319,28 +261,53 @@ export class CDPMonitor {
* This prevents issues where Chrome defers to an existing instance
* instead of starting a new one with CDP enabled.
*/
private async killExistingChromeWithProfile(): Promise<void> {
private killExistingChromeWithProfile(): void {
try {
// Find Chrome processes using this profile directory
const processes = await this.listProcesses()
const pids = processes
.filter(
(proc) =>
proc.command.includes(`--user-data-dir=${this.profileDir}`) || proc.command.includes(this.profileDir)
)
.map((proc) => proc.pid)
.filter((pid) => pid !== this.browser?.pid)
// Find Chrome processes using this profile directory without invoking a shell
const psResult = spawnSync("ps", ["aux"], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"]
})

for (const pid of pids) {
this.debugLog(`Killing existing Chrome process ${pid} using profile ${this.profileDir}`)
try {
process.kill(pid, "SIGTERM")
} catch {
// Process may have already exited
if (psResult.error || psResult.status !== 0 || !psResult.stdout) {
this.debugLog("Unable to list processes with ps")
return
}

const searchToken = `user-data-dir=${this.profileDir}`
const lines = psResult.stdout.split("\n")
const pids: string[] = []

for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue

// Skip header line that typically starts with "USER"
if (trimmed.toUpperCase().startsWith("USER")) continue

if (trimmed.includes(searchToken)) {
// ps aux format: USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
const parts = trimmed.split(/\s+/)
if (parts.length > 1) {
const pid = parts[1]
if (pid && /^\d+$/.test(pid)) {
pids.push(pid)
}
}
}
}

if (pids.length > 0) {
await new Promise((resolve) => setTimeout(resolve, 500))
for (const pid of pids) {
this.debugLog(`Killing existing Chrome process ${pid} using profile ${this.profileDir}`)
try {
process.kill(Number.parseInt(pid, 10), "SIGTERM")
} catch {
// Process may have already exited
}
}
// Give Chrome a moment to clean up
execSync("sleep 0.5")
}
} catch {
// No existing Chrome found or ps/grep not available
Expand All @@ -350,7 +317,7 @@ export class CDPMonitor {

private async launchChrome(): Promise<void> {
// Kill any existing Chrome using this profile to prevent CDP conflicts
await this.killExistingChromeWithProfile()
this.killExistingChromeWithProfile()

return new Promise((resolve, reject) => {
// Use custom browser path if provided, otherwise try different Chrome executables based on platform
Expand Down
13 changes: 9 additions & 4 deletions src/dev-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2355,10 +2355,15 @@ export class DevEnvironment {
}

// Final synchronous lsof kill - most reliable method
const result = spawnSync("sh", ["-c", `lsof -ti:${this.options.port} | xargs kill -9 2>/dev/null`], {
stdio: "pipe"
})
this.debugLog(`Final lsof kill exit code: ${result.status}`)
const portNum = Number(this.options.port)
if (!Number.isInteger(portNum) || portNum <= 0 || portNum > 65535) {
this.debugLog(`Skipping final lsof kill due to invalid port: ${this.options.port}`)
} else {
const result = spawnSync("sh", ["-c", `lsof -ti:${portNum} | xargs kill -9 2>/dev/null`], {
stdio: "pipe"
})
this.debugLog(`Final lsof kill exit code: ${result.status}`)
}
} catch {
// Ignore pkill errors
}
Expand Down
21 changes: 15 additions & 6 deletions www/lib/workflow-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export async function listWorkflowRuns(userId: string): Promise<WorkflowRun[]> {
try {
;({ blobs } = await list({ prefix }))
} catch (error) {
console.error(`[Workflow Storage] Failed to list blobs for ${prefix}:`, error)
console.error("[Workflow Storage] Failed to list blobs for prefix %s:", prefix, error)
return []
}

Expand All @@ -101,13 +101,17 @@ export async function listWorkflowRuns(userId: string): Promise<WorkflowRun[]> {
// First verify blob exists using authenticated head() call
const blobInfo = await head(blob.url)
if (!blobInfo) {
console.error(`[Workflow Storage] Blob not found: ${blob.url}`)
console.error("[Workflow Storage] Blob not found for URL %s", blob.url)
return null
}

// Check content type from head - if not JSON, skip it
if (!blobInfo.contentType?.includes("application/json")) {
console.error(`[Workflow Storage] Unexpected content type ${blobInfo.contentType} for ${blob.url}`)
console.error(
"[Workflow Storage] Unexpected content type %s for URL %s",
blobInfo.contentType,
blob.url
)
return null
}

Expand All @@ -118,7 +122,10 @@ export async function listWorkflowRuns(userId: string): Promise<WorkflowRun[]> {
// Check for non-OK responses (security checkpoint returns 200 but HTML)
if (!response.ok) {
console.error(
`[Workflow Storage] HTTP ${response.status} fetching ${fetchUrl} (content-type: ${response.headers.get("content-type")})`
"[Workflow Storage] HTTP %d fetching %s (content-type: %s)",
response.status,
fetchUrl,
response.headers.get("content-type")
)
return null
}
Expand All @@ -127,15 +134,17 @@ export async function listWorkflowRuns(userId: string): Promise<WorkflowRun[]> {
const contentType = response.headers.get("content-type")
if (contentType && !contentType.includes("application/json")) {
console.error(
`[Workflow Storage] Response is not JSON: ${contentType} for ${fetchUrl} (likely Vercel Security Checkpoint)`
"[Workflow Storage] Response is not JSON: %s for %s (likely Vercel Security Checkpoint)",
contentType,
fetchUrl
)
return null
}

const run: WorkflowRun = await response.json()
return run
} catch (error) {
console.error(`[Workflow Storage] Failed to fetch ${blob.url}:`, error)
console.error("[Workflow Storage] Failed to fetch blob URL %s:", blob.url, error)
return null
}
})
Expand Down