Skip to content

Commit ce12c20

Browse files
fix(binder,sdk): reject yield-in-try-with-catch; restore try/finally in iterators (#836) (#846)
Iterator (yield) bodies in G# already support try/finally lowering end-to-end (IteratorMoveNextBodyBuilder + IteratorTryDispatchPlanner, landed under #419). What was missing was the binder-side rejection of the C#-forbidden combination 'try { yield … } catch (…) { … }' and the follow-through in the Gsharp.Extensions.Sequences stdlib, where #806 had to work around the missing diagnostic by hoisting cleanup outside the iterator. Binder: * GS0367 (new): a yield lexically inside a try block that also has any catch clause is rejected (C# §15.14 / ECMA-335). The diagnostic fires once per offending yield, with the yield keyword's location. Pure try/finally around yield continues to bind cleanly; the iterator rewriter's existing 'drop catch clauses' fallback now becomes defense-in-depth. * The walker descends only the try block (not catches/finallies, not nested FunctionLiteralExpression bodies), so a yield inside a nested lambda inside the try does not trip the diagnostic. SDK: * WindowedIterator[T] and IndexedIterator[T] in Gsharp.Extensions.Sequences now use the explicit GetEnumerator + try/finally + Dispose shape, matching the C# baseline that #806 had to flatten. Their tests (104 in Extensions.Tests) continue to pass; consumers that break out early now observe deterministic enumerator disposal. * The InterleaveIterator comment is refreshed — using-let stays as the declarative shape but is no longer the only option. Tests: * Issue836IteratorTryFinallyBinderTests (Core.Tests): 7 binder cases covering try/finally accepted, try/catch rejected, try/catch/finally rejected, nested try/finally accepted, inner-catch-with-yield rejected, non-iterator try/catch unaffected, and yield-in-nested- lambda-inside-outer-try-with-only-finally accepted. * Issue836IteratorTryFinallyEmitTests (Compiler.Tests): 4 end-to-end emit + ilverify-checked tests — full enumeration runs the finally exactly once, early break runs the finally on Dispose, nested early-break runs inner then outer finally. * Issue836IteratorTryFinallyInterpreterTests (Interpreter.Tests): 3 tree-walking interpreter parity tests for try/finally + yield; asserts every value is observed and each finally runs exactly once. * IteratorTryFinallyEmitTests.Iterator_YieldInsideTryCatchFinally_* is repurposed to assert GS0367 fires (previously asserted the catch was silently dropped). Full GSharp.sln test suite (5345 tests) green; dotnet-ilverify clean on the new emit fixtures; website builds without broken links. Closes #836 Related #806, ADR-0084 §L5, parent #706 Co-authored-by: Copilot CLI <noreply@github.com>
1 parent d03d5df commit ce12c20

7 files changed

Lines changed: 674 additions & 20 deletions

File tree

src/Core/CodeAnalysis/Binding/StatementBinder.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2195,9 +2195,64 @@ private BoundStatement BindTryStatement(TryStatementSyntax syntax)
21952195
return BindErrorStatement();
21962196
}
21972197

2198+
// Issue #836: a `yield` lexically inside a `try` block that also
2199+
// has any `catch` clause is rejected (C# §15.14 / ECMA-335). The
2200+
// iterator state machine cannot safely resume into a protected
2201+
// region from a synthesized dispatch when that region also acts
2202+
// as a CLR exception handler frame. Pure `try`/`finally` around
2203+
// `yield` is supported and lowered by IteratorMoveNextBodyBuilder.
2204+
if (catches.Count > 0
2205+
&& function != null
2206+
&& isIteratorReturnType(function.Type)
2207+
&& YieldFinder.ContainsYieldInOwnTryBlock(tryBlock))
2208+
{
2209+
foreach (var yieldLocation in YieldFinder.GetYieldLocationsInOwnTryBlock(tryBlock))
2210+
{
2211+
Diagnostics.ReportYieldInsideTryWithCatch(yieldLocation);
2212+
}
2213+
}
2214+
21982215
return new BoundTryStatement(syntax, tryBlock, catches.ToImmutable(), finallyBlock);
21992216
}
22002217

2218+
/// <summary>
2219+
/// Walker that locates <c>yield</c> statements lexically inside a
2220+
/// bound block, but does not descend into nested function bodies
2221+
/// (lambdas / local functions). Issue #836.
2222+
/// </summary>
2223+
private sealed class YieldFinder : BoundTreeWalker
2224+
{
2225+
private readonly List<TextLocation> locations = new List<TextLocation>();
2226+
2227+
public static bool ContainsYieldInOwnTryBlock(BoundStatement tryBlock)
2228+
{
2229+
var walker = new YieldFinder();
2230+
walker.VisitStatement(tryBlock);
2231+
return walker.locations.Count > 0;
2232+
}
2233+
2234+
public static IReadOnlyList<TextLocation> GetYieldLocationsInOwnTryBlock(BoundStatement tryBlock)
2235+
{
2236+
var walker = new YieldFinder();
2237+
walker.VisitStatement(tryBlock);
2238+
return walker.locations;
2239+
}
2240+
2241+
protected override void VisitYieldStatement(BoundYieldStatement node)
2242+
{
2243+
// Prefer the `yield` keyword's location; fall back to the
2244+
// full statement syntax location if available.
2245+
if (node.Syntax is YieldStatementSyntax yieldSyntax)
2246+
{
2247+
this.locations.Add(yieldSyntax.YieldKeyword.Location);
2248+
}
2249+
else if (node.Syntax != null)
2250+
{
2251+
this.locations.Add(node.Syntax.Location);
2252+
}
2253+
}
2254+
}
2255+
22012256
private BoundStatement BindThrowStatement(ThrowStatementSyntax syntax)
22022257
{
22032258
var expression = bindExpression(syntax.Expression);

src/Core/CodeAnalysis/DiagnosticBag.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3722,6 +3722,27 @@ public void ReportBaseInterfaceCallTargetsPrivateHelper(
37223722
DiagnosticSeverity.Error);
37233723
}
37243724

3725+
/// <summary>
3726+
/// Reports GS0367 — issue #836: a <c>yield</c> statement appears
3727+
/// lexically inside a <c>try</c> block that also has one or more
3728+
/// <c>catch</c> clauses. The C# spec (§15.14) and ECMA-335 forbid
3729+
/// this combination because the iterator state machine cannot
3730+
/// safely resume into a protected region from a synthesized
3731+
/// dispatch. Wrap the <c>yield</c> in a separate <c>try</c>/
3732+
/// <c>finally</c> instead, or move the exception-handling block to
3733+
/// the consumer (<c>for v in iter()</c>) side.
3734+
/// </summary>
3735+
/// <param name="location">The source location of the offending
3736+
/// <c>yield</c> keyword.</param>
3737+
public void ReportYieldInsideTryWithCatch(TextLocation location)
3738+
{
3739+
Report(
3740+
location,
3741+
"GS0367",
3742+
"'yield' cannot appear inside a 'try' block that has a 'catch' clause; only 'try'/'finally' is supported around 'yield' (issue #836).",
3743+
DiagnosticSeverity.Error);
3744+
}
3745+
37253746
private static string FormatMissingNames(IEnumerable<string> missingNames)
37263747
{
37273748
var displayed = new List<string>();

src/Sdk/Gsharp.Extensions/Sequences/SequenceExtensions.gs

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -195,13 +195,21 @@ func (source IEnumerable[T]) ToMap[T, TKey, TValue](keyFn (T) -> TKey, valueFn (
195195
// ====================================================================
196196

197197
func WindowedIterator[T](source IEnumerable[T], size int32) IEnumerable[IList[T]] {
198-
var buffer = Queue[T](size)
199-
for item in source {
200-
buffer.Enqueue(item)
201-
if buffer.Count == size {
202-
yield List[T](buffer)
203-
buffer.Dequeue()
198+
// Issue #836: with try/finally + yield supported, eagerly grab the
199+
// source enumerator and guarantee its disposal even when the
200+
// consumer breaks early. Matches the C# baseline shape.
201+
let enumerator = source.GetEnumerator()
202+
try {
203+
var buffer = Queue[T](size)
204+
while enumerator.MoveNext() {
205+
buffer.Enqueue(enumerator.Current)
206+
if buffer.Count == size {
207+
yield List[T](buffer)
208+
buffer.Dequeue()
209+
}
204210
}
211+
} finally {
212+
enumerator.Dispose()
205213
}
206214
}
207215

@@ -222,10 +230,17 @@ func ChunkedIterator[T](source IEnumerable[T], size int32) IEnumerable[IList[T]]
222230
}
223231

224232
func IndexedIterator[T](source IEnumerable[T]) IEnumerable[(int32, T)] {
225-
var i = 0
226-
for item in source {
227-
yield (i, item)
228-
i++
233+
// Issue #836: explicit enumerator + try/finally guarantees
234+
// disposal on early termination, matching the C# baseline.
235+
let enumerator = source.GetEnumerator()
236+
try {
237+
var i = 0
238+
while enumerator.MoveNext() {
239+
yield (i, enumerator.Current)
240+
i++
241+
}
242+
} finally {
243+
enumerator.Dispose()
229244
}
230245
}
231246

@@ -243,10 +258,11 @@ func PairwiseIterator[T](source IEnumerable[T]) IEnumerable[(T, T)] {
243258
}
244259

245260
func InterleaveIterator[T](source IEnumerable[T], other IEnumerable[T]) IEnumerable[T] {
246-
// Iterator blocks can't host a `try-finally` wrap around `yield`,
247-
// so we use `using let` to bind each enumerator — the iterator
248-
// lowering disposes them in the resulting state machine's
249-
// FaultBlock.
261+
// The `using let` form binds each enumerator's lifetime to the
262+
// iterator block — the iterator lowering disposes them when the
263+
// state machine's Dispose path runs. Issue #836 also unlocked an
264+
// explicit `try { … } finally { … }` shape for this scenario, but
265+
// `using let` keeps the intent declarative.
250266
using let left = source.GetEnumerator()
251267
using let right = other.GetEnumerator()
252268

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// <copyright file="Issue836IteratorTryFinallyEmitTests.cs" company="GSharp">
2+
// Copyright (C) GSharp Authors. All rights reserved.
3+
// </copyright>
4+
5+
using System;
6+
using System.Collections.Generic;
7+
using System.IO;
8+
using System.Linq;
9+
using System.Reflection;
10+
using Xunit;
11+
12+
namespace GSharp.Compiler.Tests.Emit;
13+
14+
/// <summary>
15+
/// Issue #836 — end-to-end emit + IL-verify coverage for iterator
16+
/// state machines whose user body contains <c>try</c>/<c>finally</c>
17+
/// around <c>yield</c>. Asserts both the runtime behaviour (full
18+
/// enumeration runs the finally once, early-break runs the finally
19+
/// during Dispose) and that the synthesized state machine passes
20+
/// <c>dotnet-ilverify</c> on net10.0.
21+
/// </summary>
22+
public class Issue836IteratorTryFinallyEmitTests
23+
{
24+
[Fact]
25+
public void Iterator_TryFinally_FullEnumeration_RunsFinallyOnce()
26+
{
27+
var source = """
28+
package Probe
29+
import System
30+
import System.Collections.Generic
31+
32+
func gen() IEnumerable[int32] {
33+
try {
34+
yield 10
35+
yield 20
36+
} finally {
37+
Console.WriteLine("dispose")
38+
}
39+
}
40+
41+
public var sum = 0
42+
public var disposeMarker = ""
43+
for v in gen() {
44+
sum = sum + v
45+
}
46+
disposeMarker = "ok"
47+
""";
48+
49+
var assembly = CompileAndRun(source);
50+
Assert.Equal(30, GetIntField(assembly, "sum"));
51+
Assert.Equal("ok", GetStringField(assembly, "disposeMarker"));
52+
}
53+
54+
[Fact]
55+
public void Iterator_TryFinally_EarlyBreak_RunsFinallyOnDispose()
56+
{
57+
var source = """
58+
package Probe
59+
import System
60+
import System.Collections.Generic
61+
62+
public var finallyRan = 0
63+
public var seen = 0
64+
65+
func gen() IEnumerable[int32] {
66+
try {
67+
yield 1
68+
yield 2
69+
yield 3
70+
} finally {
71+
finallyRan = finallyRan + 1
72+
}
73+
}
74+
75+
for v in gen() {
76+
seen = seen + 1
77+
if v == 1 {
78+
break
79+
}
80+
}
81+
""";
82+
83+
var assembly = CompileAndRun(source);
84+
Assert.Equal(1, GetIntField(assembly, "seen"));
85+
Assert.Equal(1, GetIntField(assembly, "finallyRan"));
86+
}
87+
88+
[Fact]
89+
public void Iterator_NestedTryFinally_EarlyBreak_RunsInnerThenOuter()
90+
{
91+
var source = """
92+
package Probe
93+
import System
94+
import System.Collections.Generic
95+
96+
public var trace = ""
97+
98+
func gen() IEnumerable[int32] {
99+
try {
100+
try {
101+
yield 1
102+
yield 2
103+
} finally {
104+
trace = trace + "I"
105+
}
106+
} finally {
107+
trace = trace + "O"
108+
}
109+
}
110+
111+
for v in gen() {
112+
if v == 1 {
113+
break
114+
}
115+
}
116+
""";
117+
118+
var assembly = CompileAndRun(source);
119+
Assert.Equal("IO", GetStringField(assembly, "trace"));
120+
}
121+
122+
[Fact]
123+
public void Iterator_TryFinally_FullEnumeration_FinallyExactlyOnce()
124+
{
125+
var source = """
126+
package Probe
127+
import System
128+
import System.Collections.Generic
129+
130+
public var finallyRan = 0
131+
132+
func gen() IEnumerable[int32] {
133+
try {
134+
yield 1
135+
yield 2
136+
yield 3
137+
} finally {
138+
finallyRan = finallyRan + 1
139+
}
140+
}
141+
142+
for v in gen() {
143+
// consume
144+
}
145+
""";
146+
147+
var assembly = CompileAndRun(source);
148+
Assert.Equal(1, GetIntField(assembly, "finallyRan"));
149+
}
150+
151+
#region Helpers
152+
153+
private static Assembly CompileAndRun(string source)
154+
{
155+
var outPath = CompileToFile(source, target: "exe");
156+
157+
var bytes = File.ReadAllBytes(outPath);
158+
var assembly = Assembly.Load(bytes);
159+
160+
var program = assembly.GetTypes().Single(t => t.Name == "<Program>");
161+
var entry = program.GetMethod("<Main>$", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
162+
entry!.Invoke(null, entry.GetParameters().Length == 0 ? null : new object[] { Array.Empty<string>() });
163+
164+
return assembly;
165+
}
166+
167+
private static string CompileToFile(string source, string target)
168+
{
169+
var tempDir = Directory.CreateTempSubdirectory("gs_836_").FullName;
170+
var srcPath = Path.Combine(tempDir, "test.gs");
171+
var outPath = Path.Combine(tempDir, "test.dll");
172+
File.WriteAllText(srcPath, source);
173+
174+
using var compileOut = new StringWriter();
175+
using var compileErr = new StringWriter();
176+
var prevOut = Console.Out;
177+
var prevErr = Console.Error;
178+
Console.SetOut(compileOut);
179+
Console.SetError(compileErr);
180+
int compileExit;
181+
try
182+
{
183+
compileExit = Program.Main(new[]
184+
{
185+
"/out:" + outPath,
186+
"/target:" + target,
187+
"/targetframework:net10.0",
188+
srcPath,
189+
});
190+
}
191+
finally
192+
{
193+
Console.SetOut(prevOut);
194+
Console.SetError(prevErr);
195+
}
196+
197+
Assert.True(
198+
compileExit == 0,
199+
$"gsc failed:\nstdout:\n{compileOut}\nstderr:\n{compileErr}");
200+
201+
IlVerifier.Verify(outPath);
202+
return outPath;
203+
}
204+
205+
private static int GetIntField(Assembly assembly, string name)
206+
{
207+
var program = assembly.GetTypes().Single(t => t.Name == "<Program>");
208+
var field = program.GetField(name, BindingFlags.Public | BindingFlags.Static);
209+
return (int)field!.GetValue(null)!;
210+
}
211+
212+
private static string GetStringField(Assembly assembly, string name)
213+
{
214+
var program = assembly.GetTypes().Single(t => t.Name == "<Program>");
215+
var field = program.GetField(name, BindingFlags.Public | BindingFlags.Static);
216+
return (string)field!.GetValue(null)!;
217+
}
218+
219+
#endregion
220+
}

0 commit comments

Comments
 (0)