diff --git a/tools/cli/open.ts b/tools/cli/open.ts index 8d5dfc8..b747347 100644 --- a/tools/cli/open.ts +++ b/tools/cli/open.ts @@ -33,7 +33,12 @@ export async function readServerInfo(): Promise { // Legacy 3-line format: pid\nmode\nfilePath const lines = content.split('\n'); if (lines.length >= 2 && !isNaN(Number(lines[0]))) { - return { version: 1, pid: Number(lines[0]), mode: lines[1] as any, port: PORT }; + return { + version: 1, + pid: Number(lines[0]), + mode: lines[1] as any, + port: PORT, + }; } // Corrupt — clean up await fs.unlink(PID_FILE).catch(() => {}); @@ -64,35 +69,72 @@ async function validateAndResolve(filePath: string): Promise { // --- 2-state server detection --- -async function isServerRunning(): Promise<{ running: boolean; info: ServerInfo | null }> { - const info = await readServerInfo(); - if (!info) return { running: false, info: null }; - - // Check if process is alive +async function probePort(): Promise<{ + running: boolean; + info: ServerInfo | null; +}> { try { - process.kill(info.pid, 0); + const res = await fetch(`http://localhost:${PORT}/api/health`, { + signal: AbortSignal.timeout(1000), + }); + if (!res.ok) return { running: false, info: null }; + const body = await res.json(); + if (body.version !== 2) return { running: false, info: null }; + + // Recover PID file from health response + const recoveredInfo: ServerInfo = { + version: 2, + pid: body.pid, + mode: 'daemon', + port: PORT, + }; + await writeServerInfo(recoveredInfo); + console.log( + `[vync] Discovered existing server (PID ${body.pid}), recovered PID file.` + ); + return { running: true, info: recoveredInfo }; } catch { - await fs.unlink(PID_FILE).catch(() => {}); return { running: false, info: null }; } +} - // Health check - try { - const res = await fetch(`http://localhost:${info.port}/api/health`, { - signal: AbortSignal.timeout(1000), - }); - if (res.ok) { - const body = await res.json(); - if (body.version === 2) return { running: true, info }; - // Old server -> stop it - await vyncStop(); - return { running: false, info: null }; +async function isServerRunning(): Promise<{ + running: boolean; + info: ServerInfo | null; +}> { + const info = await readServerInfo(); + + if (info) { + // Check if process is alive + try { + process.kill(info.pid, 0); + } catch { + await fs.unlink(PID_FILE).catch(() => {}); + // Fall through to port probe + return probePort(); } - } catch {} - // PID alive but HTTP dead -> stale - await fs.unlink(PID_FILE).catch(() => {}); - return { running: false, info: null }; + // Health check + try { + const res = await fetch(`http://localhost:${info.port}/api/health`, { + signal: AbortSignal.timeout(1000), + }); + if (res.ok) { + const body = await res.json(); + if (body.version === 2) return { running: true, info }; + // Old server -> stop it + await vyncStop(); + return { running: false, info: null }; + } + } catch {} + + // PID alive but HTTP dead -> stale + await fs.unlink(PID_FILE).catch(() => {}); + return { running: false, info: null }; + } + + // No PID file — probe port as fallback + return probePort(); } // --- Helpers --- @@ -105,13 +147,20 @@ async function registerFile(port: number, filePath: string): Promise { }); if (!res.ok) { const body = await res.json().catch(() => ({})); - throw new Error(`[vync] Registration failed: ${(body as any).error || res.statusText}`); + throw new Error( + `[vync] Registration failed: ${(body as any).error || res.statusText}` + ); } } -async function openBrowserWithFile(port: number, filePath: string): Promise { +async function openBrowserWithFile( + port: number, + filePath: string +): Promise { const openModule = await import('open'); - await openModule.default(`http://localhost:${port}/?file=${encodeURIComponent(filePath)}`); + await openModule.default( + `http://localhost:${port}/?file=${encodeURIComponent(filePath)}` + ); } // --- Startup helpers --- @@ -144,7 +193,10 @@ async function runForeground(resolved: string): Promise { }); const { startServer } = await import('../server/server.js'); - const { shutdown } = await startServer({ initialFile: resolved, openBrowser: true }); + const { shutdown } = await startServer({ + initialFile: resolved, + openBrowser: true, + }); const cleanup = async () => { await shutdown(); @@ -182,7 +234,12 @@ async function runElectron(resolved: string): Promise { process.exit(1); } - await writeServerInfo({ version: 2, pid: childPid, mode: 'electron', port: PORT }); + await writeServerInfo({ + version: 2, + pid: childPid, + mode: 'electron', + port: PORT, + }); child.unref(); fsSync.closeSync(logFd); @@ -203,7 +260,24 @@ async function runElectron(resolved: string): Promise { try { const res = await fetch(`${url}/api/health`); if (res.ok) { - console.log(`[vync] Vync app running (PID ${childPid})`); + const body = await res.json(); + // Verify this is our server, not a ghost + if (body.pid !== childPid) { + // Ghost server detected — reuse it instead + console.log( + `[vync] Existing server found (PID ${body.pid}), reusing.` + ); + await writeServerInfo({ + version: 2, + pid: body.pid, + mode: 'daemon', + port: PORT, + }); + } else { + console.log(`[vync] Vync app running (PID ${childPid})`); + } + // Register file (same as runDaemon) + await registerFile(PORT, resolved); console.log(`[vync] Log: ${LOG_FILE}`); return; } @@ -256,7 +330,12 @@ async function runDaemon(resolved: string): Promise { } // Save child PID (not this process's PID) - await writeServerInfo({ version: 2, pid: childPid, mode: 'daemon', port: PORT }); + await writeServerInfo({ + version: 2, + pid: childPid, + mode: 'daemon', + port: PORT, + }); child.unref(); fsSync.closeSync(logFd); @@ -349,7 +428,9 @@ export async function vyncClose( const resolved = resolveVyncPath(filePath); try { const res = await fetch( - `http://localhost:${info.port}/api/files?file=${encodeURIComponent(resolved)}`, + `http://localhost:${info.port}/api/files?file=${encodeURIComponent( + resolved + )}`, { method: 'DELETE' } ); if (res.ok) console.log(`[vync] Closed: ${resolved}`); @@ -359,7 +440,9 @@ export async function vyncClose( } } else { try { - await fetch(`http://localhost:${info.port}/api/files?all=true`, { method: 'DELETE' }); + await fetch(`http://localhost:${info.port}/api/files?all=true`, { + method: 'DELETE', + }); console.log('[vync] All files closed.'); } catch { console.error('[vync] Server not reachable.'); @@ -449,9 +532,7 @@ export async function vyncStop(): Promise { } if (!portFree) { - console.error( - `[vync] Warning: port ${port} still in use after stop.` - ); + console.error(`[vync] Warning: port ${port} still in use after stop.`); } console.log(`[vync] Server stopped (PID ${info.pid})`); } diff --git a/tools/electron/main.ts b/tools/electron/main.ts index 8648898..b42dc1c 100644 --- a/tools/electron/main.ts +++ b/tools/electron/main.ts @@ -94,9 +94,54 @@ async function openFile(filePath: string): Promise { staticDir, }); } catch (err: any) { - dialog.showErrorBox('Vync Error', err.message); - app.quit(); - return; + // EADDRINUSE: try connecting to existing server + if (err.message.includes('already in use')) { + const existingUrl = 'http://localhost:3100'; + try { + const res = await fetch(`${existingUrl}/api/health`, { + signal: AbortSignal.timeout(2000), + }); + if (res.ok) { + const body = await res.json(); + if (body.version === 2) { + // Reuse existing server (no shutdown responsibility) + serverHandle = { shutdown: async () => {}, url: existingUrl }; + console.log(`[vync] Reusing existing server (PID ${body.pid})`); + // Register the file with existing server + await fetch(`${existingUrl}/api/files`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filePath: resolved }), + }); + } else { + dialog.showErrorBox( + 'Vync Error', + 'Incompatible server on port 3100' + ); + app.quit(); + return; + } + } else { + dialog.showErrorBox( + 'Vync Error', + 'Port 3100 in use by non-Vync process' + ); + app.quit(); + return; + } + } catch { + dialog.showErrorBox( + 'Vync Error', + 'Port 3100 in use but server not responding' + ); + app.quit(); + return; + } + } else { + dialog.showErrorBox('Vync Error', err.message); + app.quit(); + return; + } } } diff --git a/tools/server/server.ts b/tools/server/server.ts index e24c128..9e2757b 100644 --- a/tools/server/server.ts +++ b/tools/server/server.ts @@ -3,7 +3,11 @@ import path from 'node:path'; import express from 'express'; import { createWsServer } from './ws-handler.js'; import { FileRegistry } from './file-registry.js'; -import { addAllowedDir, createHostGuard, validateFilePath } from './security.js'; +import { + addAllowedDir, + createHostGuard, + validateFilePath, +} from './security.js'; import type { VyncFile } from '@vync/shared'; const DEFAULT_PORT = 3100; @@ -55,7 +59,12 @@ export async function startServer( // --- Health endpoint --- app.get('/api/health', (_req, res) => { - res.json({ version: 2, mode: 'hub', fileCount: registry.listFiles().length }); + res.json({ + version: 2, + mode: 'hub', + pid: process.pid, + fileCount: registry.listFiles().length, + }); }); // --- File registration API --- @@ -79,7 +88,10 @@ export async function startServer( status: alreadyRegistered ? 'already_registered' : 'registered', }); } catch (err: any) { - if (err.message.includes('outside allowed') || err.message.includes('Only .vync')) { + if ( + err.message.includes('outside allowed') || + err.message.includes('Only .vync') + ) { res.status(403).json({ error: err.message }); } else if (err.message.includes('Maximum')) { res.status(429).json({ error: err.message }); @@ -116,7 +128,9 @@ export async function startServer( app.get('/api/sync', async (req, res) => { const filePath = req.query.file as string; if (!filePath) { - res.status(400).json({ error: 'file_required', files: registry.listFiles() }); + res + .status(400) + .json({ error: 'file_required', files: registry.listFiles() }); return; } const sync = registry.getSync(filePath); @@ -194,7 +208,10 @@ export async function startServer( if (vite) await vite.close(); await new Promise((resolve) => { const timer = setTimeout(resolve, 3000); - server.close(() => { clearTimeout(timer); resolve(); }); + server.close(() => { + clearTimeout(timer); + resolve(); + }); }); }; @@ -221,7 +238,9 @@ export async function startServer( if (options.openBrowser && options.initialFile) { const openModule = await import('open'); - await openModule.default(`${url}/?file=${encodeURIComponent(options.initialFile)}`); + await openModule.default( + `${url}/?file=${encodeURIComponent(options.initialFile)}` + ); } return { shutdown, server, url, registry }; @@ -239,8 +258,14 @@ if (isDirectRun) { startServer({ initialFile: resolvedFile }) .then(({ shutdown }) => { - process.on('SIGINT', async () => { await shutdown(); process.exit(0); }); - process.on('SIGTERM', async () => { await shutdown(); process.exit(0); }); + process.on('SIGINT', async () => { + await shutdown(); + process.exit(0); + }); + process.on('SIGTERM', async () => { + await shutdown(); + process.exit(0); + }); }) .catch((err) => { console.error('[vync] Fatal error:', err.message);