Files
windmill/frontend/src/lib/components/debug/dapClient.ts
T
Ruben FiszelandClaude Opus 5 29e179f787 fix(debugger): report python debugger dependency install failures instead of timing out (#10531)
* fix: report python debugger dependency install failures instead of timing out

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: surface swallowed installer errors and stream debugger prepare progress

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: reap the python debugger on a failed launch and bound prepare-deps

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: match uv failure output by stripping progress instead of matching errors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: treat uv build, download and warning lines as install progress

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:46:36 +02:00

570 lines
13 KiB
TypeScript

/**
* DAP (Debug Adapter Protocol) WebSocket Client for Monaco integration.
*
* This client implements the DAP protocol over WebSocket to communicate
* with a Python debugpy-based debug server.
*/
import { writable, get } from 'svelte/store'
export interface DAPMessage {
seq: number
type: 'request' | 'response' | 'event'
command?: string
event?: string
request_seq?: number
success?: boolean
message?: string
body?: Record<string, unknown>
arguments?: Record<string, unknown>
}
export interface Breakpoint {
id: number
verified: boolean
line: number
source?: { path: string; name?: string }
}
export interface StackFrame {
id: number
name: string
source?: { path: string; name?: string }
line: number
column: number
}
export interface Variable {
name: string
value: string
type?: string
variablesReference: number
}
export interface Scope {
name: string
variablesReference: number
expensive: boolean
}
export interface DebugState {
connected: boolean
initialized: boolean
running: boolean
stopped: boolean
stoppedReason?: string
currentLine?: number
currentFile?: string
stackFrames: StackFrame[]
scopes: Scope[]
variables: Map<number, Variable[]>
breakpoints: Map<string, Breakpoint[]>
output: string[]
logs: string
result?: unknown
error?: string
}
const initialState: DebugState = {
connected: false,
initialized: false,
running: false,
stopped: false,
stackFrames: [],
scopes: [],
variables: new Map(),
breakpoints: new Map(),
output: [],
logs: '',
result: undefined,
error: undefined
}
export const debugState = writable<DebugState>({ ...initialState })
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000
// `launch` waits on dependency installation in the debug server, which can take minutes on a
// cold cache; anything shorter here reports a timeout while the install is still running.
const REQUEST_TIMEOUT_MS_BY_COMMAND: Record<string, number> = {
launch: 180_000
}
export class DAPClient {
private ws: WebSocket | null = null
private seq = 1
private pendingRequests: Map<
number,
{ resolve: (value: DAPMessage) => void; reject: (error: Error) => void }
> = new Map()
private url: string
constructor(url: string = 'ws://localhost:3003') {
this.url = url
}
/**
* Connect to the DAP server.
*/
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
try {
console.log('[DAP] Connecting to:', this.url)
this.ws = new WebSocket(this.url)
this.ws.onopen = () => {
console.log('[DAP] Connected successfully')
debugState.update((s) => ({ ...s, connected: true }))
resolve()
}
this.ws.onclose = () => {
console.log('[DAP] WebSocket closed')
debugState.update((s) => ({
...initialState,
breakpoints: s.breakpoints,
// Preserve result and logs after disconnect
result: s.result,
logs: s.logs,
output: s.output
}))
// Reject rather than drop: a dropped `launch` leaves its caller awaiting
// until the timeout below fires, which is minutes rather than seconds.
const aborted = Array.from(this.pendingRequests.values())
this.pendingRequests.clear()
for (const pending of aborted) {
pending.reject(new Error('DAP connection closed'))
}
}
this.ws.onerror = (error) => {
console.error('DAP WebSocket error:', error)
reject(new Error('WebSocket connection failed'))
}
this.ws.onmessage = (event) => {
this.handleMessage(event.data)
}
} catch (error) {
reject(error)
}
})
}
/**
* Disconnect from the DAP server.
*/
disconnect(): void {
if (this.ws) {
this.ws.close()
this.ws = null
}
debugState.set({ ...initialState })
}
/**
* Send a request to the DAP server and wait for a response.
*/
private async sendRequest(command: string, args?: Record<string, unknown>): Promise<DAPMessage> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('Not connected to DAP server')
}
const seq = this.seq++
const message: DAPMessage = {
seq,
type: 'request',
command,
arguments: args
}
const timeoutMs = REQUEST_TIMEOUT_MS_BY_COMMAND[command] ?? DEFAULT_REQUEST_TIMEOUT_MS
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(seq)
reject(new Error(`Request timeout: ${command} (after ${timeoutMs}ms)`))
}, timeoutMs)
this.pendingRequests.set(seq, {
resolve: (value) => {
clearTimeout(timeout)
resolve(value)
},
reject: (error) => {
clearTimeout(timeout)
reject(error)
}
})
this.ws!.send(JSON.stringify(message))
})
}
/**
* Handle incoming messages from the DAP server.
*/
private handleMessage(data: string): void {
try {
const message: DAPMessage = JSON.parse(data)
if (message.type === 'response') {
const pending = this.pendingRequests.get(message.request_seq!)
if (pending) {
this.pendingRequests.delete(message.request_seq!)
if (message.success) {
pending.resolve(message)
} else {
pending.reject(new Error(message.message || 'Request failed'))
}
}
} else if (message.type === 'event') {
console.log('[DAP] Event received:', message.event, message.body)
this.handleEvent(message)
}
} catch (error) {
console.error('Failed to parse DAP message:', error)
}
}
/**
* Handle DAP events.
*/
private handleEvent(event: DAPMessage): void {
console.log('[DAP] Handling event:', event.event)
const body = event.body as Record<string, unknown> | undefined
switch (event.event) {
case 'initialized':
// Clear logs and result when starting a new session
debugState.update((s) => ({
...s,
initialized: true,
logs: '',
output: [],
result: undefined,
error: undefined
}))
break
case 'stopped':
debugState.update((s) => ({
...s,
stopped: true,
running: false,
stoppedReason: body?.reason as string,
currentLine: body?.line as number | undefined
}))
// Automatically fetch stack trace when stopped
this.fetchStackTrace()
break
case 'continued':
debugState.update((s) => ({
...s,
stopped: false,
running: true,
stoppedReason: undefined
}))
break
case 'terminated':
debugState.update((s) => ({
...s,
running: false,
stopped: false,
initialized: false,
result: body?.result,
error: body?.error as string | undefined
}))
break
case 'output':
if (body?.output) {
debugState.update((s) => ({
...s,
output: [...s.output, body.output as string],
logs: s.logs + (body.output as string)
}))
}
break
case 'breakpoint':
// Breakpoint was verified/modified by the server
break
default:
console.log('Unhandled DAP event:', event.event)
}
}
/**
* Initialize the debug session.
*/
async initialize(): Promise<DAPMessage> {
const response = await this.sendRequest('initialize', {
clientID: 'windmill',
clientName: 'Windmill Script Editor',
adapterID: 'python',
pathFormat: 'path',
linesStartAt1: true,
columnsStartAt1: true,
supportsVariableType: true,
supportsVariablePaging: false,
supportsRunInTerminalRequest: false,
locale: 'en-US'
})
return response
}
/**
* Set breakpoints in a source file.
*/
async setBreakpoints(path: string, lines: number[]): Promise<Breakpoint[]> {
console.log('[DAP] Setting breakpoints at path:', path, 'lines:', lines)
const response = await this.sendRequest('setBreakpoints', {
source: { path },
breakpoints: lines.map((line) => ({ line }))
})
console.log('[DAP] Breakpoints response:', response.body)
const breakpoints = (response.body?.breakpoints as Breakpoint[]) || []
debugState.update((s) => {
const newBreakpoints = new Map(s.breakpoints)
newBreakpoints.set(path, breakpoints)
return { ...s, breakpoints: newBreakpoints }
})
return breakpoints
}
/**
* Notify the server that configuration is done.
*/
async configurationDone(): Promise<void> {
await this.sendRequest('configurationDone')
}
/**
* Launch a script for debugging.
*/
async launch(options: {
program?: string
code?: string
args?: string[] | Record<string, unknown>
cwd?: string
callMain?: boolean
env?: Record<string, string>
// JWT token for audit/authorization (signed by backend)
token?: string
}): Promise<void> {
await this.sendRequest('launch', {
program: options.program,
code: options.code,
args: options.args || {},
cwd: options.cwd || '.',
callMain: options.callMain || false,
env: options.env || {},
// Pass JWT token to debugger for verification
token: options.token
})
debugState.update((s) => ({ ...s, running: true }))
}
/**
* Continue execution.
*/
async continue_(): Promise<void> {
await this.sendRequest('continue', { threadId: 1 })
debugState.update((s) => ({ ...s, stopped: false, running: true }))
}
/**
* Step over (next).
*/
async stepOver(): Promise<void> {
await this.sendRequest('next', { threadId: 1 })
}
/**
* Step into.
*/
async stepIn(): Promise<void> {
await this.sendRequest('stepIn', { threadId: 1 })
}
/**
* Step out.
*/
async stepOut(): Promise<void> {
await this.sendRequest('stepOut', { threadId: 1 })
}
/**
* Pause execution.
*/
async pause(): Promise<void> {
await this.sendRequest('pause', { threadId: 1 })
}
/**
* Terminate the debug session.
*/
async terminate(): Promise<void> {
await this.sendRequest('terminate')
}
/**
* Get threads.
*/
async getThreads(): Promise<{ id: number; name: string }[]> {
const response = await this.sendRequest('threads')
return (response.body?.threads as { id: number; name: string }[]) || []
}
/**
* Get stack trace.
*/
async getStackTrace(threadId: number = 1): Promise<StackFrame[]> {
const response = await this.sendRequest('stackTrace', {
threadId,
startFrame: 0,
levels: 20
})
const frames = (response.body?.stackFrames as StackFrame[]) || []
debugState.update((s) => ({
...s,
stackFrames: frames,
currentLine: frames[0]?.line,
currentFile: frames[0]?.source?.path
}))
return frames
}
/**
* Get scopes for a stack frame.
*/
async getScopes(frameId: number): Promise<Scope[]> {
console.log('[DAP] getScopes called with frameId:', frameId)
const response = await this.sendRequest('scopes', { frameId })
console.log('[DAP] getScopes response:', response)
const scopes = (response.body?.scopes as Scope[]) || []
console.log('[DAP] getScopes parsed scopes:', scopes)
debugState.update((s) => ({ ...s, scopes }))
return scopes
}
/**
* Get variables for a scope.
*/
async getVariables(variablesReference: number): Promise<Variable[]> {
console.log('[DAP] getVariables called with variablesReference:', variablesReference)
const response = await this.sendRequest('variables', { variablesReference })
console.log('[DAP] getVariables response:', response)
const variables = (response.body?.variables as Variable[]) || []
console.log('[DAP] getVariables parsed variables:', variables)
debugState.update((s) => {
const newVariables = new Map(s.variables)
newVariables.set(variablesReference, variables)
return { ...s, variables: newVariables }
})
return variables
}
/**
* Evaluate an expression.
* @param expression The expression to evaluate
* @param frameId Optional frame ID to evaluate in
* @param context The evaluation context
* @param token Optional JWT token for signed expression (audit logging)
*/
async evaluate(
expression: string,
frameId?: number,
context: 'watch' | 'repl' | 'hover' = 'repl',
token?: string
): Promise<{ result: string; variablesReference: number }> {
const response = await this.sendRequest('evaluate', {
expression,
frameId,
context,
token
})
return {
result: response.body?.result as string,
variablesReference: response.body?.variablesReference as number
}
}
/**
* Fetch stack trace (called automatically when stopped).
*/
private async fetchStackTrace(): Promise<void> {
console.log('[DAP] fetchStackTrace called')
try {
const frames = await this.getStackTrace()
console.log('[DAP] fetchStackTrace got frames:', frames)
if (frames.length > 0) {
console.log('[DAP] fetchStackTrace calling getScopes with frameId:', frames[0].id)
await this.getScopes(frames[0].id)
} else {
console.log('[DAP] fetchStackTrace: no frames, skipping getScopes')
}
} catch (error) {
console.error('Failed to fetch stack trace:', error)
}
}
/**
* Clear output.
*/
clearOutput(): void {
debugState.update((s) => ({ ...s, output: [] }))
}
/**
* Check if connected.
*/
isConnected(): boolean {
return get(debugState).connected
}
/**
* Check if running.
*/
isRunning(): boolean {
return get(debugState).running
}
/**
* Check if stopped at a breakpoint.
*/
isStopped(): boolean {
return get(debugState).stopped
}
}
// Singleton instance
let dapClientInstance: DAPClient | null = null
export function getDAPClient(url?: string): DAPClient {
if (!dapClientInstance) {
dapClientInstance = new DAPClient(url)
}
return dapClientInstance
}
export function resetDAPClient(): void {
if (dapClientInstance) {
dapClientInstance.disconnect()
dapClientInstance = null
}
}