Browse Source

fix: handle cache manifest write failures

jelasin 3 months ago
parent
commit
ce4dcf6f51

+ 62 - 1
src/cli/cache.test.ts

@@ -1,6 +1,14 @@
 /// <reference types="bun-types" />
 
-import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+import {
+  afterEach,
+  beforeEach,
+  describe,
+  expect,
+  mock,
+  spyOn,
+  test,
+} from 'bun:test';
 import {
   mkdirSync,
   mkdtempSync,
@@ -112,6 +120,59 @@ describe('warmOpenCodePluginCache', () => {
     rmSync(tmpDir, { recursive: true, force: true });
   });
 
+  test('returns a failed result when cache package.json cannot be written', async () => {
+    const tmpDir = mkdirTemp();
+    const cacheHome = join(tmpDir, 'cache');
+    process.env.XDG_CACHE_HOME = cacheHome;
+
+    const packageRoot = join(
+      tmpDir,
+      'bunx-1000-oh-my-opencode-slim@latest',
+      'node_modules',
+      'oh-my-opencode-slim',
+    );
+    mkdirSync(join(packageRoot, 'dist', 'cli'), { recursive: true });
+    writeFileSync(
+      join(packageRoot, 'package.json'),
+      JSON.stringify({ name: 'oh-my-opencode-slim' }),
+    );
+    process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
+
+    const packageJsonSuffix = join(
+      'oh-my-opencode-slim@latest',
+      'package.json',
+    );
+    const fs = await import('node:fs');
+    const originalWriteFileSync = fs.writeFileSync;
+    const writeSpy = spyOn(fs, 'writeFileSync').mockImplementation(
+      (path, data, options) => {
+        if (String(path).endsWith(packageJsonSuffix)) {
+          throw new Error('disk full');
+        }
+        return originalWriteFileSync(path, data, options);
+      },
+    );
+    try {
+      const { warmOpenCodePluginCache } = await importFreshConfigIo();
+      const result = await warmOpenCodePluginCache();
+
+      expect(result).toEqual({
+        success: false,
+        configPath: join(
+          cacheHome,
+          'opencode',
+          'packages',
+          'oh-my-opencode-slim@latest',
+        ),
+        error: 'Failed to write cache package.json: Error: disk full',
+      });
+      expect(crossSpawnMock).not.toHaveBeenCalled();
+    } finally {
+      writeSpy.mockRestore();
+      rmSync(tmpDir, { recursive: true, force: true });
+    }
+  });
+
   test('skips cache warm-up for local repo installs', async () => {
     const tmpDir = mkdirTemp();
     const packageRoot = join(tmpDir, 'repo');

+ 21 - 13
src/cli/config-io.ts

@@ -172,20 +172,28 @@ export async function warmOpenCodePluginCache(): Promise<ConfigMergeResult | nul
 
   const packageJsonPath = join(cacheDir, 'package.json');
   if (!existsSync(packageJsonPath)) {
-    writeFileSync(
-      packageJsonPath,
-      JSON.stringify(
-        {
-          name: `${PACKAGE_NAME}-cache`,
-          private: true,
-          dependencies: {
-            [PACKAGE_NAME]: 'latest',
+    try {
+      writeFileSync(
+        packageJsonPath,
+        JSON.stringify(
+          {
+            name: `${PACKAGE_NAME}-cache`,
+            private: true,
+            dependencies: {
+              [PACKAGE_NAME]: 'latest',
+            },
           },
-        },
-        null,
-        2,
-      ),
-    );
+          null,
+          2,
+        ),
+      );
+    } catch (err) {
+      return {
+        success: false,
+        configPath: cacheDir,
+        error: `Failed to write cache package.json: ${err}`,
+      };
+    }
   }
 
   try {

+ 1 - 1
src/cli/doctor.test.ts

@@ -484,7 +484,7 @@ describe('doctor CLI wrapper', () => {
       expect(exitCode).toBe(0);
       const parsed = JSON.parse(output);
       expect(parsed.ok).toBe(true);
-      expect(parsed.project).toBe(projectDir);
+      expect(fs.realpathSync(parsed.project)).toBe(fs.realpathSync(projectDir));
       expect(parsed.configs).toHaveLength(2);
       expect(parsed.configs[0].scope).toBe('user');
       expect(parsed.configs[1].scope).toBe('project');

+ 1 - 1
src/hooks/foreground-fallback/index.ts

@@ -15,8 +15,8 @@
  */
 
 import type { PluginInput } from '@opencode-ai/plugin';
-import { abortSessionWithTimeout } from '../../utils/session';
 import { log } from '../../utils/logger';
+import { abortSessionWithTimeout } from '../../utils/session';
 
 type OpencodeClient = PluginInput['client'];
 

+ 22 - 5
src/multiplexer/tmux/index.test.ts

@@ -60,7 +60,8 @@ describe('TmuxMultiplexer', () => {
     logMock.mockClear();
     crossSpawnMock.mockReset();
     crossSpawnMock.mockImplementation((command: string[]) => {
-      if (command[0] === 'which') return createSpawnResult(0, '/usr/bin/tmux\n');
+      if (command[0] === 'which')
+        return createSpawnResult(0, '/usr/bin/tmux\n');
       if (command[1] === '-V') return createSpawnResult(0, 'tmux 3.6a');
       if (command[1] === 'split-window') {
         return createSpawnResult(0, '%2\n');
@@ -78,8 +79,18 @@ describe('TmuxMultiplexer', () => {
     const { TmuxMultiplexer } = await importFreshTmux();
     const tmux = new TmuxMultiplexer('main-vertical', 60);
 
-    await tmux.spawnPane('session-1', 'First worker', 'http://localhost:4096', '/repo');
-    await tmux.spawnPane('session-2', 'Second worker', 'http://localhost:4096', '/repo');
+    await tmux.spawnPane(
+      'session-1',
+      'First worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+    await tmux.spawnPane(
+      'session-2',
+      'Second worker',
+      'http://localhost:4096',
+      '/repo',
+    );
 
     expect(
       commands().filter((command) => command.includes('select-layout')),
@@ -105,7 +116,8 @@ describe('TmuxMultiplexer', () => {
     const tmux = new TmuxMultiplexer('main-vertical', 60);
 
     crossSpawnMock.mockImplementation((command: string[]) => {
-      if (command[0] === 'which') return createSpawnResult(0, '/usr/bin/tmux\n');
+      if (command[0] === 'which')
+        return createSpawnResult(0, '/usr/bin/tmux\n');
       if (command[1] === '-V') return createSpawnResult(0, 'tmux 3.6a');
       if (command.includes('select-layout')) {
         return createSpawnResult(1, '', 'layout failed');
@@ -134,7 +146,12 @@ describe('TmuxMultiplexer', () => {
     const { TmuxMultiplexer } = await importFreshTmux();
     const tmux = new TmuxMultiplexer('main-vertical', 60);
 
-    await tmux.spawnPane('session-1', 'First worker', 'http://localhost:4096', '/repo');
+    await tmux.spawnPane(
+      'session-1',
+      'First worker',
+      'http://localhost:4096',
+      '/repo',
+    );
     await tmux.applyLayout('tiled', 60);
     await wait(300);
 

+ 17 - 21
src/multiplexer/tmux/index.ts

@@ -209,10 +209,11 @@ export class TmuxMultiplexer implements Multiplexer {
 
     try {
       // Apply the layout
-      const layoutResult = await this.runTmux(
-        tmux,
-        ['select-layout', ...this.targetArgs(), layout],
-      );
+      const layoutResult = await this.runTmux(tmux, [
+        'select-layout',
+        ...this.targetArgs(),
+        layout,
+      ]);
       if (layoutResult !== 0) return;
 
       // For main-* layouts, set the main pane size
@@ -220,22 +221,20 @@ export class TmuxMultiplexer implements Multiplexer {
         const sizeOption =
           layout === 'main-horizontal' ? 'main-pane-height' : 'main-pane-width';
 
-        const sizeResult = await this.runTmux(
-          tmux,
-          [
-            'set-window-option',
-            ...this.targetArgs(),
-            sizeOption,
-            `${mainPaneSize}%`,
-          ],
-        );
+        const sizeResult = await this.runTmux(tmux, [
+          'set-window-option',
+          ...this.targetArgs(),
+          sizeOption,
+          `${mainPaneSize}%`,
+        ]);
         if (sizeResult !== 0) return;
 
         // Reapply layout to use the new size
-        const reapplyResult = await this.runTmux(
-          tmux,
-          ['select-layout', ...this.targetArgs(), layout],
-        );
+        const reapplyResult = await this.runTmux(tmux, [
+          'select-layout',
+          ...this.targetArgs(),
+          layout,
+        ]);
         if (reapplyResult !== 0) return;
       }
 
@@ -245,10 +244,7 @@ export class TmuxMultiplexer implements Multiplexer {
     }
   }
 
-  private async runTmux(
-    tmux: string,
-    args: string[],
-  ): Promise<number> {
+  private async runTmux(tmux: string, args: string[]): Promise<number> {
     const proc = crossSpawn([tmux, ...args], {
       stdout: 'pipe',
       stderr: 'pipe',

+ 2 - 10
src/utils/session.test.ts

@@ -34,11 +34,7 @@ describe('session utilities', () => {
     } as any;
 
     await expect(
-      promptWithTimeout(
-        client,
-        { path: { id: 's1' }, body: { parts: [] } },
-        5,
-      ),
+      promptWithTimeout(client, { path: { id: 's1' }, body: { parts: [] } }, 5),
     ).rejects.toThrow('Prompt timed out after 5ms');
 
     expect(abort).toHaveBeenCalledWith({ path: { id: 's1' } });
@@ -57,11 +53,7 @@ describe('session utilities', () => {
     } as any;
 
     await expect(
-      promptWithTimeout(
-        client,
-        { path: { id: 's1' }, body: { parts: [] } },
-        5,
-      ),
+      promptWithTimeout(client, { path: { id: 's1' }, body: { parts: [] } }, 5),
     ).rejects.toThrow('Prompt timed out after 5ms');
   });
 

+ 5 - 7
src/utils/session.ts

@@ -11,14 +11,14 @@ export const SESSION_ABORT_TIMEOUT_MS = 1_000;
 export class OperationTimeoutError extends Error {
   constructor(message: string) {
     super(message);
-    this.name = "OperationTimeoutError";
+    this.name = 'OperationTimeoutError';
   }
 }
 
 export async function withTimeout<T>(
   operation: Promise<T>,
   timeoutMs: number,
-  message: string
+  message: string,
 ): Promise<T> {
   if (timeoutMs <= 0) return operation;
 
@@ -40,12 +40,12 @@ export async function withTimeout<T>(
 export async function abortSessionWithTimeout(
   client: OpencodeClient,
   sessionId: string,
-  timeoutMs = SESSION_ABORT_TIMEOUT_MS
+  timeoutMs = SESSION_ABORT_TIMEOUT_MS,
 ): Promise<void> {
   await withTimeout(
     client.session.abort({ path: { id: sessionId } }),
     timeoutMs,
-    `Session abort timed out after ${timeoutMs}ms`
+    `Session abort timed out after ${timeoutMs}ms`,
   );
 }
 
@@ -116,9 +116,7 @@ export async function promptWithTimeout(
       new Promise<never>((_, reject) => {
         timer = setTimeout(() => {
           reject(
-            new OperationTimeoutError(
-              `Prompt timed out after ${timeoutMs}ms`,
-            ),
+            new OperationTimeoutError(`Prompt timed out after ${timeoutMs}ms`),
           );
         }, timeoutMs);
       }),