File size: 9,842 Bytes
f0634fb | 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 264 265 266 267 268 269 | /**
* OAuthManager cross-process refresh lock.
*
* Spawns N Node worker processes that each call `ensureFresh(force=true)`
* on the same OAuth provider. With the `proper-lockfile`-backed
* cross-process mutex around `doEnsureFresh`, only one worker actually
* hits the refresh endpoint (`refreshImpl`); the others re-read storage
* and see the rotated token produced by the winner.
*
* Workers run as inline `.mjs` scripts via `spawnInlineWorkers`. Each
* worker:
* 1. Dynamically imports `OAuthManager` from the current package source.
* 2. Constructs it with a file-backed TokenStorage pointing at
* `{shareDir}/token.json` and a `refreshTokenImpl` that increments
* `{shareDir}/refresh-count.txt` atomically before returning a
* rotated token (refreshToken changes every refresh).
* 3. Calls `ensureFresh({force:true})` and exits.
*
* Oracle: after all workers exit, `refresh-count.txt` contains exactly
* `1` (when the lock is in place); `N` (when it is not).
*
* **Platform**: macOS / Linux only. Windows path quirks for
* `proper-lockfile` are bypassed via the `KIMI_DISABLE_OAUTH_LOCK=1`
* env-var escape hatch; this test skips on `process.platform === 'win32'`.
*/
import { mkdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { createTempWorkDir, spawnInlineWorkers, type TempDirHandle } from './helpers';
const skipOnWindows = process.platform === 'win32';
const OAUTH_ENTRY_URL = new URL('../src/index.ts', import.meta.url).href;
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Worker body β dedicated inline .mjs script.
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
//
// One worker = one ensureFresh(force=true) invocation. All workers race
// against the same on-disk lock file and the same refresh-count.txt.
//
// `refresh-count.txt` starts empty (or missing). Workers atomically
// append a single byte per observed refresh using O_APPEND semantics;
// the final byte count equals the number of refreshes that took place.
const WORKER_SCRIPT = `
import { readFile, writeFile, appendFile, mkdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
const { OAuthManager } = await import(process.env.KIMI_OAUTH_ENTRY);
const shareDir = process.env.KIMI_CODE_HOME;
const tokenPath = join(shareDir, 'token.json');
const counterPath = join(shareDir, 'refresh-count.txt');
const readyPath = join(shareDir, 'first-load-ready.txt');
const lockDir = join(shareDir, 'oauth');
await mkdir(lockDir, { recursive: true });
async function waitForFirstLoadBarrier() {
if (process.env.KIMI_SYNC_FIRST_LOAD !== '1') return;
await appendFile(readyPath, '.');
const expected = Number(process.env.KIMI_WORKER_COUNT || '1');
const deadline = Date.now() + 10_000;
while (true) {
let ready = 0;
try {
ready = (await stat(readyPath)).size;
} catch {}
if (ready >= expected) return;
if (Date.now() >= deadline) {
throw new Error('first-load barrier timed out');
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
const config = {
name: 'test-provider',
oauthHost: 'https://unused.test',
clientId: 'test',
};
/** File-backed TokenStorage keyed on the single provider 'test-provider'. */
let firstLoad = true;
const storage = {
async load(name) {
try {
const raw = await readFile(tokenPath, 'utf8');
if (firstLoad) {
firstLoad = false;
await waitForFirstLoadBarrier();
}
const parsed = JSON.parse(raw);
return parsed[name];
} catch {
return undefined;
}
},
async save(name, token) {
// Read-modify-write. Good enough for the test oracle; the real
// cross-process correctness comes from the lock, not the storage.
let bag = {};
try {
bag = JSON.parse(await readFile(tokenPath, 'utf8'));
} catch {
bag = {};
}
bag[name] = token;
await writeFile(tokenPath, JSON.stringify(bag), 'utf8');
},
async remove(name) {},
async list() { return ['test-provider']; },
};
/** refreshImpl increments the oracle file and hands back a rotated token. */
const refreshImpl = async () => {
// One byte per observed refresh; O_APPEND is atomic on POSIX.
await appendFile(counterPath, '.');
const nowSec = Math.floor(Date.now() / 1000);
return {
accessToken: 'at-refreshed-' + String(nowSec),
refreshToken: 'rt-rotated-' + String(nowSec),
expiresAt: nowSec + 3600,
scope: '',
tokenType: 'Bearer',
expiresIn: 3600,
};
};
const manager = new OAuthManager({
config,
storage,
refreshTokenImpl: refreshImpl,
// minimal stubs β unused on ensureFresh
requestDeviceImpl: async () => { throw new Error('unused'); },
pollDeviceImpl: async () => { throw new Error('unused'); },
now: () => Math.floor(Date.now() / 1000),
});
// Every worker attempts a forced refresh. With a cross-process lock
// in place, only one worker's refreshImpl runs; the others read the
// rotated storage and return its accessToken without calling
// refreshImpl.
try {
const token = await manager.ensureFresh({ force: true });
process.stdout.write('ok:' + token + '\\n');
} catch (err) {
process.stdout.write('err:' + (err && err.message ? err.message : String(err)) + '\\n');
}
// Debug trace so the test oracle can diagnose mis-locking.
if (process.env.DEBUG_OAUTH_WORKER === '1') {
process.stderr.write('[worker ' + process.env.KIMI_WORKER_ID + '] done\\n');
}
`;
async function seedInitialToken(shareDir: string): Promise<void> {
const tokenPath = join(shareDir, 'token.json');
const nowSec = Math.floor(Date.now() / 1000);
const token = {
'test-provider': {
accessToken: 'at-initial',
refreshToken: 'rt-initial',
expiresAt: nowSec + 60, // inside refresh threshold β force refresh hits
scope: '',
tokenType: 'Bearer',
expiresIn: 3600,
},
};
const { writeFile } = await import('node:fs/promises');
await writeFile(tokenPath, JSON.stringify(token), 'utf8');
}
async function readRefreshCount(shareDir: string): Promise<number> {
const counterPath = join(shareDir, 'refresh-count.txt');
try {
const s = await stat(counterPath);
return s.size;
} catch {
return 0;
}
}
const tmpHandles: TempDirHandle[] = [];
afterEach(async () => {
while (tmpHandles.length > 0) {
await tmpHandles.pop()!.cleanup();
}
});
describe.skipIf(skipOnWindows)('OAuthManager cross-process refresh lock', () => {
it('2 workers concurrently force-refresh β exactly one refreshImpl fires', async () => {
const dir = await createTempWorkDir();
tmpHandles.push(dir);
await seedInitialToken(dir.path);
const workers = await spawnInlineWorkers({
count: 2,
inlineScript: WORKER_SCRIPT,
tmpDir: dir.path,
shareDir: dir.path,
timeoutMs: 30_000,
env: {
KIMI_OAUTH_ENTRY: OAUTH_ENTRY_URL,
KIMI_SYNC_FIRST_LOAD: '1',
KIMI_WORKER_COUNT: '2',
},
});
// All workers exit cleanly.
for (const w of workers) {
expect(w.exitCode, `worker ${String(w.id)} stderr: ${w.stderr}`).toBe(0);
expect(w.stdout.startsWith('ok:')).toBe(true);
}
// Refresh count = 1 β exactly one refresh happened across the 5
// processes. Without the lock the count equals N (or any value > 1).
const count = await readRefreshCount(dir.path);
expect(count).toBe(1);
}, 45_000);
it('stale lock (held by a killed worker) is reclaimed after stale timeout', async () => {
// Scenario: worker A takes the lock and crashes without releasing
// (SIGKILL). Worker B arrives 6+ seconds later and must reclaim
// the stale lock via `proper-lockfile`'s `stale: 5_000ms` policy.
//
// BLK-2 fix: proper-lockfile represents the lock as a DIRECTORY
// at `{target}.lock/`. The staleness probe is `stat().mtimeMs`
// on that directory, so we must `mkdir` + `utimes` (not
// `writeFile`, which would put a regular file where a dir is
// expected β `proper-lockfile` would then blow up or
// mis-interpret it).
const dir = await createTempWorkDir();
tmpHandles.push(dir);
await seedInitialToken(dir.path);
await mkdir(join(dir.path, 'oauth'), { recursive: true });
const { utimes } = await import('node:fs/promises');
const lockDir = join(dir.path, 'oauth', 'test-provider.lock');
await mkdir(lockDir, { recursive: true });
// 10 seconds ago β past the 5 s stale threshold.
const tenSecondsAgo = (Date.now() - 10_000) / 1000;
await utimes(lockDir, tenSecondsAgo, tenSecondsAgo);
const workers = await spawnInlineWorkers({
count: 1,
inlineScript: WORKER_SCRIPT,
tmpDir: dir.path,
shareDir: dir.path,
timeoutMs: 20_000,
env: {
KIMI_OAUTH_ENTRY: OAUTH_ENTRY_URL,
},
});
expect(workers[0]?.exitCode).toBe(0);
expect(workers[0]?.stdout.startsWith('ok:')).toBe(true);
}, 30_000);
});
// Prevent "no tests in file" when running on Windows.
describe.skipIf(!skipOnWindows)('OAuthManager cross-process refresh lock (Windows skip)', () => {
it('skipped on Windows β covered by KIMI_DISABLE_OAUTH_LOCK=1 env escape hatch', () => {
expect(skipOnWindows).toBe(true);
});
});
|