Skip to content

Commit caee29a

Browse files
committed
fix(channels): validate BoundedChannelOptions::FullMode on assignment (#1969)
Rule-14 sweep. This ticket's recorded blocker was an APPROVAL REQUEST, and SA-8 grants it verbatim: "making a public data member private and adding the rule-5 accessor pair" is the question #1969 asked on 2026-08-03, and SA-8 adds that the decision "is not to be re-litigated ticket by ticket". FullMode was a bare public mutable data member, and the shape was the whole obstacle -- a data member has nowhere to put a check. So static_cast<BoundedChannelFullMode>(99) could be stored, and then the writer took the drop path (mode != Wait) and matched no arm of the drop switch, so nothing was dropped and Count reached 2 on a channel bounded at 1. A caller or a deserialized value could defeat the bounded-memory contract outright. It is now private behind getFullModeProperty()/setFullModeProperty(), with .NET's four-arm switch and ArgumentOutOfRangeException("value") transcribed from ChannelOptions.cs:80-97. The reference corrects the ticket twice. .NET validates TWO members, not one -- and this port already had Capacity right, private with ThrowIfNegative(..., "value") in both its constructor and its setter, so nothing there needed changing. And the capacity bound is "< 0", not "< 1": a zero-capacity channel is legal in .NET and here, so the intuitive "at least one slot" repair would be a divergence. The three base flags stay public data members, deliberately, and the boundary is pinned rather than left looking like an oversight. .NET's SingleWriter, SingleReader and AllowSynchronousContinuations are auto-properties with no validation at all, so a public field is observationally identical; SA-8 reaches a representation .NET keeps private, readonly or absent, not one it publishes this freely. Converting them would be a source break buying no behaviour -- the exact inverse of FullMode's case. Five mutations, all caught. M3 only after repairing a vacuous assertion: it searched what() for "value", and the default message is "Specified argument was out of the range of valid values.", which contains that substring whatever the parameter is named. It now asserts getParamNameProperty(). M5 additionally hangs a pre-existing case, and a mutation caught only as a hang is not caught by name. Fixture set 38/204 -> 39/207. Six first-party sites migrated. Downstream measured: 0 sites in cna, 0 in mobile-eggbert. Filed #2388 for the identical shape one module over (ParallelOptions::MaxDegreeOfParallelism), whose header cited this ticket as its reason and is corrected here -- with the note that its parameter name is already right: .NET writes nameof(MaxDegreeOfParallelism) there and nameof(value) in Channels, and both are transcribed as they are. Gate: 17,430 run, 17,430 passed, 0 failed, 0 skipped across 38 executables (+6 on 17,424; SharpRuntimeTests_Threading_Channels 64 -> 70; no other executable moved). Module graph unchanged at 41/93.
1 parent 731ac28 commit caee29a

8 files changed

Lines changed: 369 additions & 12 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — `BoundedChannelOptions::FullMode` is validated on assignment (ticket #1969)
5+
6+
*2026-08-19.* `System::Threading::Channels::BoundedChannelOptions::FullMode` was a bare public
7+
mutable data member. It is now private, behind `getFullModeProperty()` / `setFullModeProperty()`,
8+
and an undeclared enumerator is rejected with `ArgumentOutOfRangeException` — as .NET's property
9+
setter does.
10+
11+
**This is a public source break.** Landed under `docs/StandingApprovals.md` **SA-8**, whose first
12+
bullet — *"making a public data member private and adding the rule-5 accessor pair"* — is exactly
13+
the approval this ticket had been waiting for since 2026-08-03. SA-2's five conditions are
14+
discharged below.
15+
16+
---
17+
18+
## 1. What was wrong, and why the shape was the obstacle
19+
20+
The obstacle was never the check's logic. A bare data member has **nowhere to put a check**, so
21+
validation was unreachable without changing the shape — which is what the ticket was blocked on.
22+
23+
The consequence was not cosmetic:
24+
25+
```cpp
26+
BoundedChannelOptions options(1);
27+
options.FullMode = static_cast<BoundedChannelFullMode>(99); // used to compile and stick
28+
auto channel = Channel<int>::CreateBounded(options);
29+
channel.Writer->TryWrite(1);
30+
channel.Writer->TryWrite(2); // used to SUCCEED -- Count reaches 2 on a channel bounded at 1
31+
```
32+
33+
The writer asks `fullMode != Wait` to decide whether to take the drop path, and `99 != Wait`, so
34+
it does. It then switches on the mode to decide *what* to drop, and `99` matches no arm — so
35+
nothing is dropped and the item is appended anyway. A caller, or a deserialized value, could
36+
defeat the bounded-memory contract outright.
37+
38+
## 2. What changed
39+
40+
| | Was | Is |
41+
|---|---|---|
42+
| `FullMode` | public mutable data member | **private** `fullMode_` |
43+
| read | `opts.FullMode` | `opts.getFullModeProperty()` |
44+
| write | `opts.FullMode = m` | `opts.setFullModeProperty(m)` |
45+
| an undeclared value | stored silently | `ArgumentOutOfRangeException`, param name **`"value"`** |
46+
| default | `Wait` | `Wait` — unchanged |
47+
| `Capacity` | already correct | **untouched** |
48+
| `SingleWriter`/`SingleReader`/`AllowSynchronousContinuations` | public data members | **still public data members** (§4) |
49+
50+
The setter is transcribed from `ChannelOptions.cs:80-97`: a four-arm switch over the declared
51+
enumerators with a `default` that throws `ArgumentOutOfRangeException(nameof(value))`.
52+
53+
## 3. Two corrections to the ticket's premise
54+
55+
**The ticket names one validated member; .NET has two.** `BoundedChannelOptions.Capacity` is
56+
validated as well — its setter and its constructor both throw
57+
`ArgumentOutOfRangeException(nameof(value))` when the value is negative. **This port already got
58+
that one right**: `capacity_` is private, and both the constructor and `setCapacityProperty` call
59+
`ThrowIfNegative(..., "value")`. Nothing there needed changing, and this note records it so the
60+
next reader does not "fix" a member that already matches.
61+
62+
**The capacity bound is `< 0`, not `< 1`.** .NET permits `Capacity == 0`, and this port does too.
63+
That is worth stating because the intuitive repair — requiring at least one slot — would be a
64+
divergence, and a zero-capacity channel is a working rendezvous shape with its own tests here.
65+
66+
## 4. The three base flags stay public data members, deliberately
67+
68+
.NET's `SingleWriter`, `SingleReader` and `AllowSynchronousContinuations` are plain auto-properties
69+
with **no validation at all** (`ChannelOptions.cs:17,27,39`) — `{ get; set; }` and nothing else. A
70+
public data member is observationally identical to that. SA-8 reaches a representation .NET keeps
71+
*private, readonly or absent*; it does not reach one .NET publishes as freely as this.
72+
73+
Converting them would be a source break that buys no behaviour — the exact opposite of `FullMode`,
74+
where the shape was the only thing preventing a check .NET actually performs. The boundary is
75+
pinned by `Decl1969_TheThreeBaseFlagsStayPublicDataMembers` rather than left looking like an
76+
oversight, the same way #2330 pinned `ValueTuple` when `Tuple` changed.
77+
78+
## 5. To migrate
79+
80+
```cpp
81+
// before
82+
BoundedChannelOptions options(2);
83+
options.FullMode = BoundedChannelFullMode::DropOldest;
84+
auto mode = options.FullMode;
85+
86+
// after
87+
BoundedChannelOptions options(2);
88+
options.setFullModeProperty(BoundedChannelFullMode::DropOldest);
89+
auto mode = options.getFullModeProperty();
90+
```
91+
92+
A value outside the four declared enumerators now **throws** where it used to be stored. If you
93+
were relying on that — you were relying on the defect in §1.
94+
95+
## 6. Evidence
96+
97+
Five mutations, **all caught**:
98+
99+
| Mutation | Caught by |
100+
|---|---|
101+
| M1 — the setter stores without validating | `Fix1969_AnUndeclaredValueIsRejected`, `Fix1969_TheBoundedMemoryContractCanNoLongerBeDefeated`, `Fix1969_TheParameterNameIsValueNotFullMode` |
102+
| M2 — one declared arm (`DropWrite`) dropped | `Fix1969_EveryDeclaredModeIsAccepted` **and two pre-existing tests** |
103+
| M3 — parameter name is `"FullMode"` rather than .NET's `nameof(value)` | `Fix1969_TheParameterNameIsValueNotFullMode`**only after the test was repaired**, see below |
104+
| M4 — rejection half-applies before throwing | `Fix1969_AnUndeclaredValueIsRejected`, `Fix1969_TheBoundedMemoryContractCanNoLongerBeDefeated` |
105+
| M5 — the default is no longer `Wait` | `Fix1969_TheDefaultIsWait`, `Fix1969_AnUndeclaredValueIsRejected`, `Fix1969_TheBoundedMemoryContractCanNoLongerBeDefeated` |
106+
107+
**M5 is also worth a note.** Beyond the three named failures, it makes a *pre-existing*
108+
`ChannelTests` case **hang** — a writer that expected `Wait`-mode blocking takes a drop path
109+
instead. A mutation caught only as a hang is not caught *by name*, which is why the three named
110+
failures matter: they identify the change in under a second, before the suite reaches the case
111+
that would otherwise just stop.
112+
113+
**M3 is worth recording, because the first version of its test was vacuous.** It searched
114+
`what()` for the substring `"value"` — and `ArgumentOutOfRangeException`'s default message is
115+
*"Specified argument was out of the range of valid values."*, which contains that substring
116+
**whatever the parameter is named**. The test passed against the mutation. It now asserts
117+
`getParamNameProperty() == "value"`, which is the thing actually under test.
118+
119+
Negative consumer fixture: `test/consumer/threading_channels_fullmode_private_negative.cpp`,
120+
three sites, all rejected. Fixture set grows to **39 fixtures / 207 sites**. Site 3 is the
121+
spelling that used to compile *and run*, storing a value no switch arm handles.
122+
123+
## 7. Downstream, measured
124+
125+
Per SA-2 condition 5: `FullMode` appears in **zero** places in `cna` and **zero** in
126+
`mobile-eggbert`. Neither repository was modified, and no downstream ticket is needed.
127+
128+
First-party migration was six sites, all in this repository's own tests plus one read in
129+
`Channel<T>::CreateBounded`.
130+
131+
## 8. The identical shape, one module over
132+
133+
`System::Threading::Tasks::ParallelOptions::MaxDegreeOfParallelism` is the same *shape* — a public
134+
mutable data member where .NET has a validating property — and its header said so, citing #1969 as
135+
the reason it was approval-gated. Ticket #1966 validated it at the **use site** instead, precisely
136+
because the field is public. SA-8 now reaches it, and it is filed as **#2388** rather than bundled
137+
here.
138+
139+
**One thing that is already right there and must not be "fixed" by analogy with this ticket**:
140+
#1966 uses the parameter name `"MaxDegreeOfParallelism"`, and that matches .NET exactly —
141+
`ArgumentOutOfRangeException.ThrowIfZero(value, nameof(MaxDegreeOfParallelism))`
142+
(`Parallel.cs:87-88`). So the reference is **inconsistent between the two option types**:
143+
`BoundedChannelOptions.FullMode` names `value`, `ParallelOptions.MaxDegreeOfParallelism` names the
144+
property. Both are transcribed as they are rather than harmonised. The only divergence left at
145+
#2388 is **where the guard sits** — .NET's setter refuses to store an invalid degree at all, while
146+
this port stores it and rejects it when the loop runs.

modules/threading-channels/include/System/Threading/Channels/Channel.hpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,7 @@ namespace System::Threading::Channels {
569569
[[nodiscard]] static Channel<T> CreateBounded(const BoundedChannelOptions& options) {
570570
auto state = std::make_shared<detail::ChannelState<T>>();
571571
state->capacity = options.getCapacityProperty();
572-
state->fullMode = options.FullMode;
572+
state->fullMode = options.getFullModeProperty();
573573
Channel<T> channel;
574574
channel.Reader = std::make_shared<detail::ChannelReaderImpl<T>>(state);
575575
channel.Writer = std::make_shared<detail::ChannelWriterImpl<T>>(state);

modules/threading-channels/include/System/Threading/Channels/ChannelOptions.hpp

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ namespace System::Threading::Channels {
2525
bool SingleReader = false;
2626
/** @brief true if continuations may be invoked synchronously on the thread completing an operation. */
2727
bool AllowSynchronousContinuations = false;
28+
29+
// DELIBERATELY still public data members, and the boundary is pinned by a test rather
30+
// than left to look like an oversight. .NET's three are plain auto-properties with no
31+
// validation at all (ChannelOptions.cs:17,27,39) -- `{ get; set; }` and nothing else --
32+
// so a public field is observationally identical to the reference. SA-8 reaches a
33+
// representation .NET keeps private, readonly or absent; it does not reach one .NET
34+
// publishes as freely as this. Converting them would be a source break that buys no
35+
// behaviour, which is the opposite of #1969's case: there, the shape was the only thing
36+
// preventing a check that .NET performs.
2837
};
2938

3039
/**
@@ -33,11 +42,18 @@ namespace System::Threading::Channels {
3342
* C++ counterpart of .NET System.Threading.Channels.BoundedChannelOptions.
3443
*/
3544
class BoundedChannelOptions : public ChannelOptions {
36-
SharpRuntime::intcs capacity_;
45+
SharpRuntime::intcs capacity_;
46+
// PRIVATE, matching .NET's `private BoundedChannelFullMode _mode`
47+
// (ChannelOptions.cs:47). It was a bare public data member, and the obstacle was the
48+
// field's SHAPE rather than any missing logic: a data member has nowhere to put a
49+
// check, so an undeclared value could be assigned and then defeat the channel's
50+
// bounded-memory contract outright -- with capacity 1, `static_cast<
51+
// BoundedChannelFullMode>(99)` made the writer take the drop path (the mode is not
52+
// Wait) and then match no arm of the drop switch, so nothing was dropped and Count
53+
// reached 2. Ticket #1969, under docs/StandingApprovals.md SA-8.
54+
BoundedChannelFullMode fullMode_ = BoundedChannelFullMode::Wait;
3755

3856
public:
39-
BoundedChannelFullMode FullMode = BoundedChannelFullMode::Wait;
40-
4157
/** @throws System::ArgumentOutOfRangeException if @p capacity is negative. */
4258
explicit BoundedChannelOptions(SharpRuntime::intcs capacity) : capacity_(capacity) {
4359
System::ArgumentOutOfRangeException::ThrowIfNegative(capacity, "capacity");
@@ -50,6 +66,32 @@ namespace System::Threading::Channels {
5066
System::ArgumentOutOfRangeException::ThrowIfNegative(value, "value");
5167
capacity_ = value;
5268
}
69+
70+
/** @return The behavior incurred by write operations when the channel is full. */
71+
[[nodiscard]] BoundedChannelFullMode getFullModeProperty() const noexcept { return fullMode_; }
72+
73+
/**
74+
* @brief Sets the behavior incurred by write operations when the channel is full.
75+
* @param value One of the four declared BoundedChannelFullMode values.
76+
* @throws System::ArgumentOutOfRangeException if @p value is not a declared enumerator.
77+
*
78+
* Transcribed from .NET's `FullMode` setter (ChannelOptions.cs:80-97): a four-arm
79+
* switch with a `default` that throws `ArgumentOutOfRangeException(nameof(value))` --
80+
* so the parameter name is **"value"**, not "FullMode", which is what a caller reading
81+
* the message will see.
82+
*/
83+
void setFullModeProperty(BoundedChannelFullMode value) {
84+
switch (value) {
85+
case BoundedChannelFullMode::Wait:
86+
case BoundedChannelFullMode::DropNewest:
87+
case BoundedChannelFullMode::DropOldest:
88+
case BoundedChannelFullMode::DropWrite:
89+
fullMode_ = value;
90+
break;
91+
default:
92+
throw System::ArgumentOutOfRangeException("value");
93+
}
94+
}
5395
};
5496

5597
/**

modules/threading-channels/tests/System/Threading/Channels/ChannelTests.cpp

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#include <thread>
1212
#include <vector>
1313
#include "System/Exception.hpp"
14+
#include "System/ArgumentOutOfRangeException.hpp"
1415
#include "System/InvalidOperationException.hpp"
1516
#include "System/Threading/Channels/Channel.hpp"
1617
#include "System/Threading/Channels/ChannelClosedException.hpp"
@@ -54,7 +55,7 @@ TEST(ChannelTests, BoundedChannel_RespectsCapacity_WaitMode) {
5455

5556
TEST(ChannelTests, BoundedChannel_DropOldest) {
5657
BoundedChannelOptions options(2);
57-
options.FullMode = BoundedChannelFullMode::DropOldest;
58+
options.setFullModeProperty(BoundedChannelFullMode::DropOldest);
5859
auto channel = Channel<int>::CreateBounded(options);
5960
channel.Writer->TryWrite(1);
6061
channel.Writer->TryWrite(2);
@@ -72,7 +73,7 @@ TEST(ChannelTests, BoundedChannel_DropOldest) {
7273
// DropOldest's existing coverage.
7374
TEST(ChannelTests, BoundedChannel_DropNewest) {
7475
BoundedChannelOptions options(2);
75-
options.FullMode = BoundedChannelFullMode::DropNewest;
76+
options.setFullModeProperty(BoundedChannelFullMode::DropNewest);
7677
auto channel = Channel<int>::CreateBounded(options);
7778
channel.Writer->TryWrite(1);
7879
channel.Writer->TryWrite(2);
@@ -86,7 +87,7 @@ TEST(ChannelTests, BoundedChannel_DropNewest) {
8687

8788
TEST(ChannelTests, BoundedChannel_DropWrite) {
8889
BoundedChannelOptions options(1);
89-
options.FullMode = BoundedChannelFullMode::DropWrite;
90+
options.setFullModeProperty(BoundedChannelFullMode::DropWrite);
9091
auto channel = Channel<int>::CreateBounded(options);
9192
channel.Writer->TryWrite(1);
9293
EXPECT_TRUE(channel.Writer->TryWrite(2)); // "handled" (dropped), item 1 stays
@@ -859,7 +860,7 @@ TEST(ZeroCapacityRendezvousTests, DropModes_DiscardTheItemAndKeepCountAtZero) {
859860
for (auto mode : {BoundedChannelFullMode::DropWrite, BoundedChannelFullMode::DropNewest,
860861
BoundedChannelFullMode::DropOldest}) {
861862
BoundedChannelOptions options(0);
862-
options.FullMode = mode;
863+
options.setFullModeProperty(mode);
863864
auto channel = Channel<int>::CreateBounded(options);
864865
EXPECT_TRUE(channel.Writer->TryWrite(1)) << "drop modes report the write as handled";
865866
EXPECT_EQ(channel.Reader->getCountProperty(), 0);
@@ -921,7 +922,7 @@ TEST(ZeroCapacityRendezvousTests, NonZeroCapacities_AreUnchanged) {
921922
EXPECT_EQ(accepted, 100);
922923

923924
BoundedChannelOptions dropOldest(1);
924-
dropOldest.FullMode = BoundedChannelFullMode::DropOldest;
925+
dropOldest.setFullModeProperty(BoundedChannelFullMode::DropOldest);
925926
auto dropping = Channel<int>::CreateBounded(dropOldest);
926927
dropping.Writer->TryWrite(1);
927928
dropping.Writer->TryWrite(2);
@@ -957,3 +958,82 @@ TEST(ZeroCapacityRendezvousTests, PublicLayoutIsUnchanged) {
957958
"channel alignment changed");
958959
SUCCEED();
959960
}
961+
962+
// =============================================================================================
963+
// #1969 — BoundedChannelOptions::FullMode is validated on assignment, as .NET's setter is.
964+
//
965+
// It used to be a bare public data member. The obstacle was the field's SHAPE, not any missing
966+
// logic: a data member has nowhere to put a check, so an undeclared value could be stored and
967+
// then defeat the bounded-memory contract outright. Landed under SA-8.
968+
// =============================================================================================
969+
970+
TEST(ChannelFullModeValidationTests, Fix1969_EveryDeclaredModeIsAccepted) {
971+
// The four arms .NET's switch lists (ChannelOptions.cs:83-89), asserted individually so a
972+
// mutation that drops one arm is caught by name rather than by an aggregate.
973+
for (auto mode : {BoundedChannelFullMode::Wait, BoundedChannelFullMode::DropNewest,
974+
BoundedChannelFullMode::DropOldest, BoundedChannelFullMode::DropWrite}) {
975+
BoundedChannelOptions options(2);
976+
ASSERT_NO_THROW(options.setFullModeProperty(mode));
977+
EXPECT_EQ(options.getFullModeProperty(), mode);
978+
}
979+
}
980+
981+
TEST(ChannelFullModeValidationTests, Fix1969_TheDefaultIsWait) {
982+
// .NET: `private BoundedChannelFullMode _mode = BoundedChannelFullMode.Wait;`
983+
EXPECT_EQ(BoundedChannelOptions(2).getFullModeProperty(), BoundedChannelFullMode::Wait);
984+
}
985+
986+
TEST(ChannelFullModeValidationTests, Fix1969_AnUndeclaredValueIsRejected) {
987+
BoundedChannelOptions options(2);
988+
EXPECT_THROW(options.setFullModeProperty(static_cast<BoundedChannelFullMode>(99)),
989+
System::ArgumentOutOfRangeException);
990+
// ...and rejection leaves the previous value in place, rather than half-applying.
991+
EXPECT_EQ(options.getFullModeProperty(), BoundedChannelFullMode::Wait);
992+
}
993+
994+
TEST(ChannelFullModeValidationTests, Fix1969_TheParameterNameIsValueNotFullMode) {
995+
// .NET throws ArgumentOutOfRangeException(nameof(value)) -- so the name a caller reads in
996+
// the message is "value". Naming the property instead would be the plausible wrong answer,
997+
// and it is what a from-scratch implementation would most likely write.
998+
BoundedChannelOptions options(2);
999+
try {
1000+
options.setFullModeProperty(static_cast<BoundedChannelFullMode>(-7));
1001+
FAIL() << "an undeclared mode must be rejected";
1002+
} catch (const System::ArgumentOutOfRangeException& e) {
1003+
// Assert on ParamName, NOT on what(). The first spelling of this test searched what()
1004+
// for "value" and was VACUOUS: the default message is "Specified argument was out of
1005+
// the range of valid values.", which contains the substring whatever the parameter is
1006+
// named, so mutation M3 (naming the property instead) went uncaught.
1007+
EXPECT_EQ(e.getParamNameProperty(), "value");
1008+
}
1009+
}
1010+
1011+
TEST(ChannelFullModeValidationTests, Fix1969_TheBoundedMemoryContractCanNoLongerBeDefeated) {
1012+
// THE DEFECT ITSELF, which is why this is not a cosmetic shape change. With capacity 1 and
1013+
// an undeclared mode, the writer used to take the drop path (the mode is not Wait) and then
1014+
// match no arm of the drop switch, so nothing was dropped and the item was appended anyway
1015+
// -- Count reached 2 on a channel bounded at 1.
1016+
BoundedChannelOptions options(1);
1017+
EXPECT_THROW(options.setFullModeProperty(static_cast<BoundedChannelFullMode>(99)),
1018+
System::ArgumentOutOfRangeException);
1019+
1020+
// The channel built from the options that survived validation honours its bound.
1021+
auto channel = Channel<int>::CreateBounded(options);
1022+
EXPECT_TRUE(channel.Writer->TryWrite(1));
1023+
EXPECT_FALSE(channel.Writer->TryWrite(2)) << "capacity 1 in Wait mode must refuse the second";
1024+
}
1025+
1026+
TEST(ChannelFullModeValidationTests, Decl1969_TheThreeBaseFlagsStayPublicDataMembers) {
1027+
// The boundary is pinned rather than left looking like an oversight. .NET's SingleWriter,
1028+
// SingleReader and AllowSynchronousContinuations are plain auto-properties with NO
1029+
// validation (ChannelOptions.cs:17,27,39), so a public field is observationally identical
1030+
// to the reference and SA-8 does not reach them. #1969's case is the opposite: there, the
1031+
// shape was the only thing preventing a check .NET actually performs.
1032+
BoundedChannelOptions options(2);
1033+
options.SingleWriter = true;
1034+
options.SingleReader = true;
1035+
options.AllowSynchronousContinuations = true;
1036+
EXPECT_TRUE(options.SingleWriter);
1037+
EXPECT_TRUE(options.SingleReader);
1038+
EXPECT_TRUE(options.AllowSynchronousContinuations);
1039+
}

0 commit comments

Comments
 (0)