mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
refactor: Simplify file watching - remove unnecessary OptimizedFileWatcher
- Integrate stat checking directly into StreamWatcher - Remove platform-specific code paths that all used fs.watch anyway - Keep the actual optimization: checking file stats to avoid spurious events - Simpler, cleaner code with the same benefits The real improvements remain: 1. Direct notifications for in-process sessions 2. Stat checking to verify actual file changes 3. Only processing when file size increases 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
daa1db3392
commit
d7e811412a
2 changed files with 45 additions and 297 deletions
|
|
@ -1,264 +0,0 @@
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as os from 'os';
|
|
||||||
import { EventEmitter } from 'events';
|
|
||||||
|
|
||||||
interface WatchOptions {
|
|
||||||
persistent?: boolean;
|
|
||||||
interval?: number; // For polling fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optimized file watcher that uses platform-specific mechanisms for lower latency
|
|
||||||
*/
|
|
||||||
export class OptimizedFileWatcher extends EventEmitter {
|
|
||||||
// Define event types for type safety
|
|
||||||
on(event: 'change', listener: (stats: fs.Stats) => void): this;
|
|
||||||
on(event: 'error', listener: (error: Error) => void): this;
|
|
||||||
on(event: string, listener: (...args: any[]) => void): this {
|
|
||||||
return super.on(event, listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(event: 'change', stats: fs.Stats): boolean;
|
|
||||||
emit(event: 'error', error: Error): boolean;
|
|
||||||
emit(event: string, ...args: any[]): boolean {
|
|
||||||
return super.emit(event, ...args);
|
|
||||||
}
|
|
||||||
private watcher?: fs.FSWatcher;
|
|
||||||
private pollInterval?: NodeJS.Timeout;
|
|
||||||
private lastSize: number = 0;
|
|
||||||
private lastMtime: number = 0;
|
|
||||||
private readonly platform: string;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly filePath: string,
|
|
||||||
private readonly options: WatchOptions = {}
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
this.platform = os.platform();
|
|
||||||
|
|
||||||
// Get initial stats
|
|
||||||
try {
|
|
||||||
const stats = fs.statSync(filePath);
|
|
||||||
this.lastSize = stats.size;
|
|
||||||
this.lastMtime = stats.mtimeMs;
|
|
||||||
} catch (_error) {
|
|
||||||
// File might not exist yet
|
|
||||||
this.lastSize = 0;
|
|
||||||
this.lastMtime = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start watching the file
|
|
||||||
*/
|
|
||||||
start(): void {
|
|
||||||
// Use platform-specific optimizations
|
|
||||||
switch (this.platform) {
|
|
||||||
case 'linux':
|
|
||||||
this.startLinuxWatch();
|
|
||||||
break;
|
|
||||||
case 'darwin':
|
|
||||||
this.startMacWatch();
|
|
||||||
break;
|
|
||||||
case 'win32':
|
|
||||||
this.startWindowsWatch();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// Fallback to standard fs.watch
|
|
||||||
this.startGenericWatch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Linux-specific watching using inotify (through fs.watch with optimizations)
|
|
||||||
*/
|
|
||||||
private startLinuxWatch(): void {
|
|
||||||
// On Linux, fs.watch uses inotify which is already quite efficient
|
|
||||||
// But we can optimize by using a more aggressive polling check
|
|
||||||
// when we detect changes to reduce latency
|
|
||||||
|
|
||||||
let rapidPollTimeout: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
this.watcher = fs.watch(
|
|
||||||
this.filePath,
|
|
||||||
{ persistent: this.options.persistent !== false },
|
|
||||||
(eventType) => {
|
|
||||||
if (eventType === 'change') {
|
|
||||||
// Start rapid polling for 100ms to catch quick successive changes
|
|
||||||
if (rapidPollTimeout) {
|
|
||||||
clearTimeout(rapidPollTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Immediate check
|
|
||||||
this.checkFileChange();
|
|
||||||
|
|
||||||
// Rapid poll for a short period
|
|
||||||
let pollCount = 0;
|
|
||||||
const rapidPoll = () => {
|
|
||||||
this.checkFileChange();
|
|
||||||
pollCount++;
|
|
||||||
if (pollCount < 10) {
|
|
||||||
// Poll 10 times over 100ms
|
|
||||||
rapidPollTimeout = setTimeout(rapidPoll, 10);
|
|
||||||
} else {
|
|
||||||
rapidPollTimeout = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
rapidPollTimeout = setTimeout(rapidPoll, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.watcher.on('error', (error) => {
|
|
||||||
this.emit('error', error);
|
|
||||||
// Fallback to polling on error
|
|
||||||
this.startPolling(50); // Fast polling as fallback
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* macOS-specific watching using FSEvents (through fs.watch with optimizations)
|
|
||||||
*/
|
|
||||||
private startMacWatch(): void {
|
|
||||||
// macOS fs.watch uses FSEvents which can have some latency
|
|
||||||
// We'll combine it with periodic stat checks for better responsiveness
|
|
||||||
|
|
||||||
this.watcher = fs.watch(
|
|
||||||
this.filePath,
|
|
||||||
{ persistent: this.options.persistent !== false },
|
|
||||||
(eventType) => {
|
|
||||||
if (eventType === 'change') {
|
|
||||||
this.checkFileChange();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.watcher.on('error', (error) => {
|
|
||||||
this.emit('error', error);
|
|
||||||
this.startPolling(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also add a periodic check every 50ms for better latency on macOS
|
|
||||||
// FSEvents can sometimes batch changes causing delays
|
|
||||||
this.startPolling(50);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Windows-specific watching with optimizations
|
|
||||||
*/
|
|
||||||
private startWindowsWatch(): void {
|
|
||||||
// Windows fs.watch uses ReadDirectoryChangesW which is quite responsive
|
|
||||||
// But we'll add some optimizations for better performance
|
|
||||||
|
|
||||||
this.watcher = fs.watch(
|
|
||||||
this.filePath,
|
|
||||||
{ persistent: this.options.persistent !== false },
|
|
||||||
(eventType) => {
|
|
||||||
if (eventType === 'change') {
|
|
||||||
// On Windows, we might get multiple events for a single change
|
|
||||||
// Debounce by checking actual file stats
|
|
||||||
this.checkFileChange();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.watcher.on('error', (error) => {
|
|
||||||
this.emit('error', error);
|
|
||||||
this.startPolling(50);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic watching fallback
|
|
||||||
*/
|
|
||||||
private startGenericWatch(): void {
|
|
||||||
this.watcher = fs.watch(
|
|
||||||
this.filePath,
|
|
||||||
{ persistent: this.options.persistent !== false },
|
|
||||||
(eventType) => {
|
|
||||||
if (eventType === 'change') {
|
|
||||||
this.checkFileChange();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.watcher.on('error', (error) => {
|
|
||||||
this.emit('error', error);
|
|
||||||
this.startPolling(100);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start polling as a fallback mechanism
|
|
||||||
*/
|
|
||||||
private startPolling(interval: number): void {
|
|
||||||
if (this.pollInterval) {
|
|
||||||
clearInterval(this.pollInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pollInterval = setInterval(() => {
|
|
||||||
this.checkFileChange();
|
|
||||||
}, interval);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if file has actually changed by comparing stats
|
|
||||||
*/
|
|
||||||
private checkFileChange(): void {
|
|
||||||
try {
|
|
||||||
const stats = fs.statSync(this.filePath);
|
|
||||||
|
|
||||||
// Check if size or modification time changed
|
|
||||||
if (stats.size !== this.lastSize || stats.mtimeMs !== this.lastMtime) {
|
|
||||||
// Only emit if size increased (for append-only files like asciinema)
|
|
||||||
if (stats.size > this.lastSize) {
|
|
||||||
this.lastSize = stats.size;
|
|
||||||
this.lastMtime = stats.mtimeMs;
|
|
||||||
this.emit('change', stats);
|
|
||||||
} else if (stats.size !== this.lastSize) {
|
|
||||||
// File was truncated or replaced
|
|
||||||
this.lastSize = stats.size;
|
|
||||||
this.lastMtime = stats.mtimeMs;
|
|
||||||
this.emit('change', stats);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// File might have been deleted
|
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
||||||
this.emit('error', error as Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop watching
|
|
||||||
*/
|
|
||||||
stop(): void {
|
|
||||||
if (this.watcher) {
|
|
||||||
this.watcher.close();
|
|
||||||
this.watcher = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.pollInterval) {
|
|
||||||
clearInterval(this.pollInterval);
|
|
||||||
this.pollInterval = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if watcher is active
|
|
||||||
*/
|
|
||||||
isWatching(): boolean {
|
|
||||||
return !!this.watcher || !!this.pollInterval;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory function to create an optimized file watcher
|
|
||||||
*/
|
|
||||||
export function createOptimizedFileWatcher(
|
|
||||||
filePath: string,
|
|
||||||
options?: WatchOptions
|
|
||||||
): OptimizedFileWatcher {
|
|
||||||
return new OptimizedFileWatcher(filePath, options);
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { OptimizedFileWatcher } from './optimized-file-watcher.js';
|
|
||||||
import { streamNotifier } from './stream-notifier.js';
|
import { streamNotifier } from './stream-notifier.js';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
interface StreamClient {
|
interface StreamClient {
|
||||||
response: import('express').Response;
|
response: Response;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WatcherInfo {
|
interface WatcherInfo {
|
||||||
clients: Set<StreamClient>;
|
clients: Set<StreamClient>;
|
||||||
watcher?: OptimizedFileWatcher;
|
watcher?: fs.FSWatcher;
|
||||||
lastOffset: number;
|
lastOffset: number;
|
||||||
|
lastSize: number;
|
||||||
|
lastMtime: number;
|
||||||
lineBuffer: string;
|
lineBuffer: string;
|
||||||
notificationListener?: (update: { sessionId: string; data: string; timestamp: number }) => void;
|
notificationListener?: (update: { sessionId: string; data: string; timestamp: number }) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -28,7 +30,7 @@ export class StreamWatcher {
|
||||||
/**
|
/**
|
||||||
* Add a client to watch a stream file
|
* Add a client to watch a stream file
|
||||||
*/
|
*/
|
||||||
addClient(sessionId: string, streamPath: string, response: import('express').Response): void {
|
addClient(sessionId: string, streamPath: string, response: Response): void {
|
||||||
const startTime = Date.now() / 1000;
|
const startTime = Date.now() / 1000;
|
||||||
const client: StreamClient = { response, startTime };
|
const client: StreamClient = { response, startTime };
|
||||||
|
|
||||||
|
|
@ -39,6 +41,8 @@ export class StreamWatcher {
|
||||||
watcherInfo = {
|
watcherInfo = {
|
||||||
clients: new Set(),
|
clients: new Set(),
|
||||||
lastOffset: 0,
|
lastOffset: 0,
|
||||||
|
lastSize: 0,
|
||||||
|
lastMtime: 0,
|
||||||
lineBuffer: '',
|
lineBuffer: '',
|
||||||
};
|
};
|
||||||
this.activeWatchers.set(sessionId, watcherInfo);
|
this.activeWatchers.set(sessionId, watcherInfo);
|
||||||
|
|
@ -46,10 +50,12 @@ export class StreamWatcher {
|
||||||
// Send existing content first
|
// Send existing content first
|
||||||
this.sendExistingContent(streamPath, client);
|
this.sendExistingContent(streamPath, client);
|
||||||
|
|
||||||
// Get current file size
|
// Get current file size and stats
|
||||||
if (fs.existsSync(streamPath)) {
|
if (fs.existsSync(streamPath)) {
|
||||||
const stats = fs.statSync(streamPath);
|
const stats = fs.statSync(streamPath);
|
||||||
watcherInfo.lastOffset = stats.size;
|
watcherInfo.lastOffset = stats.size;
|
||||||
|
watcherInfo.lastSize = stats.size;
|
||||||
|
watcherInfo.lastMtime = stats.mtimeMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start watching for new content
|
// Start watching for new content
|
||||||
|
|
@ -92,7 +98,7 @@ export class StreamWatcher {
|
||||||
if (watcherInfo.clients.size === 0) {
|
if (watcherInfo.clients.size === 0) {
|
||||||
console.log(`[STREAM] No more clients for session ${sessionId}, stopping watcher`);
|
console.log(`[STREAM] No more clients for session ${sessionId}, stopping watcher`);
|
||||||
if (watcherInfo.watcher) {
|
if (watcherInfo.watcher) {
|
||||||
watcherInfo.watcher.stop();
|
watcherInfo.watcher.close();
|
||||||
}
|
}
|
||||||
// Remove notification listener
|
// Remove notification listener
|
||||||
if (watcherInfo.notificationListener) {
|
if (watcherInfo.notificationListener) {
|
||||||
|
|
@ -226,46 +232,52 @@ export class StreamWatcher {
|
||||||
} else {
|
} else {
|
||||||
console.log(`[STREAM] Using file watcher for session ${sessionId} (cross-process)`);
|
console.log(`[STREAM] Using file watcher for session ${sessionId} (cross-process)`);
|
||||||
|
|
||||||
// Use optimized file watcher for cross-process scenarios
|
// Use standard fs.watch with stat checking for cross-process scenarios
|
||||||
watcherInfo.watcher = new OptimizedFileWatcher(streamPath, { persistent: true });
|
watcherInfo.watcher = fs.watch(streamPath, { persistent: true }, (eventType) => {
|
||||||
|
if (eventType === 'change') {
|
||||||
|
try {
|
||||||
|
// Check if file actually changed by comparing stats
|
||||||
|
const stats = fs.statSync(streamPath);
|
||||||
|
|
||||||
watcherInfo.watcher.on('change', (stats) => {
|
// Only process if size increased (append-only file)
|
||||||
try {
|
if (stats.size > watcherInfo.lastSize || stats.mtimeMs > watcherInfo.lastMtime) {
|
||||||
if (stats.size > watcherInfo.lastOffset) {
|
watcherInfo.lastSize = stats.size;
|
||||||
// Read only new data
|
watcherInfo.lastMtime = stats.mtimeMs;
|
||||||
const fd = fs.openSync(streamPath, 'r');
|
|
||||||
const buffer = Buffer.alloc(stats.size - watcherInfo.lastOffset);
|
|
||||||
fs.readSync(fd, buffer, 0, buffer.length, watcherInfo.lastOffset);
|
|
||||||
fs.closeSync(fd);
|
|
||||||
|
|
||||||
// Update offset
|
// Read only new data
|
||||||
watcherInfo.lastOffset = stats.size;
|
if (stats.size > watcherInfo.lastOffset) {
|
||||||
|
const fd = fs.openSync(streamPath, 'r');
|
||||||
|
const buffer = Buffer.alloc(stats.size - watcherInfo.lastOffset);
|
||||||
|
fs.readSync(fd, buffer, 0, buffer.length, watcherInfo.lastOffset);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
|
||||||
// Process new data
|
// Update offset
|
||||||
const newData = buffer.toString('utf8');
|
watcherInfo.lastOffset = stats.size;
|
||||||
watcherInfo.lineBuffer += newData;
|
|
||||||
|
|
||||||
// Process complete lines
|
// Process new data
|
||||||
const lines = watcherInfo.lineBuffer.split('\n');
|
const newData = buffer.toString('utf8');
|
||||||
watcherInfo.lineBuffer = lines.pop() || '';
|
watcherInfo.lineBuffer += newData;
|
||||||
|
|
||||||
for (const line of lines) {
|
// Process complete lines
|
||||||
if (line.trim()) {
|
const lines = watcherInfo.lineBuffer.split('\n');
|
||||||
this.broadcastLine(sessionId, line, watcherInfo);
|
watcherInfo.lineBuffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim()) {
|
||||||
|
this.broadcastLine(sessionId, line, watcherInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[STREAM] Error reading file changes:`, error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(`[STREAM] Error reading file changes:`, error);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watcherInfo.watcher.on('error', (error) => {
|
watcherInfo.watcher.on('error', (error) => {
|
||||||
console.error(`[STREAM] File watcher error for session ${sessionId}:`, error);
|
console.error(`[STREAM] File watcher error for session ${sessionId}:`, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start the watcher
|
|
||||||
watcherInfo.watcher.start();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -339,7 +351,7 @@ export class StreamWatcher {
|
||||||
private cleanup(): void {
|
private cleanup(): void {
|
||||||
for (const [_sessionId, watcherInfo] of this.activeWatchers) {
|
for (const [_sessionId, watcherInfo] of this.activeWatchers) {
|
||||||
if (watcherInfo.watcher) {
|
if (watcherInfo.watcher) {
|
||||||
watcherInfo.watcher.stop();
|
watcherInfo.watcher.close();
|
||||||
}
|
}
|
||||||
if (watcherInfo.notificationListener) {
|
if (watcherInfo.notificationListener) {
|
||||||
streamNotifier.removeListener('stream-update', watcherInfo.notificationListener);
|
streamNotifier.removeListener('stream-update', watcherInfo.notificationListener);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue