Harden server state and config persistence
This commit is contained in:
36
docs/recovery/state-recovery.md
Normal file
36
docs/recovery/state-recovery.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Harbor state recovery
|
||||
|
||||
Harbor keeps the existing data paths and volumes. `state.json` now uses `schemaVersion: 1`; subscription cache, generated sing-box config and HWID keep their existing filenames.
|
||||
|
||||
## Atomic writes
|
||||
|
||||
Persistent files are written to a unique temporary file in the same directory, flushed with `fsync`, closed and atomically renamed over the target. A failure before rename leaves the previous target untouched and removes the temporary file.
|
||||
|
||||
## Migration
|
||||
|
||||
On startup, a legacy `state.json` without `schemaVersion` is normalized and migrated to v1. Before replacement Harbor saves the original beside it:
|
||||
|
||||
```text
|
||||
state.json.backup-v0-2026-07-11T12-00-00-000Z
|
||||
```
|
||||
|
||||
The v1 migration preserves existing fields, adds normalized revision, selection and server fields, and does not rename the volume. Older Harbor builds ignore the additional `schemaVersion` field, but the backup is the safest rollback source.
|
||||
|
||||
## Corrupt JSON
|
||||
|
||||
If `state.json` cannot be parsed, Harbor renames the exact damaged bytes to:
|
||||
|
||||
```text
|
||||
state.json.corrupt-2026-07-11T12-00-00-000Z
|
||||
```
|
||||
|
||||
It then creates a valid empty v1 state and reports `storage-recovery` through `snapshot.operation`. A corrupt subscription cache is preserved with the same suffix and reported in control logs.
|
||||
|
||||
Recovery should be performed while Harbor is stopped:
|
||||
|
||||
1. Copy the whole data directory before changing anything.
|
||||
2. Inspect a backup with `jq . <backup-file>`.
|
||||
3. Restore only a valid JSON backup to the original filename.
|
||||
4. Start Harbor and verify `GET /api/state` before applying or importing anything.
|
||||
|
||||
Generated config rollback also uses the atomic writer. No automatic recovery tries to guess missing subscription credentials or repair semantically invalid sing-box configuration.
|
||||
@@ -20,6 +20,7 @@ import { buildSharedProxyInfo } from './sharedProxy.js';
|
||||
import {
|
||||
buildGatewayConfig,
|
||||
removeSingboxConfig,
|
||||
restoreSingboxConfig,
|
||||
writeSingboxConfig,
|
||||
} from './singbox.js';
|
||||
import { fetchSubscription, getHwid, selectRefreshedServer } from './subscription.js';
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
withStateV0Compatibility,
|
||||
} from '../shared/contracts/state.js';
|
||||
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
||||
import { createJsonStore, createStateStore } from './services/stateStore.js';
|
||||
|
||||
const MAX_BODY_BYTES = 1_000_000;
|
||||
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
||||
@@ -36,6 +38,30 @@ const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000;
|
||||
|
||||
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||
|
||||
const stateStore = createStateStore(settings.statePath);
|
||||
const subscriptionCacheStore = createJsonStore({
|
||||
filePath: settings.subscriptionCachePath,
|
||||
defaultValue: null,
|
||||
});
|
||||
let cacheRecoveryLogged = false;
|
||||
|
||||
function readSubscriptionCache() {
|
||||
const cached = subscriptionCacheStore.read();
|
||||
if (subscriptionCacheStore.recovery && !cacheRecoveryLogged) {
|
||||
cacheRecoveryLogged = true;
|
||||
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
const initialStoredState = stateStore.read();
|
||||
if (stateStore.migration) {
|
||||
console.log(`[storage] state migrated to v${stateStore.migration.toVersion}; backup: ${stateStore.migration.backupPath}`);
|
||||
}
|
||||
if (stateStore.recovery) {
|
||||
console.warn(`[storage] corrupt state recovered; backup: ${stateStore.recovery.backupPath}`);
|
||||
}
|
||||
|
||||
const remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET);
|
||||
const singboxRuntime = remoteDataplane
|
||||
? createDataplaneClient(settings.dataplaneSocket)
|
||||
@@ -50,33 +76,22 @@ let gatewayDiscoveryPromise = null;
|
||||
let gatewayDiscoveryTimer = null;
|
||||
let gatewayAutoState = createGatewayAutoState();
|
||||
let controlOperation = Promise.resolve();
|
||||
let operationState = { kind: null, status: 'idle', startedAt: null, error: null };
|
||||
let revision = 0;
|
||||
|
||||
function readJson(filePath, fallback) {
|
||||
try {
|
||||
return fs.existsSync(filePath)
|
||||
? JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||
: fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
revision = normalizeStoredState(readJson(settings.statePath, {})).revision;
|
||||
let operationState = stateStore.recovery ? {
|
||||
kind: 'storage-recovery',
|
||||
status: 'failed',
|
||||
startedAt: stateStore.recovery.recoveredAt,
|
||||
error: `Повреждённый state сохранён: ${path.basename(stateStore.recovery.backupPath)}`,
|
||||
} : { kind: null, status: 'idle', startedAt: null, error: null };
|
||||
let revision = normalizeStoredState(initialStoredState).revision;
|
||||
|
||||
function updateStoredState(update) {
|
||||
const current = normalizeStoredState(readJson(settings.statePath, {}));
|
||||
const next = normalizeStoredState(update(current));
|
||||
revision = Math.max(revision, current.revision) + 1;
|
||||
next.revision = revision;
|
||||
writeJson(settings.statePath, next);
|
||||
return next;
|
||||
return stateStore.update((stored) => {
|
||||
const current = normalizeStoredState(stored);
|
||||
const next = normalizeStoredState(update(current));
|
||||
revision = Math.max(revision, current.revision) + 1;
|
||||
next.revision = revision;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function withOperation(kind, operation) {
|
||||
@@ -186,7 +201,7 @@ const startSingbox = () => singboxRuntime.apply();
|
||||
|
||||
async function publicState() {
|
||||
const runtime = await singboxRuntime.refresh();
|
||||
const state = normalizeStoredState(readJson(settings.statePath, {}));
|
||||
const state = normalizeStoredState(stateStore.read());
|
||||
const gatewayAutoEnabled = state.gatewayAutoEnabled !== false;
|
||||
const configExists = fs.existsSync(settings.configPath);
|
||||
const snapshot = createStateSnapshot({
|
||||
@@ -208,8 +223,8 @@ async function publicState() {
|
||||
}
|
||||
|
||||
function writeCurrentConfig() {
|
||||
const state = readJson(settings.statePath, {});
|
||||
const cached = readJson(settings.subscriptionCachePath, null);
|
||||
const state = stateStore.read();
|
||||
const cached = readSubscriptionCache();
|
||||
if (!state.selectedTag || !cached?.config) return false;
|
||||
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedTag));
|
||||
return true;
|
||||
@@ -235,7 +250,7 @@ async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) {
|
||||
} catch (error) {
|
||||
gatewayAutoState = previousState;
|
||||
if (previousConfig === null) removeSingboxConfig();
|
||||
else fs.writeFileSync(settings.configPath, previousConfig, 'utf8');
|
||||
else restoreSingboxConfig(previousConfig);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -250,7 +265,7 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
|
||||
if (gatewayDiscoveryPromise) return gatewayDiscoveryPromise;
|
||||
|
||||
gatewayDiscoveryPromise = serializeControl(async () => {
|
||||
const state = readJson(settings.statePath, {});
|
||||
const state = stateStore.read();
|
||||
const network = state.subscriptionUrl
|
||||
? readHostNetworkState(settings.hostNetworkStatePath)
|
||||
: null;
|
||||
@@ -283,7 +298,7 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
|
||||
port: settings.gatewayPresencePort,
|
||||
subscriptionUrl: state.subscriptionUrl,
|
||||
});
|
||||
const latestState = readJson(settings.statePath, {});
|
||||
const latestState = stateStore.read();
|
||||
const latestNetwork = latestState.subscriptionUrl
|
||||
? readHostNetworkState(settings.hostNetworkStatePath)
|
||||
: null;
|
||||
@@ -303,7 +318,7 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
|
||||
);
|
||||
} catch (error) {
|
||||
const reason = error?.message || 'Gateway presence check failed';
|
||||
const latestState = readJson(settings.statePath, {});
|
||||
const latestState = stateStore.read();
|
||||
const latestNetwork = latestState.subscriptionUrl
|
||||
? readHostNetworkState(settings.hostNetworkStatePath)
|
||||
: null;
|
||||
@@ -334,7 +349,7 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
|
||||
}
|
||||
|
||||
async function applySelectedServer(selectedTag, { persist = true } = {}) {
|
||||
const cached = readJson(settings.subscriptionCachePath, null);
|
||||
const cached = readSubscriptionCache();
|
||||
if (!cached?.config) throw new HarborError('CONFIG_INVALID');
|
||||
const nextConfig = buildActiveConfig(cached.config, selectedTag);
|
||||
|
||||
@@ -354,7 +369,7 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) {
|
||||
await startSingbox();
|
||||
} catch (error) {
|
||||
if (previousConfig === null) removeSingboxConfig();
|
||||
else fs.writeFileSync(settings.configPath, previousConfig, 'utf8');
|
||||
else restoreSingboxConfig(previousConfig);
|
||||
throw error;
|
||||
}
|
||||
if (persist) {
|
||||
@@ -370,7 +385,7 @@ function refreshSavedSubscription() {
|
||||
if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
|
||||
|
||||
subscriptionRefreshPromise = (async () => {
|
||||
const initialState = readJson(settings.statePath, {});
|
||||
const initialState = stateStore.read();
|
||||
if (!initialState.subscriptionUrl) {
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
@@ -378,13 +393,13 @@ function refreshSavedSubscription() {
|
||||
const subscriptionUrl = initialState.subscriptionUrl;
|
||||
const parsed = await fetchSubscription(subscriptionUrl);
|
||||
return serializeControl(async () => {
|
||||
const currentState = readJson(settings.statePath, {});
|
||||
const currentState = stateStore.read();
|
||||
if (currentState.subscriptionUrl !== subscriptionUrl) {
|
||||
throw new HarborError('STATE_CONFLICT');
|
||||
}
|
||||
|
||||
const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers);
|
||||
const previousCache = readJson(settings.subscriptionCachePath, null);
|
||||
const previousCache = readSubscriptionCache();
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
@@ -394,7 +409,7 @@ function refreshSavedSubscription() {
|
||||
buildActiveConfig(parsed.config, selectedTag),
|
||||
)
|
||||
);
|
||||
writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed });
|
||||
subscriptionCacheStore.write({ url: subscriptionUrl, ...parsed });
|
||||
|
||||
try {
|
||||
if (singboxRuntime.running && activeConfigChanged) {
|
||||
@@ -406,10 +421,10 @@ function refreshSavedSubscription() {
|
||||
removeSingboxConfig();
|
||||
}
|
||||
} catch (error) {
|
||||
if (previousCache) writeJson(settings.subscriptionCachePath, previousCache);
|
||||
else fs.rmSync(settings.subscriptionCachePath, { force: true });
|
||||
if (previousCache) subscriptionCacheStore.write(previousCache);
|
||||
else subscriptionCacheStore.remove();
|
||||
if (previousConfig === null) removeSingboxConfig();
|
||||
else fs.writeFileSync(settings.configPath, previousConfig, 'utf8');
|
||||
else restoreSingboxConfig(previousConfig);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -459,7 +474,7 @@ async function handleApi(req, res) {
|
||||
|
||||
const requestUrl = new URL(req.url, `http://localhost:${settings.port}`);
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/api/gateway-presence') {
|
||||
const state = readJson(settings.statePath, {});
|
||||
const state = stateStore.read();
|
||||
return sendJson(res, 200, buildGatewayPresence({
|
||||
appMode: settings.appMode,
|
||||
subscriptionUrl: state.subscriptionUrl,
|
||||
@@ -469,7 +484,7 @@ async function handleApi(req, res) {
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/servers/ping-all') {
|
||||
const state = readJson(settings.statePath, {});
|
||||
const state = stateStore.read();
|
||||
const results = await Promise.all((state.servers || []).map(async (server) => ({
|
||||
tag: String(server.tag || '').trim(),
|
||||
...await tcpPing(server.server, server.server_port),
|
||||
@@ -486,7 +501,7 @@ async function handleApi(req, res) {
|
||||
await serializeControl(async () => {
|
||||
await stopSingbox();
|
||||
removeSingboxConfig();
|
||||
writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...result });
|
||||
subscriptionCacheStore.write({ url: normalizedUrl, ...result });
|
||||
updateStoredState((state) => ({
|
||||
subscriptionUrl: normalizedUrl,
|
||||
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
|
||||
@@ -541,7 +556,7 @@ async function handleApi(req, res) {
|
||||
await withOperation('subscription-forget', () => serializeControl(async () => {
|
||||
await stopSingbox();
|
||||
removeSingboxConfig();
|
||||
fs.rmSync(settings.subscriptionCachePath, { force: true });
|
||||
subscriptionCacheStore.remove();
|
||||
updateStoredState(() => ({}));
|
||||
gatewayAutoState = createGatewayAutoState();
|
||||
}));
|
||||
@@ -638,7 +653,7 @@ server.listen(settings.port, '0.0.0.0', () => {
|
||||
});
|
||||
|
||||
subscriptionRefreshTimer = setInterval(() => {
|
||||
if (!readJson(settings.statePath, {}).subscriptionUrl) return;
|
||||
if (!stateStore.read().subscriptionUrl) return;
|
||||
refreshSavedSubscription()
|
||||
.catch((error) => console.warn(`[control] подписка не обновлена: ${error.message}`));
|
||||
}, SUBSCRIPTION_REFRESH_INTERVAL_MS);
|
||||
|
||||
150
src/server/services/stateStore.js
Normal file
150
src/server/services/stateStore.js
Normal file
@@ -0,0 +1,150 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { normalizeStoredState } from '../../shared/contracts/state.js';
|
||||
|
||||
export const STATE_SCHEMA_VERSION = 1;
|
||||
|
||||
const clone = (value) => structuredClone(value);
|
||||
const stamp = (value) => value.toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
function syncDirectory(directory) {
|
||||
let descriptor;
|
||||
try {
|
||||
descriptor = fs.openSync(directory, 'r');
|
||||
fs.fsyncSync(descriptor);
|
||||
} catch (error) {
|
||||
if (!['EINVAL', 'ENOTSUP', 'EPERM'].includes(error.code)) throw error;
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function atomicWriteFile(filePath, contents, { beforeRename, mode } = {}) {
|
||||
const directory = path.dirname(filePath);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const temporaryPath = path.join(
|
||||
directory,
|
||||
`.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
|
||||
);
|
||||
const fileMode = mode ?? (fs.existsSync(filePath) ? fs.statSync(filePath).mode & 0o777 : 0o666);
|
||||
let descriptor;
|
||||
|
||||
try {
|
||||
descriptor = fs.openSync(temporaryPath, 'wx', fileMode);
|
||||
fs.writeFileSync(descriptor, contents, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
beforeRename?.(temporaryPath, filePath);
|
||||
fs.renameSync(temporaryPath, filePath);
|
||||
syncDirectory(directory);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
fs.rmSync(temporaryPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function atomicWriteJson(filePath, value, options) {
|
||||
atomicWriteFile(filePath, JSON.stringify(value, null, 2), options);
|
||||
}
|
||||
|
||||
export function migrateStoredState(value) {
|
||||
const stored = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
const version = Number.isSafeInteger(stored.schemaVersion) ? stored.schemaVersion : 0;
|
||||
if (version < 0 || version > STATE_SCHEMA_VERSION) {
|
||||
throw new Error(`Unsupported Harbor state schemaVersion: ${version}`);
|
||||
}
|
||||
return {
|
||||
...normalizeStoredState(stored),
|
||||
schemaVersion: STATE_SCHEMA_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
export function createJsonStore({
|
||||
filePath,
|
||||
defaultValue,
|
||||
migrate = (value) => value,
|
||||
initializeMissing = false,
|
||||
backupWhen = () => false,
|
||||
now = () => new Date(),
|
||||
} = {}) {
|
||||
let recovery = null;
|
||||
let migration = null;
|
||||
|
||||
function write(value, options) {
|
||||
const migrated = migrate(clone(value));
|
||||
atomicWriteJson(filePath, migrated, options);
|
||||
return clone(migrated);
|
||||
}
|
||||
|
||||
function read() {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const initial = migrate(clone(defaultValue));
|
||||
return initializeMissing ? write(initial) : clone(initial);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (cause) {
|
||||
const backupPath = `${filePath}.corrupt-${stamp(now())}`;
|
||||
fs.renameSync(filePath, backupPath);
|
||||
try {
|
||||
const recovered = write(defaultValue);
|
||||
recovery = { kind: 'corrupt-json', backupPath, recoveredAt: now().toISOString() };
|
||||
return recovered;
|
||||
} catch (error) {
|
||||
fs.renameSync(backupPath, filePath);
|
||||
throw new AggregateError([cause, error], `Failed to recover corrupt JSON: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const migrated = migrate(clone(parsed));
|
||||
if (JSON.stringify(migrated) !== JSON.stringify(parsed)) {
|
||||
if (backupWhen(parsed, migrated)) {
|
||||
const fromVersion = Number.isSafeInteger(parsed?.schemaVersion) ? parsed.schemaVersion : 0;
|
||||
const backupPath = `${filePath}.backup-v${fromVersion}-${stamp(now())}`;
|
||||
atomicWriteFile(backupPath, raw, { mode: fs.statSync(filePath).mode & 0o777 });
|
||||
migration = {
|
||||
fromVersion,
|
||||
toVersion: migrated.schemaVersion,
|
||||
backupPath,
|
||||
migratedAt: now().toISOString(),
|
||||
};
|
||||
}
|
||||
atomicWriteJson(filePath, migrated);
|
||||
}
|
||||
return clone(migrated);
|
||||
}
|
||||
|
||||
function update(mutator) {
|
||||
// ponytail: sync mutators serialize in Node's event loop; add a queue only if updates must await I/O.
|
||||
const next = mutator(read());
|
||||
if (next && typeof next.then === 'function') {
|
||||
throw new TypeError('State store mutator must be synchronous');
|
||||
}
|
||||
return write(next);
|
||||
}
|
||||
|
||||
return {
|
||||
read,
|
||||
write,
|
||||
update,
|
||||
remove: () => fs.rmSync(filePath, { force: true }),
|
||||
get recovery() { return recovery; },
|
||||
get migration() { return migration; },
|
||||
};
|
||||
}
|
||||
|
||||
export function createStateStore(filePath, options = {}) {
|
||||
return createJsonStore({
|
||||
filePath,
|
||||
defaultValue: {},
|
||||
migrate: migrateStoredState,
|
||||
initializeMissing: true,
|
||||
backupWhen: (before, after) => before?.schemaVersion !== after.schemaVersion,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { settings } from './config.js';
|
||||
import { HarborError } from '../shared/errors.js';
|
||||
import { atomicWriteFile, atomicWriteJson } from './services/stateStore.js';
|
||||
|
||||
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||
const MIXED_INBOUND = 'mixed-in';
|
||||
@@ -77,8 +77,11 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, { clientDire
|
||||
}
|
||||
|
||||
export function writeSingboxConfig(config) {
|
||||
fs.mkdirSync(path.dirname(settings.configPath), { recursive: true });
|
||||
fs.writeFileSync(settings.configPath, JSON.stringify(config, null, 2), 'utf8');
|
||||
atomicWriteJson(settings.configPath, config);
|
||||
}
|
||||
|
||||
export function restoreSingboxConfig(contents) {
|
||||
atomicWriteFile(settings.configPath, contents);
|
||||
}
|
||||
|
||||
export function removeSingboxConfig() {
|
||||
|
||||
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { settings } from './config.js';
|
||||
import { HarborError } from '../shared/errors.js';
|
||||
import { atomicWriteFile } from './services/stateStore.js';
|
||||
|
||||
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||
|
||||
@@ -11,7 +12,7 @@ export function getHwid() {
|
||||
return fs.readFileSync(settings.hwidPath, 'utf8').trim();
|
||||
}
|
||||
const hwid = crypto.randomBytes(8).toString('hex');
|
||||
fs.writeFileSync(settings.hwidPath, hwid, 'utf8');
|
||||
atomicWriteFile(settings.hwidPath, hwid);
|
||||
return hwid;
|
||||
}
|
||||
|
||||
|
||||
83
test/server/state-store.test.js
Normal file
83
test/server/state-store.test.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
atomicWriteJson,
|
||||
createStateStore,
|
||||
STATE_SCHEMA_VERSION,
|
||||
} from '../../src/server/services/stateStore.js';
|
||||
|
||||
const fixture = (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-store-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return path.join(directory, 'state.json');
|
||||
};
|
||||
|
||||
test('a failure before rename preserves the last successful file', (t) => {
|
||||
const filePath = fixture(t);
|
||||
atomicWriteJson(filePath, { revision: 1 });
|
||||
|
||||
assert.throws(
|
||||
() => atomicWriteJson(filePath, { revision: 2 }, {
|
||||
beforeRename: () => { throw new Error('injected failure'); },
|
||||
}),
|
||||
/injected failure/,
|
||||
);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(filePath, 'utf8')), { revision: 1 });
|
||||
assert.equal(
|
||||
fs.readdirSync(path.dirname(filePath)).some((name) => name.endsWith('.tmp')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy state migrates to schema v1 and keeps a backup', (t) => {
|
||||
const filePath = fixture(t);
|
||||
const legacy = {
|
||||
revision: 7,
|
||||
selectedTag: 'nl',
|
||||
servers: [{ tag: 'nl' }],
|
||||
};
|
||||
fs.writeFileSync(filePath, JSON.stringify(legacy));
|
||||
|
||||
const store = createStateStore(filePath, {
|
||||
now: () => new Date('2026-07-11T12:00:00.000Z'),
|
||||
});
|
||||
const migrated = store.read();
|
||||
|
||||
assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
|
||||
assert.equal(migrated.appliedTag, 'nl');
|
||||
assert.equal(store.migration.fromVersion, 0);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
|
||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, 1);
|
||||
});
|
||||
|
||||
test('corrupt JSON is preserved and replaced with an explicit recovery state', (t) => {
|
||||
const filePath = fixture(t);
|
||||
fs.writeFileSync(filePath, '{broken');
|
||||
|
||||
const store = createStateStore(filePath, {
|
||||
now: () => new Date('2026-07-11T12:00:00.000Z'),
|
||||
});
|
||||
const recovered = store.read();
|
||||
|
||||
assert.equal(recovered.schemaVersion, 1);
|
||||
assert.equal(recovered.revision, 0);
|
||||
assert.equal(store.recovery.kind, 'corrupt-json');
|
||||
assert.equal(fs.readFileSync(store.recovery.backupPath, 'utf8'), '{broken');
|
||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, 1);
|
||||
});
|
||||
|
||||
test('concurrent updates are serialized without lost values', async (t) => {
|
||||
const store = createStateStore(fixture(t));
|
||||
store.read();
|
||||
|
||||
await Promise.all(Array.from({ length: 50 }, () => Promise.resolve().then(() => (
|
||||
store.update((state) => ({ ...state, counter: (state.counter || 0) + 1 }))
|
||||
))));
|
||||
|
||||
assert.equal(store.read().counter, 50);
|
||||
assert.throws(() => store.update(async (state) => state), /must be synchronous/);
|
||||
});
|
||||
Reference in New Issue
Block a user