Add Channel primitive for coroutine inter-task communication

Exposes Kotlin's Channel<Obj> to Lyng scripts as a first-class type
with rendezvous, buffered, and unlimited capacity modes. Supports
suspending send/receive, non-suspending tryReceive, close/drain
semantics, and isClosedForSend/isClosedForReceive properties.

Also fixes a pre-existing typo in BookTest.kt that blocked JVM test
compilation, and adds Channel reference docs and a parallelism.md section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sergeychandClaude Sonnet 4.6 committed 2026-04-18 02:28:55 +03:00
1 parent 30e56946a0
commit f9a07f176a
6 files changed
+529 -1

No files matched your search

@@ -634,6 +634,7 @@ class Script(
addConst("Deferred", ObjDeferred.type)
addConst("CompletableDeferred", ObjCompletableDeferred.type)
addConst("Mutex", ObjMutex.type)
addConst("Channel", ObjChannel.type)
addConst("Flow", ObjFlow.type)
addConst("FlowBuilder", ObjFlowBuilder.type)
addConst("Delegate", ObjDynamic.type)
@@ -0,0 +1,133 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.obj
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.channels.ClosedSendChannelException
import net.sergeych.lyng.Scope
import net.sergeych.lyng.miniast.ParamDoc
import net.sergeych.lyng.miniast.addFnDoc
import net.sergeych.lyng.miniast.addPropertyDoc
import net.sergeych.lyng.miniast.type
/**
* Lyng-visible wrapper around Kotlin's [Channel].
*
* Construction:
* - `Channel()` – rendezvous channel (capacity 0, sender and receiver synchronise)
* - `Channel(n)` – buffered channel with capacity *n* (n > 0)
* - `Channel(Channel.UNLIMITED)` – unlimited-buffer channel
*
* Methods:
* - `send(value)` – suspend until the value is accepted by a receiver (or buffered)
* - `receive()` – suspend until a value is available; returns `null` when channel is closed and drained
* - `tryReceive()` – return the next value immediately, or `null` if none is available
* - `close()` – signal that no more values will be sent; pending receivers can still drain buffered items
*
* Properties:
* - `isClosedForSend: Bool`
* - `isClosedForReceive: Bool`
*/
class ObjChannel(val channel: Channel<Obj>) : Obj() {
override val objClass get() = type
companion object {
val type = object : ObjClass("Channel") {
override suspend fun callOn(scope: Scope): Obj {
val capacity = scope.args.list.getOrNull(0)
?.let { (it as? ObjInt)?.value?.toInt() ?: scope.raiseIllegalArgument("Channel capacity must be an integer") }
?: Channel.RENDEZVOUS
return ObjChannel(Channel(capacity))
}
}.apply {
// Expose Channel.UNLIMITED as a static constant on the Channel class so scripts can write
// Channel(Channel.UNLIMITED).
addConst("UNLIMITED", ObjInt(Channel.UNLIMITED.toLong()))
addFnDoc(
name = "send",
doc = "Suspend until the value is accepted by a receiver (or placed into the buffer). " +
"Throws if the channel is already closed.",
params = listOf(ParamDoc("value", type("lyng.Any"))),
returns = type("lyng.Void"),
moduleName = "lyng.stdlib"
) {
val value = requiredArg<Obj>(0)
try {
thisAs<ObjChannel>().channel.send(value)
} catch (e: ClosedSendChannelException) {
raiseIllegalState("Channel is closed for send")
}
ObjVoid
}
addFnDoc(
name = "receive",
doc = "Suspend until a value is available and return it, or return `null` when the channel " +
"is closed and all buffered items have been consumed.",
returns = type("lyng.Any"),
moduleName = "lyng.stdlib"
) {
try {
thisAs<ObjChannel>().channel.receive()
} catch (_: ClosedReceiveChannelException) {
ObjNull
}
}
addFnDoc(
name = "tryReceive",
doc = "Return the next buffered value immediately without suspending, or `null` if the " +
"channel is empty or closed.",
returns = type("lyng.Any"),
moduleName = "lyng.stdlib"
) {
val result = thisAs<ObjChannel>().channel.tryReceive()
result.getOrNull() ?: ObjNull
}
addFnDoc(
name = "close",
doc = "Signal that no more values will be sent. Receivers can still drain any buffered items.",
returns = type("lyng.Void"),
moduleName = "lyng.stdlib"
) {
thisAs<ObjChannel>().channel.close()
ObjVoid
}
addPropertyDoc(
name = "isClosedForSend",
doc = "Whether this channel is closed for sending (no more `send` calls are permitted).",
type = type("lyng.Bool"),
moduleName = "lyng.stdlib",
getter = { thisAs<ObjChannel>().channel.isClosedForSend.toObj() }
)
addPropertyDoc(
name = "isClosedForReceive",
doc = "Whether this channel is closed for receiving (closed and fully drained).",
type = type("lyng.Bool"),
moduleName = "lyng.stdlib",
getter = { thisAs<ObjChannel>().channel.isClosedForReceive.toObj() }
)
}
}
}
@@ -0,0 +1,131 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.eval
import kotlin.test.Test
class ChannelTest {
@Test
fun testRendezvousChannel() = runTest {
eval("""
val ch = Channel()
val producer = launch {
ch.send(1)
ch.send(2)
ch.send(3)
ch.close()
}
val results = []
var v = ch.receive()
while (v != null) {
results += v
v = ch.receive()
}
assertEquals([1, 2, 3], results)
""".trimIndent())
}
@Test
fun testBufferedChannel() = runTest {
eval("""
val ch = Channel(4)
ch.send(10)
ch.send(20)
ch.send(30)
ch.close()
assertEquals(10, ch.receive())
assertEquals(20, ch.receive())
assertEquals(30, ch.receive())
// closed and drained
assertEquals(null, ch.receive())
""".trimIndent())
}
@Test
fun testTryReceive() = runTest {
eval("""
val ch = Channel(2)
// nothing buffered yet
assertEquals(null, ch.tryReceive())
ch.send(42)
assertEquals(42, ch.tryReceive())
assertEquals(null, ch.tryReceive())
""".trimIndent())
}
@Test
fun testChannelProperties() = runTest {
eval("""
val ch = Channel(1)
assert(!ch.isClosedForSend)
assert(!ch.isClosedForReceive)
ch.send(1)
ch.close()
assert(ch.isClosedForSend)
// still has one buffered item, so not yet closed for receive
assert(!ch.isClosedForReceive)
ch.receive()
// now fully drained
assert(ch.isClosedForReceive)
""".trimIndent())
}
@Test
fun testUnlimitedChannel() = runTest {
eval("""
val ch = Channel(Channel.UNLIMITED)
(1..100).forEach { ch.send(it) }
ch.close()
var sum = 0
var v = ch.receive()
while (v != null) {
sum += v
v = ch.receive()
}
assertEquals(5050, sum)
""".trimIndent())
}
@Test
fun testChannelProducerConsumerPattern() = runTest {
eval("""
val ch = Channel()
val results = []
val mu = Mutex()
val consumer = launch {
var item = ch.receive()
while (item != null) {
mu.withLock { results += item }
item = ch.receive()
}
}
launch {
for (i in 1..5) {
ch.send("msg:${'$'}i")
}
ch.close()
}.await()
consumer.await()
assertEquals(["msg:1","msg:2","msg:3","msg:4","msg:5"], results)
""".trimIndent())
}
}
+1 -1
View File
@@ -236,7 +236,7 @@ suspend fun runDocTests(fileName: String, bookMode: Boolean = false) {
val bookScope = Scope()
var count = 0
parseDocTests(fileName, bookMode).collect { dt ->
if (bookMode)imp dt.test(bookScope)
if (bookMode) dt.test(bookScope)
else dt.test()
count++
}