Skip to content

Commit 55154bc

Browse files
committed
feat: implement file-based locking mechanism for multi-instance coordination
1 parent f305544 commit 55154bc

8 files changed

Lines changed: 266 additions & 24 deletions

File tree

src/data/state.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
* Persists and loads local sync state and configuration.
55
*/
66

7-
import { readFile, writeFile, mkdir } from 'node:fs/promises';
7+
import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
88
import { randomUUID } from 'node:crypto';
99
import { hostname } from 'node:os';
10+
import { dirname } from 'node:path';
1011
import type { SyncConfig, LocalSyncState, PathConfig } from '../types/index.js';
1112
import { DEFAULT_CONFIG } from '../types/index.js';
1213

@@ -74,11 +75,10 @@ export function getTokenSource(): 'config' | 'env' | 'none' {
7475
}
7576

7677
/**
77-
* Save plugin configuration to disk.
78+
* Save plugin configuration to disk (atomic write).
7879
*/
7980
export async function saveConfig(pathConfig: PathConfig, config: SyncConfig): Promise<void> {
80-
await ensureDir(pathConfig.configDir);
81-
await writeFile(pathConfig.pluginConfigPath, JSON.stringify(config, null, 2), 'utf-8');
81+
await atomicWriteFile(pathConfig.pluginConfigPath, JSON.stringify(config, null, 2));
8282
}
8383

8484
/**
@@ -94,11 +94,10 @@ export async function loadLocalState(pathConfig: PathConfig): Promise<LocalSyncS
9494
}
9595

9696
/**
97-
* Save local sync state to disk.
97+
* Save local sync state to disk (atomic write).
9898
*/
9999
export async function saveLocalState(pathConfig: PathConfig, state: LocalSyncState): Promise<void> {
100-
await ensureDir(pathConfig.dataDir);
101-
await writeFile(pathConfig.localStatePath, JSON.stringify(state, null, 2), 'utf-8');
100+
await atomicWriteFile(pathConfig.localStatePath, JSON.stringify(state, null, 2));
102101
}
103102

104103
/**
@@ -140,3 +139,25 @@ export function createInitialConfig(token: string, defaults: typeof DEFAULT_CONF
140139
async function ensureDir(dirPath: string): Promise<void> {
141140
await mkdir(dirPath, { recursive: true });
142141
}
142+
143+
/**
144+
* Atomically write a file using write-to-temp-then-rename pattern.
145+
* This prevents partial writes from corrupting files when multiple
146+
* processes write simultaneously.
147+
*/
148+
async function atomicWriteFile(filePath: string, content: string): Promise<void> {
149+
const tempPath = `${filePath}.${String(process.pid)}.tmp`;
150+
await ensureDir(dirname(filePath));
151+
try {
152+
await writeFile(tempPath, content, 'utf-8');
153+
await rename(tempPath, filePath);
154+
} catch (error) {
155+
// Clean up temp file on error
156+
try {
157+
await unlink(tempPath);
158+
} catch {
159+
// Ignore cleanup errors
160+
}
161+
throw error;
162+
}
163+
}

src/plugin/state-manager.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import type { PathConfig } from '../types/paths.js';
88
import { DEFAULT_CONFIG, type SyncConfig, type SyncCategory } from '../types/index.js';
9-
import { SyncEngine } from '../sync/engine/index.js';
9+
import { SyncEngine, type SyncEngineOptions } from '../sync/engine/index.js';
1010
import { RepoStorageBackend, type RepoClientConfig } from '../storage/index.js';
1111
import { createFileWatcher } from '../sync/watcher/index.js';
1212
import { loadConfig, saveConfig, loadLocalState, generateMachineId } from '../data/index.js';
@@ -19,6 +19,7 @@ const state: PluginState = createInitialState();
1919
* Initialize the plugin state from disk.
2020
*/
2121
export async function initializeState(pathConfig: PathConfig): Promise<void> {
22+
state.pathConfig = pathConfig;
2223
state.config = await loadConfig(pathConfig);
2324
state.localState = await loadLocalState(pathConfig);
2425
}
@@ -50,22 +51,21 @@ export function initializeEngine(): void {
5051
// Support key rotation: oldEncryptionKey from config is used as fallback for decryption
5152
const oldKey = state.oldPassphrase ?? state.config.oldEncryptionKey;
5253

53-
state.engine = new SyncEngine(
54-
oldKey
55-
? {
56-
config: state.config,
57-
backend,
58-
localState: state.localState,
59-
passphrase: state.passphrase ?? undefined,
60-
oldPassphrase: oldKey,
61-
}
62-
: {
63-
config: state.config,
64-
backend,
65-
localState: state.localState,
66-
passphrase: state.passphrase ?? undefined,
67-
}
68-
);
54+
// Build engine options, conditionally adding optional properties
55+
const engineOptions: SyncEngineOptions = {
56+
config: state.config,
57+
backend,
58+
localState: state.localState,
59+
passphrase: state.passphrase ?? undefined,
60+
};
61+
if (oldKey) {
62+
engineOptions.oldPassphrase = oldKey;
63+
}
64+
if (state.pathConfig?.lockPath) {
65+
engineOptions.lockPath = state.pathConfig.lockPath;
66+
}
67+
68+
state.engine = new SyncEngine(engineOptions);
6969

7070
state.isInitialized = true;
7171
}

src/plugin/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import type { SyncConfig, LocalSyncState } from '../types/index.js';
8+
import type { PathConfig } from '../types/paths.js';
89
import type { SyncEngine } from '../sync/engine/index.js';
910
import type { FileWatcher } from '../sync/watcher/index.js';
1011

@@ -17,6 +18,8 @@ export interface PluginState {
1718
/** Previous encryption key for key rotation */
1819
oldPassphrase: string | null;
1920
isInitialized: boolean;
21+
/** Path configuration for lock file and other paths */
22+
pathConfig: PathConfig | null;
2023
}
2124

2225
export function createInitialState(): PluginState {
@@ -28,5 +31,6 @@ export function createInitialState(): PluginState {
2831
passphrase: null,
2932
oldPassphrase: null,
3033
isInitialized: false,
34+
pathConfig: null,
3135
};
3236
}

src/sync/engine/result.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ export function buildNoChangeResult(): SyncResult {
6262
return { success: true, action: 'no-change', message: 'Already in sync' };
6363
}
6464

65+
/**
66+
* Build a skipped result (e.g., when another instance holds the lock).
67+
*/
68+
export function buildSkippedResult(reason: string): SyncResult {
69+
return { success: true, action: 'no-change', message: `Skipped: ${reason}` };
70+
}
71+
6572
/**
6673
* Convert an unknown error to SyncResult.
6774
*/

src/sync/engine/sync-engine.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ import {
2424
buildConflictResult,
2525
buildErrorResult,
2626
buildNoChangeResult,
27+
buildSkippedResult,
2728
handleSyncError,
2829
} from './result.js';
2930
import { checkMaxRetries, calculateBackoff, sleep } from './retry.js';
31+
import { acquireLock, releaseLock, getLockHolder } from '../local-lock.js';
3032

3133
export { type CategoryData };
3234

@@ -36,13 +38,15 @@ export class SyncEngine {
3638
private localState: LocalSyncState | null;
3739
private readonly passphrase: string | undefined;
3840
private readonly oldPassphrase: string | undefined;
41+
private readonly lockPath: string | undefined;
3942

4043
constructor(options: SyncEngineOptions) {
4144
this.backend = options.backend;
4245
this.config = options.config;
4346
this.localState = options.localState;
4447
this.passphrase = options.passphrase;
4548
this.oldPassphrase = options.oldPassphrase;
49+
this.lockPath = options.lockPath;
4650
}
4751

4852
/** Get crypto options for encryption/decryption with key rotation support */
@@ -55,10 +59,19 @@ export class SyncEngine {
5559

5660
public async sync(localData: CategoryData[]): Promise<SyncResult> {
5761
if (!this.hasStorageConfigured()) return buildErrorResult('No storage configured');
62+
63+
// Acquire local lock to prevent concurrent syncs on same machine
64+
if (this.lockPath && !acquireLock(this.lockPath, 'sync')) {
65+
const holder = getLockHolder(this.lockPath);
66+
return buildSkippedResult(`Another instance is syncing${holder ? ` (${holder})` : ''}`);
67+
}
68+
5869
try {
5970
return await this.performSync(localData);
6071
} catch (error) {
6172
return handleSyncError(error);
73+
} finally {
74+
if (this.lockPath) releaseLock(this.lockPath);
6275
}
6376
}
6477

src/sync/engine/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export interface SyncEngineOptions {
1515
passphrase: string | undefined;
1616
/** Previous encryption key for key rotation */
1717
oldPassphrase?: string;
18+
/** Lock file path for multi-instance coordination */
19+
lockPath?: string;
1820
}
1921

2022
/** Get crypto options from engine options */

0 commit comments

Comments
 (0)