diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-23 23:55:15 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-23 23:55:15 -0500 |
| commit | 0f91a8e0cd0a4b5b351efe407987bb8bb6ab125e (patch) | |
| tree | ea2ed0fc2b45f7f3d21e54d32adc9333c8ba9ff6 /.ai/scripts/tests | |
| parent | 392f14a83dedad96750749d46ad808f9c9bad493 (diff) | |
| download | rulesets-0f91a8e0cd0a4b5b351efe407987bb8bb6ab125e.tar.gz rulesets-0f91a8e0cd0a4b5b351efe407987bb8bb6ab125e.zip | |
fix(inbox-send): write handoffs atomically so a failure leaves no phantom
inbox-send wrote straight to the destination path, and write_text truncates on open, so any mid-write failure left a zero-byte .org in another project's inbox. inbox-status counted that phantom as a pending handoff and blocked a turn in the receiving project over a file with no content and no sender.
Both send paths now write to a temp sibling and os.replace it into place, so the inbox only ever sees a complete file. A caught failure removes the temp and re-raises, leaving no debris. encoding is pinned to utf-8 on the write and on the roots-config read, which closes the locale-dependent failure that first surfaced this.
inbox-status also skips the .inbox-send-* temp, so the brief window before the rename can't read as pending either. The atomicity is only complete once the consumer ignores the in-flight file.
Diffstat (limited to '.ai/scripts/tests')
| -rw-r--r-- | .ai/scripts/tests/inbox-status.bats | 12 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_inbox_send.py | 72 |
2 files changed, 84 insertions, 0 deletions
diff --git a/.ai/scripts/tests/inbox-status.bats b/.ai/scripts/tests/inbox-status.bats index bc8a734..27a497e 100644 --- a/.ai/scripts/tests/inbox-status.bats +++ b/.ai/scripts/tests/inbox-status.bats @@ -45,6 +45,18 @@ teardown() { [[ "$output" == *"0 pending"* ]] } +@test "inbox-status: ignores an in-flight .inbox-send-* temp file" { + mkdir "$TMP/inbox" + # inbox-send writes to a .inbox-send-* temp then renames it into place; + # during that window the temp must not read as a pending handoff, or a + # concurrent boundary check blocks on a file that's about to become real. + touch "$TMP/inbox/.inbox-send-abc123.org" + cd "$TMP" + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"0 pending"* ]] +} + @test "inbox-status: -q suppresses the per-item lines" { mkdir "$TMP/inbox" echo body > "$TMP/inbox/handoff.org" diff --git a/.ai/scripts/tests/test_inbox_send.py b/.ai/scripts/tests/test_inbox_send.py index f75d7a1..02af23c 100644 --- a/.ai/scripts/tests/test_inbox_send.py +++ b/.ai/scripts/tests/test_inbox_send.py @@ -476,3 +476,75 @@ class TestFilenameCollisions: assert len(files) == 2 bodies = "".join(f.read_text() for f in files) assert "message one" in bodies and "message two" in bodies + + +class TestAtomicWrite: + """A send wrote straight to the destination path in another project's + inbox/, and write_text truncates on open, so any mid-write failure left a + zero-byte .org there. inbox-status counts that phantom as a pending + handoff, blocking a turn in the receiving project over a file with no + content (2026-07-23). The write must be atomic: the inbox sees a complete + file or nothing.""" + + def test_send_text_writes_utf8(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 23, 4, 36, 0) + # An em dash and an accented char — both non-ASCII. + dest = mod.send_text(inbox, "accent café and dash — here", "src", None, now) + # Reading as utf-8 must round-trip; a locale-encoded write would raise + # under a C locale, and reading back proves the bytes are utf-8. + assert "—" in dest.read_text(encoding="utf-8") + + def test_send_text_no_partial_on_write_failure(self, tmp_path, monkeypatch): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 23, 4, 36, 0) + # Force the atomic finalize to fail after the temp file is written. + def boom(*a, **k): + raise OSError("disk full") + monkeypatch.setattr(mod.os, "replace", boom) + with pytest.raises(OSError): + mod.send_text(inbox, "a message that should never half-land", "src", None, now) + # No phantom, no leftover temp: the inbox is empty. + assert list(inbox.iterdir()) == [] + + def test_send_text_leaves_no_temp_on_success(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 23, 4, 36, 0) + dest = mod.send_text(inbox, "clean send", "src", None, now) + assert list(inbox.iterdir()) == [dest] + + def test_send_file_no_partial_on_write_failure(self, tmp_path, monkeypatch): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + src = tmp_path / "note.org" + src.write_text("body") + now = datetime(2026, 7, 23, 4, 36, 0) + def boom(*a, **k): + raise OSError("disk full") + monkeypatch.setattr(mod.os, "replace", boom) + with pytest.raises(OSError): + mod.send_file(inbox, src, "src", None, now) + assert list(inbox.iterdir()) == [] + + def test_send_file_leaves_no_temp_on_success(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + src = tmp_path / "note.org" + src.write_text("payload") + now = datetime(2026, 7, 23, 4, 36, 0) + dest = mod.send_file(inbox, src, "src", None, now) + assert list(inbox.iterdir()) == [dest] + assert dest.read_text() == "payload" |
