@mirrowel/opencode-openshare 0.2.0-dev.1 → 0.2.0-dev.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core.ts CHANGED
@@ -43,9 +43,11 @@
43
43
  // secret must never reach viewers.
44
44
 
45
45
  import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
46
+ import { createHash } from "node:crypto"
46
47
  import { join } from "node:path"
47
48
  import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken, type ShareConfig, type ShareMode, type ServerEntry } from "./config.js"
48
49
  import { CONTRACT_VERSION, cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js"
50
+ import { queueBindingWrite } from "./binding-queue.js"
49
51
 
50
52
  // --- minimal structural types (hand-written: the standalone bundle must
51
53
  // not depend on the plugin SDK at runtime; shapes verified against
@@ -107,13 +109,21 @@ type RemoteState = {
107
109
  initTimer: ReturnType<typeof setTimeout> | null
108
110
  initWasAttach: boolean
109
111
  backoff: number
112
+ /** deferred backoff reset: released after 30s of socket stability or a
113
+ * second acked sync — never right after init (init-then-drop servers
114
+ * would cycle at 1/s otherwise) */
115
+ backoffResetTimer: ReturnType<typeof setTimeout> | null
110
116
  reconnectTimer: ReturnType<typeof setTimeout> | null
111
117
  intentionalClose: boolean
112
118
  connectQueued: boolean
113
119
  /** identity dead-end for THIS server (frozen owner, unenrollable
114
120
  * private server): stop reconnecting it; other servers unaffected. */
115
121
  authBlocked: boolean
116
- pending: Array<{ items: unknown[]; status: string }> // frames awaiting ack (spool)
122
+ pending: Array<{ items: unknown[]; status: string; seq?: number; wire?: string }> // chunk-frames awaiting ack (spool; wire = prebuilt frame string)
123
+ frozenRemote?: boolean // server froze the owner mid-session: stop writing
124
+ spoolAppendFailed?: boolean // last append errored: memory-only frame exists
125
+ spoolSaveTimer: ReturnType<typeof setTimeout> | null // debounced ack-path spool rewrite
126
+ authWaiter?: (() => void) | undefined // frame-auth handshake resolver
117
127
  announced: boolean
118
128
  settleTimer: ReturnType<typeof setTimeout> | null
119
129
  idleDropTimer: ReturnType<typeof setTimeout> | null
@@ -153,6 +163,12 @@ type SessionState = {
153
163
  * item embeds content, so the engine holds the assembled view per
154
164
  * message and re-emits the normalized whole on every update */
155
165
  merged: Map<string, { info?: Record<string, unknown>; parts: Map<string, Record<string, unknown>> }>
166
+ /** content hash of the LAST item enqueued per key: identical re-feeds
167
+ * (v2 hosts re-sync the whole transcript on every refresh; unchanged
168
+ * session info on every session.updated) are skipped instead of being
169
+ * flushed to every remote, bumping rev and re-folding every viewer.
170
+ * Cleared on activation — re-activation re-backfills by design. */
171
+ sentHash: Map<string, string>
156
172
  }
157
173
 
158
174
  type Message = { info?: Record<string, unknown>; parts?: Record<string, unknown>[] }
@@ -420,6 +436,8 @@ export function createEngine(deps: EngineDeps): Engine {
420
436
  initTimer: null,
421
437
  initWasAttach: false,
422
438
  backoff: 1000,
439
+ backoffResetTimer: null,
440
+ spoolSaveTimer: null,
423
441
  reconnectTimer: null,
424
442
  intentionalClose: false,
425
443
  connectQueued: false,
@@ -485,12 +503,19 @@ export function createEngine(deps: EngineDeps): Engine {
485
503
  if (b && typeof b === "object" && (b as { policy?: unknown }).policy) {
486
504
  applyPolicy((b as { policy: unknown }).policy, url)
487
505
  }
506
+ // frame auth capability: the server accepts the token as the
507
+ // first WS frame — lets us keep it off the query string (proxy
508
+ // and access logs otherwise capture the full credential)
509
+ if (b && typeof b === "object" && (b as { frameAuth?: unknown }).frameAuth) {
510
+ frameAuthUrls.add(url)
511
+ }
488
512
  })
489
513
  .catch(() => {
490
514
  // unreachable worker: defaults stay; the init ack will correct us
491
515
  policyAsked.delete(url)
492
516
  })
493
517
  }
518
+ const frameAuthUrls = new Set<string>()
494
519
  const wsUrl = (url: string) => url.replace(/^http/, "ws")
495
520
 
496
521
  // ---- spool root ---------------------------------------------------------
@@ -534,6 +559,7 @@ export function createEngine(deps: EngineDeps): Engine {
534
559
  wroteBinding: false,
535
560
  everConnected: false,
536
561
  merged: new Map(),
562
+ sentHash: new Map(),
537
563
  })
538
564
 
539
565
  const refreshConfig = () => {
@@ -572,6 +598,9 @@ export function createEngine(deps: EngineDeps): Engine {
572
598
  }
573
599
  st.activated = true
574
600
  st.opts = opts
601
+ // re-activation re-backfills the whole session (unshare -> share
602
+ // again): the enqueue dedupe must not skip the returning content
603
+ st.sentHash.clear()
575
604
  const gated = mode === "manual" || opts2?.gated === true
576
605
  if (gated) {
577
606
  // seed the share status from the session's CURRENT state before
@@ -647,26 +676,31 @@ export function createEngine(deps: EngineDeps): Engine {
647
676
 
648
677
  async function writeMetadata(st: SessionState, value: Record<string, unknown> | null) {
649
678
  if (!st.client) return
650
- try {
651
- const info = unwrap(
652
- await st.client.session.get({
653
- path: { id: st.sid },
654
- ...(deps.directory ? { query: { directory: deps.directory } } : {}),
655
- }),
656
- )
657
- const metadata = { ...(info?.metadata ?? {}) }
658
- if (value === null) delete metadata[METADATA_KEY]
659
- else metadata[METADATA_KEY] = value
660
- unwrap(
661
- await st.client.session.update({
662
- path: { id: st.sid },
663
- body: { metadata },
664
- ...(deps.directory ? { query: { directory: deps.directory } } : {}),
665
- }),
666
- )
667
- } catch (e) {
668
- log("metadata write failed (spool still covers reconnects):", (e as Error)?.message || e)
669
- }
679
+ // the read-modify-write runs inside the shared per-session queue: the
680
+ // TUI patches the same metadata (opt-outs, collisions) and interleaved
681
+ // RMWs silently lose one side's update (see binding-queue.ts)
682
+ await queueBindingWrite(st.sid, async () => {
683
+ try {
684
+ const info = unwrap(
685
+ await st.client!.session.get({
686
+ path: { id: st.sid },
687
+ ...(deps.directory ? { query: { directory: deps.directory } } : {}),
688
+ }),
689
+ )
690
+ const metadata = { ...(info?.metadata ?? {}) }
691
+ if (value === null) delete metadata[METADATA_KEY]
692
+ else metadata[METADATA_KEY] = value
693
+ unwrap(
694
+ await st.client!.session.update({
695
+ path: { id: st.sid },
696
+ body: { metadata },
697
+ ...(deps.directory ? { query: { directory: deps.directory } } : {}),
698
+ }),
699
+ )
700
+ } catch (e) {
701
+ log("metadata write failed (spool still covers reconnects):", (e as Error)?.message || e)
702
+ }
703
+ })
670
704
  }
671
705
 
672
706
  /** Parse the metadata binding: v2 map ({v:2, servers:{url:{id,secret,
@@ -777,21 +811,51 @@ export function createEngine(deps: EngineDeps): Engine {
777
811
  const spoolSave = (st: SessionState, r: RemoteState) => {
778
812
  try {
779
813
  writeFileSync(metaPath(st, r.url), JSON.stringify({ shareId: r.shareId, secret: r.secret, ackedRev: r.ackedRev, sid: st.sid, url: r.url }))
780
- writeFileSync(queuePath(st, r.url), r.pending.map((f) => JSON.stringify(f)).join("\n") + (r.pending.length ? "\n" : ""))
814
+ // the queue line IS the wire frame when prebuilt (single
815
+ // serialization end-to-end); a pending frame without wire (legacy
816
+ // spool load) serializes here
817
+ writeFileSync(queuePath(st, r.url), r.pending.map((f) => f.wire ?? JSON.stringify(f)).join("\n") + (r.pending.length ? "\n" : ""))
818
+ // the migrated content now lives in the new queue (or was acked):
819
+ // retire the single-server-era files so they don't accumulate forever
820
+ rmSync(legacyMetaPath(st), { force: true })
821
+ rmSync(legacyQueuePath(st), { force: true })
781
822
  } catch (e) {
782
823
  logError("spool save failed", (e as Error)?.message || e)
783
824
  }
784
825
  }
785
826
  // hot path: appending one pending frame must not re-serialize the whole
786
827
  // queue (O(n^2) during backfill bursts) — append the line instead
787
- const spoolAppend = (st: SessionState, r: RemoteState, f: { items: unknown[]; status: string }) => {
828
+ const spoolAppend = (st: SessionState, r: RemoteState, f: { items: unknown[]; status: string; seq?: number; wire?: string }) => {
788
829
  try {
789
- appendFileSync(queuePath(st, r.url), JSON.stringify(f) + "\n")
830
+ appendFileSync(queuePath(st, r.url), (f.wire ?? JSON.stringify(f)) + "\n")
790
831
  } catch (e) {
832
+ // the frame lives ONLY in memory now — flag the remote so the next
833
+ // spoolLoad does NOT replace in-memory pending with the shorter
834
+ // disk queue (silent loss otherwise; live-caught class in review)
835
+ r.spoolAppendFailed = true
791
836
  logError("spool append failed", (e as Error)?.message || e)
792
837
  }
793
838
  }
839
+ /** Debounced ack-path spool rewrite: one ack per chunk during a
840
+ * multi-chunk replay each rewrote the WHOLE queue file (O(n^2) —
841
+ * ~160MB of sync disk churn on a 25-chunk outage recovery). A crash
842
+ * inside the 300ms window only loses "these frames were acked" — the
843
+ * next connect replays idempotent keyed upserts, which the protocol
844
+ * already tolerates. init-ok keeps the immediate save (binding/
845
+ * recovery info). */
846
+ const scheduleSpoolSave = (st: SessionState, r: RemoteState) => {
847
+ if (r.spoolSaveTimer) return
848
+ r.spoolSaveTimer = setTimeout(() => {
849
+ r.spoolSaveTimer = null
850
+ if (!st.closed) spoolSave(st, r)
851
+ }, 300)
852
+ }
794
853
  const spoolLoad = (st: SessionState, r: RemoteState) => {
854
+ // meta first (share binding recovery), in its OWN try: a missing meta
855
+ // file is normal (it's only written after the first successful
856
+ // handshake) and must NOT touch pending (live-caught class: the old
857
+ // blanket catch wiped in-memory pending AND skipped the queue read,
858
+ // so everything queued before the first successful connect was lost).
795
859
  try {
796
860
  const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"))
797
861
  // metadata binding (db) is authoritative when both exist and disagree
@@ -800,23 +864,40 @@ export function createEngine(deps: EngineDeps): Engine {
800
864
  r.secret = meta.secret
801
865
  r.ackedRev = meta.ackedRev ?? 0
802
866
  }
867
+ } catch {}
868
+ try {
803
869
  if (existsSync(queuePath(st, r.url))) {
804
870
  // append-only spool: a crash mid-append can tear the LAST line —
805
- // keep the valid prefix instead of dropping the whole queue
806
- r.pending = []
871
+ // keep the valid prefix instead of dropping the whole queue.
872
+ // The disk queue is the authority: every in-memory frame was
873
+ // appended before it was tracked, so replacing pending with the
874
+ // parsed queue loses nothing. A FAILED read keeps in-memory
875
+ // pending untouched.
876
+ const disk: Array<{ items: unknown[]; status: string; seq?: number }> = []
807
877
  for (const line of readFileSync(queuePath(st, r.url), "utf8").split("\n")) {
808
878
  if (!line.trim()) continue
809
879
  try {
810
- r.pending.push(JSON.parse(line))
880
+ disk.push(JSON.parse(line))
811
881
  } catch {
812
882
  log(`spool load: dropped torn tail line for ${st.sid.slice(0, 18)}`)
813
883
  break
814
884
  }
815
885
  }
886
+ // seq hygiene: re-stamp pre-seq spool lines (an ack without seq
887
+ // would otherwise clear ALL pending — the H2 wipe, once per
888
+ // upgrade) and seed the counter past every restored seq (a
889
+ // restart resetting to 0 would collide new frames with old ones)
890
+ for (const f of disk) {
891
+ if (typeof f.seq !== "number") f.seq = ++nextSeq
892
+ else if (f.seq >= nextSeq) nextSeq = f.seq + 1
893
+ }
894
+ // a failed append means the disk queue can be SHORTER than
895
+ // memory — the disk is only authoritative when every append
896
+ // landed (otherwise the replace would drop the memory-only frame)
897
+ if (!r.spoolAppendFailed) r.pending = disk
898
+ else r.spoolAppendFailed = false
816
899
  }
817
- } catch {
818
- r.pending = []
819
- }
900
+ } catch {}
820
901
  // single-server-era spool (no url suffix): belongs to the first target
821
902
  if (!r.shareId && targets[0]?.url === r.url) {
822
903
  try {
@@ -838,12 +919,27 @@ export function createEngine(deps: EngineDeps): Engine {
838
919
  }
839
920
  const spoolClear = (st: SessionState, r: RemoteState) => {
840
921
  try {
922
+ if (r.spoolSaveTimer) {
923
+ clearTimeout(r.spoolSaveTimer)
924
+ r.spoolSaveTimer = null
925
+ }
841
926
  rmSync(metaPath(st, r.url), { force: true })
842
927
  rmSync(queuePath(st, r.url), { force: true })
928
+ // single-server-era files die with the session too
929
+ rmSync(legacyMetaPath(st), { force: true })
930
+ rmSync(legacyQueuePath(st), { force: true })
843
931
  } catch {}
844
932
  }
845
933
 
846
934
  // ---- websocket transport (per remote) ----------------------------------
935
+ /** Monotonic client frame sequence: every chunk-frame gets one; the
936
+ * server echoes it in the ack, and the plugin clears exactly that
937
+ * chunk from pending. (Live-caught class: one logical frame split into
938
+ * N chunks — the worker acks EACH chunk, and a blanket "pending = []"
939
+ * on the first ack erased chunks 2..N; a socket death right there lost
940
+ * them permanently.) */
941
+ let nextSeq = 0
942
+
847
943
  const wsSend = (r: RemoteState, obj: unknown) => {
848
944
  if (debugEvents) log(`ws ${r.url.slice(0, 24)} #${r.socketSeq} send ${(obj as { op?: string })?.op} (ready=${r.wsReady})`)
849
945
  try {
@@ -861,31 +957,61 @@ export function createEngine(deps: EngineDeps): Engine {
861
957
  * and rebroadcast; worker apply is keyed-idempotent, so chunks just
862
958
  * bump rev. An item larger than the whole budget rides alone in its
863
959
  * own frame (the worker fits oversize items to its storage cap).
864
- * Status rides each chunk; last write wins. */
865
- const chunkItems = (items: unknown[], maxItems: number, maxBytes: number): unknown[][] => {
866
- const chunks: unknown[][] = []
960
+ * Status rides each chunk; last write wins.
961
+ * Returns each chunk's PREBUILT items JSON: the wire frame and the
962
+ * spool line are derived from it by concatenation — every item used
963
+ * to be serialized 3-5x per remote per flush (chunk measure, spool
964
+ * append, ws send, sendSync re-chunk). */
965
+ const chunkedFrames = (items: unknown[], maxItems: number, maxBytes: number): Array<{ items: unknown[]; json: string }> => {
966
+ const chunks: Array<{ items: unknown[]; json: string }> = []
867
967
  let cur: unknown[] = []
968
+ let curJson: string[] = []
868
969
  let curBytes = 0
869
970
  for (const it of items) {
870
- const n = (JSON.stringify(it) ?? "null").length
871
- if (cur.length && (cur.length >= maxItems || curBytes + n > maxBytes)) {
872
- chunks.push(cur)
971
+ const j = JSON.stringify(it) ?? "null"
972
+ if (cur.length && (cur.length >= maxItems || curBytes + j.length > maxBytes)) {
973
+ chunks.push({ items: cur, json: "[" + curJson.join(",") + "]" })
873
974
  cur = []
975
+ curJson = []
874
976
  curBytes = 0
875
977
  }
876
978
  cur.push(it)
877
- curBytes += n
979
+ curJson.push(j)
980
+ curBytes += j.length
878
981
  }
879
- if (cur.length) chunks.push(cur)
982
+ if (cur.length) chunks.push({ items: cur, json: "[" + curJson.join(",") + "]" })
880
983
  return chunks
881
984
  }
882
- const sendSync = (r: RemoteState, f: { items: unknown[]; status: string }) => {
883
- if (!f.items.length) return wsSend(r, { op: "sync", items: [], status: f.status })
884
- for (const chunk of chunkItems(f.items, 200, 512 * 1024)) {
885
- wsSend(r, { op: "sync", items: chunk, status: f.status })
985
+ /** legacy chunker (count/byte split without prebuilt JSON) — replay
986
+ * paths that only need the split */
987
+ const chunkItems = (items: unknown[], maxItems: number, maxBytes: number): unknown[][] =>
988
+ chunkedFrames(items, maxItems, maxBytes).map((c) => c.items)
989
+ /** the wire sync frame, built by concatenation over the prebuilt items
990
+ * JSON — byte-identical to JSON.stringify({op,items,status,seq}) */
991
+ const syncFrameStr = (itemsJson: string, status: string, seq: number | undefined) =>
992
+ `{"op":"sync","items":${itemsJson},"status":${JSON.stringify(status)}${seq !== undefined ? `,"seq":${seq}` : ""}}`
993
+ const wsSendRaw = (r: RemoteState, s: string) => {
994
+ if (debugEvents) log(`ws ${r.url.slice(0, 24)} #${r.socketSeq} send raw (${Math.round(s.length / 1024)}KB, ready=${r.wsReady})`)
995
+ try {
996
+ r.ws?.send(s)
997
+ } catch {
998
+ return false
886
999
  }
887
1000
  return true
888
1001
  }
1002
+ const sendSync = (r: RemoteState, f: { items: unknown[]; status: string; seq?: number; wire?: string }) => {
1003
+ if (!f.items.length) return wsSend(r, { op: "sync", items: [], status: f.status, seq: f.seq })
1004
+ // prebuilt wire (the hot flush path): one serialization total
1005
+ if (f.wire) return wsSendRaw(r, f.wire)
1006
+ // replayed/legacy frames: chunk-shaped by construction — only an
1007
+ // oversize one (a pre-chunking spool line) needs re-splitting
1008
+ const itemsJson = JSON.stringify(f.items)
1009
+ if (f.items.length <= 200 && itemsJson.length <= 512 * 1024) {
1010
+ return wsSendRaw(r, syncFrameStr(itemsJson, f.status, f.seq))
1011
+ }
1012
+ for (const c of chunkedFrames(f.items, 200, 512 * 1024)) wsSendRaw(r, syncFrameStr(c.json, f.status, f.seq))
1013
+ return true
1014
+ }
889
1015
 
890
1016
  const announceUrl = (st: SessionState, r: RemoteState) => {
891
1017
  if (r.announced) return
@@ -1062,7 +1188,12 @@ export function createEngine(deps: EngineDeps): Engine {
1062
1188
  logError(`sharing to ${r.url} disabled (no credential): ${cred.error}`)
1063
1189
  return
1064
1190
  }
1065
- const ws = new WebSocket(`${wsUrl(r.url)}/api/ws?token=${encodeURIComponent(token ?? "")}`)
1191
+ // frame auth: when the server advertises it, keep the token off the
1192
+ // URL (query strings land in proxy/access logs). The auth frame
1193
+ // rides the socket in order — the server sets its identity before
1194
+ // our init arrives. Old servers fall back to ?token=.
1195
+ const useFrameAuth = !!token && frameAuthUrls.has(r.url)
1196
+ const ws = new WebSocket(`${wsUrl(r.url)}/api/ws${useFrameAuth ? "" : `?token=${encodeURIComponent(token ?? "")}`}`)
1066
1197
  r.ws = ws
1067
1198
  await new Promise((resolve, reject) => {
1068
1199
  const t = setTimeout(() => reject(new Error("ws open timeout")), 15000)
@@ -1100,6 +1231,31 @@ export function createEngine(deps: EngineDeps): Engine {
1100
1231
  if (!st.closed) scheduleReconnect(st, r)
1101
1232
  })
1102
1233
 
1234
+ if (useFrameAuth) {
1235
+ wsSend(r, { op: "auth", token })
1236
+ // the auth and init frames can interleave in the worker's
1237
+ // concurrent webSocketMessage invocations — wait for the authed
1238
+ // ack before authenticating anything (timeout -> fall back to a
1239
+ // query-token reconnect)
1240
+ const authed = await new Promise<boolean>((resolve) => {
1241
+ const t = setTimeout(() => resolve(false), 3000)
1242
+ r.authWaiter = () => {
1243
+ clearTimeout(t)
1244
+ resolve(true)
1245
+ }
1246
+ })
1247
+ r.authWaiter = undefined
1248
+ if (!authed) {
1249
+ logError(`ws ${r.url.slice(0, 24)}: frame-auth ack timeout - reconnecting with query token`)
1250
+ frameAuthUrls.delete(r.url)
1251
+ try {
1252
+ ws.close(4000, "auth timeout")
1253
+ } catch {}
1254
+ if (!st.closed) scheduleReconnect(st, r)
1255
+ return
1256
+ }
1257
+ }
1258
+
1103
1259
  if (r.initWasAttach) {
1104
1260
  wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } })
1105
1261
  } else {
@@ -1129,7 +1285,9 @@ export function createEngine(deps: EngineDeps): Engine {
1129
1285
 
1130
1286
  const scheduleReconnect = (st: SessionState, r: RemoteState) => {
1131
1287
  if (st.closed || r.reconnectTimer) return
1132
- const wait = r.backoff
1288
+ // jitter (±20%) so fan-out remotes and multiple sessions never sync
1289
+ // their retry storms onto the same second
1290
+ const wait = Math.max(250, Math.round(r.backoff * (0.8 + Math.random() * 0.4)))
1133
1291
  r.backoff = Math.min(r.backoff * 2, 60000)
1134
1292
  r.reconnectTimer = setTimeout(() => {
1135
1293
  r.reconnectTimer = null
@@ -1215,6 +1373,15 @@ export function createEngine(deps: EngineDeps): Engine {
1215
1373
  }
1216
1374
 
1217
1375
  const handleFrame = async (st: SessionState, r: RemoteState, frame: Record<string, unknown>) => {
1376
+ if (frame.op === "authed") {
1377
+ // frame-auth handshake ack: release the init (the auth and init
1378
+ // frames can interleave server-side — webSocketMessage invocations
1379
+ // run concurrently — so the init must WAIT for this, else it
1380
+ // authenticates as anonymous/tokenless)
1381
+ r.authWaiter?.()
1382
+ r.authWaiter = undefined
1383
+ return
1384
+ }
1218
1385
  if (frame.op === "init") {
1219
1386
  if (!frame.ok) {
1220
1387
  const err = String(frame.error ?? "")
@@ -1268,6 +1435,19 @@ export function createEngine(deps: EngineDeps): Engine {
1268
1435
  // bad base (mismatch protocol). Content REPLAYS to the new share —
1269
1436
  // pending is never dropped on a reset (live-caught: the recreate
1270
1437
  // husk had the title but zero messages).
1438
+ if (/limit|too many|rate/i.test(err)) {
1439
+ // quota/429 ("max shares reached", "daily create limit", the
1440
+ // anonymous caps): an immediate re-init on the same socket is a
1441
+ // hot loop against the server — back off through the normal
1442
+ // reconnect cadence instead
1443
+ logError(`${r.url}: create refused:`, frame.error, "- retrying with backoff")
1444
+ r.connecting = false
1445
+ try {
1446
+ r.ws?.close(4000, "rate limited")
1447
+ } catch {}
1448
+ if (!st.closed) scheduleReconnect(st, r)
1449
+ return
1450
+ }
1271
1451
  logError(`${r.url}: init refused:`, frame.error, "- creating fresh share")
1272
1452
  deleteAbandoned(r)
1273
1453
  r.shareId = null
@@ -1298,11 +1478,23 @@ export function createEngine(deps: EngineDeps): Engine {
1298
1478
  r.connecting = false
1299
1479
  r.initWasAttach = false
1300
1480
  r.collided = null
1481
+ // a successful handshake proves write access: an admin unfreeze
1482
+ // revives the remote from here (frozenRemote is otherwise sticky)
1483
+ if (r.frozenRemote && !frame.error) r.frozenRemote = false
1301
1484
  if (r.initTimer) {
1302
1485
  clearTimeout(r.initTimer)
1303
1486
  r.initTimer = null
1304
1487
  }
1305
- r.backoff = 1000
1488
+ // backoff reset is DEFERRED until the socket proves stable: a
1489
+ // server that accepts init then immediately drops would otherwise
1490
+ // reconnect once per second forever (~1 cycle/s per session-server
1491
+ // pair, live-caught in the optimization audit). 30s alive (socket
1492
+ // still ready, no reconnect pending) or a second acked sync.
1493
+ if (r.backoffResetTimer) clearTimeout(r.backoffResetTimer)
1494
+ r.backoffResetTimer = setTimeout(() => {
1495
+ r.backoffResetTimer = null
1496
+ if (r.wsReady && !r.reconnectTimer) r.backoff = 1000
1497
+ }, 30000)
1306
1498
  st.everConnected = true
1307
1499
  if (frame.policy) applyPolicy(frame.policy, r.url)
1308
1500
  r.intentionalClose = false
@@ -1324,19 +1516,67 @@ export function createEngine(deps: EngineDeps): Engine {
1324
1516
  if (wasFanoutCreate) {
1325
1517
  // a server joined an ALREADY-live session (fan-out add, or a
1326
1518
  // post-mismatch recreate): its fresh share needs the full history —
1327
- // backfill is keyed-idempotent, other servers just re-upsert
1519
+ // backfill is keyed-idempotent, other servers just re-upsert.
1520
+ // The enqueue dedupe must step aside: those hashes say "already
1521
+ // entered the machinery", but the NEW remote never received any
1522
+ // of it (live-caught by F3: the join's backfill was fully
1523
+ // deduped away and the joined share stayed empty).
1524
+ st.sentHash.clear()
1328
1525
  chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", (e as Error)?.message || e))
1329
1526
  }
1330
1527
  return
1331
1528
  }
1332
1529
  if (frame.op === "ack") {
1333
1530
  r.ackedRev = (frame.rev as number) ?? r.ackedRev
1334
- r.pending = []
1335
- spoolSave(st, r)
1531
+ // a second acked sync also proves the socket stable — release the
1532
+ // backoff reset timer early (complements the 30s-alive timer)
1533
+ if (r.backoffResetTimer) {
1534
+ clearTimeout(r.backoffResetTimer)
1535
+ r.backoffResetTimer = null
1536
+ r.backoff = 1000
1537
+ }
1538
+ // seq-scoped retirement: an ack identifies the chunk it applied.
1539
+ // An ack WITHOUT seq = old server: legacy clear-all (correct there —
1540
+ // old servers acked the whole pending batch together).
1541
+ if (typeof frame.seq === "number") {
1542
+ r.pending = r.pending.filter((p) => p.seq !== frame.seq)
1543
+ } else {
1544
+ r.pending = []
1545
+ }
1546
+ scheduleSpoolSave(st, r)
1336
1547
  scheduleBindingWrite(st)
1337
1548
  return
1338
1549
  }
1339
1550
  if (frame.op === "nack" || frame.op === "error") {
1551
+ const errText = String(frame.error ?? "")
1552
+ if (frame.op === "nack") {
1553
+ // classify: the share dying server-side under a live socket
1554
+ // (sweeper TTL, admin delete, purge) surfaces as nacks — the
1555
+ // socket is "healthy" so nothing else recovers it. Same reset as
1556
+ // the init-refused path: fresh create + content replay.
1557
+ if (/not found|gone/i.test(errText)) {
1558
+ logError(`${r.url}: share vanished server-side (nack: ${errText}) - recreating`)
1559
+ deleteAbandoned(r)
1560
+ r.shareId = null
1561
+ r.secret = null
1562
+ r.ackedRev = 0
1563
+ try {
1564
+ r.ws?.close(4000, "share gone")
1565
+ } catch {}
1566
+ if (!st.closed) queueConnect(st, r)
1567
+ return
1568
+ }
1569
+ if (/frozen/i.test(errText)) {
1570
+ // owner frozen mid-session: every write is refused. Stop
1571
+ // spooling NEW frames for this remote (unbounded growth) but
1572
+ // KEEP what's already queued — an unfreeze revives the remote
1573
+ // on the next successful handshake (init-ok clears the flag)
1574
+ // and the backlog replays from there.
1575
+ r.frozenRemote = true
1576
+ logError(`${r.url}: share frozen by the hoster - edits refused; contact the server admin`)
1577
+ return
1578
+ }
1579
+ }
1340
1580
  logError(`${r.url}: worker refused frame:`, JSON.stringify(frame).slice(0, 200))
1341
1581
  // the control socket lost its attach state (worker-side eviction of
1342
1582
  // the control DO): force a reconnect — init attach replays pending
@@ -1371,7 +1611,7 @@ export function createEngine(deps: EngineDeps): Engine {
1371
1611
  // stamps run at frame time: the whole family's data is indexed by now
1372
1612
  // (child prompts are created during the task call, well inside the
1373
1613
  // flush debounce), and any re-enqueued message lands in this frame
1374
- stampsPass()
1614
+ stampsPass(st)
1375
1615
  const items = Array.from(st.queue.values())
1376
1616
  st.queue.clear()
1377
1617
  const statusChanged = st.status !== st.lastSentStatus
@@ -1393,44 +1633,99 @@ export function createEngine(deps: EngineDeps): Engine {
1393
1633
  if (!f) return
1394
1634
  // fan out to every remote; each carries its own pending copy and acks
1395
1635
  // independently. Remotes mid-handshake or auth-blocked just buffer.
1636
+ // PENDING IS CHUNK-SHAPED (seq-tagged): one logical frame becomes N
1637
+ // chunk-frames so a partial-ack can never erase unsent chunks
1638
+ // (live-caught class: chunk-1 ack + socket death erased chunks 2..N).
1639
+ const chunks: Array<{ items: unknown[]; json: string }> = f.items.length ? chunkedFrames(f.items, 200, 512 * 1024) : []
1396
1640
  let httpWork: Promise<unknown> | undefined
1397
1641
  for (const r of st.remotes.values()) {
1642
+ if (r.frozenRemote) continue // refused server-side; stop spooling
1643
+ const fresh: Array<{ items: unknown[]; status: string; seq: number; wire?: string }> = []
1644
+ if (chunks.length) {
1645
+ for (const c of chunks) {
1646
+ const seq = ++nextSeq
1647
+ const cf = { items: c.items, status: f.status, seq, wire: syncFrameStr(c.json, f.status, seq) }
1648
+ fresh.push(cf)
1649
+ r.pending.push(cf)
1650
+ spoolAppend(st, r, cf)
1651
+ }
1652
+ } else {
1653
+ // status-only frame (empty items): one chunk-less frame
1654
+ const seq = ++nextSeq
1655
+ const cf = { items: [] as unknown[], status: f.status, seq }
1656
+ fresh.push(cf)
1657
+ r.pending.push(cf)
1658
+ spoolAppend(st, r, cf)
1659
+ }
1398
1660
  // never send syncs before the init ack: the worker's per-socket
1399
1661
  // ControlDO has no attach until OUR init completes, and a racing
1400
1662
  // sync dies as "unknown op" (live-caught: post-drop reconnects
1401
1663
  // lost whole turns). The init-ok handler flushes pending itself.
1402
- r.pending.push(f)
1403
- spoolAppend(st, r, f)
1404
1664
  if (r.ws && !r.wsReady) continue
1405
1665
  if (r.wsReady) {
1406
- sendSync(r, f)
1666
+ for (const cf of fresh) sendSync(r, cf)
1407
1667
  continue
1408
1668
  }
1409
1669
  const viaHttp = flushViaHttp(st, r)
1410
1670
  if (viaHttp) httpWork = (httpWork ?? Promise.resolve()).then(() => viaHttp)
1411
1671
  }
1412
1672
  if (st.remotes.size) st.lastFlushAt = Date.now()
1673
+ // the HTTP work runs detached from the flush chain (one dead server
1674
+ // must not stall the fan-out); dispose awaits its own final flush
1675
+ if (httpWork) void httpWork.catch((e) => logError("http flush error", (e as Error)?.message || e))
1413
1676
  return httpWork
1414
1677
  }
1415
1678
 
1679
+ /** Client-side key derivation mirroring the server's itemKey — used only
1680
+ * for deduping the HTTP fallback batch (keyed upserts: the LAST item
1681
+ * for a key wins, matching apply() semantics). */
1682
+ const httpDedupeKey = (it: unknown): string | null => {
1683
+ if (!it || typeof it !== "object") return null
1684
+ const o = it as { type?: string; data?: { id?: string; messageID?: string; sessionID?: string }; sessionID?: string }
1685
+ switch (o.type) {
1686
+ case "session": return "session/" + (o.data?.id ?? "")
1687
+ case "session_diff": return "session_diff/" + (o.sessionID ?? o.data?.sessionID ?? "")
1688
+ case "message": return "message/" + (o.data?.id ?? "")
1689
+ case "part": return "part/" + (o.data?.messageID ?? "") + "/" + (o.data?.id ?? "")
1690
+ case "model": return "model/" + (o.sessionID ?? o.data?.sessionID ?? "")
1691
+ case "prefs": return "prefs"
1692
+ default: return null
1693
+ }
1694
+ }
1695
+
1416
1696
  const flushViaHttp = async (st: SessionState, r: RemoteState) => {
1417
1697
  if (!r.shareId || !r.pending.length) return
1418
1698
  // ALL pending frames, merged (keyed upserts — later wins). Sending
1419
1699
  // only the last frame dropped every earlier batch's items whenever
1420
1700
  // the HTTP fallback carried more than one (live-caught class:
1421
- // content loss on reconnect paths).
1422
- const items: unknown[] = []
1701
+ // content loss on reconnect paths). DEDUPE BY KEY before chunking:
1702
+ // a streaming message appearing in k pending frames shipped k
1703
+ // copies (up to 25x on a replay burst) — every duplicate was also a
1704
+ // server row-write + a rebroadcast frame.
1705
+ const byKey = new Map<string, unknown>()
1706
+ const unkeyed: unknown[] = []
1423
1707
  let status: string | undefined
1708
+ const sentSeqs = new Set<number>()
1424
1709
  for (const f of r.pending) {
1425
- for (const it of f.items as unknown[]) items.push(it)
1710
+ for (const it of f.items as unknown[]) {
1711
+ const k = httpDedupeKey(it)
1712
+ if (k) byKey.set(k, it)
1713
+ else unkeyed.push(it)
1714
+ }
1426
1715
  if (f.status) status = f.status
1716
+ if (typeof f.seq === "number") sentSeqs.add(f.seq)
1427
1717
  }
1718
+ const items = [...byKey.values(), ...unkeyed]
1428
1719
  const cred = credOf(r.url)
1429
1720
  // chunked like WS frames: one unbounded body is a whole-backlog
1430
1721
  // bomb after a mid-backfill socket drop (backfill = full history).
1431
1722
  // Failure stops the loop with pending intact — replaying already-
1432
1723
  // applied chunks is idempotent.
1433
- for (const chunk of chunkItems(items, 500, 4 * 1024 * 1024)) {
1724
+ // status-only frames (items empty, e.g. the terminal completed
1725
+ // flush in HTTP-only mode) still need ONE request or the status
1726
+ // never lands (the share stays "live" forever)
1727
+ const chunks = items.length ? chunkItems(items, 500, 4 * 1024 * 1024) : [[]]
1728
+ for (const chunk of chunks) {
1434
1729
  const res = await fetch(`${r.url}/api/share/${r.shareId}/sync`, {
1435
1730
  method: "POST",
1436
1731
  headers: { "content-type": "application/json", ...(cred.credential ? { authorization: `Bearer ${cred.credential}` } : {}) },
@@ -1441,8 +1736,12 @@ export function createEngine(deps: EngineDeps): Engine {
1441
1736
  const body = (await res.json().catch(() => ({}))) as { rev?: number }
1442
1737
  r.ackedRev = body.rev ?? r.ackedRev
1443
1738
  }
1444
- r.pending = []
1445
- spoolSave(st, r)
1739
+ // retire EXACTLY what this run sent: a concurrent flush may have
1740
+ // queued new frames while our fetches were in flight (the HTTP work
1741
+ // is deliberately detached from the flush chain) — a blanket clear
1742
+ // would erase them before anything transmitted them
1743
+ r.pending = r.pending.filter((p) => !sentSeqs.has(p.seq as number))
1744
+ scheduleSpoolSave(st, r)
1446
1745
  }
1447
1746
 
1448
1747
  const scheduleFlush = (st: SessionState) => {
@@ -1457,12 +1756,38 @@ export function createEngine(deps: EngineDeps): Engine {
1457
1756
  st.timer = setTimeout(() => {
1458
1757
  st.timer = null
1459
1758
  if (debugEvents) log(`flush timer fired sid=${st.sid.slice(0, 18)} queue=${st.queue.size} remotes=${st.remotes.size}`)
1460
- chain = chain.then(() => flush(st)).catch((e) => logError("flush error", (e as Error)?.message || e))
1759
+ // NOTE: the flush's HTTP-fallback promise is deliberately NOT
1760
+ // chained — one black-holing server (15s timeout per chunk) must
1761
+ // not stall every other remote's flushes behind it. Dispose awaits
1762
+ // its own final flush explicitly.
1763
+ chain = chain
1764
+ .then(() => {
1765
+ flush(st)
1766
+ })
1767
+ .catch((e) => logError("flush error", (e as Error)?.message || e))
1461
1768
  }, wait)
1462
1769
  }
1463
1770
 
1464
1771
  const enqueue = (st: SessionState, entry: ItemEntry | undefined) => {
1465
1772
  if (!st || !entry) return
1773
+ // content dedupe at the machinery boundary: an identical re-feed has
1774
+ // either entered the queue already or been flushed into every
1775
+ // remote's pending/spool (the per-remote delivery machinery owns it
1776
+ // from there) — re-sending it bumps rev, rewrites storage and
1777
+ // re-folds every viewer for nothing. This is the engine-side home
1778
+ // the v2-adapter dedupe lacked (an adapter-side cache poisoned while
1779
+ // dormant and skipped the flag-time content — live-caught).
1780
+ let h: string
1781
+ try {
1782
+ h = createHash("sha256").update(JSON.stringify(entry.item)).digest("hex")
1783
+ } catch {
1784
+ h = ""
1785
+ }
1786
+ if (h && st.sentHash.get(entry.key) === h) {
1787
+ if (debugEvents) log(`enqueue dedupe ${entry.item.type} ${entry.key.slice(0, 40)}`)
1788
+ return
1789
+ }
1790
+ if (h) st.sentHash.set(entry.key, h)
1466
1791
  st.queue.set(entry.key, entry.item)
1467
1792
  indexMessage(entry)
1468
1793
  if (debugEvents) log(`enqueue sid=${st.sid.slice(0, 18)} ${entry.item.type} queue=${st.queue.size}`)
@@ -1485,6 +1810,12 @@ export function createEngine(deps: EngineDeps): Engine {
1485
1810
  data: Record<string, unknown>
1486
1811
  }
1487
1812
  const msgIndex = new Map<string, IndexedMessage>() // `${sid}:${messageID}`
1813
+ /** message ids tombstoned this run: stampsPass must never re-upsert an
1814
+ * undone message (live-caught class: dropMergedMessage removed the
1815
+ * merged entry but msgIndex kept the corpse; a neighboring-segment
1816
+ * change recomputed its stamps and reEnqueueMessage resurrected the
1817
+ * deleted turn server-side). */
1818
+ const tombstoned = new Set<string>()
1488
1819
 
1489
1820
  const indexMessage = (entry: ItemEntry) => {
1490
1821
  if (entry.item.type !== "message") return
@@ -1514,6 +1845,9 @@ export function createEngine(deps: EngineDeps): Engine {
1514
1845
  sessions.get(childToParent.get(sid) ?? sid)
1515
1846
 
1516
1847
  const reEnqueueMessage = (m: IndexedMessage) => {
1848
+ // tombstone guard: an undone message stays dead even if a stamp pass
1849
+ // recomputes its segment metadata
1850
+ if (tombstoned.has(`${m.sid}:${m.data.id}`)) return
1517
1851
  const st = sessionStateOf(m.sid)
1518
1852
  if (st) enqueue(st, itemFor("message", m.data, m.sid))
1519
1853
  }
@@ -1534,13 +1868,7 @@ export function createEngine(deps: EngineDeps): Engine {
1534
1868
  /** user messages whose segment runs a DIFFERENT agent than the previous
1535
1869
  * segment carry metadata.agentSwitch = <agent> - the viewer renders the
1536
1870
  * transition chip from the stamp alone. */
1537
- const stampAgentSwitches = () => {
1538
- const bySession = new Map<string, IndexedMessage[]>()
1539
- for (const m of msgIndex.values()) {
1540
- const list = bySession.get(m.sid)
1541
- if (list) list.push(m)
1542
- else bySession.set(m.sid, [m])
1543
- }
1871
+ const stampAgentSwitches = (bySession: Map<string, IndexedMessage[]>) => {
1544
1872
  for (const [, list] of bySession) {
1545
1873
  list.sort((a, b) => a.created - b.created)
1546
1874
  const users = list.map((m, i) => (m.role === "user" ? i : -1)).filter((i) => i >= 0)
@@ -1548,11 +1876,19 @@ export function createEngine(deps: EngineDeps): Engine {
1548
1876
  for (let k = 0; k < users.length; k++) {
1549
1877
  const agent = segmentAgent(list, users[k])
1550
1878
  const d = list[users[k]].data as Record<string, any>
1551
- const meta = (d.metadata ??= {}) as Record<string, unknown>
1552
1879
  const want = k > 0 && agent && prev && agent !== prev ? agent : undefined
1553
- if (want !== (meta.agentSwitch as string | undefined)) {
1554
- if (want) meta.agentSwitch = want
1555
- else delete meta.agentSwitch
1880
+ // only touch metadata when the stamp actually CHANGES — a blanket
1881
+ // `d.metadata ??= {}` polluted every user message (including the
1882
+ // host's live objects on v2 pass-through) with an empty metadata
1883
+ // object, which re-emitted the message once per refresh (rev churn
1884
+ // per feed — live-caught by the enqueue-dedupe rev check)
1885
+ if (want !== (d.metadata?.agentSwitch as string | undefined)) {
1886
+ if (want) {
1887
+ const meta = (d.metadata ??= {}) as Record<string, unknown>
1888
+ meta.agentSwitch = want
1889
+ } else if (d.metadata) {
1890
+ delete d.metadata.agentSwitch
1891
+ }
1556
1892
  reEnqueueMessage(list[users[k]])
1557
1893
  }
1558
1894
  if (agent) prev = agent
@@ -1565,13 +1901,7 @@ export function createEngine(deps: EngineDeps): Engine {
1565
1901
  * started (v1 prompts carry the agent; v2 resolves via the response).
1566
1902
  * Rank-free pairing - a resumed child's second call lands on its second
1567
1903
  * prompt by construction time. */
1568
- const stampTaskAgents = () => {
1569
- const bySession = new Map<string, IndexedMessage[]>()
1570
- for (const m of msgIndex.values()) {
1571
- const list = bySession.get(m.sid)
1572
- if (list) list.push(m)
1573
- else bySession.set(m.sid, [m])
1574
- }
1904
+ const stampTaskAgents = (bySession: Map<string, IndexedMessage[]>) => {
1575
1905
  for (const list of bySession.values()) list.sort((a, b) => a.created - b.created)
1576
1906
  for (const m of msgIndex.values()) {
1577
1907
  if (m.role !== "assistant") continue
@@ -1665,9 +1995,31 @@ export function createEngine(deps: EngineDeps): Engine {
1665
1995
  }
1666
1996
  }
1667
1997
 
1668
- const stampsPass = () => {
1669
- stampAgentSwitches()
1670
- stampTaskAgents()
1998
+ const stampsPass = (st: SessionState) => {
1999
+ // skip entirely when this flush carries no message items: every
2000
+ // index mutation rides a message item through the queue (inserts via
2001
+ // enqueue, deletes via tombstones — both are type "message"), so no
2002
+ // message in the queue means the stamps' inputs are unchanged. A
2003
+ // status-only/model/prefs/diff flush costs zero stamp work.
2004
+ let any = false
2005
+ for (const it of st.queue.values()) {
2006
+ if ((it as { type?: string }).type === "message") {
2007
+ any = true
2008
+ break
2009
+ }
2010
+ }
2011
+ if (!any) return
2012
+ // one grouping + one sort shared by both passes (each used to build
2013
+ // and sort its own identical bySession map per flush)
2014
+ const bySession = new Map<string, IndexedMessage[]>()
2015
+ for (const m of msgIndex.values()) {
2016
+ const list = bySession.get(m.sid)
2017
+ if (list) list.push(m)
2018
+ else bySession.set(m.sid, [m])
2019
+ }
2020
+ for (const list of bySession.values()) list.sort((a, b) => a.created - b.created)
2021
+ stampAgentSwitches(bySession)
2022
+ stampTaskAgents(bySession)
1671
2023
  stampFileLanguages()
1672
2024
  }
1673
2025
 
@@ -1730,8 +2082,15 @@ export function createEngine(deps: EngineDeps): Engine {
1730
2082
  emitMerged(st, sid, part.messageID)
1731
2083
  }
1732
2084
 
1733
- const dropMergedMessage = (st: SessionState, messageID: string) => {
2085
+ const dropMergedMessage = (st: SessionState, messageID: string, evSid?: string) => {
2086
+ // the index corpse must go too: stampsPass iterates msgIndex, and a
2087
+ // re-stamp of a deleted message would re-upsert it (resurrection).
2088
+ // Key by the EVENT's session (children tombstone under their own sid;
2089
+ // the merged map is per main session)
2090
+ const sid = evSid ?? st.sid
1734
2091
  st.merged.delete(messageID)
2092
+ msgIndex.delete(`${sid}:${messageID}`)
2093
+ tombstoned.add(`${sid}:${messageID}`)
1735
2094
  }
1736
2095
 
1737
2096
  const dropMergedPart = (st: SessionState, sid: string, messageID: string, partID: string) => {
@@ -1757,6 +2116,10 @@ export function createEngine(deps: EngineDeps): Engine {
1757
2116
  clearTimeout(r.initTimer)
1758
2117
  r.initTimer = null
1759
2118
  }
2119
+ if (r.backoffResetTimer) {
2120
+ clearTimeout(r.backoffResetTimer)
2121
+ r.backoffResetTimer = null
2122
+ }
1760
2123
  if (r.reconnectTimer) {
1761
2124
  clearTimeout(r.reconnectTimer)
1762
2125
  r.reconnectTimer = null
@@ -1766,8 +2129,28 @@ export function createEngine(deps: EngineDeps): Engine {
1766
2129
  clearTimeout(r.idleDropTimer)
1767
2130
  r.idleDropTimer = null
1768
2131
  }
1769
- if (r.wsReady) wsSend(r, { op: "delete" })
1770
- else if (r.shareId && r.secret) {
2132
+ // wsReady delete only when THIS remote still owns a share: a stray
2133
+ // child teardown clears shareId after adopting into the parent —
2134
+ // the ControlDO's server-side attach would otherwise delete the
2135
+ // very share the parent just inherited (live-caught adoption churn)
2136
+ if (r.wsReady && r.shareId) {
2137
+ wsSend(r, { op: "delete" })
2138
+ // backstop: the WS delete is fire-and-forget on the wire — a
2139
+ // socket dying right after leaves the page public until TTL. The
2140
+ // HTTP goodbye is idempotent (404/ok both fine).
2141
+ if (r.shareId && r.secret) {
2142
+ const sid = r.shareId
2143
+ const sec = r.secret
2144
+ setTimeout(() => {
2145
+ void fetch(`${r.url}/api/share/${sid}`, {
2146
+ method: "DELETE",
2147
+ headers: { "content-type": "application/json" },
2148
+ body: JSON.stringify({ secret: sec }),
2149
+ signal: AbortSignal.timeout(8000),
2150
+ }).catch(() => {})
2151
+ }, 2000)
2152
+ }
2153
+ } else if (r.shareId && r.secret) {
1771
2154
  // turn mode may have dropped the socket after the last settle; the
1772
2155
  // goodbye still must land — say it over HTTP
1773
2156
  void fetch(`${r.url}/api/share/${r.shareId}`, {
@@ -1786,6 +2169,15 @@ export function createEngine(deps: EngineDeps): Engine {
1786
2169
  }
1787
2170
  }, 1000)
1788
2171
  for (const r of st.remotes.values()) spoolClear(st, r)
2172
+ // memory hygiene: the session is gone — its index corpses, tombstone
2173
+ // guards and child routing must not linger (msgIndex/tombstoned are
2174
+ // engine-global and otherwise only grow; sentHash rides the session
2175
+ // state and dies with it)
2176
+ const stSid = st.sid
2177
+ const pfx = `${stSid}:`
2178
+ for (const k of msgIndex.keys()) if (k.startsWith(pfx)) msgIndex.delete(k)
2179
+ for (const k of tombstoned) if (k.startsWith(pfx)) tombstoned.delete(k)
2180
+ for (const [child, parent] of childToParent) if (parent === stSid || child === stSid) childToParent.delete(child)
1789
2181
  sessions.delete(st.sid)
1790
2182
  try {
1791
2183
  chain = chain.then(async () => {
@@ -1858,6 +2250,11 @@ export function createEngine(deps: EngineDeps): Engine {
1858
2250
  if (nextBefore === before) break
1859
2251
  before = nextBefore
1860
2252
  }
2253
+ // the page cap is a hard truncation: surface it instead of silently
2254
+ // sharing a partial history (oldest pages dropped)
2255
+ if (out.length >= MAX_PAGES * PAGE_SIZE) {
2256
+ logError(`backfill truncated at ${out.length} messages (page cap) for ${sid.slice(0, 18)} — oldest history not shared`)
2257
+ }
1861
2258
  return out
1862
2259
  }
1863
2260
 
@@ -2145,8 +2542,13 @@ export function createEngine(deps: EngineDeps): Engine {
2145
2542
  // runs still land their final status.
2146
2543
  if (st.timer) clearTimeout(st.timer)
2147
2544
  st.timer = null
2545
+ // detached flush (HTTP fallback off the shared chain — one
2546
+ // black-holing server must not stall the fan-out; same rule
2547
+ // as scheduleFlush)
2148
2548
  chain = chain
2149
- .then(() => flush(st))
2549
+ .then(() => {
2550
+ flush(st)
2551
+ })
2150
2552
  .catch((e) => logError("idle flush error", (e as Error)?.message || e))
2151
2553
  const epoch = st.statusEpoch
2152
2554
  const settle = () => {
@@ -2155,7 +2557,9 @@ export function createEngine(deps: EngineDeps): Engine {
2155
2557
  if (st.timer) clearTimeout(st.timer)
2156
2558
  st.timer = null
2157
2559
  chain = chain
2158
- .then(() => flush(st))
2560
+ .then(() => {
2561
+ flush(st)
2562
+ })
2159
2563
  .then(() => {
2160
2564
  armSettleDrop(st)
2161
2565
  })
@@ -2195,8 +2599,12 @@ export function createEngine(deps: EngineDeps): Engine {
2195
2599
  const messageID = p.messageID as string | undefined
2196
2600
  if (!messageID) return
2197
2601
  if (t === "message.removed") {
2198
- dropMergedMessage(st, messageID)
2199
- enqueue(st, { key: `message/${messageID}`, item: messageTombstone(st.sid, messageID) })
2602
+ // key/tombstone by the EVENT's session id (a child's message
2603
+ // must not be keyed onto the parent — the worker deletes the
2604
+ // item but the viewer's tombstone key would miss and the
2605
+ // msgIndex corpse could re-upsert it)
2606
+ dropMergedMessage(st, messageID, sid)
2607
+ enqueue(st, { key: `message/${messageID}`, item: messageTombstone(sid, messageID) })
2200
2608
  } else {
2201
2609
  const partID = p.partID as string | undefined
2202
2610
  if (!partID) return
@@ -2326,6 +2734,16 @@ export function createEngine(deps: EngineDeps): Engine {
2326
2734
  await flushViaHttp(st, r).catch(() => {})
2327
2735
  }
2328
2736
  }
2737
+ // resolve any pending debounced spool rewrite before exit: the
2738
+ // queue lines are already on disk (appends are sync), but the
2739
+ // meta's ackedRev and the compacted queue should be final
2740
+ for (const r of st.remotes.values()) {
2741
+ if (r.spoolSaveTimer) {
2742
+ clearTimeout(r.spoolSaveTimer)
2743
+ r.spoolSaveTimer = null
2744
+ spoolSave(st, r)
2745
+ }
2746
+ }
2329
2747
  if (st.metaTimer) clearTimeout(st.metaTimer)
2330
2748
  st.metaTimer = null
2331
2749
  await writeBinding(st).catch(() => {})