[GH-ISSUE #6070] [Bug] environment-serve cleanup rejection replaces its primary result with UNKNOWN #3711

Closed
opened 2026-10-31 00:43:16 +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/6070

Before submitting

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

Area

CLI

Observed behavior

When final shutdown rejects, environment-serve replaces its pending listener failure or stop summary with {"code":"UNKNOWN","message":"controlled provider cleanup failure"}. The post-bind listener diagnostic, endpoint and recovery action disappear. On SIGINT the URL, environment, tool count and Evidence identifiers disappear.

This was executed through the source CLI with a real loopback environment listener and real staged shutdown. The MCP server close stage was made to reject once; the listener-error event was simulated through its production callback. No naturally occurring OS failure is claimed.

Reproduction

With locked dependencies and existing runtime artifacts prepared, save the following as tests/process-global/environmentCleanupRepro.test.ts. Set ARTIFACT_DIR to a disposable writable directory outside the checkout, run node node_modules/vitest/vitest.mjs run tests/process-global/environmentCleanupRepro.test.ts --maxWorkers 1, then remove the test and generated fixture files. The assertions characterize current behavior. The cleanup finalizer retries the retained real server owner.

import { mkdir } from 'node:fs/promises';
import { expect, it, vi } from 'vitest';
import { createCli } from '../../src/cli.js';
import { writeEnvironmentServeFiles } from '../fixtures/environmentServe.js';

const state = vi.hoisted(() => ({ ready: undefined as undefined | (() => void), served: undefined as any, options: undefined as any, failClose: false, closeCalls: 0 }));
vi.mock('../../src/server/http/serveStreamableHttp.js', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../../src/server/http/serveStreamableHttp.js')>();
  return { ...actual, serveStreamableHttp: async (options: any) => {
    state.options = options;
    const served = await actual.serveStreamableHttp({ ...options, createMcpServer: async (context: any) => {
      const server = await options.createMcpServer(context);
      const original = server.close.bind(server);
      server.close = async () => {
        state.closeCalls++;
        if (state.failClose) { state.failClose = false; throw new Error('controlled provider cleanup failure'); }
        await original();
      };
      return server;
    }});
    state.served = served;
    state.ready?.();
    return served;
  }};
});

it.each(['signal', 'listener'] as const)('environment %s result with successful/failing cleanup', async (ending) => {
  const root = process.env.ARTIFACT_DIR!;
  await mkdir(root, { recursive: true });
  const files = await writeEnvironmentServeFiles(root);
  for (const fails of [false, true]) {
    const savedExitCode = process.exitCode;
    state.failClose = fails; state.closeCalls = 0;
    let ready!: () => void;
    const started = new Promise<void>(resolve => { ready = resolve; });
    state.ready = ready;
    let stdout = '';
    let thrown: unknown;
    const pending = createCli({ REA_LOG_LEVEL: 'silent' }).serve([
      'environment-serve', files.manifestPath, '--evidence', files.capturePath, '--json',
    ], { env: {}, exit: () => {}, stdout: (s) => { stdout += s; } }).catch(e => { thrown = e; });
    try {
      await started;
      if (ending === 'signal') process.emit('SIGINT');
      else {
        const { StreamableHttpListenError } = await import('../../src/server/http/streamableHttpListenError.js');
        state.options.onerror(new StreamableHttpListenError(Object.assign(new Error('controlled listener failure'), {code:'EIO'}), 'serving'));
      }
      await pending;
      const output = stdout === '' ? undefined : JSON.parse(stdout);
      console.log('ENVIRONMENT', JSON.stringify({ ending, fails, output, thrown: thrown instanceof Error ? thrown.message : thrown, processExitCode: process.exitCode, closeCalls: state.closeCalls }));
      if (!fails) {
        expect(thrown).toBeUndefined();
        expect(output).toMatchObject(ending === 'signal' ? {status:'stopped'} : {code:'resource_constraint',details:{diagnostics:{listener_phase:'serving'}}});
      } else {
        expect(thrown).toBeUndefined();
        expect(output).toEqual({code:'UNKNOWN',message:'controlled provider cleanup failure'});
        expect(output.status).toBeUndefined();
        expect(JSON.stringify(output)).not.toContain('controlled listener failure');
      }
    } finally {
      await state.served?.close();
      state.ready = undefined;
      process.exitCode = savedExitCode;
    }
  }
});

Expected behavior

Retain the primary listener error, or the completed stop facts, together with a typed cleanup failure and remaining-resource/recovery information. Do not imply that unresolved cleanup succeeded. The CLI contract specifies stopped output after a handled signal and a structured listener failure after binding. The lifecycle contract requires cleanup to preserve the completed result or primary diagnostic.

Impact and scope

P2: a bounded finalization failure loses useful lifecycle facts and, for a post-bind failure, the primary reason and recovery advice. The cleanup exception still appears, so this is not silent success. The same fixture with successful cleanup returns the documented stopped or resource_constraint result. Natural failure frequency and platform-specific causes were not measured; no user data loss or leaked native resource is established. No public CLI recovery that restores the discarded primary result was demonstrated.

REA version

rea-agents 6.7.0; source revision 52415975942a4b9c53a817915b313ccc404f280e.

Runtime and platform

macOS 27.0.1 arm64; Node.js v24.22.0; locked Vitest 5.0.3. The exact repository-pinned Node 24.18.0 was not exercised. Source CLI execution, not a separately spawned packaged executable.

Target and artifact context

Synthetic matching environment manifest/capture Evidence generated by the existing fixture; local loopback HTTP server; no target process or external service.

Interface

CLI

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

The four executed environment cases produced these reduced observations:

{"ending":"signal","close_fails":false,"status":"stopped","process_exit_code":130}
{"ending":"signal","close_fails":true,"output":{"code":"UNKNOWN","message":"controlled provider cleanup failure"},"process_exit_code":130}
{"ending":"listener","close_fails":false,"code":"resource_constraint","listener_phase":"serving","system_code":"EIO","process_exit_code":1}
{"ending":"listener","close_fails":true,"output":{"code":"UNKNOWN","message":"controlled provider cleanup failure"},"process_exit_code":1}

Each command attempted the failing stage once. The same retained close owner successfully retried after the one-shot injected rejection. The final focused run passed seven tests, including the two parameterized environment tests; the other five checked separate Frida behavior.

Typed outcome or recovery expected

For a listener failure, retain resource_constraint and its endpoint/diagnostics alongside cleanup details. For signal shutdown with failed cleanup, expose a typed cleanup failure retaining the stop observation, rather than replacing it with UNKNOWN. Preserve the handled signal status and do not falsely claim unresolved resources were released.

Analysis or suspected cause

The command finally awaits close before either return and does not compose a rejection with the pending outcome. The HTTP close owner deliberately rejects and retains failed stages for retry. The logging boundary rethrows generic errors, which the outer CLI converts to UNKNOWN.

Additional context

Fetched upstream main 8e3bf56e11bcb56d0e8754fdfa32a7a9f004ca13; the cited implementation and lifecycle contract are unchanged. Current-main applicability is source-checked, not separately executed. Related host-remediation report #5455 and state-counter report #5974 concern different branches. PR #5352 changes environment-state/catalog behavior, and the CLI patch in #5351 retains this bare finally. NOT_ENOUGH_INFO: first affected release and natural failure frequency.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6070 ### 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 CLI ### Observed behavior When final shutdown rejects, `environment-serve` replaces its pending listener failure or stop summary with `{"code":"UNKNOWN","message":"controlled provider cleanup failure"}`. The post-bind listener diagnostic, endpoint and recovery action disappear. On SIGINT the URL, environment, tool count and Evidence identifiers disappear. This was executed through the source CLI with a real loopback environment listener and real staged shutdown. The MCP server close stage was made to reject once; the listener-error event was simulated through its production callback. No naturally occurring OS failure is claimed. ### Reproduction With locked dependencies and existing runtime artifacts prepared, save the following as `tests/process-global/environmentCleanupRepro.test.ts`. Set `ARTIFACT_DIR` to a disposable writable directory outside the checkout, run `node node_modules/vitest/vitest.mjs run tests/process-global/environmentCleanupRepro.test.ts --maxWorkers 1`, then remove the test and generated fixture files. The assertions characterize current behavior. The cleanup finalizer retries the retained real server owner. ```ts import { mkdir } from 'node:fs/promises'; import { expect, it, vi } from 'vitest'; import { createCli } from '../../src/cli.js'; import { writeEnvironmentServeFiles } from '../fixtures/environmentServe.js'; const state = vi.hoisted(() => ({ ready: undefined as undefined | (() => void), served: undefined as any, options: undefined as any, failClose: false, closeCalls: 0 })); vi.mock('../../src/server/http/serveStreamableHttp.js', async (importOriginal) => { const actual = await importOriginal<typeof import('../../src/server/http/serveStreamableHttp.js')>(); return { ...actual, serveStreamableHttp: async (options: any) => { state.options = options; const served = await actual.serveStreamableHttp({ ...options, createMcpServer: async (context: any) => { const server = await options.createMcpServer(context); const original = server.close.bind(server); server.close = async () => { state.closeCalls++; if (state.failClose) { state.failClose = false; throw new Error('controlled provider cleanup failure'); } await original(); }; return server; }}); state.served = served; state.ready?.(); return served; }}; }); it.each(['signal', 'listener'] as const)('environment %s result with successful/failing cleanup', async (ending) => { const root = process.env.ARTIFACT_DIR!; await mkdir(root, { recursive: true }); const files = await writeEnvironmentServeFiles(root); for (const fails of [false, true]) { const savedExitCode = process.exitCode; state.failClose = fails; state.closeCalls = 0; let ready!: () => void; const started = new Promise<void>(resolve => { ready = resolve; }); state.ready = ready; let stdout = ''; let thrown: unknown; const pending = createCli({ REA_LOG_LEVEL: 'silent' }).serve([ 'environment-serve', files.manifestPath, '--evidence', files.capturePath, '--json', ], { env: {}, exit: () => {}, stdout: (s) => { stdout += s; } }).catch(e => { thrown = e; }); try { await started; if (ending === 'signal') process.emit('SIGINT'); else { const { StreamableHttpListenError } = await import('../../src/server/http/streamableHttpListenError.js'); state.options.onerror(new StreamableHttpListenError(Object.assign(new Error('controlled listener failure'), {code:'EIO'}), 'serving')); } await pending; const output = stdout === '' ? undefined : JSON.parse(stdout); console.log('ENVIRONMENT', JSON.stringify({ ending, fails, output, thrown: thrown instanceof Error ? thrown.message : thrown, processExitCode: process.exitCode, closeCalls: state.closeCalls })); if (!fails) { expect(thrown).toBeUndefined(); expect(output).toMatchObject(ending === 'signal' ? {status:'stopped'} : {code:'resource_constraint',details:{diagnostics:{listener_phase:'serving'}}}); } else { expect(thrown).toBeUndefined(); expect(output).toEqual({code:'UNKNOWN',message:'controlled provider cleanup failure'}); expect(output.status).toBeUndefined(); expect(JSON.stringify(output)).not.toContain('controlled listener failure'); } } finally { await state.served?.close(); state.ready = undefined; process.exitCode = savedExitCode; } } }); ``` ### Expected behavior Retain the primary listener error, or the completed stop facts, together with a typed cleanup failure and remaining-resource/recovery information. Do not imply that unresolved cleanup succeeded. The [CLI contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/cli.md#L486-L493) specifies stopped output after a handled signal and a structured listener failure after binding. The [lifecycle contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L132-L136) requires cleanup to preserve the completed result or primary diagnostic. ### Impact and scope P2: a bounded finalization failure loses useful lifecycle facts and, for a post-bind failure, the primary reason and recovery advice. The cleanup exception still appears, so this is not silent success. The same fixture with successful cleanup returns the documented stopped or resource_constraint result. Natural failure frequency and platform-specific causes were not measured; no user data loss or leaked native resource is established. No public CLI recovery that restores the discarded primary result was demonstrated. ### REA version rea-agents 6.7.0; source revision `52415975942a4b9c53a817915b313ccc404f280e`. ### Runtime and platform macOS 27.0.1 arm64; Node.js v24.22.0; locked Vitest 5.0.3. The exact repository-pinned Node 24.18.0 was not exercised. Source CLI execution, not a separately spawned packaged executable. ### Target and artifact context Synthetic matching environment manifest/capture Evidence generated by the existing fixture; local loopback HTTP server; no target process or external service. ### Interface CLI ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence The four executed environment cases produced these reduced observations: ```jsonl {"ending":"signal","close_fails":false,"status":"stopped","process_exit_code":130} {"ending":"signal","close_fails":true,"output":{"code":"UNKNOWN","message":"controlled provider cleanup failure"},"process_exit_code":130} {"ending":"listener","close_fails":false,"code":"resource_constraint","listener_phase":"serving","system_code":"EIO","process_exit_code":1} {"ending":"listener","close_fails":true,"output":{"code":"UNKNOWN","message":"controlled provider cleanup failure"},"process_exit_code":1} ``` Each command attempted the failing stage once. The same retained close owner successfully retried after the one-shot injected rejection. The final focused run passed seven tests, including the two parameterized environment tests; the other five checked separate Frida behavior. ### Typed outcome or recovery expected For a listener failure, retain resource_constraint and its endpoint/diagnostics alongside cleanup details. For signal shutdown with failed cleanup, expose a typed cleanup failure retaining the stop observation, rather than replacing it with UNKNOWN. Preserve the handled signal status and do not falsely claim unresolved resources were released. ### Analysis or suspected cause The [command finally](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/cli/environmentCommands.ts#L174-L189) awaits close before either return and does not compose a rejection with the pending outcome. The [HTTP close owner](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/server/http/serveStreamableHttp.ts#L694-L713) deliberately rejects and retains failed stages for retry. The [logging boundary](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/cliLogging.ts#L130-L147) rethrows generic errors, which the outer CLI converts to UNKNOWN. ### Additional context Fetched upstream main `8e3bf56e11bcb56d0e8754fdfa32a7a9f004ca13`; the cited implementation and lifecycle contract are unchanged. Current-main applicability is source-checked, not separately executed. Related host-remediation report #5455 and state-counter report #5974 concern different branches. PR #5352 changes environment-state/catalog behavior, and the CLI patch in #5351 retains this bare finally. NOT_ENOUGH_INFO: first affected release and natural failure frequency.
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#3711
No description provided.