[GH-ISSUE #6052] [Bug] Kuna span-close failure loses the read outcome and descriptor ownership #3697

Closed
opened 2026-10-31 00:43:10 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 21, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/6052

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Native analysis or decompilation

Observed behavior

After a warm procedure_assembly read, a controlled close rejection causes the next assembly request to return ProviderAdapterError with the close error as its cause and cleanupIncomplete: false. The result is typed, but it loses the completed text. When the read is also short, the close error replaces that primary diagnostic. Session close reports success without retrying the close, while the exact underlying file handle remains readable.

Reproduction

Save this test as tests/boundary/providers/kuna/kunaSpanCloseOwnership.test.ts in the source checkout with locked dependencies installed, then run:

node node_modules/vitest/vitest.mjs run tests/boundary/providers/kuna/kunaSpanCloseOwnership.test.ts --maxWorkers=1 --reporter=verbose

The test executes the real provider session, cache and reader with the repository's simulated Kuna executable and controlled filesystem faults. It does not exercise a real Kuna installation or external CLI/MCP transport. Assertions describe the observed current behavior.

import { open, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { afterEach, expect, it, vi } from 'vitest';
import { run, session } from './kunaSessionFixture.js';

const fault = vi.hoisted(() => ({
  retire: false, span: '', short: false, reads: 0, closes: 0,
  handles: [] as import('node:fs/promises').FileHandle[],
  cause: Object.assign(new Error('controlled close refusal'), { code: 'EIO' }),
}));
vi.mock('node:fs/promises', async (importOriginal) => {
  const actual = await importOriginal<typeof import('node:fs/promises')>();
  return {
    ...actual,
    rename: async (...args: Parameters<typeof actual.rename>) => {
      if (fault.retire && String(args[0]).includes('/staging/build-') && String(args[1]).includes('/trash/build-'))
        throw Object.assign(new Error('controlled staging retirement refusal'), { code: 'EACCES' });
      return actual.rename(...args);
    },
    open: async (...args: Parameters<typeof actual.open>) => {
      const handle = await actual.open(...args);
      if (String(args[0]) !== fault.span) return handle;
      fault.handles.push(handle);
      return new Proxy(handle, {
        get(target, key) {
          if (key === 'read') return async (...readArgs: unknown[]) => {
            const read = await Reflect.apply(target.read, target, readArgs);
            fault.reads++;
            return fault.short ? { ...read, bytesRead: 0 } : read;
          };
          if (key === 'close') return async () => { fault.closes++; throw fault.cause; };
          const value = Reflect.get(target, key, target);
          return typeof value === 'function' ? value.bind(target) : value;
        },
      });
    },
  };
});
afterEach(async () => {
  fault.retire = false; fault.span = ''; fault.short = false;
  for (const handle of fault.handles) await handle.close();
  fault.handles = []; fault.reads = 0; fault.closes = 0;
});

it.each([false, true])('drops the span read outcome and loses the handle on close refusal (short=%s)', async (short) => {
  const { cacheRoot, open: createClient } = await session('rea-kuna-span-close-');
  const client = createClient();
  try {
    await run(client, 'procedure_assembly', { procedure: 'main' });
    const [key] = await readdir(join(cacheRoot, 'projects'));
    fault.span = join(cacheRoot, 'projects', key!, 'project', 'program.asm');
    fault.short = short;
    const result = await client.execute('procedure_assembly', { procedure: 'main' }, {});
    expect(result.ok).toBe(false);
    if (result.ok) throw new Error('Expected failure');
    expect(result.error.cause).toBe(fault.cause);
    expect(result.error.cleanupIncomplete).toBe(false);
    expect(fault.reads).toBe(1); expect(fault.closes).toBe(1);
    const closed = await client.close();
    expect(closed).toEqual({ ok: true, value: null });
    expect(fault.closes).toBe(1);
    const buffer = Buffer.alloc(4);
    const live = await fault.handles[0]!.read(buffer, 0, 4, 0);
    expect(live.bytesRead).toBe(4);
    console.log(JSON.stringify({ case: 'span-close', short, resultTag: result.error._tag, cause: result.error.cause === fault.cause ? 'close refusal' : 'other', cleanupIncomplete: result.error.cleanupIncomplete, closeReportedSuccess: closed.ok, closeAttempts: fault.closes, stillReadableBytes: live.bytesRead }));
  } finally { fault.span = ''; await client.close(); }
});

Expected behavior

Give descriptor-backed span reads a durable owner before opening. Preserve the completed text or original read failure when close fails, retain the exact handle for retry, and report incomplete cleanup until release is proven. This is the explicit descriptor-backed read contract.

Impact and scope

P2: bounded I/O faults lose the requested observation or its primary read diagnostic and escape cleanup ownership. The controlled descriptor remained readable after successful session close, which proves the cleanup gap for a refusal before release. Native close-error frequency, descriptor exhaustion and real-Kuna impact were not measured. The test manually closes its exact real handles after observing the failure.

REA version

Source revision 52415975942a4b9c53a817915b313ccc404f280e; package version 6.7.0.

Runtime and platform

Node v26.12.0; macOS 27.0.1; arm64. Simulated Kuna executable using the repository's existing fixture; no real Kuna analysis.

Target and artifact context

Synthetic target and generated project assembly from the existing Kuna session fixture. Real descriptor reads run through a proxy that rejects close before releasing the handle; the short-read variant changes only the reported byte count.

Interface

Other

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Both focused variants passed their current-behavior assertions:

{"case":"span-close","short":false,"resultTag":"ProviderAdapterError","cause":"close refusal","cleanupIncomplete":false,"closeReportedSuccess":true,"closeAttempts":1,"stillReadableBytes":4}
{"case":"span-close","short":true,"resultTag":"ProviderAdapterError","cause":"close refusal","cleanupIncomplete":false,"closeReportedSuccess":true,"closeAttempts":1,"stillReadableBytes":4}

These are controlled before-close refusals, not measurements of spontaneous OS close errors.

Typed outcome or recovery expected

Preserve the successful span observation or short-read diagnostic within the typed outcome and bind the close failure to the exact owned handle. Session cleanup should retry that handle and report incomplete cleanup until confirmed release.

Analysis or suspected cause

The raw open/read/finally-close lets the close rejection override both return ok(text) and the short-read exception. No retention callback transfers the handle to session cleanup. The session execute catch wraps the close error, so the observable failure is typed rather than an escaping untyped rejection.

Additional context

Upstream main was fetched and the relevant Kuna source files were unchanged. #4266 and merged PR #4274 repair stable-file hashing and its consumers; this independent span reader still uses raw open and needs its own owner adoption. #4700 and closed PR #4711 cover cancellation reasons; PR #4029 covers shared assembly-index cancellation. Those do not cover rejected span-handle close. No regression range was established.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6052 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Native analysis or decompilation ### Observed behavior After a warm `procedure_assembly` read, a controlled close rejection causes the next assembly request to return `ProviderAdapterError` with the close error as its cause and `cleanupIncomplete: false`. The result is typed, but it loses the completed text. When the read is also short, the close error replaces that primary diagnostic. Session close reports success without retrying the close, while the exact underlying file handle remains readable. ### Reproduction Save this test as `tests/boundary/providers/kuna/kunaSpanCloseOwnership.test.ts` in the source checkout with locked dependencies installed, then run: ```sh node node_modules/vitest/vitest.mjs run tests/boundary/providers/kuna/kunaSpanCloseOwnership.test.ts --maxWorkers=1 --reporter=verbose ``` The test executes the real provider session, cache and reader with the repository's simulated Kuna executable and controlled filesystem faults. It does not exercise a real Kuna installation or external CLI/MCP transport. Assertions describe the observed current behavior. ```ts import { open, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { afterEach, expect, it, vi } from 'vitest'; import { run, session } from './kunaSessionFixture.js'; const fault = vi.hoisted(() => ({ retire: false, span: '', short: false, reads: 0, closes: 0, handles: [] as import('node:fs/promises').FileHandle[], cause: Object.assign(new Error('controlled close refusal'), { code: 'EIO' }), })); vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal<typeof import('node:fs/promises')>(); return { ...actual, rename: async (...args: Parameters<typeof actual.rename>) => { if (fault.retire && String(args[0]).includes('/staging/build-') && String(args[1]).includes('/trash/build-')) throw Object.assign(new Error('controlled staging retirement refusal'), { code: 'EACCES' }); return actual.rename(...args); }, open: async (...args: Parameters<typeof actual.open>) => { const handle = await actual.open(...args); if (String(args[0]) !== fault.span) return handle; fault.handles.push(handle); return new Proxy(handle, { get(target, key) { if (key === 'read') return async (...readArgs: unknown[]) => { const read = await Reflect.apply(target.read, target, readArgs); fault.reads++; return fault.short ? { ...read, bytesRead: 0 } : read; }; if (key === 'close') return async () => { fault.closes++; throw fault.cause; }; const value = Reflect.get(target, key, target); return typeof value === 'function' ? value.bind(target) : value; }, }); }, }; }); afterEach(async () => { fault.retire = false; fault.span = ''; fault.short = false; for (const handle of fault.handles) await handle.close(); fault.handles = []; fault.reads = 0; fault.closes = 0; }); it.each([false, true])('drops the span read outcome and loses the handle on close refusal (short=%s)', async (short) => { const { cacheRoot, open: createClient } = await session('rea-kuna-span-close-'); const client = createClient(); try { await run(client, 'procedure_assembly', { procedure: 'main' }); const [key] = await readdir(join(cacheRoot, 'projects')); fault.span = join(cacheRoot, 'projects', key!, 'project', 'program.asm'); fault.short = short; const result = await client.execute('procedure_assembly', { procedure: 'main' }, {}); expect(result.ok).toBe(false); if (result.ok) throw new Error('Expected failure'); expect(result.error.cause).toBe(fault.cause); expect(result.error.cleanupIncomplete).toBe(false); expect(fault.reads).toBe(1); expect(fault.closes).toBe(1); const closed = await client.close(); expect(closed).toEqual({ ok: true, value: null }); expect(fault.closes).toBe(1); const buffer = Buffer.alloc(4); const live = await fault.handles[0]!.read(buffer, 0, 4, 0); expect(live.bytesRead).toBe(4); console.log(JSON.stringify({ case: 'span-close', short, resultTag: result.error._tag, cause: result.error.cause === fault.cause ? 'close refusal' : 'other', cleanupIncomplete: result.error.cleanupIncomplete, closeReportedSuccess: closed.ok, closeAttempts: fault.closes, stillReadableBytes: live.bytesRead })); } finally { fault.span = ''; await client.close(); } }); ``` ### Expected behavior Give descriptor-backed span reads a durable owner before opening. Preserve the completed text or original read failure when close fails, retain the exact handle for retry, and report incomplete cleanup until release is proven. This is the explicit [descriptor-backed read contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L137-L146). ### Impact and scope P2: bounded I/O faults lose the requested observation or its primary read diagnostic and escape cleanup ownership. The controlled descriptor remained readable after successful session close, which proves the cleanup gap for a refusal before release. Native close-error frequency, descriptor exhaustion and real-Kuna impact were not measured. The test manually closes its exact real handles after observing the failure. ### REA version Source revision `52415975942a4b9c53a817915b313ccc404f280e`; package version 6.7.0. ### Runtime and platform Node v26.12.0; macOS 27.0.1; arm64. Simulated Kuna executable using the repository's existing fixture; no real Kuna analysis. ### Target and artifact context Synthetic target and generated project assembly from the existing Kuna session fixture. Real descriptor reads run through a proxy that rejects close before releasing the handle; the short-read variant changes only the reported byte count. ### Interface Other ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Both focused variants passed their current-behavior assertions: ```json {"case":"span-close","short":false,"resultTag":"ProviderAdapterError","cause":"close refusal","cleanupIncomplete":false,"closeReportedSuccess":true,"closeAttempts":1,"stillReadableBytes":4} {"case":"span-close","short":true,"resultTag":"ProviderAdapterError","cause":"close refusal","cleanupIncomplete":false,"closeReportedSuccess":true,"closeAttempts":1,"stillReadableBytes":4} ``` These are controlled before-close refusals, not measurements of spontaneous OS close errors. ### Typed outcome or recovery expected Preserve the successful span observation or short-read diagnostic within the typed outcome and bind the close failure to the exact owned handle. Session cleanup should retry that handle and report incomplete cleanup until confirmed release. ### Analysis or suspected cause The [raw open/read/finally-close](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/kuna/KunaProjectReader.ts#L57-L66) lets the close rejection override both `return ok(text)` and the short-read exception. No retention callback transfers the handle to session cleanup. The [session execute catch](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/kuna/KunaSessionClient.ts#L420-L424) wraps the close error, so the observable failure is typed rather than an escaping untyped rejection. ### Additional context Upstream main was fetched and the relevant Kuna source files were unchanged. #4266 and merged PR #4274 repair stable-file hashing and its consumers; this independent span reader still uses raw open and needs its own owner adoption. #4700 and closed PR #4711 cover cancellation reasons; PR #4029 covers shared assembly-index cancellation. Those do not cover rejected span-handle close. No regression range was established.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3697
No description provided.