[GH-ISSUE #4471] [Bug] Cutter discovery can block indefinitely opening a named-pipe descriptor #1772

Closed
opened 2026-10-16 21:10:06 +00:00 by bsakaguchi · 5 comments
Owner

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

Before submitting

  • I searched open/closed issues and open PRs for related Cutter descriptor/FIFO reports.
  • No credentials or private target data are included.

Area

Process capture or runtime observation

Observed behavior

Source-confirmed finding, not a runtime reproduction: POSIX Cutter descriptor discovery opens every matching filename with blocking O_RDONLY | O_NOFOLLOW, then checks info.isFile(). A FIFO without a writer never reaches that check. Discovery, command selection and completion lookup can remain pending before the bounded socket request starts.

Reproduction

No REA runtime reproduction was performed. Inspected current source fe2a398dd9ab290ba6cb3411403d319cec8d07a2; Cutter source is unchanged in the local working diff.

  • src/cutter/CutterBridgeClient.ts: #readDescriptors enumerates filenames matching cutter-<pid>-<uuid>.json without using directory-entry type as admission proof.
  • Its POSIX branch calls open(..., O_RDONLY | O_NOFOLLOW) before file.stat() and the regular-file check.
  • listSessions and #find await descriptor loading before socket requests. execute checks the accepted signal before awaiting #find, which does not receive that signal.
  • src/filesystem/RegularFile.ts already owns nonblocking regular-file admission using O_NONBLOCK | O_NOCTTY, post-open descriptor validation and failed-admission cleanup ownership.

Proposed check, not executed: create a mode0700 disposable bridge directory containing a FIFO named cutter-1-00000000-0000-4000-8000-000000000001.json, with no writer. Run discovery in an owned subprocess. It should reject/skip the nonregular candidate without hanging, while an authenticated regular descriptor control remains discoverable. Include cancellation and cleanup retry at the admission boundary.

Expected behavior

Admit only regular descriptor files without an unbounded pipe open. Use the canonical nonblocking admission/ownership primitive, retain symlink/UID/mode/byte-limit checks, and preserve a failed descriptor close for retry. Do not authorize cleanup or target execution from descriptor filenames alone.

Impact and scope

POSIX source path only; Windows uses its separate native descriptor reader. The private-directory owner check limits who can place the FIFO but does not establish each candidate's type or prevent the owner's accidental FIFO. No cross-user exploit, actual stuck process, frequency or native Cutter compatibility claim is made.

Related #3946 concerns cancellation discarded for active/queued socket probes; this finding precedes socket probing and would remain after merely passing the signal to #request. Existing #3887 has the same admission pattern in firmware, a separate owner.

REA version

6.5.0 source fe2a398dd9.

Runtime and platform

Source inspection on macOS arm64. POSIX FIFO open semantics; no runtime/real Cutter reproduction performed.

Interface

Shared Cutter client/service used by CLI and MCP; reachability is source-derived.

Typed outcome or recovery expected

Discovery settles with truthful usable sessions/invalid descriptor handling; cancellation settles before new socket probes. Admission and close failures retain exact ownership and meaningful reasons, rather than an indefinitely pending call or silently discarded cleanup failure.

Originally created by @morluto on GitHub (Oct 16, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/4471 ### Before submitting - [x] I searched open/closed issues and open PRs for related Cutter descriptor/FIFO reports. - [x] No credentials or private target data are included. ### Area Process capture or runtime observation ### Observed behavior Source-confirmed finding, not a runtime reproduction: POSIX Cutter descriptor discovery opens every matching filename with blocking `O_RDONLY | O_NOFOLLOW`, then checks `info.isFile()`. A FIFO without a writer never reaches that check. Discovery, command selection and completion lookup can remain pending before the bounded socket request starts. ### Reproduction No REA runtime reproduction was performed. Inspected current source `fe2a398dd9ab290ba6cb3411403d319cec8d07a2`; Cutter source is unchanged in the local working diff. - `src/cutter/CutterBridgeClient.ts`: `#readDescriptors` enumerates filenames matching `cutter-<pid>-<uuid>.json` without using directory-entry type as admission proof. - Its POSIX branch calls `open(..., O_RDONLY | O_NOFOLLOW)` before `file.stat()` and the regular-file check. - `listSessions` and `#find` await descriptor loading before socket requests. `execute` checks the accepted signal before awaiting `#find`, which does not receive that signal. - `src/filesystem/RegularFile.ts` already owns nonblocking regular-file admission using `O_NONBLOCK | O_NOCTTY`, post-open descriptor validation and failed-admission cleanup ownership. Proposed check, not executed: create a mode0700 disposable bridge directory containing a FIFO named `cutter-1-00000000-0000-4000-8000-000000000001.json`, with no writer. Run discovery in an owned subprocess. It should reject/skip the nonregular candidate without hanging, while an authenticated regular descriptor control remains discoverable. Include cancellation and cleanup retry at the admission boundary. ### Expected behavior Admit only regular descriptor files without an unbounded pipe open. Use the canonical nonblocking admission/ownership primitive, retain symlink/UID/mode/byte-limit checks, and preserve a failed descriptor close for retry. Do not authorize cleanup or target execution from descriptor filenames alone. ### Impact and scope POSIX source path only; Windows uses its separate native descriptor reader. The private-directory owner check limits who can place the FIFO but does not establish each candidate's type or prevent the owner's accidental FIFO. No cross-user exploit, actual stuck process, frequency or native Cutter compatibility claim is made. Related #3946 concerns cancellation discarded for active/queued socket probes; this finding precedes socket probing and would remain after merely passing the signal to `#request`. Existing #3887 has the same admission pattern in firmware, a separate owner. ### REA version 6.5.0 source fe2a398dd9ab290ba6cb3411403d319cec8d07a2. ### Runtime and platform Source inspection on macOS arm64. POSIX FIFO open semantics; no runtime/real Cutter reproduction performed. ### Interface Shared Cutter client/service used by CLI and MCP; reachability is source-derived. ### Typed outcome or recovery expected Discovery settles with truthful usable sessions/invalid descriptor handling; cancellation settles before new socket probes. Admission and close failures retain exact ownership and meaningful reasons, rather than an indefinitely pending call or silently discarded cleanup failure.
Author
Owner

@qorvr commented on GitHub (Oct 16, 2026):

I've investigated and implemented a fix for this on branch fix/cutter-discovery-nonblock (commit cf56e66b7):

Root Causes Addressed

  1. Blocking POSIX open: In CutterBridgeClient.#readDescriptors, candidate files were opened with blocking O_RDONLY | O_NOFOLLOW. A FIFO candidate without a writer blocks open() indefinitely before reaching the info.isFile() check.
  2. Discarded descriptor cleanup: Descriptors were closed in finally with .catch(() => undefined) without tracking failed closes or retaining ownership when admission failed.
  3. Missing cancellation propagation: execute checked input.signal before calling #find, but #find, listSessions, listDescriptors, and #readDescriptors did not accept or propagate signal.

Changes Implemented

  1. Canonical Nonblocking File Admission:
    • Switched to openRegularFile(join(this.#directory, entry), { symlinks: "reject", signal }) from src/filesystem/RegularFile.ts, opening with O_RDONLY | O_NONBLOCK | O_NOCTTY | O_NOFOLLOW.
    • FIFOs and other non-regular file types fail admission immediately without blocking, throw NonRegularFileReadError, and are safely skipped from discovery.
  2. Cancellation Propagation:
    • Passed signal?: AbortSignal through listSessions(), listDescriptors(), #find(), #readDescriptors(), mapDiscovery(), and CutterBridgeService.listSessions(). Pre-aborted or cancelled operations terminate discovery promptly before initiating socket requests.
  3. Retained Descriptor Ownership and Cleanup Retry:
    • Tracked descriptor handles via OwnedFileHandle in #descriptorOwners.
    • Captured RegularFileAdmissionFailure owners and unclosed descriptor handles in #descriptorOwners.
    • Implemented retryCleanup() on CutterBridgeClient and wired it into CutterBridgeService.retryCleanup() so unclosed handles gate command execution until successfully closed.
  4. Regression Tests Added:
    • tests/boundary/providers/CutterBridgeClient.test.ts:
      • Verified discovery on a directory containing a real FIFO named cutter-1-00000000-0000-4000-8000-000000000001.json (no writer) alongside an authenticated regular descriptor. Discovery completes non-blocking and successfully discovers the regular descriptor.
      • Verified descriptor discovery cancellation terminates promptly before socket requests when signal is aborted.
      • Verified that failed descriptor closes are retained and released on retryCleanup().
    • tests/boundary/providers/CutterBridgeService.test.ts:
      • Verified that client descriptor cleanup failures propagate through service.retryCleanup() and gate command execution.

Ran npm run check:fast (0 errors) and all 42 tests in CutterBridgeClient.test.ts and CutterBridgeService.test.ts pass cleanly. Ready for PR once open contributor PR quota allows.

<!-- gh-comment-id:6216380177 --> @qorvr commented on GitHub (Oct 16, 2026): I've investigated and implemented a fix for this on branch [`fix/cutter-discovery-nonblock`](https://github.com/qorvr/rea/tree/fix/cutter-discovery-nonblock) (commit `cf56e66b7`): ### Root Causes Addressed 1. **Blocking POSIX open**: In `CutterBridgeClient.#readDescriptors`, candidate files were opened with blocking `O_RDONLY | O_NOFOLLOW`. A FIFO candidate without a writer blocks `open()` indefinitely before reaching the `info.isFile()` check. 2. **Discarded descriptor cleanup**: Descriptors were closed in `finally` with `.catch(() => undefined)` without tracking failed closes or retaining ownership when admission failed. 3. **Missing cancellation propagation**: `execute` checked `input.signal` before calling `#find`, but `#find`, `listSessions`, `listDescriptors`, and `#readDescriptors` did not accept or propagate `signal`. ### Changes Implemented 1. **Canonical Nonblocking File Admission**: - Switched to `openRegularFile(join(this.#directory, entry), { symlinks: "reject", signal })` from `src/filesystem/RegularFile.ts`, opening with `O_RDONLY | O_NONBLOCK | O_NOCTTY | O_NOFOLLOW`. - FIFOs and other non-regular file types fail admission immediately without blocking, throw `NonRegularFileReadError`, and are safely skipped from discovery. 2. **Cancellation Propagation**: - Passed `signal?: AbortSignal` through `listSessions()`, `listDescriptors()`, `#find()`, `#readDescriptors()`, `mapDiscovery()`, and `CutterBridgeService.listSessions()`. Pre-aborted or cancelled operations terminate discovery promptly before initiating socket requests. 3. **Retained Descriptor Ownership and Cleanup Retry**: - Tracked descriptor handles via `OwnedFileHandle` in `#descriptorOwners`. - Captured `RegularFileAdmissionFailure` owners and unclosed descriptor handles in `#descriptorOwners`. - Implemented `retryCleanup()` on `CutterBridgeClient` and wired it into `CutterBridgeService.retryCleanup()` so unclosed handles gate command execution until successfully closed. 4. **Regression Tests Added**: - `tests/boundary/providers/CutterBridgeClient.test.ts`: - Verified discovery on a directory containing a real FIFO named `cutter-1-00000000-0000-4000-8000-000000000001.json` (no writer) alongside an authenticated regular descriptor. Discovery completes non-blocking and successfully discovers the regular descriptor. - Verified descriptor discovery cancellation terminates promptly before socket requests when `signal` is aborted. - Verified that failed descriptor closes are retained and released on `retryCleanup()`. - `tests/boundary/providers/CutterBridgeService.test.ts`: - Verified that client descriptor cleanup failures propagate through `service.retryCleanup()` and gate command execution. Ran `npm run check:fast` (0 errors) and all 42 tests in `CutterBridgeClient.test.ts` and `CutterBridgeService.test.ts` pass cleanly. Ready for PR once open contributor PR quota allows.
Author
Owner

@matbrgz commented on GitHub (Oct 16, 2026):

Cross-referencing with sibling reports #3887 ([Bug] Named-pipe firmware input blocks cancellation and the operation deadline) and #3946 ([Bug] Cutter session discovery keeps probing after MCP cancellation). All three address blocking I/O and lack of cancellation responsiveness on named-pipe descriptors in Cutter/firmware boundaries.

Consolidated under Cluster W (Cutter / Named-Pipe Blocking & Probing) in tracker #4330.

<!-- gh-comment-id:6219893835 --> @matbrgz commented on GitHub (Oct 16, 2026): Cross-referencing with sibling reports #3887 (`[Bug] Named-pipe firmware input blocks cancellation and the operation deadline`) and #3946 (`[Bug] Cutter session discovery keeps probing after MCP cancellation`). All three address blocking I/O and lack of cancellation responsiveness on named-pipe descriptors in Cutter/firmware boundaries. Consolidated under **Cluster W (Cutter / Named-Pipe Blocking & Probing)** in tracker #4330.
Author
Owner

@DeryFerd commented on GitHub (Oct 16, 2026):

closing my PR: https://github.com/morluto/rea/pull/4486 bcs it's already fixed

<!-- gh-comment-id:6220675708 --> @DeryFerd commented on GitHub (Oct 16, 2026): closing my PR: https://github.com/morluto/rea/pull/4486 bcs it's already fixed
Author
Owner

@CassieFox23 commented on GitHub (Oct 18, 2026):

TL;DR: This does not reproduce on main 8f7d12e30 (#4962) on Linux. A named pipe with no writer in the bridge folder is now skipped, so discovery returns at once. Commit 2b7b698db fixed the hang, and this issue is still open.

I ran the issue's proposed check. A private folder held a named pipe with no writer, named like a bridge descriptor. I ran the CLI command list-cutter-sessions --json under a 30 second timeout.

Results

Check Node 22.23.1 Node 24.18.0 Node 26.11.1
main 8f7d12e30, named pipe in the folder no sessions, exit 0 no sessions, exit 0 no sessions, exit 0
ce0d6790e (parent of 2b7b698db), same check hangs, killed at 35 s hangs, killed at 35 s hangs, killed at 35 s
main, cutterDescriptorLifetime.test.ts, 9 tests 9 pass 9 pass 9 pass

An empty folder on the parent returned in under a second, so the pipe causes the hang. The three other test files 2b7b698db touched also pass on main (49 tests, Node 24).

Commit 2b7b698db says "Refs #4471, #3946", so GitHub did not close this issue. It swaps the parent's blocking open for withArtifactRegularFile. It also adds the named-pipe test.

Not run

  • macOS and Windows.
  • A real Cutter session.
  • The test file on the parent. I used the direct command there.
  • The fork branch qorvr mentioned, and closed PR #4486.
  • Command selection and completion lookup by hand. Only the repo's tests cover them.

Thanks, morluto, for the source trace and the exact check. Thanks, DeryFerd, for PR #4486.

Commands
git archive 8f7d12e30d63f3d3b4cfe2a90336362841438f56 | tar -x -C /tmp/i4471/src
git archive ce0d6790ed2ac594dadba77819727c92037c5927 | tar -x -C /tmp/i4471/ctl
npm ci --ignore-scripts --prefix /tmp/i4471/src
npm run build:cached --prefix /tmp/i4471/src

mkdir -m 0700 /tmp/i4471/fifo
mkfifo /tmp/i4471/fifo/cutter-1-00000000-0000-4000-8000-000000000001.json
env REA_CUTTER_BRIDGE_DIR=/tmp/i4471/fifo timeout -k 5 30 \
  env -C /tmp/i4471/src node scripts/rea.mjs list-cutter-sessions --json

npm run test:focused --prefix /tmp/i4471/src -- tests/process-global/cutterDescriptorLifetime.test.ts

The parent tree got the same npm ci and build:cached in /tmp/i4471/ctl, then the same mkdir, mkfifo and timeout lines. Exit codes: 0 on main for all three Nodes. 137 on the parent for all three, which is timeout sending KILL after 30 s plus 5 s. The test file exited 0 on all three Nodes. Node 22 was the system node. For Node 24 and 26 I put their bin first on PATH.

Test setup
  • Box: Nobara 44 (Fedora 44 base), Linux 7.2.9
  • CPU: AMD Ryzen 9 5900X, 12 cores / 24 threads
  • RAM: 32 GB DDR4-3600
  • Node: 22.23.1, 24.18.0, 26.11.1
  • Head / base: 8f7d12e30 (#4962) / none, since this is an issue check. Control: ce0d6790e and 2b7b698db.
  • Other: rea-agents 6.6.0 on main, 6.5.0 on the control tree; vitest 5.0.3; each run had an 8 GB memory cap; other test agents shared the box, so run times are rough

🤖 Generated with Claude Code (Sonnet 5.5, verified by Opus 5.5) 🦊

<!-- gh-comment-id:6247467414 --> @CassieFox23 commented on GitHub (Oct 18, 2026): **TL;DR:** This does not reproduce on main 8f7d12e30 (#4962) on Linux. A named pipe with no writer in the bridge folder is now skipped, so discovery returns at once. Commit 2b7b698db fixed the hang, and this issue is still open. I ran the issue's proposed check. A private folder held a named pipe with no writer, named like a bridge descriptor. I ran the CLI command `list-cutter-sessions --json` under a 30 second timeout. ### Results | Check | Node 22.23.1 | Node 24.18.0 | Node 26.11.1 | |---|---|---|---| | main 8f7d12e30, named pipe in the folder | no sessions, exit 0 | no sessions, exit 0 | no sessions, exit 0 | | ce0d6790e (parent of 2b7b698db), same check | hangs, killed at 35 s | hangs, killed at 35 s | hangs, killed at 35 s | | main, cutterDescriptorLifetime.test.ts, 9 tests | 9 pass | 9 pass | 9 pass | An empty folder on the parent returned in under a second, so the pipe causes the hang. The three other test files 2b7b698db touched also pass on main (49 tests, Node 24). Commit 2b7b698db says "Refs #4471, #3946", so GitHub did not close this issue. It swaps the parent's blocking open for `withArtifactRegularFile`. It also adds the named-pipe test. ### Not run - macOS and Windows. - A real Cutter session. - The test file on the parent. I used the direct command there. - The fork branch qorvr mentioned, and closed PR #4486. - Command selection and completion lookup by hand. Only the repo's tests cover them. Thanks, morluto, for the source trace and the exact check. Thanks, DeryFerd, for PR #4486. <details> <summary>Commands</summary> ```bash git archive 8f7d12e30d63f3d3b4cfe2a90336362841438f56 | tar -x -C /tmp/i4471/src git archive ce0d6790ed2ac594dadba77819727c92037c5927 | tar -x -C /tmp/i4471/ctl npm ci --ignore-scripts --prefix /tmp/i4471/src npm run build:cached --prefix /tmp/i4471/src mkdir -m 0700 /tmp/i4471/fifo mkfifo /tmp/i4471/fifo/cutter-1-00000000-0000-4000-8000-000000000001.json env REA_CUTTER_BRIDGE_DIR=/tmp/i4471/fifo timeout -k 5 30 \ env -C /tmp/i4471/src node scripts/rea.mjs list-cutter-sessions --json npm run test:focused --prefix /tmp/i4471/src -- tests/process-global/cutterDescriptorLifetime.test.ts ``` The parent tree got the same `npm ci` and `build:cached` in /tmp/i4471/ctl, then the same `mkdir`, `mkfifo` and `timeout` lines. Exit codes: 0 on main for all three Nodes. 137 on the parent for all three, which is `timeout` sending KILL after 30 s plus 5 s. The test file exited 0 on all three Nodes. Node 22 was the system node. For Node 24 and 26 I put their `bin` first on PATH. </details> <details> <summary>Test setup</summary> - Box: Nobara 44 (Fedora 44 base), Linux 7.2.9 - CPU: AMD Ryzen 9 5900X, 12 cores / 24 threads - RAM: 32 GB DDR4-3600 - Node: 22.23.1, 24.18.0, 26.11.1 - Head / base: 8f7d12e30 (#4962) / none, since this is an issue check. Control: ce0d6790e and 2b7b698db. - Other: rea-agents 6.6.0 on main, 6.5.0 on the control tree; vitest 5.0.3; each run had an 8 GB memory cap; other test agents shared the box, so run times are rough </details> 🤖 Generated with Claude Code (Sonnet 5.5, verified by Opus 5.5) 🦊
Author
Owner

@morluto commented on GitHub (Oct 18, 2026):

Closing as fixed on main after reconciling this report with the landed owner/consumer changes.

  • 2b7b698db: fix(cutter): retain descriptor owners and join discovery cancellation. Read only admitted regular descriptors without blocking on pipes. Retain failed closes, join discovery workers, and propagate cancellation through CLI and MCP. Preserve command and descriptor cleanup as independent owners.

Evidence scope: this closure uses the landed source, regression coverage and validation already recorded in the linked commits/thread. No new local tests or native-provider reproduction were run during this backlog pass; previously documented platform and real-provider gaps still apply.

<!-- gh-comment-id:6256016123 --> @morluto commented on GitHub (Oct 18, 2026): <!-- rea-backlog-reconciliation-2026-10-19-4471 --> Closing as fixed on `main` after reconciling this report with the landed owner/consumer changes. - [2b7b698db](https://github.com/morluto/rea/commit/2b7b698db8ead1a5d6fde7007f1556e440d25127): fix(cutter): retain descriptor owners and join discovery cancellation. Read only admitted regular descriptors without blocking on pipes. Retain failed closes, join discovery workers, and propagate cancellation through CLI and MCP. Preserve command and descriptor cleanup as independent owners. Evidence scope: this closure uses the landed source, regression coverage and validation already recorded in the linked commits/thread. No new local tests or native-provider reproduction were run during this backlog pass; previously documented platform and real-provider gaps still apply.
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#1772
No description provided.