Skip to content

Harden the MTP client: UTF-8 framing, shutdown, uid filter, and tests - #12

Merged
nohwnd merged 8 commits into
nohwnd:nohwnd-mtp-source-client-integrationfrom
azat-msft:azat-msft-mtp-client-hardening
Jul 30, 2026
Merged

Harden the MTP client: UTF-8 framing, shutdown, uid filter, and tests#12
nohwnd merged 8 commits into
nohwnd:nohwnd-mtp-source-client-integrationfrom
azat-msft:azat-msft-mtp-client-hardening

Conversation

@azat-msft

@azat-msft azat-msft commented Jul 28, 2026

Copy link
Copy Markdown

Stacked on your nohwnd-mtp-source-client-integration branch — targeting it directly rather than main so it reads as an increment on your work and you can take it as a fast-forward.

Started as a review of microsoft#16300, then turned into fixes for what the review turned up. Companion testfx PR: microsoft/testfx#10297 (stacked on microsoft#10085).

1. A regression the retarget introduces: UTF-8 frame desynchronization

The single most important item, because it is a step backwards rather than an inherited flaw.

MTP frames declare Content-Length in UTF-8 bytes. The package's TcpMessageHandler writes it that way:

int byteCount = Encoding.UTF8.GetByteCount(messageStr);
await _writer.WriteLineAsync($"Content-Length: {byteCount}");

but reads it as a character count:

char[] commandCharsBuffer = ArrayPool<char>.Shared.Rent(commandSize);
await _reader.ReadBlockAsync(memoryBuffer, cancellationToken);

For any frame carrying multi-byte UTF-8 the two disagree: the reader under-reads, leaves the body's tail in the stream, and the framing desynchronizes from the next message onward.

The deleted MtpServerConnection was byte-correct here — it read count bytes into a byte[] and then Encoding.UTF8.GetString(body) — so this is newly introduced, not pre-existing.

Fixed upstream in microsoft/testfx#10297: read exactly Content-Length bytes and decode, and read headers through the same byte-level buffer so no StreamReader can swallow part of the body across the boundary. This PR consumes that drop.

Two findings worth recording, because they change how the bug should be described:

  • It is not reachable end to end through a .NET MTP app today. The server serializes with System.Text.Json, whose default encoder escapes non-ASCII to \uXXXX, so the bytes on the wire are ASCII and byte count coincidentally equals character count. It has to be proved at the unit level against the transport — which is what the testfx PR does. The acceptance test here is a name-integrity guard and its comments say so rather than overclaiming.
  • A separate defect surfaced while writing it. Astral-plane characters are escaped as a surrogate pair and arrive in the TRX as the literal text \ud83c\udf89 instead of the character; BMP characters decode correctly. I have not chased it down and it is not addressed here — flagging it as its own issue. The test name is narrowed to BMP multi-byte characters so it does not trip over this.

2. Shutdown: cancellation and an unbounded wait

Exit changed from a fire-and-forget notification into an awaited request/response, which introduced two problems:

  • It was awaited on the run's own cancellation token. Cancelling or aborting is exactly when that token is already cancelled, so ExitAsync threw immediately and the graceful handshake was skipped in the one case it matters most.
  • The await was unbounded. A test application that never acknowledges exit would hang discovery or execution indefinitely; the notification it replaced could not block at all.

Both managers now go through MtpServerClientFactory.TryExit, which runs on its own bounded token, swallows failures (the caller disposes next, which tears the process down anyway), and is called from a finally so a failed or cancelled run still shuts down.

The factory also exposes a replaceable Launch delegate purely so the managers can be driven against a fake server in tests. Production always uses MtpServerClient.Launch. No public API added.

3. A missing node uid silently ran zero tests

BuildUids substituted FullyQualifiedName when a TestCase carried no MTP.TestNode.Uid. Per G3 the server projects node.Uid alone and never reads any other field, so that substitution produces a filter matching nothing: the run reports success having executed zero of the tests the user selected, with no error anywhere.

Now throws, with a comment at the site explaining why no fallback is correct — the fallback looks defensive, so without the rationale written down someone will restore it.

(The good news: I checked, and the uid does survive cross-process serialization. JsoniteConvert round-trips custom properties and auto-registers unknown ones by id, so the VS / AzDO "run selected tests" path is fine. The defect was only the fallback.)

4. Coercion fixes

  • TryGetRawInt used unchecked((int)l), wrapping out-of-range values into a plausible-looking wrong line number. Now range-checked, so the property stays at its visibly-unset default.
  • AddTraits collapsed every non-string trait value to string.Empty. Since the formatters box JSON scalars differently, a numeric or boolean trait was silently dropped on one formatter and kept on the other. Now formatted invariantly.

5. Deduplicated the connection timeout

MtpClientOptionsFactory re-read VSTEST_CONNECTION_TIMEOUT and hardcoded the 90-second default. EnvironmentHelper.GetConnectionTimeout() already owns this, is used by seven other call sites, and also traces the override. Carried over from the deleted MtpClientHelpers, but this is a new file, so it seemed the moment to stop copying the literals forward.

6. Tests

There were none for any MTP code. The retarget deletes the transport and leaves behind pure, dependency-free conversion logic, which is ideal to test directly.

85 new unit tests, covering the normalized-Node contract and per-formatter number boxing (G1), outcome mapping, the action-node filter, bridge properties, standard output/error, traits, duration, log levels, the env-var casing behaviour (G5), the uid filter (G3), and both manager flows against a fake server.

Worth noting these turn three of the five G-items from "confirmed verbally by the package owner" into executable assertions that keep holding across future package drops.

Verification

  • CrossPlatEngine builds clean on net462, netstandard2.0 and net8.0.
  • MTP unit tests 140/140 (70 per axis, net11.0 and net481).
  • MtpUnderVstestTests 16/16 on both console axes.

On that last number: the two /logger:trx failures you saw do not reproduce here, which supports your read that they are a local deployment issue rather than anything in the retarget.

Still interim

eng/local-mtp-feed now holds a local build of testfx#10297, pinned as 2.4.0-dev.utf8fix1 — uniquely named so NuGet cannot silently resolve a stale cache entry while the package is served from a committed folder, matching the convention the branch already used for 2.4.0-dev.numberfix. All of it still comes out once microsoft#10085 ships to a real feed.

One suggestion unrelated to the above: the IntegrationTestBuild.GetNugetSourceParameters change is labelled interim, but the rewrite from .Descendants("add") + attribute scraping to .Elements("add") + .Attribute("value") fixes a latent bug independent of the local feed, and the reordering becomes a no-op once the feed is gone. Might be worth keeping permanently and splitting into its own commit that can merge ahead of everything else.

azat-msft and others added 8 commits July 28, 2026 17:02
The retarget onto Microsoft.Testing.Platform.ServerClient.Source left the MTP
glue with no unit coverage at all - the only tests were the end-to-end
MtpUnderVstestTests. The conversion code is now pure and dependency-free, so
cover it directly.

Add MtpTestNodeConverterTests and MtpClientOptionsFactoryTests (55 tests)
covering the normalized-Node contract, per-formatter number boxing, outcome
mapping, the action-node filter, vstest bridge properties, standard
output/error, traits, duration and log-level mapping.

Three fixes fall out of writing them:

- TryGetRawInt wrapped out-of-range values with unchecked((int)l), turning a
  bad line number into a plausible-looking wrong answer. Range-check instead so
  the property stays at its visibly-unset default.
- AddTraits collapsed every non-string trait value to an empty string. The two
  formatters box JSON scalars differently, so a numeric or boolean trait was
  silently dropped on one formatter and kept on the other. Format invariantly.
- MtpClientOptionsFactory re-read VSTEST_CONNECTION_TIMEOUT and hardcoded the
  90-second default instead of calling EnvironmentHelper.GetConnectionTimeout,
  which seven other vstest call sites already use and which also traces the
  override.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cd0793f4-d530-44b6-b881-fed3be6aa52f
Retargeting onto the source package changed exit from a fire-and-forget
notification into an awaited request/response call, which introduced two
regressions:

- Exit was awaited on the run's own cancellation token. Cancelling or aborting
  a run is exactly when that token is already cancelled, so ExitAsync threw
  immediately and the graceful shutdown handshake was skipped in the one case
  it matters most.
- The await was unbounded, so a test application that never acknowledges exit
  would hang discovery or execution indefinitely. The notification it replaced
  could not block at all.

Route both proxy managers through MtpServerClientFactory: TryExit runs on its
own bounded token, swallows failures (the caller disposes the client next,
which tears the process down regardless), and is called from a finally block so
a failed or cancelled run still shuts the application down.

The factory also exposes a replaceable Launch delegate so the managers can be
driven against a fake server in unit tests; production always uses
MtpServerClient.Launch.

Separately, BuildUids substituted FullyQualifiedName when a TestCase carried no
MTP.TestNode.Uid. The server projects node.Uid alone when building a run filter
and never reads any other field, so that substitution produced a filter
matching nothing: the run reported success having executed zero of the tests
the user selected, with no error anywhere. Throw instead, with a comment
explaining why no fallback is correct.

Adds 15 tests covering the shutdown paths, the uid filter, and both manager
flows against a fake MTP server.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cd0793f4-d530-44b6-b881-fed3be6aa52f
MTP frames declare Content-Length in UTF-8 bytes, but the transport shipped by
Microsoft.Testing.Platform.ServerClient.Source reads that number of characters:
it rents a char buffer of Content-Length and calls StreamReader.ReadBlockAsync.
For any frame carrying multi-byte UTF-8 the two disagree, so the reader
under-reads and leaves the body's tail to be parsed as the next frame's headers
- the connection desynchronizes from the following message onward.

vstest's deleted MtpServerConnection was byte-correct here (it read Content-Length
bytes into a byte[] and then UTF-8-decoded), so the retarget is a regression, not
an inherited defect. Client-to-server traffic is ASCII in practice, which is why
it has not surfaced; node updates flow the other way and carry user-authored test
names.

Give MtpMSTestProject a test whose display name mixes German umlauts (2 bytes
each), Japanese (3 bytes each) and an emoji (4 bytes, 2 chars), and mirror it in
MtpPureProject. Because the corruption lands on the message *after* the offending
one, its mere presence makes the whole run fail rather than just that test, so
every existing MTP scenario now exercises the transport with multi-byte content.
Adds a dedicated test asserting the name survives into the TRX.

These fail until the fix lands upstream in testfx.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cd0793f4-d530-44b6-b881-fed3be6aa52f
…ount

Running the acceptance test revealed two things worth recording.

First, an end-to-end MTP run cannot reproduce the Content-Length byte-vs-char
framing bug: the .NET MTP server serializes with System.Text.Json, whose default
encoder escapes every non-ASCII character to \\uXXXX, so the bytes on the wire
are ASCII and the byte count coincidentally equals the character count. The
framing bug is real but has to be proved at the unit level against the transport
directly, which is what the companion testfx change does. This test is therefore
a name-integrity guard, and its comments now say so rather than overclaiming.

Second, the emoji originally in the name exposed a separate defect: astral-plane
characters are escaped by System.Text.Json as a surrogate pair and arrive in the
TRX as the literal text \\ud83c\\udf89 instead of the character. BMP characters
decode correctly. That is its own bug, tracked separately, so the name is
narrowed to BMP multi-byte characters (umlauts 2 bytes, Japanese 3 bytes) which
still exercise the byte-denominated length without tripping over it.

Also updates the out-of-proc data collector's expected per-test-case attachment
count, which follows the test count.

MtpUnderVstestTests: 16/16 on both console axes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cd0793f4-d530-44b6-b881-fed3be6aa52f
Replaces the interim local-feed pack with a build of microsoft/testfx#10297,
which stacks the Content-Length byte/char fix onto microsoft#10085. The transport now
reads exactly Content-Length bytes and UTF-8-decodes them, symmetric with the
write path, and reads the headers through the same byte-level buffer so no
StreamReader can buffer part of the body across the boundary.

That drop also carries microsoft#10085's ServerRequestHandler signature change (the
result is now constrained to a serializable dictionary), so FakeMtpServerClient
is updated to match.

Verification on this drop:
- CrossPlatEngine builds clean on net462, netstandard2.0 and net8.0.
- MTP unit tests 140/140 (70 per axis, net11.0 and net481).
- MtpUnderVstestTests 16/16 on both console axes.

Note the 16/16: the two /logger:trx failures reported against the earlier drop
do not reproduce here, so they look like a local deployment issue rather than
anything in the retarget.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cd0793f4-d530-44b6-b881-fed3be6aa52f
Swaps the interim feed pack from the plain 2.4.0-dev build output to the
coordinator's canonical 2.4.0-dev.utf8fix1 drop of microsoft/testfx#10297.

Byte-equivalent content: all 184 contentFiles are identical between the two
packs, including TcpMessageHandler.cs with both ReadExactlyAsync and the
TrimPreamble BOM tolerance. Only the version metadata differs.

The rename is the point. While the package is served from a committed local
folder, NuGet caches by version, so a plain 2.4.0-dev risks silently resolving a
stale cache entry from an earlier drop of the same name. The unique suffix makes
that impossible, matching the convention the branch already used for
2.4.0-dev.numberfix.

Re-verified from a cleared package cache: CrossPlatEngine clean on all three
TFMs, MTP unit tests 140/140, MtpUnderVstestTests 16/16.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cd0793f4-d530-44b6-b881-fed3be6aa52f
Localize the missing-uid error. The message reaches the user verbatim -
StartTestRun funnels ex.Message into HandleLogMessage(Error) - and every other
user-facing TestPlatformException in this assembly is resourced, so a hardcoded
English string formatted with CurrentCulture was self-contradictory. Adds
MtpTestCaseMissingNodeUid to Resources.resx, the generated designer property,
and a trans-unit to all 13 xlf files. The text now also states the remedy
(re-run discovery, or run without a selection) rather than only naming the
failure, and the comment records that aborting the whole source is deliberate:
silently running the addressable subset would recreate the same class of bug in
a smaller form.

Mark the three new test classes DoNotParallelize. MSTest parallelizes across
classes at MethodLevel by default here, and these classes mutate process-global
state - the MtpServerClientFactory.Launch seam and VSTEST_CONNECTION_TIMEOUT -
so a save/restore in TestInitialize/TestCleanup could restore one class's value
while another class's test was still relying on its own. That would have flaked
in CI looking like a product bug.

Close a hole in the float range guard. (float)int.MaxValue rounds *up* to
2147483648f, so comparing a float directly against int.MaxValue let that value
through and the cast then saturated - precisely the plausible-looking wrong
answer the guard exists to reject. Widen to double before comparing, and extend
the regression test to cover it.

Capture ProcessId before the exit handshake instead of reading it afterwards,
when the process may already be gone.

Test fixes: TryExitDoesNotUseAnAlreadyCancelledRunToken was vacuous (it built a
cancelled token it never passed anywhere) and LaunchDefaultsToTheRealClientLauncher
asserted only non-null, which any delegate satisfies. Both now assert something
that fails if the behaviour regresses. Adds the missing mixed-selection case,
where only some tests carry a uid.

Also fixes a stale test-count comment and softens an overclaim in
MtpPureProject, which no test currently references.

Unit tests 142/142 across net11.0 and net481; MtpUnderVstestTests 16/16 on both
console axes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cd0793f4-d530-44b6-b881-fed3be6aa52f
Picks up the two commits that landed on the testfx branch after the utf8fix1
pack: the header line buffer is now reused across lines instead of allocated per
line (server mode emits a notification per test, so that was a real hot-path
allocation), plus comments recording why Content-Length is intentionally not
capped and why the framing tests are not cross-TFM coverage.

Both changes are to TcpMessageHandler, which compiles into CrossPlatEngine, so
they are verified here rather than assumed. Re-verified from a cleared NuGet
package cache:

- CrossPlatEngine builds clean on net462, netstandard2.0 and net8.0.
- MTP unit tests 142/142 across net11.0 and net481.
- MtpUnderVstestTests 16/16 on both console axes.
- testfx's own ServerClient unit tests 48/48, confirming the shared transport is
  still good on both formatter paths.

The buffer is safe to hold as instance state for the same reason the existing
read offsets are: reads are single-threaded, driven by exactly one read loop.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cd0793f4-d530-44b6-b881-fed3be6aa52f
@nohwnd
nohwnd merged commit 93dfe63 into nohwnd:nohwnd-mtp-source-client-integration Jul 30, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants