Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 | 232x 232x 232x 232x 232x 232x 232x 232x 232x 232x 232x 346x 275x 147x 143x 143x 141x 134x 132x 132x 232x 20x 6x 6x 2x 2x 232x 10x 9x 2x 232x 63x 199x 41x 41x 151x 151x 151x 145x 145x 145x 34x 34x 32x 32x 2x 1x 384x 385x 384x 99x 99x 99x 6x 385x 385x 158x 1x 227x 227x 134x 134x 227x 97x 96x 3x 93x 93x 93x 138x 138x 16x 93x 145x 145x 145x 145x 145x 145x 145x 145x 111x 111x 1x 145x 34x 1x 34x 32x 32x 32x 53x 53x 53x 53x 12x 41x 10x 10x 244x 232x 1x | /**
* Where account settings are kept: in the session storage when it can keep them, otherwise
* in a directory in the configuration directory of the user running the server, shared by
* every server process of that user.
*/
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { AccountSettings, AccountSettingsRecord } from './types';
import { AccountSettingsSchema } from './types';
import { logWarn } from '../logger';
import { syncDirectory } from '../utils/sync-directory';
export interface SettingsStore {
get(accountKey: string): Promise<AccountSettingsRecord | undefined>;
/** Compare-and-set write; undefined when another write changed the settings first. */
put(
accountKey: string,
settings: AccountSettings,
expectedVersion: number,
): Promise<AccountSettingsRecord | undefined>;
}
interface SettingsFile {
version: 1;
accounts: Record<string, AccountSettingsRecord>;
}
/** The settings as of one generation; generation 0 is the empty state before any write. */
interface Snapshot {
generation: number;
file: SettingsFile;
}
/** How many lost races a write retries before it gives up. */
const SAVE_ATTEMPTS = 100;
/** A temporary file older than this belongs to a writer that died before saving it. */
const ABANDONED_TEMP_MS = 60 * 60_000;
const GENERATION_FILE = /^(\d+)\.json$/;
const TEMP_SUFFIX = '.tmp';
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
const errorCode = (error: unknown): string | undefined => (error as NodeJS.ErrnoException).code;
/**
* A stored record as written, or undefined when any field is off: its account, a version
* from 1 (every compare-and-set write names it), its update time and valid settings.
*/
function validRecord(key: string, record: unknown): AccountSettingsRecord | undefined {
if (!isPlainObject(record)) return undefined;
const { accountKey, version, updatedAt } = record;
if (accountKey !== key) return undefined;
if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) return undefined;
if (typeof updatedAt !== 'number' || !Number.isFinite(updatedAt)) return undefined;
const settings = AccountSettingsSchema.safeParse(record.settings);
return settings.success ? { accountKey, settings: settings.data, version, updatedAt } : undefined;
}
/**
* A record read from the session storage, whose version is that storage's compare-and-set
* token: settings of the wrong shape are read as none, keeping the version, so the account
* still resolves and its next write replaces them.
*/
export function usableStoredRecord(
record: AccountSettingsRecord | undefined,
): AccountSettingsRecord | undefined {
if (!record) return undefined;
const settings = AccountSettingsSchema.safeParse(record.settings);
if (settings.success) return { ...record, settings: settings.data };
logWarn('Ignoring invalid stored account settings', { accountKey: record.accountKey });
return { ...record, settings: {} };
}
/** A record from a list keyed by its own account, checked as {@link validRecord} does. */
export function validStoredRecord(record: unknown): AccountSettingsRecord | undefined {
if (!isPlainObject(record) || typeof record.accountKey !== 'string') return undefined;
return validRecord(record.accountKey, record);
}
/** Whether a stored record is this write: the same version with the same settings. */
function holds(stored: AccountSettingsRecord | undefined, written: AccountSettingsRecord): boolean {
return (
stored?.version === written.version &&
JSON.stringify(stored.settings) === JSON.stringify(written.settings)
);
}
/**
* Settings of a local server as numbered generations in one directory. A write saves the
* next generation with an exclusive link, which fails when another write saved it first; the
* write then reads the new state and checks its version again. There is no lock, so a
* process that dies or is suspended mid-write cannot block others or overwrite newer
* settings when it resumes.
*/
export class LocalSettingsFile implements SettingsStore {
constructor(private readonly dir: string) {}
async get(accountKey: string): Promise<AccountSettingsRecord | undefined> {
return (await this.latest()).file.accounts[accountKey];
}
async put(
accountKey: string,
settings: AccountSettings,
expectedVersion: number,
): Promise<AccountSettingsRecord | undefined> {
await fs.promises.mkdir(this.dir, { recursive: true });
// Each attempt depends on the state the previous one lost to.
for (let attempt = 0; attempt < SAVE_ATTEMPTS; attempt++) {
const { generation, file } = await this.latest();
const current = file.accounts[accountKey]?.version ?? 0;
if (current !== expectedVersion) return undefined;
const record: AccountSettingsRecord = {
accountKey,
settings,
version: current + 1,
updatedAt: Date.now(),
};
file.accounts[accountKey] = record;
if (!(await this.save(generation + 1, file))) continue;
// Pruning frees old generation numbers, so a writer that resumes after several newer
// writes can link a number already used. A write counts only when the newest state
// holds it; otherwise the next attempt re-reads that state and its version check
// refuses the stale edit.
const newest = await this.latest();
if (newest.generation === generation + 1) {
await this.prune(generation + 1);
return record;
}
if (holds(newest.file.accounts[accountKey], record)) return record;
}
throw new Error(`Settings are being changed by other processes: ${this.dir}`);
}
/** The newest generation, or the empty state when none was saved yet. */
private async latest(): Promise<Snapshot> {
// A generation found can be pruned by a newer write before it is read: read again.
for (;;) {
const generation = await this.newestGeneration();
if (generation === 0) return { generation, file: { version: 1, accounts: {} } };
const filePath = this.generationPath(generation);
try {
return {
generation,
file: this.parse(await fs.promises.readFile(filePath, 'utf-8'), filePath),
};
} catch (error: unknown) {
if (errorCode(error) !== 'ENOENT') throw error;
}
}
}
private async newestGeneration(): Promise<number> {
let names: string[];
try {
names = await fs.promises.readdir(this.dir);
} catch (error: unknown) {
if (errorCode(error) === 'ENOENT') return 0;
throw error;
}
let newest = 0;
for (const name of names) {
const match = GENERATION_FILE.exec(name);
if (match) newest = Math.max(newest, Number(match[1]));
}
return newest;
}
private parse(content: string, filePath: string): SettingsFile {
const parsed: unknown = JSON.parse(content);
if (!isPlainObject(parsed)) {
throw new Error(`Settings file is not a JSON object: ${filePath}`);
}
const accounts: Record<string, AccountSettingsRecord> = {};
const stored = isPlainObject(parsed.accounts) ? parsed.accounts : {};
for (const [key, record] of Object.entries(stored)) {
// A hand-edited entry of the wrong shape, or settings the schema refuses, is ignored
// rather than applied half-valid or failing every call.
const valid = validRecord(key, record);
if (valid) accounts[key] = valid;
else logWarn('Ignoring invalid saved settings', { file: filePath });
}
return { version: 1, accounts };
}
/**
* Saves a generation unless another write saved it first (false). The content is written
* and synced in a temporary file, then linked under the generation's name: the link is
* atomic and fails when the name exists, so a generation is saved once, complete.
*/
private async save(generation: number, file: SettingsFile): Promise<boolean> {
const temp = path.join(this.dir, `.${process.pid}.${randomUUID()}${TEMP_SUFFIX}`);
const handle = await fs.promises.open(temp, 'w', 0o600);
try {
await handle.writeFile(JSON.stringify(file, null, 2), 'utf-8');
await handle.sync();
} finally {
await handle.close();
}
try {
await fs.promises.link(temp, this.generationPath(generation));
} catch (error: unknown) {
// EEXIST: another write saved this generation. ENOENT: the temporary file was cleared
// as abandoned while this process was suspended. Either way, prepare the write again.
const code = errorCode(error);
if (code === 'EEXIST' || code === 'ENOENT') return false;
throw error;
} finally {
// Once linked the generation is saved: a temporary file left behind must not report
// the write as failed. A later write prunes it as abandoned.
await fs.promises.rm(temp, { force: true }).catch(() => undefined);
}
// The link is the commit: every reader already takes this generation as current, so a
// failed sync only leaves it at risk from a crash and must not report the write as failed.
await syncDirectory(this.dir).catch((error: unknown) =>
logWarn('Saved settings may not survive a crash: the directory could not be synced', {
dir: this.dir,
err: error as Error,
}),
);
return true;
}
/**
* Removes generations older than the one just saved and temporary files of writers that
* died before saving. Best effort: a leftover is never read as the current settings.
*/
private async prune(saved: number): Promise<void> {
const names = await fs.promises.readdir(this.dir).catch(() => [] as string[]);
const now = Date.now();
await Promise.all(
names.map(async (name) => {
const target = path.join(this.dir, name);
try {
const match = GENERATION_FILE.exec(name);
if (match && Number(match[1]) < saved) {
await fs.promises.rm(target, { force: true });
} else if (name.endsWith(TEMP_SUFFIX)) {
const { mtimeMs } = await fs.promises.stat(target);
if (now - mtimeMs > ABANDONED_TEMP_MS) await fs.promises.rm(target, { force: true });
}
} catch {
// Removed by another writer meanwhile, or not removable now: the next write retries.
}
}),
);
}
private generationPath(generation: number): string {
return path.join(this.dir, `${generation}.json`);
}
}
/** Default location of a local server's settings. */
export function localSettingsDir(): string {
return path.join(os.homedir(), '.config', 'gitlab-mcp', 'settings');
}
|