Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
11 changes: 11 additions & 0 deletions .changeset/frozen-container-readonly-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@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 first runtime submit triggers an internal read→write upgrade attempt that cannot succeed (no upstream, no quorum join op), so the container settles into `Disconnected`. DDS local apply continues, and submitted ops accumulate in the runtime's pending-state manager — this is the state needed to accrue and capture additional pending state without publishing it.

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 🐶 [Microsoft.Dashes] Remove the spaces around ' — '. Raw Output: {"message": "[Microsoft.Dashes] Remove the spaces around ' — '.", "location": {"path": ".changeset/frozen-container-readonly-option.md", "range": {"start": {"line": 9, "column": 371}}}, "severity": "ERROR"}

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": 340}}}, "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": 277}}}, "severity": "INFO"}

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()`.
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
20 changes: 14 additions & 6 deletions packages/loader/container-loader/src/connectionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,9 +582,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 +594,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 @@ -1095,6 +1094,15 @@ export class ConnectionManager implements IConnectionManager {
// Note that we also want nacks to be rare and be treated as catastrophic failures.
// Be careful with reentrancy though - disconnected event should not be be raised in the
// middle of the current workflow, but rather on clean stack!
//
// Tripwire: writable-frozen container loads (`loadFrozenContainerFromPendingState({
// readOnly: false })`) depend on this short-circuit. Their FrozenDeltaStream is mode
// "read", so a runtime submit lands here, the deferred `reconnect("write")` reaches
// FrozenDocumentService.connectToDeltaStream({ mode: "write" }) which hangs the
// promise, and the runtime's outbox skips actual send so ops accumulate in
// pendingStateManager. If this short-circuit is refactored to call submit directly
// instead of triggering a reconnect, writable-frozen loses its only mechanism for
// capturing additional pending state — see frozenServices.ts JSDoc.
if (this.connectionMode === "read") {
if (!this.pendingReconnect) {
this.pendingReconnect = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,28 @@ 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 first submission triggers an internal read→write upgrade attempt that cannot succeed
* (no upstream, no quorum join op), so the container settles into a `Disconnected` state.
* 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.
*/
readonly readOnly?: boolean;
}

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

Expand Down
Loading
Loading