@fluidframework/test-utils 2.0.0-dev.3.1.0.125672 → 2.0.0-dev.4.2.0.153917

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/dist/TestConfigs.d.ts.map +1 -1
  3. package/dist/TestConfigs.js +0 -2
  4. package/dist/TestConfigs.js.map +1 -1
  5. package/dist/TestSummaryUtils.d.ts +14 -3
  6. package/dist/TestSummaryUtils.d.ts.map +1 -1
  7. package/dist/TestSummaryUtils.js +24 -16
  8. package/dist/TestSummaryUtils.js.map +1 -1
  9. package/dist/containerUtils.d.ts +10 -19
  10. package/dist/containerUtils.d.ts.map +1 -1
  11. package/dist/containerUtils.js +12 -25
  12. package/dist/containerUtils.js.map +1 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -3
  16. package/dist/index.js.map +1 -1
  17. package/dist/loaderContainerTracker.d.ts +30 -15
  18. package/dist/loaderContainerTracker.d.ts.map +1 -1
  19. package/dist/loaderContainerTracker.js +138 -58
  20. package/dist/loaderContainerTracker.js.map +1 -1
  21. package/dist/packageVersion.d.ts +1 -1
  22. package/dist/packageVersion.js +1 -1
  23. package/dist/packageVersion.js.map +1 -1
  24. package/dist/testObjectProvider.d.ts +7 -1
  25. package/dist/testObjectProvider.d.ts.map +1 -1
  26. package/dist/testObjectProvider.js +44 -15
  27. package/dist/testObjectProvider.js.map +1 -1
  28. package/dist/timeoutUtils.d.ts +0 -2
  29. package/dist/timeoutUtils.d.ts.map +1 -1
  30. package/dist/timeoutUtils.js +1 -7
  31. package/dist/timeoutUtils.js.map +1 -1
  32. package/package.json +62 -63
  33. package/src/TestConfigs.ts +0 -2
  34. package/src/TestSummaryUtils.ts +23 -30
  35. package/src/containerUtils.ts +12 -25
  36. package/src/index.ts +2 -7
  37. package/src/loaderContainerTracker.ts +174 -72
  38. package/src/packageVersion.ts +1 -1
  39. package/src/testObjectProvider.ts +72 -16
  40. package/src/timeoutUtils.ts +0 -7
@@ -29,10 +29,11 @@ import { timeoutAwait } from "./timeoutUtils";
29
29
  const summarizerClientType = "summarizer";
30
30
 
31
31
  async function createSummarizerCore(
32
- absoluteUrl: string | undefined,
32
+ container: IContainer,
33
33
  loader: IHostLoader,
34
34
  summaryVersion?: string,
35
35
  ) {
36
+ const absoluteUrl = await container.getAbsoluteUrl("");
36
37
  if (absoluteUrl === undefined) {
37
38
  throw new Error("URL could not be resolved");
38
39
  }
@@ -52,10 +53,12 @@ async function createSummarizerCore(
52
53
  const summarizerContainer = await loader.resolve(request);
53
54
  await waitForContainerConnection(summarizerContainer);
54
55
 
55
- const fluidObject = await requestFluidObject<FluidObject<ISummarizer>>(summarizerContainer, {
56
- url: "_summarizer",
57
- });
58
- if (fluidObject.ISummarizer === undefined) {
56
+ const fluidObject: FluidObject<ISummarizer> | undefined = summarizerContainer.getEntryPoint
57
+ ? await summarizerContainer.getEntryPoint?.()
58
+ : await requestFluidObject<FluidObject<ISummarizer>>(summarizerContainer, {
59
+ url: "_summarizer",
60
+ });
61
+ if (fluidObject?.ISummarizer === undefined) {
59
62
  throw new Error("Fluid object does not implement ISummarizer");
60
63
  }
61
64
 
@@ -74,6 +77,11 @@ const defaultSummaryOptions: ISummaryRuntimeOptions = {
74
77
  },
75
78
  };
76
79
 
80
+ /**
81
+ * Creates a summarizer client from the given container and data store factory, and returns the summarizer client's
82
+ * IContainer and ISummarizer.
83
+ * The ISummarizer can be used to generate on-demand summaries. The IContainer can be used to fetch data stores, etc.
84
+ */
77
85
  export async function createSummarizerFromFactory(
78
86
  provider: ITestObjectProvider,
79
87
  container: IContainer,
@@ -81,7 +89,8 @@ export async function createSummarizerFromFactory(
81
89
  summaryVersion?: string,
82
90
  containerRuntimeFactoryType = ContainerRuntimeFactoryWithDefaultDataStore,
83
91
  registryEntries?: NamedFluidDataStoreRegistryEntries,
84
- ): Promise<ISummarizer> {
92
+ logger?: ITelemetryBaseLogger,
93
+ ): Promise<{ container: IContainer; summarizer: ISummarizer }> {
85
94
  const innerRequestHandler = async (request: IRequest, runtime: IContainerRuntimeBase) =>
86
95
  runtime.IFluidHandleContext.resolveHandle(request);
87
96
  const runtimeFactory = new containerRuntimeFactoryType(
@@ -94,11 +103,15 @@ export async function createSummarizerFromFactory(
94
103
 
95
104
  const loader = provider.createLoader([[provider.defaultCodeDetails, runtimeFactory]], {
96
105
  configProvider: mockConfigProvider(),
106
+ logger,
97
107
  });
98
- const absoluteUrl = await container.getAbsoluteUrl("");
99
- return (await createSummarizerCore(absoluteUrl, loader, summaryVersion)).summarizer;
108
+ return createSummarizerCore(container, loader, summaryVersion);
100
109
  }
101
110
 
111
+ /**
112
+ * Creates a summarizer client from the given container and returns the summarizer client's IContainer and ISummarizer.
113
+ * The ISummarizer can be used to generate on-demand summaries. The IContainer can be used to fetch data stores, etc.
114
+ */
102
115
  export async function createSummarizer(
103
116
  provider: ITestObjectProvider,
104
117
  container: IContainer,
@@ -106,27 +119,6 @@ export async function createSummarizer(
106
119
  gcOptions?: IGCRuntimeOptions,
107
120
  configProvider: IConfigProviderBase = mockConfigProvider(),
108
121
  logger?: ITelemetryBaseLogger,
109
- ): Promise<ISummarizer> {
110
- const absoluteUrl = await container.getAbsoluteUrl("");
111
- return (
112
- await createSummarizerWithContainer(
113
- provider,
114
- absoluteUrl,
115
- summaryVersion,
116
- gcOptions,
117
- configProvider,
118
- logger,
119
- )
120
- ).summarizer;
121
- }
122
-
123
- export async function createSummarizerWithContainer(
124
- provider: ITestObjectProvider,
125
- absoluteUrl: string | undefined,
126
- summaryVersion?: string,
127
- gcOptions?: IGCRuntimeOptions,
128
- configProvider: IConfigProviderBase = mockConfigProvider(),
129
- logger?: ITelemetryBaseLogger,
130
122
  ): Promise<{ container: IContainer; summarizer: ISummarizer }> {
131
123
  const testContainerConfig: ITestContainerConfig = {
132
124
  runtimeOptions: {
@@ -136,8 +128,9 @@ export async function createSummarizerWithContainer(
136
128
  loaderProps: { configProvider, logger },
137
129
  };
138
130
  const loader = provider.makeTestLoader(testContainerConfig);
139
- return createSummarizerCore(absoluteUrl, loader, summaryVersion);
131
+ return createSummarizerCore(container, loader, summaryVersion);
140
132
  }
133
+
141
134
  /**
142
135
  * Summarizes on demand and returns the summary tree, the version number and the reference sequence number of the
143
136
  * submitted summary.
@@ -4,23 +4,9 @@
4
4
  */
5
5
 
6
6
  import { IContainer } from "@fluidframework/container-definitions";
7
- import { ConnectionState, Container } from "@fluidframework/container-loader";
7
+ import { ConnectionState } from "@fluidframework/container-loader";
8
8
  import { PromiseExecutor, timeoutPromise, TimeoutWithError } from "./timeoutUtils";
9
9
 
10
- /**
11
- * Waits for the specified container to emit a 'connected' event.
12
- *
13
- * @deprecated Use waitForContainerConnection instead.
14
- * Note that an upcoming release will change the default parameters on that function to:
15
- * - failOnContainerClose = true
16
- * - timeoutOptions.durationMs = 1s
17
- */
18
- export async function ensureContainerConnected(container: Container): Promise<void> {
19
- if (!container.connected) {
20
- return timeoutPromise((resolve) => container.once("connected", () => resolve()));
21
- }
22
- }
23
-
24
10
  /**
25
11
  * Utility function to wait for the specified Container to be in Connected state.
26
12
  * If the Container is already connected, the Promise returns immediately; otherwise it resolves when the Container emits
@@ -30,20 +16,21 @@ export async function ensureContainerConnected(container: Container): Promise<vo
30
16
  * @param container - The container to wait for.
31
17
  * @param failOnContainerClose - If true, the returned Promise will be rejected if the container emits a 'closed' event
32
18
  * before a 'connected' event.
33
- * Defaults to false (but this will change in an upcoming version).
19
+ * Defaults to true.
34
20
  * @param timeoutOptions - Options related to the behavior of the timeout.
35
- * If not provided, no timeout will be applied and the promise will wait indefinitely for the Container to emit its
36
- * 'connected' (or 'closed, if failOnContainerClose === true) event.
37
- * @returns A Promise that resolves when the specified container emits a 'connected' event (or immediately if the
38
- * Container is already connected).
39
- * If failOnContainerClose === true and the container emits a 'closed' event before a 'connected' event, the Promise
40
- * is rejected with the error from the 'closed' event, if any.
41
- * If timeoutOptions is provided, the Promise will reject if the container hasn't emmited a relevant event before
42
- * timeoutOptions.durationMs (which defaults to 250ms if left undefined).
21
+ * If provided, the returned Promise will reject if the container hasn't emitted relevant events in timeoutOptions.durationMs.
22
+ * If not provided, the Promise will wait indefinitely for the Container to emit its 'connected' (or 'closed', if
23
+ * failOnContainerClose === true) event.
24
+ *
25
+ * @returns A Promise that either:
26
+ * - Resolves when the specified container emits a 'connected' event (or immediately if the Container is already connected).
27
+ * - Rejects if failOnContainerClose === true and the container emits a 'closed' event before a 'connected' event.
28
+ * - Rejects after timeoutOptions.durationMs if timeoutOptions !== undefined and the container does not emit relevant
29
+ * events, within that timeframe.
43
30
  */
44
31
  export async function waitForContainerConnection(
45
32
  container: IContainer,
46
- failOnContainerClose: boolean = false,
33
+ failOnContainerClose: boolean = true,
47
34
  timeoutOptions?: TimeoutWithError,
48
35
  ): Promise<void> {
49
36
  if (container.connectionState !== ConnectionState.Connected) {
package/src/index.ts CHANGED
@@ -29,12 +29,7 @@ export {
29
29
  ITestObjectProvider,
30
30
  TestObjectProvider,
31
31
  } from "./testObjectProvider";
32
- export {
33
- createSummarizer,
34
- createSummarizerFromFactory,
35
- createSummarizerWithContainer,
36
- summarizeNow,
37
- } from "./TestSummaryUtils";
32
+ export { createSummarizer, createSummarizerFromFactory, summarizeNow } from "./TestSummaryUtils";
38
33
  export {
39
34
  defaultTimeoutDurationMs,
40
35
  timeoutAwait,
@@ -42,4 +37,4 @@ export {
42
37
  TimeoutWithError,
43
38
  TimeoutWithValue,
44
39
  } from "./timeoutUtils";
45
- export { ensureContainerConnected, waitForContainerConnection } from "./containerUtils";
40
+ export { waitForContainerConnection } from "./containerUtils";
@@ -4,13 +4,14 @@
4
4
  */
5
5
  import { assert } from "@fluidframework/common-utils";
6
6
  import { IContainer, IDeltaQueue, IHostLoader } from "@fluidframework/container-definitions";
7
- import { Container } from "@fluidframework/container-loader";
7
+ import { ConnectionState } from "@fluidframework/container-loader";
8
8
  import { canBeCoalescedByService } from "@fluidframework/driver-utils";
9
9
  import {
10
10
  IDocumentMessage,
11
11
  ISequencedDocumentMessage,
12
12
  MessageType,
13
13
  } from "@fluidframework/protocol-definitions";
14
+ import { waitForContainerConnection } from "./containerUtils";
14
15
  import { debug } from "./debug";
15
16
  import { IOpProcessingController } from "./testObjectProvider";
16
17
  import { timeoutAwait, timeoutPromise } from "./timeoutUtils";
@@ -24,6 +25,7 @@ interface ContainerRecord {
24
25
 
25
26
  // LoaderContainerTracker paused state
26
27
  paused: boolean;
28
+ pauseP?: Promise<void>; // promise for for the pause that is in progress
27
29
 
28
30
  // Tracking trailing no-op that may or may be acked by the server so we can discount them
29
31
  // See issue #5629
@@ -123,7 +125,7 @@ export class LoaderContainerTracker implements IOpProcessingController {
123
125
  // Received the no op back, update the record if we are tracking
124
126
  if (
125
127
  canBeCoalescedByService(message) &&
126
- message.clientId === (container as Container).clientId &&
128
+ message.clientId === container.clientId &&
127
129
  record.trailingNoOps !== 0 &&
128
130
  record.startTrailingNoOps <= message.clientSequenceNumber
129
131
  ) {
@@ -162,57 +164,51 @@ export class LoaderContainerTracker implements IOpProcessingController {
162
164
  // REVIEW: do we need to unpatch the loaders?
163
165
  }
164
166
 
165
- /**
166
- * Ensure all tracked containers are synchronized
167
- */
168
- public async ensureSynchronized(...containers: IContainer[]): Promise<void> {
169
- await this.processSynchronized(undefined, ...containers);
170
- }
171
-
172
167
  /**
173
168
  * Ensure all tracked containers are synchronized with a time limit
169
+ *
170
+ * @deprecated - this method is equivalent to @see {@link LoaderContainerTracker.ensureSynchronized}, please configure the test timeout instead
174
171
  */
175
172
  public async ensureSynchronizedWithTimeout?(
176
173
  timeoutDuration: number | undefined,
177
174
  ...containers: IContainer[]
178
175
  ) {
179
- await this.processSynchronized(timeoutDuration, ...containers);
176
+ await this.ensureSynchronized(...containers);
180
177
  }
181
178
 
182
179
  /**
183
180
  * Make sure all the tracked containers are synchronized.
184
181
  *
185
182
  * No isDirty (non-readonly) containers
186
- *
187
183
  * No extra clientId in quorum of any container that is not tracked and still opened.
188
- *
189
184
  * - i.e. no pending Join/Leave message.
190
- *
191
185
  * No unresolved proposal (minSeqNum \>= lastProposalSeqNum)
192
- *
193
186
  * lastSequenceNumber of all container is the same
194
- *
195
187
  * clientSequenceNumberObserved is the same as clientSequenceNumber sent
196
- *
197
188
  * - this overlaps with !isDirty, but include task scheduler ops.
198
- *
199
189
  * - Trailing NoOp is tracked and don't count as pending ops.
190
+ *
191
+ * Containers that are already pause will resume process and paused again once
192
+ * everything is synchronized. Containers that aren't paused will remain unpaused when this
193
+ * function returns.
200
194
  */
201
- private async processSynchronized(
202
- timeoutDuration: number | undefined,
203
- ...containers: IContainer[]
204
- ) {
195
+ public async ensureSynchronized(...containers: IContainer[]): Promise<void> {
205
196
  const resumed = this.resumeProcessing(...containers);
206
197
 
207
- let waitingSequenceNumberSynchronized = false;
198
+ let waitingSequenceNumberSynchronized: string | undefined;
208
199
  // eslint-disable-next-line no-constant-condition
209
200
  while (true) {
201
+ // yield a turn to allow side effect of resuming or the ops we just processed execute before we check
202
+ await new Promise<void>((resolve) => {
203
+ setTimeout(resolve, 0);
204
+ });
205
+
210
206
  const containersToApply = this.getContainers(containers);
211
207
  if (containersToApply.length === 0) {
212
208
  break;
213
209
  }
214
210
 
215
- // Ignore readonly dirty containers, because it can't sent up and nothing can be done about it being dirty
211
+ // Ignore readonly dirty containers, because it can't sent ops and nothing can be done about it being dirty
216
212
  const dirtyContainers = containersToApply.filter((c) => {
217
213
  const { deltaManager, isDirty } = c;
218
214
  return deltaManager.readOnlyInfo.readonly !== true && isDirty;
@@ -221,20 +217,22 @@ export class LoaderContainerTracker implements IOpProcessingController {
221
217
  // Wait for all the leave messages
222
218
  const pendingClients = this.getPendingClients(containersToApply);
223
219
  if (pendingClients.length === 0) {
224
- if (this.isSequenceNumberSynchronized(containersToApply)) {
220
+ const needSync = this.needSequenceNumberSynchronize(containersToApply);
221
+ if (needSync === undefined) {
225
222
  // done, we are in sync
226
223
  break;
227
224
  }
228
- if (!waitingSequenceNumberSynchronized) {
229
- // Only write it out once
230
- waitingSequenceNumberSynchronized = true;
231
- debugWait("Waiting for sequence number synchronized");
232
- await timeoutAwait(this.waitForAnyInboundOps(containersToApply), {
233
- errorMsg: "Timeout on waiting for sequence number synchronized",
234
- });
225
+ if (waitingSequenceNumberSynchronized !== needSync.reason) {
226
+ // Don't repeat writing to console if it is the same reason
227
+ waitingSequenceNumberSynchronized = needSync.reason;
228
+ debugWait(needSync.message);
235
229
  }
230
+ // Wait for one inbounds ops which might change the state of things
231
+ await timeoutAwait(this.waitForAnyInboundOps(containersToApply), {
232
+ errorMsg: `Timeout on ${needSync.message}`,
233
+ });
236
234
  } else {
237
- waitingSequenceNumberSynchronized = false;
235
+ waitingSequenceNumberSynchronized = undefined;
238
236
  await timeoutAwait(this.waitForPendingClients(pendingClients), {
239
237
  errorMsg: "Timeout on waiting for pending join or leave op",
240
238
  });
@@ -242,12 +240,9 @@ export class LoaderContainerTracker implements IOpProcessingController {
242
240
  } else {
243
241
  // Wait for all the containers to be saved
244
242
  debugWait(
245
- `Waiting container to be saved ${dirtyContainers.map(
246
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
247
- (c) => this.containers.get(c)!.index,
248
- )}`,
243
+ `Waiting container to be saved ${this.containerIndexStrings(dirtyContainers)}`,
249
244
  );
250
- waitingSequenceNumberSynchronized = false;
245
+ waitingSequenceNumberSynchronized = undefined;
251
246
  await Promise.all(
252
247
  dirtyContainers.map(async (c) =>
253
248
  Promise.race([
@@ -259,11 +254,6 @@ export class LoaderContainerTracker implements IOpProcessingController {
259
254
  ),
260
255
  );
261
256
  }
262
-
263
- // yield a turn to allow side effect of the ops we just processed execute before we check again
264
- await new Promise<void>((resolve) => {
265
- setTimeout(resolve, 0);
266
- });
267
257
  }
268
258
 
269
259
  // Pause all container that was resumed
@@ -287,9 +277,7 @@ export class LoaderContainerTracker implements IOpProcessingController {
287
277
  // All the clientId we track should be a superset of the quorum, otherwise, we are missing
288
278
  // leave messages
289
279
  const openedDocuments = Array.from(this.containers.keys()).filter((c) => !c.closed);
290
- const openedClientId = openedDocuments.map(
291
- (container) => (container as Container).clientId,
292
- );
280
+ const openedClientId = openedDocuments.map((container) => container.clientId);
293
281
 
294
282
  const pendingClients: [IContainer, Set<string>][] = [];
295
283
  containersToApply.forEach((container) => {
@@ -321,48 +309,78 @@ export class LoaderContainerTracker implements IOpProcessingController {
321
309
  *
322
310
  * @param containersToApply - the set of containers to check
323
311
  */
324
- private isSequenceNumberSynchronized(containersToApply: IContainer[]) {
312
+ private needSequenceNumberSynchronize(containersToApply: IContainer[]) {
313
+ // If there is a pending proposal, wait for it to be accepted
314
+ const minSeqNum = containersToApply[0].deltaManager.minimumSequenceNumber;
315
+ if (minSeqNum < this.lastProposalSeqNum) {
316
+ return {
317
+ reason: "Proposal",
318
+ message: `waiting for MSN to advance to proposal at sequence number ${this.lastProposalSeqNum}`,
319
+ };
320
+ }
321
+
325
322
  // clientSequenceNumber check detects ops in flight, both on the wire and in the outbound queue
326
323
  // We need both client sequence number and isDirty check because:
327
324
  // - Currently isDirty flag ignores ops for task scheduler, so we need the client sequence number check
328
325
  // - But isDirty flags include ops during forceReadonly and disconnected, because we don't submit
329
326
  // the ops in the first place, clientSequenceNumber is not assigned
330
327
 
331
- const isClientSequenceNumberSynchronized = containersToApply.every((container) => {
328
+ const containerWithInflightOps = containersToApply.filter((container) => {
332
329
  if (container.deltaManager.readOnlyInfo.readonly === true) {
333
330
  // Ignore readonly container. the clientSeqNum and clientSeqNumObserved might be out of sync
334
331
  // because we transition to readonly when outbound is not empty or the in transit op got lost
335
- return true;
332
+ return false;
336
333
  }
337
334
  // Note that in read only mode, the op won't be submitted
338
335
  let deltaManager = container.deltaManager as any;
339
336
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
340
337
  const { trailingNoOps } = this.containers.get(container)!;
341
- // Back-compat: clientSequenceNumber & clientSequenceNumberObserved moved to ConnectionManager in 0.53
338
+ // Back-compat: lastSubmittedClientId/clientSequenceNumber/clientSequenceNumberObserved moved to ConnectionManager in 0.53
342
339
  if (!("clientSequenceNumber" in deltaManager)) {
343
340
  deltaManager = deltaManager.connectionManager;
344
341
  }
345
342
  assert("clientSequenceNumber" in deltaManager, "no clientSequenceNumber");
346
343
  assert("clientSequenceNumberObserved" in deltaManager, "no clientSequenceNumber");
344
+ // If last submittedClientId isn't the current clientId, then we haven't send any ops
347
345
  return (
348
- deltaManager.clientSequenceNumber ===
349
- (deltaManager.clientSequenceNumberObserved as number) + trailingNoOps
346
+ deltaManager.lastSubmittedClientId === container.clientId &&
347
+ deltaManager.clientSequenceNumber !==
348
+ (deltaManager.clientSequenceNumberObserved as number) + trailingNoOps
350
349
  );
351
350
  });
352
351
 
353
- if (!isClientSequenceNumberSynchronized) {
354
- return false;
352
+ if (containerWithInflightOps.length !== 0) {
353
+ return {
354
+ reason: "InflightOps",
355
+ message: `waiting for containers with inflight ops: ${this.containerIndexStrings(
356
+ containerWithInflightOps,
357
+ )}`,
358
+ };
355
359
  }
356
360
 
357
- const minSeqNum = containersToApply[0].deltaManager.minimumSequenceNumber;
358
- if (minSeqNum < this.lastProposalSeqNum) {
359
- // There is an unresolved proposal
360
- return false;
361
+ // Check to see if all the container has process the same number of ops.
362
+ const maxSeqNum = Math.max(
363
+ ...containersToApply.map((c) => c.deltaManager.lastSequenceNumber),
364
+ );
365
+ const containerWithPendingIncoming = containersToApply.filter(
366
+ (c) => c.deltaManager.lastSequenceNumber !== maxSeqNum,
367
+ );
368
+ if (containerWithPendingIncoming.length !== 0) {
369
+ return {
370
+ reason: "Pending",
371
+ message: `waiting for containers with pending incoming ops up to sequence number ${maxSeqNum}: ${this.containerIndexStrings(
372
+ containerWithPendingIncoming,
373
+ )}`,
374
+ };
361
375
  }
376
+ return undefined;
377
+ }
362
378
 
363
- // Check to see if all the container has process the same number of ops.
364
- const seqNum = containersToApply[0].deltaManager.lastSequenceNumber;
365
- return containersToApply.every((c) => c.deltaManager.lastSequenceNumber === seqNum);
379
+ private containerIndexStrings(containers: IContainer[]) {
380
+ return containers.map(
381
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
382
+ (c) => this.containers.get(c)!.index,
383
+ );
366
384
  }
367
385
 
368
386
  /**
@@ -375,7 +393,7 @@ export class LoaderContainerTracker implements IOpProcessingController {
375
393
  */
376
394
  private async waitForPendingClients(pendingClients: [IContainer, Set<string>][]) {
377
395
  const unconnectedClients = Array.from(this.containers.keys()).filter(
378
- (c) => !c.closed && !(c as Container).connected,
396
+ (c) => !c.closed && c.connectionState !== ConnectionState.Connected,
379
397
  );
380
398
  return Promise.all(
381
399
  pendingClients.map(async ([container, pendingClientId]) => {
@@ -435,6 +453,10 @@ export class LoaderContainerTracker implements IOpProcessingController {
435
453
  const containersToApply = this.getContainers(containers);
436
454
  for (const container of containersToApply) {
437
455
  const record = this.containers.get(container);
456
+ assert(
457
+ record?.pauseP === undefined,
458
+ "Cannot resume container while pausing is in progress",
459
+ );
438
460
  if (record?.paused === true) {
439
461
  debugWait(`${record.index}: container resumed`);
440
462
  container.deltaManager.inbound.resume();
@@ -449,26 +471,98 @@ export class LoaderContainerTracker implements IOpProcessingController {
449
471
  /**
450
472
  * Pause all queue activities on the containers given, or all tracked containers
451
473
  * Any containers given that is not tracked will be ignored.
474
+ *
475
+ * When a container is paused, it is assumed that we want fine grain control over op
476
+ * sequencing. This function will prepare the container and force it into write mode to
477
+ * avoid missing join messages or change the sequence of event when switching from read to
478
+ * write mode.
452
479
  */
453
480
  public async pauseProcessing(...containers: IContainer[]) {
454
- const pauseP: Promise<void>[] = [];
481
+ const waitP: Promise<void>[] = [];
455
482
  const containersToApply = this.getContainers(containers);
456
483
  for (const container of containersToApply) {
457
484
  const record = this.containers.get(container);
458
485
  if (record !== undefined && !record.paused) {
459
- debugWait(`${record.index}: container paused`);
460
- pauseP.push(container.deltaManager.inbound.pause());
461
- pauseP.push(container.deltaManager.outbound.pause());
462
- record.paused = true;
486
+ if (record.pauseP === undefined) {
487
+ record.pauseP = this.pauseContainer(container, record);
488
+ }
489
+ waitP.push(record.pauseP);
463
490
  }
464
491
  }
465
- await Promise.all(pauseP);
492
+ await Promise.all(waitP);
493
+ }
494
+
495
+ /**
496
+ * When a container is paused, it is assumed that we want fine grain control over op
497
+ * sequencing. This function will prepare the container and force it into write mode to
498
+ * avoid missing join messages or change the sequence of event when switching from read to
499
+ * write mode.
500
+ *
501
+ * @param container - the container to pause
502
+ * @param record - the record for the container
503
+ */
504
+ private async pauseContainer(container: IContainer, record: ContainerRecord) {
505
+ debugWait(`${record.index}: pausing container`);
506
+ assert(!container.deltaManager.outbound.paused, "Container should not be paused yet");
507
+ assert(!container.deltaManager.inbound.paused, "Container should not be paused yet");
508
+
509
+ // Pause outbound
510
+ debugWait(`${record.index}: pausing container outbound queues`);
511
+ await container.deltaManager.outbound.pause();
512
+
513
+ // Ensure the container is connected first.
514
+ if (container.connectionState !== ConnectionState.Connected) {
515
+ debugWait(`${record.index}: Wait for container connection`);
516
+ await waitForContainerConnection(container);
517
+ }
518
+
519
+ // Check if the container is in write mode
520
+ if (!container.deltaManager.active) {
521
+ let proposalP: Promise<boolean> | undefined;
522
+ if (container.deltaManager.outbound.idle) {
523
+ // Need to generate an op to force write mode
524
+ debugWait(`${record.index}: container force write connection`);
525
+ const maybeContainer = container as Partial<IContainer>;
526
+ const codeProposal = maybeContainer.getLoadedCodeDetails
527
+ ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
528
+ container.getLoadedCodeDetails()!
529
+ : (container as any).chaincodePackage;
530
+
531
+ proposalP = container.proposeCodeDetails(codeProposal);
532
+ }
533
+
534
+ // Wait for nack
535
+ debugWait(`${record.index}: Wait for container disconnect`);
536
+ container.deltaManager.outbound.resume();
537
+ await new Promise<void>((resolve) => container.once("disconnected", resolve));
538
+ const accepted = proposalP ? await proposalP : false;
539
+ assert(!accepted, "A proposal in read mode should be rejected");
540
+ await container.deltaManager.outbound.pause();
541
+
542
+ // Ensure the container is reconnect.
543
+ if (container.connectionState !== ConnectionState.Connected) {
544
+ debugWait(`${record.index}: Wait for container reconnection`);
545
+ await waitForContainerConnection(container);
546
+ }
547
+ }
548
+
549
+ debugWait(`${record.index}: pausing container inbound queues`);
550
+
551
+ // Pause inbound
552
+ await container.deltaManager.inbound.pause();
553
+
554
+ debugWait(`${record.index}: container paused`);
555
+
556
+ record.pauseP = undefined;
557
+ record.paused = true;
466
558
  }
467
559
 
468
560
  /**
469
561
  * Pause all queue activities on all tracked containers, and resume only
470
562
  * inbound to process ops until it is idle. All queues are left in the paused state
471
- * after the function
563
+ * after the function.
564
+ *
565
+ * Pausing will switch the container to write mode. See `pauseProcessing`
472
566
  */
473
567
  public async processIncoming(...containers: IContainer[]) {
474
568
  return this.processQueue(containers, (container) => container.deltaManager.inbound);
@@ -477,7 +571,9 @@ export class LoaderContainerTracker implements IOpProcessingController {
477
571
  /**
478
572
  * Pause all queue activities on all tracked containers, and resume only
479
573
  * outbound to process ops until it is idle. All queues are left in the paused state
480
- * after the function
574
+ * after the function.
575
+ *
576
+ * Pausing will switch the container to write mode. See `pauseProcessing`
481
577
  */
482
578
  public async processOutgoing(...containers: IContainer[]) {
483
579
  return this.processQueue(containers, (container) => container.deltaManager.outbound);
@@ -494,9 +590,15 @@ export class LoaderContainerTracker implements IOpProcessingController {
494
590
  const resumed: IDeltaQueue<U>[] = [];
495
591
 
496
592
  const containersToApply = this.getContainers(containers);
593
+
497
594
  const inflightTracker = new Map<IContainer, number>();
498
595
  const cleanup: (() => void)[] = [];
499
596
  for (const container of containersToApply) {
597
+ assert(
598
+ container.deltaManager.active,
599
+ "Container should be connected in write mode already",
600
+ );
601
+
500
602
  const queue = getQueue(container);
501
603
 
502
604
  // track the outgoing ops (if any) to make sure they make the round trip to at least to the same client
@@ -546,7 +648,7 @@ export class LoaderContainerTracker implements IOpProcessingController {
546
648
  const inHandler = (message: ISequencedDocumentMessage) => {
547
649
  if (
548
650
  !canBeCoalescedByService(message) &&
549
- message.clientId === (container as Container).clientId &&
651
+ message.clientId === container.clientId &&
550
652
  inflightTracker.get(container) === message.clientSequenceNumber
551
653
  ) {
552
654
  inflightTracker.delete(container);
@@ -600,7 +702,7 @@ export class LoaderContainerTracker implements IOpProcessingController {
600
702
  return `${e.message}: ${e.stack}`;
601
703
  }
602
704
  };
603
- debugOp(`${index}: ADD: clientId: ${(container as Container).clientId}`);
705
+ debugOp(`${index}: ADD: clientId: ${container.clientId}`);
604
706
  container.deltaManager.outbound.on("op", (messages) => {
605
707
  for (const msg of messages) {
606
708
  debugOp(
@@ -614,7 +716,7 @@ export class LoaderContainerTracker implements IOpProcessingController {
614
716
  const getInboundHandler = (type: string) => {
615
717
  return (msg: ISequencedDocumentMessage) => {
616
718
  const clientSeq =
617
- msg.clientId === (container as Container).clientId
719
+ msg.clientId === container.clientId
618
720
  ? `cli: ${msg.clientSequenceNumber.toString().padStart(3)}`
619
721
  : " ";
620
722
  debugOp(
@@ -6,4 +6,4 @@
6
6
  */
7
7
 
8
8
  export const pkgName = "@fluidframework/test-utils";
9
- export const pkgVersion = "2.0.0-dev.3.1.0.125672";
9
+ export const pkgVersion = "2.0.0-dev.4.2.0.153917";