diff --git a/README.md b/README.md
index a84c502c..5909f922 100644
--- a/README.md
+++ b/README.md
@@ -317,13 +317,15 @@ This code can resume the execution of the Lua script after waiting with await, a
`state.SetHook(hook, mask, count)` installs a hook for that Lua thread. The mask combines `c` (call), `r` (return), and `l` (line); a positive `count` enables instruction events even with an empty mask. The callback receives the event name and a line number (`nil` for non-line events). Return values, including `false`, are ignored. `state.SetHook(null, "")` removes the hook.
-A hook can call `SetHook` to replace or remove itself or change its interval. Setting a hook resets the instruction counter, so subsequent count events use the new interval. Hooks are suppressed while the callback executes, including Lua code it invokes.
+A hook can call `SetHook` to replace or remove itself or change its interval. Setting a hook resets the instruction counter, so subsequent count events use the new interval. Hooks on that thread are suppressed while the callback executes, including Lua code it invokes on the same thread.
+
+Host hook settings are copied when `CreateThread`, `CreateCoroutine`, `coroutine.create`, or `coroutine.wrap` creates a child. The child starts a fresh instruction counter and uses the same callback with its own `context.State`; use that state to inspect or reconfigure the executing thread. Parent and child settings are independent: later changes do not update existing children, and a child created before hook installation remains unconfigured. Hook suppression is also independent, so a new child can fire hooks even when created inside a parent hook. Lua `debug.sethook` callbacks remain thread-local, as in [Lua 5.2](https://www.lua.org/source/5.2/ldblib.c.html#hookf); a child inherits their mask/count metadata without running the parent's Lua callback. Scripts given access to `debug.sethook` can still replace or remove a host hook.
Count events measure Lua instructions, as in [Lua 5.2](https://www.lua.org/manual/5.2/manual.html#lua_sethook). They do not fire during a C# host function's own execution or while awaiting it. If that host function calls back into Lua, the nested Lua instructions can trigger hooks. A timeout checked by a hook therefore cannot interrupt a blocking C# function. Long-running host functions should check the supplied `CancellationToken` or pass it to cancellable operations. A host-caught exception or cancellation from a hook does not prevent hooks from running on later executions using the same state.
Hook-based memory sampling is approximate: one Lua instruction or host operation can allocate substantially before the next event. `GC.GetTotalMemory(false)` reports the managed heap, not memory attributable to one Lua state, so it cannot enforce a strict per-state memory limit.
-A `LuaRuntimeException` thrown by a hook is a Lua error that `pcall` can catch; it is not an uncatchable execution limit.
+A `LuaRuntimeException` thrown by a hook is a Lua error that `pcall` or `coroutine.resume` can catch; it is not an uncatchable execution limit.
## Coroutines
diff --git a/README_JA.md b/README_JA.md
index 3f0b18ef..28515e03 100644
--- a/README_JA.md
+++ b/README_JA.md
@@ -314,13 +314,15 @@ print "goodbye!"
`state.SetHook(hook, mask, count)`で、そのLuaスレッドにフックを設定できます。`mask`には`c`(呼び出し)、`r`(戻り)、`l`(行)を組み合わせます。正の`count`を指定すると、空のmaskでも命令数に応じたイベントが有効になります。コールバックにはイベント名と行番号(行イベント以外では`nil`)が渡されます。`false`を含む戻り値は無視されます。`state.SetHook(null, "")`でフックを解除できます。
-フック内で`SetHook`を呼び、自身を置き換えたり解除したり、間隔を変更したりできます。設定時に命令カウンターがリセットされ、その後のcountイベントは新しい間隔を使います。コールバックの実行中は、そこから呼び出したLuaコードも含め、フックは再帰的に発火しません。
+フック内で`SetHook`を呼び、自身を置き換えたり解除したり、間隔を変更したりできます。設定時に命令カウンターがリセットされ、その後のcountイベントは新しい間隔を使います。コールバックの実行中は、そこから同じスレッドで呼び出したLuaコードも含め、そのスレッドのフックは再帰的に発火しません。
+
+`CreateThread`、`CreateCoroutine`、`coroutine.create`、`coroutine.wrap`で子を作ると、作成元のホストフック設定がコピーされます。子の命令カウンターは初期値から始まり、同じコールバックに子自身の`context.State`が渡されます。実行中のスレッドを調べたり再設定したりする場合は、このstateを使ってください。親と子の設定は独立しており、作成後の変更は既存の子へ反映されません。フック設定前に作成した子にも後から反映されません。フックの再入抑制もスレッドごとに独立するため、親のフック内で作成した子ではフックが発火できます。Lua側の`debug.sethook`のコールバックは[Lua 5.2](https://www.lua.org/source/5.2/ldblib.c.html#hookf)と同様にスレッド固有です。子はmask/countの情報を引き継ぎますが、親のLuaコールバックを呼びません。また、`debug.sethook`を公開すると、スクリプトからホストフックを置き換えたり解除したりできます。
countイベントが数えるのは[Lua 5.2](https://www.lua.org/manual/5.2/manual.html#lua_sethook)と同様にLuaの命令です。C#ホスト関数自体の実行中や、そのawait中には発火しません。ホスト関数からLuaを呼び出した場合は、そのLua命令によって発火できます。そのため、フックで確認するタイムアウトでは、実行中のC#関数を強制中断できません。長時間動くホスト関数では、渡された`CancellationToken`を確認するか、キャンセル可能な処理に渡してください。フックからの例外やキャンセルをホスト側で捕捉した後も、同じstateの次の実行ではフックが動作します。
フックによるメモリ使用量の確認は近似的なものです。次のイベントまでに、1つのLua命令やホスト処理が大量のメモリを確保する場合があります。また、`GC.GetTotalMemory(false)`は特定のLua stateの使用量ではなくマネージドヒープの値なので、stateごとの厳密なメモリ上限には使えません。
-フックが投げた`LuaRuntimeException`はLuaのエラーとして`pcall`で捕捉できます。そのため、捕捉できない強制的な実行上限にはなりません。
+フックが投げた`LuaRuntimeException`はLuaのエラーとして`pcall`や`coroutine.resume`で捕捉できます。そのため、捕捉できない強制的な実行上限にはなりません。
## コルーチン
@@ -656,4 +658,4 @@ Lua-CSharpはC#で実装されているため.NETのGCに依存しています
## ライセンス
-このライブラリは[MITライセンス](LICENSE)の下で提供されています。
\ No newline at end of file
+このライブラリは[MITライセンス](LICENSE)の下で提供されています。
diff --git a/src/Lua/LuaState.cs b/src/Lua/LuaState.cs
index 5dfbe6f7..290ba387 100644
--- a/src/Lua/LuaState.cs
+++ b/src/Lua/LuaState.cs
@@ -34,23 +34,40 @@ public static LuaState Create(LuaPlatform platform)
return LuaGlobalState.Create(platform).MainThread;
}
- internal static LuaState CreateCoroutine(
- LuaGlobalState globalState,
- LuaFunction function,
- bool isProtectedMode = false
- )
+ /// Creates a thread sharing this state's globals and copying its host hook settings.
+ public LuaState CreateThread()
{
- return new(globalState, function, isProtectedMode);
+ var thread = new LuaState(GlobalState);
+ thread.InheritHook(this);
+ return thread;
}
- public LuaState CreateThread()
+ /// Creates a coroutine sharing this state's globals and copying its host hook settings.
+ public LuaState CreateCoroutine(LuaFunction function, bool isProtectedMode = false)
{
- return new(GlobalState);
+ var thread = new LuaState(GlobalState, function, isProtectedMode);
+ thread.InheritHook(this);
+ return thread;
}
- public LuaState CreateCoroutine(LuaFunction function, bool isProtectedMode = false)
+ void InheritHook(LuaState parent)
{
- return new(GlobalState, function, isProtectedMode);
+ HookMask = parent.HookMask;
+ BaseHookCount = parent.BaseHookCount;
+ IsLuaDebugHook = parent.IsLuaDebugHook;
+ // Lua's debug library registers callbacks per thread. Its mask/count metadata
+ // is inherited, but there is no Lua callback registered for the new thread.
+ if (IsLuaDebugHook)
+ {
+ return;
+ }
+
+ Hook = parent.Hook;
+ IsLineHookEnabled = parent.IsLineHookEnabled;
+ CallOrReturnHookMask = parent.CallOrReturnHookMask;
+ // lua_newthread copies settings, not the parent's in-progress counter or hook suppression.
+ HookCount = BaseHookCount > 0 ? (long)BaseHookCount + 1 : 0;
+ LastPc = -1;
}
public LuaThreadStatus GetStatus()
@@ -187,6 +204,8 @@ public void Release()
public bool IsRunning => CallStackFrameCount != 0;
public bool IsCoroutine => coroutine != null;
internal LuaFunction? Hook { get; set; }
+ internal bool IsLuaDebugHook;
+ internal byte HookMask;
public LuaFunction? CoroutineFunction => coroutine?.Function;
public bool CanResume => GetStatus() == LuaThreadStatus.Suspended;
@@ -422,7 +441,10 @@ public LuaTable GetCurrentEnvironment()
/// A positive Lua instruction interval, independent of the mask. Zero disables count events.
///
/// The callback can call SetHook to replace or remove itself or change the interval.
- /// Setting the hook resets the count interval; callbacks do not recursively trigger hooks.
+ /// Setting the hook resets the count interval; callbacks do not recursively trigger hooks on the same thread.
+ /// New threads and coroutines copy host hook settings with an independent instruction counter.
+ /// Later changes affect only this thread. Hooks installed by Lua's debug.sethook do not copy their callback.
+ /// A shared callback should use its context.State to inspect or reconfigure the executing thread.
/// Count events track Lua instructions, not C# execution or time spent awaiting host functions.
/// A host function that calls back into Lua can trigger count events for that Lua execution.
/// Long-running host functions must cooperate with the execution's CancellationToken to stop promptly.
@@ -434,6 +456,8 @@ public void SetHook(LuaFunction? hook, string mask, int count = 0)
HookCount = 0;
BaseHookCount = 0;
Hook = null;
+ HookMask = 0;
+ IsLuaDebugHook = false;
IsLineHookEnabled = false;
IsCallHookEnabled = false;
IsReturnHookEnabled = false;
@@ -453,6 +477,18 @@ public void SetHook(LuaFunction? hook, string mask, int count = 0)
}
Hook = hook;
+ HookMask = (byte)(
+ (IsCallHookEnabled ? 1 : 0)
+ | (IsReturnHookEnabled ? 2 : 0)
+ | (IsLineHookEnabled ? 4 : 0)
+ );
+ IsLuaDebugHook = false;
+ }
+
+ internal void SetHookFromLua(LuaFunction? hook, string mask, int count)
+ {
+ SetHook(hook, mask, count);
+ IsLuaDebugHook = hook is not null;
}
internal void DumpStackValues()
diff --git a/src/Lua/Standard/CoroutineLibrary.cs b/src/Lua/Standard/CoroutineLibrary.cs
index b3fce084..d54cb41f 100644
--- a/src/Lua/Standard/CoroutineLibrary.cs
+++ b/src/Lua/Standard/CoroutineLibrary.cs
@@ -30,7 +30,7 @@ CancellationToken cancellationToken
)
{
var arg0 = context.GetArgument(0);
- return new(context.Return(LuaState.CreateCoroutine(context.State.GlobalState, arg0, true)));
+ return new(context.Return(context.State.CreateCoroutine(arg0, true)));
}
public ValueTask Resume(
@@ -82,7 +82,7 @@ CancellationToken cancellationToken
)
{
var arg0 = context.GetArgument(0);
- var state = LuaState.CreateCoroutine(context.State.GlobalState, arg0, false);
+ var state = context.State.CreateCoroutine(arg0, false);
return new(context.Return(new CSharpClosure("wrap", [state], WrapResume)));
}
diff --git a/src/Lua/Standard/DebugLibrary.cs b/src/Lua/Standard/DebugLibrary.cs
index 3c927bd1..88f7a49d 100644
--- a/src/Lua/Standard/DebugLibrary.cs
+++ b/src/Lua/Standard/DebugLibrary.cs
@@ -474,7 +474,7 @@ CancellationToken cancellationToken
var hook = context.GetArgumentOrDefault(argOffset);
var mask = context.GetArgumentOrDefault(argOffset + 1) ?? "";
var count = context.GetArgumentOrDefault(argOffset + 2);
- state.SetHook(hook, mask, count);
+ state.SetHookFromLua(hook, mask, count);
if (hook is null)
{
return 0;
@@ -521,17 +521,17 @@ CancellationToken cancellationToken
)
{
var state = GetLuaThread(context, out _);
- if (state.Hook is null)
+ if (state.Hook is null && !state.IsLuaDebugHook)
{
return new(context.Return(LuaValue.Nil, LuaValue.Nil, LuaValue.Nil));
}
return new(
context.Return(
- state.Hook,
- (state.IsCallHookEnabled ? "c" : "")
- + (state.IsReturnHookEnabled ? "r" : "")
- + (state.IsLineHookEnabled ? "l" : ""),
+ state.Hook is null ? LuaValue.Nil : state.Hook,
+ ((state.HookMask & 1) != 0 ? "c" : "")
+ + ((state.HookMask & 2) != 0 ? "r" : "")
+ + ((state.HookMask & 4) != 0 ? "l" : ""),
state.BaseHookCount
)
);
diff --git a/tests/Lua.Tests/CoroutineHookTests.cs b/tests/Lua.Tests/CoroutineHookTests.cs
new file mode 100644
index 00000000..88884721
--- /dev/null
+++ b/tests/Lua.Tests/CoroutineHookTests.cs
@@ -0,0 +1,438 @@
+using Lua.Runtime;
+using Lua.Standard;
+
+namespace Lua.Tests;
+
+public class CoroutineHookTests
+{
+ const string Loop = "local x=0 for i=1,100 do x=x+i end return x";
+
+ [Test]
+ [Combinatorial]
+ public async Task HostHook_NewChild_InheritsAllEvents(
+ [Values("thread", "coroutine", "create", "wrap")] string creation,
+ [Values(false, true)] bool asynchronous
+ )
+ {
+ using var parent = LuaState.Create();
+ parent.OpenCoroutineLibrary();
+ var events = new Dictionary>();
+ parent.Environment["echo"] = new LuaFunction((c, ct) => new(c.Return(c.GetArgument(0))));
+ parent.SetHook(
+ new(
+ async (c, ct) =>
+ {
+ if (asynchronous)
+ await Task.Yield();
+ if (c.State != parent)
+ {
+ if (!events.TryGetValue(c.State, out var childEvents))
+ {
+ events.Add(c.State, childEvents = new());
+ }
+ childEvents.Add(c.GetArgument(0));
+ }
+ return c.Return();
+ }
+ ),
+ "lcr",
+ 4
+ );
+ const string source = "local x=0\nfor i=1,100 do x=x+i end\necho(x)\nreturn x";
+ LuaValue[] result;
+ if (creation == "thread")
+ {
+ using var child = parent.CreateThread();
+ result = await child.DoStringAsync(source);
+ }
+ else if (creation == "coroutine")
+ {
+ using var child = parent.CreateCoroutine(parent.Load(source, "child.lua"));
+ var buffer = new LuaStack();
+ await child.ResumeAsync(buffer);
+ result = buffer.AsSpan().ToArray();
+ Assert.That(result[0], Is.EqualTo((LuaValue)true));
+ }
+ else
+ {
+ result = await parent.DoStringAsync(
+ creation == "create"
+ ? "return coroutine.resume(coroutine.create(function() " + source + " end))"
+ : "return coroutine.wrap(function() " + source + " end)()"
+ );
+ if (creation == "create")
+ Assert.That(result[0], Is.EqualTo((LuaValue)true));
+ }
+ Assert.That(result[^1], Is.EqualTo((LuaValue)5050));
+ Assert.That(events.Count, Is.EqualTo(1));
+ Assert.That(
+ events.Values.Single(),
+ Is.SupersetOf(new[] { "count", "line", "call", "return" })
+ );
+ Assert.That(parent.CallStackFrameCount, Is.Zero);
+ Assert.That(events.Keys.Single().CallStackFrameCount, Is.Zero);
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public async Task HostHook_ParentChanges_DoNotChangeExistingChild(bool remove)
+ {
+ using var parent = LuaState.Create();
+ var first = new Dictionary();
+ var second = new Dictionary();
+ parent.SetHook(Counter(first), "", 4);
+ using var child = parent.CreateThread();
+ parent.SetHook(remove ? null : Counter(second), "", 4);
+ using var laterChild = parent.CreateThread();
+ await child.DoStringAsync(Loop);
+ await laterChild.DoStringAsync(Loop);
+ Assert.That(first.GetValueOrDefault(child), Is.EqualTo(51));
+ Assert.That(first.GetValueOrDefault(laterChild), Is.Zero);
+ Assert.That(second.GetValueOrDefault(child), Is.Zero);
+ Assert.That(second.GetValueOrDefault(laterChild), Is.EqualTo(remove ? 0 : 51));
+ }
+
+ [Test]
+ public async Task HostHook_InstalledAfterChildCreation_DoesNotApplyRetroactively()
+ {
+ using var parent = LuaState.Create();
+ using var existingChild = parent.CreateThread();
+ var counts = new Dictionary();
+ parent.SetHook(Counter(counts), "", 4);
+ using var laterChild = parent.CreateThread();
+ await existingChild.DoStringAsync(Loop);
+ await laterChild.DoStringAsync(Loop);
+ Assert.That(counts.GetValueOrDefault(existingChild), Is.Zero);
+ Assert.That(counts.GetValueOrDefault(laterChild), Is.EqualTo(51));
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public async Task HostHook_ResumedChild_KeepsItsSnapshotAfterParentChanges(bool remove)
+ {
+ using var parent = LuaState.Create();
+ parent.OpenCoroutineLibrary();
+ var original = new Dictionary();
+ var replacement = new Dictionary();
+ parent.SetHook(Counter(original), "", 4);
+ using var child = parent.CreateCoroutine(
+ parent.Load("coroutine.yield(7) " + Loop, "child.lua")
+ );
+ var first = new LuaStack();
+ await child.ResumeAsync(first);
+ Assert.That(first.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 7 }));
+ var firesBeforeResume = original.GetValueOrDefault(child);
+ parent.SetHook(remove ? null : Counter(replacement), "", 64);
+ var second = new LuaStack();
+ await child.ResumeAsync(second);
+ Assert.That(second.AsSpan().ToArray(), Is.EqualTo(new LuaValue[] { true, 5050 }));
+ Assert.That(original.GetValueOrDefault(child), Is.GreaterThan(firesBeforeResume));
+ Assert.That(replacement.GetValueOrDefault(child), Is.Zero);
+ }
+
+ [Test]
+ public async Task HostHook_ChildOverrideAndRemoval_DoNotChangeParentOrSibling()
+ {
+ using var parent = LuaState.Create();
+ var original = new Dictionary();
+ var replacement = new Dictionary();
+ parent.SetHook(Counter(original), "", 4);
+ using var child = parent.CreateThread();
+ using var sibling = parent.CreateThread();
+ child.SetHook(Counter(replacement), "", 4);
+ await child.DoStringAsync(Loop);
+ await sibling.DoStringAsync(Loop);
+ await parent.DoStringAsync(Loop);
+ Assert.That(original.GetValueOrDefault(child), Is.Zero);
+ Assert.That(original.GetValueOrDefault(sibling), Is.EqualTo(51));
+ Assert.That(original.GetValueOrDefault(parent), Is.EqualTo(51));
+ Assert.That(replacement.GetValueOrDefault(child), Is.EqualTo(51));
+
+ child.SetHook(null, "");
+ using var grandchild = child.CreateThread();
+ await child.DoStringAsync(Loop);
+ await grandchild.DoStringAsync(Loop);
+ Assert.That(replacement.GetValueOrDefault(child), Is.EqualTo(51));
+ Assert.That(original.GetValueOrDefault(grandchild), Is.Zero);
+ Assert.That(replacement.GetValueOrDefault(grandchild), Is.Zero);
+ }
+
+ [TestCase("create")]
+ [TestCase("wrap")]
+ public async Task HostHook_NestedCoroutine_InheritsFromCreatingThread(string creation)
+ {
+ using var parent = LuaState.Create();
+ parent.OpenCoroutineLibrary();
+ var original = new Dictionary();
+ var replacement = new Dictionary();
+ parent.SetHook(Counter(original), "", 4);
+ LuaState? creator = null;
+ parent.Environment["install"] = new LuaFunction(
+ (c, ct) =>
+ {
+ creator = c.State;
+ c.State.SetHook(Counter(replacement), "", 4);
+ return new(c.Return());
+ }
+ );
+ var inner =
+ creation == "create"
+ ? "local ok,x=coroutine.resume(coroutine.create(function() "
+ + Loop
+ + " end)) return x"
+ : "return coroutine.wrap(function() " + Loop + " end)()";
+ var result = await parent.DoStringAsync(
+ "return coroutine.resume(coroutine.create(function() install() " + inner + " end))"
+ );
+ Assert.That(result, Is.EqualTo(new LuaValue[] { true, 5050 }));
+ Assert.That(creator, Is.Not.Null);
+ Assert.That(replacement.Keys.Any(x => x != creator), Is.True);
+ var grandchild = replacement.Keys.Single(x => x != creator);
+ Assert.That(replacement[grandchild], Is.EqualTo(51));
+ Assert.That(original.GetValueOrDefault(grandchild), Is.Zero);
+ }
+
+ [Test]
+ public async Task HostHook_ChildCounter_StartsWithFullInterval()
+ {
+ using var parent = LuaState.Create();
+ var positions = new Dictionary>();
+ var hook = new LuaFunction(
+ (c, ct) =>
+ {
+ if (!positions.TryGetValue(c.State, out var list))
+ positions.Add(c.State, list = new());
+ list.Add(c.State.GetCallStackFrames()[^1].CallerInstructionIndex);
+ return new(c.Return());
+ }
+ );
+ parent.SetHook(hook, "", 4);
+ await parent.DoStringAsync("return 7");
+ Assert.That(positions.ContainsKey(parent), Is.False);
+ using var inherited = parent.CreateThread();
+ using var explicitlyConfigured = parent.CreateThread();
+ explicitlyConfigured.SetHook(hook, "", 4);
+ await inherited.DoStringAsync(Loop);
+ await explicitlyConfigured.DoStringAsync(Loop);
+ Assert.That(positions.ContainsKey(inherited), Is.True);
+ Assert.That(positions[inherited], Is.EqualTo(positions[explicitlyConfigured]));
+ }
+
+ [Test]
+ public async Task HostHook_ChildCreatedInsideCallback_IsNotSuppressed()
+ {
+ using var parent = LuaState.Create();
+ var childFires = 0;
+ var parentFires = 0;
+ parent.SetHook(
+ new(
+ async (c, ct) =>
+ {
+ if (c.State == parent)
+ {
+ parentFires++;
+ if (parentFires == 1)
+ {
+ using var child = parent.CreateThread();
+ var nested = await child.DoStringAsync(Loop, "child.lua", ct);
+ Assert.That(nested[0], Is.EqualTo((LuaValue)5050));
+ }
+ }
+ else
+ childFires++;
+ return c.Return();
+ }
+ ),
+ "",
+ 4
+ );
+ await parent.DoStringAsync(Loop);
+ Assert.That(parentFires, Is.EqualTo(51));
+ Assert.That(childFires, Is.EqualTo(51));
+ }
+
+ [Test]
+ public async Task HostHook_LuaClosureInstalledFromCSharp_IsInherited()
+ {
+ using var parent = LuaState.Create();
+ LuaState? child = null;
+ var fires = 0;
+ parent.Environment["mark"] = new LuaFunction(
+ (c, ct) =>
+ {
+ if (c.State == child)
+ fires++;
+ return new(c.Return());
+ }
+ );
+ var hook = (await parent.DoStringAsync("return function() mark() end"))[0]
+ .Read();
+ parent.SetHook(hook, "", 4);
+ using (child = parent.CreateThread())
+ {
+ await child.DoStringAsync(Loop);
+ Assert.That(fires, Is.EqualTo(51));
+ }
+ }
+
+ [Test]
+ public async Task HostHook_InheritedBudgetError_StopsProtectedChild()
+ {
+ using var parent = LuaState.Create();
+ parent.OpenCoroutineLibrary();
+ var fires = 0;
+ parent.SetHook(
+ new(
+ (c, ct) =>
+ {
+ if (c.State != parent && ++fires == 3)
+ throw new LuaRuntimeException(c.State, "budget");
+ return new(c.Return());
+ }
+ ),
+ "",
+ 4
+ );
+ var result = await parent.DoStringAsync(
+ "return coroutine.resume(coroutine.create(function() " + Loop + " end))"
+ );
+ Assert.That(result, Is.EqualTo(new LuaValue[] { false, "budget" }));
+ Assert.That(fires, Is.EqualTo(3));
+ }
+
+ [Test]
+ public async Task HostHook_InheritedCancellation_PropagatesThroughPCallAndResume()
+ {
+ using var parent = LuaState.Create();
+ using var cancellation = new CancellationTokenSource();
+ parent.OpenBasicLibrary();
+ parent.OpenCoroutineLibrary();
+ LuaState? child = null;
+ parent.SetHook(
+ new(
+ (c, ct) =>
+ {
+ if (c.State != parent)
+ {
+ child = c.State;
+ cancellation.Cancel();
+ ct.ThrowIfCancellationRequested();
+ }
+ return new(c.Return());
+ }
+ ),
+ "",
+ 4
+ );
+ try
+ {
+ Assert.ThrowsAsync(async () =>
+ await parent.DoStringAsync(
+ "return pcall(coroutine.resume,coroutine.create(function() " + Loop + " end))",
+ "cancel.lua",
+ cancellation.Token
+ )
+ );
+ Assert.That(child, Is.Not.Null);
+ Assert.That(parent.CallStackFrameCount, Is.Zero);
+ Assert.That(child!.CallStackFrameCount, Is.Zero);
+ }
+ finally
+ {
+ child?.Dispose();
+ }
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public async Task LuaDebugHook_CallbackIsThreadLocal_MaskAndIntervalAreInherited(
+ bool hostCallback
+ )
+ {
+ using var parent = LuaState.Create();
+ parent.OpenCoroutineLibrary();
+ parent.OpenDebugLibrary();
+ var childFires = 0;
+ parent.Environment["hostHook"] = new LuaFunction(
+ (c, ct) =>
+ {
+ if (c.State != parent)
+ childFires++;
+ return new(c.Return());
+ }
+ );
+ var source =
+ """
+ local parent = coroutine.running()
+ local childFires = 0
+ local hook = function()
+ if coroutine.running() ~= parent then childFires = childFires + 1 end
+ end
+ """
+ + (hostCallback ? "\nhook = hostHook\n" : "\n")
+ + """
+ debug.sethook(hook, "lcr", 4)
+ local child = coroutine.create(function()
+ local grandchild = coroutine.create(function() return 7 end)
+ local gh,gm,gc = debug.gethook(grandchild)
+ return gh,gm,gc
+ end)
+ local h,m,c = debug.gethook(child)
+ local ok,gh,gm,gc = coroutine.resume(child)
+ debug.sethook()
+ return h,m,c,ok,gh,gm,gc,childFires
+ """;
+ var result = await parent.DoStringAsync(source);
+ Assert.That(
+ result,
+ Is.EqualTo(new LuaValue[] { LuaValue.Nil, "crl", 4, true, LuaValue.Nil, "crl", 4, 0 })
+ );
+ Assert.That(childFires, Is.Zero);
+ }
+
+ [Test]
+ public async Task LuaDebugHook_ExplicitChildHook_DoesNotPropagateCallbackToGrandchild()
+ {
+ using var parent = LuaState.Create();
+ parent.OpenBasicLibrary();
+ parent.OpenCoroutineLibrary();
+ parent.OpenDebugLibrary();
+ var result = await parent.DoStringAsync(
+ """
+ local parentFires, childFires, grandchildFires = 0,0,0
+ local parent = coroutine.running()
+ local child
+ local parentHook = function() parentFires = parentFires + 1 end
+ local childHook = function()
+ if coroutine.running() == child then childFires = childFires + 1
+ else grandchildFires = grandchildFires + 1 end
+ end
+ debug.sethook(parentHook, "", 4)
+ child = coroutine.create(function()
+ local grandchild = coroutine.create(function()
+ local x=0 for i=1,100 do x=x+i end return x
+ end)
+ return coroutine.resume(grandchild)
+ end)
+ debug.sethook(child, childHook, "", 4)
+ local h,m,c = debug.gethook(child)
+ local ok,innerOk,x = coroutine.resume(child)
+ debug.sethook()
+ return h == childHook,m,c,ok,innerOk,x,parentFires > 0,childFires > 0,grandchildFires
+ """
+ );
+ Assert.That(
+ result,
+ Is.EqualTo(new LuaValue[] { true, "", 4, true, true, 5050, true, true, 0 })
+ );
+ }
+
+ static LuaFunction Counter(Dictionary counts) =>
+ new(
+ (c, ct) =>
+ {
+ counts[c.State] = counts.GetValueOrDefault(c.State) + 1;
+ return new(c.Return());
+ }
+ );
+}