Browse Source

fix: recover from stale companion install locks

Alvin Unreal 1 month ago
parent
commit
ce5a5490a3
3 changed files with 50 additions and 1 deletions
  1. 3 1
      docs/companion.md
  2. 30 0
      src/companion/updater.test.ts
  3. 17 0
      src/companion/updater.ts

+ 3 - 1
docs/companion.md

@@ -145,7 +145,9 @@ Startup checks run before the Companion is spawned, but they use a short timeout
 so OpenCode startup is not blocked by a slow network. Plugin auto-update also
 tries to update the Companion binary after the new package is installed. If a
 download fails, the plugin update still succeeds and Companion update is retried
-on the next OpenCode restart.
+on the next OpenCode restart. The updater uses a lock to prevent concurrent
+OpenCode processes from replacing the same binary, and stale locks from crashed
+updates are cleaned up automatically.
 
 Automatic native updates only use release archives listed in the packaged
 companion manifest, and every archive must have a matching SHA256 checksum.

+ 30 - 0
src/companion/updater.test.ts

@@ -4,6 +4,7 @@ import {
   mkdirSync,
   readFileSync,
   rmSync,
+  utimesSync,
   writeFileSync,
 } from 'node:fs';
 import * as os from 'node:os';
@@ -205,6 +206,35 @@ describe('companion updater', () => {
     });
   });
 
+  test('recovers from stale install locks', async () => {
+    const bin = getCompanionBinaryPath();
+    const lock = `${bin}.lock`;
+    mkdirSync(lock, { recursive: true });
+    const oldDate = new Date(Date.now() - 10 * 60_000);
+    utimesSync(lock, oldDate, oldDate);
+    globalThis.fetch = (() => {
+      throw new Error('should not fetch without checksum');
+    }) as unknown as typeof fetch;
+
+    const result = await ensureCompanionVersion({
+      config: { enabled: true },
+      manifest: {
+        version: '0.2.0',
+        tag: 'companion-v0.2.0',
+        repo: 'owner/repo',
+        checksums: {},
+      },
+      lockTimeoutMs: 100,
+      lockStaleMs: 1,
+    });
+
+    expect(existsSync(lock)).toBe(false);
+    expect(result).toMatchObject({
+      status: 'failed',
+      error: expect.stringContaining('Missing SHA256 checksum'),
+    });
+  });
+
   test('loads a companion manifest from an installed package root', () => {
     const packageRoot = path.join(TEST_DIR, 'package');
     const manifestDir = path.join(packageRoot, 'src', 'companion');

+ 17 - 0
src/companion/updater.ts

@@ -8,6 +8,7 @@ import {
   readFileSync,
   renameSync,
   rmSync,
+  statSync,
   writeFileSync,
 } from 'node:fs';
 import { homedir, platform, tmpdir } from 'node:os';
@@ -41,6 +42,7 @@ export type CompanionUpdateResult =
 
 const DOWNLOAD_TIMEOUT_MS = 30_000;
 const LOCK_TIMEOUT_MS = 2_000;
+const STALE_LOCK_MS = 5 * 60_000;
 const FIRST_METADATA_VERSION = '0.1.2';
 
 export const COMPANION_MANIFEST: CompanionManifest = {
@@ -120,6 +122,7 @@ export async function ensureCompanionVersion(options: {
   dryRun?: boolean;
   downloadTimeoutMs?: number;
   lockTimeoutMs?: number;
+  lockStaleMs?: number;
 }): Promise<CompanionUpdateResult> {
   const { config, dryRun = false } = options;
   const manifest = options.manifest ?? COMPANION_MANIFEST;
@@ -175,6 +178,7 @@ export async function ensureCompanionVersion(options: {
   return withCompanionInstallLock(
     binaryPath,
     options.lockTimeoutMs,
+    options.lockStaleMs,
     async () => {
       const lockedCurrent = readInstallMetadata(binaryPath);
       if (
@@ -374,10 +378,12 @@ function metadataPath(binaryPath: string): string {
 async function withCompanionInstallLock(
   binaryPath: string,
   timeoutMs: number | undefined,
+  staleMs: number | undefined,
   run: () => Promise<CompanionUpdateResult>,
 ): Promise<CompanionUpdateResult> {
   const lock = `${binaryPath}.lock`;
   const deadline = Date.now() + (timeoutMs ?? LOCK_TIMEOUT_MS);
+  const staleAfterMs = staleMs ?? STALE_LOCK_MS;
   mkdirSync(path.dirname(binaryPath), { recursive: true });
   while (Date.now() <= deadline) {
     try {
@@ -392,6 +398,17 @@ async function withCompanionInstallLock(
     } catch (err) {
       const code = (err as NodeJS.ErrnoException).code;
       if (code !== 'EEXIST') throw err;
+      try {
+        const ageMs = Date.now() - statSync(lock).mtimeMs;
+        if (ageMs > staleAfterMs) {
+          rmSync(lock, { recursive: true, force: true });
+          log('[companion] removed stale install lock', lock);
+          continue;
+        }
+      } catch (statErr) {
+        const statCode = (statErr as NodeJS.ErrnoException).code;
+        if (statCode !== 'ENOENT') throw statErr;
+      }
       await delay(25);
     }
   }