Skip to content

[BUG]: Checklists and notes can be silently replaced by an empty uuid-only stub (non-atomic write + read errors returned as "") #608

Description

@pedwards3x

Describe the bug

A checklist (or note) can be silently replaced by an empty stub containing only a freshly generated uuid — no title, no checklistType, no items. The list still exists in the UI, but it is empty, and the original content is gone from disk with no error logged.

This happened twice to one list on my instance. Both times it followed ticking an item off.

Root cause

Three behaviours combine:

1. serverWriteFile is not atomic — app/_server/actions/file/index.ts

export const serverWriteFile = async (filePath: string, content: string) => {
  await ensureDir(path.dirname(filePath));
  await fs.writeFile(filePath, content, "utf-8");
};

fs.writeFile opens with O_TRUNC, so the file is zero bytes from the truncate until the write completes.

2. serverReadFile converts every failure into an empty string — same file

export const serverReadFile = async (filePath, customReturn?): Promise<string> => {
  try {
    return (await fs.readFile(filePath, "utf-8")) || "";
  } catch (error) {
    return customReturn || "";
  }
};

A read that lands in the truncation window is indistinguishable from a missing file.

3. The readers self-heal a missing uuid by writing it back — app/_server/actions/checklist/readers.ts (and note/readers.ts)

const content = await serverReadFile(filePath);
if (isRaw) {
  const { metadata } = extractYamlMetadata(content);
  let uuid = metadata.uuid;
  if (!uuid) {
    uuid = generateUuid();
    const updatedContent = updateYamlMetadata(content, { uuid });
    await serverWriteFile(filePath, updatedContent);   // ← writes the empty read back
  }

Given content === "", extractYamlMetadata returns {}, so there is no uuid, so a new one is minted and updateYamlMetadata("", { uuid }) produces ---\nuuid: <new>\n---\n — which is then written over the real file.

What closes the loop: updateItem calls broadcast({ type: "checklist", action: "updated", ... }) immediately after serverWriteFile. Every connected client re-reads the list at once, and queries.ts does that with isRaw: true — the exact branch that performs the self-heal write. Ticking a checkbox fires the write that races the reads it triggers. More clients or devices open on the list widens the window.

Evidence

The damaged file was exactly 51 bytes:

---
uuid: 90bebaec-330a-43ab-a96e-7247063a9757
---

Reconstructing what this path emits from an empty read gives 51 bytes, byte-for-byte identical.

listToMarkdown — the normal serializer — cannot produce that file:

metadata.title = list.title || "Untitled Checklist";   // always emits a title
...
if (list.items.length === 0) return frontmatter.trim();  // no trailing newline

It always writes a title: line, and it trims for an empty list, which would give 50 bytes with no trailing newline. The damaged file had the trailing newline and no title at all. Only the updateYamlMetadata("", { uuid }) path matches.

The uuid also changed, which an update path would never do — confirming the file was rewritten as a brand-new object rather than modified.

To Reproduce

Timing-dependent, so it is intermittent by nature:

  1. Open the same checklist in two or more clients (two browsers, or phone + desktop).
  2. Tick items off in fairly quick succession.
  3. Occasionally the write and the broadcast-triggered re-read overlap, and the list is replaced by a uuid-only stub.

Expected behavior

A read failure should never cause a write. A concurrent reader should never observe a partially written file.

Suggested fix

Two small changes, either of which alone prevents the loss; together they are belt and braces.

Make the write atomic — write to a temp file and rename(2) it into place. rename is atomic within a filesystem, so a reader sees either the old file or the new one:

const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
let mode: number | undefined;
try { mode = (await fs.stat(filePath)).mode & 0o777; } catch { /* new file */ }
try {
  await fs.writeFile(tmp, content, "utf-8");
  if (mode !== undefined) await fs.chmod(tmp, mode);
  await fs.rename(tmp, filePath);
} catch (error) {
  try { await fs.unlink(tmp); } catch {}
  throw error;
}

Guard the self-heal in both checklist/readers.ts and note/readers.ts:

if (!uuid && content.trim().length > 0) {

It may also be worth reconsidering serverReadFile swallowing real errors — "file is missing" and "the read failed" are very different facts, and collapsing them is what makes this reachable.

Version

Reproduced on 1.20.0. Verified still present on main at 1.27.0 — serverReadFile and serverWriteFile are unchanged, and the self-heal write is at checklist/readers.ts:245.

Additional context

I am running the two fixes above as a local patch and have had no recurrence. Happy to open a PR if useful.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    acceptedThis has been accepted and will be worked onto be deployedBeen worked on and will be deployed in the next release

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions