Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"productName": "wmux",
"version": "2.3.1",
"description": "Windows terminal multiplexer with MCP server for AI agents",
"license": "MIT",
"private": true,
"main": ".vite/build/index.js",
"bin": {
Expand Down Expand Up @@ -33,6 +32,7 @@
"name": "openwong2kim",
"email": "100856670+openwong2kim@users.noreply.github.com"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/openwong2kim/wmux.git"
Expand Down
19 changes: 19 additions & 0 deletions src/daemon/RingBuffer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { writeFile } from 'node:fs/promises';
import { readFileSync } from 'node:fs';

/**
* Fixed-size circular byte buffer for storing ConPTY output per session.
* Preserves raw bytes including ANSI escape sequences without any filtering.
Expand Down Expand Up @@ -91,4 +94,20 @@ export class RingBuffer {
return this.capacity;
}

/** Dump the buffer contents to a file (for DEAD session log preservation). */
async dumpToFile(filePath: string): Promise<void> {
const data = this.readAll();
// Note: mode is no-op on Windows; use icacls for NTFS ACLs
await writeFile(filePath, data, { mode: 0o600 });
}

/** Create a RingBuffer pre-filled with data loaded from a file. */
static loadFromFile(filePath: string, capacityBytes: number): RingBuffer {
const data = readFileSync(filePath);
const rb = new RingBuffer(capacityBytes);
if (data.length > 0) {
rb.write(data);
}
return rb;
}
}
202 changes: 202 additions & 0 deletions src/daemon/StateWriter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import fs from 'node:fs';
import path from 'node:path';
import type { DaemonState, DaemonSession } from './types';

const DEBOUNCE_MS = 30_000;

/**
* Persists DaemonState (sessions.json) to disk using atomic write pattern.
* Mirrors SessionManager's tmp → bak → rename strategy.
*/
export class StateWriter {
private filePath: string;
private tmpPath: string;
private bakPath: string;
private debounceTimer: NodeJS.Timeout | null = null;
private pendingState: DaemonState | null = null;

constructor(baseDir: string) {
this.filePath = path.join(baseDir, 'sessions.json');
this.tmpPath = this.filePath + '.tmp';
this.bakPath = this.filePath + '.bak';
}

/** Immediately write state to disk (session create/destroy/state change). */
saveImmediate(state: DaemonState): void {
try {
const dir = path.dirname(this.filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}

const json = JSON.stringify(state, null, 2);

// 1. Write to temporary file
// Note: mode is no-op on Windows; use icacls for NTFS ACLs
fs.writeFileSync(this.tmpPath, json, { encoding: 'utf-8', mode: 0o600 });

// 2. Backup current file (if it exists)
if (fs.existsSync(this.filePath)) {
try {
fs.renameSync(this.filePath, this.bakPath);
} catch (bakErr) {
console.warn('[StateWriter] Failed to create backup:', bakErr);
// Continue — saving is more important than backing up
}
}

// 3. Atomic rename: tmp → sessions.json
fs.renameSync(this.tmpPath, this.filePath);

// Clear pending since we just saved
this.pendingState = null;
} catch (err) {
console.error('[StateWriter] Failed to save state:', err);
// Clean up tmp file if it exists
try {
if (fs.existsSync(this.tmpPath)) fs.unlinkSync(this.tmpPath);
} catch {
// ignore cleanup errors
}
}
}

/** Debounced save — coalesces frequent updates (e.g. lastActivity) over 30s. */
saveDebounced(state: DaemonState): void {
this.pendingState = state;

if (this.debounceTimer !== null) {
return; // Timer already running; state will be picked up when it fires
}

this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
if (this.pendingState !== null) {
this.saveImmediate(this.pendingState);
}
}, DEBOUNCE_MS);
}

/** Load state from disk. Falls back to .bak on failure. Prunes expired DEAD sessions. */
load(): DaemonState {
const empty: DaemonState = { version: 1, sessions: [] };

let state: DaemonState | null = null;

// Try primary
try {
state = this.parseStateFile(this.filePath);
} catch (err) {
console.error('[StateWriter] Failed to load primary state:', err);
}

// Fallback to backup
if (!state) {
try {
console.warn('[StateWriter] Trying backup...');
state = this.parseStateFile(this.bakPath);
if (state) {
console.warn('[StateWriter] Recovered state from backup.');
}
} catch (bakErr) {
console.error('[StateWriter] Backup recovery also failed:', bakErr);
}
}

if (!state) {
return empty;
}

// Prune DEAD sessions that exceeded their TTL
state.sessions = state.sessions.filter((s) => {
if (s.state !== 'dead') return true;
const deadSince = new Date(s.lastActivity).getTime();
const ttlMs = s.deadTtlHours * 60 * 60 * 1000;
return Date.now() - deadSince < ttlMs;
});

return state;
}

/** Flush pending debounce — if there is pending state, write it immediately. */
flush(): void {
if (this.debounceTimer !== null) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
if (this.pendingState !== null) {
this.saveImmediate(this.pendingState);
}
}

/** Clean up timers (daemon shutdown). Flushes pending state first. */
dispose(): void {
this.flush();
}

/** Get the path where a session's scrollback buffer should be dumped. */
getBufferDumpPath(sessionId: string): string {
return path.join(path.dirname(this.filePath), 'buffers', `${sessionId}.buf`);
}

/** Ensure the buffers/ directory exists. */
ensureBufferDir(): void {
const dir = path.join(path.dirname(this.filePath), 'buffers');
if (!fs.existsSync(dir)) {
// Note: mode is no-op on Windows; use icacls for NTFS ACLs
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
}

/** Remove orphaned .buf files not referenced by any session. */
cleanOrphanedBuffers(activeIds: Set<string>): void {
const dir = path.join(path.dirname(this.filePath), 'buffers');
if (!fs.existsSync(dir)) return;
try {
for (const file of fs.readdirSync(dir)) {
if (!file.endsWith('.buf')) continue;
const id = file.replace(/\.buf$/, '');
if (!activeIds.has(id)) {
try { fs.unlinkSync(path.join(dir, file)); } catch { /* ignore */ }
}
}
} catch { /* ignore */ }
}

// ── Internal helpers ──────────────────────────────────────────────

private parseStateFile(filePath: string): DaemonState | null {
if (!fs.existsSync(filePath)) return null;

const raw = fs.readFileSync(filePath, 'utf-8');
if (!raw) return null;

const parsed: unknown = JSON.parse(raw, (key, value) => {
// Prototype pollution guard
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
return undefined;
}
return value;
});

return this.validateState(parsed);
}

private validateState(parsed: unknown): DaemonState | null {
if (typeof parsed !== 'object' || parsed === null) return null;
const obj = parsed as Record<string, unknown>;

if (typeof obj['version'] !== 'number') return null;
if (!Array.isArray(obj['sessions'])) return null;

// Validate each session has minimum required fields
for (const s of obj['sessions'] as unknown[]) {
if (typeof s !== 'object' || s === null) return null;
const sess = s as Record<string, unknown>;
if (typeof sess['id'] !== 'string') return null;
if (typeof sess['state'] !== 'string') return null;
}

return parsed as DaemonState;
}
}
74 changes: 72 additions & 2 deletions src/daemon/__tests__/RingBuffer.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { RingBuffer } from '../RingBuffer';

/** Temp files created during tests — cleaned up in afterEach */
const tempFiles: string[] = [];

afterEach(() => {
for (const f of tempFiles) {
try {
if (fs.existsSync(f)) fs.unlinkSync(f);
} catch {
// ignore cleanup errors
}
}
tempFiles.length = 0;
});

describe('RingBuffer', () => {
// 1. Basic write + readAll
it('stores data and returns it via readAll', () => {
Expand Down Expand Up @@ -71,7 +88,20 @@ describe('RingBuffer', () => {
expect(rb.totalCapacity).toBe(32);
});

// 7. Empty buffer readAll returns empty Buffer
// 7. dumpToFile writes buffer contents to disk
it('dumps buffer contents to a file', async () => {
const rb = new RingBuffer(64);
rb.write(Buffer.from('dump-test-content'));

const tmpFile = path.join(os.tmpdir(), `wmux-rb-test-${Date.now()}.bin`);
tempFiles.push(tmpFile);

await rb.dumpToFile(tmpFile);
const ondisk = fs.readFileSync(tmpFile);
expect(ondisk.toString()).toBe('dump-test-content');
});

// 8. Empty buffer readAll returns empty Buffer
it('returns an empty Buffer when nothing has been written', () => {
const rb = new RingBuffer(16);
const result = rb.readAll();
Expand Down Expand Up @@ -125,4 +155,44 @@ describe('RingBuffer', () => {
expect(rb.readAll().toString()).toBe('ABCDEFGH');
});

// loadFromFile: round-trip dump → load
it('loadFromFile restores buffer contents from a dump', async () => {
const rb = new RingBuffer(64);
rb.write(Buffer.from('hello-from-dump'));

const tmpFile = path.join(os.tmpdir(), `wmux-rb-load-${Date.now()}.bin`);
tempFiles.push(tmpFile);

await rb.dumpToFile(tmpFile);

const restored = RingBuffer.loadFromFile(tmpFile, 64);
expect(restored.readAll().toString()).toBe('hello-from-dump');
expect(restored.size).toBe(15);
});

// loadFromFile: data larger than capacity keeps only tail
it('loadFromFile truncates to capacity when file is larger', async () => {
const rb = new RingBuffer(32);
rb.write(Buffer.from('A'.repeat(32)));

const tmpFile = path.join(os.tmpdir(), `wmux-rb-load-big-${Date.now()}.bin`);
tempFiles.push(tmpFile);

await rb.dumpToFile(tmpFile);

const restored = RingBuffer.loadFromFile(tmpFile, 8);
expect(restored.readAll().toString()).toBe('A'.repeat(8));
expect(restored.size).toBe(8);
});

// loadFromFile: empty file produces empty buffer
it('loadFromFile with empty file produces empty buffer', () => {
const tmpFile = path.join(os.tmpdir(), `wmux-rb-load-empty-${Date.now()}.bin`);
tempFiles.push(tmpFile);
fs.writeFileSync(tmpFile, '');

const restored = RingBuffer.loadFromFile(tmpFile, 16);
expect(restored.size).toBe(0);
expect(restored.readAll().length).toBe(0);
});
});
Loading
Loading