@@ -299,6 +299,9 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
299299 private long sentDictBytesAddr ;
300300 private int sentDictBytesCapacity ;
301301 private int sentDictBytesLen ;
302+ // False only while an orphan-drainer loop borrows the persisted prefix.
303+ // Any growth first performs a copy-on-write; only owned buffers are freed.
304+ private boolean sentDictBytesOwned ;
302305 private int sentDictCount ;
303306 // Reusable staging frame for dictionary catch-up chunks. A reconnect may split a
304307 // large dictionary into many chunks, and every later reconnect repeats that split;
@@ -721,24 +724,17 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
721724 // tracks. When the dictionary could not be opened, pd is null, the mirror
722725 // simply starts empty and grows from the frames themselves.
723726 PersistedSymbolDict pd = engine .getPersistedSymbolDict ();
727+ int persistedPrefixLen = 0 ;
724728 if (pd != null && pd .size () > 0 ) {
725729 int len = pd .loadedEntriesLen ();
726730 if (len > 0 ) {
727- // COPY the persisted dictionary's loaded-entries buffer into this
728- // loop's own mirror rather than taking ownership of it. The engine
729- // (and its PersistedSymbolDict) OUTLIVES this loop on the orphan
730- // drainer path: BackgroundDrainer builds a fresh send loop per wire
731- // session against the same engine on a durable-ack capability-gap
732- // recycle. A one-shot ownership transfer would leave every loop
733- // after the first with an EMPTY mirror -- it would then send no
734- // reconnect catch-up, and the first replayed delta frame
735- // (deltaStart > 0) would trip the torn-dict guard, falsely
736- // quarantining a healthy slot. Copying keeps the dictionary's
737- // loaded entries intact for the engine's lifetime so every
738- // recycled loop re-seeds; pd.close() (at engine close) frees the
739- // dictionary's copy, this loop frees its own copy on exit.
740- sentDictBytesAddr = Unsafe .malloc (len , MemoryTag .NATIVE_DEFAULT );
741- Unsafe .getUnsafe ().copyMemory (pd .loadedEntriesAddr (), sentDictBytesAddr , len );
731+ // Seed by reference. The foreground loop takes ownership after
732+ // construction succeeds; orphan-drainer loops keep borrowing because
733+ // one engine can create several sessions during capability-gap
734+ // recycling. A borrowed mirror performs copy-on-write if a recovered
735+ // frame suffix must extend it.
736+ persistedPrefixLen = len ;
737+ sentDictBytesAddr = pd .loadedEntriesAddr ();
742738 sentDictBytesCapacity = len ;
743739 sentDictBytesLen = len ;
744740 // Set the count only alongside the bytes so sentDictCount can
@@ -786,16 +782,25 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
786782 }
787783 }
788784 } catch (Throwable t ) {
789- if (sentDictBytesAddr != 0 ) {
790- Unsafe .free (sentDictBytesAddr , sentDictBytesCapacity , MemoryTag .NATIVE_DEFAULT );
791- sentDictBytesAddr = 0 ;
792- sentDictBytesCapacity = 0 ;
793- sentDictBytesLen = 0 ;
794- sentDictCount = 0 ;
795- }
785+ releaseSentDictBytes ();
796786 throw t ;
797787 }
798788 }
789+ // QwpWebSocketSender seeds the producer dictionary before constructing
790+ // its one foreground loop, so that loop can own the recovered prefix and
791+ // eliminate the engine-side duplicate. BackgroundDrainer must retain the
792+ // prefix for later recycled loops and therefore keeps borrowing it.
793+ if (persistedPrefixLen > 0 && catchUpCapGapPolicy == CatchUpCapGapPolicy .RETRY_FOREVER ) {
794+ long persistedPrefixAddr = pd .takeLoadedEntries ();
795+ if (sentDictBytesOwned ) {
796+ // Copy-on-grow already produced the combined mirror. Retire the
797+ // no-longer-needed persisted prefix after successful construction.
798+ Unsafe .free (persistedPrefixAddr , persistedPrefixLen , MemoryTag .NATIVE_DEFAULT );
799+ } else {
800+ assert persistedPrefixAddr == sentDictBytesAddr ;
801+ sentDictBytesOwned = true ;
802+ }
803+ }
799804 this .fsnAtZero = fsnAtZero ;
800805 this .parkNanos = parkNanos ;
801806 this .reconnectFactory = reconnectFactory ;
@@ -1131,15 +1136,7 @@ public synchronized void close() {
11311136 // thread may still be mid-send, so touching the mirror here would race.
11321137 // A duplicate close observes sentDictBytesAddr == 0 and skips.
11331138 if (loopNeverRan && sentDictBytesAddr != 0 ) {
1134- Unsafe .free (sentDictBytesAddr , sentDictBytesCapacity , MemoryTag .NATIVE_DEFAULT );
1135- sentDictBytesAddr = 0 ;
1136- sentDictBytesCapacity = 0 ;
1137- sentDictBytesLen = 0 ;
1138- // Reset the count alongside the buffer so the mirror stays all-or-
1139- // nothing: a hypothetical close()-then-start() (start() has no closed
1140- // guard) must not observe a non-zero sentDictCount against a freed
1141- // buffer and drive setWireBaselineWithCatchUp into a null-mirror catch-up.
1142- sentDictCount = 0 ;
1139+ releaseSentDictBytes ();
11431140 }
11441141 if (loopNeverRan ) {
11451142 freeCatchUpFrameBuffer ();
@@ -2048,11 +2045,7 @@ private void ioLoop() {
20482045 // The symbol-dict mirror is I/O-thread-owned; free it here, on the
20492046 // owning thread's exit path, after the last send that could touch it.
20502047 if (sentDictBytesAddr != 0 ) {
2051- Unsafe .free (sentDictBytesAddr , sentDictBytesCapacity , MemoryTag .NATIVE_DEFAULT );
2052- sentDictBytesAddr = 0 ;
2053- sentDictBytesCapacity = 0 ;
2054- sentDictBytesLen = 0 ;
2055- sentDictCount = 0 ; // keep the mirror all-or-nothing (see close())
2048+ releaseSentDictBytes ();
20562049 }
20572050 freeCatchUpFrameBuffer ();
20582051 shutdownLatch .countDown ();
@@ -2438,10 +2431,34 @@ private void ensureSentDictCapacity(long required) {
24382431 if (newCap > MAX_SENT_DICT_BYTES ) {
24392432 newCap = MAX_SENT_DICT_BYTES ;
24402433 }
2441- sentDictBytesAddr = Unsafe .realloc (sentDictBytesAddr , sentDictBytesCapacity , (int ) newCap , MemoryTag .NATIVE_DEFAULT );
2434+ if (sentDictBytesOwned ) {
2435+ sentDictBytesAddr = Unsafe .realloc (
2436+ sentDictBytesAddr ,
2437+ sentDictBytesCapacity ,
2438+ (int ) newCap ,
2439+ MemoryTag .NATIVE_DEFAULT );
2440+ } else {
2441+ long newAddr = Unsafe .malloc ((int ) newCap , MemoryTag .NATIVE_DEFAULT );
2442+ if (sentDictBytesLen > 0 ) {
2443+ Unsafe .getUnsafe ().copyMemory (sentDictBytesAddr , newAddr , sentDictBytesLen );
2444+ }
2445+ sentDictBytesAddr = newAddr ;
2446+ sentDictBytesOwned = true ;
2447+ }
24422448 sentDictBytesCapacity = (int ) newCap ;
24432449 }
24442450
2451+ private void releaseSentDictBytes () {
2452+ if (sentDictBytesAddr != 0 && sentDictBytesOwned ) {
2453+ Unsafe .free (sentDictBytesAddr , sentDictBytesCapacity , MemoryTag .NATIVE_DEFAULT );
2454+ }
2455+ sentDictBytesAddr = 0 ;
2456+ sentDictBytesCapacity = 0 ;
2457+ sentDictBytesLen = 0 ;
2458+ sentDictBytesOwned = false ;
2459+ sentDictCount = 0 ;
2460+ }
2461+
24452462 private long readVarintAt (long p , long limit ) {
24462463 long value = 0 ;
24472464 int shift = 0 ;
0 commit comments