Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions emrg/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(<fifo>) -> DID NOT RETURN within 25s
MemoryIndex().save(<fifo>) -> 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")
Expand All @@ -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
Expand Down
88 changes: 88 additions & 0 deletions tests/test_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading