diff --git a/docs/RFCs/019-Code-Coverage-Messages.md b/docs/RFCs/019-Code-Coverage-Messages.md
index 618b7a9a01..dd7cb460ae 100644
--- a/docs/RFCs/019-Code-Coverage-Messages.md
+++ b/docs/RFCs/019-Code-Coverage-Messages.md
@@ -737,6 +737,11 @@ public sealed class CoverageReportReference
}
```
+`ITestCoverageCapabilities.SupportsTestCoverageMessages` describes the platform's in-process message
+and consumer contract. It is distinct from the JSON-RPC server-mode capability with the same suffix:
+`capabilities.testing.supportsTestCoverageMessages` remains `false` until those first-class messages
+are forwarded over the server-mode wire.
+
None of these public properties use `init`, per platform guidelines.
## How consumers use it — two tiers
diff --git a/docs/mstest-runner-protocol/001-protocol-intro.md b/docs/mstest-runner-protocol/001-protocol-intro.md
index 9176da38a6..6e369bdd16 100644
--- a/docs/mstest-runner-protocol/001-protocol-intro.md
+++ b/docs/mstest-runner-protocol/001-protocol-intro.md
@@ -12,6 +12,9 @@ This document describes the JSON-RPC server-mode protocol. The separate, binary
[`dotnet test` named-pipe protocol](./004-protocol-dotnet-test-pipe.md) (`--server dotnettestcli`)
is specified in its own document.
+The machine-readable JSON Schema for the base protocol is
+[`server-mode-1.0.schema.json`](./server-mode-1.0.schema.json).
+
## API overview
Here's the current list of APIs that supported by the client.
@@ -24,8 +27,6 @@ Here's the current list of APIs that supported by the client.
- [testing/testUpdates/tests](#discovery-of-tests) - Notifies client about test updates (test cases and test results)
- [testing/testUpdates/attachments](#execution-of-tests) - Notifies client about additional attachments (trx/coverage)
- Client notifications updates
- - [client/launchDebugger](#launch-debugger) - Requests a client to launch a process with debugger attached to it
- - [client/attachDebugger](#attach-debugger) - Requests a client to attach a debugger to a process by pid
- [client/log](#logging-of-messages) - Notifies a client to logs a message to the output window
- Miscellaneous requests
- [telemetry/update](#telemetry) - Sends telemetry data to the client
@@ -148,6 +149,9 @@ namespace ErrorCodes {
export const testingPlatformErrorRangeStart = -31700;
+ // The initialize request did not contain a protocol version supported by the server.
+ export const ProtocolVersionNotSupported: integer = -31699;
+
// If a top level assertion has failed when running tests.
// TODO: Decide if we can forward all assertion failures and attach them to test nodes.
export const AssertionFailed: integer = -31001;
@@ -181,6 +185,15 @@ what the client supports and limit functionality based on unsupported features.
> Since the capabilities are fetched by sending an RPC request to a started executable, these can only be queried by the client after
> the project was successfully built.
+The `initialize` request MUST be the first request sent on a connection:
+
+- Before `initialize` is received, requests are rejected with `ServerNotInitialized` (`-32002`) and
+ notifications other than `exit` and `$/cancelRequest` are ignored.
+- While initialization is in progress, non-initialize requests wait for it to complete. A second
+ `initialize` request is rejected with `InvalidRequest` (`-32600`).
+- After initialization succeeds, normal requests are processed and another `initialize` request is rejected.
+ If initialization fails, the client may correct the request and try again.
+
### Determine capabilities during test runner initialization
> [!NOTE]
@@ -194,33 +207,33 @@ Request:
```typescript
interface InitializeParams {
- // The process Id of the parent process that started the server. Is null if
- // the process has not been started by another process. If the parent
- // process is not alive then the server should exit (see exit notification)
- // its process.
+ // The process ID of the client process that started the server.
+ // Protocol 1.0 requires this value to be an integer.
processId: PID,
clientInfo: {
// The name of the client.
name: string,
- // This is the version of the client's protocol and should follow
- // semver.
- // The client and the server should be able to communicate
- // only if the major version stays the same.
- // The client can attempt to fallback on the older protocol
- // version if it has such a fallback and send another INITIALIZE
- // request.
+ // Client compatibility version. Existing server integrations use this
+ // to gate client-specific behavior, so it remains separate from the
+ // independently negotiated wire-protocol version below.
version: string,
},
+ // OPTIONAL for compatibility with clients predating protocol negotiation.
+ // The server selects its most preferred mutually supported version.
+ // If omitted, null, or empty, the server uses the legacy base protocol (1.0.0).
+ protocolVersions?: string[] | null,
+
capabilities: {
// Note: Since the initialize message is compatible with the LSP protocol,
// we should make sure that the capability paths for the testing features are unique.
// As such, we put all of them under a single testing namespace.
// This reduces collisions with other LSP capabilities.
testing: {
- // If true, the client supports the client/attachDebugger and client/launchDebugger requests.
+ // Reserved for future debugger callbacks. Protocol 1.0 accepts this field
+ // for compatibility but does not send debugger requests.
debuggerProvider: boolean,
// If true, the client is stateful: it persists an addressable set of test nodes for the
@@ -247,9 +260,13 @@ interface InitializeResponse {
// The name of the server.
name: string,
- // The server's protocol version.
+ // Product/build version of the server. This is not the wire-protocol version.
version: string
},
+
+ // Independently negotiated wire-protocol version.
+ protocolVersion?: string | null,
+
capabilities: {
testing: {
// If true, the server supports test discovery.
@@ -276,8 +293,10 @@ interface InitializeResponse {
// If true, the server understands the first-class test-coverage message contract.
// This advertises protocol support only; it does not imply that an enabled extension
- // will produce coverage during the run. The current server always advertises true.
- supportsTestCoverageMessages: true;
+ // will produce coverage during the run.
+ // This MUST only be true when first-class coverage messages are actually
+ // forwarded over this JSON-RPC connection.
+ supportsTestCoverageMessages: boolean;
},
}
}
@@ -288,6 +307,11 @@ interface InitializeResponse {
Any behavior changes should be announced via capabilities, where the client/server
should announce what kinds of additional RPC requests and responses they can handle.
+The independently negotiated `protocolVersion` versions the base wire contract. It is intentionally
+separate from `clientInfo.version` and `serverInfo.version`, which identify the client compatibility
+surface and server product build. Protocol `1.0.0` is the legacy base. An unsupported set is rejected
+with `ProtocolVersionNotSupported` (`-31699`).
+
For instance, let's say the server would like to be make the file/line location lazy.
The client should announce that is supports lazy locations.
If the capability is present the server can then send lazy location data.
@@ -298,6 +322,14 @@ lazy locations and send the full location.
> The TestNode format specified in the initial release of the protocol should be supported by all
> clients and servers. As such, it is not expressed via capabilities.
+## Additional passive connections
+
+A server advertising `multipleConnectionProvider` can use additional passive connections to push
+artifacts from child or watcher processes. The endpoint is configured by the client out of band when
+the process is launched; protocol 1.0 does not define a `testing.callbackProvider` initialize capability.
+This is used, for example, to preserve hang-dump or crash-dump attachments if the primary test-host
+process terminates.
+
## Discovery and run requests
The two most basic features that the a test runner should provide is the capability to discover and run tests.
@@ -467,7 +499,7 @@ type ExecutionState =
| 'failed'
| 'timed-out'
| 'error'
- | "cancelled"
+ | "canceled"
interface Trait {
key: string;
@@ -492,6 +524,12 @@ Request:
```typescript
interface DiscoverTestsParams {
+ // Optional explicit test selection. If omitted, all tests matching the filter are discovered.
+ tests?: TestNode[],
+
+ // Optional graph filter that narrows discovery.
+ filter?: string,
+
runId: GUID
}
```
@@ -511,7 +549,7 @@ Notifications:
// These should be processed in order and also should be complete,
// i.e. a server will send an update for the test, if it already sent updates
// for all of the parent nodes.
- changes: TestUpdateChange[]
+ changes: TestUpdateChange[] | null
// Run id for which the notification is sent. It should match the id sent during the discovery request.
runId: GUID
@@ -535,7 +573,11 @@ Notifications:
processing all node update notifications.
- Implementation detail: if a server sends callback notifications and returns a JSON-RPC response, vs-streamjsonrpc may resolve them in any order. As such, awaiting of the response is insufficient to guarantee that all updates were processed by the callback handler.
- method: `testing/testUpdates/tests`
- - params: `TestUpdateNotificationParams` where `params.children == null`.
+ - params: `TestUpdateNotificationParams` where `params.changes == null`.
+ - The server sends this terminal notification before the final success or error response for requests
+ whose discovery/run params were successfully deserialized, including requests canceled while queued
+ during initialization. Requests rejected by lifecycle checks and requests whose params could not be
+ deserialized receive only the error response.
Response:
@@ -567,6 +609,9 @@ interface RunTestsParams {
// If not specified all tests will run.
tests?: TestNode[],
+ // Optional graph filter that narrows execution.
+ filter?: string,
+
// Token which should be specified for all update notifications.
// This way the client can under which the update notifications should be reported.
runId: GUID
@@ -583,22 +628,17 @@ Notifications:
As soon as the client processes this notification it is guaranteed to be done
processing all node update notifications.
- method: `testing/testUpdates/tests`
- - params: `TestUpdateNotificationParams` where `params.testCases == null`.
+ - params: `TestUpdateNotificationParams` where `params.changes == null`.
- Attachment updates
- method: `testing/testUpdates/attachments`
- params: `AttachmentUpdatesParams` defined as follows:
```typescript
interface AttachmentUpdatesParams {
- attachments?: Attachment[],
- runId: GUID
+ attachments: Attachment[],
}
```
-- Attachment updates completes
- method: `testing/testUpdates/attachments`
- params: `AttachmentUpdatesParams` where `params.attachments == null`
-
Response:
- result: `RunTestsResponse` defined as follows:
@@ -609,33 +649,33 @@ interface RunTestsResponse {
// rather than to a single test result.
// Note: If multiple data collectors are generating attachments, they should send separate
// attachment events.
- attachments?: Attachment[]
+ attachments: Attachment[]
}
interface Attachment {
- // OPTIONAL: If the attachment is based on a file (and the client can show a hyperlink to it)
- // the file's location should be specified by this property.
+ // If the attachment is based on a file (and the client can show a hyperlink to it),
+ // the file's location should be specified; otherwise this value is null.
// Example: "uri": "file://some/coverage.trx"
- uri: string,
+ uri: string | null,
// The name of the extension that generated the attachment.
// Note: For the time being the client does not special case attachments
// based on their producer.
// Example: "producer": "TrxReportGeneratorProcessLifetimeHandler"
- producer: string,
+ producer: string | null,
- // OPTIONAL: The file extension can be used to resolve the attachment type
- // if that isn't ambiguous.
+ // The file extension can be used to resolve the attachment type if that isn't ambiguous;
+ // otherwise this value is null.
// Note: For the time being the client does not special case attachments
// based on their type.
// Example: "type": "file"
- type: string,
+ type: string | null,
// How the attachment can be displayed as by the client.
// Example: "display-name": "Code Coverage"
- displayName: string;
+ 'display-name': string | null;
- description: string;
+ description: string | null;
}
```
@@ -696,8 +736,11 @@ Notification:
- params: `CancelParams` defined as follows:
```typescript
+type NumericStringId = `${bigint}`;
+
interface CancelParams {
- id: number;
+ // String IDs use the canonical decimal integer representation and signed 32-bit range.
+ id: number | NumericStringId;
}
```
@@ -706,6 +749,8 @@ interface CancelParams {
### Launch debugger
> Message direction: Server -> Client
+> [!NOTE]
+> Reserved protocol shape. Microsoft.Testing.Platform 1.0 does not currently send this request.
Requests a client to attach a debugger to the spawned child process.
@@ -737,6 +782,8 @@ interface LaunchDebuggerParams {
### Attach debugger
> Message direction: Server -> Client
+> [!NOTE]
+> Reserved protocol shape. Microsoft.Testing.Platform 1.0 does not currently send this request.
Requests a client to attach a debugger to the spawned child process.
@@ -781,17 +828,13 @@ interface LogMessageParams {
level: TestingPlatformLogLevel;
message: string;
}
-```
-```typescript
-enum TestingPlatformLogLevel
-{
- Trace = 0,
- Debug = 1,
- Information = 2,
- Warning = 3,
- Error = 4,
- Critical = 5,
- None = 6,
-}
+type TestingPlatformLogLevel =
+ 'Trace'
+ | 'Debug'
+ | 'Information'
+ | 'Warning'
+ | 'Error'
+ | 'Critical'
+ | 'None';
```
diff --git a/docs/mstest-runner-protocol/server-mode-1.0.schema.json b/docs/mstest-runner-protocol/server-mode-1.0.schema.json
new file mode 100644
index 0000000000..221863bcf7
--- /dev/null
+++ b/docs/mstest-runner-protocol/server-mode-1.0.schema.json
@@ -0,0 +1,756 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://raw.githubusercontent.com/microsoft/testfx/main/docs/mstest-runner-protocol/server-mode-1.0.schema.json",
+ "title": "Microsoft.Testing.Platform JSON-RPC server-mode protocol 1.0",
+ "description": "Machine-readable envelope and payload contract for the MTP JSON-RPC server-mode protocol.",
+ "oneOf": [
+ {
+ "$ref": "#/$defs/initializeRequest"
+ },
+ {
+ "$ref": "#/$defs/discoverRequest"
+ },
+ {
+ "$ref": "#/$defs/runRequest"
+ },
+ {
+ "$ref": "#/$defs/cancelNotification"
+ },
+ {
+ "$ref": "#/$defs/exitNotification"
+ },
+ {
+ "$ref": "#/$defs/testUpdateNotification"
+ },
+ {
+ "$ref": "#/$defs/attachmentNotification"
+ },
+ {
+ "$ref": "#/$defs/logNotification"
+ },
+ {
+ "$ref": "#/$defs/telemetryNotification"
+ },
+ {
+ "$ref": "#/$defs/successResponse"
+ },
+ {
+ "$ref": "#/$defs/errorResponse"
+ }
+ ],
+ "$defs": {
+ "rpcId": {
+ "description": "Protocol 1.0 normalizes signed 32-bit integer IDs and their canonical decimal string representation.",
+ "oneOf": [
+ {
+ "type": "integer",
+ "minimum": -2147483648,
+ "maximum": 2147483647
+ },
+ {
+ "type": "string",
+ "oneOf": [
+ {
+ "pattern": "^(?:0|[1-9][0-9]{0,8}|1[0-9]{9}|20[0-9]{8}|21[0-3][0-9]{7}|214[0-6][0-9]{6}|2147[0-3][0-9]{5}|21474[0-7][0-9]{4}|214748[0-2][0-9]{3}|2147483[0-5][0-9]{2}|21474836[0-3][0-9]|214748364[0-7])$"
+ },
+ {
+ "pattern": "^-(?:[1-9][0-9]{0,8}|1[0-9]{9}|20[0-9]{8}|21[0-3][0-9]{7}|214[0-6][0-9]{6}|2147[0-3][0-9]{5}|21474[0-7][0-9]{4}|214748[0-2][0-9]{3}|2147483[0-5][0-9]{2}|21474836[0-3][0-9]|214748364[0-8])$"
+ }
+ ]
+ }
+ ]
+ },
+ "requestEnvelope": {
+ "type": "object",
+ "required": [
+ "jsonrpc",
+ "id",
+ "method"
+ ],
+ "properties": {
+ "jsonrpc": {
+ "const": "2.0"
+ },
+ "id": {
+ "$ref": "#/$defs/rpcId"
+ },
+ "method": {
+ "type": "string"
+ },
+ "params": {
+ "type": [
+ "object",
+ "null"
+ ]
+ }
+ },
+ "additionalProperties": true
+ },
+ "notificationEnvelope": {
+ "type": "object",
+ "required": [
+ "jsonrpc",
+ "method"
+ ],
+ "properties": {
+ "jsonrpc": {
+ "const": "2.0"
+ },
+ "method": {
+ "type": "string"
+ },
+ "params": {
+ "type": [
+ "object",
+ "null"
+ ]
+ }
+ },
+ "not": {
+ "required": [
+ "id"
+ ]
+ },
+ "additionalProperties": true
+ },
+ "clientCapabilities": {
+ "type": "object",
+ "required": [
+ "testing"
+ ],
+ "properties": {
+ "testing": {
+ "type": "object",
+ "required": [
+ "debuggerProvider"
+ ],
+ "properties": {
+ "debuggerProvider": {
+ "type": "boolean"
+ },
+ "isStateful": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "additionalProperties": true
+ }
+ },
+ "additionalProperties": true
+ },
+ "serverCapabilities": {
+ "type": "object",
+ "required": [
+ "testing"
+ ],
+ "properties": {
+ "testing": {
+ "type": "object",
+ "required": [
+ "supportsDiscovery",
+ "experimental_multiRequestSupport",
+ "vstestProvider",
+ "attachmentsSupport",
+ "multipleConnectionProvider",
+ "supportsTestCoverageMessages"
+ ],
+ "properties": {
+ "supportsDiscovery": {
+ "type": "boolean"
+ },
+ "experimental_multiRequestSupport": {
+ "type": "boolean"
+ },
+ "vstestProvider": {
+ "type": "boolean"
+ },
+ "attachmentsSupport": {
+ "type": "boolean"
+ },
+ "multipleConnectionProvider": {
+ "type": "boolean"
+ },
+ "supportsTestCoverageMessages": {
+ "type": "boolean"
+ }
+ },
+ "additionalProperties": true
+ }
+ },
+ "additionalProperties": true
+ },
+ "initializeRequest": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/requestEnvelope"
+ },
+ {
+ "required": [
+ "params"
+ ],
+ "properties": {
+ "method": {
+ "const": "initialize"
+ },
+ "params": {
+ "type": "object",
+ "required": [
+ "processId",
+ "clientInfo",
+ "capabilities"
+ ],
+ "properties": {
+ "processId": {
+ "type": "integer",
+ "minimum": -2147483648,
+ "maximum": 2147483647
+ },
+ "clientInfo": {
+ "type": "object",
+ "required": [
+ "name",
+ "version"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": true
+ },
+ "capabilities": {
+ "$ref": "#/$defs/clientCapabilities"
+ },
+ "protocolVersions": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": true
+ }
+ }
+ }
+ ]
+ },
+ "testSelectionParams": {
+ "type": "object",
+ "required": [
+ "runId"
+ ],
+ "properties": {
+ "runId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "tests": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "$ref": "#/$defs/testNode"
+ }
+ },
+ "filter": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "additionalProperties": true
+ },
+ "discoverRequest": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/requestEnvelope"
+ },
+ {
+ "required": [
+ "params"
+ ],
+ "properties": {
+ "method": {
+ "const": "testing/discoverTests"
+ },
+ "params": {
+ "$ref": "#/$defs/testSelectionParams"
+ }
+ }
+ }
+ ]
+ },
+ "runRequest": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/requestEnvelope"
+ },
+ {
+ "required": [
+ "params"
+ ],
+ "properties": {
+ "method": {
+ "const": "testing/runTests"
+ },
+ "params": {
+ "$ref": "#/$defs/testSelectionParams"
+ }
+ }
+ }
+ ]
+ },
+ "cancelNotification": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/notificationEnvelope"
+ },
+ {
+ "required": [
+ "params"
+ ],
+ "properties": {
+ "method": {
+ "const": "$/cancelRequest"
+ },
+ "params": {
+ "type": "object",
+ "required": [
+ "id"
+ ],
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/rpcId"
+ }
+ },
+ "additionalProperties": true
+ }
+ }
+ }
+ ]
+ },
+ "exitNotification": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/notificationEnvelope"
+ },
+ {
+ "properties": {
+ "method": {
+ "const": "exit"
+ },
+ "params": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "maxProperties": 0
+ }
+ }
+ }
+ ]
+ },
+ "testNode": {
+ "type": "object",
+ "required": [
+ "uid",
+ "display-name"
+ ],
+ "properties": {
+ "uid": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "\\S"
+ },
+ "display-name": {
+ "type": "string"
+ },
+ "node-type": {
+ "enum": [
+ "action",
+ "group"
+ ]
+ },
+ "execution-state": {
+ "enum": [
+ "discovered",
+ "in-progress",
+ "passed",
+ "skipped",
+ "failed",
+ "timed-out",
+ "error",
+ "canceled"
+ ]
+ },
+ "location.file": {
+ "type": "string"
+ },
+ "location.line-start": {
+ "type": "integer",
+ "minimum": -2147483648,
+ "maximum": 2147483647
+ },
+ "location.line-end": {
+ "type": "integer",
+ "minimum": -2147483648,
+ "maximum": 2147483647
+ },
+ "time.duration-ms": {
+ "type": "number",
+ "minimum": 0
+ },
+ "retry.attempt": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "retry.is-superseded": {
+ "type": "boolean"
+ }
+ },
+ "dependentRequired": {
+ "location.file": [
+ "location.line-start",
+ "location.line-end"
+ ],
+ "location.line-start": [
+ "location.file",
+ "location.line-end"
+ ],
+ "location.line-end": [
+ "location.file",
+ "location.line-start"
+ ]
+ },
+ "additionalProperties": true
+ },
+ "testUpdateNotification": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/notificationEnvelope"
+ },
+ {
+ "required": [
+ "params"
+ ],
+ "properties": {
+ "method": {
+ "const": "testing/testUpdates/tests"
+ },
+ "params": {
+ "type": "object",
+ "required": [
+ "runId",
+ "changes"
+ ],
+ "properties": {
+ "runId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "changes": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "node"
+ ],
+ "properties": {
+ "parent": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "node": {
+ "$ref": "#/$defs/testNode"
+ }
+ },
+ "additionalProperties": true
+ }
+ }
+ ]
+ }
+ },
+ "additionalProperties": true
+ }
+ }
+ }
+ ]
+ },
+ "artifact": {
+ "type": "object",
+ "required": [
+ "uri",
+ "producer",
+ "type",
+ "display-name",
+ "description"
+ ],
+ "properties": {
+ "uri": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "producer": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "type": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "display-name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "description": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "additionalProperties": true
+ },
+ "attachmentNotification": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/notificationEnvelope"
+ },
+ {
+ "required": [
+ "params"
+ ],
+ "properties": {
+ "method": {
+ "const": "testing/testUpdates/attachments"
+ },
+ "params": {
+ "type": "object",
+ "required": [
+ "attachments"
+ ],
+ "properties": {
+ "attachments": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/artifact"
+ }
+ }
+ },
+ "additionalProperties": true
+ }
+ }
+ }
+ ]
+ },
+ "logNotification": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/notificationEnvelope"
+ },
+ {
+ "required": [
+ "params"
+ ],
+ "properties": {
+ "method": {
+ "const": "client/log"
+ },
+ "params": {
+ "type": "object",
+ "required": [
+ "level",
+ "message"
+ ],
+ "properties": {
+ "level": {
+ "enum": [
+ "Trace",
+ "Debug",
+ "Information",
+ "Warning",
+ "Error",
+ "Critical",
+ "None"
+ ]
+ },
+ "message": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": true
+ }
+ }
+ }
+ ]
+ },
+ "telemetryNotification": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/notificationEnvelope"
+ },
+ {
+ "required": [
+ "params"
+ ],
+ "properties": {
+ "method": {
+ "const": "telemetry/update"
+ },
+ "params": {
+ "type": "object",
+ "required": [
+ "eventName",
+ "metrics"
+ ],
+ "properties": {
+ "eventName": {
+ "type": "string"
+ },
+ "metrics": {
+ "type": "object"
+ }
+ },
+ "additionalProperties": true
+ }
+ }
+ }
+ ]
+ },
+ "successResponse": {
+ "type": "object",
+ "required": [
+ "jsonrpc",
+ "id",
+ "result"
+ ],
+ "properties": {
+ "jsonrpc": {
+ "const": "2.0"
+ },
+ "id": {
+ "$ref": "#/$defs/rpcId"
+ },
+ "result": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "processId": {
+ "type": "integer",
+ "minimum": -2147483648,
+ "maximum": 2147483647
+ },
+ "serverInfo": {
+ "type": "object",
+ "required": [
+ "name",
+ "version"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": true
+ },
+ "protocolVersion": {
+ "oneOf": [
+ {
+ "const": "1.0.0"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "capabilities": {
+ "$ref": "#/$defs/serverCapabilities"
+ },
+ "attachments": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/artifact"
+ }
+ }
+ },
+ "additionalProperties": true
+ }
+ },
+ "not": {
+ "required": [
+ "method"
+ ]
+ },
+ "additionalProperties": true
+ },
+ "errorResponse": {
+ "type": "object",
+ "required": [
+ "jsonrpc",
+ "id",
+ "error"
+ ],
+ "properties": {
+ "jsonrpc": {
+ "const": "2.0"
+ },
+ "id": {
+ "$ref": "#/$defs/rpcId"
+ },
+ "error": {
+ "type": "object",
+ "required": [
+ "code",
+ "message"
+ ],
+ "properties": {
+ "code": {
+ "type": "integer",
+ "minimum": -2147483648,
+ "maximum": 2147483647
+ },
+ "message": {
+ "type": "string"
+ },
+ "data": true
+ },
+ "additionalProperties": true
+ }
+ },
+ "not": {
+ "required": [
+ "method"
+ ]
+ },
+ "additionalProperties": true
+ }
+ }
+}
diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/IMtpServerClient.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/IMtpServerClient.cs
index f5584ed07e..9ff871ea6d 100644
--- a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/IMtpServerClient.cs
+++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/IMtpServerClient.cs
@@ -159,7 +159,8 @@ public MtpServerCapabilities(
bool multiRequestSupport,
bool vstestProviderSupport,
bool supportsAttachments,
- bool multiConnectionProvider)
+ bool multiConnectionProvider,
+ string? protocolVersion = null)
{
ServerProcessId = serverProcessId;
ServerName = serverName;
@@ -169,6 +170,7 @@ public MtpServerCapabilities(
VSTestProviderSupport = vstestProviderSupport;
SupportsAttachments = supportsAttachments;
MultiConnectionProvider = multiConnectionProvider;
+ ProtocolVersion = protocolVersion;
}
/// Gets the process id reported by the server.
@@ -180,6 +182,9 @@ public MtpServerCapabilities(
/// Gets the server version.
public string? ServerVersion { get; }
+ /// Gets the independently negotiated server-mode protocol version.
+ public string? ProtocolVersion { get; }
+
/// Gets a value indicating whether the server supports discovery.
public bool SupportsDiscovery { get; }
diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpJsonRpcConnection.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpJsonRpcConnection.cs
index f60c5206b4..e4c23dd811 100644
--- a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpJsonRpcConnection.cs
+++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpJsonRpcConnection.cs
@@ -19,7 +19,7 @@ internal sealed class MtpJsonRpcConnection : IDisposable
{
private readonly IMessageHandler _handler;
private readonly IMtpClientLogger _logger;
- private readonly ConcurrentDictionary _pendingRequests = new();
+ private readonly ConcurrentDictionary<(int Id, bool IsString), PendingRequest> _pendingRequests = new();
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly CancellationTokenSource _readLoopCancellation = new();
private readonly object _startLock = new();
@@ -113,15 +113,16 @@ public async Task SendRequestAsync(string method, object? @para
}
int id = Interlocked.Increment(ref _nextRequestId);
+ (int Id, bool IsString) requestKey = GetRequestKey(id, stringId: null);
var pending = new PendingRequest(method);
- _pendingRequests[id] = pending;
+ _pendingRequests[requestKey] = pending;
// Re-check after registering: the read loop may have latched a terminal reason and run
// FailAllPending between the check above and this insert, missing this entry. Observing the reason
// here guarantees the request is completed rather than left waiting.
if (Volatile.Read(ref _closedReason) is { } closedAfter)
{
- _pendingRequests.TryRemove(id, out _);
+ _pendingRequests.TryRemove(requestKey, out _);
throw closedAfter;
}
@@ -135,7 +136,7 @@ public async Task SendRequestAsync(string method, object? @para
}
finally
{
- _pendingRequests.TryRemove(id, out _);
+ _pendingRequests.TryRemove(requestKey, out _);
}
}
@@ -202,7 +203,7 @@ private void Dispatch(RpcMessage message, CancellationToken cancellationToken)
switch (message)
{
case ResponseMessage response:
- if (_pendingRequests.TryGetValue(response.Id, out PendingRequest? successful))
+ if (_pendingRequests.TryGetValue(GetRequestKey(response.Id, response.StringId), out PendingRequest? successful))
{
successful.Completion.TrySetResult(response);
}
@@ -210,7 +211,7 @@ private void Dispatch(RpcMessage message, CancellationToken cancellationToken)
break;
case ErrorMessage error:
- if (_pendingRequests.TryGetValue(error.Id, out PendingRequest? failed))
+ if (_pendingRequests.TryGetValue(GetRequestKey(error.Id, error.StringId), out PendingRequest? failed))
{
failed.Completion.TrySetException(new MtpServerErrorException(error.ErrorCode, error.Message));
}
@@ -258,7 +259,9 @@ private async Task HandleServerRequestAsync(RequestMessage request, Cancellation
// Always answer so the server is never left waiting.
try
{
- await WriteMessageAsync(new ResponseMessage(request.Id, result), cancellationToken).ConfigureAwait(false);
+ await WriteMessageAsync(
+ new ResponseMessage(request.Id, result) { StringId = request.StringId },
+ cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -268,7 +271,7 @@ private async Task HandleServerRequestAsync(RequestMessage request, Cancellation
private void CancelPendingRequest(int id, CancellationToken cancellationToken)
{
- if (!_pendingRequests.TryGetValue(id, out PendingRequest? pending))
+ if (!_pendingRequests.TryGetValue(GetRequestKey(id, stringId: null), out PendingRequest? pending))
{
return;
}
@@ -305,7 +308,7 @@ private void Close(Exception reason)
private void FailAllPending(Exception exception)
{
- foreach (KeyValuePair entry in _pendingRequests)
+ foreach (KeyValuePair<(int Id, bool IsString), PendingRequest> entry in _pendingRequests)
{
if (_pendingRequests.TryRemove(entry.Key, out PendingRequest? pending))
{
@@ -314,6 +317,9 @@ private void FailAllPending(Exception exception)
}
}
+ private static (int Id, bool IsString) GetRequestKey(int id, string? stringId)
+ => (id, stringId is not null);
+
public void Dispose()
{
Task? readLoop;
diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClient.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClient.cs
index bab20eb106..0fe0eb3ffa 100644
--- a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClient.cs
+++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClient.cs
@@ -121,10 +121,21 @@ public async Task InitializeAsync(CancellationToken cance
var args = new InitializeRequestArgs(
GetCurrentProcessId(),
new ClientInfo(_options.ClientName, _options.ClientVersion),
- new ClientCapabilities(_options.DebuggerProvider, _options.IsStateful));
+ new ClientCapabilities(_options.DebuggerProvider, _options.IsStateful))
+ {
+ ProtocolVersions = _options.SupportedProtocolVersions.ToArray(),
+ };
ResponseMessage response = await _connection.SendRequestAsync(JsonRpcMethods.Initialize, args, cancellationToken).ConfigureAwait(false);
MtpServerCapabilities capabilities = DecodeCapabilities(AsResultDictionary(response.Result));
+ string effectiveProtocolVersion = capabilities.ProtocolVersion ?? JsonRpcProtocolVersions.V1;
+ if (!IsSupportedProtocolVersion(effectiveProtocolVersion))
+ {
+ throw new MtpServerClientException(
+ $"The server negotiated unsupported protocol version '{effectiveProtocolVersion}'. "
+ + $"Supported versions: {string.Join(", ", _options.SupportedProtocolVersions)}.");
+ }
+
Capabilities = capabilities;
return capabilities;
}
@@ -210,6 +221,18 @@ private static MtpServerCapabilities DecodeCapabilities(IDictionary null,
+ string value => value,
+ _ => throw new MtpServerClientException(
+ $"Expected '{JsonRpcStrings.ProtocolVersion}' to be a string but it was '{protocolVersionObj.GetType()}'."),
+ };
+ }
+
if (result.TryGetValue(JsonRpcStrings.Capabilities, out object? capabilitiesObj)
&& capabilitiesObj is IDictionary capabilities
&& capabilities.TryGetValue(JsonRpcStrings.Testing, out object? testingObj)
@@ -230,9 +253,15 @@ private static MtpServerCapabilities DecodeCapabilities(IDictionary _options.SupportedProtocolVersions.Count == 0
+ ? negotiatedProtocolVersion == JsonRpcProtocolVersions.V1
+ : _options.SupportedProtocolVersions.Contains(negotiatedProtocolVersion, StringComparer.Ordinal);
+
private static int? AsInt(object? value)
=> value switch
{
diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClientOptions.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClientOptions.cs
index 2363d92d3e..835633ac6c 100644
--- a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClientOptions.cs
+++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/MtpServerClientOptions.cs
@@ -17,15 +17,21 @@ internal sealed class MtpServerClientOptions
public string ClientName { get; set; } = "Microsoft.Testing.Platform.ServerMode.Client";
///
- /// Gets or sets the client protocol/tool version reported to the server (clientInfo.version).
+ /// Gets or sets the client compatibility version reported to the server (clientInfo.version).
+ /// This is separate from .
///
public string ClientVersion { get; set; } = "1.0.0";
///
- /// Gets or sets a value indicating whether the client advertises that it can provide a debugger
- /// (capabilities.testing.debuggerProvider). When the server may send
- /// client/attachDebugger / client/launchDebugger requests, which the caller must answer via
- /// a debugger callback. Defaults to .
+ /// Gets or sets the server-mode protocol versions supported by the client. The server selects its most
+ /// preferred mutually supported version.
+ ///
+ public IReadOnlyCollection SupportedProtocolVersions { get; set; } = JsonRpcProtocolVersions.Supported;
+
+ ///
+ /// Gets or sets a value indicating whether the client advertises the reserved debugger-provider
+ /// capability (capabilities.testing.debuggerProvider). Microsoft.Testing.Platform protocol 1.0
+ /// accepts this field for compatibility but does not currently send debugger requests.
///
public bool DebuggerProvider { get; set; }
diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/SerializerUtilities.ClientSerializers.cs b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/SerializerUtilities.ClientSerializers.cs
index 74ad11be65..e04c5d0b9e 100644
--- a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/SerializerUtilities.ClientSerializers.cs
+++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Client/SerializerUtilities.ClientSerializers.cs
@@ -70,11 +70,21 @@ private static void RegisterClientSerializersCore()
},
});
- Serializers[typeof(InitializeRequestArgs)] = new ObjectSerializer(args => new Dictionary
+ Serializers[typeof(InitializeRequestArgs)] = new ObjectSerializer(args =>
{
- [JsonRpcStrings.ProcessId] = args.ProcessId,
- [JsonRpcStrings.ClientInfo] = Serialize(args.ClientInfo),
- [JsonRpcStrings.Capabilities] = Serialize(args.Capabilities),
+ Dictionary properties = new()
+ {
+ [JsonRpcStrings.ProcessId] = args.ProcessId,
+ [JsonRpcStrings.ClientInfo] = Serialize(args.ClientInfo),
+ [JsonRpcStrings.Capabilities] = Serialize(args.Capabilities),
+ };
+
+ if (args.ProtocolVersions is not null)
+ {
+ properties[JsonRpcStrings.ProtocolVersions] = args.ProtocolVersions;
+ }
+
+ return properties;
});
Serializers[typeof(DiscoverRequestArgs)] = new ObjectSerializer(SerializeRequestArgs);
@@ -126,17 +136,21 @@ private static void RegisterClientSerializersCore()
if (properties.TryGetValue(JsonRpcStrings.Method, out object? methodObj) && methodObj is not null)
{
string method = (string)methodObj;
- object? idObj = GetOptionalPropertyFromJson(properties, JsonRpcStrings.Id);
+ bool hasId = properties.TryGetValue(JsonRpcStrings.Id, out object? idObj);
+ if (hasId && idObj is null)
+ {
+ throw new MessageFormatException($"'{JsonRpcStrings.Id}' field cannot be null");
+ }
// Keep the params as the raw dictionary; the client decodes it based on the method name.
object? @params = GetOptionalPropertyFromJson(properties, JsonRpcStrings.Params);
- int? id = idObj is null
+ int? id = !hasId
? null
: GetIdFromJson(idObj) ?? throw new MessageFormatException("id field should be a string or an int");
return id.HasValue
- ? new RequestMessage(id.Value, method, @params)
+ ? new RequestMessage(id.Value, method, @params) { StringId = idObj as string }
: new NotificationMessage(method, @params);
}
else if (properties.TryGetValue(JsonRpcStrings.Error, out _))
@@ -149,7 +163,7 @@ private static void RegisterClientSerializersCore()
var result = resultObj as IDictionary;
int id = GetIdFromJson(idObj) ?? throw new MessageFormatException("id field should be a string or an int");
- return new ResponseMessage(id, result);
+ return new ResponseMessage(id, result) { StringId = idObj as string };
}
throw new MessageFormatException();
diff --git a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Microsoft.Testing.Platform.ServerMode.Client.Sources.csproj b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Microsoft.Testing.Platform.ServerMode.Client.Sources.csproj
index 62c0d94e22..421c614dc6 100644
--- a/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Microsoft.Testing.Platform.ServerMode.Client.Sources.csproj
+++ b/src/Platform/Microsoft.Testing.Platform.ServerMode.Client.Sources/Microsoft.Testing.Platform.ServerMode.Client.Sources.csproj
@@ -107,6 +107,7 @@
+
diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.MessageLoop.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.MessageLoop.cs
index ec4eb91d17..478c787245 100644
--- a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.MessageLoop.cs
+++ b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.MessageLoop.cs
@@ -75,15 +75,22 @@ private async Task HandleMessagesAsync(CancellationToken cancellationToken)
case NotificationMessage notification:
// This task is recorded inside the _clientToServerRequests
+ // Cancellation is applied synchronously so queued requests observe it before they resume.
_ = HandleNotificationAsync(notification, _serverClosingTokenSource.Token);
break;
case ResponseMessage response:
- CompleteRequest(ref _serverToClientRequests, response.Id, completion => completion.TrySetResult(response));
+ CompleteRequest(
+ ref _serverToClientRequests,
+ GetRequestKey(response.Id, response.StringId),
+ completion => completion.TrySetResult(response));
break;
case ErrorMessage error:
RemoteInvocationException exception = new(error.ErrorCode, error.Message, error.Data);
- CompleteRequest(ref _serverToClientRequests, error.Id, completion => completion.TrySetException(exception));
+ CompleteRequest(
+ ref _serverToClientRequests,
+ GetRequestKey(error.Id, error.StringId),
+ completion => completion.TrySetException(exception));
break;
}
}
@@ -118,17 +125,30 @@ private async Task HandleNotificationAsync(NotificationMessage message, Cancella
}
}
- // Note: Yield, so that the main message reading loop can continue.
- await Task.Yield();
+ if (Volatile.Read(ref _initializeState) != Initialized
+ && message.Method != JsonRpcMethods.CancelRequest)
+ {
+ _requestCounter.Signal();
+ return;
+ }
try
{
switch (message.Method, message.Params)
{
case (JsonRpcMethods.CancelRequest, CancelRequestArgs args):
- if (_clientToServerRequests.TryGetValue(args.CancelRequestId, out RpcInvocationState? rpcState))
+ if (_clientToServerRequests.TryGetValue(
+ GetRequestKey(args.CancelRequestId, args.StringId),
+ out RpcInvocationState? rpcState))
{
- Exception? cancellationException = rpcState.CancelRequest();
+ if (!rpcState.TryRequestCancellation())
+ {
+ break;
+ }
+
+ // Record cancellation synchronously so a queued request cannot resume into execution.
+ // Run token callbacks asynchronously so extension code cannot block the message reader.
+ Exception? cancellationException = await Task.Run(rpcState.CancelRequest).ConfigureAwait(false);
if (cancellationException is not null)
{
// This is intentionally not using PlatformResources.ExceptionDuringCancellationWarningMessage
@@ -159,7 +179,13 @@ private async Task HandleRequestAsync(RequestMessage request, CancellationToken
{
try
{
- await SendErrorAsync(reqId: request.Id, errorCode: ErrorCodes.InvalidRequest, message: "Server is closing", data: null, cancellationToken).ConfigureAwait(false);
+ await SendErrorAsync(
+ reqId: request.Id,
+ errorCode: ErrorCodes.InvalidRequest,
+ message: "Server is closing",
+ data: null,
+ cancellationToken,
+ stringId: request.StringId).ConfigureAwait(false);
}
finally
{
@@ -169,51 +195,315 @@ private async Task HandleRequestAsync(RequestMessage request, CancellationToken
}
else
{
+ bool isInitializeRequest = request.Method == JsonRpcMethods.Initialize;
+ bool rejectRequest;
+ Task? initializationTask = null;
+ lock (_initializeStateLock)
+ {
+ if (isInitializeRequest)
+ {
+ rejectRequest = _initializeState != NotInitialized;
+ if (!rejectRequest)
+ {
+ _initializeState = Initializing;
+ _initializationCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ }
+ }
+ else
+ {
+ rejectRequest = _initializeState == NotInitialized;
+ if (_initializeState == Initializing)
+ {
+ RoslynDebug.Assert(_initializationCompletionSource is not null);
+ initializationTask = _initializationCompletionSource.Task;
+ }
+ }
+ }
+
+ if (isInitializeRequest && rejectRequest)
+ {
+ try
+ {
+ await SendErrorAsync(
+ reqId: request.Id,
+ errorCode: ErrorCodes.InvalidRequest,
+ message: "The server has already received an initialize request.",
+ data: null,
+ cancellationToken,
+ stringId: request.StringId).ConfigureAwait(false);
+ }
+ finally
+ {
+ _requestCounter.Signal();
+ }
+
+ return;
+ }
+
+ RpcInvocationState? rpcState = null;
+ bool requestRegistered = false;
+ if (initializationTask is not null)
+ {
+ rpcState = new RpcInvocationState();
+ requestRegistered = _clientToServerRequests.TryAdd(
+ GetRequestKey(request.Id, request.StringId),
+ rpcState);
+ bool initialized = await initializationTask.ConfigureAwait(false);
+ rejectRequest = !initialized;
+ }
+
+ if (!isInitializeRequest && rejectRequest)
+ {
+ try
+ {
+ await SendErrorAsync(
+ reqId: request.Id,
+ errorCode: ErrorCodes.ServerNotInitialized,
+ message: "The server must be initialized before this request can be processed.",
+ data: null,
+ cancellationToken,
+ stringId: request.StringId).ConfigureAwait(false);
+ }
+ finally
+ {
+ if (requestRegistered)
+ {
+ var exception = new JsonRpcException(
+ ErrorCodes.ServerNotInitialized,
+ "The server must be initialized before this request can be processed.");
+ CompleteRequest(
+ ref _clientToServerRequests,
+ GetRequestKey(request.Id, request.StringId),
+ completion => completion.TrySetException(exception));
+ }
+ else
+ {
+ _requestCounter.Signal();
+ }
+ }
+
+ return;
+ }
+
// We enqueue the request before to "unlink" the current thread so we're sure that we
// correctly handle the completion also after the "exit"
- RpcInvocationState rpcState = new();
- _clientToServerRequests.TryAdd(request.Id, rpcState);
+ rpcState ??= new RpcInvocationState();
+ if (!requestRegistered)
+ {
+ _clientToServerRequests.TryAdd(GetRequestKey(request.Id, request.StringId), rpcState);
+ }
// Note: Yield, so that the main message reading loop can continue.
await Task.Yield();
+ bool testUpdateCompletionSent = false;
try
{
+ rpcState.ThrowIfCancellationRequested();
object response = await HandleRequestCoreAsync(request, rpcState, cancellationToken).ConfigureAwait(false);
- await SendResponseAsync(reqId: request.Id, result: response, cancellationToken).ConfigureAwait(false);
- CompleteRequest(ref _clientToServerRequests, request.Id, completion => completion.TrySetResult(response));
+ testUpdateCompletionSent = await SendTestUpdateCompleteIfNeededAsync(request, cancellationToken).ConfigureAwait(false);
+ await SendResponseAsync(
+ reqId: request.Id,
+ result: response,
+ cancellationToken,
+ stringId: request.StringId).ConfigureAwait(false);
+ if (isInitializeRequest)
+ {
+ CompleteInitialization();
+ }
+
+ CompleteRequest(
+ ref _clientToServerRequests,
+ GetRequestKey(request.Id, request.StringId),
+ completion => completion.TrySetResult(response));
}
catch (OperationCanceledException e)
{
- // We don't return the stack of the exception if we're canceling the single request because it's expected and it's not an exception.
- (string errorMessage, int errorCode) = rpcState.CancellationToken.IsCancellationRequested
- ? (string.Empty, ErrorCodes.RequestCanceled)
- : (e.ToString(), ErrorCodes.RequestCanceled);
+ TaskCompletionSource? failedInitialization = isInitializeRequest
+ ? MakeInitializationRetryable(
+ GetRequestKey(request.Id, request.StringId),
+ rpcState)
+ : null;
+ try
+ {
+ if (!testUpdateCompletionSent)
+ {
+ await SendTestUpdateCompleteIfNeededAsync(request, cancellationToken, bestEffort: true).ConfigureAwait(false);
+ }
+
+ // We don't return the stack of the exception if we're canceling the single request because it's expected and it's not an exception.
+ (string errorMessage, int errorCode) = rpcState.IsCancellationRequested
+ ? (string.Empty, ErrorCodes.RequestCanceled)
+ : (e.ToString(), ErrorCodes.RequestCanceled);
+
+ await SendErrorAsync(
+ reqId: request.Id,
+ errorCode: errorCode,
+ message: errorMessage,
+ data: null,
+ cancellationToken,
+ stringId: request.StringId).ConfigureAwait(false);
+ }
+ finally
+ {
+ failedInitialization?.TrySetResult(false);
- await SendErrorAsync(reqId: request.Id, errorCode: errorCode, message: errorMessage, data: null, cancellationToken).ConfigureAwait(false);
- CompleteRequest(ref _clientToServerRequests, request.Id, completion => completion.TrySetCanceled());
+ CompleteFailedRequest(
+ isInitializeRequest,
+ GetRequestKey(request.Id, request.StringId),
+ rpcState,
+ completion => completion.TrySetCanceled());
+ }
}
catch (JsonRpcException e)
{
- await SendErrorAsync(reqId: request.Id, errorCode: e.ErrorCode, message: e.Message, data: null, cancellationToken).ConfigureAwait(false);
- CompleteRequest(ref _clientToServerRequests, request.Id, completion => completion.TrySetException(e));
+ TaskCompletionSource? failedInitialization = isInitializeRequest
+ ? MakeInitializationRetryable(
+ GetRequestKey(request.Id, request.StringId),
+ rpcState)
+ : null;
+ try
+ {
+ if (!testUpdateCompletionSent)
+ {
+ await SendTestUpdateCompleteIfNeededAsync(request, cancellationToken, bestEffort: true).ConfigureAwait(false);
+ }
+
+ await SendErrorAsync(
+ reqId: request.Id,
+ errorCode: e.ErrorCode,
+ message: e.Message,
+ data: null,
+ cancellationToken,
+ stringId: request.StringId).ConfigureAwait(false);
+ }
+ finally
+ {
+ failedInitialization?.TrySetResult(false);
+
+ CompleteFailedRequest(
+ isInitializeRequest,
+ GetRequestKey(request.Id, request.StringId),
+ rpcState,
+ completion => completion.TrySetException(e));
+ }
}
catch (Exception e)
{
- await SendErrorAsync(reqId: request.Id, errorCode: 0, message: e.ToString(), data: null, cancellationToken).ConfigureAwait(false);
- CompleteRequest(ref _clientToServerRequests, request.Id, completion => completion.SetException(e));
+ TaskCompletionSource? failedInitialization = isInitializeRequest
+ ? MakeInitializationRetryable(
+ GetRequestKey(request.Id, request.StringId),
+ rpcState)
+ : null;
+ try
+ {
+ if (!testUpdateCompletionSent)
+ {
+ await SendTestUpdateCompleteIfNeededAsync(request, cancellationToken, bestEffort: true).ConfigureAwait(false);
+ }
+
+ await SendErrorAsync(
+ reqId: request.Id,
+ errorCode: ErrorCodes.InternalError,
+ message: e.ToString(),
+ data: null,
+ cancellationToken,
+ stringId: request.StringId).ConfigureAwait(false);
+ }
+ finally
+ {
+ failedInitialization?.TrySetResult(false);
+
+ CompleteFailedRequest(
+ isInitializeRequest,
+ GetRequestKey(request.Id, request.StringId),
+ rpcState,
+ completion => completion.TrySetException(e));
+ }
+ }
+ }
+ }
+
+ private void CompleteInitialization()
+ {
+ TaskCompletionSource? completionSource;
+ lock (_initializeStateLock)
+ {
+ _initializeState = Initialized;
+ completionSource = _initializationCompletionSource;
+ _initializationCompletionSource = null;
+ }
+
+ completionSource?.TrySetResult(true);
+ }
+
+ private TaskCompletionSource? MakeInitializationRetryable(
+ (int Id, bool IsString) requestKey,
+ RpcInvocationState rpcState)
+ {
+ lock (_initializeStateLock)
+ {
+ RoslynDebug.Assert(_initializeState == Initializing);
+ RoslynDebug.Assert(_initializationCompletionSource is not null);
+ bool requestDetached = ((ICollection>)_clientToServerRequests)
+ .Remove(new(requestKey, rpcState));
+ RoslynDebug.Assert(requestDetached);
+ _initializeState = NotInitialized;
+ TaskCompletionSource? completionSource = _initializationCompletionSource;
+ _initializationCompletionSource = null;
+ return completionSource;
+ }
+ }
+
+ private void CompleteFailedRequest(
+ bool requestWasDetached,
+ (int Id, bool IsString) requestKey,
+ RpcInvocationState rpcState,
+ Action> completion)
+ {
+ if (!requestWasDetached)
+ {
+ CompleteRequest(ref _clientToServerRequests, requestKey, completion);
+ return;
+ }
+
+ try
+ {
+ completion(rpcState.CompletionSource);
+ rpcState.Dispose();
+ if (_clientToServerRequests.IsEmpty && _serverClosingTokenSource.IsCancellationRequested)
+ {
+ _stopMessageHandler.Cancel();
}
}
+ finally
+ {
+ _requestCounter.Signal();
+ }
+ }
+
+ private async Task SendTestUpdateCompleteIfNeededAsync(
+ RequestMessage request,
+ CancellationToken cancellationToken,
+ bool bestEffort = false)
+ {
+ if (request.Params is not RequestArgsBase args)
+ {
+ return false;
+ }
+
+ await SendTestUpdateCompleteAsync(args.RunId, cancellationToken, bestEffort).ConfigureAwait(false);
+ return true;
}
private void CompleteRequest(
- ref ConcurrentDictionary rpcStates,
- int reqId,
+ ref ConcurrentDictionary<(int Id, bool IsString), RpcInvocationState> rpcStates,
+ (int Id, bool IsString) requestKey,
Action> completion)
{
try
{
- if (rpcStates.TryRemove(reqId, out RpcInvocationState? completedInvocation))
+ if (rpcStates.TryRemove(requestKey, out RpcInvocationState? completedInvocation))
{
completion(completedInvocation.CompletionSource);
completedInvocation.Dispose();
@@ -233,6 +523,9 @@ private void CompleteRequest(
}
}
+ private static (int Id, bool IsString) GetRequestKey(int id, string? stringId)
+ => (id, stringId is not null);
+
private sealed class RpcInvocationState : IDisposable
{
#if NET9_0_OR_GREATER
@@ -242,6 +535,7 @@ private sealed class RpcInvocationState : IDisposable
#endif
private readonly CancellationTokenSource _cancellationTokenSource = new();
private volatile bool _isDisposed;
+ private int _cancellationRequested;
///
/// For outbound requests, this is populated with the response from the client.
@@ -253,6 +547,20 @@ private sealed class RpcInvocationState : IDisposable
// We don't expose directly the source because we need to synchronize the complete/cancel
public CancellationToken CancellationToken => _cancellationTokenSource.Token;
+ public bool IsCancellationRequested
+ => Volatile.Read(ref _cancellationRequested) != 0 || _cancellationTokenSource.IsCancellationRequested;
+
+ public bool TryRequestCancellation()
+ => Interlocked.Exchange(ref _cancellationRequested, 1) == 0;
+
+ public void ThrowIfCancellationRequested()
+ {
+ if (IsCancellationRequested)
+ {
+ throw new OperationCanceledException(CancellationToken);
+ }
+ }
+
public AggregateException? CancelRequest()
{
if (!_isDisposed)
diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.Messaging.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.Messaging.cs
index a986671cbb..5409b42abc 100644
--- a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.Messaging.cs
+++ b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.Messaging.cs
@@ -9,10 +9,16 @@ namespace Microsoft.Testing.Platform.Hosts;
internal sealed partial class ServerTestHost
{
- private async Task SendErrorAsync(int reqId, int errorCode, string message, object? data, CancellationToken cancellationToken)
+ private async Task SendErrorAsync(
+ int reqId,
+ int errorCode,
+ string message,
+ object? data,
+ CancellationToken cancellationToken,
+ string? stringId = null)
{
AssertInitialized();
- ErrorMessage error = new(reqId, errorCode, message, data);
+ ErrorMessage error = new(reqId, errorCode, message, data) { StringId = stringId };
using (await _messageMonitor.LockAsync(cancellationToken).ConfigureAwait(false))
{
@@ -20,10 +26,14 @@ private async Task SendErrorAsync(int reqId, int errorCode, string message, obje
}
}
- private async Task SendResponseAsync(int reqId, object result, CancellationToken cancellationToken)
+ private async Task SendResponseAsync(
+ int reqId,
+ object result,
+ CancellationToken cancellationToken,
+ string? stringId = null)
{
AssertInitialized();
- ResponseMessage response = new(reqId, result);
+ ResponseMessage response = new(reqId, result) { StringId = stringId };
using (await _messageMonitor.LockAsync(cancellationToken).ConfigureAwait(false))
{
@@ -91,13 +101,21 @@ private void QueueLog(LogLevel logLevel, string message)
=> _ = Task.Run(() => TryLogAsync(logLevel, message));
internal Task SendTestUpdateCompleteAsync(Guid runId, CancellationToken cancellationToken)
- => SendTestUpdateAsync(new TestNodeStateChangedEventArgs(runId, Changes: null), cancellationToken);
+ => SendTestUpdateCompleteAsync(runId, cancellationToken, bestEffort: false);
+
+ private Task SendTestUpdateCompleteAsync(Guid runId, CancellationToken cancellationToken, bool bestEffort)
+ => SendTestUpdateAsync(new TestNodeStateChangedEventArgs(runId, Changes: null), cancellationToken, bestEffort);
public Task SendTestUpdateAsync(TestNodeStateChangedEventArgs update, CancellationToken cancellationToken)
+ => SendTestUpdateAsync(update, cancellationToken, bestEffort: false);
+
+ private Task SendTestUpdateAsync(TestNodeStateChangedEventArgs update, CancellationToken cancellationToken, bool bestEffort)
=> SendMessageAsync(
method: JsonRpcMethods.TestingTestUpdatesTests,
@params: update,
- cancellationToken);
+ cancellationToken,
+ checkServerExit: bestEffort,
+ rethrowException: !bestEffort);
public Task SendTelemetryEventUpdateAsync(TelemetryEventArgs args, CancellationToken cancellationToken)
=> SendMessageAsync(
diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.RequestExecution.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.RequestExecution.cs
index 395c28fec7..5deb48add0 100644
--- a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.RequestExecution.cs
+++ b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.RequestExecution.cs
@@ -18,15 +18,6 @@ internal sealed partial class ServerTestHost
{
private async Task
-internal sealed record ResponseMessage(int Id, object? Result) : RpcMessage;
+internal sealed record ResponseMessage(int Id, object? Result) : RpcMessage
+{
+ public string? StringId { get; init; }
+}
-internal sealed record InitializeRequestArgs(int ProcessId, ClientInfo ClientInfo, ClientCapabilities Capabilities);
+internal sealed record InitializeRequestArgs(int ProcessId, ClientInfo ClientInfo, ClientCapabilities Capabilities)
+{
+ public string[]? ProtocolVersions { get; init; }
+}
-internal sealed record InitializeResponseArgs(int? ProcessId, ServerInfo ServerInfo, ServerCapabilities Capabilities);
+internal sealed record InitializeResponseArgs(int? ProcessId, ServerInfo ServerInfo, ServerCapabilities Capabilities)
+{
+ public string? ProtocolVersion { get; init; }
+}
internal record RequestArgsBase(Guid RunId, ICollection? TestNodes, string? GraphFilter);
@@ -77,7 +92,10 @@ public override string ToString()
internal sealed record Artifact(string Uri, string Producer, string Type, string DisplayName, string? Description = null);
-internal sealed record CancelRequestArgs(int CancelRequestId);
+internal sealed record CancelRequestArgs(int CancelRequestId)
+{
+ public string? StringId { get; init; }
+}
internal sealed record ExitRequestArgs;
@@ -103,7 +121,9 @@ internal sealed record ServerTestingCapabilities(
bool SupportsAttachments,
bool MultiConnectionProvider)
{
- public static bool SupportsTestCoverageMessages => true;
+ // This capability describes JSON-RPC wire forwarding, not in-process coverage-message consumption.
+ // Keep it false until server mode forwards the first-class messages defined by RFC 019.
+ public static bool SupportsTestCoverageMessages => false;
}
internal sealed record TestNodeStateChangedEventArgs(Guid RunId, TestNodeUpdateMessage[]? Changes)
diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.Deserializers.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.Deserializers.cs
index 3bcfb8d72a..f470cab938 100644
--- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.Deserializers.cs
+++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.Deserializers.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
// Note: System.Text.Json is only available in .NET 6.0 and above.
@@ -7,7 +7,6 @@
using Jsonite;
#endif
using Microsoft.Testing.Platform.Extensions.Messages;
-using Microsoft.Testing.Platform.Helpers;
namespace Microsoft.Testing.Platform.ServerMode;
@@ -24,46 +23,63 @@ private static void RegisterDeserializers()
{
string method = (string)methodObj;
- object? idObj = GetOptionalPropertyFromJson(properties, JsonRpcStrings.Id);
-
- IDictionary paramsObj = method != JsonRpcMethods.Exit
- ? GetRequiredPropertyFromJson>(properties, JsonRpcStrings.Params)
- : new Dictionary();
+ bool hasId = properties.TryGetValue(JsonRpcStrings.Id, out object? idObj);
+ if (hasId && idObj is null)
+ {
+ throw new MessageFormatException($"'{JsonRpcStrings.Id}' field cannot be null");
+ }
- int? id = idObj is null
+ int? id = !hasId
? null
: GetIdFromJson(idObj) ?? throw new MessageFormatException("id field should be a string or an int");
+ string? stringId = idObj as string;
object? @params;
- try
+ object? rawParams = GetOptionalPropertyFromJson(properties, JsonRpcStrings.Params);
+ bool paramsRequired = method is JsonRpcMethods.Initialize
+ or JsonRpcMethods.TestingDiscoverTests
+ or JsonRpcMethods.TestingRunTests
+ or JsonRpcMethods.CancelRequest;
+ if (paramsRequired && rawParams is not IDictionary)
{
- // Parse the specific methods
- @params = method switch
- {
- JsonRpcMethods.Initialize => Deserialize(paramsObj),
- JsonRpcMethods.TestingDiscoverTests => Deserialize(paramsObj),
- JsonRpcMethods.TestingRunTests => Deserialize(paramsObj),
- JsonRpcMethods.CancelRequest => Deserialize(paramsObj),
- JsonRpcMethods.Exit => Deserialize(paramsObj),
-
- // Note: Let the server report unknown RPC request back to the client.
- _ => null,
- };
+ @params = new InvalidRequestParamsArgs(
+ ErrorCodes.InvalidParams,
+ rawParams is null ? "'params' field is missing" : "'params' field has wrong type (expected Object)");
}
- catch (Exception ex) when (ex is MessageFormatException or InvalidCastException)
+ else
{
- // If params can't be deserialized for a request, capture the failure so
- // we can later send back a properly coded JSON-RPC error using the request id.
- // For notifications there's no one to respond to, but we still avoid
- // crashing the message-handling loop by swallowing into the sentinel.
- // We catch the broader set of deserialization-related exceptions because the
- // request payload is untrusted client input and the lower-level helpers can
- // throw types other than MessageFormatException.
- @params = new InvalidRequestParamsArgs(ErrorCodes.InvalidParams, ex.Message);
+ IDictionary paramsObj = rawParams as IDictionary ?? new Dictionary();
+ try
+ {
+ // Parse the specific methods
+ @params = method switch
+ {
+ JsonRpcMethods.Initialize => Deserialize(paramsObj),
+ JsonRpcMethods.TestingDiscoverTests => Deserialize(paramsObj),
+ JsonRpcMethods.TestingRunTests => Deserialize(paramsObj),
+ JsonRpcMethods.CancelRequest => Deserialize(paramsObj),
+ JsonRpcMethods.Exit => Deserialize(paramsObj),
+
+ // Preserve server-to-client notification params when this formatter is used by a
+ // client or protocol test, matching the System.Text.Json path.
+ _ => rawParams is IDictionary ? paramsObj : null,
+ };
+ }
+ catch (Exception ex) when (ex is MessageFormatException or InvalidCastException)
+ {
+ // If params can't be deserialized for a request, capture the failure so
+ // we can later send back a properly coded JSON-RPC error using the request id.
+ // For notifications there's no one to respond to, but we still avoid
+ // crashing the message-handling loop by swallowing into the sentinel.
+ // We catch the broader set of deserialization-related exceptions because the
+ // request payload is untrusted client input and the lower-level helpers can
+ // throw types other than MessageFormatException.
+ @params = new InvalidRequestParamsArgs(ErrorCodes.InvalidParams, ex.Message);
+ }
}
return id.HasValue
- ? new RequestMessage(id.Value, method, @params)
+ ? new RequestMessage(id.Value, method, @params) { StringId = stringId }
: new NotificationMessage(method, @params);
}
else if (properties.TryGetValue(JsonRpcStrings.Error, out object? errorObj))
@@ -80,7 +96,7 @@ private static void RegisterDeserializers()
int id = GetIdFromJson(idObj) ?? throw new MessageFormatException("id field should be a string or an int");
- return new ResponseMessage(id, paramsObj);
+ return new ResponseMessage(id, paramsObj) { StringId = idObj as string };
}
throw new MessageFormatException();
@@ -92,8 +108,28 @@ private static void RegisterDeserializers()
int processId = GetRequiredPropertyFromJson(properties, JsonRpcStrings.ProcessId);
ClientInfo clientInfo = Deserialize(properties);
ClientCapabilities capabilities = Deserialize(properties);
+ object? protocolVersionsValue = GetOptionalPropertyFromJson(properties, JsonRpcStrings.ProtocolVersions);
+ string[]? protocolVersions = null;
+ if (protocolVersionsValue is not null)
+ {
+ if (protocolVersionsValue is not ICollection protocolVersionsJson)
+ {
+ throw new MessageFormatException($"'{JsonRpcStrings.ProtocolVersions}' field has wrong type (expected Array)");
+ }
- return new InitializeRequestArgs(processId, clientInfo, capabilities);
+ protocolVersions = new string[protocolVersionsJson.Count];
+ int index = 0;
+ foreach (object? protocolVersion in protocolVersionsJson)
+ {
+ protocolVersions[index++] = protocolVersion as string
+ ?? throw new MessageFormatException($"'{JsonRpcStrings.ProtocolVersions}' entries must be strings");
+ }
+ }
+
+ return new InitializeRequestArgs(processId, clientInfo, capabilities)
+ {
+ ProtocolVersions = protocolVersions,
+ };
});
Deserializers[typeof(ClientInfo)] = new ObjectDeserializer(properties =>
@@ -120,8 +156,19 @@ private static void RegisterDeserializers()
int processId = GetRequiredPropertyFromJson(properties, JsonRpcStrings.ProcessId);
ServerInfo serverInfo = Deserialize(GetRequiredPropertyFromJson>(properties, JsonRpcStrings.ServerInfo));
ServerCapabilities capabilities = Deserialize(GetRequiredPropertyFromJson>(properties, JsonRpcStrings.Capabilities));
+ object? protocolVersionValue = GetOptionalPropertyFromJson(properties, JsonRpcStrings.ProtocolVersion);
+ string? protocolVersion = protocolVersionValue switch
+ {
+ null => null,
+ string value => value,
+ _ => throw new MessageFormatException(
+ $"'{JsonRpcStrings.ProtocolVersion}' field has wrong type (expected String)"),
+ };
- return new InitializeResponseArgs(processId, serverInfo, capabilities);
+ return new InitializeResponseArgs(processId, serverInfo, capabilities)
+ {
+ ProtocolVersion = protocolVersion,
+ };
});
Deserializers[typeof(ServerInfo)] = new ObjectDeserializer(properties =>
@@ -140,7 +187,6 @@ private static void RegisterDeserializers()
bool vstestProviderSupport = GetRequiredPropertyFromJson(testingCapabilities, JsonRpcStrings.VSTestProviderSupport);
bool attachmentsSupport = GetRequiredPropertyFromJson(testingCapabilities, JsonRpcStrings.AttachmentsSupport);
bool multiConnectionProvider = GetRequiredPropertyFromJson(testingCapabilities, JsonRpcStrings.MultiConnectionProvider);
-
return new ServerCapabilities(new ServerTestingCapabilities(
SupportsDiscovery: supportsDiscovery,
MultiRequestSupport: multiRequestSupport,
@@ -157,11 +203,8 @@ private static void RegisterDeserializers()
throw new MessageFormatException(JsonRpcStrings.InvalidRunIdErrorMessage);
}
- var testsJson = GetOptionalPropertyFromJson(properties, JsonRpcStrings.Tests) as ICollection;
-
- ICollection? tests = testsJson?.OfType>()?.Select(obj => Deserialize(obj)).ToList();
-
- string? filter = GetOptionalPropertyFromJson(properties, JsonRpcStrings.Filter) as string;
+ ICollection? tests = DeserializeOptionalTestNodes(properties);
+ string? filter = GetOptionalTypedProperty(properties, JsonRpcStrings.Filter);
return new DiscoverRequestArgs(runId, tests, filter);
});
@@ -174,10 +217,8 @@ private static void RegisterDeserializers()
throw new MessageFormatException(JsonRpcStrings.InvalidRunIdErrorMessage);
}
- var testsJson = GetOptionalPropertyFromJson(properties, JsonRpcStrings.Tests) as ICollection;
-
- ICollection? tests = testsJson?.OfType>().Select(obj => Deserialize(obj)).ToList();
- string? filter = GetOptionalPropertyFromJson(properties, JsonRpcStrings.Filter) as string;
+ ICollection? tests = DeserializeOptionalTestNodes(properties);
+ string? filter = GetOptionalTypedProperty(properties, JsonRpcStrings.Filter);
return new RunRequestArgs(runId, tests, filter);
});
@@ -185,37 +226,42 @@ private static void RegisterDeserializers()
Deserializers[typeof(TestNode)] = new ObjectDeserializer(
properties =>
{
- string uid = string.Empty;
- string displayName = string.Empty;
+ string uid = GetRequiredPropertyFromJson(properties, JsonRpcStrings.Uid);
+ string displayName = GetRequiredPropertyFromJson(properties, JsonRpcStrings.DisplayName);
+ if (RoslynString.IsNullOrWhiteSpace(uid))
+ {
+ throw new MessageFormatException($"'{JsonRpcStrings.Uid}' field cannot be empty or whitespace");
+ }
+
PropertyBag propertyBag = new();
- foreach (KeyValuePair kvp in properties)
+ if (properties.TryGetValue("location.file", out object? locationFileValue))
{
- if (kvp.Key == JsonRpcStrings.Uid)
+ if (locationFileValue is not string locationFile)
{
- uid = kvp.Value as string ?? string.Empty;
- continue;
+ throw new MessageFormatException("'location.file' field has wrong type (expected String)");
}
- if (kvp.Key == JsonRpcStrings.DisplayName)
+ bool hasLineStart = properties.TryGetValue("location.line-start", out object? locationLineStartValue);
+ bool hasLineEnd = properties.TryGetValue("location.line-end", out object? locationLineEndValue);
+ if (!hasLineStart || locationLineStartValue is not int locationLineStart
+ || !hasLineEnd || locationLineEndValue is not int locationLineEnd)
{
- displayName = kvp.Value as string ?? string.Empty;
- continue;
+ throw new MessageFormatException(
+ "'location.file', 'location.line-start', and 'location.line-end' fields must be specified together as strings and integers");
}
- }
- if (properties.TryGetValue("location.file", out object? location_file))
+ TestFileLocationProperty testFileLocationProperty = new(
+ locationFile,
+ new LinePositionSpan(
+ new LinePosition(locationLineStart, 0),
+ new LinePosition(locationLineEnd, 0)));
+ propertyBag.Add(testFileLocationProperty);
+ }
+ else if (properties.ContainsKey("location.line-start") || properties.ContainsKey("location.line-end"))
{
- ApplicationStateGuard.Ensure(location_file is not null);
- if (properties.TryGetValue("location.line-start", out object? location_lineStart) && properties.TryGetValue("location.line-end", out object? location_lineEnd))
- {
- ApplicationStateGuard.Ensure(location_lineStart is not null);
- ApplicationStateGuard.Ensure(location_lineEnd is not null);
- TestFileLocationProperty testFileLocationProperty = new(
- (string)location_file,
- new LinePositionSpan(new LinePosition((int)location_lineStart, 0), new LinePosition((int)location_lineEnd, 0)));
- propertyBag.Add(testFileLocationProperty);
- }
+ throw new MessageFormatException(
+ "'location.file', 'location.line-start', and 'location.line-end' fields must be specified together");
}
return new TestNode
@@ -231,7 +277,7 @@ private static void RegisterDeserializers()
object? idObj = GetOptionalPropertyFromJson(properties, JsonRpcStrings.Id);
int id = GetIdFromJson(idObj) ?? throw new MessageFormatException("id field should be a string or an int");
- return new CancelRequestArgs(id);
+ return new CancelRequestArgs(id) { StringId = idObj as string };
});
Deserializers[typeof(ExitRequestArgs)] = new ObjectDeserializer(_ => new ExitRequestArgs());
@@ -272,7 +318,45 @@ private static void RegisterDeserializers()
Id: id,
ErrorCode: code,
Message: errorMessage,
- Data: data);
+ Data: data)
+ {
+ StringId = idObj as string,
+ };
});
}
+
+ private static ICollection? DeserializeOptionalTestNodes(IDictionary properties)
+ {
+ ICollection? testsJson = GetOptionalTypedProperty>(properties, JsonRpcStrings.Tests);
+ if (testsJson is null)
+ {
+ return null;
+ }
+
+ List tests = [];
+ foreach (object? testJson in testsJson)
+ {
+ if (testJson is not IDictionary testProperties)
+ {
+ throw new MessageFormatException($"'{JsonRpcStrings.Tests}' entries must be objects");
+ }
+
+ tests.Add(Deserialize(testProperties));
+ }
+
+ return tests;
+ }
+
+ private static T? GetOptionalTypedProperty(IDictionary properties, string propertyName)
+ where T : class
+ {
+ object? value = GetOptionalPropertyFromJson(properties, propertyName);
+ return value switch
+ {
+ null => null,
+ T typed => typed,
+ _ => throw new MessageFormatException(
+ $"'{propertyName}' field has wrong type (expected {typeof(T).Name})"),
+ };
+ }
}
diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.RpcMessageSerializers.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.RpcMessageSerializers.cs
index c1c835c7eb..318009163c 100644
--- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.RpcMessageSerializers.cs
+++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.RpcMessageSerializers.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
// Note: System.Text.Json is only available in .NET 6.0 and above.
@@ -19,7 +19,7 @@ private static void RegisterRpcMessageSerializers()
Dictionary values = new()
{
[JsonRpcStrings.JsonRpc] = "2.0",
- [JsonRpcStrings.Id] = req.Id,
+ [JsonRpcStrings.Id] = req.StringId ?? (object)req.Id,
[JsonRpcStrings.Method] = req.Method,
[JsonRpcStrings.Params] = req.Params is null ? null : SerializeObject(req.Params),
};
@@ -32,7 +32,7 @@ private static void RegisterRpcMessageSerializers()
Dictionary values = new()
{
[JsonRpcStrings.JsonRpc] = "2.0",
- [JsonRpcStrings.Id] = res.Id,
+ [JsonRpcStrings.Id] = res.StringId ?? (object)res.Id,
[JsonRpcStrings.Result] = res.Result is null ? null : SerializeObject(res.Result),
};
@@ -56,7 +56,7 @@ private static void RegisterRpcMessageSerializers()
Dictionary values = new()
{
[JsonRpcStrings.JsonRpc] = "2.0",
- [JsonRpcStrings.Id] = error.Id,
+ [JsonRpcStrings.Id] = error.StringId ?? (object)error.Id,
[JsonRpcStrings.Error] = new Dictionary
{
[JsonRpcStrings.Code] = error.ErrorCode,
@@ -77,6 +77,11 @@ private static void RegisterRpcMessageSerializers()
[JsonRpcStrings.Capabilities] = Serialize(res.Capabilities),
};
+ if (res.ProtocolVersion is not null)
+ {
+ values[JsonRpcStrings.ProtocolVersion] = res.ProtocolVersion;
+ }
+
return values;
});
@@ -121,7 +126,7 @@ private static void RegisterRpcMessageSerializers()
{
Dictionary values = new()
{
- [JsonRpcStrings.Id] = ev.CancelRequestId,
+ [JsonRpcStrings.Id] = ev.StringId ?? (object)ev.CancelRequestId,
};
return values;
diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.cs
index 6c6b66e31a..ea1461d3cc 100644
--- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.cs
+++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.cs
@@ -80,7 +80,11 @@ private static T GetRequiredPropertyFromJson(IDictionary pro
=> idObj switch
{
int idInt => idInt,
- string idStr => int.TryParse(idStr, out int id)
+ decimal idDecimal when idDecimal == decimal.Truncate(idDecimal)
+ && idDecimal >= int.MinValue
+ && idDecimal <= int.MaxValue => decimal.ToInt32(idDecimal),
+ string idStr => int.TryParse(idStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out int id)
+ && idStr == id.ToString(CultureInfo.InvariantCulture)
? id
: null,
_ => null,
diff --git a/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/FakeMtpServer.cs b/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/FakeMtpServer.cs
index e476fd556d..d4fec7b565 100644
--- a/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/FakeMtpServer.cs
+++ b/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/FakeMtpServer.cs
@@ -69,7 +69,10 @@ public FakeMtpServer()
MultiRequestSupport: true,
VSTestProviderSupport: false,
SupportsAttachments: true,
- MultiConnectionProvider: false)));
+ MultiConnectionProvider: false)))
+ {
+ ProtocolVersion = JsonRpcProtocolVersions.Current,
+ };
_ = Task.Run(AcceptAndServeAsync);
}
@@ -80,6 +83,9 @@ public FakeMtpServer()
/// Gets or sets the response returned for an initialize request.
public InitializeResponseArgs InitializeResponse { get; set; }
+ /// Gets or sets a raw response override for malformed-response tests.
+ public object? InitializeResponseOverride { get; set; }
+
/// Gets or sets the response returned for a testing/runTests request.
public RunResponseArgs RunResponse { get; set; } = new RunResponseArgs([]);
@@ -220,7 +226,7 @@ public Task SendAttachmentAsync(string uri, string producer, string type, string
/// answers. Params are null because the client keeps request params as a raw dictionary and the tests only
/// assert on the method name and the returned result.
///
- public Task SendServerRequestAsync(string method)
+ public Task SendServerRequestAsync(string method, bool useStringId = false)
{
int id = Interlocked.Increment(ref _nextServerRequestId);
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -229,10 +235,43 @@ public Task SendServerRequestAsync(string method)
_pendingServerRequests[id] = tcs;
}
- _ = WriteAsync(new RequestMessage(id, method, null));
+ _ = WriteAsync(new RequestMessage(id, method, null)
+ {
+ StringId = useStringId ? id.ToString(CultureInfo.InvariantCulture) : null,
+ });
return tcs.Task;
}
+ /// Waits for and returns a client request with the given method.
+ public async Task WaitForRequestAsync(string method, TimeSpan timeout)
+ {
+ DateTime deadline = DateTime.UtcNow + timeout;
+ while (DateTime.UtcNow < deadline)
+ {
+ lock (_receivedRequestsLock)
+ {
+ foreach (RequestMessage request in _receivedRequests)
+ {
+ if (request.Method == method)
+ {
+ return request;
+ }
+ }
+ }
+
+ await Task.Delay(15).ConfigureAwait(false);
+ }
+
+ throw new TimeoutException($"Timed out waiting for a '{method}' request from the client.");
+ }
+
+ /// Sends the configured run response using the request's numeric or numeric-string ID form.
+ public Task SendRunResponseAsync(RequestMessage request, bool useStringId)
+ => WriteAsync(new ResponseMessage(request.Id, RunResponse)
+ {
+ StringId = useStringId ? request.Id.ToString(CultureInfo.InvariantCulture) : null,
+ });
+
///
/// Writes a raw, pre-framed body to the client so a test can inject a malformed message. The
/// Content-Length header is computed from the UTF-8 body so the client reads exactly this body.
@@ -424,7 +463,7 @@ private async Task HandleClientRequestAsync(RequestMessage request)
object? result;
if (request.Method == JsonRpcMethods.Initialize)
{
- result = InitializeResponse;
+ result = InitializeResponseOverride ?? InitializeResponse;
}
else if (request.Method == JsonRpcMethods.TestingDiscoverTests)
{
diff --git a/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/MtpServerClientTests.cs b/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/MtpServerClientTests.cs
index c44c9c1e2f..257cfa89af 100644
--- a/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/MtpServerClientTests.cs
+++ b/test/UnitTests/Microsoft.Testing.Platform.ServerMode.Client.Sources.UnitTests/MtpServerClientTests.cs
@@ -35,12 +35,108 @@ public async Task InitializeAsync_DecodesServerCapabilities()
Assert.AreEqual(4242, capabilities.ServerProcessId);
Assert.AreEqual("FakeMtpServer", capabilities.ServerName);
Assert.AreEqual("1.2.3", capabilities.ServerVersion);
+ Assert.AreEqual(JsonRpcProtocolVersions.Current, capabilities.ProtocolVersion);
Assert.IsTrue(capabilities.SupportsDiscovery);
Assert.IsTrue(capabilities.MultiRequestSupport);
Assert.IsFalse(capabilities.VSTestProviderSupport);
Assert.IsTrue(capabilities.SupportsAttachments);
Assert.IsFalse(capabilities.MultiConnectionProvider);
Assert.AreSame(capabilities, client.Capabilities);
+
+ InitializeRequestArgs initializeArgs = GetSingleRequestParams(server, JsonRpcMethods.Initialize);
+ Assert.AreSequenceEqual(JsonRpcProtocolVersions.Supported, initializeArgs.ProtocolVersions);
+ }
+
+ [TestMethod]
+ public async Task InitializeAsync_LegacyServerWithoutProtocolVersion_Succeeds()
+ {
+ using FakeMtpServer server = new();
+ server.InitializeResponse = server.InitializeResponse with { ProtocolVersion = null };
+ using MtpServerClient client = server.ConnectClient();
+
+ MtpServerCapabilities capabilities = await WithTimeoutAsync(client.InitializeAsync(TestContext.CancellationToken)).ConfigureAwait(false);
+
+ Assert.IsNull(capabilities.ProtocolVersion);
+ }
+
+ [TestMethod]
+ public async Task InitializeAsync_LegacyServerWithoutSupportedVersion_Throws()
+ {
+ using FakeMtpServer server = new();
+ server.InitializeResponse = server.InitializeResponse with { ProtocolVersion = null };
+ using MtpServerClient client = server.ConnectClient(new MtpServerClientOptions
+ {
+ SupportedProtocolVersions = ["2.0.0"],
+ });
+
+ MtpServerClientException exception = await AssertThrowsAsync(
+ () => client.InitializeAsync(TestContext.CancellationToken)).ConfigureAwait(false);
+
+ Assert.Contains(JsonRpcProtocolVersions.V1, exception.Message);
+ Assert.IsNull(client.Capabilities);
+ }
+
+ [TestMethod]
+ public async Task InitializeAsync_UnsupportedNegotiatedProtocolVersion_Throws()
+ {
+ using FakeMtpServer server = new();
+ server.InitializeResponse = server.InitializeResponse with { ProtocolVersion = "2.0.0" };
+ using MtpServerClient client = server.ConnectClient();
+
+ MtpServerClientException exception = await AssertThrowsAsync(
+ () => client.InitializeAsync(TestContext.CancellationToken)).ConfigureAwait(false);
+
+ Assert.Contains("2.0.0", exception.Message);
+ Assert.IsNull(client.Capabilities);
+ }
+
+ [TestMethod]
+ public async Task InitializeAsync_EmptySupportedVersions_AcceptsLegacyVersion()
+ {
+ using FakeMtpServer server = new();
+ using MtpServerClient client = server.ConnectClient(new MtpServerClientOptions
+ {
+ SupportedProtocolVersions = [],
+ });
+
+ MtpServerCapabilities capabilities = await WithTimeoutAsync(
+ client.InitializeAsync(TestContext.CancellationToken)).ConfigureAwait(false);
+
+ Assert.AreEqual(JsonRpcProtocolVersions.V1, capabilities.ProtocolVersion);
+ }
+
+ [TestMethod]
+ public async Task InitializeAsync_NonStringProtocolVersion_Throws()
+ {
+ using FakeMtpServer server = new();
+ server.InitializeResponseOverride = new Dictionary
+ {
+ [JsonRpcStrings.ProcessId] = 4242,
+ [JsonRpcStrings.ServerInfo] = new Dictionary
+ {
+ [JsonRpcStrings.Name] = "FakeMtpServer",
+ [JsonRpcStrings.Version] = "1.2.3",
+ },
+ [JsonRpcStrings.Capabilities] = new Dictionary
+ {
+ [JsonRpcStrings.Testing] = new Dictionary
+ {
+ [JsonRpcStrings.SupportsDiscovery] = true,
+ [JsonRpcStrings.MultiRequestSupport] = true,
+ [JsonRpcStrings.VSTestProviderSupport] = false,
+ [JsonRpcStrings.AttachmentsSupport] = true,
+ [JsonRpcStrings.MultiConnectionProvider] = false,
+ },
+ },
+ [JsonRpcStrings.ProtocolVersion] = 1,
+ };
+ using MtpServerClient client = server.ConnectClient();
+
+ MtpServerClientException exception = await AssertThrowsAsync(
+ () => client.InitializeAsync(TestContext.CancellationToken)).ConfigureAwait(false);
+
+ Assert.Contains(JsonRpcStrings.ProtocolVersion, exception.Message);
+ Assert.IsNull(client.Capabilities);
}
[TestMethod]
@@ -414,6 +510,25 @@ public async Task RunTestsAsync_Cancellation_SendsCancelRequestAndThrows()
await server.WaitForNotificationAsync(JsonRpcMethods.CancelRequest, DefaultTimeout).ConfigureAwait(false);
}
+ [TestMethod]
+ public async Task RunTestsAsync_NumericStringResponseId_DoesNotCompleteNumericRequest()
+ {
+ using FakeMtpServer server = new() { WithholdRunResponse = true };
+ using MtpServerClient client = await ConnectAndInitializeAsync(server).ConfigureAwait(false);
+
+ Task runTask = client.RunTestsAsync(TestContext.CancellationToken);
+ RequestMessage request = await server.WaitForRequestAsync(JsonRpcMethods.TestingRunTests, DefaultTimeout).ConfigureAwait(false);
+ Task responseProcessed = WaitForEventAsync(handler => client.LogReceived += handler);
+
+ await server.SendRunResponseAsync(request, useStringId: true).ConfigureAwait(false);
+ await server.SendLogAsync("response barrier").ConfigureAwait(false);
+ _ = await WithTimeoutAsync(responseProcessed).ConfigureAwait(false);
+ Assert.IsFalse(runTask.IsCompleted);
+
+ await server.SendRunResponseAsync(request, useStringId: false).ConfigureAwait(false);
+ _ = await WithTimeoutAsync(runTask).ConfigureAwait(false);
+ }
+
[TestMethod]
public async Task ReadLoop_MalformedFrame_FailsPendingRequestWithClientException()
{
@@ -457,6 +572,18 @@ public async Task ServerInitiatedRequest_NoHandler_RespondsWithNull()
Assert.IsNull(response.Result);
}
+ [TestMethod]
+ public async Task ServerInitiatedRequest_NumericStringId_PreservesResponseIdRepresentation()
+ {
+ using FakeMtpServer server = new();
+ using MtpServerClient client = await ConnectAndInitializeAsync(server).ConfigureAwait(false);
+
+ ResponseMessage response = await WithTimeoutAsync(
+ server.SendServerRequestAsync(ClientAttachDebuggerMethod, useStringId: true)).ConfigureAwait(false);
+
+ Assert.AreEqual(response.Id.ToString(CultureInfo.InvariantCulture), response.StringId);
+ }
+
[TestMethod]
public async Task ServerInitiatedRequest_WithHandler_InvokesHandler()
{
diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/FormatterUtilitiesTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/FormatterUtilitiesTests.cs
index fdc2d5af6b..507c5a61bf 100644
--- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/FormatterUtilitiesTests.cs
+++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/FormatterUtilitiesTests.cs
@@ -104,6 +104,297 @@ public void CanDeserializeTaskResponse()
Assert.IsNull(response.Result);
}
+ [TestMethod]
+ public void CanDeserializeNumericStringRequestId()
+ {
+ RpcMessage message = Deserialize(
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": "42",
+ "method": "testing/unknown"
+ }
+ """);
+
+ RequestMessage request = Assert.IsInstanceOfType(message);
+ Assert.AreEqual(42, request.Id);
+ Assert.AreEqual("42", request.StringId);
+ }
+
+ [DataRow("1.0", 1)]
+ [DataRow("1e0", 1)]
+ [DataRow("-2.000", -2)]
+ [TestMethod]
+ public void CanDeserializeIntegralNumericRequestId(string serializedId, int expectedId)
+ {
+ RpcMessage message = Deserialize(
+ $$"""
+ {
+ "jsonrpc": "2.0",
+ "id": {{serializedId}},
+ "method": "testing/unknown"
+ }
+ """);
+
+ RequestMessage request = Assert.IsInstanceOfType(message);
+ Assert.AreEqual(expectedId, request.Id);
+ Assert.IsNull(request.StringId);
+ }
+
+ [DataRow("1.00000000000000001")]
+ [DataRow("1.0000000000000000000000000000001")]
+ [DataRow("1e-1")]
+ [DataRow("2147483648.0")]
+ [TestMethod]
+ public void RejectsNonIntegralOrOutOfRangeNumericRequestId(string serializedId)
+ => Assert.ThrowsExactly(() => Deserialize(
+ $$"""
+ {
+ "jsonrpc": "2.0",
+ "id": {{serializedId}},
+ "method": "testing/unknown"
+ }
+ """));
+
+ [TestMethod]
+ public async Task NumericStringId_IsPreservedInResponsesAndErrors()
+ {
+ ResponseMessage response = new(42, Result: null) { StringId = "42" };
+ ErrorMessage error = new(42, ErrorCodes.InvalidRequest, "invalid", Data: null) { StringId = "42" };
+
+ string serializedResponse = (await _formatter.SerializeAsync(response)).Replace(" ", string.Empty);
+ string serializedError = (await _formatter.SerializeAsync(error)).Replace(" ", string.Empty);
+
+ Assert.Contains("\"id\":\"42\"", serializedResponse);
+ Assert.Contains("\"id\":\"42\"", serializedError);
+ }
+
+ [TestMethod]
+ public void CanDeserializeNumericStringCancellationId()
+ {
+ RpcMessage message = Deserialize(
+ """
+ {
+ "jsonrpc": "2.0",
+ "method": "$/cancelRequest",
+ "params": {
+ "id": "42"
+ }
+ }
+ """);
+
+ NotificationMessage notification = Assert.IsInstanceOfType(message);
+ CancelRequestArgs args = Assert.IsInstanceOfType(notification.Params);
+ Assert.AreEqual(42, args.CancelRequestId);
+ Assert.AreEqual("42", args.StringId);
+ }
+
+ [TestMethod]
+ public void CanDeserializeIntegralNumericCancellationId()
+ {
+ RpcMessage message = Deserialize(
+ """
+ {
+ "jsonrpc": "2.0",
+ "method": "$/cancelRequest",
+ "params": {
+ "id": 42.0
+ }
+ }
+ """);
+
+ NotificationMessage notification = Assert.IsInstanceOfType(message);
+ CancelRequestArgs args = Assert.IsInstanceOfType(notification.Params);
+ Assert.AreEqual(42, args.CancelRequestId);
+ Assert.IsNull(args.StringId);
+ }
+
+ [TestMethod]
+ public void NullRequestId_IsRejected()
+ => Assert.ThrowsExactly(() => Deserialize(
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": null,
+ "method": "testing/unknown"
+ }
+ """));
+
+ [TestMethod]
+ public void DeserializeInitializeRequest_NullProtocolVersions_UsesLegacyNegotiation()
+ {
+ RpcMessage message = Deserialize(
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "processId": 32,
+ "clientInfo": { "name": "test-client", "version": "1.0.0" },
+ "capabilities": {
+ "testing": {
+ "debuggerProvider": false
+ }
+ },
+ "protocolVersions": null
+ }
+ }
+ """);
+
+ InitializeRequestArgs request = Assert.IsInstanceOfType(message).Params
+ as InitializeRequestArgs
+ ?? throw new InvalidOperationException("Expected typed initialize request arguments.");
+ Assert.IsNull(request.ProtocolVersions);
+ }
+
+ [DataRow("\"1.0.0\"")]
+ [DataRow("[1]")]
+ [DataRow("[null]")]
+ [TestMethod]
+ public void DeserializeInitializeRequest_InvalidProtocolVersions_CapturesInvalidParams(string protocolVersions)
+ {
+ RpcMessage message = Deserialize(
+ $$"""
+ {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "processId": 32,
+ "clientInfo": { "name": "test-client", "version": "1.0.0" },
+ "capabilities": {
+ "testing": {
+ "debuggerProvider": false
+ }
+ },
+ "protocolVersions": {{protocolVersions}}
+ }
+ }
+ """);
+
+ RequestMessage request = Assert.IsInstanceOfType(message);
+ InvalidRequestParamsArgs invalidParams = Assert.IsInstanceOfType(request.Params);
+ Assert.AreEqual(ErrorCodes.InvalidParams, invalidParams.ErrorCode);
+ }
+
+ [DataRow(JsonRpcMethods.Initialize)]
+ [DataRow(JsonRpcMethods.TestingDiscoverTests)]
+ [DataRow(JsonRpcMethods.TestingRunTests)]
+ [DataRow(JsonRpcMethods.CancelRequest)]
+ [TestMethod]
+ public void DeserializeKnownRequest_MissingParams_CapturesInvalidParams(string method)
+ {
+ RpcMessage message = Deserialize(
+ $$"""
+ {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "{{method}}"
+ }
+ """);
+
+ RequestMessage request = Assert.IsInstanceOfType(message);
+ InvalidRequestParamsArgs invalidParams = Assert.IsInstanceOfType(request.Params);
+ Assert.AreEqual(ErrorCodes.InvalidParams, invalidParams.ErrorCode);
+ }
+
+ [TestMethod]
+ public void DeserializeUnknownNotification_NonObjectParams_DropsParams()
+ {
+ RpcMessage message = Deserialize(
+ """
+ {
+ "jsonrpc": "2.0",
+ "method": "testing/unknown",
+ "params": "not-an-object"
+ }
+ """);
+
+ Assert.IsNull(Assert.IsInstanceOfType(message).Params);
+ }
+
+ [DataRow("\"filter\": 42")]
+ [DataRow("\"tests\": \"not-an-array\"")]
+ [DataRow("\"tests\": [42]")]
+ [DataRow("\"tests\": [{}]")]
+ [DataRow("\"tests\": [{\"uid\": 42, \"display-name\": \"Test\"}]")]
+ [DataRow("\"tests\": [{\"uid\": \"test\"}]")]
+ [DataRow("\"tests\": [{\"uid\": null, \"display-name\": \"Test\"}]")]
+ [DataRow("\"tests\": [{\"uid\": \"test\", \"display-name\": null}]")]
+ [DataRow("\"tests\": [{\"uid\": \"\", \"display-name\": \"Test\"}]")]
+ [DataRow("\"tests\": [{\"uid\": \" \", \"display-name\": \"Test\"}]")]
+ [DataRow("\"tests\": [{\"uid\": \"test\", \"display-name\": \"Test\", \"location.file\": null}]")]
+ [DataRow("\"tests\": [{\"uid\": \"test\", \"display-name\": \"Test\", \"location.file\": \"file\", \"location.line-start\": null, \"location.line-end\": 2}]")]
+ [DataRow("\"tests\": [{\"uid\": \"test\", \"display-name\": \"Test\", \"location.file\": \"file\"}]")]
+ [DataRow("\"tests\": [{\"uid\": \"test\", \"display-name\": \"Test\", \"location.file\": \"file\", \"location.line-start\": 1}]")]
+ [DataRow("\"tests\": [{\"uid\": \"test\", \"display-name\": \"Test\", \"location.line-start\": 1, \"location.line-end\": 2}]")]
+ [TestMethod]
+ public void DeserializeRunRequest_InvalidOptionalPropertyType_CapturesInvalidParams(string property)
+ {
+ RpcMessage message = Deserialize(
+ $$"""
+ {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "testing/runTests",
+ "params": {
+ "runId": "00000000-0000-0000-0000-000000000001",
+ {{property}}
+ }
+ }
+ """);
+
+ RequestMessage request = Assert.IsInstanceOfType(message);
+ InvalidRequestParamsArgs invalidParams = Assert.IsInstanceOfType(request.Params);
+ Assert.AreEqual(ErrorCodes.InvalidParams, invalidParams.ErrorCode);
+ }
+
+ [TestMethod]
+ public void DeserializeError_PrimitiveData_PreservesValue()
+ {
+ RpcMessage message = Deserialize(
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "error": {
+ "code": -32600,
+ "message": "invalid",
+ "data": "detail"
+ }
+ }
+ """);
+
+ Assert.AreEqual("detail", Assert.IsInstanceOfType(message).Data);
+ }
+
+ [TestMethod]
+ public void DeserializeInitializeResponse_NonStringProtocolVersion_Throws()
+ {
+ const string Json = """
+ {
+ "processId": 1,
+ "serverInfo": {
+ "name": "server",
+ "version": "1.2.3"
+ },
+ "capabilities": {
+ "testing": {
+ "supportsDiscovery": true,
+ "experimental_multiRequestSupport": false,
+ "vstestProvider": false,
+ "attachmentsSupport": true,
+ "multipleConnectionProvider": false
+ }
+ },
+ "protocolVersion": 1
+ }
+ """;
+
+ Assert.Throws(() => Deserialize(Json));
+ }
+
[TestMethod]
public async Task Serialize_TestNodeWithRetryAttempt_EmitsRetryProperties()
{
@@ -143,6 +434,18 @@ public async Task Serialize_TestNodeWithSupersededRetryAttempt_EmitsIsSuperseded
Assert.Contains("\"retry.is-superseded\":true", serialized, serialized);
}
+ [TestMethod]
+ public async Task Serialize_TestsAttachmentsWithoutRunId_PreservesLegacyShape()
+ {
+ TestsAttachments attachments = new([new RunTestAttachment("uri", "producer", "type", "name", null)]);
+
+ string serialized = (await _formatter.SerializeAsync(attachments)).Replace(" ", string.Empty);
+
+ Assert.AreEqual(
+ """{"attachments":[{"uri":"uri","producer":"producer","type":"type","display-name":"name","description":null}]}""",
+ serialized);
+ }
+
[DynamicData(nameof(SerializerTypesForDynamicData), DynamicDataDisplayName = nameof(FormatSerializerTypes))]
[TestMethod]
public async Task SerializeDeserialize_Succeed(Type type)
@@ -460,13 +763,13 @@ private static void AssertSerialize(Type type, string instanceSerialized)
if (type == typeof(ServerTestingCapabilities))
{
- Assert.AreEqual("""{"supportsDiscovery":true,"experimental_multiRequestSupport":true,"vstestProvider":true,"attachmentsSupport":true,"multipleConnectionProvider":true,"supportsTestCoverageMessages":true}""".Replace(" ", string.Empty), instanceSerialized, because);
+ Assert.AreEqual("""{"supportsDiscovery":true,"experimental_multiRequestSupport":true,"vstestProvider":true,"attachmentsSupport":true,"multipleConnectionProvider":true,"supportsTestCoverageMessages":false}""".Replace(" ", string.Empty), instanceSerialized, because);
return;
}
if (type == typeof(ServerCapabilities))
{
- Assert.AreEqual("""{"testing":{"supportsDiscovery":true,"experimental_multiRequestSupport":true,"vstestProvider":true,"attachmentsSupport":true,"multipleConnectionProvider":true,"supportsTestCoverageMessages":true}}""".Replace(" ", string.Empty), instanceSerialized, because);
+ Assert.AreEqual("""{"testing":{"supportsDiscovery":true,"experimental_multiRequestSupport":true,"vstestProvider":true,"attachmentsSupport":true,"multipleConnectionProvider":true,"supportsTestCoverageMessages":false}}""".Replace(" ", string.Empty), instanceSerialized, because);
return;
}
@@ -478,7 +781,7 @@ private static void AssertSerialize(Type type, string instanceSerialized)
if (type == typeof(InitializeResponseArgs))
{
- Assert.AreEqual("""{"processId":1,"serverInfo":{"name":"ServerInfoName","version":"Version"},"capabilities":{"testing":{"supportsDiscovery":true,"experimental_multiRequestSupport":true,"vstestProvider":true,"attachmentsSupport":true,"multipleConnectionProvider":true,"supportsTestCoverageMessages":true}}}""".Replace(" ", string.Empty), instanceSerialized, because);
+ Assert.AreEqual("""{"processId":1,"serverInfo":{"name":"ServerInfoName","version":"Version"},"capabilities":{"testing":{"supportsDiscovery":true,"experimental_multiRequestSupport":true,"vstestProvider":true,"attachmentsSupport":true,"multipleConnectionProvider":true,"supportsTestCoverageMessages":false}},"protocolVersion":"1.0.0"}""".Replace(" ", string.Empty), instanceSerialized, because);
return;
}
@@ -638,7 +941,13 @@ private static object CreateInstance(Type type)
if (type == typeof(InitializeResponseArgs))
{
- return new InitializeResponseArgs(1, new ServerInfo("ServerInfoName", "Version"), new ServerCapabilities(new ServerTestingCapabilities(true, true, true, true, true)));
+ return new InitializeResponseArgs(
+ 1,
+ new ServerInfo("ServerInfoName", "Version"),
+ new ServerCapabilities(new ServerTestingCapabilities(true, true, true, true, true)))
+ {
+ ProtocolVersion = JsonRpcProtocolVersions.Current,
+ };
}
if (type == typeof(ErrorMessage))
diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/PassiveNodeTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/PassiveNodeTests.cs
new file mode 100644
index 0000000000..3e3017adf8
--- /dev/null
+++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/PassiveNodeTests.cs
@@ -0,0 +1,130 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.Testing.Platform.Helpers;
+using Microsoft.Testing.Platform.Logging;
+using Microsoft.Testing.Platform.ServerMode;
+using Microsoft.Testing.Platform.Services;
+
+using Moq;
+
+namespace Microsoft.Testing.Platform.UnitTests;
+
+[TestClass]
+public sealed class PassiveNodeTests
+{
+ [TestMethod]
+ public async Task ConnectAsync_NegotiatesSupportedProtocolVersion()
+ {
+ RequestMessage request = CreateInitializeRequest([JsonRpcProtocolVersions.Current]) with { StringId = "1" };
+ TestMessageHandler handler = new(request);
+ using PassiveNode node = CreatePassiveNode(handler);
+
+ Assert.IsTrue(await node.ConnectAsync());
+
+ ResponseMessage response = Assert.IsInstanceOfType(handler.WrittenMessage);
+ InitializeResponseArgs result = Assert.IsInstanceOfType(response.Result);
+ Assert.AreEqual("1", response.StringId);
+ Assert.AreEqual(JsonRpcProtocolVersions.Current, result.ProtocolVersion);
+ }
+
+ [TestMethod]
+ public async Task ConnectAsync_RejectsUnsupportedProtocolVersion()
+ {
+ TestMessageHandler handler = new(CreateInitializeRequest(["2.0.0"]));
+ using PassiveNode node = CreatePassiveNode(handler);
+
+ Assert.IsFalse(await node.ConnectAsync());
+
+ ErrorMessage error = Assert.IsInstanceOfType(handler.WrittenMessage);
+ Assert.AreEqual(ErrorCodes.ProtocolVersionNotSupported, error.ErrorCode);
+ }
+
+ [TestMethod]
+ public async Task ConnectAsync_RejectsNonInitializeRequestBeforeInitialization()
+ {
+ RequestMessage request = new(
+ 1,
+ JsonRpcMethods.TestingDiscoverTests,
+ new DiscoverRequestArgs(Guid.NewGuid(), TestNodes: null, GraphFilter: null));
+ TestMessageHandler handler = new(request);
+ using PassiveNode node = CreatePassiveNode(handler);
+
+ Assert.IsFalse(await node.ConnectAsync());
+
+ ErrorMessage error = Assert.IsInstanceOfType(handler.WrittenMessage);
+ Assert.AreEqual(ErrorCodes.ServerNotInitialized, error.ErrorCode);
+ }
+
+ [DataRow(true)]
+ [DataRow(false)]
+ [TestMethod]
+ public async Task ConnectAsync_RejectsNonRequestInitialMessage(bool isNotification)
+ {
+ RpcMessage message = isNotification
+ ? new NotificationMessage(JsonRpcMethods.Exit, Params: null)
+ : new ResponseMessage(1, Result: null);
+ TestMessageHandler handler = new(message);
+ using PassiveNode node = CreatePassiveNode(handler);
+
+ Assert.IsFalse(await node.ConnectAsync());
+ Assert.IsNull(handler.WrittenMessage);
+ }
+
+ private static PassiveNode CreatePassiveNode(TestMessageHandler handler)
+ {
+ var cancellationTokenSource = new Mock();
+ cancellationTokenSource.SetupGet(source => source.CancellationToken).Returns(CancellationToken.None);
+
+ var environment = new Mock();
+ environment.SetupGet(value => value.ProcessId).Returns(42);
+
+ var logger = new Mock>();
+ logger.Setup(value => value.IsEnabled(It.IsAny())).Returns(false);
+
+ return new PassiveNode(
+ new TestMessageHandlerFactory(handler),
+ cancellationTokenSource.Object,
+ environment.Object,
+ new SystemMonitorAsyncFactory(),
+ logger.Object);
+ }
+
+ private static RequestMessage CreateInitializeRequest(string[] protocolVersions)
+ => new(
+ 1,
+ JsonRpcMethods.Initialize,
+ new InitializeRequestArgs(
+ 123,
+ new ClientInfo("test-client", "1.0.0"),
+ new ClientCapabilities(DebuggerProvider: false, IsStateful: false))
+ {
+ ProtocolVersions = protocolVersions,
+ });
+
+ private sealed class TestMessageHandlerFactory(IMessageHandler messageHandler) : IMessageHandlerFactory
+ {
+ public Task CreateMessageHandlerAsync(CancellationToken cancellationToken)
+ => Task.FromResult(messageHandler);
+ }
+
+ private sealed class TestMessageHandler(RpcMessage message) : IMessageHandler
+ {
+ private RpcMessage? _message = message;
+
+ public RpcMessage? WrittenMessage { get; private set; }
+
+ public Task ReadAsync(CancellationToken cancellationToken)
+ {
+ RpcMessage? message = _message;
+ _message = null;
+ return Task.FromResult(message);
+ }
+
+ public Task WriteRequestAsync(RpcMessage message, CancellationToken cancellationToken)
+ {
+ WrittenMessage = message;
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/ServerTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/ServerTests.cs
index d4a7ddfd22..233f8ff17f 100644
--- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/ServerTests.cs
+++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/ServerTests.cs
@@ -117,10 +117,15 @@ await WriteMessageAsync(
InitializeResponseArgs expectedResponse = new(
1,
new ServerInfo("test-anywhere", "this is dynamic"),
- new ServerCapabilities(new ServerTestingCapabilities(SupportsDiscovery: true, MultiRequestSupport: false, VSTestProviderSupport: false, SupportsAttachments: true, MultiConnectionProvider: false)));
+ new ServerCapabilities(new ServerTestingCapabilities(SupportsDiscovery: true, MultiRequestSupport: false, VSTestProviderSupport: false, SupportsAttachments: true, MultiConnectionProvider: false)))
+ {
+ ProtocolVersion = JsonRpcProtocolVersions.Current,
+ };
Assert.AreEqual(expectedResponse.Capabilities, resultJson.Capabilities);
Assert.AreEqual(expectedResponse.ServerInfo.Name, resultJson.ServerInfo.Name);
+ Assert.AreEqual(JsonRpcProtocolVersions.Current, resultJson.ProtocolVersion);
+ Assert.IsNotEmpty(resultJson.ServerInfo.Version);
await WriteMessageAsync(writer, """{ "jsonrpc": "2.0", "method": "exit", "params": { } }""");
@@ -128,6 +133,662 @@ await WriteMessageAsync(
Assert.AreEqual(0, result);
}
+ [TestMethod]
+ public async Task ServerEnforcesLifecycleAndNegotiatesProtocolVersion()
+ {
+ using var server = TcpServer.Create();
+
+ string[] args = ["--no-banner", "--server", "--client-port", $"{server.Port}", "--internal-testingplatform-skipbuildercheck"];
+ ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args);
+ builder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_, __) => new MockTestAdapter
+ {
+ DiscoveryAction = context =>
+ {
+ context.Complete();
+ return Task.CompletedTask;
+ },
+ });
+ var testApplication = (TestApplication)await builder.BuildAsync();
+ testApplication.ServiceProvider.GetRequiredService().SuppressOutput();
+ Task serverTask = Task.Run(testApplication.RunAsync);
+
+ using CancellationTokenSource timeout = new(TimeoutHelper.DefaultHangTimeSpanTimeout);
+ using TcpClient client = await server.WaitForConnectionAsync(timeout.Token);
+ using NetworkStream stream = client.GetStream();
+ using StreamWriter writer = new(stream, Encoding.UTF8);
+ TcpMessageHandler messageHandler = new(
+ client,
+ clientToServerStream: client.GetStream(),
+ serverToClientStream: client.GetStream(),
+ FormatterUtilities.CreateFormatter());
+
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "testing/discoverTests",
+ "params": {
+ "runId": "00000000-0000-0000-0000-000000000001"
+ }
+ }
+ """);
+
+ var beforeInitializeError = (ErrorMessage)(await WaitForMessage(
+ messageHandler,
+ rpcMessage => rpcMessage is ErrorMessage { Id: 1 },
+ "Wait server-not-initialized error",
+ timeout.Token))!;
+ Assert.AreEqual(ErrorCodes.ServerNotInitialized, beforeInitializeError.ErrorCode);
+
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "initialize",
+ "params": {
+ "processId": 32,
+ "clientInfo": { "name": "testingplatform-unittests", "version": "42.0.0" },
+ "capabilities": {
+ "testing": {
+ "debuggerProvider": false
+ }
+ },
+ "protocolVersions": [ "99.0.0" ]
+ }
+ }
+ """);
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": 20,
+ "method": "testing/discoverTests",
+ "params": {
+ "runId": "00000000-0000-0000-0000-000000000020"
+ }
+ }
+ """);
+
+ var incompatibleVersionError = (ErrorMessage)(await WaitForMessage(
+ messageHandler,
+ rpcMessage => rpcMessage is ErrorMessage { Id: 2 },
+ "Wait incompatible protocol version error",
+ timeout.Token))!;
+ Assert.AreEqual(ErrorCodes.ProtocolVersionNotSupported, incompatibleVersionError.ErrorCode);
+
+ const string initializeMessage = """
+ {
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "initialize",
+ "params": {
+ "processId": 32,
+ "clientInfo": { "name": "testingplatform-unittests", "version": "42.0.0" },
+ "capabilities": {
+ "testing": {
+ "debuggerProvider": false
+ }
+ },
+ "protocolVersions": [ "1.0.0" ]
+ }
+ }
+ """;
+ await WriteMessageAsync(writer, initializeMessage);
+
+ ErrorMessage? queuedRequestError = null;
+ ResponseMessage? initializeResponse = null;
+ while (queuedRequestError is null || initializeResponse is null)
+ {
+ RpcMessage? message = await messageHandler.ReadAsync(timeout.Token);
+ if (queuedRequestError is null && message is ErrorMessage { Id: 20 } error)
+ {
+ queuedRequestError = error;
+ }
+
+ if (initializeResponse is null && message is ResponseMessage { Id: 2 } response)
+ {
+ initializeResponse = response;
+ }
+ }
+
+ Assert.AreEqual(ErrorCodes.ServerNotInitialized, queuedRequestError.ErrorCode);
+ InitializeResponseArgs initializeResult = SerializerUtilities.Deserialize(
+ (IDictionary)initializeResponse.Result!);
+ Assert.AreEqual(JsonRpcProtocolVersions.Current, initializeResult.ProtocolVersion);
+ Assert.IsNotEmpty(initializeResult.ServerInfo.Version);
+ var responseResult = (IDictionary)initializeResponse.Result!;
+ var responseCapabilities = (IDictionary)responseResult[JsonRpcStrings.Capabilities]!;
+ var testingCapabilities = (IDictionary)responseCapabilities[JsonRpcStrings.Testing]!;
+ Assert.IsFalse((bool)testingCapabilities[JsonRpcStrings.SupportsTestCoverageMessages]!);
+
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": "4",
+ "method": "testing/unknown",
+ "params": {}
+ }
+ """);
+
+ var methodNotFoundError = (ErrorMessage)(await WaitForMessage(
+ messageHandler,
+ rpcMessage => rpcMessage is ErrorMessage { Id: 4 },
+ "Wait method-not-found error",
+ timeout.Token))!;
+ Assert.AreEqual(ErrorCodes.MethodNotFound, methodNotFoundError.ErrorCode);
+ Assert.AreEqual("4", methodNotFoundError.StringId);
+
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": 5,
+ "method": "testing/runTests",
+ "params": {
+ "runId": "00000000-0000-0000-0000-000000000005",
+ "filter": "/A(&B)"
+ }
+ }
+ """);
+
+ RpcMessage? failedRunCompletion = await WaitForMessage(
+ messageHandler,
+ IsTestUpdateCompletion,
+ "Wait failed run completion",
+ timeout.Token);
+ Assert.IsNotNull(failedRunCompletion);
+
+ var internalError = (ErrorMessage)(await WaitForMessage(
+ messageHandler,
+ rpcMessage => rpcMessage is ErrorMessage { Id: 5 },
+ "Wait failed run error",
+ timeout.Token))!;
+ Assert.AreEqual(ErrorCodes.InternalError, internalError.ErrorCode);
+
+ await WriteMessageAsync(writer, initializeMessage.Replace("\"id\": 2", "\"id\": 6"));
+ var duplicateInitializeError = (ErrorMessage)(await WaitForMessage(
+ messageHandler,
+ rpcMessage => rpcMessage is ErrorMessage { Id: 6 },
+ "Wait duplicate initialize error",
+ timeout.Token))!;
+ Assert.AreEqual(ErrorCodes.InvalidRequest, duplicateInitializeError.ErrorCode);
+
+ await WriteMessageAsync(writer, """{ "jsonrpc": "2.0", "method": "exit", "params": { } }""");
+
+ Assert.AreEqual(0, await serverTask);
+ }
+
+ [TestMethod]
+ public async Task PipelinedRequestWaitsForInitializeResponse()
+ {
+ using var server = TcpServer.Create();
+
+ string[] args = ["--no-banner", "--server", "--client-port", $"{server.Port}", "--internal-testingplatform-skipbuildercheck"];
+ ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args);
+ builder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_, __) => new MockTestAdapter
+ {
+ DiscoveryAction = context =>
+ {
+ context.Complete();
+ return Task.CompletedTask;
+ },
+ });
+ var testApplication = (TestApplication)await builder.BuildAsync();
+ testApplication.ServiceProvider.GetRequiredService().SuppressOutput();
+ Task serverTask = Task.Run(testApplication.RunAsync);
+
+ using CancellationTokenSource timeout = new(TimeoutHelper.DefaultHangTimeSpanTimeout);
+ using TcpClient client = await server.WaitForConnectionAsync(timeout.Token);
+ using NetworkStream stream = client.GetStream();
+ using StreamWriter writer = new(stream, Encoding.UTF8);
+ TcpMessageHandler messageHandler = new(
+ client,
+ clientToServerStream: client.GetStream(),
+ serverToClientStream: client.GetStream(),
+ FormatterUtilities.CreateFormatter());
+
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "processId": 32,
+ "clientInfo": { "name": "testingplatform-unittests", "version": "1.0.0" },
+ "capabilities": {
+ "testing": {
+ "debuggerProvider": false
+ }
+ }
+ }
+ }
+ """);
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "testing/discoverTests",
+ "params": {
+ "runId": "00000000-0000-0000-0000-000000000002"
+ }
+ }
+ """);
+
+ _ = await WaitForMessage(
+ messageHandler,
+ rpcMessage => rpcMessage is ResponseMessage { Id: 1 },
+ "Wait initialize response",
+ timeout.Token);
+ RpcMessage? discoveryResponse = await WaitForMessage(
+ messageHandler,
+ rpcMessage => rpcMessage is ResponseMessage { Id: 2 } or ErrorMessage { Id: 2 },
+ "Wait pipelined discovery response",
+ timeout.Token);
+ Assert.IsInstanceOfType(discoveryResponse);
+
+ await WriteMessageAsync(writer, """{ "jsonrpc": "2.0", "method": "exit", "params": { } }""");
+ Assert.AreEqual(0, await serverTask);
+ }
+
+ [TestMethod]
+ public async Task PipelinedRequestCanBeCanceledWhileInitializationCompletes()
+ {
+ using var server = TcpServer.Create();
+ using var testFrameworkCapabilities = new BlockingTestFrameworkCapabilities();
+ int discoveryInvocationCount = 0;
+
+ string[] args = ["--no-banner", "--server", "--client-port", $"{server.Port}", "--internal-testingplatform-skipbuildercheck"];
+ ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args);
+ builder.RegisterTestFramework(_ => testFrameworkCapabilities, (_, __) => new MockTestAdapter
+ {
+ DiscoveryAction = context =>
+ {
+ Interlocked.Increment(ref discoveryInvocationCount);
+ return Task.Delay(Timeout.Infinite, context.CancellationToken);
+ },
+ });
+ var testApplication = (TestApplication)await builder.BuildAsync();
+ testApplication.ServiceProvider.GetRequiredService().SuppressOutput();
+ Task serverTask = Task.Run(testApplication.RunAsync);
+
+ using CancellationTokenSource timeout = new(TimeoutHelper.DefaultHangTimeSpanTimeout);
+ using TcpClient client = await server.WaitForConnectionAsync(timeout.Token);
+ using NetworkStream stream = client.GetStream();
+ using StreamWriter writer = new(stream, Encoding.UTF8);
+ TcpMessageHandler messageHandler = new(
+ client,
+ clientToServerStream: client.GetStream(),
+ serverToClientStream: client.GetStream(),
+ FormatterUtilities.CreateFormatter());
+
+ testFrameworkCapabilities.BlockNextAccess();
+ try
+ {
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "processId": 32,
+ "clientInfo": { "name": "testingplatform-unittests", "version": "1.0.0" },
+ "capabilities": {
+ "testing": {
+ "debuggerProvider": false
+ }
+ }
+ }
+ }
+ """);
+ await testFrameworkCapabilities.WaitUntilBlockedAsync().WaitAsync(timeout.Token);
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "testing/discoverTests",
+ "params": {
+ "runId": "00000000-0000-0000-0000-000000000002"
+ }
+ }
+ """);
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "method": "$/cancelRequest",
+ "params": {
+ "id": 2
+ }
+ }
+ """);
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": 3,
+ "method": "initialize",
+ "params": {
+ "processId": 32,
+ "clientInfo": { "name": "testingplatform-unittests", "version": "1.0.0" },
+ "capabilities": {
+ "testing": {
+ "debuggerProvider": false
+ }
+ }
+ }
+ }
+ """);
+
+ var duplicateInitializeError = (ErrorMessage)(await WaitForMessage(
+ messageHandler,
+ rpcMessage => rpcMessage is ErrorMessage { Id: 3 },
+ "Wait duplicate initialize error after cancellation",
+ timeout.Token))!;
+ Assert.AreEqual(ErrorCodes.InvalidRequest, duplicateInitializeError.ErrorCode);
+ }
+ finally
+ {
+ testFrameworkCapabilities.Release();
+ }
+
+ _ = await WaitForMessage(
+ messageHandler,
+ rpcMessage => rpcMessage is ResponseMessage { Id: 1 },
+ "Wait initialize response",
+ timeout.Token);
+ _ = await WaitForMessage(
+ messageHandler,
+ IsTestUpdateCompletion,
+ "Wait canceled discovery completion",
+ timeout.Token);
+ var cancellationError = (ErrorMessage)(await WaitForMessage(
+ messageHandler,
+ rpcMessage => rpcMessage is ErrorMessage { Id: 2 },
+ "Wait canceled discovery error",
+ timeout.Token))!;
+ Assert.AreEqual(ErrorCodes.RequestCanceled, cancellationError.ErrorCode);
+ Assert.AreEqual(0, Volatile.Read(ref discoveryInvocationCount));
+
+ await WriteMessageAsync(writer, """{ "jsonrpc": "2.0", "method": "exit", "params": { } }""");
+ Assert.AreEqual(0, await serverTask);
+ }
+
+ [TestMethod]
+ public async Task SlowCancellationCallbackDoesNotBlockMessageReader()
+ {
+ using var server = TcpServer.Create();
+ TaskCompletionSource discoveryStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ TaskCompletionSource cancellationCallbackStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var releaseCancellationCallback = new ManualResetEventSlim(initialState: false);
+ using CancellationTokenSource cancellationCallbackTimeout = new(TimeoutHelper.DefaultHangTimeSpanTimeout);
+
+ string[] args = ["--no-banner", "--server", "--client-port", $"{server.Port}", "--internal-testingplatform-skipbuildercheck"];
+ ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args);
+ builder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_, __) => new MockTestAdapter
+ {
+ DiscoveryAction = async context =>
+ {
+ var cancellationDelay = Task.Delay(Timeout.Infinite, context.CancellationToken);
+ using CancellationTokenRegistration registration = context.CancellationToken.Register(() =>
+ {
+ cancellationCallbackStarted.TrySetResult(true);
+ releaseCancellationCallback.Wait(cancellationCallbackTimeout.Token);
+ });
+ discoveryStarted.TrySetResult(true);
+ await cancellationDelay;
+ },
+ });
+ var testApplication = (TestApplication)await builder.BuildAsync();
+ testApplication.ServiceProvider.GetRequiredService().SuppressOutput();
+ Task serverTask = Task.Run(testApplication.RunAsync);
+
+ using CancellationTokenSource timeout = new(TimeoutHelper.DefaultHangTimeSpanTimeout);
+ using TcpClient client = await server.WaitForConnectionAsync(timeout.Token);
+ using NetworkStream stream = client.GetStream();
+ using StreamWriter writer = new(stream, Encoding.UTF8);
+ TcpMessageHandler messageHandler = new(
+ client,
+ clientToServerStream: client.GetStream(),
+ serverToClientStream: client.GetStream(),
+ FormatterUtilities.CreateFormatter());
+
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "processId": 32,
+ "clientInfo": { "name": "testingplatform-unittests", "version": "1.0.0" },
+ "capabilities": {
+ "testing": {
+ "debuggerProvider": false
+ }
+ }
+ }
+ }
+ """);
+ _ = await WaitForMessage(
+ messageHandler,
+ rpcMessage => rpcMessage is ResponseMessage { Id: 1 },
+ "Wait initialize response",
+ timeout.Token);
+
+ try
+ {
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "testing/discoverTests",
+ "params": {
+ "runId": "00000000-0000-0000-0000-000000000002"
+ }
+ }
+ """);
+ await discoveryStarted.Task.WaitAsync(timeout.Token);
+
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "method": "$/cancelRequest",
+ "params": {
+ "id": 2
+ }
+ }
+ """);
+ await cancellationCallbackStarted.Task.WaitAsync(timeout.Token);
+
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": 3,
+ "method": "testing/unknown",
+ "params": {}
+ }
+ """);
+ var methodNotFoundError = (ErrorMessage)(await WaitForMessage(
+ messageHandler,
+ rpcMessage => rpcMessage is ErrorMessage { Id: 3 },
+ "Wait method-not-found response while cancellation callback is blocked",
+ timeout.Token))!;
+ Assert.AreEqual(ErrorCodes.MethodNotFound, methodNotFoundError.ErrorCode);
+ }
+ finally
+ {
+ releaseCancellationCallback.Set();
+ }
+
+ _ = await WaitForMessage(
+ messageHandler,
+ IsTestUpdateCompletion,
+ "Wait canceled discovery completion",
+ timeout.Token);
+ var cancellationError = (ErrorMessage)(await WaitForMessage(
+ messageHandler,
+ rpcMessage => rpcMessage is ErrorMessage { Id: 2 },
+ "Wait canceled discovery error",
+ timeout.Token))!;
+ Assert.AreEqual(ErrorCodes.RequestCanceled, cancellationError.ErrorCode);
+
+ await WriteMessageAsync(writer, """{ "jsonrpc": "2.0", "method": "exit", "params": { } }""");
+ Assert.AreEqual(0, await serverTask);
+ }
+
+ [TestMethod]
+ public async Task NumericAndStringRequestIdsHaveIndependentCancellation()
+ {
+ using var server = TcpServer.Create();
+ TaskCompletionSource bothRequestsStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ TaskCompletionSource releaseNumericRequest = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ int startedRequestCount = 0;
+
+ string[] args = ["--no-banner", "--server", "--client-port", $"{server.Port}", "--internal-testingplatform-skipbuildercheck"];
+ ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args);
+ builder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_, __) => new MockTestAdapter
+ {
+ DiscoveryAction = async context =>
+ {
+ TreeNodeFilter filter = Assert.IsInstanceOfType(
+ Assert.IsInstanceOfType(context.Request).Filter);
+ if (Interlocked.Increment(ref startedRequestCount) == 2)
+ {
+ bothRequestsStarted.TrySetResult(true);
+ }
+
+ if (filter.Filter == "/string")
+ {
+ await Task.Delay(Timeout.Infinite, context.CancellationToken);
+ }
+ else
+ {
+ await releaseNumericRequest.Task;
+ context.Complete();
+ }
+ },
+ });
+ var testApplication = (TestApplication)await builder.BuildAsync();
+ testApplication.ServiceProvider.GetRequiredService().SuppressOutput();
+ Task serverTask = Task.Run(testApplication.RunAsync);
+
+ using CancellationTokenSource timeout = new(TimeoutHelper.DefaultHangTimeSpanTimeout);
+ using TcpClient client = await server.WaitForConnectionAsync(timeout.Token);
+ using NetworkStream stream = client.GetStream();
+ using StreamWriter writer = new(stream, Encoding.UTF8);
+ TcpMessageHandler messageHandler = new(
+ client,
+ clientToServerStream: client.GetStream(),
+ serverToClientStream: client.GetStream(),
+ FormatterUtilities.CreateFormatter());
+
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "processId": 32,
+ "clientInfo": { "name": "testingplatform-unittests", "version": "1.0.0" },
+ "capabilities": {
+ "testing": {
+ "debuggerProvider": false
+ }
+ }
+ }
+ }
+ """);
+ _ = await WaitForMessage(
+ messageHandler,
+ rpcMessage => rpcMessage is ResponseMessage { Id: 1 },
+ "Wait initialize response",
+ timeout.Token);
+
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "testing/discoverTests",
+ "params": {
+ "runId": "00000000-0000-0000-0000-000000000002",
+ "filter": "/numeric"
+ }
+ }
+ """);
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": "2",
+ "method": "testing/discoverTests",
+ "params": {
+ "runId": "00000000-0000-0000-0000-000000000003",
+ "filter": "/string"
+ }
+ }
+ """);
+ await bothRequestsStarted.Task.TimeoutAfterAsync(TimeoutHelper.DefaultHangTimeSpanTimeout, timeout.Token);
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "method": "$/cancelRequest",
+ "params": {
+ "id": "2"
+ }
+ }
+ """);
+
+ var stringRequestError = (ErrorMessage)(await WaitForMessage(
+ messageHandler,
+ rpcMessage => rpcMessage is ErrorMessage { Id: 2, StringId: "2" },
+ "Wait string request cancellation",
+ timeout.Token))!;
+ Assert.AreEqual(ErrorCodes.RequestCanceled, stringRequestError.ErrorCode);
+
+ releaseNumericRequest.TrySetResult(true);
+ var numericResponse = (ResponseMessage)(await WaitForMessage(
+ messageHandler,
+ rpcMessage => rpcMessage is ResponseMessage { Id: 2, StringId: null },
+ "Wait numeric request response",
+ timeout.Token))!;
+ Assert.IsNull(numericResponse.StringId);
+
+ await WriteMessageAsync(writer, """{ "jsonrpc": "2.0", "method": "exit", "params": { } }""");
+ Assert.AreEqual(0, await serverTask);
+ }
+
[TestMethod]
public async Task RunRequestWithEmptyTests_PreservesEmptyUidSelection()
{
@@ -288,7 +949,14 @@ public async Task DiscoveryRequestCanBeCanceled()
await WriteMessageAsync(writer, cancelRequestMessage);
using CancellationTokenSource cancellationTokenSource2 = new(TimeoutHelper.DefaultHangTimeSpanTimeout);
- msg = await WaitForMessage(messageHandler, rpcMessage => rpcMessage is ErrorMessage, "Wait cancelRequest", cancellationTokenSource.Token);
+ RpcMessage? completion = await WaitForMessage(
+ messageHandler,
+ IsTestUpdateCompletion,
+ "Wait cancellation completion",
+ cancellationTokenSource2.Token);
+ Assert.IsNotNull(completion);
+
+ msg = await WaitForMessage(messageHandler, rpcMessage => rpcMessage is ErrorMessage, "Wait cancelRequest", cancellationTokenSource2.Token);
var error = (ErrorMessage)msg!;
Assert.AreEqual(ErrorCodes.RequestCanceled, error.ErrorCode);
@@ -299,6 +967,81 @@ public async Task DiscoveryRequestCanBeCanceled()
Assert.AreEqual(0, result);
}
+ [TestMethod]
+ public async Task GlobalCancellationDuringDiscovery_DoesNotHangShutdown()
+ {
+ using var server = TcpServer.Create();
+ TaskCompletionSource discoveryStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ string[] args = ["--no-banner", "--server", "--client-port", $"{server.Port}", "--internal-testingplatform-skipbuildercheck"];
+ ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args);
+ builder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_, __) => new MockTestAdapter
+ {
+ DiscoveryAction = async context =>
+ {
+ discoveryStarted.TrySetResult(true);
+ await Task.Delay(Timeout.Infinite, context.CancellationToken);
+ },
+ });
+
+ var testApplication = (TestApplication)await builder.BuildAsync();
+ testApplication.ServiceProvider.GetRequiredService().SuppressOutput();
+ Task serverTask = Task.Run(testApplication.RunAsync);
+
+ using CancellationTokenSource timeout = new(TimeoutHelper.DefaultHangTimeSpanTimeout);
+ using TcpClient client = await server.WaitForConnectionAsync(timeout.Token);
+ using NetworkStream stream = client.GetStream();
+ using StreamWriter writer = new(stream, Encoding.UTF8);
+ TcpMessageHandler messageHandler = new(
+ client,
+ clientToServerStream: client.GetStream(),
+ serverToClientStream: client.GetStream(),
+ FormatterUtilities.CreateFormatter());
+
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "processId": 32,
+ "clientInfo": { "name": "testingplatform-unittests", "version": "1.0.0" },
+ "capabilities": {
+ "testing": {
+ "debuggerProvider": false
+ }
+ }
+ }
+ }
+ """);
+ _ = await WaitForMessage(
+ messageHandler,
+ rpcMessage => rpcMessage is ResponseMessage { Id: 1 },
+ "Wait initialize",
+ timeout.Token);
+
+ await WriteMessageAsync(
+ writer,
+ """
+ {
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "testing/discoverTests",
+ "params": {
+ "runId": "00000000-0000-0000-0000-000000000002"
+ }
+ }
+ """);
+ await discoveryStarted.Task.TimeoutAfterAsync(TimeoutHelper.DefaultHangTimeSpanTimeout, timeout.Token);
+
+ testApplication.ServiceProvider.GetTestApplicationCancellationTokenSource().Cancel();
+
+ await serverTask.TimeoutAfterAsync(TimeoutHelper.DefaultHangTimeSpanTimeout, timeout.Token);
+ Assert.AreEqual((int)ExitCode.TestSessionAborted, await serverTask);
+ }
+
[TestMethod]
public async Task DeadlineStateIsIsolatedBetweenServerRequests()
{
@@ -330,6 +1073,7 @@ await stopPoliciesService.RegisterOnDeadlineCallbackAsync(
},
};
});
+
var testApplication = (TestApplication)await builder.BuildAsync();
testApplication.ServiceProvider.GetRequiredService().SuppressOutput();
Task serverTask = Task.Run(testApplication.RunAsync);
@@ -507,6 +1251,13 @@ public async Task RequestWithInvalidRunId_ReturnsInvalidParamsError(string metho
}
}
+ private static bool IsTestUpdateCompletion(RpcMessage? rpcMessage)
+ => rpcMessage is NotificationMessage notification
+ && notification.Method == JsonRpcMethods.TestingTestUpdatesTests
+ && notification.Params is IDictionary completionParams
+ && completionParams.TryGetValue(JsonRpcStrings.Changes, out object? changes)
+ && changes is null;
+
private static async Task WriteMessageAsync(StreamWriter writer, string message)
{
await writer.WriteLineAsync($"Content-Length: {message.Length}");
@@ -566,6 +1317,35 @@ private sealed class MockTestAdapter : ITestFramework
public Task ExecuteRequestAsync(ExecuteRequestContext context) => DiscoveryAction is not null ? DiscoveryAction(context) : Task.CompletedTask;
}
+ private sealed class BlockingTestFrameworkCapabilities : ITestFrameworkCapabilities, IDisposable
+ {
+ private readonly TaskCompletionSource _blocked = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private readonly ManualResetEventSlim _release = new(initialState: false);
+ private int _blockNextAccess;
+
+ public IReadOnlyCollection Capabilities
+ {
+ get
+ {
+ if (Interlocked.Exchange(ref _blockNextAccess, 0) == 1)
+ {
+ _blocked.TrySetResult(true);
+ _release.Wait();
+ }
+
+ return [];
+ }
+ }
+
+ public void BlockNextAccess() => Volatile.Write(ref _blockNextAccess, 1);
+
+ public Task WaitUntilBlockedAsync() => _blocked.Task;
+
+ public void Release() => _release.Set();
+
+ public void Dispose() => _release.Dispose();
+ }
+
private sealed record ServerRequestState(
IStopPoliciesService StopPoliciesService,
ITestApplicationProcessExitCode TestApplicationResult,