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 HandleRequestCoreAsync(RequestMessage message, RpcInvocationState rpcInvocationState, CancellationToken cancellationToken) { - var perRequestServiceProvider = (ServiceProvider)ServiceProvider.Clone(); - - // Add custom linked ITestApplicationCooperativeLifetimeService cancellation token source - perRequestServiceProvider.AddService(new PerRequestTestSessionContext( - rpcInvocationState.CancellationToken, - cancellationToken)); - - perRequestServiceProvider.AddService(new TestHostTestFrameworkInvoker(perRequestServiceProvider)); - AssertInitialized(); await _logger.LogDebugAsync($"Received {message.Method} request").ConfigureAwait(false); @@ -37,14 +28,20 @@ private async Task HandleRequestCoreAsync(RequestMessage message, RpcInv throw new JsonRpcException(invalidParams.ErrorCode, invalidParams.ErrorMessage); case (JsonRpcMethods.Initialize, InitializeRequestArgs args): + string negotiatedProtocolVersion = JsonRpcProtocolVersions.Negotiate(args.ProtocolVersions) + ?? throw new JsonRpcException( + ErrorCodes.ProtocolVersionNotSupported, + $"None of the client's protocol versions are supported. Server versions: {string.Join(", ", JsonRpcProtocolVersions.Supported)}."); + _client = new(args.ClientInfo.Name, args.ClientInfo.Version); _clientInfoService = new ClientInfoService(args.ClientInfo.Name, args.ClientInfo.Version, new ClientCapabilitiesService(args.Capabilities.IsStateful)); - await _logger.LogDebugAsync($"Connection established with '{_client.Id}', protocol version {_client.Version}").ConfigureAwait(false); + await _logger.LogDebugAsync( + $"Connection established with '{_client.Id}' version '{_client.Version}', protocol version '{negotiatedProtocolVersion}'").ConfigureAwait(false); INamedFeatureCapability? namedFeatureCapability = ServiceProvider.GetTestFrameworkCapabilities().GetCapability(); return new InitializeResponseArgs( ProcessId: ServiceProvider.GetEnvironment().ProcessId, - ServerInfo: new ServerInfo("test-anywhere", Version: ProtocolVersion), + ServerInfo: new ServerInfo("test-anywhere", Version: PlatformVersion.Version), Capabilities: new ServerCapabilities( new ServerTestingCapabilities( SupportsDiscovery: true, @@ -52,21 +49,37 @@ private async Task HandleRequestCoreAsync(RequestMessage message, RpcInv MultiRequestSupport: false, VSTestProviderSupport: namedFeatureCapability?.IsSupported(JsonRpcStrings.VSTestProviderSupport) == true, SupportsAttachments: true, - MultiConnectionProvider: false))); + MultiConnectionProvider: false))) + { + ProtocolVersion = negotiatedProtocolVersion, + }; case (JsonRpcMethods.TestingDiscoverTests, DiscoverRequestArgs args): - return await ExecuteRequestAsync(args, JsonRpcMethods.TestingDiscoverTests, perRequestServiceProvider, cancellationToken).ConfigureAwait(false); + return await ExecuteRequestAsync(args, JsonRpcMethods.TestingDiscoverTests, rpcInvocationState, cancellationToken).ConfigureAwait(false); case (JsonRpcMethods.TestingRunTests, RunRequestArgs args): - return await ExecuteRequestAsync(args, JsonRpcMethods.TestingRunTests, perRequestServiceProvider, cancellationToken).ConfigureAwait(false); + return await ExecuteRequestAsync(args, JsonRpcMethods.TestingRunTests, rpcInvocationState, cancellationToken).ConfigureAwait(false); default: - throw new NotImplementedException(); + throw new JsonRpcException(ErrorCodes.MethodNotFound, $"The method '{message.Method}' is not supported."); } } - private async Task ExecuteRequestAsync(RequestArgsBase args, string method, ServiceProvider perRequestServiceProvider, CancellationToken cancellationToken) + private async Task ExecuteRequestAsync( + RequestArgsBase args, + string method, + RpcInvocationState rpcInvocationState, + CancellationToken cancellationToken) { + var perRequestServiceProvider = (ServiceProvider)ServiceProvider.Clone(); + + // Add custom linked ITestApplicationCooperativeLifetimeService cancellation token source + perRequestServiceProvider.AddService(new PerRequestTestSessionContext( + rpcInvocationState.CancellationToken, + cancellationToken)); + + perRequestServiceProvider.AddService(new TestHostTestFrameworkInvoker(perRequestServiceProvider)); + DateTimeOffset requestStart = _clock.UtcNow; // Avoid allocating request-scoped services that register cancellation callbacks when the request @@ -188,7 +201,6 @@ await ExecuteRequestAsync( // catch and propagated as correct json rpc error perRequestTestSessionContext.CancellationToken.ThrowIfCancellationRequested(); - await SendTestUpdateCompleteAsync(args.RunId, cancellationToken).ConfigureAwait(false); requestExecuteStop = _clock.UtcNow; } finally diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs index 81279d32c7..31eed8a1b4 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs @@ -20,11 +20,10 @@ namespace Microsoft.Testing.Platform.Hosts; [StackTraceHidden] internal sealed partial class ServerTestHost : CommonHost, IServerTestHost, IDisposable, IOutputDeviceDataProducer { - // The value is the build-time version stamp (e.g. "2.4.0-dev" locally vs "2.4.0-ci" on CI), so it is an - // implementation detail rather than a stable API surface and must not be tracked by the internal API analyzers. -#pragma warning disable RS0051 // Add internal types and members to the declared API - public const string ProtocolVersion = PlatformVersion.Version; -#pragma warning restore RS0051 // Add internal types and members to the declared API + private const int NotInitialized = 0; + private const int Initializing = 1; + private const int Initialized = 2; + private readonly Func> _buildTestFrameworkAsync; private readonly IMessageHandlerFactory _messageHandlerFactory; @@ -40,19 +39,26 @@ internal sealed partial class ServerTestHost : CommonHost, IServerTestHost, IDis // We start by one so we can wait all other requests private readonly CountdownEvent _requestCounter = new(1); private readonly IClock _clock; +#if NET9_0_OR_GREATER + private readonly Lock _initializeStateLock = new(); +#else + private readonly object _initializeStateLock = new(); +#endif // In-flight requests from the client to the server. // The client can cancel these requests at any time. // When the server completes the handler it will complete the backing RpcRequest. - private ConcurrentDictionary _clientToServerRequests; + private ConcurrentDictionary<(int Id, bool IsString), RpcInvocationState> _clientToServerRequests; // In-flight requests from the server to the client. // Whenever a client responds with a result or an error, the requests // get completed. - private ConcurrentDictionary _serverToClientRequests; + private ConcurrentDictionary<(int Id, bool IsString), RpcInvocationState> _serverToClientRequests; private IMessageHandler? _messageHandler; private TestHost.ClientInfo? _client; private IClientInfo? _clientInfoService; + private TaskCompletionSource? _initializationCompletionSource; + private int _initializeState; public ServerTestHost( ServiceProvider serviceProvider, diff --git a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt index b956bf4021..d002cd9d01 100644 --- a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt @@ -261,6 +261,26 @@ Microsoft.Testing.Platform.Services.ClientCapabilitiesService.Deconstruct(out bo Microsoft.Testing.Platform.Services.ClientCapabilitiesService.Equals(Microsoft.Testing.Platform.Services.ClientCapabilitiesService? other) -> bool Microsoft.Testing.Platform.Services.ClientCapabilitiesService.IsStateful.get -> bool Microsoft.Testing.Platform.Services.ClientCapabilitiesService.IsStateful.init -> void +Microsoft.Testing.Platform.ServerMode.InitializeRequestArgs.ProtocolVersions.get -> string![]? +Microsoft.Testing.Platform.ServerMode.InitializeRequestArgs.ProtocolVersions.init -> void +Microsoft.Testing.Platform.ServerMode.InitializeResponseArgs.ProtocolVersion.get -> string? +Microsoft.Testing.Platform.ServerMode.InitializeResponseArgs.ProtocolVersion.init -> void +Microsoft.Testing.Platform.ServerMode.JsonRpcProtocolVersions +const Microsoft.Testing.Platform.ServerMode.JsonRpcProtocolVersions.V1 = "1.0.0" -> string! +static Microsoft.Testing.Platform.ServerMode.JsonRpcProtocolVersions.Current.get -> string! +static Microsoft.Testing.Platform.ServerMode.JsonRpcProtocolVersions.Negotiate(System.Collections.Generic.IReadOnlyCollection? clientSupportedVersions) -> string? +static Microsoft.Testing.Platform.ServerMode.JsonRpcProtocolVersions.Supported.get -> System.Collections.Generic.IReadOnlyList! +const Microsoft.Testing.Platform.ServerMode.JsonRpcStrings.ProtocolVersion = "protocolVersion" -> string! +const Microsoft.Testing.Platform.ServerMode.JsonRpcStrings.ProtocolVersions = "protocolVersions" -> string! +static readonly Microsoft.Testing.Platform.ServerMode.ErrorCodes.ProtocolVersionNotSupported -> int +Microsoft.Testing.Platform.ServerMode.RequestMessage.StringId.get -> string? +Microsoft.Testing.Platform.ServerMode.RequestMessage.StringId.init -> void +Microsoft.Testing.Platform.ServerMode.ResponseMessage.StringId.get -> string? +Microsoft.Testing.Platform.ServerMode.ResponseMessage.StringId.init -> void +Microsoft.Testing.Platform.ServerMode.ErrorMessage.StringId.get -> string? +Microsoft.Testing.Platform.ServerMode.ErrorMessage.StringId.init -> void +Microsoft.Testing.Platform.ServerMode.CancelRequestArgs.StringId.get -> string? +Microsoft.Testing.Platform.ServerMode.CancelRequestArgs.StringId.init -> void Microsoft.Testing.Platform.Services.ClientInfoService.Capabilities.get -> Microsoft.Testing.Platform.Services.IClientCapabilities! Microsoft.Testing.Platform.Services.ClientInfoService.Capabilities.init -> void Microsoft.Testing.Platform.Services.ClientInfoService.ClientInfoService(string! Id, string! Version, Microsoft.Testing.Platform.Services.IClientCapabilities! Capabilities) -> void diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/ErrorCodes.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/ErrorCodes.cs index d001fa1d8c..002cb396ea 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/ErrorCodes.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/ErrorCodes.cs @@ -26,6 +26,7 @@ internal sealed class ErrorCodes #region Testing Platform error codes public static readonly int TestingPlatformErrorRangeStart = -31700; + public static readonly int ProtocolVersionNotSupported = -31699; public static readonly int TestingPlatformErrorRangeEnd = -31000; #endregion } diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/FormatterUtilities.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/FormatterUtilities.cs index 5f58ec1139..7ed3be2cb6 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/FormatterUtilities.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/FormatterUtilities.cs @@ -22,13 +22,59 @@ internal static IMessageFormatter CreateFormatter() internal sealed class MessageFormatter : IMessageFormatter { + private static readonly Jsonite.JsonSettings NumericTextSettings = new() + { + ParseValuesAsStrings = true, + }; + public string Id => "Jsonite"; public T Deserialize(string serializedUtf8Content) - => SerializerUtilities.Deserialize((Jsonite.JsonObject)Jsonite.Json.Deserialize(serializedUtf8Content)); + { + var properties = (Jsonite.JsonObject)Jsonite.Json.Deserialize(serializedUtf8Content); + PreserveExactRpcIds(properties, serializedUtf8Content); + return SerializerUtilities.Deserialize(properties); + } public Task SerializeAsync(object obj) => Task.FromResult(Jsonite.Json.Serialize(SerializerUtilities.Serialize(obj.GetType(), obj))); + + private static void PreserveExactRpcIds(Jsonite.JsonObject properties, string serializedContent) + { + bool hasFloatingPointMessageId = properties.TryGetValue(JsonRpcStrings.Id, out object? messageId) + && messageId is double; + Jsonite.JsonObject? paramsObject = properties.TryGetValue(JsonRpcStrings.Params, out object? paramsValue) + ? paramsValue as Jsonite.JsonObject + : null; + bool hasFloatingPointCancellationId = properties.TryGetValue(JsonRpcStrings.Method, out object? method) + && method is JsonRpcMethods.CancelRequest + && paramsObject?.TryGetValue(JsonRpcStrings.Id, out object? cancellationId) == true + && cancellationId is double; + if (!hasFloatingPointMessageId && !hasFloatingPointCancellationId) + { + return; + } + + var rawProperties = (Jsonite.JsonObject)Jsonite.Json.Deserialize(serializedContent, NumericTextSettings); + if (hasFloatingPointMessageId) + { + PreserveExactRpcId(properties, rawProperties); + } + + if (hasFloatingPointCancellationId + && rawProperties[JsonRpcStrings.Params] is Jsonite.JsonObject rawParams) + { + PreserveExactRpcId(paramsObject!, rawParams); + } + } + + private static void PreserveExactRpcId(Jsonite.JsonObject properties, Jsonite.JsonObject rawProperties) + { + string rawId = (string)rawProperties[JsonRpcStrings.Id]!; + properties[JsonRpcStrings.Id] = RpcIdParser.TryParseNumericId(rawId, out int id) + ? id + : rawId; + } } #else internal static IMessageFormatter CreateFormatter() => new MessageFormatter(); diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.Deserializers.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.Deserializers.cs index 87ec35c5b0..fab3b33dc0 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.Deserializers.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.Deserializers.cs @@ -90,7 +90,7 @@ private static void RegisterDefaultDeserializers(Dictionary(jsonElement, JsonRpcStrings.Id); + int id = BindRpcId(jsonElement, out string? stringId); IDictionary? result = element.ValueKind == JsonValueKind.Null ? null : json.Bind>(jsonElement, JsonRpcStrings.Result); - return new ResponseMessage(id, result); + return new ResponseMessage(id, result) { StringId = stringId }; } return json.TryBind(jsonElement, out ErrorMessage? errorMessage) ? errorMessage! : throw new MessageFormatException(); }); - deserializers[typeof(InitializeRequestArgs)] = new JsonElementDeserializer((json, jsonElement) => new InitializeRequestArgs( + deserializers[typeof(InitializeRequestArgs)] = new JsonElementDeserializer((json, jsonElement) => + { + json.TryArrayBind(jsonElement, out string[]? protocolVersions, JsonRpcStrings.ProtocolVersions); + if (protocolVersions is not null) + { + for (int i = 0; i < protocolVersions.Length; i++) + { + protocolVersions[i] = protocolVersions[i] + ?? throw new MessageFormatException($"'{JsonRpcStrings.ProtocolVersions}' entries must be strings"); + } + } + + return new InitializeRequestArgs( ProcessId: json.Bind(jsonElement, JsonRpcStrings.ProcessId), ClientInfo: json.Bind(jsonElement, JsonRpcStrings.ClientInfo), - Capabilities: json.Bind(jsonElement, JsonRpcStrings.Capabilities))); + Capabilities: json.Bind(jsonElement, JsonRpcStrings.Capabilities)) + { + ProtocolVersions = protocolVersions, + }; + }); deserializers[typeof(ClientInfo)] = new JsonElementDeserializer((json, jsonElement) => new ClientInfo( Name: json.Bind(jsonElement, JsonRpcStrings.Name), @@ -176,10 +199,26 @@ private static void RegisterDefaultDeserializers(Dictionary( - (json, jsonElement) => new InitializeResponseArgs( + (json, jsonElement) => + { + string? protocolVersion = null; + if (jsonElement.TryGetProperty(JsonRpcStrings.ProtocolVersion, out JsonElement protocolVersionElement) + && protocolVersionElement.ValueKind != JsonValueKind.Null) + { + protocolVersion = protocolVersionElement.ValueKind == JsonValueKind.String + ? protocolVersionElement.GetString() + : throw new MessageFormatException( + $"'{JsonRpcStrings.ProtocolVersion}' field has wrong type (expected String)"); + } + + return new InitializeResponseArgs( ProcessId: json.Bind(jsonElement, JsonRpcStrings.ProcessId), ServerInfo: json.Bind(jsonElement, JsonRpcStrings.ServerInfo), - Capabilities: json.Bind(jsonElement, JsonRpcStrings.Capabilities))); + Capabilities: json.Bind(jsonElement, JsonRpcStrings.Capabilities)) + { + ProtocolVersion = protocolVersion, + }; + }); deserializers[typeof(ServerInfo)] = new JsonElementDeserializer( (json, jsonElement) => new ServerInfo( @@ -192,11 +231,11 @@ private static void RegisterDefaultDeserializers(Dictionary( (json, jsonElement) => new ServerTestingCapabilities( - SupportsDiscovery: json.Bind(jsonElement, JsonRpcStrings.SupportsDiscovery), - MultiRequestSupport: json.Bind(jsonElement, JsonRpcStrings.MultiRequestSupport), - VSTestProviderSupport: json.Bind(jsonElement, JsonRpcStrings.VSTestProviderSupport), - SupportsAttachments: json.Bind(jsonElement, JsonRpcStrings.AttachmentsSupport), - MultiConnectionProvider: json.Bind(jsonElement, JsonRpcStrings.MultiConnectionProvider))); + SupportsDiscovery: json.Bind(jsonElement, JsonRpcStrings.SupportsDiscovery), + MultiRequestSupport: json.Bind(jsonElement, JsonRpcStrings.MultiRequestSupport), + VSTestProviderSupport: json.Bind(jsonElement, JsonRpcStrings.VSTestProviderSupport), + SupportsAttachments: json.Bind(jsonElement, JsonRpcStrings.AttachmentsSupport), + MultiConnectionProvider: json.Bind(jsonElement, JsonRpcStrings.MultiConnectionProvider))); deserializers[typeof(DiscoverRequestArgs)] = new JsonElementDeserializer((json, jsonElement) => { @@ -236,19 +275,41 @@ private static void RegisterDefaultDeserializers(Dictionary { PropertyBag propertyBag = new(); - string uid = json.Bind(properties, JsonRpcStrings.Uid) ?? string.Empty; - string displayName = json.Bind(properties, JsonRpcStrings.DisplayName); + string uid = json.Bind(properties, JsonRpcStrings.Uid) + ?? throw new MessageFormatException($"'{JsonRpcStrings.Uid}' field cannot be null"); + string displayName = json.Bind(properties, JsonRpcStrings.DisplayName) + ?? throw new MessageFormatException($"'{JsonRpcStrings.DisplayName}' field cannot be null"); + if (RoslynString.IsNullOrWhiteSpace(uid)) + { + throw new MessageFormatException($"'{JsonRpcStrings.Uid}' field cannot be empty or whitespace"); + } if (json.TryBind(properties, out string? locationFile, "location.file")) { - json.TryBind(properties, out int locationLineStart, "location.line-start"); - json.TryBind(properties, out int locationLineEnd, "location.line-end"); + if (locationFile is null) + { + throw new MessageFormatException("'location.file' field cannot be null"); + } + + bool hasLineStart = json.TryBind(properties, out int locationLineStart, "location.line-start"); + bool hasLineEnd = json.TryBind(properties, out int locationLineEnd, "location.line-end"); + if (!hasLineStart || !hasLineEnd) + { + throw new MessageFormatException( + "'location.file', 'location.line-start', and 'location.line-end' fields must be specified together"); + } TestFileLocationProperty testFileLocationProperty = new( - locationFile!, + locationFile, new LinePositionSpan(new LinePosition(locationLineStart, 0), new LinePosition(locationLineEnd, 0))); propertyBag.Add(testFileLocationProperty); } + else if (properties.TryGetProperty("location.line-start", out _) + || properties.TryGetProperty("location.line-end", out _)) + { + throw new MessageFormatException( + "'location.file', 'location.line-start', and 'location.line-end' fields must be specified together"); + } return new TestNode { @@ -259,7 +320,9 @@ private static void RegisterDefaultDeserializers(Dictionary( - (json, jsonElement) => json.TryBind(jsonElement, out int id, JsonRpcStrings.Id) ? new CancelRequestArgs(id) : throw new MessageFormatException("id field should be an int")); + (json, jsonElement) => TryGetRpcId(jsonElement, out int id, out string? stringId) + ? new CancelRequestArgs(id) { StringId = stringId } + : throw new MessageFormatException("id field is missing")); deserializers[typeof(ExitRequestArgs)] = new JsonElementDeserializer( (json, jsonElement) => new ExitRequestArgs()); @@ -269,13 +332,16 @@ private static void RegisterDefaultDeserializers(Dictionary(jsonElement, JsonRpcStrings.Id); + int id = BindRpcId(jsonElement, out string? stringId); JsonElement error = jsonElement.GetProperty(JsonRpcStrings.Error); int code = json.Bind(error, JsonRpcStrings.Code); string message = json.Bind(error, JsonRpcStrings.Message); - if (json.TryBind(error, out IDictionary? data, JsonRpcStrings.Data) && data?.Count == 0) + object? data = error.TryGetProperty(JsonRpcStrings.Data, out JsonElement dataElement) + ? ReadUntypedValue(json, dataElement) + : null; + if (data is IDictionary { Count: 0 }) { data = null; } @@ -284,7 +350,10 @@ private static void RegisterDefaultDeserializers(Dictionary element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => ReadNumber(element), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Object => json.Bind>(element), + JsonValueKind.Array => json.Bind(element), + JsonValueKind.Null => null, + _ => throw new MessageFormatException($"Unsupported JSON value kind '{element.ValueKind}'"), + }; + + private static bool TryGetRpcId(JsonElement jsonElement, out int id, out string? stringId) + { + if (!jsonElement.TryGetProperty(JsonRpcStrings.Id, out JsonElement idElement)) + { + id = default; + stringId = null; + return false; + } + + if (idElement.ValueKind == JsonValueKind.Null) + { + throw new MessageFormatException($"'{JsonRpcStrings.Id}' field cannot be null"); + } + + stringId = idElement.ValueKind == JsonValueKind.String ? idElement.GetString() : null; + id = ReadRpcId(idElement); + return true; + } + + private static int BindRpcId(JsonElement jsonElement, out string? stringId) + => jsonElement.TryGetProperty(JsonRpcStrings.Id, out JsonElement idElement) + ? ReadRpcIdAndCaptureString(idElement, out stringId) + : throw new MessageFormatException($"'{JsonRpcStrings.Id}' field is missing"); + + private static int ReadRpcIdAndCaptureString(JsonElement idElement, out string? stringId) + { + stringId = idElement.ValueKind == JsonValueKind.String ? idElement.GetString() : null; + return ReadRpcId(idElement); + } + + private static int ReadRpcId(JsonElement idElement) + => idElement.ValueKind switch + { + JsonValueKind.Number when RpcIdParser.TryParseNumericId(idElement.GetRawText(), out int numericId) => numericId, + JsonValueKind.String when int.TryParse( + idElement.GetString(), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int stringId) + && idElement.GetString() == stringId.ToString(CultureInfo.InvariantCulture) => stringId, + _ => throw new MessageFormatException($"'{JsonRpcStrings.Id}' field should be an int or a numeric string"), + }; } diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.Serializers.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.Serializers.cs index 684f432863..03dd9495a9 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.Serializers.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.Serializers.cs @@ -15,7 +15,7 @@ private static void RegisterDefaultSerializers(Dictionary serializers[typeof(RequestMessage)] = new JsonObjectSerializer(request => [ (JsonRpcStrings.JsonRpc, "2.0"), - (JsonRpcStrings.Id, request.Id), + (JsonRpcStrings.Id, request.StringId ?? (object)request.Id), (JsonRpcStrings.Method, request.Method), (JsonRpcStrings.Params, request.Params) ]); @@ -23,7 +23,7 @@ private static void RegisterDefaultSerializers(Dictionary serializers[typeof(ResponseMessage)] = new JsonObjectSerializer(response => [ (JsonRpcStrings.JsonRpc, "2.0"), - (JsonRpcStrings.Id, response.Id), + (JsonRpcStrings.Id, response.StringId ?? (object)response.Id), (JsonRpcStrings.Result, response.Result) ]); @@ -46,17 +46,26 @@ private static void RegisterDefaultSerializers(Dictionary return [ (JsonRpcStrings.JsonRpc, "2.0"), - (JsonRpcStrings.Id, error.Id), + (JsonRpcStrings.Id, error.StringId ?? (object)error.Id), (JsonRpcStrings.Error, errorMsg) ]; }); serializers[typeof(InitializeResponseArgs)] = new JsonObjectSerializer(response => - [ - (JsonRpcStrings.ProcessId, response.ProcessId), - (JsonRpcStrings.ServerInfo, response.ServerInfo), - (JsonRpcStrings.Capabilities, response.Capabilities) - ]); + response.ProtocolVersion is null + ? + [ + (JsonRpcStrings.ProcessId, response.ProcessId), + (JsonRpcStrings.ServerInfo, response.ServerInfo), + (JsonRpcStrings.Capabilities, response.Capabilities) + ] + : + [ + (JsonRpcStrings.ProcessId, response.ProcessId), + (JsonRpcStrings.ServerInfo, response.ServerInfo), + (JsonRpcStrings.Capabilities, response.Capabilities), + (JsonRpcStrings.ProtocolVersion, response.ProtocolVersion) + ]); serializers[typeof(ServerInfo)] = new JsonObjectSerializer(info => [ @@ -117,7 +126,7 @@ private static void RegisterDefaultSerializers(Dictionary serializers[typeof(CancelRequestArgs)] = new JsonObjectSerializer(request => [ - (JsonRpcStrings.Id, request.CancelRequestId) + (JsonRpcStrings.Id, request.StringId ?? (object)request.CancelRequestId) ]); serializers[typeof(TelemetryEventArgs)] = new JsonObjectSerializer(ev => diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.cs index ea0fb7b061..637c69d6e4 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/Json/Json.cs @@ -106,7 +106,8 @@ internal bool TryBind(JsonElement element, out T? value, string? property = n internal bool TryArrayBind(JsonElement element, out T[]? value, string? property = null) { - if (property is not null && !element.TryGetProperty(property, out element)) + if (property is not null + && (!element.TryGetProperty(property, out element) || element.ValueKind == JsonValueKind.Null)) { value = default; return false; diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/JsonRpcMethods.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/JsonRpcMethods.cs index 20ada74dff..be7e8f7828 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/JsonRpcMethods.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/JsonRpcMethods.cs @@ -17,6 +17,34 @@ internal static class JsonRpcMethods public const string TestingTestUpdatesAttachments = "testing/testUpdates/attachments"; } +internal static class JsonRpcProtocolVersions +{ + public const string V1 = "1.0.0"; + + public static string Current => V1; + + public static IReadOnlyList Supported { get; } = Array.AsReadOnly([V1]); + + public static string? Negotiate(IReadOnlyCollection? clientSupportedVersions) + { + if (clientSupportedVersions is null || clientSupportedVersions.Count == 0) + { + return V1; + } + + IReadOnlyList serverSupportedVersions = Supported; + for (int i = serverSupportedVersions.Count - 1; i >= 0; i--) + { + if (clientSupportedVersions.Contains(serverSupportedVersions[i])) + { + return serverSupportedVersions[i]; + } + } + + return null; + } +} + internal static class JsonRpcStrings { // Common @@ -35,6 +63,8 @@ internal static class JsonRpcStrings public const string ServerInfo = "serverInfo"; public const string Name = "name"; public const string Version = "version"; + public const string ProtocolVersions = "protocolVersions"; + public const string ProtocolVersion = "protocolVersion"; // Capabilities public const string Capabilities = "capabilities"; diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/PassiveNode.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/PassiveNode.cs index ea6237ce3f..1df705f357 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/PassiveNode.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/PassiveNode.cs @@ -59,7 +59,45 @@ public async Task ConnectAsync() await _logger.LogTraceAsync(message.ToString()).ConfigureAwait(false); } - var requestMessage = (RequestMessage)message; + if (message is not RequestMessage requestMessage) + { + return false; + } + + if (requestMessage.Method != JsonRpcMethods.Initialize) + { + await SendErrorAsync( + requestMessage.Id, + ErrorCodes.ServerNotInitialized, + "The server must be initialized before this request can be processed.", + _testApplicationCancellationTokenSource.CancellationToken, + requestMessage.StringId).ConfigureAwait(false); + return false; + } + + if (requestMessage.Params is not InitializeRequestArgs initializeRequest) + { + await SendErrorAsync( + requestMessage.Id, + ErrorCodes.InvalidParams, + "The initialize request params are invalid.", + _testApplicationCancellationTokenSource.CancellationToken, + requestMessage.StringId).ConfigureAwait(false); + return false; + } + + string? negotiatedProtocolVersion = JsonRpcProtocolVersions.Negotiate(initializeRequest.ProtocolVersions); + if (negotiatedProtocolVersion is null) + { + await SendErrorAsync( + requestMessage.Id, + ErrorCodes.ProtocolVersionNotSupported, + $"None of the client's protocol versions are supported. Server versions: {string.Join(", ", JsonRpcProtocolVersions.Supported)}.", + _testApplicationCancellationTokenSource.CancellationToken, + requestMessage.StringId).ConfigureAwait(false); + return false; + } + var responseObject = new InitializeResponseArgs( ProcessId: _environment.ProcessId, ServerInfo: new ServerInfo("test-anywhere", Version: PlatformVersion.Version), @@ -71,17 +109,44 @@ public async Task ConnectAsync() // This means we push attachments SupportsAttachments: true, // This means we're a push node - MultiConnectionProvider: true))); + MultiConnectionProvider: true))) + { + ProtocolVersion = negotiatedProtocolVersion, + }; - await SendResponseAsync(requestMessage.Id, responseObject, _testApplicationCancellationTokenSource.CancellationToken).ConfigureAwait(false); + await SendResponseAsync( + requestMessage.Id, + responseObject, + _testApplicationCancellationTokenSource.CancellationToken, + requestMessage.StringId).ConfigureAwait(false); return true; } - private async Task SendResponseAsync(int reqId, object result, CancellationToken cancellationToken) + private async Task SendErrorAsync( + int reqId, + int errorCode, + string message, + CancellationToken cancellationToken, + string? stringId) + { + AssertInitialized(); + + ErrorMessage error = new(reqId, errorCode, message, Data: null) { StringId = stringId }; + using (await _messageMonitor.LockAsync(cancellationToken).ConfigureAwait(false)) + { + await _messageHandler.WriteRequestAsync(error, cancellationToken).ConfigureAwait(false); + } + } + + private async Task SendResponseAsync( + int reqId, + object result, + CancellationToken cancellationToken, + string? stringId) { AssertInitialized(); - ResponseMessage response = new(reqId, result); + ResponseMessage response = new(reqId, result) { StringId = stringId }; using (await _messageMonitor.LockAsync(cancellationToken).ConfigureAwait(false)) { await _messageHandler.WriteRequestAsync(response, cancellationToken).ConfigureAwait(false); diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/RpcIdParser.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/RpcIdParser.cs new file mode 100644 index 0000000000..ab2279fe1c --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/RpcIdParser.cs @@ -0,0 +1,94 @@ +// 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.CodeAnalysis; + +namespace Microsoft.Testing.Platform.ServerMode; + +[Embedded] +internal static class RpcIdParser +{ + public static bool TryParseNumericId(string value, out int result) + { + int start = value[0] == '-' ? 1 : 0; + bool isNegative = start == 1; + int exponentIndex = value.IndexOf('e'); + if (exponentIndex < 0) + { + exponentIndex = value.IndexOf('E'); + } + + string mantissa = exponentIndex < 0 ? value.Substring(start) : value.Substring(start, exponentIndex - start); + int decimalPointIndex = mantissa.IndexOf('.'); + int fractionalDigits = decimalPointIndex < 0 ? 0 : mantissa.Length - decimalPointIndex - 1; + string digits = decimalPointIndex < 0 ? mantissa : mantissa.Remove(decimalPointIndex, 1); + if (digits.All(c => c == '0')) + { + result = 0; + return true; + } + + int exponent = 0; + if (exponentIndex >= 0 + && !int.TryParse( + value.Substring(exponentIndex + 1), + NumberStyles.AllowLeadingSign, + CultureInfo.InvariantCulture, + out exponent)) + { + result = default; + return false; + } + + long scale = (long)fractionalDigits - exponent; + if (scale > 0) + { + if (scale > digits.Length) + { + result = default; + return false; + } + + int firstFractionalIndex = digits.Length - (int)scale; + for (int i = firstFractionalIndex; i < digits.Length; i++) + { + if (digits[i] != '0') + { + result = default; + return false; + } + } + + digits = digits.Substring(0, firstFractionalIndex); + } + else if (scale < 0) + { + long trailingZeroCount = -scale; + int significantDigitCount = digits.TrimStart('0').Length; + if (trailingZeroCount > 10 || significantDigitCount + trailingZeroCount > 10) + { + result = default; + return false; + } + + digits += new string('0', (int)trailingZeroCount); + } + + digits = digits.TrimStart('0'); + if (!long.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out long magnitude)) + { + result = default; + return false; + } + + long signedValue = isNegative ? -magnitude : magnitude; + if (signedValue is < int.MinValue or > int.MaxValue) + { + result = default; + return false; + } + + result = (int)signedValue; + return true; + } +} diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/RpcMessages.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/RpcMessages.cs index f9efe9faa8..557f56e1d8 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/RpcMessages.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/RpcMessages.cs @@ -12,7 +12,10 @@ internal abstract record RpcMessage; /// A request is a message for which the server should return a corresponding /// or . /// -internal sealed record RequestMessage(int Id, string Method, object? Params) : RpcMessage; +internal sealed record RequestMessage(int Id, string Method, object? Params) : RpcMessage +{ + public string? StringId { get; init; } +} /// /// A notification message is a message that notifies the server of an event. @@ -24,7 +27,10 @@ internal sealed record NotificationMessage(string Method, object? Params) : RpcM /// /// An error message is sent if some exception was thrown when processing the request. /// -internal sealed record ErrorMessage(int Id, int ErrorCode, string Message, object? Data) : RpcMessage; +internal sealed record ErrorMessage(int Id, int ErrorCode, string Message, object? Data) : RpcMessage +{ + public string? StringId { get; init; } +} /// /// An response message is sent if a request is handled successfully. @@ -33,11 +39,20 @@ internal sealed record ErrorMessage(int Id, int ErrorCode, string Message, objec /// If the RPC handler returns a the /// will be returned as null. /// -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,