Skip to content
Open
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
33 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
87fe785
chore: remove changeset
anthony-murphy May 8, 2026
dfcb644
fix(container-loader): throw on read-only FrozenDocumentService.conne…
anthony-murphy May 8, 2026
78c9ef4
docs(container-loader): wrap long JSDoc bullets to a sane width
anthony-murphy May 8, 2026
731aaea
docs: rename NoDeltaStream → FrozenDeltaStream in comments and test n…
anthony-murphy May 8, 2026
e8dd9b9
docs(container-loader): lead WritableFrozenDeltaStream JSDoc with "ap…
anthony-murphy May 8, 2026
7422e30
refactor(container-loader): rename readOnly → allowLocalChanges (posi…
anthony-murphy May 8, 2026
7312def
test(container-loader): drop vacuous disconnect/reconnect integration…
anthony-murphy May 8, 2026
858990e
Revert "refactor(container-loader): rename readOnly → allowLocalChang…
anthony-murphy May 8, 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
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
38 changes: 30 additions & 8 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,21 +586,20 @@ 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;
} else if (
isFluidError(origError) &&
origError.errorType === DriverErrorTypes.outOfStorageError
) {
// If we get out of storage error from calling joinsession, then use the NoDeltaStream object so
// If we get out of storage error from calling joinsession, then use the FrozenDeltaStream 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`
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if there are other cases in the runtime that trigger reconnect that would cause trouble?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Walked through the runtime-reconnect triggers — they all funnel into ConnectionManager.connectCoredocService.connectToDeltaStream → returns a fresh WritableFrozenDeltaStream. The failure modes the writable-frozen path needs to defend against:

  1. Runtime submit → read-mode reconnect branch in sendMessages — blocked by the isWritableFrozenDeltaStreamConnection short-circuit (this hunk).
  2. Container forceReadonly(true) mid-life — disconnect/reconnect-as-read; the new connection is another WritableFrozenDeltaStream; _forceReadonly = true overrides readOnlyInfo.readonly. Pinned by the forceReadonly(true) surfaces ... as readonly integration test.
  3. Runtime-driven disconnect() + connect() cycle (e.g. via IContainer.disconnect() / connect() in the host) — same path: fresh WritableFrozenDeltaStream, runtime replays pending state. Pinned by the survives a disconnect/reconnect cycle test (per-instance UUID clientId defends pendingStateManager 0x173).
  4. Connection error / disconnect events from the live stream — the WritableFrozenDeltaStream is inert (no upstream emits these), so no event-driven reconnect fires in normal flow.
  5. NACK on outbound submitsendMessages drops at the short-circuit before reaching the connection's submit, so no nack-driven reconnectOnError("write").

So the only path that would still trigger a runtime-driven write reconnect is a stray submit hitting the connection's submit directly (bypassing sendMessages) — which would emit a 403 nack and could drive reconnectOnError("write"). That path is unreachable in normal flow because sendMessages is the sole runtime → connection submit path. The 403-nack tripwire on the read-only variant's submit deliberately stays as the loud-failure signal if any future change opens that path.

Are you thinking of a specific scenario I haven't covered? Happy to add a test if there's a path I'm missing.

// (`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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The term "readonly" typically makes me think of a state where you can't write but you can receive remote changes.

I'd call the parameter "allowLocalChanges" (defaulting to false).

Copy link
Copy Markdown
Member

@markfields markfields May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or if you want negative polarity (I just read the remarks), then disallowLocalChanges...?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 7422e300f5. Renamed to allowLocalChanges with default false (positive opt-in — caller passes allowLocalChanges: true to opt into the writable-frozen behavior; preserves the previous default behavior of "no local writes" when no arg is passed). Updated the props interface, factory function, internal class fields, JSDoc, comments, tests, and the API report.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with positive allowLocalChanges (default false) over negative disallowLocalChanges (default true) — the double-negative in disallowLocalChanges: false is harder to parse than allowLocalChanges: true. The earlier "negative polarity" rationale (alignment with IContainer.readOnlyInfo.readonly) was specific to the readOnly name; once the name itself was wrong, that justification dropped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On reflection, going back to readOnly?: boolean (default true) in 858990ee5b. Rationale: readOnly is the established state name across the container, runtime, and datastore layers — IContainer.readOnlyInfo.readonly, the readonly signals on the runtime and datastore, and version-history / storage-only mode all use the same word for the same concept. It determines whether ops are allowed; matching that convention here keeps the surface aligned rather than introducing a parallel name for the same idea.

The allowLocalChanges framing is more literal about what the flag toggles in this specific path, but the cost is splitting readers between two conventions for "this thing controls whether ops can be submitted." For consistency with the rest of the stack I'd rather keep readOnly.

}

/**
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