fix(extension): resolve 10-item limit truncation & WS zombie disconnection (v0.5.14)

This commit is contained in:
Variet Worker
2026-04-01 18:21:51 +09:00
parent 2d5059d2d5
commit 13f13ee243
10 changed files with 147 additions and 4 deletions

View File

@@ -29,6 +29,20 @@
## 🔴 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 에러 무한 루프
- **증상**: `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초마다 동일 요청 무한 재시도.

View 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` | ✅ |

View 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번 최상단에 고정되게끔 메커니즘을 수정함.

View File

@@ -2,7 +2,7 @@
"name": "gravity-bridge",
"displayName": "Gravity Bridge",
"description": "Antigravity ↔ Discord 브리지 연동 확장",
"version": "0.5.11",
"version": "0.5.14",
"publisher": "variet",
"engines": {
"vscode": "^1.100.0"

View File

@@ -178,7 +178,8 @@ function setupMonitor() {
ctx.logToFile(`[POLL#${pollCount}] alive`);
}
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 (pollCount <= 3) ctx.logToFile('[POLL] no trajectorySummaries');
return;

View File

@@ -124,6 +124,7 @@ export class WSBridgeClient {
private heartbeatTimer: NodeJS.Timeout | null = null;
private authTimer: NodeJS.Timeout | null = null;
private lastPongTime: number = 0;
private forceHeartbeatTimeoutIfNoPong = false;
// Message queue (survives reconnection)
private messageQueue: WSMessage[] = [];
@@ -440,6 +441,14 @@ export class WSBridgeClient {
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:
this.logFn(`[WS] Unknown message type: ${msg.type}`);
}
@@ -498,7 +507,8 @@ export class WSBridgeClient {
this.heartbeatTimer = setInterval(() => {
if (this.ws && this.connected) {
// 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');
if (this.ws) {
try { this.ws.terminate(); } catch { try { this.ws.close(); } catch { } }

3
hub.py
View File

@@ -590,7 +590,8 @@ class WSHub:
await self._on_brain_event(conn.project, payload)
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:
logger.warning(f"[HUB] Unknown message type: {msg_type} from {conn.conn_id}")

20
install_vsix.py Normal file
View 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
View 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
View 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)