@fluidframework/container-loader 2.0.0-dev.1.4.6.106135 → 2.0.0-dev.2.3.0.115467

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 (96) hide show
  1. package/.eslintrc.js +21 -8
  2. package/README.md +21 -11
  3. package/dist/audience.d.ts +0 -4
  4. package/dist/audience.d.ts.map +1 -1
  5. package/dist/audience.js +11 -11
  6. package/dist/audience.js.map +1 -1
  7. package/dist/collabWindowTracker.js +5 -4
  8. package/dist/collabWindowTracker.js.map +1 -1
  9. package/dist/connectionManager.d.ts.map +1 -1
  10. package/dist/connectionManager.js +49 -7
  11. package/dist/connectionManager.js.map +1 -1
  12. package/dist/connectionStateHandler.d.ts +20 -11
  13. package/dist/connectionStateHandler.d.ts.map +1 -1
  14. package/dist/connectionStateHandler.js +65 -36
  15. package/dist/connectionStateHandler.js.map +1 -1
  16. package/dist/container.d.ts +8 -2
  17. package/dist/container.d.ts.map +1 -1
  18. package/dist/container.js +32 -36
  19. package/dist/container.js.map +1 -1
  20. package/dist/containerContext.d.ts +1 -1
  21. package/dist/containerContext.d.ts.map +1 -1
  22. package/dist/containerContext.js +7 -3
  23. package/dist/containerContext.js.map +1 -1
  24. package/dist/containerStorageAdapter.js +1 -1
  25. package/dist/containerStorageAdapter.js.map +1 -1
  26. package/dist/contracts.d.ts +8 -0
  27. package/dist/contracts.d.ts.map +1 -1
  28. package/dist/contracts.js.map +1 -1
  29. package/dist/deltaManager.d.ts +1 -3
  30. package/dist/deltaManager.d.ts.map +1 -1
  31. package/dist/deltaManager.js +40 -16
  32. package/dist/deltaManager.js.map +1 -1
  33. package/dist/packageVersion.d.ts +1 -1
  34. package/dist/packageVersion.js +1 -1
  35. package/dist/packageVersion.js.map +1 -1
  36. package/dist/protocol.d.ts +8 -3
  37. package/dist/protocol.d.ts.map +1 -1
  38. package/dist/protocol.js +34 -8
  39. package/dist/protocol.js.map +1 -1
  40. package/dist/retriableDocumentStorageService.d.ts.map +1 -1
  41. package/dist/retriableDocumentStorageService.js +7 -3
  42. package/dist/retriableDocumentStorageService.js.map +1 -1
  43. package/lib/audience.d.ts +0 -4
  44. package/lib/audience.d.ts.map +1 -1
  45. package/lib/audience.js +11 -11
  46. package/lib/audience.js.map +1 -1
  47. package/lib/collabWindowTracker.js +5 -4
  48. package/lib/collabWindowTracker.js.map +1 -1
  49. package/lib/connectionManager.d.ts.map +1 -1
  50. package/lib/connectionManager.js +49 -7
  51. package/lib/connectionManager.js.map +1 -1
  52. package/lib/connectionStateHandler.d.ts +20 -11
  53. package/lib/connectionStateHandler.d.ts.map +1 -1
  54. package/lib/connectionStateHandler.js +65 -36
  55. package/lib/connectionStateHandler.js.map +1 -1
  56. package/lib/container.d.ts +8 -2
  57. package/lib/container.d.ts.map +1 -1
  58. package/lib/container.js +31 -36
  59. package/lib/container.js.map +1 -1
  60. package/lib/containerContext.d.ts +1 -1
  61. package/lib/containerContext.d.ts.map +1 -1
  62. package/lib/containerContext.js +7 -3
  63. package/lib/containerContext.js.map +1 -1
  64. package/lib/containerStorageAdapter.js +1 -1
  65. package/lib/containerStorageAdapter.js.map +1 -1
  66. package/lib/contracts.d.ts +8 -0
  67. package/lib/contracts.d.ts.map +1 -1
  68. package/lib/contracts.js.map +1 -1
  69. package/lib/deltaManager.d.ts +1 -3
  70. package/lib/deltaManager.d.ts.map +1 -1
  71. package/lib/deltaManager.js +43 -19
  72. package/lib/deltaManager.js.map +1 -1
  73. package/lib/packageVersion.d.ts +1 -1
  74. package/lib/packageVersion.js +1 -1
  75. package/lib/packageVersion.js.map +1 -1
  76. package/lib/protocol.d.ts +8 -3
  77. package/lib/protocol.d.ts.map +1 -1
  78. package/lib/protocol.js +33 -7
  79. package/lib/protocol.js.map +1 -1
  80. package/lib/retriableDocumentStorageService.d.ts.map +1 -1
  81. package/lib/retriableDocumentStorageService.js +7 -3
  82. package/lib/retriableDocumentStorageService.js.map +1 -1
  83. package/package.json +27 -29
  84. package/prettier.config.cjs +8 -0
  85. package/src/audience.ts +11 -12
  86. package/src/collabWindowTracker.ts +5 -5
  87. package/src/connectionManager.ts +56 -11
  88. package/src/connectionStateHandler.ts +87 -39
  89. package/src/container.ts +36 -38
  90. package/src/containerContext.ts +10 -4
  91. package/src/containerStorageAdapter.ts +1 -1
  92. package/src/contracts.ts +8 -0
  93. package/src/deltaManager.ts +61 -39
  94. package/src/packageVersion.ts +1 -1
  95. package/src/protocol.ts +31 -8
  96. package/src/retriableDocumentStorageService.ts +7 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fluidframework/container-loader",
3
- "version": "2.0.0-dev.1.4.6.106135",
3
+ "version": "2.0.0-dev.2.3.0.115467",
4
4
  "description": "Fluid container loader",
5
5
  "homepage": "https://fluidframework.com",
6
6
  "repository": {
@@ -28,17 +28,19 @@
28
28
  "clean": "rimraf dist lib *.tsbuildinfo *.build.log",
29
29
  "eslint": "eslint --format stylish src",
30
30
  "eslint:fix": "eslint --format stylish src --fix --fix-type problem,suggestion,layout",
31
+ "format": "npm run prettier:fix",
31
32
  "lint": "npm run eslint",
32
33
  "lint:fix": "npm run eslint:fix",
34
+ "prettier": "prettier --check . --ignore-path ../../../.prettierignore",
35
+ "prettier:fix": "prettier --write . --ignore-path ../../../.prettierignore",
33
36
  "test": "npm run test:mocha",
34
37
  "test:coverage": "nyc npm test -- --reporter xunit --reporter-option output=nyc/junit-report.xml",
35
38
  "test:mocha": "mocha --ignore 'dist/test/types/*' --recursive dist/test -r node_modules/@fluidframework/mocha-test-setup --unhandled-rejections=strict",
36
39
  "test:mocha:verbose": "cross-env FLUID_TEST_VERBOSE=1 npm run test:mocha",
37
40
  "tsc": "tsc",
38
41
  "tsc:watch": "tsc --watch",
39
- "tsfmt": "tsfmt --verify",
40
- "tsfmt:fix": "tsfmt --replace",
41
- "typetests:gen": "fluid-type-validator -g -d ."
42
+ "typetests:gen": "flub generate typetests --generate --dir .",
43
+ "typetests:prepare": "flub generate typetests --prepare --dir . --pin"
42
44
  },
43
45
  "nyc": {
44
46
  "all": true,
@@ -63,14 +65,14 @@
63
65
  "dependencies": {
64
66
  "@fluidframework/common-definitions": "^0.20.1",
65
67
  "@fluidframework/common-utils": "^1.0.0",
66
- "@fluidframework/container-definitions": "2.0.0-dev.1.4.6.106135",
67
- "@fluidframework/container-utils": "2.0.0-dev.1.4.6.106135",
68
- "@fluidframework/core-interfaces": "2.0.0-dev.1.4.6.106135",
69
- "@fluidframework/driver-definitions": "2.0.0-dev.1.4.6.106135",
70
- "@fluidframework/driver-utils": "2.0.0-dev.1.4.6.106135",
71
- "@fluidframework/protocol-base": "^0.1037.2001",
72
- "@fluidframework/protocol-definitions": "^1.0.0",
73
- "@fluidframework/telemetry-utils": "2.0.0-dev.1.4.6.106135",
68
+ "@fluidframework/container-definitions": ">=2.0.0-dev.2.3.0.115467 <2.0.0-dev.3.0.0",
69
+ "@fluidframework/container-utils": ">=2.0.0-dev.2.3.0.115467 <2.0.0-dev.3.0.0",
70
+ "@fluidframework/core-interfaces": ">=2.0.0-dev.2.3.0.115467 <2.0.0-dev.3.0.0",
71
+ "@fluidframework/driver-definitions": ">=2.0.0-dev.2.3.0.115467 <2.0.0-dev.3.0.0",
72
+ "@fluidframework/driver-utils": ">=2.0.0-dev.2.3.0.115467 <2.0.0-dev.3.0.0",
73
+ "@fluidframework/protocol-base": "^0.1038.2000",
74
+ "@fluidframework/protocol-definitions": "^1.1.0",
75
+ "@fluidframework/telemetry-utils": ">=2.0.0-dev.2.3.0.115467 <2.0.0-dev.3.0.0",
74
76
  "abort-controller": "^3.0.0",
75
77
  "double-ended-queue": "^2.1.0-0",
76
78
  "lodash": "^4.17.21",
@@ -78,12 +80,13 @@
78
80
  "uuid": "^8.3.1"
79
81
  },
80
82
  "devDependencies": {
81
- "@fluidframework/build-common": "^1.0.0",
82
- "@fluidframework/build-tools": "^0.4.4000",
83
- "@fluidframework/container-loader-previous": "npm:@fluidframework/container-loader@^1.0.0",
84
- "@fluidframework/eslint-config-fluid": "^1.0.0",
85
- "@fluidframework/mocha-test-setup": "2.0.0-dev.1.4.6.106135",
86
- "@fluidframework/test-loader-utils": "2.0.0-dev.1.4.6.106135",
83
+ "@fluid-tools/build-cli": "^0.7.0",
84
+ "@fluidframework/build-common": "^1.1.0",
85
+ "@fluidframework/build-tools": "^0.7.0",
86
+ "@fluidframework/container-loader-previous": "npm:@fluidframework/container-loader@2.0.0-internal.2.2.0",
87
+ "@fluidframework/eslint-config-fluid": "^1.2.0",
88
+ "@fluidframework/mocha-test-setup": ">=2.0.0-dev.2.3.0.115467 <2.0.0-dev.3.0.0",
89
+ "@fluidframework/test-loader-utils": ">=2.0.0-dev.2.3.0.115467 <2.0.0-dev.3.0.0",
87
90
  "@microsoft/api-extractor": "^7.22.2",
88
91
  "@rushstack/eslint-config": "^2.5.1",
89
92
  "@types/double-ended-queue": "^2.1.0",
@@ -97,20 +100,15 @@
97
100
  "eslint": "~8.6.0",
98
101
  "mocha": "^10.0.0",
99
102
  "nyc": "^15.0.0",
103
+ "prettier": "~2.6.2",
100
104
  "rimraf": "^2.6.2",
101
105
  "sinon": "^7.4.2",
102
- "typescript": "~4.5.5",
103
- "typescript-formatter": "7.1.0"
106
+ "typescript": "~4.5.5"
104
107
  },
105
108
  "typeValidation": {
106
- "version": "2.0.0",
107
- "broken": {
108
- "EnumDeclaration_ConnectionState": {
109
- "forwardCompat": false
110
- },
111
- "ClassDeclaration_Container": {
112
- "forwardCompat": false
113
- }
114
- }
109
+ "version": "2.0.0-internal.2.3.0",
110
+ "baselineRange": ">=2.0.0-internal.2.2.0 <2.0.0-internal.2.3.0",
111
+ "baselineVersion": "2.0.0-internal.2.2.0",
112
+ "broken": {}
115
113
  }
116
114
  }
@@ -0,0 +1,8 @@
1
+ /*!
2
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3
+ * Licensed under the MIT License.
4
+ */
5
+
6
+ module.exports = {
7
+ ...require("@fluidframework/build-common/prettier.config.cjs"),
8
+ };
package/src/audience.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * Licensed under the MIT License.
4
4
  */
5
5
  import { EventEmitter } from "events";
6
+ import { assert } from "@fluidframework/common-utils";
6
7
  import { IAudienceOwner } from "@fluidframework/container-definitions";
7
8
  import { IClient } from "@fluidframework/protocol-definitions";
8
9
 
@@ -21,8 +22,16 @@ export class Audience extends EventEmitter implements IAudienceOwner {
21
22
  * Adds a new client to the audience
22
23
  */
23
24
  public addMember(clientId: string, details: IClient) {
24
- this.members.set(clientId, details);
25
- this.emit("addMember", clientId, details);
25
+ // Given that signal delivery is unreliable process, we might observe same client being added twice
26
+ // In such case we should see exactly same payload (IClient), and should not raise event twice!
27
+ if (this.members.has(clientId)) {
28
+ const client = this.members.get(clientId);
29
+ assert(JSON.stringify(client) === JSON.stringify(details), 0x4b2 /* new client has different payload from existing one */);
30
+ }
31
+ else {
32
+ this.members.set(clientId, details);
33
+ this.emit("addMember", clientId, details);
34
+ }
26
35
  }
27
36
 
28
37
  /**
@@ -53,14 +62,4 @@ export class Audience extends EventEmitter implements IAudienceOwner {
53
62
  public getMember(clientId: string): IClient | undefined {
54
63
  return this.members.get(clientId);
55
64
  }
56
-
57
- /**
58
- * Clears the audience
59
- */
60
- public clear(): void {
61
- const clientIds = this.members.keys();
62
- for (const clientId of clientIds) {
63
- this.removeMember(clientId);
64
- }
65
- }
66
65
  }
@@ -73,11 +73,11 @@ export class CollabWindowTracker {
73
73
  // Ensure we only send noop after a batch of many ops is processed
74
74
  // eslint-disable-next-line @typescript-eslint/no-floating-promises
75
75
  Promise.resolve().then(() => {
76
- assert(this.opsCountSinceNoop >= this.NoopCountFrequency,
77
- 0x3ae /* not enough ops were sent to reach the noop frequency */);
78
- this.submitNoop(false /* immediate */);
79
- // reset count now that all ops are processed
80
- this.opsCountSinceNoop = 0;
76
+ if (this.opsCountSinceNoop >= this.NoopCountFrequency) {
77
+ this.submitNoop(false /* immediate */);
78
+ // reset count now that all ops are processed
79
+ this.opsCountSinceNoop = 0;
80
+ }
81
81
  return;
82
82
  });
83
83
  }
@@ -18,6 +18,7 @@ import {
18
18
  } from "@fluidframework/container-definitions";
19
19
  import { GenericError, UsageError } from "@fluidframework/container-utils";
20
20
  import {
21
+ IAnyDriverError,
21
22
  IDocumentService,
22
23
  IDocumentDeltaConnection,
23
24
  IDocumentDeltaConnectionEvents,
@@ -27,7 +28,6 @@ import {
27
28
  createWriteError,
28
29
  createGenericNetworkError,
29
30
  getRetryDelayFromError,
30
- IAnyDriverError,
31
31
  waitForConnectedState,
32
32
  DeltaStreamConnectionForbiddenError,
33
33
  logNetworkFailure,
@@ -59,6 +59,7 @@ import {
59
59
  IConnectionManagerFactoryArgs,
60
60
  } from "./contracts";
61
61
  import { DeltaQueue } from "./deltaQueue";
62
+ import { SignalType } from "./protocol";
62
63
 
63
64
  const MaxReconnectDelayInMs = 8000;
64
65
  const InitialReconnectDelayInMs = 1000;
@@ -80,10 +81,19 @@ function getNackReconnectInfo(nackContent: INackContent) {
80
81
  * Implementation of IDocumentDeltaConnection that does not support submitting
81
82
  * or receiving ops. Used in storage-only mode.
82
83
  */
84
+ const clientNoDeltaStream: IClient = {
85
+ mode: "read",
86
+ details: { capabilities: { interactive: true } },
87
+ permission: [],
88
+ user: { id: "storage-only client" }, // we need some "fake" ID here.
89
+ scopes: [],
90
+ };
91
+ const clientIdNoDeltaStream: string = "storage-only client";
92
+
83
93
  class NoDeltaStream
84
94
  extends TypedEventEmitter<IDocumentDeltaConnectionEvents>
85
95
  implements IDocumentDeltaConnection, IDisposable {
86
- clientId: string = "storage-only client";
96
+ clientId = clientIdNoDeltaStream;
87
97
  claims: ITokenClaims = {
88
98
  scopes: [ScopeType.DocRead],
89
99
  } as any;
@@ -93,7 +103,7 @@ class NoDeltaStream
93
103
  version: string = "";
94
104
  initialMessages: ISequencedDocumentMessage[] = [];
95
105
  initialSignals: ISignalMessage[] = [];
96
- initialClients: ISignalClient[] = [];
106
+ initialClients: ISignalClient[] = [{ client: clientNoDeltaStream, clientId: clientIdNoDeltaStream }];
97
107
  serviceConfiguration: IClientConfiguration = {
98
108
  maxMessageSize: 0,
99
109
  blockSize: 0,
@@ -257,7 +267,7 @@ export class ConnectionManager implements IConnectionManager {
257
267
  * It is undefined if we have not yet established websocket connection
258
268
  * and do not know if user has write access to a file.
259
269
  */
260
- private get readonly() {
270
+ private get readonly(): boolean | undefined {
261
271
  if (this._forceReadonly) {
262
272
  return true;
263
273
  }
@@ -570,7 +580,13 @@ export class ConnectionManager implements IConnectionManager {
570
580
  * @param args - The connection arguments
571
581
  */
572
582
  private triggerConnect(connectionMode: ConnectionMode) {
573
- assert(this.connection === undefined, 0x239 /* "called only in disconnected state" */);
583
+ // reconnect() has async await of waitForConnectedState(), and that causes potential race conditions
584
+ // where we might already have a connection. If it were to happen, it's possible that we will connect
585
+ // with different mode to `connectionMode`. Glancing through the caller chains, it looks like code should be
586
+ // fine (if needed, reconnect flow will get triggered again). Places where new mode matters should encode it
587
+ // directly in connectCore - see this.shouldJoinWrite() test as an example.
588
+ // assert(this.connection === undefined, 0x239 /* "called only in disconnected state" */);
589
+
574
590
  if (this.reconnectMode !== ReconnectMode.Enabled) {
575
591
  return;
576
592
  }
@@ -649,6 +665,9 @@ export class ConnectionManager implements IConnectionManager {
649
665
  // But if we ask read, server can still give us write.
650
666
  const readonly = !connection.claims.scopes.includes(ScopeType.DocWrite);
651
667
 
668
+ if (connection.mode !== requestedMode) {
669
+ this.logger.sendTelemetryEvent({ eventName: "ConnectionModeMismatch", requestedMode, mode: connection.mode });
670
+ }
652
671
  // This connection mode validation logic is moving to the driver layer in 0.44. These two asserts can be
653
672
  // removed after those packages have released and become ubiquitous.
654
673
  assert(requestedMode === "read" || readonly === (this.connectionMode === "read"),
@@ -713,17 +732,43 @@ export class ConnectionManager implements IConnectionManager {
713
732
  initialMessages,
714
733
  this.connectFirstConnection ? "InitialOps" : "ReconnectOps");
715
734
 
716
- if (connection.initialSignals !== undefined) {
717
- for (const signal of connection.initialSignals) {
718
- this.props.signalHandler(signal);
719
- }
720
- }
721
-
722
735
  const details = ConnectionManager.detailsFromConnection(connection);
723
736
  details.checkpointSequenceNumber = checkpointSequenceNumber;
724
737
  this.props.connectHandler(details);
725
738
 
726
739
  this.connectFirstConnection = false;
740
+
741
+ // Synthesize clear & join signals out of initialClients state.
742
+ // This allows us to have single way to process signals, and makes it simpler to initialize
743
+ // protocol in Container.
744
+ const clearSignal: ISignalMessage = {
745
+ clientId: null, // system message
746
+ content: JSON.stringify({
747
+ type: SignalType.Clear,
748
+ }),
749
+ };
750
+ this.props.signalHandler(clearSignal);
751
+
752
+ for (const priorClient of connection.initialClients ?? []) {
753
+ const joinSignal: ISignalMessage = {
754
+ clientId: null, // system signal
755
+ content: JSON.stringify({
756
+ type: SignalType.ClientJoin,
757
+ content: priorClient, // ISignalClient
758
+ }),
759
+ };
760
+ this.props.signalHandler(joinSignal);
761
+ }
762
+
763
+ // Unfortunately, there is no defined order between initialSignals (including join & leave signals)
764
+ // and connection.initialClients. In practice, connection.initialSignals quite often contains join signal
765
+ // for "self" and connection.initialClients does not contain "self", so we have to process them after
766
+ // "clear" signal above.
767
+ if (connection.initialSignals !== undefined) {
768
+ for (const signal of connection.initialSignals) {
769
+ this.props.signalHandler(signal);
770
+ }
771
+ }
727
772
  }
728
773
 
729
774
  /**
@@ -7,14 +7,20 @@ import { ITelemetryLogger, ITelemetryProperties } from "@fluidframework/common-d
7
7
  import { assert, Timer } from "@fluidframework/common-utils";
8
8
  import { IConnectionDetails, IDeltaManager } from "@fluidframework/container-definitions";
9
9
  import { ILocalSequencedClient } from "@fluidframework/protocol-base";
10
- import { ConnectionMode } from "@fluidframework/protocol-definitions";
10
+ import { ISequencedClient, IClient } from "@fluidframework/protocol-definitions";
11
11
  import { PerformanceEvent, loggerToMonitoringContext } from "@fluidframework/telemetry-utils";
12
12
  import { ConnectionState } from "./connectionState";
13
13
  import { CatchUpMonitor, ICatchUpMonitor } from "./catchUpMonitor";
14
14
  import { IProtocolHandler } from "./protocol";
15
15
 
16
+ // Based on recent data, it looks like majority of cases where we get stuck are due to really slow or
17
+ // timing out ops fetches. So attempt recovery infrequently. Also fetch uses 30 second timeout, so
18
+ // if retrying fixes the problem, we should not see these events.
16
19
  const JoinOpTimeoutMs = 45000;
17
20
 
21
+ // Timeout waiting for "self" join signal, before giving up
22
+ const JoinSignalTimeoutMs = 5000;
23
+
18
24
  /** Constructor parameter type for passing in dependencies needed by the ConnectionStateHandler */
19
25
  export interface IConnectionStateHandlerInputs {
20
26
  logger: ITelemetryLogger;
@@ -39,7 +45,7 @@ export interface IConnectionStateHandler {
39
45
  containerSaved(): void;
40
46
  dispose(): void;
41
47
  initProtocol(protocol: IProtocolHandler): void;
42
- receivedConnectEvent(connectionMode: ConnectionMode, details: IConnectionDetails): void;
48
+ receivedConnectEvent(details: IConnectionDetails): void;
43
49
  receivedDisconnectEvent(reason: string): void;
44
50
  }
45
51
 
@@ -50,7 +56,8 @@ export function createConnectionStateHandler(
50
56
  ) {
51
57
  const mc = loggerToMonitoringContext(inputs.logger);
52
58
  return createConnectionStateHandlerCore(
53
- mc.config.getBoolean("Fluid.Container.CatchUpBeforeDeclaringConnected") === true,
59
+ mc.config.getBoolean("Fluid.Container.CatchUpBeforeDeclaringConnected") === true, // connectedRaisedWhenCaughtUp
60
+ mc.config.getBoolean("Fluid.Container.DisableJoinSignalWait") !== true, // readClientsWaitForJoinSignal
54
61
  inputs,
55
62
  deltaManager,
56
63
  clientId,
@@ -58,20 +65,34 @@ export function createConnectionStateHandler(
58
65
  }
59
66
 
60
67
  export function createConnectionStateHandlerCore(
61
- wait: boolean,
68
+ connectedRaisedWhenCaughtUp: boolean,
69
+ readClientsWaitForJoinSignal: boolean,
62
70
  inputs: IConnectionStateHandlerInputs,
63
71
  deltaManager: IDeltaManager<any, any>,
64
72
  clientId?: string,
65
73
  ) {
66
- if (!wait) {
67
- return new ConnectionStateHandler(inputs, clientId);
74
+ if (!connectedRaisedWhenCaughtUp) {
75
+ return new ConnectionStateHandler(inputs, readClientsWaitForJoinSignal, clientId);
68
76
  }
69
77
  return new ConnectionStateCatchup(
70
78
  inputs,
71
- (handler: IConnectionStateHandlerInputs) => new ConnectionStateHandler(handler, clientId),
79
+ (handler: IConnectionStateHandlerInputs) => new ConnectionStateHandler(
80
+ handler,
81
+ readClientsWaitForJoinSignal,
82
+ clientId),
72
83
  deltaManager);
73
84
  }
74
85
 
86
+ /**
87
+ * Helper internal interface to abstract away Audience & Quorum
88
+ */
89
+ interface IMembership {
90
+ on(
91
+ eventName: "addMember" | "removeMember",
92
+ listener: (clientId: string, details: IClient | ISequencedClient) => void);
93
+ getMember(clientId: string): undefined | unknown;
94
+ }
95
+
75
96
  /**
76
97
  * Class that can be used as a base class for building IConnectionStateHandler adapters / pipeline.
77
98
  * It implements both ends of communication interfaces and passes data back and forward
@@ -97,8 +118,8 @@ class ConnectionStateHandlerPassThrough implements IConnectionStateHandler, ICon
97
118
  public initProtocol(protocol: IProtocolHandler) { return this.pimpl.initProtocol(protocol); }
98
119
  public receivedDisconnectEvent(reason: string) { return this.pimpl.receivedDisconnectEvent(reason); }
99
120
 
100
- public receivedConnectEvent(connectionMode: ConnectionMode, details: IConnectionDetails) {
101
- return this.pimpl.receivedConnectEvent(connectionMode, details);
121
+ public receivedConnectEvent(details: IConnectionDetails) {
122
+ return this.pimpl.receivedConnectEvent(details);
102
123
  }
103
124
 
104
125
  /**
@@ -201,8 +222,8 @@ class ConnectionStateCatchup extends ConnectionStateHandlerPassThrough {
201
222
  *
202
223
  * For (a) we give up waiting after some time (same timeout as server uses), and go ahead and transition to Connected.
203
224
  *
204
- * For (b) we log telemetry if it takes too long, but still only transition to Connected when the Join op is processed
205
- * and we are added to the Quorum.
225
+ * For (b) we log telemetry if it takes too long, but still only transition to Connected when the Join op/signal is
226
+ * processed.
206
227
  *
207
228
  * For (c) this is optional behavior, controlled by the parameters of receivedConnectEvent
208
229
  */
@@ -212,6 +233,8 @@ class ConnectionStateHandler implements IConnectionStateHandler {
212
233
  private readonly prevClientLeftTimer: Timer;
213
234
  private readonly joinOpTimer: Timer;
214
235
  private protocol?: IProtocolHandler;
236
+ private connection?: IConnectionDetails;
237
+ private _clientId?: string;
215
238
 
216
239
  private waitEvent: PerformanceEvent | undefined;
217
240
 
@@ -229,8 +252,10 @@ class ConnectionStateHandler implements IConnectionStateHandler {
229
252
 
230
253
  constructor(
231
254
  private readonly handler: IConnectionStateHandlerInputs,
232
- private _clientId?: string,
255
+ private readonly readClientsWaitForJoinSignal: boolean,
256
+ clientIdFromPausedSession?: string,
233
257
  ) {
258
+ this._clientId = clientIdFromPausedSession;
234
259
  this.prevClientLeftTimer = new Timer(
235
260
  // Default is 5 min for which we are going to wait for its own "leave" message. This is same as
236
261
  // the max time on server after which leave op is sent.
@@ -242,11 +267,8 @@ class ConnectionStateHandler implements IConnectionStateHandler {
242
267
  },
243
268
  );
244
269
 
245
- // Based on recent data, it looks like majority of cases where we get stuck are due to really slow or
246
- // timing out ops fetches. So attempt recovery infrequently. Also fetch uses 30 second timeout, so
247
- // if retrying fixes the problem, we should not see these events.
248
270
  this.joinOpTimer = new Timer(
249
- JoinOpTimeoutMs,
271
+ 0, // default value is not used - startJoinOpTimer() explicitly provides timeout
250
272
  () => {
251
273
  // I've observed timer firing within couple ms from disconnect event, looks like
252
274
  // queued timer callback is not cancelled if timer is cancelled while callback sits in the queue.
@@ -266,7 +288,10 @@ class ConnectionStateHandler implements IConnectionStateHandler {
266
288
 
267
289
  private startJoinOpTimer() {
268
290
  assert(!this.joinOpTimer.hasTimer, 0x234 /* "has joinOpTimer" */);
269
- this.joinOpTimer.start();
291
+ assert(this.connection !== undefined, 0x4b3 /* have connection */);
292
+ this.joinOpTimer.start(
293
+ this.connection.mode === "write" ? JoinOpTimeoutMs : JoinSignalTimeoutMs,
294
+ );
270
295
  }
271
296
 
272
297
  private stopJoinOpTimer() {
@@ -297,8 +322,8 @@ class ConnectionStateHandler implements IConnectionStateHandler {
297
322
  if (clientId === this.pendingClientId) {
298
323
  if (this.joinOpTimer.hasTimer) {
299
324
  this.stopJoinOpTimer();
300
- } else {
301
- // timer has already fired, meaning it took too long to get join on.
325
+ } else if (this.shouldWaitForJoinSignal()) {
326
+ // timer has already fired, meaning it took too long to get join op/signal.
302
327
  // Record how long it actually took to recover.
303
328
  this.handler.logConnectionIssue("ReceivedJoinOp");
304
329
  }
@@ -333,9 +358,11 @@ class ConnectionStateHandler implements IConnectionStateHandler {
333
358
  this.setConnectionState(ConnectionState.Connected);
334
359
  } else {
335
360
  // Adding this event temporarily so that we can get help debugging if something goes wrong.
361
+ // We may not see any ops due to being disconnected all that time - that's not an error!
362
+ const error = source === "timeout" && this.connectionState !== ConnectionState.Disconnected;
336
363
  this.handler.logger.sendTelemetryEvent({
337
364
  eventName: "connectedStateRejected",
338
- category: source === "timeout" ? "error" : "generic",
365
+ category: error ? "error" : "generic",
339
366
  details: JSON.stringify({
340
367
  source,
341
368
  pendingClientId: this.pendingClientId,
@@ -356,29 +383,35 @@ class ConnectionStateHandler implements IConnectionStateHandler {
356
383
  }
357
384
 
358
385
  public receivedDisconnectEvent(reason: string) {
386
+ this.connection = undefined;
359
387
  this.setConnectionState(ConnectionState.Disconnected, reason);
360
388
  }
361
389
 
390
+ private shouldWaitForJoinSignal() {
391
+ assert(this.connection !== undefined, 0x4b4 /* all callers call here with active connection */);
392
+ return this.connection.mode === "write" || this.readClientsWaitForJoinSignal;
393
+ }
394
+
362
395
  /**
363
396
  * The "connect" event indicates the connection to the Relay Service is live.
364
397
  * However, some additional conditions must be met before we can fully transition to
365
398
  * "Connected" state. This function handles that interim period, known as "Connecting" state.
366
- * @param connectionMode - Read or Write connection
367
399
  * @param details - Connection details returned from the Relay Service
368
400
  * @param deltaManager - DeltaManager to be used for delaying Connected transition until caught up.
369
401
  * If it's undefined, then don't delay and transition to Connected as soon as Leave/Join op are accounted for
370
402
  */
371
403
  public receivedConnectEvent(
372
- connectionMode: ConnectionMode,
373
404
  details: IConnectionDetails,
374
405
  ) {
406
+ this.connection = details;
407
+
375
408
  const oldState = this._connectionState;
376
409
  this._connectionState = ConnectionState.CatchingUp;
377
410
 
378
- const writeConnection = connectionMode === "write";
379
-
380
411
  // The following checks are wrong. They are only valid if user has write access to a file.
381
412
  // If user lost such access mid-session, user will not be able to get "write" connection.
413
+ //
414
+ // const writeConnection = details.mode === "write";
382
415
  // assert(!this.handler.shouldClientJoinWrite() || writeConnection,
383
416
  // 0x30a /* shouldClientJoinWrite should imply this is a writeConnection */);
384
417
  // assert(!this.waitingForLeaveOp || writeConnection,
@@ -395,16 +428,14 @@ class ConnectionStateHandler implements IConnectionStateHandler {
395
428
  // IMPORTANT: Report telemetry after we set _pendingClientId, but before transitioning to Connected state
396
429
  this.handler.connectionStateChanged(ConnectionState.CatchingUp, oldState);
397
430
 
398
- // For write connections, this pending clientId could be in the quorum already (i.e. join op already processed).
399
- // We are fetching ops from storage in parallel to connecting to Relay Service,
400
- // and given async processes, it's possible that we have already processed our own join message before
401
- // connection was fully established.
402
- // If protocol is not initialized yet, we expect it will process the join op after it's initialized.
403
- const waitingForJoinOp = writeConnection && !this.hasMember(this._pendingClientId);
404
-
405
- if (waitingForJoinOp) {
406
- // Previous client left, and we are waiting for our own join op. When it is processed we'll join the quorum
407
- // and attempt to transition to Connected state via receivedAddMemberEvent.
431
+ // Check if we need to wait for join op/signal, and if we need to wait for leave op from previous connection.
432
+ // Pending clientId could have joined already (i.e. join op/signal already processed):
433
+ // We are fetching ops from storage in parallel to connecting to Relay Service,
434
+ // and given async processes, it's possible that we have already processed our own join message before
435
+ // connection was fully established.
436
+ if (!this.hasMember(this._pendingClientId) && this.shouldWaitForJoinSignal()) {
437
+ // We are waiting for our own join op / signal. When it is processed
438
+ // we'll attempt to transition to Connected state via receivedAddMemberEvent() flow.
408
439
  this.startJoinOpTimer();
409
440
  } else if (!this.waitingForLeaveOp) {
410
441
  // We're not waiting for Join or Leave op (if read-only connection those don't even apply),
@@ -426,6 +457,10 @@ class ConnectionStateHandler implements IConnectionStateHandler {
426
457
 
427
458
  const oldState = this._connectionState;
428
459
  this._connectionState = value;
460
+
461
+ // This is the only place in code that deals with quorum. The rest works with audience
462
+ // The code below ensures that we do not send ops until we know that old "write" client's disconnect
463
+ // produced (and sequenced) leave op
429
464
  let client: ILocalSequencedClient | undefined;
430
465
  if (this._clientId !== undefined) {
431
466
  client = this.protocol?.quorum?.getMember(this._clientId);
@@ -476,23 +511,36 @@ class ConnectionStateHandler implements IConnectionStateHandler {
476
511
  // Helper method to switch between quorum and audience.
477
512
  // Old design was checking only quorum for "write" clients.
478
513
  // Latest change checks audience for all types of connections.
479
- protected get membership() {
480
- return this.protocol?.quorum;
514
+ protected get membership(): IMembership | undefined {
515
+ // We could always use audience here, and in practice it will probably be correct.
516
+ // (including case when this.readClientsWaitForJoinSignal === false).
517
+ // But only if it's superset of quorum, i.e. when filtered to "write" clients, they are always identical!
518
+ // It's safer to assume that we have bugs and engaging kill-bit switch should bring us back to well-known
519
+ // and tested state!
520
+ return this.readClientsWaitForJoinSignal ? this.protocol?.audience : this.protocol?.quorum;
481
521
  }
482
522
 
483
523
  public initProtocol(protocol: IProtocolHandler) {
484
524
  this.protocol = protocol;
485
525
 
486
- this.membership?.on("addMember", (clientId) => {
526
+ this.membership?.on("addMember", (clientId, details) => {
527
+ assert((details as IClient).mode === "read" || protocol.quorum.getMember(clientId) !== undefined,
528
+ 0x4b5 /* Audience is subset of quorum */);
487
529
  this.receivedAddMemberEvent(clientId);
488
530
  });
489
531
 
490
532
  this.membership?.on("removeMember", (clientId) => {
533
+ assert(protocol.quorum.getMember(clientId) === undefined, 0x4b6 /* Audience is subset of quorum */);
491
534
  this.receivedRemoveMemberEvent(clientId);
492
535
  });
493
536
 
494
- // Very unlikely race condition, but theoretically can happen - our new connection is already
495
- // summarized and we are loading from such summary.
537
+ /* There is a tiny tiny race possible, where these events happen in this order:
538
+ 1. A connection is established (no "cached" mode is used, so it happens in parallel / faster than other steps)
539
+ 2. Some other client produces a summary
540
+ 3. We get "lucky" and load from that summary as our initial snapshot
541
+ 4. ConnectionStateHandler.initProtocol is called, "self" is already in the quorum.
542
+ We could avoid this sequence (and delete test case for it) if we move connection lower in Container.load()
543
+ */
496
544
  if (this.hasMember(this.pendingClientId)) {
497
545
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
498
546
  this.receivedAddMemberEvent(this.pendingClientId!);