From f0fd0ae8033421dc9a832817b617768f5c0802dd Mon Sep 17 00:00:00 2001 From: PresenceWith Date: Sat, 14 Mar 2026 03:26:05 +0900 Subject: [PATCH] fix(sync): PUT broadcast + Electron duplicate browser + probePort mode - PUT /api/sync now broadcasts file-changed to all WS clients (I-002) - Skip browser open when server is in electron mode (I-003) - Preserve existing PID file mode in probePort() recovery (I-004) - Add put-broadcast tests (2 tests, 95 total) - Update docs: PLAN.md status, ARCHITECTURE.md data flow, ISSUES.md Co-Authored-By: Claude Opus 4.6 --- docs/ARCHITECTURE.md | 5 +- docs/ISSUES.md | 124 ++++++++++++ docs/PLAN.md | 45 ++++- tools/cli/open.ts | 13 +- tools/server/__tests__/put-broadcast.test.ts | 187 +++++++++++++++++++ tools/server/server.ts | 5 + 6 files changed, 366 insertions(+), 13 deletions(-) create mode 100644 docs/ISSUES.md create mode 100644 tools/server/__tests__/put-broadcast.test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 809a0eb..c880da6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -67,6 +67,8 @@ → FileRegistry가 해당 파일의 SyncService를 조회 → 서버가 원자적 쓰기 (tmp + rename) → content hash 업데이트 (에코 방지) + → 해당 파일의 WS 클라이언트에 file-changed 브로드캐스트 + → PUT 클라이언트는 remoteUpdateUntilRef로 수신 무시 (에코 방지) ``` ### 2.3 충돌 시나리오 (→ D-008: Last Write Wins) @@ -353,7 +355,8 @@ Vync/ # nx monorepo (Drawnix 포크 기반) │ │ ├── security.ts # validateFilePath + hostGuard (LFI/DNS rebinding 방지) [VYNC 추가: Phase 8] │ │ ├── file-watcher.ts # chokidar 파일 감시 (unlink 이벤트 포함) │ │ ├── sync-service.ts # 동기화 로직 (에코 방지 + 원자적 쓰기 + drain()) -│ │ └── ws-handler.ts # WebSocket 메시지 핸들러 (파일 스코프 라우팅, ?file=) +│ │ ├── ws-handler.ts # WebSocket 메시지 핸들러 (파일 스코프 라우팅, ?file=) +│ │ └── __tests__/ # 서버 테스트 (hub-ws, discover, security, multi-file-e2e, put-broadcast 등) │ └── cli/ │ ├── main.ts # CLI 진입점 (init/open/close/stop/diff 라우팅) [VYNC 수정: Phase 8, diff 추가] │ ├── init.ts # vync init: 빈 .vync 파일 생성 diff --git a/docs/ISSUES.md b/docs/ISSUES.md new file mode 100644 index 0000000..c7d0540 --- /dev/null +++ b/docs/ISSUES.md @@ -0,0 +1,124 @@ +# Vync — 이슈 레지스트리 + +> 발견된 버그와 기술적 문제를 추적한다. 해결되면 상태를 `resolved`로 변경하고 해결 내용을 기록한다. +> 설계 결정은 [DECISIONS.md](./DECISIONS.md), 구현 계획은 [PLAN.md](./PLAN.md) 참조. + +--- + +## 이슈 목록 + +| ID | 제목 | 심각도 | 상태 | 컴포넌트 | 발견일 | +|----|------|--------|------|----------|--------| +| I-001 | [Sub-agent `.lastread` Write 실패](#i-001) | minor | open | vync-translator | 2026-03-14 | +| I-002 | [PUT /api/sync가 WebSocket 브로드캐스트 안 함](#i-002) | major | resolved | server | 2026-03-14 | +| I-003 | [Electron 모드에서 `vync open` 시 브라우저 중복 열림](#i-003) | minor | resolved | CLI (open.ts) | 2026-03-14 | +| I-004 | [probePort()가 mode를 항상 'daemon'으로 덮어씀](#i-004) | minor | resolved | CLI (open.ts) | 2026-03-14 | + +--- + +## 상태 정의 + +| 상태 | 설명 | +|------|------| +| `open` | 발견됨, 미착수 | +| `in-progress` | 수정 진행 중 | +| `resolved` | 해결 완료 (해결일 + 방법 기록) | +| `won't-fix` | 수정하지 않기로 결정 (사유 기록) | + +## 심각도 정의 + +| 심각도 | 설명 | +|--------|------| +| `critical` | 데이터 손실 또는 핵심 기능 불가 | +| `major` | 기능 저하, 워크어라운드 존재 | +| `minor` | 불편하지만 기능에 영향 없음 | + +--- + +## 상세 + +### I-001 + +**Sub-agent `.lastread` Write 실패** + +심각도: `minor` · 상태: `open` · 발견일: 2026-03-14 +컴포넌트: `vync-translator` sub-agent / diff pipeline + +**현상**: +Sub-agent가 `.vync` 파일 수정 후 `.lastread` 스냅샷 파일을 직접 Write하려 할 때, Claude Code의 Write 도구 안전 장치에 의해 차단됨. + +``` +Write(/Users/presence/projects/Vync/.vync/roadmap.vync.lastread) +→ Error: File has not been read yet. Read it first before writing to it. +``` + +**근본 원인**: +1. **직접 원인**: Write 도구는 기존 파일을 덮어쓰려면 먼저 Read해야 하는 안전 장치가 있음. Sub-agent가 `.vync` 파일은 읽었지만 `.lastread` 파일은 읽지 않고 Write 시도. +2. **구조적 원인**: `.lastread` 스냅샷 갱신은 `vync diff` 명령이 `Snapshot updated`로 자동 처리하는 영역. Sub-agent가 이 역할을 직접 수행하려 한 것 자체가 역할 경계 위반. + +**영향**: +- `.lastread` 스냅샷이 갱신되지 않아, 다음 `vync diff` 실행 시 이미 확인된 변경이 다시 보고될 수 있음 +- `.vync` 파일 자체는 정상 수정됨 — 데이터 손실 없음 + +**워크어라운드**: +`vync diff ` 한 번 실행하면 스냅샷이 동기화됨. + +**해결 방향**: +- `agents/vync-translator.md`에 `.lastread` 파일을 직접 조작하지 말라는 명시적 지침 추가 +- 또는 MCP 서버 전환 시 스냅샷 관리를 Tool API로 캡슐화하여 구조적으로 방지 + +--- + +### I-002 + +**PUT /api/sync가 WebSocket 브로드캐스트 안 함** + +심각도: `major` · 상태: `resolved` · 발견일: 2026-03-14 · 해결일: 2026-03-14 +컴포넌트: `tools/server/server.ts` (PUT /api/sync 핸들러) + +**현상**: +브라우저 A가 캔버스 편집 → PUT /api/sync → 서버가 디스크에 쓰기 → chokidar가 감지하지만 `isWriting=true`로 에코 방지됨 → 다른 클라이언트(B)에 브로드캐스트 안 됨. 멀티 탭/멀티 윈도우 환경에서 편집이 다른 클라이언트에 반영되지 않음. + +**근본 원인**: +PUT 핸들러가 `sync.writeFile()` 후 `res.json({ ok: true })`만 반환. chokidar 경로는 에코 방지(isWriting=true + hash 일치)로 항상 억제됨. 결과적으로 PUT으로 들어온 변경이 어떤 WS 클라이언트에도 전달되지 않음. + +**해결**: +PUT 핸들러에서 `sync.writeFile()` 후 `registry.broadcastToFile(filePath, { type: 'file-changed', filePath, data })` 추가. PUT 클라이언트는 `remoteUpdateUntilRef` 메커니즘으로 수신 무시 (에코 방지). + +--- + +### I-003 + +**Electron 모드에서 `vync open` 시 브라우저 중복 열림** + +심각도: `minor` · 상태: `resolved` · 발견일: 2026-03-14 · 해결일: 2026-03-14 +컴포넌트: `tools/cli/open.ts` (vyncOpen) + +**현상**: +Electron 서버가 실행 중일 때 `vync open ` 호출 시 파일 등록 후 시스템 브라우저도 열림. Electron 내에서 Hub WS로 탭이 자동 추가되므로 브라우저 열기는 불필요. + +**근본 원인**: +`vyncOpen()`에서 서버가 이미 실행 중일 때 `info?.mode`를 확인하지 않고 항상 `openBrowserWithFile()` 호출. + +**해결**: +`info?.mode !== 'electron'` 조건 추가. Electron 모드이면 파일 등록만 하고 브라우저 열기 생략. + +--- + +### I-004 + +**probePort()가 mode를 항상 'daemon'으로 덮어씀** + +심각도: `minor` · 상태: `resolved` · 발견일: 2026-03-14 · 해결일: 2026-03-14 +컴포넌트: `tools/cli/open.ts` (probePort) + +**현상**: +Electron 서버 실행 중 PID 파일이 없거나 stale일 때, `probePort()`가 포트에서 서버를 발견하면 PID 파일을 `mode: 'daemon'`으로 항상 복구. 이로 인해 Electron 서버임에도 `mode: 'daemon'`으로 기록됨. + +**근본 원인**: +`probePort()`의 recoveredInfo에서 `mode: 'daemon'`으로 하드코딩. + +**해결**: +`readServerInfo()`로 기존 PID 파일의 mode를 읽어서 보존. 기존 PID 파일이 없으면 `'daemon'` 기본값 사용. + +--- diff --git a/docs/PLAN.md b/docs/PLAN.md index edf9ba2..90c9560 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -7,13 +7,21 @@ ## 현재 상태 -**Phase**: Post-MVP 안정화 — 2026-03-12 +**Phase**: Post-MVP 안정화 — 2026-03-14 - Phase 1~9 (MVP): 완료 (2026-03-07 ~ 2026-03-09) - Diff Pipeline (D-015/D-016): 완료 (2026-03-11) - Server Lifecycle Fix (PR #10): 완료 (2026-03-11) - macOS 코드 서명 + 공증: 완료 (2026-03-11) -- Tab Bar "+" 버튼 수정 (PR #11): 구현 완료, PR 대기 (2026-03-12) -- Semantic Sync (D-017): 구현 완료, PR 대기 (2026-03-13) — `feat/semantic-sync` 브랜치 +- Tab Bar "+" 버튼 수정 (PR #11): 완료 (2026-03-12, develop 병합) +- Semantic Sync (D-017/D-018): 완료 (PR #14, 2026-03-13, develop 병합) +- Plugin Path Fix (PR #15): 완료 ($VYNC_HOME 기반 경로, 2026-03-13, develop 병합) +- Asar Unpacked Path Fix: 완료 (정적 파일 경로 수정, `fcba037`) +- Fix: Electron/Web Sync + Duplicate Browser Opening: 완료 (2026-03-14, develop) + - PUT /api/sync 후 WebSocket 브로드캐스트 추가 (I-002) + - Electron 모드에서 `vync open` 시 브라우저 중복 열기 방지 (I-003) + - probePort() mode 보존 (I-004) + - 95개 테스트 PASS (신규 2개) +- **develop → main 병합 필요** (develop가 main 대비 20+ 커밋 ahead) --- @@ -342,10 +350,9 @@ ### 검증 - [x] TB.9 TypeScript 컴파일 확인 - [x] TB.10 전체 테스트 PASS (72개) -- [ ] TB.11 E2E 수동 검증 (6개 시나리오) +- [x] TB.11 E2E 수동 검증 -### 남은 작업 -- E2E 수동 검증 후 PR 생성 (fix/tab-add-button → develop) +**완료**: develop 병합 (`7b83ae8`), fix/tab-add-button 브랜치 삭제됨 --- @@ -373,8 +380,30 @@ - [x] 메인 세션이 확신 레벨별 행동 가이드 보유 - [x] 93개 전체 테스트 PASS (diff 31개 포함) -### 남은 작업 -- PR 생성 (feat/semantic-sync → develop) +**PR**: #14 (feat/semantic-sync → develop, 2026-03-13), feat/semantic-sync 브랜치 삭제됨 + +--- + +## Plugin Path Fix (PR #15) + +**목표**: 플러그인 스크립트 경로를 하드코딩(`~/.claude/skills/...`)에서 `$VYNC_HOME` 환경변수 기반으로 전환하여 마켓플레이스 설치 환경에서도 정상 동작하게 한다. +**브랜치**: `fix/plugin-path` +**계획**: `docs/plans/2026-03-13-plugin-path-fix.md` + +- [x] PP.1 `skills/vync-editing/SKILL.md` — 5곳 `$VYNC_HOME` 전환 +- [x] PP.2 `agents/vync-translator.md` — 7곳 `$VYNC_HOME` 전환 + fallback 섹션 +- [x] PP.3 PoC 3/3 PASS (sub-agent $VYNC_HOME 접근, 스크립트 실행, Read fallback) +- [x] PP.4 E2E 검증 PASS (새 세션, `/vync create` 전체 흐름) +- [x] PP.5 플러그인 캐시 동기화 완료 + +**PR**: #15 (fix/plugin-path → develop, 2026-03-13) + +--- + +## Asar Unpacked Path Fix + +**목표**: Electron 패키징 시 정적 파일 경로를 `asar.unpacked` 기반으로 수정. +**커밋**: `fcba037` (fix(desktop): use asar.unpacked path for static assets) --- diff --git a/tools/cli/open.ts b/tools/cli/open.ts index b747347..4bdbb77 100644 --- a/tools/cli/open.ts +++ b/tools/cli/open.ts @@ -81,11 +81,12 @@ async function probePort(): Promise<{ const body = await res.json(); if (body.version !== 2) return { running: false, info: null }; - // Recover PID file from health response + // Recover PID file from health response, preserving existing mode if available + const existingInfo = await readServerInfo().catch(() => null); const recoveredInfo: ServerInfo = { version: 2, pid: body.pid, - mode: 'daemon', + mode: existingInfo?.mode ?? 'daemon', port: PORT, }; await writeServerInfo(recoveredInfo); @@ -394,10 +395,14 @@ export async function vyncOpen( const port = info?.port ?? PORT; if (running) { - // Hub mode: register file and open browser + // Hub mode: register file console.log('[vync] Server running, registering file...'); await registerFile(port, resolved); - await openBrowserWithFile(port, resolved); + if (info?.mode !== 'electron') { + await openBrowserWithFile(port, resolved); + } else { + console.log('[vync] File registered. Electron will update via Hub WS.'); + } return; } diff --git a/tools/server/__tests__/put-broadcast.test.ts b/tools/server/__tests__/put-broadcast.test.ts new file mode 100644 index 0000000..415c135 --- /dev/null +++ b/tools/server/__tests__/put-broadcast.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import http from 'node:http'; +import express from 'express'; +import { WebSocket } from 'ws'; +import { createWsServer } from '../ws-handler.js'; +import { FileRegistry } from '../file-registry.js'; +import { addAllowedDir, clearAllowedDirs } from '../security.js'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import type { VyncFile } from '@vync/shared'; + +function waitForMessage(ws: WebSocket): Promise { + return new Promise((resolve) => { + ws.on('message', function handler(data) { + ws.off('message', handler); + resolve(JSON.parse(data.toString())); + }); + }); +} + +describe('PUT /api/sync broadcast', () => { + let server: http.Server; + let registry: FileRegistry; + let port: number; + let tmpDir: string; + + const vyncData: VyncFile = { + version: 1, + viewport: { zoom: 1, x: 0, y: 0 }, + elements: [], + }; + + beforeEach(async () => { + tmpDir = path.join(os.tmpdir(), `put-broadcast-test-${Date.now()}`); + await fs.mkdir(tmpDir, { recursive: true }); + clearAllowedDirs(); + addAllowedDir(tmpDir); + + const app = express(); + app.use(express.json({ limit: '10mb' })); + + registry = new FileRegistry(); + + // Replicate the PUT /api/sync handler from server.ts + app.put('/api/sync', async (req, res) => { + const filePath = req.query.file as string; + if (!filePath) { + res.status(400).json({ error: 'file_required' }); + return; + } + const sync = registry.getSync(filePath); + if (!sync) { + res.status(404).json({ error: 'File not registered', filePath }); + return; + } + try { + const data = req.body as VyncFile; + if (!data || !Array.isArray(data.elements)) { + res.status(400).json({ error: 'Invalid VyncFile format' }); + return; + } + await sync.writeFile(data); + registry.broadcastToFile(filePath, { + type: 'file-changed', + filePath, + data, + }); + res.json({ ok: true }); + } catch (err) { + res.status(500).json({ error: 'Failed to write file' }); + } + }); + + server = http.createServer(app); + createWsServer(server, 0, registry); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + port = (server.address() as any).port; + }); + + afterEach(async () => { + await registry.shutdown(); + server.close(); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('broadcasts file-changed to all WS clients after PUT', async () => { + const filePath = path.join(tmpDir, 'test.vync'); + await fs.writeFile(filePath, JSON.stringify(vyncData)); + await registry.register(filePath); + + const realPath = await fs.realpath(filePath); + + // Connect two file-scoped WS clients + const ws1 = new WebSocket( + `ws://127.0.0.1:${port}/ws?file=${encodeURIComponent(realPath)}` + ); + const ws2 = new WebSocket( + `ws://127.0.0.1:${port}/ws?file=${encodeURIComponent(realPath)}` + ); + + // Wait for connected messages + await waitForMessage(ws1); + await waitForMessage(ws2); + + // Set up message listeners before PUT + const msg1Promise = waitForMessage(ws1); + const msg2Promise = waitForMessage(ws2); + + // PUT new data + const updatedData: VyncFile = { + version: 1, + viewport: { zoom: 1, x: 0, y: 0 }, + elements: [{ id: 'abc12', type: 'mindmap', data: {}, children: [] }] as any, + }; + + const res = await fetch( + `http://127.0.0.1:${port}/api/sync?file=${encodeURIComponent(realPath)}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updatedData), + } + ); + expect(res.ok).toBe(true); + + // Both clients should receive file-changed + const [msg1, msg2] = await Promise.all([msg1Promise, msg2Promise]); + + expect(msg1.type).toBe('file-changed'); + expect(msg1.filePath).toBe(realPath); + expect(msg1.data.elements).toHaveLength(1); + + expect(msg2.type).toBe('file-changed'); + expect(msg2.filePath).toBe(realPath); + expect(msg2.data.elements).toHaveLength(1); + + ws1.close(); + ws2.close(); + }); + + it('does not broadcast to clients of other files', async () => { + const fileA = path.join(tmpDir, 'a.vync'); + const fileB = path.join(tmpDir, 'b.vync'); + await fs.writeFile(fileA, JSON.stringify(vyncData)); + await fs.writeFile(fileB, JSON.stringify(vyncData)); + await registry.register(fileA); + await registry.register(fileB); + + const realA = await fs.realpath(fileA); + const realB = await fs.realpath(fileB); + + // Connect client to file B + const wsB = new WebSocket( + `ws://127.0.0.1:${port}/ws?file=${encodeURIComponent(realB)}` + ); + await waitForMessage(wsB); + + // PUT to file A + const updatedData: VyncFile = { + version: 1, + viewport: { zoom: 1, x: 0, y: 0 }, + elements: [{ id: 'xyz99', type: 'mindmap', data: {}, children: [] }] as any, + }; + + await fetch( + `http://127.0.0.1:${port}/api/sync?file=${encodeURIComponent(realA)}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updatedData), + } + ); + + // Client B should NOT receive any message — wait briefly to confirm + const received = await Promise.race([ + waitForMessage(wsB).then(() => true), + new Promise((r) => setTimeout(() => r(false), 300)), + ]); + + expect(received).toBe(false); + + wsB.close(); + }); +}); diff --git a/tools/server/server.ts b/tools/server/server.ts index c247667..37f2cc5 100644 --- a/tools/server/server.ts +++ b/tools/server/server.ts @@ -203,6 +203,11 @@ export async function startServer( return; } await sync.writeFile(data); + registry.broadcastToFile(filePath, { + type: 'file-changed', + filePath, + data, + }); res.json({ ok: true }); } catch (err) { console.error('[vync] Error writing file:', err);