fix(extension): resolve 10-item limit truncation & WS zombie disconnection (v0.5.14)
This commit is contained in:
@@ -29,6 +29,20 @@
|
|||||||
|
|
||||||
## 🔴 Active/Recent Issues
|
## 🔴 Active/Recent Issues
|
||||||
|
|
||||||
|
### [2026-03-31] [step-probe] GetAllCascadeTrajectories 10-Item Hard Limit (Signal Drop)
|
||||||
|
- **증상**: `guitar_score` 등에서 활성화된 세션의 디스코드 승인 신호를 "계속해서" 잡지 못함. (WS 60초 타임아웃보다 더 치명적으로 신호가 아예 가지 않음)
|
||||||
|
- **원인**: Extension이 활성 세션을 찾기 위해 호출하는 `GetAllCascadeTrajectories` LS API가 `{}`(빈 인자)로 호출될 때, 기본적으로 **10개의 세션만 반환하는 하드 리밋(Pagination Limit)**이 걸려있음. 이로 인해 작업 내역이 누적되면 수많은 최신/진행 중 세션들이 10개 목록에서 밀려나 누락됨. 익스텐션은 세션이 없다고 판단해 강제로 `IDLE` 모드에 진입하며, 승인 대기열(WAITING) 자체를 검사하지 않게 됨.
|
||||||
|
- **해결** (v0.5.14): `v0.5.13`에서 도입했던 `{ limit: 100 }`이 LS 단의 쿼리 과부하로 인한 VS Code UI 프리징(DoS)을 유발하여 롤백하는 중 필수 정렬 파라미터(`descending: true`)까지 소실되었던 실수를 교정함. 최종적으로 `{ limit: 30, descending: true }`를 적용하여 파싱 부하 최소화 및 최신 세션 최상단(Index 0) 조회를 안전하게 구현함.
|
||||||
|
- **주의**: LS의 기본 SQLite/DB 응답 Limit 규칙에 의존하여 전체 데이터 스캔을 수행하는 로직은 언제든 Truncation 이슈(Data Loss)를 유발할 수 있음.
|
||||||
|
|
||||||
|
### [2026-03-31] [WS] Browser API Fallback 60s Timeout (Zombie Connection)
|
||||||
|
- **증상**: `guitar_score` 등 모든 작업 환경에서 약 60초마다 WebSocket 연결이 끊기고 재연결되는 현상이 반복되며(extension.log에 `Heartbeat timeout` 계속 출력), 그 사이 디스코드 승인 신호를 놓침.
|
||||||
|
- **원인**: Extension이 `ws` 모듈 로드 실패(VS Code 환경 등)로 인해 브라우저 내장 `WebSocket` 객체로 Fallback 됨. 브라우저 WS는 서버의 네이티브 ping을 받아 pong을 자동 응답하지만 JS에 이벤트를 노출하지 않음. 이로 인해 `lastPongTime` 갱신이 불가능해져, `Date.now() - lastPongTime > 60000` 조건이 무조건 통과되어 멀쩡한 연결을 강제 종료함 (False Positive).
|
||||||
|
- **해결** (v0.5.12):
|
||||||
|
1. `hub.py`: `{"type": "heartbeat"}` JSON 메시지 수신 시 명시적으로 `{"type": "pong"}` JSON을 응답하도록 수정.
|
||||||
|
2. `ws-client.ts`: 명시적 `pong` 핸들러 추가. JSON pong 지원 서버거나 Node.js ws를 사용할 때만 60초 타임아웃 검증을 거치도록 조건 보강 (`forceHeartbeatTimeoutIfNoPong`).
|
||||||
|
- **주의**: 브라우저 표준 WebSockets(W3C)는 ping/pong 제어 프레임을 JS로 노출하지 않음. 폴리필/크로스플랫폼 WS 래퍼 사용 시 하트비트는 반드시 JSON 메세지 형태의 Application Layer Ping/Pong으로 풀어내거나, Native WS API 여부를 확실히 체크해야 함.
|
||||||
|
|
||||||
### [2026-03-28] [step-probe] GetCascadeTrajectorySteps UTF-8 에러 무한 루프
|
### [2026-03-28] [step-probe] GetCascadeTrajectorySteps UTF-8 에러 무한 루프
|
||||||
- **증상**: `guitar_score` 프로젝트에서 `[STEP-PROBE] error: ...invalid UTF-8` 에러가 5초마다 반복되며 Discord 승인 신호가 전달되지 않음.
|
- **증상**: `guitar_score` 프로젝트에서 `[STEP-PROBE] error: ...invalid UTF-8` 에러가 5초마다 반복되며 Discord 승인 신호가 전달되지 않음.
|
||||||
- **원인**: AG LS 서버에서 특정 step의 `CortexStepEphemeralMessage.content`에 바이너리 데이터(이미지 등) 포함 → proto UTF-8 직렬화 500 에러. `catch(e)` 블록에서 `stallProbed=true`를 설정하지 않아 `!ctx.stallProbed` 조건이 항상 true → 5초마다 동일 요청 무한 재시도.
|
- **원인**: AG LS 서버에서 특정 step의 `CortexStepEphemeralMessage.content`에 바이너리 데이터(이미지 등) 포함 → proto UTF-8 직렬화 500 에러. `catch(e)` 블록에서 `stallProbed=true`를 설정하지 않아 `!ctx.stallProbed` 조건이 항상 true → 5초마다 동일 요청 무한 재시도.
|
||||||
|
|||||||
5
docs/devlog/2026-04-01.md
Normal file
5
docs/devlog/2026-04-01.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 2026-04-01 Devlog
|
||||||
|
|
||||||
|
| NNN | HH:MM | 작업 설명 | `커밋해시` | ✅ 또는 🔧 |
|
||||||
|
|-------|-------|-----------|-------------|--------------|
|
||||||
|
| 001 | 18:22 | `step-probe` 10-Item Truncation/DoS 우회 (vsix v0.5.14) | `TBD` | ✅ |
|
||||||
11
docs/devlog/entries/20260401-001.md
Normal file
11
docs/devlog/entries/20260401-001.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# step-probe Pagination 10-Item Truncation vs LS DoS 오류 수정
|
||||||
|
|
||||||
|
- **시간**: 2026-04-01 13:00~18:22
|
||||||
|
- **Commit**: `TBD`
|
||||||
|
- **Vikunja**: #N/A (임시 버그 픽스)
|
||||||
|
|
||||||
|
## 결정 사항
|
||||||
|
- 기존 `v0.5.13`에서 `limit: 100`으로 Pagination Limit(기본 10개)을 우회하려 했으나, LS DB 스캔 및 거대한 JSON 파싱이 VS Code Event Loop 블로킹을 유발하여 UI 멈춤(DoS) 발생.
|
||||||
|
- 롤백 과정에서 `{}`(인자 없음)으로 원복하면서 필수적인 `descending: true` 파라미터까지 누락됨.
|
||||||
|
- 이로 인해 `guitar_score` 등의 최신 작성 세션이 LS 조회 리밋(10)에서 밀려나 승인 신호를 수신하지 못하는 이슈 재발.
|
||||||
|
- 이를 해결하기 위해 `limit: 30, descending: true`로 설정. 파싱해야 할 JSON 객체 수를 1/3로 줄임과 동시에, 정렬 보장을 통해 최근 10초 이내에 활성화된 세션은 언제나 Index 0번 최상단에 고정되게끔 메커니즘을 수정함.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "gravity-bridge",
|
"name": "gravity-bridge",
|
||||||
"displayName": "Gravity Bridge",
|
"displayName": "Gravity Bridge",
|
||||||
"description": "Antigravity ↔ Discord 브리지 연동 확장",
|
"description": "Antigravity ↔ Discord 브리지 연동 확장",
|
||||||
"version": "0.5.11",
|
"version": "0.5.14",
|
||||||
"publisher": "variet",
|
"publisher": "variet",
|
||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.100.0"
|
"vscode": "^1.100.0"
|
||||||
|
|||||||
@@ -178,7 +178,8 @@ function setupMonitor() {
|
|||||||
ctx.logToFile(`[POLL#${pollCount}] alive`);
|
ctx.logToFile(`[POLL#${pollCount}] alive`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const allTraj = await ctx.sdk.ls.rawRPC('GetAllCascadeTrajectories', {});
|
// Fix (v0.5.14): Reverted 100-limit DoS but restored descending: true with a safe limit of 30
|
||||||
|
const allTraj = await ctx.sdk.ls.rawRPC('GetAllCascadeTrajectories', { limit: 30, descending: true });
|
||||||
if (!allTraj?.trajectorySummaries) {
|
if (!allTraj?.trajectorySummaries) {
|
||||||
if (pollCount <= 3) ctx.logToFile('[POLL] no trajectorySummaries');
|
if (pollCount <= 3) ctx.logToFile('[POLL] no trajectorySummaries');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export class WSBridgeClient {
|
|||||||
private heartbeatTimer: NodeJS.Timeout | null = null;
|
private heartbeatTimer: NodeJS.Timeout | null = null;
|
||||||
private authTimer: NodeJS.Timeout | null = null;
|
private authTimer: NodeJS.Timeout | null = null;
|
||||||
private lastPongTime: number = 0;
|
private lastPongTime: number = 0;
|
||||||
|
private forceHeartbeatTimeoutIfNoPong = false;
|
||||||
|
|
||||||
// Message queue (survives reconnection)
|
// Message queue (survives reconnection)
|
||||||
private messageQueue: WSMessage[] = [];
|
private messageQueue: WSMessage[] = [];
|
||||||
@@ -440,6 +441,14 @@ export class WSBridgeClient {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'pong': {
|
||||||
|
// Sent by Hub in response to our 'heartbeat' JSON message
|
||||||
|
// This is crucial for Browser-style WebSockets that don't expose native ping/pong
|
||||||
|
this.forceHeartbeatTimeoutIfNoPong = true;
|
||||||
|
this.lastPongTime = Date.now();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
this.logFn(`[WS] Unknown message type: ${msg.type}`);
|
this.logFn(`[WS] Unknown message type: ${msg.type}`);
|
||||||
}
|
}
|
||||||
@@ -498,7 +507,8 @@ export class WSBridgeClient {
|
|||||||
this.heartbeatTimer = setInterval(() => {
|
this.heartbeatTimer = setInterval(() => {
|
||||||
if (this.ws && this.connected) {
|
if (this.ws && this.connected) {
|
||||||
// Check for zombie connection (no pong for 60s)
|
// Check for zombie connection (no pong for 60s)
|
||||||
if (Date.now() - this.lastPongTime > 60000) {
|
const isNodeWs = (typeof this.ws.ping === 'function');
|
||||||
|
if ((isNodeWs || this.forceHeartbeatTimeoutIfNoPong) && Date.now() - this.lastPongTime > 60000) {
|
||||||
this.logFn('[WS] Heartbeat timeout — no pong received for 60s (zombie connection), terminating');
|
this.logFn('[WS] Heartbeat timeout — no pong received for 60s (zombie connection), terminating');
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
try { this.ws.terminate(); } catch { try { this.ws.close(); } catch { } }
|
try { this.ws.terminate(); } catch { try { this.ws.close(); } catch { } }
|
||||||
|
|||||||
3
hub.py
3
hub.py
@@ -590,7 +590,8 @@ class WSHub:
|
|||||||
await self._on_brain_event(conn.project, payload)
|
await self._on_brain_event(conn.project, payload)
|
||||||
|
|
||||||
elif msg_type == MsgType.HEARTBEAT:
|
elif msg_type == MsgType.HEARTBEAT:
|
||||||
pass # last_heartbeat already updated above
|
# Echo back a "pong" so clients without native ping/pong can update their timers
|
||||||
|
await conn.ws.send_json({"type": "pong"})
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[HUB] Unknown message type: {msg_type} from {conn.conn_id}")
|
logger.warning(f"[HUB] Unknown message type: {msg_type} from {conn.conn_id}")
|
||||||
|
|||||||
20
install_vsix.py
Normal file
20
install_vsix.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import zipfile, shutil, os
|
||||||
|
|
||||||
|
vsix = r"c:\Users\Variet-Worker\Desktop\gravity_control\extension\gravity-bridge-0.5.14.vsix"
|
||||||
|
dest = os.path.expanduser(r"~\.antigravity\extensions\variet.gravity-bridge-0.5.14")
|
||||||
|
tmp = r"C:\tmp\vsix_extract"
|
||||||
|
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
shutil.rmtree(tmp)
|
||||||
|
os.makedirs(tmp, exist_ok=True)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(vsix, 'r') as z:
|
||||||
|
z.extractall(tmp)
|
||||||
|
|
||||||
|
src = os.path.join(tmp, "extension")
|
||||||
|
if os.path.exists(dest):
|
||||||
|
shutil.rmtree(dest)
|
||||||
|
|
||||||
|
shutil.copytree(src, dest)
|
||||||
|
print(f"Installed to {dest}")
|
||||||
|
print("Files:", os.listdir(dest))
|
||||||
31
test_rpc.js
Normal file
31
test_rpc.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const { LSBridge } = require('./extension/out/sdk/ls-bridge');
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
const ls = new LSBridge();
|
||||||
|
await ls.connect();
|
||||||
|
|
||||||
|
console.log("Testing { limit: 5, descending: true }...");
|
||||||
|
let start = Date.now();
|
||||||
|
const res = await ls._rpc('GetAllCascadeTrajectories', { limit: 5, descending: true });
|
||||||
|
let duration = Date.now() - start;
|
||||||
|
|
||||||
|
const summaries = res.trajectorySummaries || {};
|
||||||
|
const keys = Object.keys(summaries);
|
||||||
|
console.log(`Execution time: ${duration}ms`);
|
||||||
|
console.log(`Returned entries: ${keys.length}`);
|
||||||
|
|
||||||
|
keys.slice(0, 5).forEach((k, idx) => {
|
||||||
|
const modT = summaries[k].lastModifiedTime || summaries[k].lastModifiedTimestamp || 'UNKNOWN';
|
||||||
|
console.log(`[${idx}] id=${k.substring(0,8)} mod=${modT} status=${summaries[k].status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\nTesting { limit: 100, descending: true }...");
|
||||||
|
start = Date.now();
|
||||||
|
const res100 = await ls._rpc('GetAllCascadeTrajectories', { limit: 100, descending: true });
|
||||||
|
duration = Date.now() - start;
|
||||||
|
console.log(`Execution time: ${duration}ms`);
|
||||||
|
console.log(`Returned entries: ${Object.keys(res100.trajectorySummaries || {}).length}`);
|
||||||
|
|
||||||
|
ls.disconnect();
|
||||||
|
}
|
||||||
|
test();
|
||||||
50
test_ws_logic.js
Normal file
50
test_ws_logic.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// test_ws_logic.js
|
||||||
|
class FakeWS {
|
||||||
|
constructor() {
|
||||||
|
this.msgLog = [];
|
||||||
|
this.terminated = false;
|
||||||
|
}
|
||||||
|
send(msg) {
|
||||||
|
this.msgLog.push(msg);
|
||||||
|
}
|
||||||
|
terminate() {
|
||||||
|
this.terminated = true;
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
this.terminated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIMULATE _startHeartbeat() logic from ws-client.ts v0.5.12
|
||||||
|
function testLogic(isNodeWs, serverSendsPong) {
|
||||||
|
let ws = new FakeWS();
|
||||||
|
let connected = true;
|
||||||
|
let lastPongTime = Date.now();
|
||||||
|
let forceHeartbeatTimeoutIfNoPong = serverSendsPong;
|
||||||
|
let checkCounter = 0;
|
||||||
|
|
||||||
|
// Fast forward 61 seconds in time
|
||||||
|
let timeElapsed = 61000;
|
||||||
|
let currentNow = Date.now() + timeElapsed;
|
||||||
|
|
||||||
|
// Simulate heartbeat timeout logic
|
||||||
|
let conditionMet = false;
|
||||||
|
if ((isNodeWs || forceHeartbeatTimeoutIfNoPong) && currentNow - lastPongTime > 60000) {
|
||||||
|
conditionMet = true;
|
||||||
|
ws.terminate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
conditionMet: conditionMet,
|
||||||
|
terminated: ws.terminated
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Scenario 1: Node WS (native ping/pong) MUST enforce 60s timeout:");
|
||||||
|
console.log(testLogic(true, false)); // expect true, true
|
||||||
|
|
||||||
|
console.log("\nScenario 2: Browser WS (fallback) + NO JSON PONG FROM SERVER MUST NOT enforce 60s timeout:");
|
||||||
|
console.log(testLogic(false, false)); // expect false, false (PREVENTS FALSE POSITIVE)
|
||||||
|
|
||||||
|
console.log("\nScenario 3: Browser WS (fallback) + JSON PONG FROM SERVER MUST enforce 60s timeout:");
|
||||||
|
console.log(testLogic(false, true)); // expect true, true (DETECTS ZOMBIE)
|
||||||
Reference in New Issue
Block a user