fix(extension): Discord signal drop and UI freeze (async IO, regex filters, WS rate-limits) (v0.5.10)
This commit is contained in:
@@ -29,6 +29,15 @@
|
|||||||
|
|
||||||
## 🔴 Active/Recent Issues
|
## 🔴 Active/Recent Issues
|
||||||
|
|
||||||
|
### [2026-03-25] [Architecture] Discord Signal Drop & Extension Freezes
|
||||||
|
- **증상**: 장시간 자리비움 후 복귀 시 Discord로 승인 신호가 오지 않거나 VS Code UI가 간헐적/지속적으로 멈춤(Freeze).
|
||||||
|
- **원인**:
|
||||||
|
1. `ws.onerror` 발생 후 `onclose` 누락 시 재연결 콜백 호출이 이루어지지 않아 무한 대기 (장시간 마비)
|
||||||
|
2. `ws-client` 재연결 시 누적된 200개 큐를 동기식 burst 전송하여 Hub의 속도 제한(60개/10초)에 걸려 확정 영구 삭제됨
|
||||||
|
3. 로컬 브릿지 `http-bridge.ts`의 과거 유산인 `FALSE_POSITIVE_RE` 정규식이 AI 고유 버튼(Allow, Deny, Accept) 마저 필터링하여 Discord 전송 원천 차단
|
||||||
|
4. `step-probe.ts` 폴링 루프 내 동기식 파일 I/O 사용으로 인한 프리즈
|
||||||
|
- **해결** (v0.5.10): ws-client에 하드 타임아웃 및 50ms Paced-flush 적용, http-bridge의 정규식 기능 완화, step-probe 비동기 I/O 전환 체제 적용, observer-script의 필터된 신호 무한 HTTP 폴링 방어 코드 반영.
|
||||||
|
- **주의**: Extension 내부 로직 버그였으므로 Hub(Python) 코드는 건드리지 않음. Hub 속도 제한은 정상 방어 기제이므로 클라이언트 단의 Pacing이 올바른 방향임.
|
||||||
### [2026-03-24] DOM Observer /trigger-click 렌더링 순서 오작동 및 False Positive 프리징
|
### [2026-03-24] DOM Observer /trigger-click 렌더링 순서 오작동 및 False Positive 프리징
|
||||||
- **증상**: v0.5.9 패치 이후 코딩 시 Agent 화면이 끊임없이 서명 대기(Pending) 상태로 멈춤. 또는 디스코드에서 `Approve` 시 에디터 내의 엉뚱한 `Run Test`(코드 렌즈)를 클릭함.
|
- **증상**: v0.5.9 패치 이후 코딩 시 Agent 화면이 끊임없이 서명 대기(Pending) 상태로 멈춤. 또는 디스코드에서 `Approve` 시 에디터 내의 엉뚱한 `Run Test`(코드 렌즈)를 클릭함.
|
||||||
- **원인**: 텍스트와 정규식(`/^Run/i` 등)에만 의존하여 `querySelectorAll`을 수행할 경우, DOM 트리에 렌더링된 수많은 VS Code 네이티브 코드 렌즈 버튼을 Agent 버튼보다 먼저 찾아버리는 발생 위치(Context)의 한계점.
|
- **원인**: 텍스트와 정규식(`/^Run/i` 등)에만 의존하여 `querySelectorAll`을 수행할 경우, DOM 트리에 렌더링된 수많은 VS Code 네이티브 코드 렌즈 버튼을 Agent 버튼보다 먼저 찾아버리는 발생 위치(Context)의 한계점.
|
||||||
|
|||||||
5
docs/devlog/2026-03-25.md
Normal file
5
docs/devlog/2026-03-25.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 2026-03-25 Devlog
|
||||||
|
|
||||||
|
| NNN | HH:MM | 작업 설명 | `커밋해시` | ✅ 또는 🔧 |
|
||||||
|
|-----|-------|----------|-----------|-----------|
|
||||||
|
| 001 | 07:15 | ws-client reconnect pacing 및 http-bridge 정규식 필터 완화로 Signal Drop 해결 (v0.5.10) | `pending` | ✅ |
|
||||||
@@ -189,7 +189,8 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
|
|||||||
|
|
||||||
// ── Server-side false positive filter ──
|
// ── Server-side false positive filter ──
|
||||||
const cmd = (data.command || '').trim();
|
const cmd = (data.command || '').trim();
|
||||||
const FALSE_POSITIVE_RE = /^(Proceed|Continue|Open|Close|OK|Yes|No|Save|Undo|Redo|Back|Next|More|Less|Got it|Deny|Allow Once|Allow This Conversation|Dismiss|Decline|Accept|Reject|Accept all|Reject all)$/i;
|
// Removed valid AI buttons (Accept, Reject, Allow, Deny) which are now structurally protected by the observer script
|
||||||
|
const FALSE_POSITIVE_RE = /^(Proceed|Continue|Open|Close|OK|Yes|No|Save|Undo|Redo|Back|Next|More|Less|Got it|Dismiss)$/i;
|
||||||
if (FALSE_POSITIVE_RE.test(cmd)) {
|
if (FALSE_POSITIVE_RE.test(cmd)) {
|
||||||
ctx.logToFile(`[HTTP] filtered false positive: "${cmd}"`);
|
ctx.logToFile(`[HTTP] filtered false positive: "${cmd}"`);
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
|||||||
@@ -479,6 +479,12 @@ export function generateApprovalObserverScript(_port: number): string {
|
|||||||
headers:{'Content-Type':'application/json'},
|
headers:{'Content-Type':'application/json'},
|
||||||
body:JSON.stringify(payload)
|
body:JSON.stringify(payload)
|
||||||
}).then(function(r){return r.json();}).then(function(d){
|
}).then(function(r){return r.json();}).then(function(d){
|
||||||
|
if (!d.ok || d.filtered) {
|
||||||
|
log('Pending rejected/filtered for group ['+buttonsArr2.map(function(x){return x.text;}).join(', ')+']');
|
||||||
|
delete _sent[groupKey2];
|
||||||
|
for(var di=0;di<bidList2.length;di++){delete _sent[bidList2[di]];}
|
||||||
|
return;
|
||||||
|
}
|
||||||
log('Pending created: '+d.request_id+' for group ['+buttonsArr2.map(function(x){return x.text;}).join(', ')+']');
|
log('Pending created: '+d.request_id+' for group ['+buttonsArr2.map(function(x){return x.text;}).join(', ')+']');
|
||||||
pollResponseGroup(d.request_id,btnRefs2,bidList2,groupKey2);
|
pollResponseGroup(d.request_id,btnRefs2,bidList2,groupKey2);
|
||||||
}).catch(function(e){
|
}).catch(function(e){
|
||||||
|
|||||||
@@ -405,7 +405,9 @@ function setupMonitor() {
|
|||||||
(pd.step_index === ctx.lastPendingStepIndex || (ageMs < 60_000 && ageMs >= 0));
|
(pd.step_index === ctx.lastPendingStepIndex || (ageMs < 60_000 && ageMs >= 0));
|
||||||
if (isMatch) {
|
if (isMatch) {
|
||||||
pd.status = 'auto_resolved';
|
pd.status = 'auto_resolved';
|
||||||
fs.writeFileSync(pfPath, JSON.stringify(pd, null, 2), 'utf-8');
|
fs.promises.writeFile(pfPath, JSON.stringify(pd, null, 2), 'utf-8').catch(e => {
|
||||||
|
ctx.logToFile(`[AUTO-RESOLVE] write error: ${e.message}`);
|
||||||
|
});
|
||||||
resolvedCount++;
|
resolvedCount++;
|
||||||
const cmd = pd.command || '';
|
const cmd = pd.command || '';
|
||||||
if (cmd.length > primaryCommand.length && cmd !== 'Deny' && !cmd.includes('Allow')) {
|
if (cmd.length > primaryCommand.length && cmd !== 'Deny' && !cmd.includes('Allow')) {
|
||||||
@@ -989,7 +991,9 @@ export function writePendingApproval(data: { conversation_id: string; command: s
|
|||||||
if (data.step_type) existing.step_type = data.step_type;
|
if (data.step_type) existing.step_type = data.step_type;
|
||||||
if (data.step_index !== undefined) existing.step_index = data.step_index;
|
if (data.step_index !== undefined) existing.step_index = data.step_index;
|
||||||
existing.source = 'dom_observer+step_probe'; // mark as merged
|
existing.source = 'dom_observer+step_probe'; // mark as merged
|
||||||
fs.writeFileSync(efPath, JSON.stringify(existing, null, 2), 'utf-8');
|
fs.promises.writeFile(efPath, JSON.stringify(existing, null, 2), 'utf-8').catch(e => {
|
||||||
|
ctx.logToFile(`[DEDUP] merge write error: ${e.message}`);
|
||||||
|
});
|
||||||
ctx.logToFile(`[DEDUP] MERGED step_probe info into DOM pending: ${ef} cmd="${data.command.substring(0, 60)}"`);
|
ctx.logToFile(`[DEDUP] MERGED step_probe info into DOM pending: ${ef} cmd="${data.command.substring(0, 60)}"`);
|
||||||
// Record in memory dedup
|
// Record in memory dedup
|
||||||
if (data.step_index !== undefined && data.conversation_id) {
|
if (data.step_index !== undefined && data.conversation_id) {
|
||||||
@@ -1071,7 +1075,9 @@ export function writePendingApproval(data: { conversation_id: string; command: s
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// File route (fallback — only when WS is NOT connected)
|
// File route (fallback — only when WS is NOT connected)
|
||||||
fs.writeFileSync(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
|
fs.promises.writeFile(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8').catch(e => {
|
||||||
|
console.error(`Gravity Bridge: failed to write pending: ${e.message}`);
|
||||||
|
});
|
||||||
console.log(`Gravity Bridge: pending approval written → ${id}.json`);
|
console.log(`Gravity Bridge: pending approval written → ${id}.json`);
|
||||||
// Cache diff_review metadata in-memory (survives pending file deletion by Collector/Bot)
|
// Cache diff_review metadata in-memory (survives pending file deletion by Collector/Bot)
|
||||||
if (data.step_type === 'diff_review' && (data.edit_step_indices?.length || data.modified_files?.length)) {
|
if (data.step_type === 'diff_review' && (data.edit_step_indices?.length || data.modified_files?.length)) {
|
||||||
|
|||||||
@@ -213,12 +213,21 @@ export class WSBridgeClient {
|
|||||||
this.logFn(`[WS] Connecting to ${this.hubUrl}...`);
|
this.logFn(`[WS] Connecting to ${this.hubUrl}...`);
|
||||||
const ws = new WebSocket(this.hubUrl);
|
const ws = new WebSocket(this.hubUrl);
|
||||||
|
|
||||||
|
let connectTimeout: NodeJS.Timeout | null = null;
|
||||||
|
const clearConnectTimeout = () => {
|
||||||
|
if (connectTimeout) {
|
||||||
|
clearTimeout(connectTimeout);
|
||||||
|
connectTimeout = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Detect API style: Node.js 'ws' module has .on(), browser WebSocket doesn't
|
// Detect API style: Node.js 'ws' module has .on(), browser WebSocket doesn't
|
||||||
const isNodeWs = typeof ws.on === 'function';
|
const isNodeWs = typeof ws.on === 'function';
|
||||||
|
|
||||||
if (isNodeWs) {
|
if (isNodeWs) {
|
||||||
// ─── Node.js ws module (EventEmitter API) ───
|
// ─── Node.js ws module (EventEmitter API) ───
|
||||||
ws.on('open', () => {
|
ws.on('open', () => {
|
||||||
|
clearConnectTimeout();
|
||||||
this.logFn('[WS] Connection opened, authenticating...');
|
this.logFn('[WS] Connection opened, authenticating...');
|
||||||
this.ws = ws;
|
this.ws = ws;
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
@@ -235,11 +244,18 @@ export class WSBridgeClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', (code: number, reason: Buffer) => {
|
ws.on('close', (code: number, reason: Buffer) => {
|
||||||
|
clearConnectTimeout();
|
||||||
const reasonStr = reason ? reason.toString('utf-8') : '';
|
const reasonStr = reason ? reason.toString('utf-8') : '';
|
||||||
this.logFn(`[WS] Connection closed: code=${code} reason=${reasonStr}`);
|
this.logFn(`[WS] Connection closed: code=${code} reason=${reasonStr}`);
|
||||||
this._onDisconnect();
|
this._onDisconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ws.on('error', (err: any) => {
|
||||||
|
clearConnectTimeout();
|
||||||
|
this.logFn(`[WS] Connection error: ${err.message || err}`);
|
||||||
|
this._onDisconnect();
|
||||||
|
});
|
||||||
|
|
||||||
ws.on('pong', () => {
|
ws.on('pong', () => {
|
||||||
// Server responded to our ping — connection is alive
|
// Server responded to our ping — connection is alive
|
||||||
this.lastPongTime = Date.now();
|
this.lastPongTime = Date.now();
|
||||||
@@ -247,6 +263,7 @@ export class WSBridgeClient {
|
|||||||
} else {
|
} else {
|
||||||
// ─── Browser-style WebSocket API (.onopen / .onmessage) ───
|
// ─── Browser-style WebSocket API (.onopen / .onmessage) ───
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
clearConnectTimeout();
|
||||||
this.logFn('[WS] Connection opened (browser API), authenticating...');
|
this.logFn('[WS] Connection opened (browser API), authenticating...');
|
||||||
this.ws = ws;
|
this.ws = ws;
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
@@ -264,15 +281,29 @@ export class WSBridgeClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = (event: any) => {
|
ws.onclose = (event: any) => {
|
||||||
|
clearConnectTimeout();
|
||||||
this.logFn(`[WS] Connection closed: code=${event.code} reason=${event.reason || ''}`);
|
this.logFn(`[WS] Connection closed: code=${event.code} reason=${event.reason || ''}`);
|
||||||
this._onDisconnect();
|
this._onDisconnect();
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = (event: any) => {
|
ws.onerror = (event: any) => {
|
||||||
|
clearConnectTimeout();
|
||||||
this.logFn(`[WS] Error: ${event.message || 'connection error'}`);
|
this.logFn(`[WS] Error: ${event.message || 'connection error'}`);
|
||||||
|
this._onDisconnect();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connection timeout to prevent hanging if no close/error fires
|
||||||
|
connectTimeout = setTimeout(() => {
|
||||||
|
this.logFn('[WS] Connection timeout (15s) — forcing disconnect');
|
||||||
|
if (this.ws) {
|
||||||
|
try { this.ws.terminate(); } catch { try { this.ws.close(); } catch { } }
|
||||||
|
} else if (ws) {
|
||||||
|
try { ws.terminate(); } catch { try { ws.close(); } catch { } }
|
||||||
|
}
|
||||||
|
this._onDisconnect();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.logFn(`[WS] Connect failed: ${e.message}`);
|
this.logFn(`[WS] Connect failed: ${e.message}`);
|
||||||
this._scheduleReconnect();
|
this._scheduleReconnect();
|
||||||
@@ -448,13 +479,15 @@ export class WSBridgeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _flushQueue(): void {
|
private async _flushQueue(): Promise<void> {
|
||||||
if (this.messageQueue.length === 0) return;
|
if (this.messageQueue.length === 0) return;
|
||||||
this.logFn(`[WS] Flushing ${this.messageQueue.length} queued messages`);
|
this.logFn(`[WS] Flushing ${this.messageQueue.length} queued messages (paced)`);
|
||||||
const queue = [...this.messageQueue];
|
const queue = [...this.messageQueue];
|
||||||
this.messageQueue = [];
|
this.messageQueue = [];
|
||||||
for (const msg of queue) {
|
for (const msg of queue) {
|
||||||
this._sendRaw(msg);
|
this._sendRaw(msg);
|
||||||
|
// Pace the burst to avoid hitting the Hub's rate limit (60 msgs / 10s)
|
||||||
|
await new Promise(r => setTimeout(r, 50));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user