From 7c2f57b9424ac95ad5cc8eca6c2e8e92b1f9506e Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sun, 11 Oct 2026 05:47:43 +0800 Subject: [PATCH] emrg: the memory index's reader and writer ask a path's kind before opening it --- emrg/memory.py | 62 ++++++++++++++++++++++++++++++- tests/test_memory.py | 88 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 2 deletions(-) diff --git a/emrg/memory.py b/emrg/memory.py index 61ce6521..9334e856 100644 --- a/emrg/memory.py +++ b/emrg/memory.py @@ -787,8 +787,47 @@ def _append_under(out: list[str], type_name: str, lines: list[str]) -> None: out[end:end] = lines def save(self, path: Path) -> None: - """Write the index to disk.""" + """Write the index to disk, refusing a subject that is not a regular file. + + The reader's guard, one direction over: **an open is not a read, and a write is an + open too**. A write to a FIFO blocks on `open` for writing until a reader appears, + a socket until a connection does — so this call would never return, exactly as + `from_file` never returned, and the pair is why `#2075` left both halves for one + issue: guarding the reader alone only moved the hang here. + + Measured 2026-10-11 on `#2076`'s head `b831a745` (since landed as master `123d0abc`), + one fixture, each call in its own process under a parent's 25 s cap: + + MemoryIndex.from_file() -> DID NOT RETURN within 25s + MemoryIndex().save() -> DID NOT RETURN within 25s + both, with a regular file -> RETURNED (0.0s) + both, with the path absent -> RETURNED (0.0s) + + The **refusal is raised** rather than the write being skipped, and the reason is + that both answers are partial: `MemoryStore.create` writes its memory file first + and indexes it second, so a skip would return success with a file on disk that no + index row names — the store reporting an agreement it does not have, which is the + class this module keeps closing elsewhere (`get_with_reason`'s two absences). An + `OSError` names the subject and the kind, and it is what the module already raises + one kind over (a *directory* at this path is `IsADirectoryError`; see the class + docstring of `NotARegularFile`). + """ path.parent.mkdir(parents=True, exist_ok=True) + # Only an existing path has a kind to judge — the same rule the `write` tool + # states for its own subject, and the reason this guard is not `non_regular_kind` + # unconditionally (which raises `FileNotFoundError` for a path the writer is + # about to create). + if path.exists(): + kind = non_regular_kind(path) + if kind: + raise NotARegularFile( + f"{path} is {kind}, not a regular file. This writer opens its " + f"subject directly, and opening this one blocks until a peer appears " + f"— a write to a FIFO waits for a reader, a socket for a connection, " + f"a device never ends — so the call would never return, and the store " + f"methods that reach it (`create`, `update`, `rebuild_index`, " + f"`promote_to_project`) would not either." + ) old_size = path.stat().st_size if path.exists() else 0 content = self.to_markdown() path.write_text(content, encoding="utf-8") @@ -802,9 +841,28 @@ def save(self, path: Path) -> None: @classmethod def from_file(cls, path: Path) -> MemoryIndex: - """Parse an existing MEMORY.md file (returns empty index if missing).""" + """Parse an existing MEMORY.md file (returns empty index if missing). + + The subject's **kind** is asked before it is opened, for the reason + `MemoryFile.from_file` states one class up and `#2076` measured: on a FIFO the + open blocks until a writer appears and raises nothing, so a reader that never asks + does not fail — it never returns. `MEMORY.md` is *written* as well as read, which + is why this pair travels with `save` rather than alone (`#2075`'s "Not taken"). + Reachability is weaker than `MemoryFile`'s and is stated as such in issue `#2078`: + no daemon frame reads the index today, so what reaches this is the store's own API + (`create`, `update`, `merge`, `rebuild_index`, `delete`, `promote_to_project`). + """ if not path.exists(): return cls() + kind = non_regular_kind(path) + if kind: + raise NotARegularFile( + f"{path} is {kind}, not a regular file. This reader opens its subject " + f"directly, and opening this one blocks until a peer appears — a FIFO " + f"waits for a writer, a socket for a connection, a device never ends — so " + f"the call would never return, and neither would the store methods that " + f"reach it on the daemon's event loop." + ) return cls.from_text(path.read_text(encoding="utf-8")) @classmethod diff --git a/tests/test_memory.py b/tests/test_memory.py index b35e28ce..85ea2447 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -863,3 +863,91 @@ def test_the_scan_names_the_pipe_as_a_reason_rather_than_never_answering(temp_cw assert "NotARegularFile" in reason, ( "the reason must name the kind's verdict — `OSError` there would name nothing" ) + + +# ── The index pair: MEMORY.md is written as well as read (issue #2078) ────── +# +# `#2076` guarded the memory *files*, and `#2075`'s "Not taken" section named this pair +# and the reason it was left whole: `MEMORY.md` is written as well as read, and **a write +# to a FIFO blocks on `open` for writing too**, so guarding the reader alone would only +# move the hang. Both halves are here, and each test is written so that removing the +# guard makes it **hang** rather than pass — the same shape as the block above. + + +@_needs_mkfifo +def test_the_index_reader_refuses_a_named_pipe_instead_of_blocking(tmp_path): + """`MemoryIndex.from_file` names the kind rather than opening the subject.""" + fifo = tmp_path / "MEMORY.md" + os.mkfifo(fifo) + + with pytest.raises(NotARegularFile) as excinfo: + MemoryIndex.from_file(fifo) + + assert "a FIFO (named pipe)" in str(excinfo.value), str(excinfo.value) + assert "not a regular file" in str(excinfo.value), str(excinfo.value) + + +@_needs_mkfifo +def test_the_index_writer_refuses_a_named_pipe_instead_of_blocking(tmp_path): + """The other half, and the one whose absence would leave the hang standing. + + A write to a FIFO waits for a reader, so this call has no bound either — measured + 2026-10-11 (`cyc20261011-044528`) on `#2076`'s head: both arms did not return within + 25 s, while a regular file and an absent path returned in 0.0 s. + """ + fifo = tmp_path / "MEMORY.md" + os.mkfifo(fifo) + + with pytest.raises(NotARegularFile) as excinfo: + MemoryIndex().save(fifo) + + assert "a FIFO (named pipe)" in str(excinfo.value), str(excinfo.value) + assert "write" in str(excinfo.value), ( + "the writer's refusal should say which direction it is: " + str(excinfo.value) + ) + + +@_needs_mkfifo +def test_the_index_pair_still_reads_and_writes_beside_a_pipe(tmp_path): + """The control: the refusal is about the kind at this path, not about the directory. + + A pipe *beside* the index changes nothing — and an absent index is still the empty + index the reader has always returned, which is the case a guard that asked + `non_regular_kind` unconditionally would turn into a `FileNotFoundError`. + """ + os.mkfifo(tmp_path / "pipe.md") + index_path = tmp_path / "MEMORY.md" + index_path.write_text("# Memory Index\n", encoding="utf-8") + + assert MemoryIndex.from_file(index_path).entries == [] + + written = MemoryIndex() + written.save(index_path) + assert index_path.read_text(encoding="utf-8").strip().startswith("# Memory Index") + + absent = tmp_path / "nested" / "MEMORY.md" + assert MemoryIndex.from_file(absent).entries == [] + MemoryIndex().save(absent) # creates the parent, as it always did + assert absent.exists() + + +@_needs_mkfifo +def test_the_store_route_refuses_rather_than_never_answering(temp_cwd): + """`create()` reaches both halves, and the answer is a refusal — not a silent skip. + + The issue left this half to decide rather than copy: `_save_index`'s callers return + normally today, so a skipped write would let `create()` report success while its own + index does not name the memory it just wrote — an agreement the store does not have, + which is the class `get_with_reason` closes for the other absence. Refusing names the + subject and the kind instead, and it is an `OSError`, which is what this module + already raises one kind over at this path (`IsADirectoryError`). + """ + directory = temp_cwd / ".emrg" / "memory" + directory.mkdir(parents=True) + os.mkfifo(directory / "MEMORY.md") + store = ProjectMemoryStore(temp_cwd) + + with pytest.raises(NotARegularFile) as excinfo: + store.create("reference", "A note", "body") + + assert "MEMORY.md" in str(excinfo.value), str(excinfo.value)