tern
Code
Ready
Default model
You
The journal test fails on Windows CI about one run in five and never locally. Find out why and fix it.
Assistant
91.4 tok/s
TTFT 0.412s
gen 0.777s
13,785 tokens
SearchacquireLock|openJournal7 matches · 4 files
Readsrc/store/lock.ts13 lines
Readtest/journal.test.ts23 lines
Assistant
88.7 tok/s
TTFT 0.301s
gen 0.812s
14,557 tokens
Runnode --test --test-name-pattern="reopens" test/journal.test.tsexit 0
Assistant

It passes on macOS, which fits the pattern. The race is in acquireLock():

  1. close() closes the handle and removes .tern/lock.
  2. On Windows that file stays pending delete for a few milliseconds.
  3. The next openJournal() calls open(path, 'wx') inside that window and gets EPERM or EBUSY.

Nothing retries, so the test fails whenever CI is fast enough to land in the gap. I will retry only those two codes, with a short backoff, and keep failing fast on everything else.

87.9 tok/s
TTFT 0.355s
gen 1.206s
14,822 tokens
Edit+16−1
search
/** An exclusive lock file: only one tern process may write a journal. */
export async function acquireLock(dir: string): Promise<() => Promise<void>> {
  const path = join(dir, '.tern', 'lock');
  const handle: FileHandle = await open(path, 'wx');
  await handle.write(String(process.pid));
  return async () => {
    await handle.close();
    await rm(path, { force: true });
  };
}
replace
/** Codes Windows returns while a just-closed lock file is still being released. */
const RELEASING = new Set(['EBUSY', 'EPERM']);

/** An exclusive lock file: only one tern process may write a journal. */
export async function acquireLock(dir: string): Promise<() => Promise<void>> {
  const path = join(dir, '.tern', 'lock');
  const handle = await openExclusive(path);
  await handle.write(String(process.pid));
  return async () => {
    await handle.close();
    await rm(path, { force: true });
  };
}

async function openExclusive(path: string, attempts = 5): Promise<FileHandle> {
  for (let attempt = 0; ; attempt++) {
    try {
      return await open(path, 'wx');
    } catch (err) {
      const code = (err as NodeJS.ErrnoException).code ?? '';
      if (!RELEASING.has(code) || attempt === attempts - 1) throw err;
      await new Promise((r) => setTimeout(r, 20 * 2 ** attempt));
    }
  }
}
4+/** Codes Windows returns while a just-closed lock file is still being released. */
5+const RELEASING = new Set(['EBUSY', 'EPERM']);
6+
7- const handle: FileHandle = await open(path, 'wx');
10+ const handle = await openExclusive(path);
16+}
17+
18+async function openExclusive(path: string, attempts = 5): Promise<FileHandle> {
19+ for (let attempt = 0; ; attempt++) {
20+ try {
21+ return await open(path, 'wx');
22+ } catch (err) {
23+ const code = (err as NodeJS.ErrnoException).code ?? '';
24+ if (!RELEASING.has(code) || attempt === attempts - 1) throw err;
25+ await new Promise((r) => setTimeout(r, 20 * 2 ** attempt));
26+ }
27+ }
Raw input and output
{
  "path": "src/store/lock.ts",
  "search": "/** An exclusive lock file: only one tern process may write a journal. */\nexport async function acquireLock(dir: string): Promise<() => Promise<void>> {\n  const path = join(dir, '.tern', 'lock');\n  const handle: FileHandle = await open(path, 'wx');\n  await handle.write(String(process.pid));\n  return async () => {\n    await handle.close();\n    await rm(path, { force: true });\n  };\n}",
  "replace": "/** Codes Windows returns while a just-closed lock file is still being released. */\nconst RELEASING = new Set(['EBUSY', 'EPERM']);\n\n/** An exclusive lock file: only one tern process may write a journal. */\nexport async function acquireLock(dir: string): Promise<() => Promise<void>> {\n  const path = join(dir, '.tern', 'lock');\n  const handle = await openExclusive(path);\n  await handle.write(String(process.pid));\n  return async () => {\n    await handle.close();\n    await rm(path, { force: true });\n  };\n}\n\nasync function openExclusive(path: string, attempts = 5): Promise<FileHandle> {\n  for (let attempt = 0; ; attempt++) {\n    try {\n      return await open(path, 'wx');\n    } catch (err) {\n      const code = (err as NodeJS.ErrnoException).code ?? '';\n      if (!RELEASING.has(code) || attempt === attempts - 1) throw err;\n      await new Promise((r) => setTimeout(r, 20 * 2 ** attempt));\n    }\n  }\n}\n"
}
Replaced 1 occurrence(s) in src/store/lock.ts
Runnpm testexit 0
File issueLock file is left behind when tern is killed mid-sync
Assistant

Fixed, and the suite passes (6/6).

  • Cause: on Windows a just-removed .tern/lock is still pending delete for a few milliseconds, so an immediate reopen fails with EPERM or EBUSY.
  • Fix: acquireLock() now goes through openExclusive(), which retries only those two codes — 5 attempts, 20 ms doubling — and rethrows anything else, including a real EEXIST.
  • Worth knowing: macOS never shows this, so the proof is windows-latest. Re-run CI a few times before trusting it.

While reading the lock code I filed TER-13: a lock left behind by a killed tern blocks every later sync.

92.3 tok/s
TTFT 0.322s
gen 1.062s
15,954 tokens
Edited 1 file
+16−1
src/store/lock.ts+16−1
lock.ts
New tab
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
import { open, rm, type FileHandle } from 'node:fs/promises';
import { join } from 'node:path';

/** Codes Windows returns while a just-closed lock file is still being released. */
const RELEASING = new Set(['EBUSY', 'EPERM']);

/** An exclusive lock file: only one tern process may write a journal. */
export async function acquireLock(dir: string): Promise<() => Promise<void>> {
const path = join(dir, '.tern', 'lock');
const handle = await openExclusive(path);
await handle.write(String(process.pid));
return async () => {
await handle.close();
await rm(path, { force: true });
};
}

async function openExclusive(path: string, attempts = 5): Promise<FileHandle> {
for (let attempt = 0; ; attempt++) {
try {
return await open(path, 'wx');
} catch (err) {
const code = (err as NodeJS.ErrnoException).code ?? '';
if (!RELEASING.has(code) || attempt === attempts - 1) throw err;
await new Promise((r) => setTimeout(r, 20 * 2 ** attempt));
}
}
}


Scheduler

Default modelQwen3 coder 30B · Q4_K_MLM Studio (local)
Response complete. Fixed, and the suite passes (6/6). - **Cause:** on Windows a just-removed `.tern/lock` is still pending delete for a few…