Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3d57157
feat(container-loader): add readOnly option to loadFrozenContainerFro…
anthony-murphy Apr 30, 2026
2991cf6
add changeset and regenerate api-report
anthony-murphy Apr 30, 2026
8f2774f
fix biome formatting and unresolved @link references
anthony-murphy Apr 30, 2026
e771980
fix(container-loader): address frozen-container review findings
anthony-murphy Apr 30, 2026
fb6a0fd
fix(container-loader): hand out writable surface on forced-write init…
anthony-murphy Apr 30, 2026
f4a202e
test(container-loader): cover dispose/close while frozen upgrade-conn…
anthony-murphy May 1, 2026
a08917f
fix(test): drop redundant <void> type argument on timeoutPromise calls
anthony-murphy May 1, 2026
3e767b8
fix(test): replace disconnected-event wait with bounded sleep in disp…
anthony-murphy May 1, 2026
7c31d95
polish(container-loader): batch deferred polish from frozen-container…
anthony-murphy May 1, 2026
ff37f19
fix(container-loader): short-circuit sendMessages for FrozenDeltaStre…
anthony-murphy May 1, 2026
411863f
test(container-loader): restore dispose-rejecter coverage; align docs…
anthony-murphy May 1, 2026
d1fd645
fix(test): use Error|undefined type to satisfy TS2358 on rejection check
anthony-murphy May 1, 2026
7866ef5
fix(test): isolate strictEqual narrowing on rejection check
anthony-murphy May 1, 2026
cdcf203
fix(test): rename catch parameters to satisfy unicorn/catch-error-name
anthony-murphy May 1, 2026
849c0ed
fix(container-loader): mint per-instance clientId for FrozenDeltaStream
anthony-murphy May 2, 2026
ce7f4ed
polish(container-loader): narrow sendMessages short-circuit; pin per-…
anthony-murphy May 2, 2026
e93eb26
Merge branch 'main' into users/anthonm/frozen-container-readonly-option
anthony-murphy May 4, 2026
ac44bd1
refactor(container-loader): split FrozenDeltaStream into read-only/wr…
anthony-murphy May 4, 2026
ebb5b29
test(container-loader): add forceReadonly × writable-frozen pinning t…
anthony-murphy May 5, 2026
8f15eef
refactor(container-loader): preserve read-only clientId; localize per…
anthony-murphy May 5, 2026
86c509a
Merge branch 'main' into users/anthonm/frozen-container-readonly-option
anthony-murphy May 5, 2026
2035078
Merge branch 'main' into users/anthonm/frozen-container-readonly-option
anthony-murphy May 5, 2026
4ddd5c1
Merge branch 'main' into users/anthonm/frozen-container-readonly-option
anthony-murphy May 6, 2026
a3360ee
test(container-loader): replace fixed timeouts with deterministic wai…
anthony-murphy May 6, 2026
102c584
fix(test): collapse Promise.race new Promise to one line for biome
anthony-murphy May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/frozen-container-readonly-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@fluidframework/container-loader": minor
"__section": feature
---
Add readOnly option to loadFrozenContainerFromPendingState

Check failure on line 5 in .changeset/frozen-container-readonly-option.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [Vale.Spelling] Did you really mean 'readOnly'? Raw Output: {"message": "[Vale.Spelling] Did you really mean 'readOnly'?", "location": {"path": ".changeset/frozen-container-readonly-option.md", "range": {"start": {"line": 5, "column": 5}}}, "severity": "ERROR"}

`loadFrozenContainerFromPendingState` and `createFrozenDocumentServiceFactory` now accept an optional `readOnly` parameter (default `true`, preserving existing behavior).

When `readOnly: false`, the frozen container loads as writable so the runtime accepts DDS submissions. The container stays `Connected` against a synthetic `WritableFrozenDeltaStream`: `ConnectionManager.sendMessages` recognizes it as the live connection and short-circuits before the read-mode reconnect branch, dropping outbound messages at the connection-manager layer. Submitted ops accumulate in the runtime's pending-state manager so `getPendingLocalState()` can capture them.

Check failure on line 9 in .changeset/frozen-container-readonly-option.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [Vale.Spelling] Did you really mean 'runtime's'? Raw Output: {"message": "[Vale.Spelling] Did you really mean 'runtime's'?", "location": {"path": ".changeset/frozen-container-readonly-option.md", "range": {"start": {"line": 9, "column": 405}}}, "severity": "ERROR"}

Check warning on line 9 in .changeset/frozen-container-readonly-option.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [Microsoft.Acronyms] 'DDS' has no definition. Raw Output: {"message": "[Microsoft.Acronyms] 'DDS' has no definition.", "location": {"path": ".changeset/frozen-container-readonly-option.md", "range": {"start": {"line": 9, "column": 87}}}, "severity": "INFO"}

Use `readOnly: false` when the caller wants to load a frozen container, apply additional local changes, and capture the resulting pending state via `getPendingLocalState()`.

Also: `FrozenDeltaStream.submitSignal` is now a silent no-op for both variants. The pre-existing read-only-variant behavior was a 403 nack on signals; this PR drops it for both variants because (a) for the writable variant a stray signal would close or reconnect the container, and (b) signals are ephemeral and dropping them is the correct behavior with no upstream. `FrozenDeltaStream.submit` continues to nack defensively.

Check failure on line 13 in .changeset/frozen-container-readonly-option.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [Vale.Spelling] Did you really mean 'nack'? Raw Output: {"message": "[Vale.Spelling] Did you really mean 'nack'?", "location": {"path": ".changeset/frozen-container-readonly-option.md", "range": {"start": {"line": 13, "column": 409}}}, "severity": "ERROR"}

Check warning on line 13 in .changeset/frozen-container-readonly-option.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [Microsoft.Semicolon] Try to simplify this sentence. Raw Output: {"message": "[Microsoft.Semicolon] Try to simplify this sentence.", "location": {"path": ".changeset/frozen-container-readonly-option.md", "range": {"start": {"line": 13, "column": 150}}}, "severity": "INFO"}

Check failure on line 13 in .changeset/frozen-container-readonly-option.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [Vale.Spelling] Did you really mean 'nack'? Raw Output: {"message": "[Vale.Spelling] Did you really mean 'nack'?", "location": {"path": ".changeset/frozen-container-readonly-option.md", "range": {"start": {"line": 13, "column": 135}}}, "severity": "ERROR"}

Check warning on line 13 in .changeset/frozen-container-readonly-option.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [Microsoft.Hyphens] 'only-variant' doesn't need a hyphen. Raw Output: {"message": "[Microsoft.Hyphens] 'only-variant' doesn't need a hyphen.", "location": {"path": ".changeset/frozen-container-readonly-option.md", "range": {"start": {"line": 13, "column": 103}}}, "severity": "WARNING"}

Check warning on line 13 in .changeset/frozen-container-readonly-option.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [Microsoft.SentenceLength] Try to keep sentences short (< 30 words). Raw Output: {"message": "[Microsoft.SentenceLength] Try to keep sentences short (\u003c 30 words).", "location": {"path": ".changeset/frozen-container-readonly-option.md", "range": {"start": {"line": 13, "column": 81}}}, "severity": "INFO"}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export interface ContainerAlpha extends IContainer {
export function createDetachedContainer(createDetachedContainerProps: ICreateDetachedContainerProps): Promise<IContainer>;

// @alpha @legacy
export function createFrozenDocumentServiceFactory(factory?: IDocumentServiceFactory | Promise<IDocumentServiceFactory>): IDocumentServiceFactory;
export function createFrozenDocumentServiceFactory(factory?: IDocumentServiceFactory | Promise<IDocumentServiceFactory>, readOnly?: boolean): IDocumentServiceFactory;

// @beta @legacy (undocumented)
export interface IBaseProtocolHandler {
Expand Down Expand Up @@ -106,6 +106,7 @@ export interface ILoadExistingContainerProps extends ICreateAndLoadContainerProp
// @alpha @legacy
export interface ILoadFrozenContainerFromPendingStateProps extends ILoadExistingContainerProps {
readonly pendingLocalState: string;
readonly readOnly?: boolean;
}

// @alpha @legacy
Expand Down
36 changes: 29 additions & 7 deletions packages/loader/container-loader/src/connectionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ import {
ReconnectMode,
} from "./contracts.js";
import { DeltaQueue } from "./deltaQueue.js";
import { FrozenDeltaStream, isFrozenDeltaStreamConnection } from "./frozenServices.js";
import {
FrozenDeltaStream,
isFrozenDeltaStreamConnection,
isWritableFrozenDeltaStreamConnection,
} from "./frozenServices.js";
import { SignalType } from "./protocol.js";
import { isDeltaStreamConnectionForbiddenError } from "./utils.js";

Expand Down Expand Up @@ -582,9 +586,9 @@ export class ConnectionManager implements IConnectionManager {
LogLevel.verbose,
);
if (isDeltaStreamConnectionForbiddenError(origError)) {
connection = new FrozenDeltaStream(origError.storageOnlyReason, {
text: origError.message,
error: origError,
connection = new FrozenDeltaStream({
storageOnlyReason: origError.storageOnlyReason,
readonlyConnectionReason: { text: origError.message, error: origError },
});
requestedMode = "read";
break;
Expand All @@ -594,9 +598,8 @@ export class ConnectionManager implements IConnectionManager {
) {
// If we get out of storage error from calling joinsession, then use the NoDeltaStream object so
// that user can at least load the container.
connection = new FrozenDeltaStream(undefined, {
text: origError.message,
error: origError,
connection = new FrozenDeltaStream({
readonlyConnectionReason: { text: origError.message, error: origError },
});
requestedMode = "read";
break;
Expand Down Expand Up @@ -1089,6 +1092,25 @@ export class ConnectionManager implements IConnectionManager {

public sendMessages(messages: IDocumentMessage[]): void {
assert(this.connected, 0x2b4 /* "not connected on sending ops!" */);
Comment thread
anthony-murphy marked this conversation as resolved.
// WritableFrozenDeltaStream short-circuit: writable-frozen containers
Comment thread
anthony-murphy marked this conversation as resolved.
// (`loadFrozenContainerFromPendingState({ readOnly: false })`) attach a
// WritableFrozenDeltaStream as the live connection. Its `mode` is "read" (advertising
// "write" would imply quorum membership we cannot honor), so a runtime submit
// would otherwise fall into the read-mode reconnect branch below. That branch
// schedules `reconnect("write")`, which under `ReconnectMode.Never`
// (`allowReconnect: false`) calls `closeHandler` and closes the container — the
// opposite of what writable-frozen wants. Drop the messages here: the runtime's
// outbox keeps them in `pendingStateManager` so `getPendingLocalState()` can
// capture them, which is the entire point of the writable-frozen flow.
//
// Match only the writable variant (a sibling class, not a subclass) so the read-only
// `FrozenDeltaStream` retains its `submit` 403-nack tripwire — a stray submit on a
// storage-only frozen connection signals an upstream invariant break and should
// remain observable. The read-only variant shouldn't reach here in normal flow anyway
// (its `storageOnly` policy keeps the runtime from submitting).
if (isWritableFrozenDeltaStreamConnection(this.connection)) {
return;
}
// If connection is "read" or implicit "read" (got leave op for "write" connection),
// then op can't make it through - we will get a nack if op is sent.
// We can short-circuit this process.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,34 @@ export interface ILoadFrozenContainerFromPendingStateProps
* Pending local state to be applied to the container.
*/
readonly pendingLocalState: string;

/**
* Controls whether the frozen container is surfaced as read-only.
*
* Defaults to `true`. When `true`, the container reports `readOnlyInfo.readonly === true`
* with `storageOnly === true`, matching the historical behavior of frozen loads.
*
* When `false`, the container loads as writable so the runtime will accept DDS submissions.
* The connection itself stays `Connected`: the connection manager recognizes the synthetic
* frozen delta stream and drops outbound messages at the network layer, so no read→write
* reconnect is attempted. Local DDS state continues to update via optimistic apply, and
* submitted ops accumulate in the runtime's pending-state manager. Use this when callers
* want to accrue and capture pending state without publishing it.
*
* @remarks
* The flag uses negative polarity (`readOnly`) rather than a positive opt-in (`writable`)
* to align with `IContainer.readOnlyInfo.readonly`, which is the established surface for
* read/write state on a loaded container. A future positive-polarity option can layer on
* top of this without breaking callers, but flipping the polarity now would split readers
* between two conventions for the same concept.
*
* Subsystem behavior is unchanged from the read-only frozen path regardless of `readOnly`:
* storage operations still throw (only `readBlob` is supported); summarizer / id-compressor
* never fire because no acks arrive; the quorum is whatever was captured in pending state
* and gains no members during the writable-frozen lifetime. The only behavioral delta is
* that the runtime accepts DDS submissions and accumulates them in `pendingStateManager`.
*/
readonly readOnly?: boolean;
}

/**
Expand All @@ -241,7 +269,10 @@ export async function loadFrozenContainerFromPendingState(
): Promise<IContainer> {
return loadExistingContainer({
...props,
documentServiceFactory: createFrozenDocumentServiceFactory(props.documentServiceFactory),
documentServiceFactory: createFrozenDocumentServiceFactory(
props.documentServiceFactory,
props.readOnly,
),
});
}

Expand Down
Loading
Loading