Skip to content

Nightly 1400 backend exhausts heap during attachment cleanup on startup #10774

Description

@Gigioxx

What happened

The installed macOS T3 Code Nightly stopped starting after updating to 0.0.41-nightly.20260908.1400. Its embedded backend repeatedly exhausted the JavaScript heap and aborted. Restoring official signed 0.0.41-nightly.20260908.1387 restored access to the same existing projects and threads without deleting history or editing projection cursors.

Diagnosis

The attachment-cleanup bootstrap added by #9871, commit 7220dfe2c949476eaa7d21eccbcd3a0ce0eddb49, forces a full historical event replay when its new cursor is absent or zero, even when all ordinary projections are caught up.

On the stopped-app recovery backup:

  • All nine ordinary projection cursors were at event 1131083; projection.attachment-cleanup was 0.
  • The event store held 1,131,083 events and 3,441,699,990 serialized payload bytes. The largest event was 67,627,295 bytes; the largest 500-event page was 95,024,354 bytes.
  • Only 24 thread.deleted events, totaling 2,160 payload characters, were relevant to cleanup. There were no thread.reverted events.

ProjectionPipeline.ts persists the starting cursor, runs readFromSequence(cleanupStart, Number.MAX_SAFE_INTEGER), filters deletions/reverts in JavaScript, and advances the cleanup cursor only after the complete scan and successful cleanup. An OOM leaves the next launch starting from zero again.

The existing event-store reader uses 500-row SQL pages but recursively concatenates them inside Stream.flatMap. A synthetic comparison using Effect 4.0.0-rc.112 reproduces retention of consumed pages even with a discard consumer. Non-recursive Stream.unfold pagination over the same generated rows stays flat in that test.

The native crash stack reaches node::sqlite::StatementExecutionHelper::All; the Node SQL adapter calls statement.all. This is paged SQL with cumulative retention, not a single SQL query returning the entire database. The shipped 1400 ASAR contains the same cleanup and recursive reader code.

The evidence strongly connects the newly forced full replay to a pre-existing stream-retention problem. A full-app heap profile has not been captured, so the exact retained-object graph and contribution of other startup allocations remain unproven.

Steps to reproduce

Observed on the affected profile, not repeated against live data during this diagnosis:

  1. Start with an established 1387 profile containing a large orchestration event history, with ordinary projections caught up.
  2. Update the desktop app to Nightly 1400 and launch it.
  3. Observe the embedded backend abort with V8 OOM during startup and retry without advancing the cleanup cursor.
  4. Restore 1387; the existing environment becomes usable again.

The portable synthetic test below can be saved as stream-retention.mjs. It uses 100 pages of 500 generated events with 2,048-character text payloads, a discard consumer, forced GC every 10,000 events, and count assertions. Run against an existing Effect 4.0.0-rc.112 package:

node --expose-gc stream-retention.mjs recursive /absolute/path/to/effect
node --expose-gc stream-retention.mjs flat /absolute/path/to/effect
stream-retention.mjs
import { pathToFileURL } from "node:url";
import path from "node:path";
const effectRoot = process.argv[3];
if (!effectRoot) throw new Error("Pass variant (recursive|flat) and absolute path to effect package directory");
const Effect = await import(pathToFileURL(path.join(effectRoot, "dist/Effect.js")));
const Stream = await import(pathToFileURL(path.join(effectRoot, "dist/Stream.js")));
import assert from "node:assert/strict";
const pages = 100;
const pageSize = 500;
const readRows = (cursor) => Array.from({length: pageSize}, (_,i) => ({sequence: cursor+i+1, payload: JSON.parse(JSON.stringify({text: String(cursor+i).padEnd(2048,"x")}))}));
const recursive = (cursor, remaining) => Stream.fromEffect(Effect.sync(() => readRows(cursor))).pipe(Stream.flatMap(events => {
 const nextRemaining = remaining - events.length;
 if(nextRemaining <= 0) return Stream.fromIterable(events);
 return Stream.concat(Stream.fromIterable(events), recursive(events.at(-1).sequence,nextRemaining));
}));
const flat = Stream.unfold(0, cursor => Effect.sync(() => cursor >= pages*pageSize ? undefined : [readRows(cursor), cursor+pageSize])).pipe(Stream.flattenIterable);
let count=0;
await Effect.runPromise(Stream.runForEach(process.argv[2] === "recursive" ? recursive(0,pages*pageSize) : flat, () => Effect.sync(() => {
 count++;
 if(count % 10000 === 0) { global.gc(); console.log(JSON.stringify({variant:process.argv[2],count,heapMB:Math.round(process.memoryUsage().heapUsed/1024/1024)})); }
})));
assert.equal(count,pages*pageSize);

Both consume exactly 50,000 events. This reproduces stream retention only; it is not a minimal full-application reproduction. No private database or event contents are attached.

Version

Failed: 0.0.41-nightly.20260908.1400, release commit eb115063634c416c6362cc407f8572cb0c136ddf.

Working rollback: 0.0.41-nightly.20260908.1387.

Environment

macOS 27.0 (26A5425a), arm64, desktop app with its embedded local Electron backend. Triage launched with npx t3@nightly triage; its Node v24.15.0 describes the triage process, not a verified version of the failed Electron Node runtime. Source lockfile pins Effect 4.0.0-rc.112.

Evidence

2026-09-08T13:01:20.803Z
Mark-Compact (reduce) 3693.7 (3751.1) -> 3693.5 (3749.1) MB
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory

2026-09-08T13:03:01Z macOS crash report, version 1400:
EXC_CRASH (SIGABRT), Abort trap: 6
node::OOMErrorHandler
v8::String::NewFromUtf8
node::sqlite::StatementExecutionHelper::ColumnToValue
node::sqlite::ExtractRowValues
node::sqlite::StatementExecutionHelper::All

Synthetic retained heap after forced GC (MiB):
events       10000  20000  30000  40000  50000
recursive      29     50     71     91    112
unfold         10     10     10     10     10

Desktop traces record backend restart scheduling after OOM. The source awaits projection bootstrap before starting the orchestration worker. The 1387-to-1400 comparison changes the cleanup path but not the event-store implementation or schema migrations.

Related issues

No confirmed duplicate found in targeted searches on September 8, 2026. #8648 concerns long-uptime OOM on 0.0.35; #7075 concerns provider resume payloads. Neither establishes this new cleanup-startup trigger.

Merged #8992 bounds WebSocket replay payloads, but its guard does not cover cleanup bootstrap. Open #10512 addresses a V2 compatibility RPC rather than this startup path. #7537 documents why silently capping projection catch-up is unsafe.

At investigation time 1400 was the newest published release. Main at 061543e9e5b54ec0048725c37d52fef2962df173 had no subsequent cleanup fix.

Fix applied or workaround

Recovery restored official signed Nightly 1387 and verified existing projects and threads were accessible. User history and attachments were preserved. This triage only queried the recovery backup read-only and inspected source/logs; it made no live changes.

Suggested upstream direction: non-retaining pagination in the shared event reader, plus a cleanup query that reads only necessary deletion/revert metadata. Preserve full catch-up and retry-safe cleanup semantics. Raising the heap limit or manually skipping cleanup is not a verified fix.

Filed by

Diagnosed and filed with GPT-6 Astra in the Codex harness via T3 Code triage, including a synthetic retention experiment and read-only database measurements.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions