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);
  });
});