Skip to content
155 changes: 152 additions & 3 deletions lib/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { promises as fs, readFileSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
import type { PluginConfig } from "./types.js";
import {
Expand All @@ -16,6 +16,12 @@ const TUI_GLYPH_MODES = new Set(["ascii", "unicode", "auto"]);
const REQUEST_TRANSFORM_MODES = new Set(["native", "legacy"]);
const UNSUPPORTED_CODEX_POLICIES = new Set(["strict", "fallback"]);
const RETRY_PROFILES = new Set(["conservative", "balanced", "aggressive"]);
const CONFIG_LOCK_PATH = `${CONFIG_PATH}.lock`;
const CONFIG_LOCK_RETRY_ATTEMPTS = 5;
const CONFIG_LOCK_RETRY_BASE_DELAY_MS = 10;
const CONFIG_LOCK_RETRY_MAX_DELAY_MS = 200;
const CONFIG_LOCK_STALE_MS = 30_000;
let configMutationMutex: Promise<void> = Promise.resolve();

export type UnsupportedCodexPolicy = "strict" | "fallback";

Expand Down Expand Up @@ -111,7 +117,91 @@ function stripUtf8Bom(content: string): string {
}

function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object";
return value !== null && typeof value === "object" && !Array.isArray(value);
}

type RawPluginConfig = Record<string, unknown>;

function withConfigMutationLock<T>(fn: () => Promise<T>): Promise<T> {
const previous = configMutationMutex;
let release: () => void;
configMutationMutex = new Promise<void>((resolve) => {
release = resolve;
});
return previous.then(fn).finally(() => release());
}

async function withConfigProcessLock<T>(fn: () => Promise<T>): Promise<T> {
let lastError: NodeJS.ErrnoException | null = null;
let attempt = 0;

await fs.mkdir(dirname(CONFIG_PATH), { recursive: true });

while (attempt < CONFIG_LOCK_RETRY_ATTEMPTS) {
try {
const handle = await fs.open(CONFIG_LOCK_PATH, "wx", 0o600);
try {
return await fn();
} finally {
await handle.close();
await fs.unlink(CONFIG_LOCK_PATH).catch(() => undefined);
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "EEXIST") {
try {
const stat = await fs.stat(CONFIG_LOCK_PATH);
if (Date.now() - stat.mtimeMs > CONFIG_LOCK_STALE_MS) {
await fs.unlink(CONFIG_LOCK_PATH).catch(() => undefined);
continue;
}
} catch (statError) {
const statCode = (statError as NodeJS.ErrnoException).code;
if (statCode === "ENOENT") {
Comment on lines +155 to +160
continue;
}
}
lastError = error as NodeJS.ErrnoException;
await new Promise((resolve) =>
setTimeout(
resolve,
Math.min(CONFIG_LOCK_RETRY_BASE_DELAY_MS * 2 ** attempt, CONFIG_LOCK_RETRY_MAX_DELAY_MS),
),
);
attempt += 1;
continue;
}
throw error;
}
}

throw lastError ?? new Error(`Timed out acquiring config lock ${CONFIG_LOCK_PATH}`);
}

async function renameConfigWithWindowsRetry(sourcePath: string, destinationPath: string): Promise<void> {
let lastError: NodeJS.ErrnoException | null = null;
for (let attempt = 0; attempt < CONFIG_LOCK_RETRY_ATTEMPTS; attempt += 1) {
try {
await fs.rename(sourcePath, destinationPath);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "EPERM" || code === "EBUSY") {
lastError = error as NodeJS.ErrnoException;
await new Promise((resolve) =>
setTimeout(
resolve,
Math.min(CONFIG_LOCK_RETRY_BASE_DELAY_MS * 2 ** attempt, CONFIG_LOCK_RETRY_MAX_DELAY_MS),
),
);
continue;
}
throw error;
}
}
Comment on lines +181 to +201
if (lastError) {
throw lastError;
}
}

/**
Expand Down Expand Up @@ -501,3 +591,62 @@ export function getStreamStallTimeoutMs(pluginConfig: PluginConfig): number {
{ min: 1_000 },
);
}

async function savePluginConfigMutation(
mutate: (current: RawPluginConfig) => RawPluginConfig,
): Promise<void> {
await withConfigMutationLock(async () => withConfigProcessLock(async () => {
await fs.mkdir(dirname(CONFIG_PATH), { recursive: true });
const current = existsSync(CONFIG_PATH)
? await (async () => {
const raw = stripUtf8Bom(await fs.readFile(CONFIG_PATH, "utf-8"));
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid JSON in config file ${CONFIG_PATH}: ${message}`);
}
if (!isRecord(parsed)) {
throw new Error(`Config file must contain a JSON object: ${CONFIG_PATH}`);
}
return { ...parsed };
})()
: {};
const next = mutate(current);
const tempPath = `${CONFIG_PATH}.${process.pid}.${Date.now()}.tmp`;
try {
await fs.writeFile(tempPath, `${JSON.stringify(next, null, 2)}\n`, {
encoding: "utf-8",
mode: 0o600,
});
await renameConfigWithWindowsRetry(tempPath, CONFIG_PATH);
} catch (error) {
try {
await fs.unlink(tempPath);
} catch {
// Best effort cleanup only.
}
throw error;
}
}));
}

export function getSyncFromCodexMultiAuthEnabled(pluginConfig: PluginConfig): boolean {
return pluginConfig.experimental?.syncFromCodexMultiAuth?.enabled === true;
}

export async function setSyncFromCodexMultiAuthEnabled(enabled: boolean): Promise<void> {
await savePluginConfigMutation((current) => {
const experimental = isRecord(current.experimental) ? { ...current.experimental } : {};
const syncSettings = isRecord(experimental.syncFromCodexMultiAuth)
? { ...experimental.syncFromCodexMultiAuth }
: {};
syncSettings.enabled = enabled;
experimental.syncFromCodexMultiAuth = syncSettings;
return {
...current,
experimental,
};
});
}
5 changes: 5 additions & 0 deletions lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export const PluginConfigSchema = z.object({
pidOffsetEnabled: z.boolean().optional(),
fetchTimeoutMs: z.number().min(1_000).optional(),
streamStallTimeoutMs: z.number().min(1_000).optional(),
experimental: z.object({
syncFromCodexMultiAuth: z.object({
enabled: z.boolean().optional(),
}).optional(),
}).optional(),
});

export type PluginConfigFromSchema = z.infer<typeof PluginConfigSchema>;
Expand Down
Loading