Эх сурвалжийг харах

fix: recover from stale companion install locks

Alvin Unreal 1 сар өмнө
parent
commit
ce5a5490a3

+ 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
 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
 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
 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
 Automatic native updates only use release archives listed in the packaged
 companion manifest, and every archive must have a matching SHA256 checksum.
 companion manifest, and every archive must have a matching SHA256 checksum.

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

@@ -4,6 +4,7 @@ import {
   mkdirSync,
   mkdirSync,
   readFileSync,
   readFileSync,
   rmSync,
   rmSync,
+  utimesSync,
   writeFileSync,
   writeFileSync,
 } from 'node:fs';
 } from 'node:fs';
 import * as os from 'node:os';
 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', () => {
   test('loads a companion manifest from an installed package root', () => {
     const packageRoot = path.join(TEST_DIR, 'package');
     const packageRoot = path.join(TEST_DIR, 'package');
     const manifestDir = path.join(packageRoot, 'src', 'companion');
     const manifestDir = path.join(packageRoot, 'src', 'companion');

+ 17 - 0
src/companion/updater.ts

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