Skip to content

Commit 0c2de35

Browse files
PhysShellclaude
andcommitted
fix(roslyn): a callback that takes a token outside a region entry is refused
A token exists only inside a region. The lowering held that for methods (a token parameter is refused) and for locals (a token local outside a region is refused), and not for the one remaining callable: a lambda or a local function. Those were read only as the in-place body of a region entry. A callback that took a token any other way was not lowered at all: public static class LetterProtocol { [ProtocolRegion] public static void WithOpen(Letter l, OpenLetterRegion body) => ...; public static void Peek(Letter l, OpenLetterRegion body) => body(new OpenLetter(l)); } LetterProtocol.Peek(letter, open => { open.Seal(); open.Seal(); }); `Peek` is a method of the protocol's own type that is not marked as a region entry. The declaring type is trusted, so nothing objects to the hand-out; the callback is consumer code, and it was absent from the facts. Exit 0, a token spent twice, clean. Now every lambda and local function in consumer code that takes a token must be the body of a region entry that was lowered; any other one is a refusal. The protocol's own types are exempt (they hand tokens out), and a method already refused does not get a second message for its callbacks. refused/R16 (a lambda) and refused/R17 (a local function passed as a method group) pin it; both exit 0 on main's extractor. No committed fact moves: every lambda in the existing cases is a lowered region body. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent a08b733 commit 0c2de35

5 files changed

Lines changed: 132 additions & 0 deletions

File tree

‎frontend/roslyn/OwnSharp.Extractor/ProtocolLowering.cs‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ private sealed class Ctx
9393
public Dictionary<ISymbol, (string Name, Region Region)> Tokens { get; } =
9494
new(SymbolEqualityComparer.Default);
9595
public HashSet<SyntaxNode> LoweredEntries { get; } = new();
96+
/// The lambdas that were lowered as region bodies: the only callables in consumer
97+
/// code that may take a token.
98+
public HashSet<SyntaxNode> LoweredBodies { get; } = new();
9699
public int Counter;
97100
/// How many times the scan has lowered a mention of a borrowed entity. A construct
98101
/// that runs unknown code is refused UNLESS it moved this counter: then the core
@@ -118,6 +121,8 @@ public static Result Lower(Compilation compilation,
118121
var model = compilation.GetSemanticModel(tree);
119122
var root = tree.GetRoot();
120123
var loweredEntries = new HashSet<SyntaxNode>();
124+
var loweredBodies = new HashSet<SyntaxNode>();
125+
var refusedMethods = new List<SyntaxNode>();
121126

122127
foreach (var method in root.DescendantNodes().OfType<BaseMethodDeclarationSyntax>())
123128
{
@@ -189,6 +194,7 @@ public static Result Lower(Compilation compilation,
189194
throw new Refused(At(file, v,
190195
$"protocol token '{l.Name}' is declared outside a protocol region"));
191196
loweredEntries.UnionWith(ctx.LoweredEntries);
197+
loweredBodies.UnionWith(ctx.LoweredBodies);
192198

193199
if (ops.Count > 0)
194200
functions.Add(new Dictionary<string, object?>
@@ -199,9 +205,33 @@ public static Result Lower(Compilation compilation,
199205
catch (Refused r)
200206
{
201207
refusals.Add(r.Message);
208+
refusedMethods.Add(method);
202209
}
203210
}
204211

212+
// A token exists only inside a region. A method cannot take one (refused above),
213+
// and a lambda or local function may take one only as the in-place body of a
214+
// region entry that was lowered. Any other callable with a token parameter is
215+
// consumer code that receives a token some other way — from a method of the
216+
// protocol's own type that is not marked as a region entry, typically — and that
217+
// nothing reads: without this it is simply absent from the facts, and a token
218+
// spent twice in it is clean.
219+
foreach (var callable in root.DescendantNodes().Where(n =>
220+
n is AnonymousFunctionExpressionSyntax or LocalFunctionStatementSyntax))
221+
{
222+
if (loweredBodies.Contains(callable)
223+
|| refusedMethods.Any(m => m.Span.Contains(callable.Span)))
224+
continue; // a lowered region body, or inside a method already refused
225+
var symbol = callable is LocalFunctionStatementSyntax
226+
? model.GetDeclaredSymbol(callable) as IMethodSymbol
227+
: model.GetSymbolInfo(callable).Symbol as IMethodSymbol;
228+
var taken = symbol?.Parameters.FirstOrDefault(p => IsToken(p.Type));
229+
if (taken is null || Enclosing(model, callable).Any(IsApiType))
230+
continue; // no token; or the protocol's own types, which hand tokens out
231+
refusals.Add(At(file, callable,
232+
$"a callback that takes protocol token '{taken.Type.Name}' is not the in-place body of a region entry: a token exists only inside a region, and code that receives one any other way is not analysed"));
233+
}
234+
205235
// A region entry outside any method body (an accessor, a field initializer, a
206236
// top-level statement) is not modelled either.
207237
foreach (var inv in root.DescendantNodes().OfType<InvocationExpressionSyntax>())
@@ -831,6 +861,7 @@ private static void LowerRegion(Ctx ctx, InvocationExpressionSyntax entry, List<
831861
if (existing is null)
832862
ops.Add(Release(owner, line));
833863
ctx.LoweredEntries.Add(entry);
864+
ctx.LoweredBodies.Add(lambda);
834865
}
835866

836867
private static void LowerTokenLocal(Ctx ctx, ILocalSymbol local, ExpressionSyntax? init,

‎frontend/roslyn/README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ not skipped: the extractor exits `2` and writes no facts, for the whole scan.
148148
- *Trust boundary.* A region opened inside a type that declares the protocol
149149
(see above): write the code that uses a protocol outside the types that
150150
declare it.
151+
- *Tokens stay in regions.* A lambda or local function that takes a token must be
152+
the in-place body of a region entry. A token handed to a callback any other
153+
way — by a method of the protocol's own type that is not marked
154+
`[ProtocolRegion]`, for one — reaches consumer code nobody lowered.
151155
- *Binding.* The scan does not read `obj/`, so usings a project only gets
152156
**implicitly** are not there. A region whose entity does not bind is refused;
153157
write the usings out in the files that open regions (or qualify the names).
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using System;
2+
using Own.Protocols.Sample;
3+
4+
namespace Own.Protocols.Cases;
5+
6+
// A token exists only inside a region. Here the protocol's own type hands one out through a
7+
// method that is NOT marked as a region entry, so the consumer's callback is not a region
8+
// body: nothing lowers it, and before this was refused it was simply absent from the facts —
9+
// a token spent twice, clean. The declaring type is trusted; the callback is consumer code,
10+
// and consumer code that receives a token is either a lowered region or a refusal.
11+
public sealed class Letter
12+
{
13+
public bool Sealed { get; private set; }
14+
15+
internal void MarkSealed() => Sealed = true;
16+
}
17+
18+
[ProtocolToken]
19+
public readonly ref struct OpenLetter
20+
{
21+
private readonly Letter _letter;
22+
internal OpenLetter(Letter letter) => _letter = letter;
23+
24+
public void Seal() => _letter.MarkSealed();
25+
}
26+
27+
public delegate void OpenLetterRegion(OpenLetter open);
28+
29+
public static class LetterProtocol
30+
{
31+
[ProtocolRegion]
32+
public static void WithOpen(Letter letter, OpenLetterRegion body) => body(new OpenLetter(letter));
33+
34+
// the same hand-out, without the mark
35+
public static void Peek(Letter letter, OpenLetterRegion body) => body(new OpenLetter(letter));
36+
}
37+
38+
public static class R16TokenHandedOutPastARegionEntry
39+
{
40+
public static void Run(Letter letter)
41+
{
42+
LetterProtocol.Peek(letter, open =>
43+
{
44+
open.Seal();
45+
open.Seal(); // a stale token: OWN002 inside a region
46+
});
47+
}
48+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using System;
2+
using Own.Protocols.Sample;
3+
4+
namespace Own.Protocols.Cases;
5+
6+
// The same escape with no lambda at all: a LOCAL FUNCTION that takes the token, passed as a
7+
// method group to a hand-out that is not a region entry. A method may not take a token; a
8+
// local function is a method the consumer can write inside a handler.
9+
public sealed class Crate
10+
{
11+
public bool Shipped { get; private set; }
12+
13+
internal void MarkShipped() => Shipped = true;
14+
}
15+
16+
[ProtocolToken]
17+
public readonly ref struct PackedCrate
18+
{
19+
private readonly Crate _crate;
20+
internal PackedCrate(Crate crate) => _crate = crate;
21+
22+
public void Ship() => _crate.MarkShipped();
23+
}
24+
25+
public delegate void PackedCrateRegion(PackedCrate packed);
26+
27+
public static class CrateProtocol
28+
{
29+
[ProtocolRegion]
30+
public static void WithPacked(Crate crate, PackedCrateRegion body) => body(new PackedCrate(crate));
31+
32+
public static void Borrow(Crate crate, PackedCrateRegion body) => body(new PackedCrate(crate));
33+
}
34+
35+
public static class R17TokenTakenByALocalFunction
36+
{
37+
public static void Run(Crate crate)
38+
{
39+
void Twice(PackedCrate packed)
40+
{
41+
packed.Ship();
42+
packed.Ship();
43+
}
44+
45+
CrateProtocol.Borrow(crate, Twice);
46+
}
47+
}

‎frontend/roslyn/protocol-samples/refused/expected.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
"R13_public_mutator_reaches_protocol_state": "'Doc.Publish' is public and writes 'Published', state the protocol's transitions own",
66
"R14_public_setter_of_protocol_state": "'Ticket.State' is public and writes 'State', state the protocol's transitions own",
77
"R15_region_opened_inside_protocol_type": "a protocol region is opened inside 'ParcelHandlers', one of the protocol's own types",
8+
"R16_token_handed_out_past_a_region_entry": "a callback that takes protocol token 'OpenLetter' is not the in-place body of a region entry",
9+
"R17_token_taken_by_a_local_function": "a callback that takes protocol token 'PackedCrate' is not the in-place body of a region entry",
810
"R1_default_token": "has no lowerable origin",
911
"R2_token_outside_region": "is declared outside a protocol region",
1012
"R3_return_inside_region": "`return` inside a protocol region",

0 commit comments

Comments
 (0)