feat(container-loader): add readOnly option to loadFrozenContainerFromPendingState#27211
Conversation
…mPendingState Currently a frozen container is always read-only via the storageOnly policy. This adds a `readOnly` option (default `true`) so callers can load a frozen container that surfaces as writable in order to accrue and capture additional pending state without publishing it. When `readOnly: false`: - The initial connect returns a `FrozenDeltaStream` configured with `DocWrite` scope so `readOnlyInfo.readonly === false`. - The runtime accepts DDS submissions; the first submit triggers a read→write upgrade attempt that the document service intercepts and hangs (we have no upstream and won't fabricate a quorum join op). - The container settles into Disconnected. DDS local apply continues, and ops accumulate in the runtime's pendingStateManager so getPendingLocalState() can capture them. Other changes: - `FrozenDeltaStream` constructor switched from positional args to an options bag with `readOnly`, `storageOnlyReason`, and `readonlyConnectionReason`. - Both variants nack on submit/submitSignal — under normal flow neither is ever called (sendMessages short-circuits read-mode ops to reconnect), so a nack reaching the connectionManager is the right defensive signal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Hi! Thank you for opening this PR. Want me to review it? Based on the diff (334 lines, 4 files), I've queued these reviewers:
Toggle the reviewer checkboxes above to adjust, then tick the box below to start:
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- submitSignal becomes a silent no-op for both FrozenDeltaStream variants.
The old read-only behavior emitted a non-array nack payload (would crash
nackHandler's `messages[0]` access) and, on the writable variant, would
trigger reconnect/close on stray runtime/presence signals. Dropping is
the correct behavior since signals are ephemeral and we have no upstream.
- createFrozenDocumentServiceFactory now re-wraps an already-wrapped
factory when its readOnly differs from the caller's argument, so the
caller's most recent intent wins instead of being silently dropped.
- Surface FrozenDocumentServiceFactory.readOnly/inner so the rewrap path
can unwrap; rename the inner field for clarity.
- Tests:
- signal submission on the writable frozen container does not close it.
- getPendingLocalState() round-trips writable-frozen edits through a
second writable-frozen load (the load-bearing invariant for this PR).
- readOnly: false wins over an already-wrapped readOnly: true factory.
- Fix a misleading comment that claimed writable variant "silently
drops" submissions; ops actually accumulate in pendingStateManager.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review fixes — addressed in e771980Picking up the four findings from the Deep Review (only one was an inline thread; the other three were summary-only): 1 + 3:
|
|
Deep Review: Thanks for the rapid turnaround on One new concern surfaced this round, posted as an inline thread on |
…ial connect
Container.connectToDeltaStream forces args.mode = "write" on the very first
connect when allowReconnect is false or the client is non-interactive
(container.ts:1517-1523). The previous FrozenDocumentService logic gated
on client.mode alone, so that initial connect would be intercepted by the
upgrade-hang path and the load would never complete.
Track first-vs-subsequent connect on FrozenDocumentService and always
return the writable FrozenDeltaStream on the first call, regardless of
client.mode. Mode-based hang now applies only to subsequent write-mode
connects, which is the runtime-driven read→write upgrade we actually
want to intercept. Subsequent read-mode connects still return a fresh
writable stream.
Tests:
- loadFrozenContainerFromPendingState({readOnly: false, allowReconnect: false})
asserts readonly === false, container stays open, and pending edits
round-trip through getPendingLocalState().
- Same assertions for the non-interactive-client variant
(clientDetailsOverride.capabilities.interactive === false).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ect is hung The dispose ordering on the hung write-upgrade promise is the most invariant-relevant scenario in writable-frozen — FrozenDocumentService.dispose() and pendingConnectRejecters exist specifically to make container.dispose() complete in this state — and it had no test coverage. Two regression tests: - dispose() completes while the read→write upgrade connect is hung — triggers the upgrade via a DDS write, awaits the disconnect that precedes the hang, then asserts dispose() returns and disposed === true. - close() does not hang while the read→write upgrade connect is hung — same flow via close(); pins the documented benign-leak tradeoff (close() does not propagate to service.dispose(), so the hung promise stays pending until GC). Also document the close-vs-dispose tradeoff in connectToDeltaStream's hung-promise block so future readers know which path drains pendingConnectRejecters and why. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Blocked CI lint with @typescript-eslint/no-unnecessary-type-arguments — void is the default for timeoutPromise's T parameter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ose/close tests The new dispose-during-hung-upgrade and close-during-hung-upgrade tests were timing out at mocha's ~2s default while waiting 5s for a "disconnected" event. The writable-frozen container does not transition to a "saved" state and does not necessarily emit "disconnected" before the upgrade-connect call lands — connection state may transition through EstablishingConnection instead, or the runtime's outbox flush is async enough that nothing visible happens in 2s. Switch to a bounded 500ms sleep that yields long enough for the upgrade attempt to register the hung promise on pendingConnectRejecters. Test still validates the dispose drain path; it just doesn't require an explicit disconnected signal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… deep review Six independent items, each previously held as Tier 3 polish: - connectionManager.sendMessages: tripwire comment near the read-mode short-circuit so a future refactor surfaces writable-frozen's dependency on this exact path (the deferred reconnect-to-write is what FrozenDocumentService.connectToDeltaStream intercepts to hang). - FrozenDeltaStream constructor: runtime asserts that storageOnlyReason and readonlyConnectionReason are not passed when readOnly: false — the writable variant has no readOnlyInfo to surface them on, so silent acceptance was misleading. Per repo convention, asserts use string literal messages, not hex codes. - FrozenDeltaStream constructor: JSDoc the `as ITokenClaims` cast — only scopes drives observable behavior (DocRead vs DocWrite gates readOnlyInfo); inventing sentinels for tenant/document/user/iat/exp/ver would imply quorum membership we cannot honor. - ILoadFrozenContainerFromPendingStateProps.readOnly: JSDoc rationale for the negative polarity (alignment with IContainer.readOnlyInfo), preempting the alpha-rename-window question. - New writes-after-disconnect integration test: writes performed AFTER the upgrade-connect has hung still apply locally and round-trip through getPendingLocalState() into a second writable-frozen load. - New unit-test file for FrozenDeltaStream covering submit's nack payload shape (array, code 403, one entry per op), submitSignal as a silent no-op for both variants, and the constructor guards above. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…am connections
Without this, loadFrozenContainerFromPendingState({ readOnly: false,
allowReconnect: false }) closes asynchronously after the first DDS write:
the read-mode reconnect scheduled by sendMessages → reconnect("write") →
ReconnectMode.Never path → closeHandler() fires on a microtask. The
existing allowReconnect:false test masked this because it captured pending
state before the close microtask could be observed.
ConnectionManager.sendMessages now recognizes a FrozenDeltaStream as the
live connection and drops outbound messages at the network layer — the
runtime's outbox keeps them in pendingStateManager for getPendingLocalState
to capture. This is the design invariant the writable-frozen flow already
depended on, made explicit and enforced rather than load-bearing on a
side-effect of the read-mode short-circuit.
Other consequences:
- Container stays Connected for writable-frozen (previously settled into
Disconnected after the upgrade-connect hung). JSDocs in frozenServices.ts
and createAndLoadContainerUtils.ts updated to reflect the new mechanism.
- The upgrade-hang at FrozenDocumentService.connectToDeltaStream({ mode:
"write" }) is now defense-in-depth — unreachable in normal flow but
retained for invariant breaks. JSDoc updated accordingly.
Tests:
- Extended `loads with allowReconnect: false` to flush microtasks after
the initial writes, assert closed === false, perform another batch of
writes, and assert all writes round-trip through pending state into a
second writable-frozen load. Without the sendMessages fix this would
fail on the closed assertion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…trings + changeset
After the sendMessages FrozenDeltaStream short-circuit landed, the
integration "dispose() / close() while upgrade-connect is hung" tests
no longer exercise the path they were written for: the short-circuit
returns before any read→write reconnect fires, so pendingConnectRejecters
is empty when those tests call dispose() / close(), and disposed === true
/ closed === true assertions pass on the synchronous setter alone.
Restoration: add focused unit tests in frozenServices.spec.ts that drive
FrozenDocumentService.connectToDeltaStream({mode: "write"}) directly to
exercise the rejecter:
- rejects the hung upgrade-connect when service.dispose() is called
- rejects synchronously when called after service.dispose()
- drains multiple pending rejecters on a single dispose() call
Also align the now-stale narrative across artifacts:
- Changeset: replace the "first runtime submit triggers an internal
read→write upgrade attempt that cannot succeed, so the container settles
into Disconnected" narrative with the actual flow (sendMessages drops
outbound at the FrozenDeltaStream short-circuit; container stays
Connected). Call out the submitSignal read-only-variant behavior
change (was 403 nack, now silent no-op) explicitly.
- Integration tests: rename "dispose() while the read→write upgrade
connect is hung" → "dispose() runs cleanly on a writable-frozen
container after a local write" (and same for close); rename
"captures writes performed after the upgrade-connect has hung" →
"captures writes batched across timer/microtask boundaries". Update
inline comments to describe the actual short-circuit-first flow and
cross-link the focused unit test for the dispose-rejecter coverage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tier 1 finding addressed in 411863fThe dispose/close-during-hung-upgrade integration tests were vacuous after the sendMessages short-circuit landed — confirmed. Restored real coverage via focused unit tests in `packages/loader/container-loader/src/test/frozenServices.spec.ts` that drive `FrozenDocumentService.connectToDeltaStream({mode: "write"})` directly, then assert dispose() rejects the hung promise. Three cases:
Picked option (b) — focused unit test against a directly-constructed `FrozenDocumentService` — over option (a) — driving connectToDeltaStream from the integration test — because (b) is lighter weight and doesn't require accessing the underlying `IDocumentService` from inside the integration container. Also addressed Tier-3 polish in the same commit:
Held for the next iteration / human reviewer:
|
The unit test typed `rejection` as `unknown` and called `instanceof Error`, which TypeScript rejects for `unknown` LHS. Type rejection as `Error | undefined` and check for `undefined` instead — equivalent semantics, no instanceof on unknown. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
assert.strictEqual is declared with `asserts actual is T`, which narrowed `rejection` to type `undefined` for the rest of the function. The subsequent `assert(rejection !== undefined)` then narrowed to `never`, making `rejection.message` a TS2339 error. Capture into a separate const before the pending-state assertion so the narrowing scopes to that const and the post-dispose check sees rejection as Error|undefined again. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The lint rule requires catch handler parameters to be named 'error'. Bare 'e' was caught by eslint --quiet at 178:22. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Deep Review: Thanks for the rapid turnaround on
One new concern surfaced this round, posted as an inline thread on |
Pre-fix, every FrozenDeltaStream instance shared the constant clientId
"storage-only client". A subsequent connect through
FrozenDocumentService.connectToDeltaStream's `client.mode !== "write"`
branch (e.g. an explicit container.disconnect/connect cycle on a
writable-frozen container with dirty pending ops) would replay pending
ops against the same clientId twice and trip pendingStateManager's
0x173 assert ("replayPendingStates called twice for same clientId").
Mint a fresh `frozen-delta-stream/<uuid>` clientId per instance, mirror
it in initialClients so the audience handler still observes "self" and
transitions to Connected.
Adjusted two existing tests in noDeltaStream.spec.ts that asserted the
literal "storage-only client" string — they now assert the
"frozen-delta-stream/" prefix instead.
New regression test in loadFrozenContainerFromPendingState.spec.ts
forces a disconnect/reconnect cycle on a writable-frozen container
with dirty pending ops and verifies the container survives, all writes
remain locally visible, and pending state still round-trips. Pre-fix
this would have tripped 0x173.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…instance clientId; subsystem JSDoc Three Tier-3 polish items from the deep review on 849c0ed: - Narrow ConnectionManager.sendMessages's FrozenDeltaStream short-circuit to the writable variant (`!this.connection.readOnly`). The read-only variant retains its FrozenDeltaStream.submit 403-nack tripwire — a stray submit on a storage-only frozen connection should remain observable as an upstream invariant break. Tripwire comment expanded to document the read-only-vs-writable split. - Add a focused unit test in frozenServices.spec.ts pinning that subsequent connectToDeltaStream({mode:"read"}) calls on a writable FrozenDocumentService return distinct FrozenDeltaStream instances with distinct frozen-delta-stream/<uuid> clientIds, and that initialClients mirrors the per-instance id. This pins the linchpin that prevents pendingStateManager 0x173 on the read-mode reconnect branch — a future refactor that breaks the per-instance UUID would now fail this test. - Subsystem JSDoc note on ILoadFrozenContainerFromPendingStateProps.readOnly confirming summarizer / id-compressor / blob behavior is unchanged from the read-only frozen path (storage ops throw, no acks, no quorum changes); only delta is the runtime accepting DDS submissions and accumulating them in pendingStateManager. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Quick status note on the 8/10 verdict on The "held subsystem JSDoc note" item in Path to Ready is already addressed in
Remaining items from the Path to Ready are all on the human reviewer side:
All 7 prior Deep Review iterations have closed; no fresh correctness findings on this commit. Standing down on autonomous changes until human review lands. |
…itable siblings - FrozenDeltaStream (read-only) and WritableFrozenDeltaStream (writable) now extend a shared abstract FrozenDeltaStreamBase. The split replaces the prior `readOnly` flag and the runtime asserts that gated which options were valid for which variant — now encoded in types. - New isWritableFrozenDeltaStreamConnection predicate parallels isFrozenDeltaStreamConnection. ConnectionManager.sendMessages uses the predicate instead of a raw `instanceof + .readOnly` check; all type discrimination at consumer call sites goes through predicate functions. - Collapsed FrozenDocumentService.connectToDeltaStream: it now returns a fresh WritableFrozenDeltaStream regardless of client.mode or whether this is the initial connect. The write-mode hang fallback was unreachable in normal flow (sendMessages short-circuit blocks the only runtime path that would request a write reconnect), so the rejecter Set, disposed flag, handedOutInitialConnection flag, and dispose-time draining loop are all gone. dispose() is a no-op. - Tests updated: removed three lifecycle tests that exercised the now-deleted hang/rejecter machinery; kept the per-instance clientId regression test. Refreshed comments in the integration-test file that referenced the dead code paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…est; document storageOnly consumer audit - New `forceReadonly(true) surfaces a writable-frozen container as readonly` integration test pins the interaction with microsoft#11655's "lie disconnected" mechanism: forceReadonly(true) on a writable-frozen container falls through to the standard disconnect/reconnect-as-read flow, the new connection is another WritableFrozenDeltaStream, and `_forceReadonly = true` overrides readOnlyInfo.readonly to true regardless of the new stream's DocWrite scope. The runtime stops submitting at the readonly boundary; the sendMessages short-circuit becomes double-protection rather than the load-bearing layer. - JSDoc note on `FrozenDocumentService.policies` records the consumer audit (only ConnectionManager.connectCore consumes `policies.storageOnly` as a frozen-container marker; all other matches are drivers reading their own policies or `IReadOnlyInfo.storageOnly`, which is derived from the live connection). Writable-frozen is intentionally indistinguishable from a normal container at the policies layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-instance UUID to writable variant
Address review feedback that the read-only frozen path's identity (clientId, IClient.user.id) shouldn't change since customers may key off it. The 0x173 replay-assert risk that motivated per-instance clientIds applies only to the writable variant, where the runtime accumulates dirty pending ops across reconnects.
- FrozenDeltaStream (read-only): clientId restored to the constant `"storage-only client"`. clientFrozenDeltaStream.user.id reverted to `"storage-only client"`. The historical clientIdFrozenDeltaStream constant is restored.
- WritableFrozenDeltaStream: still mints a per-instance `frozen-delta-stream/<uuid>` clientId; the 0x173 dodge stays where it's actually needed.
- FrozenDeltaStreamBase: constructor now takes `(clientId, claims)` parameters so each subclass picks its own scheme. claims is a single field on the base (was abstract + duplicate `as ITokenClaims` casts in two subclasses); module-level readOnlyClaims / writableClaims constants share the cast rationale.
- noDeltaStream.spec.ts: 4 assertions reverted from `assert.match(/^frozen-delta-stream\//)` back to `strictEqual("storage-only client")` to match the restored read-only identity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds a readOnly?: boolean option (defaulting to true) to loadFrozenContainerFromPendingState and createFrozenDocumentServiceFactory, enabling a new “writable frozen” load mode where the runtime can accept local DDS submissions while still preventing any outbound ops from reaching a real service.
Changes:
- Add
readOnly?: booleanplumbing for frozen-from-pending-state loads and factory wrapping (default preserves existing read-only behavior). - Split frozen delta stream behavior into read-only vs writable variants, and add a
ConnectionManager.sendMessagesshort-circuit to drop outbound messages for the writable-frozen connection. - Add targeted unit + local-server tests for the writable-frozen flow and regression coverage (pending state accrual, reconnect safety, signals, etc.).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/test/local-server-tests/src/test/loadFrozenContainerFromPendingState.spec.ts | Adds extensive local-server coverage for readOnly: false flow, reconnects, pending state round-trip, and lifecycle behaviors. |
| packages/loader/container-loader/src/test/frozenServices.spec.ts | New unit tests for frozen delta stream nack/no-op behavior and per-instance clientId uniqueness for writable connections. |
| packages/loader/container-loader/src/frozenServices.ts | Introduces writable frozen delta stream variant + per-instance clientId; adds readOnly wrapping behavior to factory/service. |
| packages/loader/container-loader/src/createAndLoadContainerUtils.ts | Extends frozen-load props with readOnly?: boolean and forwards to frozen document service factory wrapper. |
| packages/loader/container-loader/src/connectionManager.ts | Adds writable-frozen sendMessages short-circuit; updates FrozenDeltaStream construction to new options shape. |
| packages/loader/container-loader/api-report/container-loader.legacy.alpha.api.md | Updates legacy alpha API report for the new optional readOnly parameter. |
| .changeset/frozen-container-readonly-option.md | Adds release note + minor bump for the new option and behavior changes. |
…ts; fix changeset wording Address Copilot review feedback on timing-dependent test waits and stale wording: - Changeset: "synthetic FrozenDeltaStream" → "synthetic WritableFrozenDeltaStream" in the writable-context paragraph (the writable path uses WritableFrozenDeltaStream specifically, and sendMessages short-circuits on that). - captures writes batched across timer/microtask boundaries: 500ms sleep → setTimeout(0). Crossing one macrotask boundary is sufficient for the test's intent (runtime continues to capture pending state across timer boundaries); 500ms was overkill. Renamed map keys preDisconnect/postDisconnect → preDelay/postDelay since no disconnect occurs in this test. Updated comment "FrozenDeltaStream short-circuit" → "WritableFrozenDeltaStream short-circuit". - dispose() / close() runs cleanly: dropped the 200ms delays. The simplification that collapsed FrozenDocumentService.connectToDeltaStream branches removed the async hung-promise machinery, so there's no deferred work to wait for; dispose()/close() can run immediately after the synchronous root.set(). - forceReadonly(true): dropped the 200ms delay. forceReadonly toggles _forceReadonly synchronously, so readOnlyInfo updates without awaiting a reconnect cycle (the disconnect/reconnect-as-read triggered behind it is exercised by the survives-disconnect/reconnect test). - survives a disconnect/reconnect cycle: replaced the 200ms sleep with Promise.race against the "connected" event and a 500ms safety-net timeout. Resolves immediately if the event fires; bounded otherwise. The safety net is documented. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI biome check on `a3360ee43c` flagged the multiline `new Promise<void>((resolve) => frozenContainer.once("connected", () => resolve()))` arrow body. Reformat to one line.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
🔗 No broken links found! ✅ Your attention to detail is admirable. linkcheck output |
Deep ReviewReviewed commit Readiness: 10/10 — READY Sign-off ready. No Tier 1–3 findings on Context for Reviewers
For human reviewer
Review history (10 prior reviews)
|
Description
Adds an optional
readOnlyparameter (defaulttrue, preserving existing behavior) toloadFrozenContainerFromPendingStateandcreateFrozenDocumentServiceFactory. WhenreadOnly: false, the frozen container loads as writable so the runtime accepts DDS submissions and accumulates them inpendingStateManagerforgetPendingLocalState()to capture — without publishing them.The mechanism: a sibling
WritableFrozenDeltaStreamis the live connection (claims includeDocWrite, mode stays"read").ConnectionManager.sendMessagesmatches it viaisWritableFrozenDeltaStreamConnectionand short-circuits before the read-mode reconnect branch — outbound writes are dropped at the network layer instead of triggering a reconnect (which underReconnectMode.Neverwould close the container).Other notable changes:
FrozenDeltaStreamis split into two sibling classes (FrozenDeltaStreamread-only,WritableFrozenDeltaStreamwritable) over a shared abstract base. Both internal-only. Type discrimination at consumer call sites uses predicate functions (isFrozenDeltaStreamConnection,isWritableFrozenDeltaStreamConnection) — no rawinstanceof.WritableFrozenDeltaStreammints a per-instancefrozen-delta-stream/<uuid>clientIdto avoidpendingStateManager0x173(replayPendingStates called twice for same clientId!) on reconnect with dirty pending ops. The read-only variant keeps the historical constant"storage-only client".submitSignalis now a silent no-op on both variants (was a 403 nack on the read-only variant). Signals are ephemeral and dropping is the right behavior with no upstream; the read-only variant'ssubmitstays defensive (403 nack) since ops are durable state.createFrozenDocumentServiceFactoryrewraps an already-wrapped factory if itsreadOnlydiffers from the caller — most recent intent wins.API report and a changeset are included.
Reviewer Guidance
ConnectionManager.sendMessagesWritableFrozenDeltaStreamshort-circuit — sits in front of Remove two types of nacks - "Nonexistent client" & "Readonly client" #7753's read-mode invariant.@legacy @alphashape (readOnly?: booleanpolarity / positional arg oncreateFrozenDocumentServiceFactory).FrozenDeltaStreamconstructor reshape at her two historical call sites.The
submit/submitSignalasymmetry onFrozenDeltaStreamis intentional: ops are durable (nack to surface invariant breaks); signals are ephemeral (drop).fixes AB#72108