Skip to content

[pi/0.49.12] NUL-corrupted served-ledger JSONL files permanently block a session with a false 'storage is busy' refusal (Windows/Bun) #723

Closed

Description

@Zireael

[pi/0.49.12] NUL-corrupted served-ledger JSONL files permanently block a session with a false "storage is busy" refusal (Windows/Bun)

Short description

Corrupt trailing NUL bytes in pi-served-array-digests/*.jsonl / pi-served-tag-numbers/*.jsonl make getPiServedTagNumbers() throw a raw JSON Parse error: Unrecognized token '\u0000' on every turn in one session; the pi fallback wraps it into PiStorageBusyError, so the host displays a misleading, non-transient "storage is busy" refusal that persists until the files are manually repaired.

Environment

  • Plugin: @cortexkit/pi-magic-context 0.49.12 (dist, Bun backend)
  • Harness: OMP (omp/18.12.1, --harness pi), Codex/Responses provider
  • Platform: Windows x64 (MSYS2 shell for tooling; omp itself runs on Bun)
  • Affected session: one long-lived Pi session with a large history; only 2 of 874 ledger files across all sessions were affected — the corruption is per-session, not global
  • Storage: default data dir (~/.local/share/cortexkit/magic-context/) — the SQLite store was ruled out (schema in sync: database=v95, supported_fence=v95; no SQLITE_BUSY in the log at failure times; "SQLite boot busy timeout" is a separate, unrelated boot line)

What happened

From a user's perspective:

Error: Magic Context could not safely prepare this turn; send your message again.
stage=recovery elapsed=394ms recovery=no managed result; refused (Magic Context storage is busy; send your message again)

Every retry from 2026-10-17 12:09 UTC onward (6+ refusals across Days 1–2) produced the same refusal. Resending never helped — the error is deterministic, not transient.

The plugin log shows the real error, DEGRADED PASS JSON Parse error: Unrecognized token '\u0000': LKG unavailable (lkg_miss); refusing unreduced 107-message input, immediately before each turn refused line.

Evidence chain

  1. Corrupt files. Two sidecar JSONL files for the affected session had trailing NUL (0x00) bytes:
    • pi-served-array-digests/<session>-<hash>.jsonl: 4,911,606 bytes on disk, 1,584 trailing NULs (first NUL at offset 4,910,022)
    • pi-served-tag-numbers/<session>-<hash>.jsonl: 241,535 bytes on disk, 87 trailing NULs (first NUL at offset 241,448)
      All NULs sit strictly at EOF; no mid-file NULs. Every NUL was a position with no record content behind it — the files had grown (or been written) without content, i.e. a hole-padded tail, not a truncated parse artifact. stat mtimes froze at the corruption moment (10:58 Oct 17), hours before the first refusal (12:09), and never advanced again — consistent with the capture path failing on its own eventual read loop (the ledger identity is also re-read, so the corrupt write is only consumed later when the stamp cache invalidates).
  2. Reader-side blast radius. getPiServedTagNumbers() (served-array-ledger.ts) reads the identity file, does text.split("\n"), and JSON.parse(line)-es each non-empty line. The NUL tail becomes a final "line" that is non-empty, so it is parsed → raw SyntaxError: JSON Parse error: Unrecognized token '\u0000' (message exactly reproduced by running the same parse in Bun against the corrupt bytes).
  3. Unhandled propagation. That SyntaxError is not a PiServedEnvelopeUnavailable, so the db-path wrapper can't catch it; it escapes to the context handler as a plain error → the transform pass is marked degraded.
  4. Misleading fail-closed wrap. In the pi raw fallback (registerPiGuardedContext), when the LKG snapshot isn't available (lkg_miss, no lkg_slots row for the session), the non-transient path wraps any error into PiStorageBusyError("\u201cMagic Context storage is busy; send your message again\u201d"). So a deterministic corrupt-file parse error is surfaced as a "busy, resend" refusal — a message with no valid remediation for this cause. This is the same catch-all surface noted in [Pi] PiStorageBusyError on SQLite lock contention triggers fail-closed ctx.abort(), blocking interactive turns #601.
  5. Related. [Pi Agent] Conflicting served tool tag numbers permanently block a session, reported as storage busy (0.47.0) #650 is the same visible symptom ("served tag numbers permanently block a session, reported as storage busy") with a different root cause — further evidence this error surface is fragile to any per-session state anomaly.

Repro condition

  • Windows + Bun runtime (omp/pi harness), plugin writing these ledger/identity .jsonl files via writeStorageFileSync(path, data, { flag: "a" }) (and timer-flushed appends via appendPendingLines).
  • One session's files got a hole-padded tail (mechanism not directly captured — likely a Bun/Windows append-mode write race; both files corrupted in the same window). Every subsequent turn in that session fails at getPiServedTagNumbers, every other session is unaffected.

Minimal repro once the file is corrupt: JSON.parse on the file's final \n-split segment (the NUL run) throws SyntaxError: JSON Parse error: Unrecognized token '\u0000' under Bun; the refusal then repeats on every turn.

Suggested fixes

  1. Fail open on a NUL/garbage tail (reader): in getPiServedTagNumbers, trim trailing NUL bytes (or skip trailing lines that fail to parse and contain no {) before splitting. Losing a corrupt tail record is strictly better than permanently blocking the session; the DB-backed committed-served-numbers path remains the authoritative fallback.
  2. Wrap, don't leak: convert file-read SyntaxErrors into PiServedEnvelopeUnavailable (or PiServedIdentityError) so existing recoverability handling applies instead of a raw parse error escaping the transform.
  3. Stop using PiStorageBusyError as a catch-all: only throw it for genuinely transient causes (SQLITE_BUSY/SQLITE_LOCKED); for everything else, surface the real cause in the CONTEXT REFUSED text — "send your message again" is actively misleading for a persistent error and cost a full day of misdirected debugging here.
  4. Writer hardening: write these appends through writeStorageFileDurableAtomicSync-style temp-file + rename (or hold one O_APPEND fd and fsync), instead of writeFileSync(..., { flag: "a" }) per flush on Windows/Bun, to eliminate the hole-padded-tail mechanism.

Verification performed

  • Byte-level audit of both files (NUL positions strictly at EOF).
  • Reader-semantics simulation: all 874 ledger files across both directories validated line-by-line under the exact reader rules before repair; exactly the two affected files failed.
  • After repair (strip trailing NULs only, originals kept): all 874 files parse under the reader's full record validation (version===1, matching session_id, array of safe positive integers in tag_numbers); the repaired digest ledger's last record validates version/SHA256/message_count/sequence.

Activity

  1. magic-alfonso commented on Oct 18, 2026

    @magic-alfonso

    Thanks for this report. The byte-level audit, the reader rules and the full-validation check after repair made it easy to follow, and your diagnosis matches the code: a zero-filled tail in one of those sidecar files makes the reader throw a raw parse error, and the Pi fallback then reports it as "storage is busy", which no amount of resending can fix.

    The fix is being worked on now and goes into the next patch release. It covers three things:

    • The reader treats a tail of only zero bytes as unwritten space and ignores it. That's safe because no record was ever written there. A partially written last line is handled according to whether it was saved before or after the turn was sent, so a tag number can never be reused.
    • The refusal text only says "storage is busy, send your message again" when storage really is busy. Other failures name their actual cause.
    • The writer is hardened against the zero-filled tail on Windows.

    Your repair (stripping the trailing zero bytes while keeping the originals) is the right workaround until then.

  2. magic-alfonso commented on Oct 20, 2026

    @magic-alfonso

    This shipped in Magic Context 0.49.15. The 0.49.14 Pi package never became installable on npm, so 0.49.15 is the first Pi release that carries it. A damaged session record, including a zero-filled tail after a crash, no longer blocks the session: the unreadable tail is skipped without changing the file, and refusals now name their real cause instead of reporting that storage is busy. Thank you @Zireael for the report, the evidence and the fixtures. Please reopen if you still see it after updating.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions