{"slug": "ctrl-s-said-saved-the-file-was-0-bytes", "title": "Ctrl+S said \"Saved.\" The file was 0 bytes.", "summary": "A bug in the open-source Magic: The Gathering client Cockatrice causes deck files to be silently truncated to 0 bytes when saving to a full disk. The issue, filed as GitHub #6952, stems from three compounded failures: WriteOnly mode truncates the file on open, the serializers always return true without checking if bytes were written, and the return value of flush() is discarded. The bug also affects two other write paths, including one that runs on load, meaning merely opening a deck can destroy it.", "body_md": "*This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.*\n\n*Written with the help of AI (Claude). The bug, the fix, the validation setup, and every claim below are mine, and were verified against the real codebase and a real full disk.*\n\nSomeone lost a Magic: The Gathering decklist.\n\nThey were playing on [Cockatrice](https://github.com/Cockatrice/Cockatrice) — the open-source MTG client — with their decks on a drive that had quietly filled up while Oracle pushed an update in the background. They added a card, hit Ctrl+S, and Cockatrice said it saved. The debug log agreed:\n\n```\n[2026-05-28 22:31:42.031 I] Saved deck to \"G:/cockatrice300/data/decks/edh-b2-gitrog-reanimate.cod\" with format 1 - true\n```\n\n`- true`\n\n. Success.\n\nThe file was 0 bytes. The deck was gone.\n\nThat was [issue #6952](https://github.com/Cockatrice/Cockatrice/issues/6952), filed by Mekkiss. The steps to reproduce are four lines long and completely damning:\n\n- Have a full disk.\n- Open a deck on the full disk\n- Add one card to it\n- Save the deck (ctrl+s)\n- Observe that the deck is now a 0 byte file.\n\nThe save path lived in `DeckLoader::saveToFile()`\n\n. Stripped down, it looked like this:\n\n```\nQFile file(fileName);\nif (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {\n    qCWarning(DeckLoaderLog) << \"Could not create or open file:\" << fileName;\n    return std::nullopt;\n}\n\nbool success = false;\nswitch (fmt) { /* ... saveToFile_Native / saveToFile_Plain ... */ }\n\nfile.flush();\nfile.close();\n\nqCInfo(DeckLoaderLog) << \"Saved deck to \" << fileName << \"with format\" << fmt << \"-\" << success;\n```\n\nThere are three independent failures stacked on top of each other here, and you need all three to lose data:\n\n**1. WriteOnly truncates on open.** The instant\n\n`open()`\n\nsucceeds, the existing deck is 0 bytes. Not after a successful write — `open()`\n\nstill succeeds: truncating a file doesn't need free space. It frees space.**2. The serializers always returned true.**\n\n`saveToFile_Native()`\n\nand `saveToFile_Plain()`\n\nwrite into the `QTextStream`\n\n/ `QIODevice`\n\nand return `true`\n\nunconditionally. They never asked whether the bytes landed.**3. The return value of flush() was discarded.** This is the last place the truncation could still have been caught, and the result went straight into the void.\n\n`close()`\n\nafter it can't help either — its failure was also ignored.So: file truncated, writes silently fail because there's no room, nobody checks, and the log cheerfully prints `- true`\n\n. The user is told their deck is safe at the exact moment it stops existing.\n\nWhile tracing the save path I checked the other places `DeckLoader`\n\nwrites deck files. There were two more, and both used the same truncate-then-write-then-hope pattern.\n\n`updateLastLoadedTimestamp()`\n\nrewrites a deck to stamp it with a \"last loaded\" time — it runs on **load**, not save. Same `QFile(fileName)`\n\nopened `WriteOnly`\n\n, same always-`true`\n\nserializer. On a full disk, *merely opening a deck* truncated it to 0 bytes and reported success. You could lose a decklist without ever pressing Ctrl+S.\n\n`convertToCockatriceFormat()`\n\nwas the ugly one. It opened the destination `.cod`\n\nfile `WriteOnly`\n\n, wrote the deck, and then — if `result`\n\nwas true, which it always was — deleted the original file:\n\n```\nfile.close();\n\nif (result) {\n    if (!QFile::remove(fileName)) { /* warn */ }\n```\n\nA failed write there doesn't leave you with a 0-byte file and a backup. It leaves you with a 0-byte file and *no* original. And because the function opened the destination before checking whether the source format was even convertible, the early-return branches ran with the file already truncated.\n\nOne user-visible bug on a full disk, and two more write paths queued up behind it waiting for the same conditions.\n\nQt has exactly the right tool for this and it has been sitting in QtCore since 5.1: `QSaveFile`\n\n. It writes to a temporary file next to the target and only replaces the target — atomically — when you call `commit()`\n\nand every byte has actually made it to disk. If anything fails, the original file is never touched.\n\nThe change is mostly deletion:\n\n```\n// Use QSaveFile so that a failed write (e.g. a full disk) leaves the existing deck untouched\n// instead of truncating it to a 0-byte file. The target is only replaced once every byte has\n// been flushed successfully in commit().\nQSaveFile file(fileName);\nif (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {\n    qCWarning(DeckLoaderLog) << \"Could not create or open file:\" << fileName;\n    return std::nullopt;\n}\n\nbool success = false;\nswitch (fmt) { /* ... */ }\n\nif (!success) {\n    file.cancelWriting();\n    qCWarning(DeckLoaderLog) << \"Failed to serialize deck for file:\" << fileName;\n    return std::nullopt;\n}\n\nif (!file.commit()) {\n    qCWarning(DeckLoaderLog) << \"Failed to save deck to \" << fileName << \":\" << file.errorString();\n    return std::nullopt;\n}\n\nqCInfo(DeckLoaderLog) << \"Saved deck to \" << fileName << \"with format\" << fmt;\n```\n\nNote what moved: the success log now happens *after* `commit()`\n\nreturns true, so it can no longer lie. The failure path logs `file.errorString()`\n\nat warning level instead of printing `- false`\n\nat info level and carrying on.\n\nThe same treatment went on all three write paths, plus one ordering fix in `convertToCockatriceFormat()`\n\n— decide the format *before* opening anything, so an already-converted or unsupported deck can never be truncated and then deleted by a function that decided partway through it had nothing to do.\n\nNet: **+73 / −56** in one file. The success path behaves identically.\n\nHere's the part I actually care about, because \"I reasoned about it and it looks right\" is how you ship a data-loss fix that doesn't work.\n\nI couldn't reproduce this the way the reporter did. Cockatrice is a Qt desktop app, I wasn't going to build the whole GUI to test a file-I/O path, and — more importantly — I didn't want to fill up a real drive to find out.\n\nSo I made a disk that was genuinely, physically full:\n\n`diskpart`\n\n, formatted and mounted it.`QFile`\n\n+ `WriteOnly`\n\n+ ignored-`flush()`\n\npattern, and the new `QSaveFile`\n\n+ `commit()`\n\npattern, both pointed at an existing non-empty file on the full volume.The result, against real Qt 6.8.1 QtCore:\n\nold `QFile` path |\nnew `QSaveFile` path |\n|\n|---|---|---|\n| Reported outcome | success | failure, with `errorString()`\n|\n| File on disk afterwards | 0 bytes |\noriginal, intact |\n\nThat's a real `ENOSPC`\n\nfrom a real filesystem, not a mock, not an injected error, not a `#ifdef`\n\n. The old code lost the file and said it hadn't. The new code kept the file and said it couldn't save. Which is the entire point of the ticket.\n\nThe harness took maybe twenty minutes to set up and it is the only reason I'd put my name on the patch. If you're fixing a bug whose trigger is an environmental condition — full disk, no network, permission denied, clock skew — build the condition. Don't mock it. Mocks agree with whatever you already believe.\n\n[ PR #6978](https://github.com/Cockatrice/Cockatrice/pull/6978) — merged into Cockatrice on 2026-06-09.\n\n** - true in a log line is a claim, and claims need to be checked.** The log wasn't broken. It was faithfully reporting a variable that had been hardcoded to\n\n`true`\n\nthree call frames down. Every layer was honest about the value it was handed; nobody ever checked whether the value meant anything.** WriteOnly is destructive before it's useful.** Any\n\n`open(WriteOnly)`\n\non a path that already holds user data is a window where the data is gone and the replacement doesn't exist yet. If the write can fail — and it can always fail — that window is a data-loss bug waiting for the right Tuesday. `QSaveFile`\n\n, `write-temp-then-rename`\n\n, `O_TMPFILE`\n\n+ `linkat`\n\n: pick your platform's version, but pick one.**When you find one instance of a pattern, grep for the pattern.** The reported bug was one function. The pattern was three, and the scariest one fired on *load*. The user who filed #6952 would have eventually lost a deck just by opening it, and would never have connected that to a save bug.\n\n*Submitted for DEV's Summer Bug Smash — Smash Stories.*", "url": "https://wpnews.pro/news/ctrl-s-said-saved-the-file-was-0-bytes", "canonical_source": "https://dev.to/booyaka101/ctrls-said-saved-the-file-was-0-bytes-4kf1", "published_at": "2026-07-26 12:52:01+00:00", "updated_at": "2026-07-26 13:30:26.885739+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Cockatrice", "Mekkiss", "Qt", "QSaveFile", "GitHub"], "alternates": {"html": "https://wpnews.pro/news/ctrl-s-said-saved-the-file-was-0-bytes", "markdown": "https://wpnews.pro/news/ctrl-s-said-saved-the-file-was-0-bytes.md", "text": "https://wpnews.pro/news/ctrl-s-said-saved-the-file-was-0-bytes.txt", "jsonld": "https://wpnews.pro/news/ctrl-s-said-saved-the-file-was-0-bytes.jsonld"}}