@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/README.md +36 -37
- package/ci/README.md +9 -3
- package/ci/openshare.js +379 -85
- package/dist/binding-queue.d.ts +1 -0
- package/dist/binding-queue.js +22 -0
- package/dist/commands.js +41 -285
- package/dist/core.js +524 -88
- package/dist/dialogs.js +1 -1
- package/dist/settings.js +141 -16
- package/dist/share-flow.d.ts +7 -2
- package/dist/share-flow.js +32 -17
- package/dist/sidebar.js +0 -21
- package/dist/tui.js +256 -386
- package/dist/v2-server.js +55 -0
- package/package.json +1 -1
- package/src/binding-queue.ts +26 -0
- package/src/commands.ts +41 -280
- package/src/core.ts +505 -87
- package/src/dialogs.tsx +11 -2
- package/src/settings.ts +133 -15
- package/src/share-flow.ts +33 -19
- package/src/sidebar.tsx +0 -24
- package/src/v2-server.ts +50 -0
package/dist/core.js
CHANGED
|
@@ -42,9 +42,11 @@
|
|
|
42
42
|
// - The plugin strips this key from everything it syncs — the write
|
|
43
43
|
// secret must never reach viewers.
|
|
44
44
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
45
|
+
import { createHash } from "node:crypto";
|
|
45
46
|
import { join } from "node:path";
|
|
46
47
|
import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken } from "./config.js";
|
|
47
48
|
import { cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js";
|
|
49
|
+
import { queueBindingWrite } from "./binding-queue.js";
|
|
48
50
|
const DEFAULT_POLICY = {
|
|
49
51
|
mode: "persistent",
|
|
50
52
|
turnGraceMs: 120000,
|
|
@@ -271,6 +273,8 @@ export function createEngine(deps) {
|
|
|
271
273
|
initTimer: null,
|
|
272
274
|
initWasAttach: false,
|
|
273
275
|
backoff: 1000,
|
|
276
|
+
backoffResetTimer: null,
|
|
277
|
+
spoolSaveTimer: null,
|
|
274
278
|
reconnectTimer: null,
|
|
275
279
|
intentionalClose: false,
|
|
276
280
|
connectQueued: false,
|
|
@@ -336,12 +340,19 @@ export function createEngine(deps) {
|
|
|
336
340
|
if (b && typeof b === "object" && b.policy) {
|
|
337
341
|
applyPolicy(b.policy, url);
|
|
338
342
|
}
|
|
343
|
+
// frame auth capability: the server accepts the token as the
|
|
344
|
+
// first WS frame — lets us keep it off the query string (proxy
|
|
345
|
+
// and access logs otherwise capture the full credential)
|
|
346
|
+
if (b && typeof b === "object" && b.frameAuth) {
|
|
347
|
+
frameAuthUrls.add(url);
|
|
348
|
+
}
|
|
339
349
|
})
|
|
340
350
|
.catch(() => {
|
|
341
351
|
// unreachable worker: defaults stay; the init ack will correct us
|
|
342
352
|
policyAsked.delete(url);
|
|
343
353
|
});
|
|
344
354
|
};
|
|
355
|
+
const frameAuthUrls = new Set();
|
|
345
356
|
const wsUrl = (url) => url.replace(/^http/, "ws");
|
|
346
357
|
// ---- spool root ---------------------------------------------------------
|
|
347
358
|
let spoolRoot = spoolDirFor(cfg);
|
|
@@ -383,6 +394,7 @@ export function createEngine(deps) {
|
|
|
383
394
|
wroteBinding: false,
|
|
384
395
|
everConnected: false,
|
|
385
396
|
merged: new Map(),
|
|
397
|
+
sentHash: new Map(),
|
|
386
398
|
});
|
|
387
399
|
const refreshConfig = () => {
|
|
388
400
|
// settings may change between activations (the TUI writes the config
|
|
@@ -422,6 +434,9 @@ export function createEngine(deps) {
|
|
|
422
434
|
}
|
|
423
435
|
st.activated = true;
|
|
424
436
|
st.opts = opts;
|
|
437
|
+
// re-activation re-backfills the whole session (unshare -> share
|
|
438
|
+
// again): the enqueue dedupe must not skip the returning content
|
|
439
|
+
st.sentHash.clear();
|
|
425
440
|
const gated = mode === "manual" || opts2?.gated === true;
|
|
426
441
|
if (gated) {
|
|
427
442
|
// seed the share status from the session's CURRENT state before
|
|
@@ -496,25 +511,30 @@ export function createEngine(deps) {
|
|
|
496
511
|
async function writeMetadata(st, value) {
|
|
497
512
|
if (!st.client)
|
|
498
513
|
return;
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
metadata
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
514
|
+
// the read-modify-write runs inside the shared per-session queue: the
|
|
515
|
+
// TUI patches the same metadata (opt-outs, collisions) and interleaved
|
|
516
|
+
// RMWs silently lose one side's update (see binding-queue.ts)
|
|
517
|
+
await queueBindingWrite(st.sid, async () => {
|
|
518
|
+
try {
|
|
519
|
+
const info = unwrap(await st.client.session.get({
|
|
520
|
+
path: { id: st.sid },
|
|
521
|
+
...(deps.directory ? { query: { directory: deps.directory } } : {}),
|
|
522
|
+
}));
|
|
523
|
+
const metadata = { ...(info?.metadata ?? {}) };
|
|
524
|
+
if (value === null)
|
|
525
|
+
delete metadata[METADATA_KEY];
|
|
526
|
+
else
|
|
527
|
+
metadata[METADATA_KEY] = value;
|
|
528
|
+
unwrap(await st.client.session.update({
|
|
529
|
+
path: { id: st.sid },
|
|
530
|
+
body: { metadata },
|
|
531
|
+
...(deps.directory ? { query: { directory: deps.directory } } : {}),
|
|
532
|
+
}));
|
|
533
|
+
}
|
|
534
|
+
catch (e) {
|
|
535
|
+
log("metadata write failed (spool still covers reconnects):", e?.message || e);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
518
538
|
}
|
|
519
539
|
/** Parse the metadata binding: v2 map ({v:2, servers:{url:{id,secret,
|
|
520
540
|
* rev,url,enabled?}}}) or legacy flat ({id,secret,rev,url}) which maps
|
|
@@ -635,7 +655,14 @@ export function createEngine(deps) {
|
|
|
635
655
|
const spoolSave = (st, r) => {
|
|
636
656
|
try {
|
|
637
657
|
writeFileSync(metaPath(st, r.url), JSON.stringify({ shareId: r.shareId, secret: r.secret, ackedRev: r.ackedRev, sid: st.sid, url: r.url }));
|
|
638
|
-
|
|
658
|
+
// the queue line IS the wire frame when prebuilt (single
|
|
659
|
+
// serialization end-to-end); a pending frame without wire (legacy
|
|
660
|
+
// spool load) serializes here
|
|
661
|
+
writeFileSync(queuePath(st, r.url), r.pending.map((f) => f.wire ?? JSON.stringify(f)).join("\n") + (r.pending.length ? "\n" : ""));
|
|
662
|
+
// the migrated content now lives in the new queue (or was acked):
|
|
663
|
+
// retire the single-server-era files so they don't accumulate forever
|
|
664
|
+
rmSync(legacyMetaPath(st), { force: true });
|
|
665
|
+
rmSync(legacyQueuePath(st), { force: true });
|
|
639
666
|
}
|
|
640
667
|
catch (e) {
|
|
641
668
|
logError("spool save failed", e?.message || e);
|
|
@@ -645,13 +672,38 @@ export function createEngine(deps) {
|
|
|
645
672
|
// queue (O(n^2) during backfill bursts) — append the line instead
|
|
646
673
|
const spoolAppend = (st, r, f) => {
|
|
647
674
|
try {
|
|
648
|
-
appendFileSync(queuePath(st, r.url), JSON.stringify(f) + "\n");
|
|
675
|
+
appendFileSync(queuePath(st, r.url), (f.wire ?? JSON.stringify(f)) + "\n");
|
|
649
676
|
}
|
|
650
677
|
catch (e) {
|
|
678
|
+
// the frame lives ONLY in memory now — flag the remote so the next
|
|
679
|
+
// spoolLoad does NOT replace in-memory pending with the shorter
|
|
680
|
+
// disk queue (silent loss otherwise; live-caught class in review)
|
|
681
|
+
r.spoolAppendFailed = true;
|
|
651
682
|
logError("spool append failed", e?.message || e);
|
|
652
683
|
}
|
|
653
684
|
};
|
|
685
|
+
/** Debounced ack-path spool rewrite: one ack per chunk during a
|
|
686
|
+
* multi-chunk replay each rewrote the WHOLE queue file (O(n^2) —
|
|
687
|
+
* ~160MB of sync disk churn on a 25-chunk outage recovery). A crash
|
|
688
|
+
* inside the 300ms window only loses "these frames were acked" — the
|
|
689
|
+
* next connect replays idempotent keyed upserts, which the protocol
|
|
690
|
+
* already tolerates. init-ok keeps the immediate save (binding/
|
|
691
|
+
* recovery info). */
|
|
692
|
+
const scheduleSpoolSave = (st, r) => {
|
|
693
|
+
if (r.spoolSaveTimer)
|
|
694
|
+
return;
|
|
695
|
+
r.spoolSaveTimer = setTimeout(() => {
|
|
696
|
+
r.spoolSaveTimer = null;
|
|
697
|
+
if (!st.closed)
|
|
698
|
+
spoolSave(st, r);
|
|
699
|
+
}, 300);
|
|
700
|
+
};
|
|
654
701
|
const spoolLoad = (st, r) => {
|
|
702
|
+
// meta first (share binding recovery), in its OWN try: a missing meta
|
|
703
|
+
// file is normal (it's only written after the first successful
|
|
704
|
+
// handshake) and must NOT touch pending (live-caught class: the old
|
|
705
|
+
// blanket catch wiped in-memory pending AND skipped the queue read,
|
|
706
|
+
// so everything queued before the first successful connect was lost).
|
|
655
707
|
try {
|
|
656
708
|
const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"));
|
|
657
709
|
// metadata binding (db) is authoritative when both exist and disagree
|
|
@@ -660,26 +712,48 @@ export function createEngine(deps) {
|
|
|
660
712
|
r.secret = meta.secret;
|
|
661
713
|
r.ackedRev = meta.ackedRev ?? 0;
|
|
662
714
|
}
|
|
715
|
+
}
|
|
716
|
+
catch { }
|
|
717
|
+
try {
|
|
663
718
|
if (existsSync(queuePath(st, r.url))) {
|
|
664
719
|
// append-only spool: a crash mid-append can tear the LAST line —
|
|
665
|
-
// keep the valid prefix instead of dropping the whole queue
|
|
666
|
-
|
|
720
|
+
// keep the valid prefix instead of dropping the whole queue.
|
|
721
|
+
// The disk queue is the authority: every in-memory frame was
|
|
722
|
+
// appended before it was tracked, so replacing pending with the
|
|
723
|
+
// parsed queue loses nothing. A FAILED read keeps in-memory
|
|
724
|
+
// pending untouched.
|
|
725
|
+
const disk = [];
|
|
667
726
|
for (const line of readFileSync(queuePath(st, r.url), "utf8").split("\n")) {
|
|
668
727
|
if (!line.trim())
|
|
669
728
|
continue;
|
|
670
729
|
try {
|
|
671
|
-
|
|
730
|
+
disk.push(JSON.parse(line));
|
|
672
731
|
}
|
|
673
732
|
catch {
|
|
674
733
|
log(`spool load: dropped torn tail line for ${st.sid.slice(0, 18)}`);
|
|
675
734
|
break;
|
|
676
735
|
}
|
|
677
736
|
}
|
|
737
|
+
// seq hygiene: re-stamp pre-seq spool lines (an ack without seq
|
|
738
|
+
// would otherwise clear ALL pending — the H2 wipe, once per
|
|
739
|
+
// upgrade) and seed the counter past every restored seq (a
|
|
740
|
+
// restart resetting to 0 would collide new frames with old ones)
|
|
741
|
+
for (const f of disk) {
|
|
742
|
+
if (typeof f.seq !== "number")
|
|
743
|
+
f.seq = ++nextSeq;
|
|
744
|
+
else if (f.seq >= nextSeq)
|
|
745
|
+
nextSeq = f.seq + 1;
|
|
746
|
+
}
|
|
747
|
+
// a failed append means the disk queue can be SHORTER than
|
|
748
|
+
// memory — the disk is only authoritative when every append
|
|
749
|
+
// landed (otherwise the replace would drop the memory-only frame)
|
|
750
|
+
if (!r.spoolAppendFailed)
|
|
751
|
+
r.pending = disk;
|
|
752
|
+
else
|
|
753
|
+
r.spoolAppendFailed = false;
|
|
678
754
|
}
|
|
679
755
|
}
|
|
680
|
-
catch {
|
|
681
|
-
r.pending = [];
|
|
682
|
-
}
|
|
756
|
+
catch { }
|
|
683
757
|
// single-server-era spool (no url suffix): belongs to the first target
|
|
684
758
|
if (!r.shareId && targets[0]?.url === r.url) {
|
|
685
759
|
try {
|
|
@@ -702,12 +776,26 @@ export function createEngine(deps) {
|
|
|
702
776
|
};
|
|
703
777
|
const spoolClear = (st, r) => {
|
|
704
778
|
try {
|
|
779
|
+
if (r.spoolSaveTimer) {
|
|
780
|
+
clearTimeout(r.spoolSaveTimer);
|
|
781
|
+
r.spoolSaveTimer = null;
|
|
782
|
+
}
|
|
705
783
|
rmSync(metaPath(st, r.url), { force: true });
|
|
706
784
|
rmSync(queuePath(st, r.url), { force: true });
|
|
785
|
+
// single-server-era files die with the session too
|
|
786
|
+
rmSync(legacyMetaPath(st), { force: true });
|
|
787
|
+
rmSync(legacyQueuePath(st), { force: true });
|
|
707
788
|
}
|
|
708
789
|
catch { }
|
|
709
790
|
};
|
|
710
791
|
// ---- websocket transport (per remote) ----------------------------------
|
|
792
|
+
/** Monotonic client frame sequence: every chunk-frame gets one; the
|
|
793
|
+
* server echoes it in the ack, and the plugin clears exactly that
|
|
794
|
+
* chunk from pending. (Live-caught class: one logical frame split into
|
|
795
|
+
* N chunks — the worker acks EACH chunk, and a blanket "pending = []"
|
|
796
|
+
* on the first ack erased chunks 2..N; a socket death right there lost
|
|
797
|
+
* them permanently.) */
|
|
798
|
+
let nextSeq = 0;
|
|
711
799
|
const wsSend = (r, obj) => {
|
|
712
800
|
if (debugEvents)
|
|
713
801
|
log(`ws ${r.url.slice(0, 24)} #${r.socketSeq} send ${obj?.op} (ready=${r.wsReady})`);
|
|
@@ -726,31 +814,63 @@ export function createEngine(deps) {
|
|
|
726
814
|
* and rebroadcast; worker apply is keyed-idempotent, so chunks just
|
|
727
815
|
* bump rev. An item larger than the whole budget rides alone in its
|
|
728
816
|
* own frame (the worker fits oversize items to its storage cap).
|
|
729
|
-
* Status rides each chunk; last write wins.
|
|
730
|
-
|
|
817
|
+
* Status rides each chunk; last write wins.
|
|
818
|
+
* Returns each chunk's PREBUILT items JSON: the wire frame and the
|
|
819
|
+
* spool line are derived from it by concatenation — every item used
|
|
820
|
+
* to be serialized 3-5x per remote per flush (chunk measure, spool
|
|
821
|
+
* append, ws send, sendSync re-chunk). */
|
|
822
|
+
const chunkedFrames = (items, maxItems, maxBytes) => {
|
|
731
823
|
const chunks = [];
|
|
732
824
|
let cur = [];
|
|
825
|
+
let curJson = [];
|
|
733
826
|
let curBytes = 0;
|
|
734
827
|
for (const it of items) {
|
|
735
|
-
const
|
|
736
|
-
if (cur.length && (cur.length >= maxItems || curBytes +
|
|
737
|
-
chunks.push(cur);
|
|
828
|
+
const j = JSON.stringify(it) ?? "null";
|
|
829
|
+
if (cur.length && (cur.length >= maxItems || curBytes + j.length > maxBytes)) {
|
|
830
|
+
chunks.push({ items: cur, json: "[" + curJson.join(",") + "]" });
|
|
738
831
|
cur = [];
|
|
832
|
+
curJson = [];
|
|
739
833
|
curBytes = 0;
|
|
740
834
|
}
|
|
741
835
|
cur.push(it);
|
|
742
|
-
|
|
836
|
+
curJson.push(j);
|
|
837
|
+
curBytes += j.length;
|
|
743
838
|
}
|
|
744
839
|
if (cur.length)
|
|
745
|
-
chunks.push(cur);
|
|
840
|
+
chunks.push({ items: cur, json: "[" + curJson.join(",") + "]" });
|
|
746
841
|
return chunks;
|
|
747
842
|
};
|
|
843
|
+
/** legacy chunker (count/byte split without prebuilt JSON) — replay
|
|
844
|
+
* paths that only need the split */
|
|
845
|
+
const chunkItems = (items, maxItems, maxBytes) => chunkedFrames(items, maxItems, maxBytes).map((c) => c.items);
|
|
846
|
+
/** the wire sync frame, built by concatenation over the prebuilt items
|
|
847
|
+
* JSON — byte-identical to JSON.stringify({op,items,status,seq}) */
|
|
848
|
+
const syncFrameStr = (itemsJson, status, seq) => `{"op":"sync","items":${itemsJson},"status":${JSON.stringify(status)}${seq !== undefined ? `,"seq":${seq}` : ""}}`;
|
|
849
|
+
const wsSendRaw = (r, s) => {
|
|
850
|
+
if (debugEvents)
|
|
851
|
+
log(`ws ${r.url.slice(0, 24)} #${r.socketSeq} send raw (${Math.round(s.length / 1024)}KB, ready=${r.wsReady})`);
|
|
852
|
+
try {
|
|
853
|
+
r.ws?.send(s);
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
return false;
|
|
857
|
+
}
|
|
858
|
+
return true;
|
|
859
|
+
};
|
|
748
860
|
const sendSync = (r, f) => {
|
|
749
861
|
if (!f.items.length)
|
|
750
|
-
return wsSend(r, { op: "sync", items: [], status: f.status });
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
862
|
+
return wsSend(r, { op: "sync", items: [], status: f.status, seq: f.seq });
|
|
863
|
+
// prebuilt wire (the hot flush path): one serialization total
|
|
864
|
+
if (f.wire)
|
|
865
|
+
return wsSendRaw(r, f.wire);
|
|
866
|
+
// replayed/legacy frames: chunk-shaped by construction — only an
|
|
867
|
+
// oversize one (a pre-chunking spool line) needs re-splitting
|
|
868
|
+
const itemsJson = JSON.stringify(f.items);
|
|
869
|
+
if (f.items.length <= 200 && itemsJson.length <= 512 * 1024) {
|
|
870
|
+
return wsSendRaw(r, syncFrameStr(itemsJson, f.status, f.seq));
|
|
871
|
+
}
|
|
872
|
+
for (const c of chunkedFrames(f.items, 200, 512 * 1024))
|
|
873
|
+
wsSendRaw(r, syncFrameStr(c.json, f.status, f.seq));
|
|
754
874
|
return true;
|
|
755
875
|
};
|
|
756
876
|
const announceUrl = (st, r) => {
|
|
@@ -944,7 +1064,12 @@ export function createEngine(deps) {
|
|
|
944
1064
|
logError(`sharing to ${r.url} disabled (no credential): ${cred.error}`);
|
|
945
1065
|
return;
|
|
946
1066
|
}
|
|
947
|
-
|
|
1067
|
+
// frame auth: when the server advertises it, keep the token off the
|
|
1068
|
+
// URL (query strings land in proxy/access logs). The auth frame
|
|
1069
|
+
// rides the socket in order — the server sets its identity before
|
|
1070
|
+
// our init arrives. Old servers fall back to ?token=.
|
|
1071
|
+
const useFrameAuth = !!token && frameAuthUrls.has(r.url);
|
|
1072
|
+
const ws = new WebSocket(`${wsUrl(r.url)}/api/ws${useFrameAuth ? "" : `?token=${encodeURIComponent(token ?? "")}`}`);
|
|
948
1073
|
r.ws = ws;
|
|
949
1074
|
await new Promise((resolve, reject) => {
|
|
950
1075
|
const t = setTimeout(() => reject(new Error("ws open timeout")), 15000);
|
|
@@ -985,6 +1110,32 @@ export function createEngine(deps) {
|
|
|
985
1110
|
if (!st.closed)
|
|
986
1111
|
scheduleReconnect(st, r);
|
|
987
1112
|
});
|
|
1113
|
+
if (useFrameAuth) {
|
|
1114
|
+
wsSend(r, { op: "auth", token });
|
|
1115
|
+
// the auth and init frames can interleave in the worker's
|
|
1116
|
+
// concurrent webSocketMessage invocations — wait for the authed
|
|
1117
|
+
// ack before authenticating anything (timeout -> fall back to a
|
|
1118
|
+
// query-token reconnect)
|
|
1119
|
+
const authed = await new Promise((resolve) => {
|
|
1120
|
+
const t = setTimeout(() => resolve(false), 3000);
|
|
1121
|
+
r.authWaiter = () => {
|
|
1122
|
+
clearTimeout(t);
|
|
1123
|
+
resolve(true);
|
|
1124
|
+
};
|
|
1125
|
+
});
|
|
1126
|
+
r.authWaiter = undefined;
|
|
1127
|
+
if (!authed) {
|
|
1128
|
+
logError(`ws ${r.url.slice(0, 24)}: frame-auth ack timeout - reconnecting with query token`);
|
|
1129
|
+
frameAuthUrls.delete(r.url);
|
|
1130
|
+
try {
|
|
1131
|
+
ws.close(4000, "auth timeout");
|
|
1132
|
+
}
|
|
1133
|
+
catch { }
|
|
1134
|
+
if (!st.closed)
|
|
1135
|
+
scheduleReconnect(st, r);
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
988
1139
|
if (r.initWasAttach) {
|
|
989
1140
|
wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } });
|
|
990
1141
|
}
|
|
@@ -1018,7 +1169,9 @@ export function createEngine(deps) {
|
|
|
1018
1169
|
const scheduleReconnect = (st, r) => {
|
|
1019
1170
|
if (st.closed || r.reconnectTimer)
|
|
1020
1171
|
return;
|
|
1021
|
-
|
|
1172
|
+
// jitter (±20%) so fan-out remotes and multiple sessions never sync
|
|
1173
|
+
// their retry storms onto the same second
|
|
1174
|
+
const wait = Math.max(250, Math.round(r.backoff * (0.8 + Math.random() * 0.4)));
|
|
1022
1175
|
r.backoff = Math.min(r.backoff * 2, 60000);
|
|
1023
1176
|
r.reconnectTimer = setTimeout(() => {
|
|
1024
1177
|
r.reconnectTimer = null;
|
|
@@ -1110,6 +1263,15 @@ export function createEngine(deps) {
|
|
|
1110
1263
|
}
|
|
1111
1264
|
};
|
|
1112
1265
|
const handleFrame = async (st, r, frame) => {
|
|
1266
|
+
if (frame.op === "authed") {
|
|
1267
|
+
// frame-auth handshake ack: release the init (the auth and init
|
|
1268
|
+
// frames can interleave server-side — webSocketMessage invocations
|
|
1269
|
+
// run concurrently — so the init must WAIT for this, else it
|
|
1270
|
+
// authenticates as anonymous/tokenless)
|
|
1271
|
+
r.authWaiter?.();
|
|
1272
|
+
r.authWaiter = undefined;
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1113
1275
|
if (frame.op === "init") {
|
|
1114
1276
|
if (!frame.ok) {
|
|
1115
1277
|
const err = String(frame.error ?? "");
|
|
@@ -1168,6 +1330,21 @@ export function createEngine(deps) {
|
|
|
1168
1330
|
// bad base (mismatch protocol). Content REPLAYS to the new share —
|
|
1169
1331
|
// pending is never dropped on a reset (live-caught: the recreate
|
|
1170
1332
|
// husk had the title but zero messages).
|
|
1333
|
+
if (/limit|too many|rate/i.test(err)) {
|
|
1334
|
+
// quota/429 ("max shares reached", "daily create limit", the
|
|
1335
|
+
// anonymous caps): an immediate re-init on the same socket is a
|
|
1336
|
+
// hot loop against the server — back off through the normal
|
|
1337
|
+
// reconnect cadence instead
|
|
1338
|
+
logError(`${r.url}: create refused:`, frame.error, "- retrying with backoff");
|
|
1339
|
+
r.connecting = false;
|
|
1340
|
+
try {
|
|
1341
|
+
r.ws?.close(4000, "rate limited");
|
|
1342
|
+
}
|
|
1343
|
+
catch { }
|
|
1344
|
+
if (!st.closed)
|
|
1345
|
+
scheduleReconnect(st, r);
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1171
1348
|
logError(`${r.url}: init refused:`, frame.error, "- creating fresh share");
|
|
1172
1349
|
deleteAbandoned(r);
|
|
1173
1350
|
r.shareId = null;
|
|
@@ -1199,11 +1376,26 @@ export function createEngine(deps) {
|
|
|
1199
1376
|
r.connecting = false;
|
|
1200
1377
|
r.initWasAttach = false;
|
|
1201
1378
|
r.collided = null;
|
|
1379
|
+
// a successful handshake proves write access: an admin unfreeze
|
|
1380
|
+
// revives the remote from here (frozenRemote is otherwise sticky)
|
|
1381
|
+
if (r.frozenRemote && !frame.error)
|
|
1382
|
+
r.frozenRemote = false;
|
|
1202
1383
|
if (r.initTimer) {
|
|
1203
1384
|
clearTimeout(r.initTimer);
|
|
1204
1385
|
r.initTimer = null;
|
|
1205
1386
|
}
|
|
1206
|
-
|
|
1387
|
+
// backoff reset is DEFERRED until the socket proves stable: a
|
|
1388
|
+
// server that accepts init then immediately drops would otherwise
|
|
1389
|
+
// reconnect once per second forever (~1 cycle/s per session-server
|
|
1390
|
+
// pair, live-caught in the optimization audit). 30s alive (socket
|
|
1391
|
+
// still ready, no reconnect pending) or a second acked sync.
|
|
1392
|
+
if (r.backoffResetTimer)
|
|
1393
|
+
clearTimeout(r.backoffResetTimer);
|
|
1394
|
+
r.backoffResetTimer = setTimeout(() => {
|
|
1395
|
+
r.backoffResetTimer = null;
|
|
1396
|
+
if (r.wsReady && !r.reconnectTimer)
|
|
1397
|
+
r.backoff = 1000;
|
|
1398
|
+
}, 30000);
|
|
1207
1399
|
st.everConnected = true;
|
|
1208
1400
|
if (frame.policy)
|
|
1209
1401
|
applyPolicy(frame.policy, r.url);
|
|
@@ -1229,19 +1421,70 @@ export function createEngine(deps) {
|
|
|
1229
1421
|
if (wasFanoutCreate) {
|
|
1230
1422
|
// a server joined an ALREADY-live session (fan-out add, or a
|
|
1231
1423
|
// post-mismatch recreate): its fresh share needs the full history —
|
|
1232
|
-
// backfill is keyed-idempotent, other servers just re-upsert
|
|
1424
|
+
// backfill is keyed-idempotent, other servers just re-upsert.
|
|
1425
|
+
// The enqueue dedupe must step aside: those hashes say "already
|
|
1426
|
+
// entered the machinery", but the NEW remote never received any
|
|
1427
|
+
// of it (live-caught by F3: the join's backfill was fully
|
|
1428
|
+
// deduped away and the joined share stayed empty).
|
|
1429
|
+
st.sentHash.clear();
|
|
1233
1430
|
chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", e?.message || e));
|
|
1234
1431
|
}
|
|
1235
1432
|
return;
|
|
1236
1433
|
}
|
|
1237
1434
|
if (frame.op === "ack") {
|
|
1238
1435
|
r.ackedRev = frame.rev ?? r.ackedRev;
|
|
1239
|
-
|
|
1240
|
-
|
|
1436
|
+
// a second acked sync also proves the socket stable — release the
|
|
1437
|
+
// backoff reset timer early (complements the 30s-alive timer)
|
|
1438
|
+
if (r.backoffResetTimer) {
|
|
1439
|
+
clearTimeout(r.backoffResetTimer);
|
|
1440
|
+
r.backoffResetTimer = null;
|
|
1441
|
+
r.backoff = 1000;
|
|
1442
|
+
}
|
|
1443
|
+
// seq-scoped retirement: an ack identifies the chunk it applied.
|
|
1444
|
+
// An ack WITHOUT seq = old server: legacy clear-all (correct there —
|
|
1445
|
+
// old servers acked the whole pending batch together).
|
|
1446
|
+
if (typeof frame.seq === "number") {
|
|
1447
|
+
r.pending = r.pending.filter((p) => p.seq !== frame.seq);
|
|
1448
|
+
}
|
|
1449
|
+
else {
|
|
1450
|
+
r.pending = [];
|
|
1451
|
+
}
|
|
1452
|
+
scheduleSpoolSave(st, r);
|
|
1241
1453
|
scheduleBindingWrite(st);
|
|
1242
1454
|
return;
|
|
1243
1455
|
}
|
|
1244
1456
|
if (frame.op === "nack" || frame.op === "error") {
|
|
1457
|
+
const errText = String(frame.error ?? "");
|
|
1458
|
+
if (frame.op === "nack") {
|
|
1459
|
+
// classify: the share dying server-side under a live socket
|
|
1460
|
+
// (sweeper TTL, admin delete, purge) surfaces as nacks — the
|
|
1461
|
+
// socket is "healthy" so nothing else recovers it. Same reset as
|
|
1462
|
+
// the init-refused path: fresh create + content replay.
|
|
1463
|
+
if (/not found|gone/i.test(errText)) {
|
|
1464
|
+
logError(`${r.url}: share vanished server-side (nack: ${errText}) - recreating`);
|
|
1465
|
+
deleteAbandoned(r);
|
|
1466
|
+
r.shareId = null;
|
|
1467
|
+
r.secret = null;
|
|
1468
|
+
r.ackedRev = 0;
|
|
1469
|
+
try {
|
|
1470
|
+
r.ws?.close(4000, "share gone");
|
|
1471
|
+
}
|
|
1472
|
+
catch { }
|
|
1473
|
+
if (!st.closed)
|
|
1474
|
+
queueConnect(st, r);
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
if (/frozen/i.test(errText)) {
|
|
1478
|
+
// owner frozen mid-session: every write is refused. Stop
|
|
1479
|
+
// spooling NEW frames for this remote (unbounded growth) but
|
|
1480
|
+
// KEEP what's already queued — an unfreeze revives the remote
|
|
1481
|
+
// on the next successful handshake (init-ok clears the flag)
|
|
1482
|
+
// and the backlog replays from there.
|
|
1483
|
+
r.frozenRemote = true;
|
|
1484
|
+
logError(`${r.url}: share frozen by the hoster - edits refused; contact the server admin`);
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1245
1488
|
logError(`${r.url}: worker refused frame:`, JSON.stringify(frame).slice(0, 200));
|
|
1246
1489
|
// the control socket lost its attach state (worker-side eviction of
|
|
1247
1490
|
// the control DO): force a reconnect — init attach replays pending
|
|
@@ -1275,7 +1518,7 @@ export function createEngine(deps) {
|
|
|
1275
1518
|
// stamps run at frame time: the whole family's data is indexed by now
|
|
1276
1519
|
// (child prompts are created during the task call, well inside the
|
|
1277
1520
|
// flush debounce), and any re-enqueued message lands in this frame
|
|
1278
|
-
stampsPass();
|
|
1521
|
+
stampsPass(st);
|
|
1279
1522
|
const items = Array.from(st.queue.values());
|
|
1280
1523
|
st.queue.clear();
|
|
1281
1524
|
const statusChanged = st.status !== st.lastSentStatus;
|
|
@@ -1300,18 +1543,41 @@ export function createEngine(deps) {
|
|
|
1300
1543
|
return;
|
|
1301
1544
|
// fan out to every remote; each carries its own pending copy and acks
|
|
1302
1545
|
// independently. Remotes mid-handshake or auth-blocked just buffer.
|
|
1546
|
+
// PENDING IS CHUNK-SHAPED (seq-tagged): one logical frame becomes N
|
|
1547
|
+
// chunk-frames so a partial-ack can never erase unsent chunks
|
|
1548
|
+
// (live-caught class: chunk-1 ack + socket death erased chunks 2..N).
|
|
1549
|
+
const chunks = f.items.length ? chunkedFrames(f.items, 200, 512 * 1024) : [];
|
|
1303
1550
|
let httpWork;
|
|
1304
1551
|
for (const r of st.remotes.values()) {
|
|
1552
|
+
if (r.frozenRemote)
|
|
1553
|
+
continue; // refused server-side; stop spooling
|
|
1554
|
+
const fresh = [];
|
|
1555
|
+
if (chunks.length) {
|
|
1556
|
+
for (const c of chunks) {
|
|
1557
|
+
const seq = ++nextSeq;
|
|
1558
|
+
const cf = { items: c.items, status: f.status, seq, wire: syncFrameStr(c.json, f.status, seq) };
|
|
1559
|
+
fresh.push(cf);
|
|
1560
|
+
r.pending.push(cf);
|
|
1561
|
+
spoolAppend(st, r, cf);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
else {
|
|
1565
|
+
// status-only frame (empty items): one chunk-less frame
|
|
1566
|
+
const seq = ++nextSeq;
|
|
1567
|
+
const cf = { items: [], status: f.status, seq };
|
|
1568
|
+
fresh.push(cf);
|
|
1569
|
+
r.pending.push(cf);
|
|
1570
|
+
spoolAppend(st, r, cf);
|
|
1571
|
+
}
|
|
1305
1572
|
// never send syncs before the init ack: the worker's per-socket
|
|
1306
1573
|
// ControlDO has no attach until OUR init completes, and a racing
|
|
1307
1574
|
// sync dies as "unknown op" (live-caught: post-drop reconnects
|
|
1308
1575
|
// lost whole turns). The init-ok handler flushes pending itself.
|
|
1309
|
-
r.pending.push(f);
|
|
1310
|
-
spoolAppend(st, r, f);
|
|
1311
1576
|
if (r.ws && !r.wsReady)
|
|
1312
1577
|
continue;
|
|
1313
1578
|
if (r.wsReady) {
|
|
1314
|
-
|
|
1579
|
+
for (const cf of fresh)
|
|
1580
|
+
sendSync(r, cf);
|
|
1315
1581
|
continue;
|
|
1316
1582
|
}
|
|
1317
1583
|
const viaHttp = flushViaHttp(st, r);
|
|
@@ -1320,29 +1586,67 @@ export function createEngine(deps) {
|
|
|
1320
1586
|
}
|
|
1321
1587
|
if (st.remotes.size)
|
|
1322
1588
|
st.lastFlushAt = Date.now();
|
|
1589
|
+
// the HTTP work runs detached from the flush chain (one dead server
|
|
1590
|
+
// must not stall the fan-out); dispose awaits its own final flush
|
|
1591
|
+
if (httpWork)
|
|
1592
|
+
void httpWork.catch((e) => logError("http flush error", e?.message || e));
|
|
1323
1593
|
return httpWork;
|
|
1324
1594
|
};
|
|
1595
|
+
/** Client-side key derivation mirroring the server's itemKey — used only
|
|
1596
|
+
* for deduping the HTTP fallback batch (keyed upserts: the LAST item
|
|
1597
|
+
* for a key wins, matching apply() semantics). */
|
|
1598
|
+
const httpDedupeKey = (it) => {
|
|
1599
|
+
if (!it || typeof it !== "object")
|
|
1600
|
+
return null;
|
|
1601
|
+
const o = it;
|
|
1602
|
+
switch (o.type) {
|
|
1603
|
+
case "session": return "session/" + (o.data?.id ?? "");
|
|
1604
|
+
case "session_diff": return "session_diff/" + (o.sessionID ?? o.data?.sessionID ?? "");
|
|
1605
|
+
case "message": return "message/" + (o.data?.id ?? "");
|
|
1606
|
+
case "part": return "part/" + (o.data?.messageID ?? "") + "/" + (o.data?.id ?? "");
|
|
1607
|
+
case "model": return "model/" + (o.sessionID ?? o.data?.sessionID ?? "");
|
|
1608
|
+
case "prefs": return "prefs";
|
|
1609
|
+
default: return null;
|
|
1610
|
+
}
|
|
1611
|
+
};
|
|
1325
1612
|
const flushViaHttp = async (st, r) => {
|
|
1326
1613
|
if (!r.shareId || !r.pending.length)
|
|
1327
1614
|
return;
|
|
1328
1615
|
// ALL pending frames, merged (keyed upserts — later wins). Sending
|
|
1329
1616
|
// only the last frame dropped every earlier batch's items whenever
|
|
1330
1617
|
// the HTTP fallback carried more than one (live-caught class:
|
|
1331
|
-
// content loss on reconnect paths).
|
|
1332
|
-
|
|
1618
|
+
// content loss on reconnect paths). DEDUPE BY KEY before chunking:
|
|
1619
|
+
// a streaming message appearing in k pending frames shipped k
|
|
1620
|
+
// copies (up to 25x on a replay burst) — every duplicate was also a
|
|
1621
|
+
// server row-write + a rebroadcast frame.
|
|
1622
|
+
const byKey = new Map();
|
|
1623
|
+
const unkeyed = [];
|
|
1333
1624
|
let status;
|
|
1625
|
+
const sentSeqs = new Set();
|
|
1334
1626
|
for (const f of r.pending) {
|
|
1335
|
-
for (const it of f.items)
|
|
1336
|
-
|
|
1627
|
+
for (const it of f.items) {
|
|
1628
|
+
const k = httpDedupeKey(it);
|
|
1629
|
+
if (k)
|
|
1630
|
+
byKey.set(k, it);
|
|
1631
|
+
else
|
|
1632
|
+
unkeyed.push(it);
|
|
1633
|
+
}
|
|
1337
1634
|
if (f.status)
|
|
1338
1635
|
status = f.status;
|
|
1636
|
+
if (typeof f.seq === "number")
|
|
1637
|
+
sentSeqs.add(f.seq);
|
|
1339
1638
|
}
|
|
1639
|
+
const items = [...byKey.values(), ...unkeyed];
|
|
1340
1640
|
const cred = credOf(r.url);
|
|
1341
1641
|
// chunked like WS frames: one unbounded body is a whole-backlog
|
|
1342
1642
|
// bomb after a mid-backfill socket drop (backfill = full history).
|
|
1343
1643
|
// Failure stops the loop with pending intact — replaying already-
|
|
1344
1644
|
// applied chunks is idempotent.
|
|
1345
|
-
|
|
1645
|
+
// status-only frames (items empty, e.g. the terminal completed
|
|
1646
|
+
// flush in HTTP-only mode) still need ONE request or the status
|
|
1647
|
+
// never lands (the share stays "live" forever)
|
|
1648
|
+
const chunks = items.length ? chunkItems(items, 500, 4 * 1024 * 1024) : [[]];
|
|
1649
|
+
for (const chunk of chunks) {
|
|
1346
1650
|
const res = await fetch(`${r.url}/api/share/${r.shareId}/sync`, {
|
|
1347
1651
|
method: "POST",
|
|
1348
1652
|
headers: { "content-type": "application/json", ...(cred.credential ? { authorization: `Bearer ${cred.credential}` } : {}) },
|
|
@@ -1354,8 +1658,12 @@ export function createEngine(deps) {
|
|
|
1354
1658
|
const body = (await res.json().catch(() => ({})));
|
|
1355
1659
|
r.ackedRev = body.rev ?? r.ackedRev;
|
|
1356
1660
|
}
|
|
1357
|
-
|
|
1358
|
-
|
|
1661
|
+
// retire EXACTLY what this run sent: a concurrent flush may have
|
|
1662
|
+
// queued new frames while our fetches were in flight (the HTTP work
|
|
1663
|
+
// is deliberately detached from the flush chain) — a blanket clear
|
|
1664
|
+
// would erase them before anything transmitted them
|
|
1665
|
+
r.pending = r.pending.filter((p) => !sentSeqs.has(p.seq));
|
|
1666
|
+
scheduleSpoolSave(st, r);
|
|
1359
1667
|
};
|
|
1360
1668
|
const scheduleFlush = (st) => {
|
|
1361
1669
|
if (st.timer)
|
|
@@ -1371,12 +1679,41 @@ export function createEngine(deps) {
|
|
|
1371
1679
|
st.timer = null;
|
|
1372
1680
|
if (debugEvents)
|
|
1373
1681
|
log(`flush timer fired sid=${st.sid.slice(0, 18)} queue=${st.queue.size} remotes=${st.remotes.size}`);
|
|
1374
|
-
|
|
1682
|
+
// NOTE: the flush's HTTP-fallback promise is deliberately NOT
|
|
1683
|
+
// chained — one black-holing server (15s timeout per chunk) must
|
|
1684
|
+
// not stall every other remote's flushes behind it. Dispose awaits
|
|
1685
|
+
// its own final flush explicitly.
|
|
1686
|
+
chain = chain
|
|
1687
|
+
.then(() => {
|
|
1688
|
+
flush(st);
|
|
1689
|
+
})
|
|
1690
|
+
.catch((e) => logError("flush error", e?.message || e));
|
|
1375
1691
|
}, wait);
|
|
1376
1692
|
};
|
|
1377
1693
|
const enqueue = (st, entry) => {
|
|
1378
1694
|
if (!st || !entry)
|
|
1379
1695
|
return;
|
|
1696
|
+
// content dedupe at the machinery boundary: an identical re-feed has
|
|
1697
|
+
// either entered the queue already or been flushed into every
|
|
1698
|
+
// remote's pending/spool (the per-remote delivery machinery owns it
|
|
1699
|
+
// from there) — re-sending it bumps rev, rewrites storage and
|
|
1700
|
+
// re-folds every viewer for nothing. This is the engine-side home
|
|
1701
|
+
// the v2-adapter dedupe lacked (an adapter-side cache poisoned while
|
|
1702
|
+
// dormant and skipped the flag-time content — live-caught).
|
|
1703
|
+
let h;
|
|
1704
|
+
try {
|
|
1705
|
+
h = createHash("sha256").update(JSON.stringify(entry.item)).digest("hex");
|
|
1706
|
+
}
|
|
1707
|
+
catch {
|
|
1708
|
+
h = "";
|
|
1709
|
+
}
|
|
1710
|
+
if (h && st.sentHash.get(entry.key) === h) {
|
|
1711
|
+
if (debugEvents)
|
|
1712
|
+
log(`enqueue dedupe ${entry.item.type} ${entry.key.slice(0, 40)}`);
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
if (h)
|
|
1716
|
+
st.sentHash.set(entry.key, h);
|
|
1380
1717
|
st.queue.set(entry.key, entry.item);
|
|
1381
1718
|
indexMessage(entry);
|
|
1382
1719
|
if (debugEvents)
|
|
@@ -1386,6 +1723,12 @@ export function createEngine(deps) {
|
|
|
1386
1723
|
scheduleFlush(st);
|
|
1387
1724
|
};
|
|
1388
1725
|
const msgIndex = new Map(); // `${sid}:${messageID}`
|
|
1726
|
+
/** message ids tombstoned this run: stampsPass must never re-upsert an
|
|
1727
|
+
* undone message (live-caught class: dropMergedMessage removed the
|
|
1728
|
+
* merged entry but msgIndex kept the corpse; a neighboring-segment
|
|
1729
|
+
* change recomputed its stamps and reEnqueueMessage resurrected the
|
|
1730
|
+
* deleted turn server-side). */
|
|
1731
|
+
const tombstoned = new Set();
|
|
1389
1732
|
const indexMessage = (entry) => {
|
|
1390
1733
|
if (entry.item.type !== "message")
|
|
1391
1734
|
return;
|
|
@@ -1413,6 +1756,10 @@ export function createEngine(deps) {
|
|
|
1413
1756
|
};
|
|
1414
1757
|
const sessionStateOf = (sid) => sessions.get(childToParent.get(sid) ?? sid);
|
|
1415
1758
|
const reEnqueueMessage = (m) => {
|
|
1759
|
+
// tombstone guard: an undone message stays dead even if a stamp pass
|
|
1760
|
+
// recomputes its segment metadata
|
|
1761
|
+
if (tombstoned.has(`${m.sid}:${m.data.id}`))
|
|
1762
|
+
return;
|
|
1416
1763
|
const st = sessionStateOf(m.sid);
|
|
1417
1764
|
if (st)
|
|
1418
1765
|
enqueue(st, itemFor("message", m.data, m.sid));
|
|
@@ -1435,15 +1782,7 @@ export function createEngine(deps) {
|
|
|
1435
1782
|
/** user messages whose segment runs a DIFFERENT agent than the previous
|
|
1436
1783
|
* segment carry metadata.agentSwitch = <agent> - the viewer renders the
|
|
1437
1784
|
* transition chip from the stamp alone. */
|
|
1438
|
-
const stampAgentSwitches = () => {
|
|
1439
|
-
const bySession = new Map();
|
|
1440
|
-
for (const m of msgIndex.values()) {
|
|
1441
|
-
const list = bySession.get(m.sid);
|
|
1442
|
-
if (list)
|
|
1443
|
-
list.push(m);
|
|
1444
|
-
else
|
|
1445
|
-
bySession.set(m.sid, [m]);
|
|
1446
|
-
}
|
|
1785
|
+
const stampAgentSwitches = (bySession) => {
|
|
1447
1786
|
for (const [, list] of bySession) {
|
|
1448
1787
|
list.sort((a, b) => a.created - b.created);
|
|
1449
1788
|
const users = list.map((m, i) => (m.role === "user" ? i : -1)).filter((i) => i >= 0);
|
|
@@ -1451,13 +1790,20 @@ export function createEngine(deps) {
|
|
|
1451
1790
|
for (let k = 0; k < users.length; k++) {
|
|
1452
1791
|
const agent = segmentAgent(list, users[k]);
|
|
1453
1792
|
const d = list[users[k]].data;
|
|
1454
|
-
const meta = (d.metadata ??= {});
|
|
1455
1793
|
const want = k > 0 && agent && prev && agent !== prev ? agent : undefined;
|
|
1456
|
-
|
|
1457
|
-
|
|
1794
|
+
// only touch metadata when the stamp actually CHANGES — a blanket
|
|
1795
|
+
// `d.metadata ??= {}` polluted every user message (including the
|
|
1796
|
+
// host's live objects on v2 pass-through) with an empty metadata
|
|
1797
|
+
// object, which re-emitted the message once per refresh (rev churn
|
|
1798
|
+
// per feed — live-caught by the enqueue-dedupe rev check)
|
|
1799
|
+
if (want !== d.metadata?.agentSwitch) {
|
|
1800
|
+
if (want) {
|
|
1801
|
+
const meta = (d.metadata ??= {});
|
|
1458
1802
|
meta.agentSwitch = want;
|
|
1459
|
-
|
|
1460
|
-
|
|
1803
|
+
}
|
|
1804
|
+
else if (d.metadata) {
|
|
1805
|
+
delete d.metadata.agentSwitch;
|
|
1806
|
+
}
|
|
1461
1807
|
reEnqueueMessage(list[users[k]]);
|
|
1462
1808
|
}
|
|
1463
1809
|
if (agent)
|
|
@@ -1470,15 +1816,7 @@ export function createEngine(deps) {
|
|
|
1470
1816
|
* started (v1 prompts carry the agent; v2 resolves via the response).
|
|
1471
1817
|
* Rank-free pairing - a resumed child's second call lands on its second
|
|
1472
1818
|
* prompt by construction time. */
|
|
1473
|
-
const stampTaskAgents = () => {
|
|
1474
|
-
const bySession = new Map();
|
|
1475
|
-
for (const m of msgIndex.values()) {
|
|
1476
|
-
const list = bySession.get(m.sid);
|
|
1477
|
-
if (list)
|
|
1478
|
-
list.push(m);
|
|
1479
|
-
else
|
|
1480
|
-
bySession.set(m.sid, [m]);
|
|
1481
|
-
}
|
|
1819
|
+
const stampTaskAgents = (bySession) => {
|
|
1482
1820
|
for (const list of bySession.values())
|
|
1483
1821
|
list.sort((a, b) => a.created - b.created);
|
|
1484
1822
|
for (const m of msgIndex.values()) {
|
|
@@ -1586,9 +1924,35 @@ export function createEngine(deps) {
|
|
|
1586
1924
|
reEnqueueMessage(m);
|
|
1587
1925
|
}
|
|
1588
1926
|
};
|
|
1589
|
-
const stampsPass = () => {
|
|
1590
|
-
|
|
1591
|
-
|
|
1927
|
+
const stampsPass = (st) => {
|
|
1928
|
+
// skip entirely when this flush carries no message items: every
|
|
1929
|
+
// index mutation rides a message item through the queue (inserts via
|
|
1930
|
+
// enqueue, deletes via tombstones — both are type "message"), so no
|
|
1931
|
+
// message in the queue means the stamps' inputs are unchanged. A
|
|
1932
|
+
// status-only/model/prefs/diff flush costs zero stamp work.
|
|
1933
|
+
let any = false;
|
|
1934
|
+
for (const it of st.queue.values()) {
|
|
1935
|
+
if (it.type === "message") {
|
|
1936
|
+
any = true;
|
|
1937
|
+
break;
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
if (!any)
|
|
1941
|
+
return;
|
|
1942
|
+
// one grouping + one sort shared by both passes (each used to build
|
|
1943
|
+
// and sort its own identical bySession map per flush)
|
|
1944
|
+
const bySession = new Map();
|
|
1945
|
+
for (const m of msgIndex.values()) {
|
|
1946
|
+
const list = bySession.get(m.sid);
|
|
1947
|
+
if (list)
|
|
1948
|
+
list.push(m);
|
|
1949
|
+
else
|
|
1950
|
+
bySession.set(m.sid, [m]);
|
|
1951
|
+
}
|
|
1952
|
+
for (const list of bySession.values())
|
|
1953
|
+
list.sort((a, b) => a.created - b.created);
|
|
1954
|
+
stampAgentSwitches(bySession);
|
|
1955
|
+
stampTaskAgents(bySession);
|
|
1592
1956
|
stampFileLanguages();
|
|
1593
1957
|
};
|
|
1594
1958
|
// ---- contract v2 message assembly --------------------------------------
|
|
@@ -1649,8 +2013,15 @@ export function createEngine(deps) {
|
|
|
1649
2013
|
e.parts.set(part.id, part);
|
|
1650
2014
|
emitMerged(st, sid, part.messageID);
|
|
1651
2015
|
};
|
|
1652
|
-
const dropMergedMessage = (st, messageID) => {
|
|
2016
|
+
const dropMergedMessage = (st, messageID, evSid) => {
|
|
2017
|
+
// the index corpse must go too: stampsPass iterates msgIndex, and a
|
|
2018
|
+
// re-stamp of a deleted message would re-upsert it (resurrection).
|
|
2019
|
+
// Key by the EVENT's session (children tombstone under their own sid;
|
|
2020
|
+
// the merged map is per main session)
|
|
2021
|
+
const sid = evSid ?? st.sid;
|
|
1653
2022
|
st.merged.delete(messageID);
|
|
2023
|
+
msgIndex.delete(`${sid}:${messageID}`);
|
|
2024
|
+
tombstoned.add(`${sid}:${messageID}`);
|
|
1654
2025
|
};
|
|
1655
2026
|
const dropMergedPart = (st, sid, messageID, partID) => {
|
|
1656
2027
|
const e = st.merged.get(messageID);
|
|
@@ -1678,6 +2049,10 @@ export function createEngine(deps) {
|
|
|
1678
2049
|
clearTimeout(r.initTimer);
|
|
1679
2050
|
r.initTimer = null;
|
|
1680
2051
|
}
|
|
2052
|
+
if (r.backoffResetTimer) {
|
|
2053
|
+
clearTimeout(r.backoffResetTimer);
|
|
2054
|
+
r.backoffResetTimer = null;
|
|
2055
|
+
}
|
|
1681
2056
|
if (r.reconnectTimer) {
|
|
1682
2057
|
clearTimeout(r.reconnectTimer);
|
|
1683
2058
|
r.reconnectTimer = null;
|
|
@@ -1687,8 +2062,28 @@ export function createEngine(deps) {
|
|
|
1687
2062
|
clearTimeout(r.idleDropTimer);
|
|
1688
2063
|
r.idleDropTimer = null;
|
|
1689
2064
|
}
|
|
1690
|
-
|
|
2065
|
+
// wsReady delete only when THIS remote still owns a share: a stray
|
|
2066
|
+
// child teardown clears shareId after adopting into the parent —
|
|
2067
|
+
// the ControlDO's server-side attach would otherwise delete the
|
|
2068
|
+
// very share the parent just inherited (live-caught adoption churn)
|
|
2069
|
+
if (r.wsReady && r.shareId) {
|
|
1691
2070
|
wsSend(r, { op: "delete" });
|
|
2071
|
+
// backstop: the WS delete is fire-and-forget on the wire — a
|
|
2072
|
+
// socket dying right after leaves the page public until TTL. The
|
|
2073
|
+
// HTTP goodbye is idempotent (404/ok both fine).
|
|
2074
|
+
if (r.shareId && r.secret) {
|
|
2075
|
+
const sid = r.shareId;
|
|
2076
|
+
const sec = r.secret;
|
|
2077
|
+
setTimeout(() => {
|
|
2078
|
+
void fetch(`${r.url}/api/share/${sid}`, {
|
|
2079
|
+
method: "DELETE",
|
|
2080
|
+
headers: { "content-type": "application/json" },
|
|
2081
|
+
body: JSON.stringify({ secret: sec }),
|
|
2082
|
+
signal: AbortSignal.timeout(8000),
|
|
2083
|
+
}).catch(() => { });
|
|
2084
|
+
}, 2000);
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
1692
2087
|
else if (r.shareId && r.secret) {
|
|
1693
2088
|
// turn mode may have dropped the socket after the last settle; the
|
|
1694
2089
|
// goodbye still must land — say it over HTTP
|
|
@@ -1710,6 +2105,21 @@ export function createEngine(deps) {
|
|
|
1710
2105
|
}, 1000);
|
|
1711
2106
|
for (const r of st.remotes.values())
|
|
1712
2107
|
spoolClear(st, r);
|
|
2108
|
+
// memory hygiene: the session is gone — its index corpses, tombstone
|
|
2109
|
+
// guards and child routing must not linger (msgIndex/tombstoned are
|
|
2110
|
+
// engine-global and otherwise only grow; sentHash rides the session
|
|
2111
|
+
// state and dies with it)
|
|
2112
|
+
const stSid = st.sid;
|
|
2113
|
+
const pfx = `${stSid}:`;
|
|
2114
|
+
for (const k of msgIndex.keys())
|
|
2115
|
+
if (k.startsWith(pfx))
|
|
2116
|
+
msgIndex.delete(k);
|
|
2117
|
+
for (const k of tombstoned)
|
|
2118
|
+
if (k.startsWith(pfx))
|
|
2119
|
+
tombstoned.delete(k);
|
|
2120
|
+
for (const [child, parent] of childToParent)
|
|
2121
|
+
if (parent === stSid || child === stSid)
|
|
2122
|
+
childToParent.delete(child);
|
|
1713
2123
|
sessions.delete(st.sid);
|
|
1714
2124
|
try {
|
|
1715
2125
|
chain = chain.then(async () => {
|
|
@@ -1786,6 +2196,11 @@ export function createEngine(deps) {
|
|
|
1786
2196
|
break;
|
|
1787
2197
|
before = nextBefore;
|
|
1788
2198
|
}
|
|
2199
|
+
// the page cap is a hard truncation: surface it instead of silently
|
|
2200
|
+
// sharing a partial history (oldest pages dropped)
|
|
2201
|
+
if (out.length >= MAX_PAGES * PAGE_SIZE) {
|
|
2202
|
+
logError(`backfill truncated at ${out.length} messages (page cap) for ${sid.slice(0, 18)} — oldest history not shared`);
|
|
2203
|
+
}
|
|
1789
2204
|
return out;
|
|
1790
2205
|
}
|
|
1791
2206
|
const backfillSession = async (st, client, sid, info) => {
|
|
@@ -2079,8 +2494,13 @@ export function createEngine(deps) {
|
|
|
2079
2494
|
if (st.timer)
|
|
2080
2495
|
clearTimeout(st.timer);
|
|
2081
2496
|
st.timer = null;
|
|
2497
|
+
// detached flush (HTTP fallback off the shared chain — one
|
|
2498
|
+
// black-holing server must not stall the fan-out; same rule
|
|
2499
|
+
// as scheduleFlush)
|
|
2082
2500
|
chain = chain
|
|
2083
|
-
.then(() =>
|
|
2501
|
+
.then(() => {
|
|
2502
|
+
flush(st);
|
|
2503
|
+
})
|
|
2084
2504
|
.catch((e) => logError("idle flush error", e?.message || e));
|
|
2085
2505
|
const epoch = st.statusEpoch;
|
|
2086
2506
|
const settle = () => {
|
|
@@ -2090,7 +2510,9 @@ export function createEngine(deps) {
|
|
|
2090
2510
|
clearTimeout(st.timer);
|
|
2091
2511
|
st.timer = null;
|
|
2092
2512
|
chain = chain
|
|
2093
|
-
.then(() =>
|
|
2513
|
+
.then(() => {
|
|
2514
|
+
flush(st);
|
|
2515
|
+
})
|
|
2094
2516
|
.then(() => {
|
|
2095
2517
|
armSettleDrop(st);
|
|
2096
2518
|
})
|
|
@@ -2137,8 +2559,12 @@ export function createEngine(deps) {
|
|
|
2137
2559
|
if (!messageID)
|
|
2138
2560
|
return;
|
|
2139
2561
|
if (t === "message.removed") {
|
|
2140
|
-
|
|
2141
|
-
|
|
2562
|
+
// key/tombstone by the EVENT's session id (a child's message
|
|
2563
|
+
// must not be keyed onto the parent — the worker deletes the
|
|
2564
|
+
// item but the viewer's tombstone key would miss and the
|
|
2565
|
+
// msgIndex corpse could re-upsert it)
|
|
2566
|
+
dropMergedMessage(st, messageID, sid);
|
|
2567
|
+
enqueue(st, { key: `message/${messageID}`, item: messageTombstone(sid, messageID) });
|
|
2142
2568
|
}
|
|
2143
2569
|
else {
|
|
2144
2570
|
const partID = p.partID;
|
|
@@ -2281,6 +2707,16 @@ export function createEngine(deps) {
|
|
|
2281
2707
|
await flushViaHttp(st, r).catch(() => { });
|
|
2282
2708
|
}
|
|
2283
2709
|
}
|
|
2710
|
+
// resolve any pending debounced spool rewrite before exit: the
|
|
2711
|
+
// queue lines are already on disk (appends are sync), but the
|
|
2712
|
+
// meta's ackedRev and the compacted queue should be final
|
|
2713
|
+
for (const r of st.remotes.values()) {
|
|
2714
|
+
if (r.spoolSaveTimer) {
|
|
2715
|
+
clearTimeout(r.spoolSaveTimer);
|
|
2716
|
+
r.spoolSaveTimer = null;
|
|
2717
|
+
spoolSave(st, r);
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2284
2720
|
if (st.metaTimer)
|
|
2285
2721
|
clearTimeout(st.metaTimer);
|
|
2286
2722
|
st.metaTimer = null;
|