Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion kyo-browser/shared/src/test/scala/kyo/BaseBrowserTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@ abstract class BaseBrowserTest extends kyo.test.Test[Any]:
// through Browser.assert* domain helpers and expected-exception fail-paths (Abort.run(...) { case Failure(_: X)
// => () ; case _ => fail(...) }) that do not flow through the kyo.test assert macros, so the per-leaf
// evaluation counter sees zero even though the leaf does verify behavior. The check is a false positive here.
override def config = super.config.sequential.failOnNoAssertion(false)
//
// The two opaque-inode descriptor categories are disabled (socket + non-socket fd), not the whole check. SharedChrome
// deliberately holds the headless Chrome process, its CDP connection socket, and the process's stdio pipes open for the
// WHOLE run (torn down at scheduler shutdown, see SharedChrome.ensureStarted). A CDP `socket:[inode]` and a stdio
// `pipe:[inode]` are opaque with no stable identifier an allowlist could match, so the socket and file-descriptor
// categories are the resources that cannot be expressed any finer. The other long-lived resource, the kyo-http
// NioIoDriver event-loop fiber, is already covered by the built-in allowlist, so fiber and thread detection stay on.
override def config =
super.config.sequential.failOnNoAssertion(false).leakCheckSockets(false).leakCheckFileDescriptors(false)

// Pre-flight: check whether the current (OS, arch) tuple has a chrome-headless-shell artifact
// (mac-arm64 / mac-x64 / linux64 / win64 / win32). Linux/Aarch64 and Windows/ARM have no published
Expand Down
8 changes: 7 additions & 1 deletion kyo-caliban/src/test/scala/kyo/BaseCalibanTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ abstract class BaseCalibanTest extends kyo.test.Test[Any]:
// ResolversTest's WebSocket subscription leaves are timing-sensitive: under kyo-test's concurrent leaf pool,
// overlapping leaves starve WebSocket message delivery past the collectMessages deadline (these pass reliably on
// main, where ScalaTest ran the suite's tests sequentially). Run each suite's leaves sequentially to restore that.
override def config = super.config.sequential
//
// Only the socket category is disabled. The GraphQL server in these suites is Scope-managed (closed on scope exit),
// but the NIO transport defers a listening socket's real fd close to its idle selector's next select(), which nothing
// wakes, so the listener fd outlives the run. That fix belongs to the transport (frozen for the kyo-net rewrite); the
// socket is an opaque socket:[inode] on an ephemeral port that no allowlist can match. File-descriptor, thread, and
// fiber detection stay on.
override def config = super.config.sequential.leakCheckSockets(false)

// Run the body THROUGH the ZIO runtime (preserving the kyo<->ZIO/caliban interop these tests cover),
// bridging the resulting Future back into a kyo computation so it can be a kyo-test leaf body.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
package kyo.ffi.internal

import java.nio.channels.FileChannel
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
import kyo.*
import kyo.AllowUnsafe.embrace.danger
import kyo.ffi.Test

/** Resource-extraction concurrency.
Expand All @@ -24,32 +23,37 @@ class NativeLoaderConcurrencyTest extends Test:
override def config = super.config.sequential

private def tempDir(): Path =
Files.createTempDirectory("kyo-ffi-f11-").nn
Sync.Unsafe.evalOrThrow(Path.tempDir("kyo-ffi-f11-"))

// NativeLoader's API, FileChannel, and the extracted-file registry are java.nio.file.Path infra (FFM, cross-process
// advisory locks, atomic-move fallback); bridge the test's kyo.Path values to java only at those call sites.
private def j(p: Path): java.nio.file.Path =
java.nio.file.Path.of(p.toString)

"tryCleanupStaleLock" - {
"removes an abandoned .lck file with no live lock holder" in {
val dir = tempDir()
val lck = dir.resolve("libabc-deadbeef.lck").nn
Files.createFile(lck): Unit
assert(Files.exists(lck) == true)
val lck = dir / "libabc-deadbeef.lck"
lck.unsafe.mkFile().getOrThrow
assert(lck.unsafe.exists() == true)

val removed = NativeLoader.tryCleanupStaleLock(lck)
val removed = NativeLoader.tryCleanupStaleLock(j(lck))

assert(removed == true)
assert(Files.exists(lck) == false)
assert(lck.unsafe.exists() == false)
}

"leaves a live-locked .lck file in place" in {
val dir = tempDir()
val lck = dir.resolve("libxyz-cafef00d.lck").nn
val ch = FileChannel.open(lck, StandardOpenOption.CREATE, StandardOpenOption.WRITE).nn
val lck = dir / "libxyz-cafef00d.lck"
val ch = FileChannel.open(j(lck), StandardOpenOption.CREATE, StandardOpenOption.WRITE).nn
try
val lk = ch.lock().nn
try
val removed = NativeLoader.tryCleanupStaleLock(lck)
val removed = NativeLoader.tryCleanupStaleLock(j(lck))
// Another in-JVM lock holder → tryLock throws OverlappingFileLockException, caller must NOT delete.
assert(removed == false)
assert(Files.exists(lck) == true)
assert(lck.unsafe.exists() == true)
finally lk.release()
end try
finally ch.close()
Expand All @@ -58,14 +62,14 @@ class NativeLoaderConcurrencyTest extends Test:

"returns false for a missing lock file" in {
val dir = tempDir()
val lck = dir.resolve("does-not-exist.lck").nn
assert(NativeLoader.tryCleanupStaleLock(lck) == false)
val lck = dir / "does-not-exist.lck"
assert(NativeLoader.tryCleanupStaleLock(j(lck)) == false)
}
}

"resolveExtractDir" - {
"honours -Dkyo.ffi.extractDir= verbatim" in {
val explicit = tempDir().resolve("f11-explicit").nn
val explicit = tempDir() / "f11-explicit"
val prop = "kyo.ffi.extractDir"
val prior = Option(java.lang.System.getProperty(prop))
java.lang.System.setProperty(prop, explicit.toString): Unit
Expand Down Expand Up @@ -96,59 +100,57 @@ class NativeLoaderConcurrencyTest extends Test:
"writeAtomicRename" - {
"atomically installs the full payload and leaves no .tmp-<uuid> residue on success" in {
val dir = tempDir()
val out = dir.resolve("libpayload-cafebabe.so").nn
val data = "kyo-ffi F11 atomic payload".getBytes
val out = dir / "libpayload-cafebabe.so"
val data = "kyo-ffi F11 atomic payload"

NativeLoader.writeAtomicRename(dir, out, data)
NativeLoader.writeAtomicRename(j(dir), j(out), data.getBytes)

assert(Files.exists(out) == true)
assert(Files.readAllBytes(out).nn.toSeq == data.toSeq)
assert(out.unsafe.exists() == true)
assert(out.unsafe.read().getOrThrow == data)
// No `.tmp-<uuid>` sibling should remain, the atomic rename consumed the temp file.
val entries = Files.list(dir).nn.iterator().nn
while entries.hasNext do
val name = entries.next().nn.getFileName.nn.toString
assert(!name.contains(".tmp-"))
end while
// Path.Unsafe.list closes the dir stream as it collects (no leaked fd).
val entries = dir.unsafe.list().getOrThrow
entries.foreach(entry => assert(!entry.name.getOrElse("").contains(".tmp-")))
}
}

"cleanupExtractedFiles" - {
"removes files newer than install epoch" in {
val dir = tempDir()
val fresh = dir.resolve("libfresh-00112233.so").nn
Files.write(fresh, "fresh bytes".getBytes): Unit
val fresh = dir / "libfresh-00112233.so"
fresh.unsafe.write("fresh bytes").getOrThrow

val reg =
classOf[NativeLoader.type].nn.getDeclaredField("extractedThisJvm").nn
reg.setAccessible(true)
val set = reg.get(NativeLoader).asInstanceOf[java.util.Set[Path]]
set.add(fresh): Unit
val set = reg.get(NativeLoader).asInstanceOf[java.util.Set[java.nio.file.Path]]
set.add(j(fresh)): Unit
try
// Install epoch is BEFORE file creation, so fresh file's mtime ≥ install → deleted.
NativeLoader.cleanupExtractedFiles(0L)
assert(Files.exists(fresh) == false)
finally set.remove(fresh): Unit
assert(fresh.unsafe.exists() == false)
finally set.remove(j(fresh)): Unit
end try
}

"leaves files older than install epoch alone" in {
val dir = tempDir()
val old_ = dir.resolve("libold-44556677.so").nn
Files.write(old_, "old bytes".getBytes): Unit
val old_ = dir / "libold-44556677.so"
old_.unsafe.write("old bytes").getOrThrow

val reg =
classOf[NativeLoader.type].nn.getDeclaredField("extractedThisJvm").nn
reg.setAccessible(true)
val set = reg.get(NativeLoader).asInstanceOf[java.util.Set[Path]]
set.add(old_): Unit
val set = reg.get(NativeLoader).asInstanceOf[java.util.Set[java.nio.file.Path]]
set.add(j(old_)): Unit
try
// Install epoch is FAR in the future, every file is older → none deleted.
val farFuture = java.lang.System.currentTimeMillis() + (1000L * 60 * 60 * 24 * 365)
NativeLoader.cleanupExtractedFiles(farFuture)
assert(Files.exists(old_) == true)
assert(old_.unsafe.exists() == true)
finally
set.remove(old_): Unit
Files.deleteIfExists(old_): Unit
set.remove(j(old_)): Unit
discard(old_.unsafe.remove())
end try
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
package kyo.ffi.internal

import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.security.MessageDigest
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kyo.*
import kyo.AllowUnsafe.embrace.danger
import kyo.ffi.Test
import scala.concurrent.Await
import scala.concurrent.ExecutionContext
Expand All @@ -26,12 +25,12 @@ class NativeLoaderForkStressTest extends Test:

// Resolve the JVM test classpath and a java binary so we can ProcessBuilder a child JVM that loads NativeLoaderForkMain.
private val javaHome: Path =
Paths.get(java.lang.System.getProperty("java.home").nn).nn
Path(java.lang.System.getProperty("java.home").nn)

private val javaBin: Path =
val candidate = javaHome.resolve("bin").nn.resolve("java").nn
if Files.exists(candidate) then candidate
else javaHome.resolve("bin").nn.resolve("java.exe").nn // Windows fallback; test will also cover Windows hosts.
val candidate = javaHome / "bin" / "java"
if candidate.unsafe.exists() then candidate
else javaHome / "bin" / "java.exe" // Windows fallback; test will also cover Windows hosts.
end javaBin

// The test runs in-VM; java.class.path contains the test runtime classpath (prod classes + test classes + scalatest + deps).
Expand All @@ -50,7 +49,7 @@ class NativeLoaderForkStressTest extends Test:
"fork N JVMs extract the same payload concurrently" in {
val forkN = sys.props.getOrElse("kyo.ffi.testForkN", "4").toInt
val payload = ("F11-fork-stress-payload-" + java.util.UUID.randomUUID()).getBytes()
val dir = Files.createTempDirectory("kyo-ffi-fork-").nn
val dir = Sync.Unsafe.evalOrThrow(Path.tempDir("kyo-ffi-fork-"))
val libId = s"forkstress_${java.lang.System.currentTimeMillis()}"
val hex = hexEncode(payload)

Expand Down Expand Up @@ -87,19 +86,11 @@ class NativeLoaderForkStressTest extends Test:
assert(reportedPaths.size == 1)

// The final extracted file exists, matches the payload byte-for-byte (atomic rename guarantee: no partial writes).
val finalPath = Paths.get(reportedPaths.head).nn
assert(Files.exists(finalPath) == true)
assert(Files.readAllBytes(finalPath).nn.toSeq == payload.toSeq)
// No `.tmp-<uuid>` residue from any child, atomic rename cleaned up every interim write.
val tmpLeftovers = Files.list(dir).nn.iterator().nn
val residue =
val buf = scala.collection.mutable.Buffer.empty[String]
while tmpLeftovers.hasNext do
val n = tmpLeftovers.next().nn.getFileName.nn.toString
if n.contains(".tmp-") then buf += n
end while
buf.toList
end residue
val finalPath = Path(reportedPaths.head)
assert(finalPath.unsafe.exists() == true)
assert(finalPath.unsafe.read().getOrThrow == new String(payload))
// No `.tmp-<uuid>` residue from any child; Path.Unsafe.list closes the dir stream as it collects (no leaked fd).
val residue = dir.unsafe.list().getOrThrow.map(_.name.getOrElse("")).filter(_.contains(".tmp-")).toList
assert(residue == Nil)
finally
pool.shutdownNow(): Unit
Expand Down
Loading
Loading