Closing a standard descriptor leaves the OS descriptor open

perfloop-oss/cpp2rust · RESOURCE LEAK

https://perfloop.ai/t/oss/case_dgt106mmf4

Verdict

VERIFIED · settled 2026-11-02 · pull request opened as Cpp2Rust/cpp2rust#579

What happened: The assertion is violated on the comparison and satisfied with this change.

Hypothesis

Closing standard input in the default refcount runtime can report success while leaving OS descriptor 0 open. A subsequent open receives another number instead of reusing 0. This changes the behavior of C programs that close and reopen standard input. Exposure in translated applications and any runtime cost remain unmeasured.

At revision f349223c3648e07f886890771b2e6d63dea8df61, FD_REGISTRY initializes slots 0–2 with try_clone_to_owned duplicates of the standard descriptors. FdRegistry::close takes the selected OwnedFd out of the table, drops it, and returns 0. When the original standard descriptor is live and cloning succeeds, that releases the duplicate rather than the original number. Ordinary registrations differ: register indexes each owned handle by its actual raw number. The shared standard-descriptor initialization is the broader ownership mismatch; only descriptor 0 was exercised by the failing witness.

The existing [descriptor-lifetime documentation](https://github.com/Cpp2Rust/cpp2rust/blob/f349223c3648e07f886890771b2e6d63dea8df61/docs/src/runtime/io.md#file-descriptors) says the table maps each integer to the descriptor it names and that close removes and closes that descriptor, including the initial standard descriptors. rules/unistd/tgt_refcount.rs:6–8 directly calls this entry, and rules/fcntl/tgt_refcount.rs:37–53 registers the OS-assigned open result. The [README](https://github.com/Cpp2Rust/cpp2rust/blob/f349223c3648e07f886890771b2e6d63dea8df61/README.md) identifies refcount as the default safe output model. This concerns an existing contract, not a request to switch callers to unsafe output.

Stored property prop_20hmd0p1ac produced a counterexample in checkProperty at that revision, with exit code 101. In isolated subprocesses with descriptors 0–2 live over regular files, the cold-registry trace was close(0), open, read. Both close calls returned 0. The libc comparator left descriptor 0 closed and reopened the file at 0. The runtime left descriptor 0 open and reopened at 3. Both read the expected bytes [0,127,255], and unrelated-descriptor controls passed. All 40 ordinary-descriptor controls completed before the first standard case failed; the remaining standard grid was not completed. The emitted replay, libcc2rs/tests/perfloop_fd_close_release.replay containing `0 0 0`, is retained on the node. A second checkProperty run replayed it before search and reproduced the same failure. Run the stored files with `cargo test --manifest-path libcc2rs/Cargo.toml --test perfloop_fd_close_release -- --nocapture --test-threads=1`.

The separate property prop_yxpwytj2h1 completed all 21 bounded registry-lifecycle traces. Those check ordinary entry removal, documented ub: bad fd panics for unknown or already-closed numbers, remaining entries, and registration after removal. They are regression controls, not evidence of a second defect. The witness uses public runtime operations matching the inspected rules; it is not yet an end-to-end translator observation or production measurement.

The smallest useful Case check is an isolated native-C versus default-translated-C close(0), open(path, O_RDONLY), read fixture. It should establish the consumer route and check close status, physical closure, returned descriptor number and readback. The repair should then preserve those effects for live descriptors 0–2 with cold and initialized registries, along with ordinary close/reopen, unrelated-descriptor liveness and the documented invalid-number policy. A baseline translation that does not reach the witnessed behavior would require resolving that difference before claiming the consumer defect. No CPU-generation target or performance gain is claimed.

The prior FdRegistry-related finding hyp_s5mdskgfth concerns buffering character reads to reduce descriptor calls; it does not repair close ownership. The complete [open-PR page](https://github.com/Cpp2Rust/cpp2rust/pulls), refreshed on 2026-11-01 before submission, contained no matching descriptor-ownership or close implementation among its listed changes. The visible [substring work in PR #577](https://github.com/Cpp2Rust/cpp2rust/pull/577) concerns operand evaluation and is distinct from this registry lifetime transition.

Change to test: Make the registry release the OS descriptor named by the C integer, including 0–2, rather than releasing only a duplicate. Preserve the safe generated API, scoped borrowing, and ordinary descriptor registration and reuse.

Where it lives

perfloop-oss/cpp2rust

Evidence

The assertion is violated on the comparison and satisfied with this change: `In an isolated process with live file-backed standard descriptors and no external descriptor changes, FdRegistry::close on each of 0, 1, and 2, with a cold or initialized registry, returns 0, closes that OS descriptor before returning, and allows open/register/read to reuse its number and recover the input bytes while unrelated descriptors remain live.`

The assertion is violated on the comparison and satisfied with this change: `In isolated processes with live file-backed standard descriptors and no external descriptor changes, the refcount and unsafe C++ standard-stream APIs can be acquired before or after closing their descriptor; subsequent nonempty reads or writes return EBADF without panicking, and unrelated standard descriptors remain open.`

Checks: 8 of 8 passed. Verification: no defect found.

Timeline