Skip to content

Commit bb1789f

Browse files
Merge pull request #6 from alexarchambault/recoverable-errors
Mark LockError-s as either recoverable or fatal, tweak API, add README
2 parents 9266e0b + d500dad commit bb1789f

File tree

7 files changed

+184
-58
lines changed

7 files changed

+184
-58
lines changed

README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# libdaemon-jvm
2+
3+
*libdaemon-jvm* is a [libdaemon](http://0pointer.de/lennart/projects/libdaemon)-inspired
4+
library for the JVM written in Scala.
5+
6+
It aims at making it easier for JVM-based daemon processes to
7+
- ensure that a single instance of it is running at a time
8+
- rely on Unix domain sockets (or Windows named pipes) to listen to incoming connections
9+
10+
## Single process
11+
12+
*libdaemon-jvm* relies on Java file lock mechanism to ensure only a single instance
13+
of a process is running at a time.
14+
15+
More concretely, it is passed a directory, where it writes or creates:
16+
- a lock file
17+
- a PID file
18+
- a domain socket (except when named pipes are used on Windows)
19+
20+
It ensures that no-two processes relying on the same directory can run at a time, relying
21+
on both the PID file and the domain socket to check for another running process.
22+
23+
## Domain sockets
24+
25+
*libdaemon-jvm* creates Unix domain sockets or Windows named pipes using either
26+
- the JNI Unix domain socket and Windows named pipe support in the [ipcsocket](https://github.com/sbt/ipcsocket) library
27+
- Unix domain socket support in Java >= 16
28+
29+
The ipcsocket library JNI support is only available on Linux / macOS / Windows for the
30+
x86_64 architecture, and macOS for the ARM64 architecture (untested). For other OSes and
31+
architectures, Java >= 16 is required.
32+
33+
On Windows on x86_64, *libdaemon-jvm* defaults to using ipcsocket JNI-based Windows named pipes.
34+
On Windows but on a different architecture, it defaults to the Unix domain socket support of
35+
Java >= 16, that happens to also work on Windows (requires a not-too-dated Windows 10 version),
36+
but is incompatible with Windows named pipes.
37+
38+
On other OSes, when using Java >= 16, *libdaemon-jvm* defaults to Java's own Unix domain socket
39+
support. On Java < 16, it only supports Linux on x86_64, or macOS on x86_64 or ARM64. Java >= 16
40+
and ipcsocket JNI-based sockets can talk to each other on the same machine (no hard requirement
41+
to use Java >= 16 for both clients and servers).
42+
43+
In all cases, when Java < 16 is supported, both Java >= 16 and Java < 16 clients and servers
44+
can talk to each other.
45+
46+
## Usage
47+
48+
Add the following dependency to your build
49+
```text
50+
io.github.alexarchambault.libdaemon::libdaemon:0.0.3
51+
```
52+
The latest version is [![Maven Central](https://img.shields.io/maven-central/v/io.github.alexarchambault.libdaemon/libdaemon.svg)](https://maven-badges.herokuapp.com/maven-central/io.github.alexarchambault.libdaemon/libdaemon).
53+
54+
From the server, call `Lock.tryAcquire`, and start accepting connections on the server socket in the thunk passed to it:
55+
```scala
56+
import libdaemonjvm.server._
57+
import java.nio.file._
58+
59+
val daemonDirectory: Path = ??? // pass a directory under the user home dir, computed with directories-jvm for example
60+
val lockFiles = LockFiles.under(daemonDirectory, "my-app-name\\daemon") // second argument is the Windows named pipe path (that doesn't live in the file system)
61+
Lock.tryAcquire(lockFiles) { serverSocket: Either[ServerSocket, ServerSocketChannel] =>
62+
// serverSocket is a Right(…) when Java >= 16 Unix domain socket support is used,
63+
// it's Left(…) when ipcsocket JNI support is used
64+
65+
// you should start listening on serverSocket here, and as much as possible,
66+
// only exit this block when you are actually accepting incoming connections
67+
}
68+
```

library/src/libdaemonjvm/LockFiles.scala

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,38 @@ import java.nio.file.attribute.PosixFilePermission
88
import java.nio.file.StandardOpenOption
99
import scala.collection.JavaConverters._
1010
import scala.util.Properties
11+
import libdaemonjvm.server.LockError
12+
import java.nio.channels.OverlappingFileLockException
1113

1214
final case class LockFiles(
1315
lockFile: Path,
1416
pidFile: Path,
1517
socketPaths: SocketPaths
1618
) {
17-
def withLock[T](t: => T): T = {
19+
def withLock[T](t: => Either[LockError, T]): Either[LockError, T] = {
1820
if (!Files.exists(lockFile)) {
1921
Files.createDirectories(lockFile.normalize.getParent)
2022
Files.write(lockFile, Array.emptyByteArray)
2123
}
22-
var c: FileChannel = null
23-
var l: FileLock = null
24+
var c: FileChannel = null
25+
var l: Either[OverlappingFileLockException, FileLock] = null
2426
try {
2527
c = FileChannel.open(lockFile, StandardOpenOption.WRITE)
26-
l = c.lock()
27-
t
28+
l =
29+
try Right(c.tryLock())
30+
catch {
31+
case ex: OverlappingFileLockException =>
32+
Left(ex)
33+
}
34+
l match {
35+
case Left(ex) => Left(new LockError.Locked(lockFile, ex))
36+
case Right(null) => Left(new LockError.Locked(lockFile))
37+
case Right(_) => t
38+
}
2839
}
2940
finally {
3041
if (l != null)
31-
try l.release()
42+
try l.foreach(_.release())
3243
catch {
3344
case _: ClosedChannelException =>
3445
case _: IOException =>

library/src/libdaemonjvm/server/Lock.scala

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,20 @@ import java.net.ServerSocket
1010

1111
object Lock {
1212

13-
def tryAcquire[T](files: LockFiles)
14-
: Either[LockError, Either[ServerSocket, ServerSocketChannel]] =
15-
tryAcquire(files, LockProcess.default, SocketHandler.server(files.socketPaths))
1613
def tryAcquire[T](
17-
files: LockFiles,
18-
proc: LockProcess
19-
): Either[LockError, Either[ServerSocket, ServerSocketChannel]] =
20-
tryAcquire(files, proc, SocketHandler.server(files.socketPaths))
14+
files: LockFiles
15+
)(
16+
startListening: Either[ServerSocket, ServerSocketChannel] => T
17+
): Either[LockError, T] =
18+
tryAcquire(files, LockProcess.default) {
19+
val socket = SocketHandler.server(files.socketPaths)
20+
startListening(socket)
21+
}
2122

2223
def tryAcquire[T](
2324
files: LockFiles,
24-
proc: LockProcess,
25+
proc: LockProcess
26+
)(
2527
setup: => T
2628
): Either[LockError, T] = {
2729

library/src/libdaemonjvm/server/LockError.scala

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,23 @@ sealed abstract class LockError(
88
) extends Exception(message, cause)
99

1010
object LockError {
11+
12+
sealed abstract class RecoverableError(
13+
message: String,
14+
cause: Throwable = null
15+
) extends LockError(message, cause)
16+
17+
sealed abstract class FatalError(
18+
message: String,
19+
cause: Throwable = null
20+
) extends LockError(message, cause)
21+
1122
final class AlreadyRunning(val pid: Int)
12-
extends LockError(s"Daemon already running (PID: $pid)")
23+
extends FatalError(s"Daemon already running (PID: $pid)")
1324
final class CannotDeleteFile(val file: Path, cause: Throwable)
14-
extends LockError(s"Cannot delete $file", cause)
25+
extends FatalError(s"Cannot delete $file", cause)
1526
final class ZombieFound(val pid: Int, val connectionError: Throwable)
16-
extends LockError(s"Cannot connect to process $pid", connectionError)
27+
extends RecoverableError(s"Cannot connect to process $pid", connectionError)
28+
final class Locked(val file: Path, cause: Throwable = null)
29+
extends RecoverableError(s"$file already locked", cause)
1730
}

manual/server/src/libdaemonjvm/TestServer.scala

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package libdaemonjvm
22

3-
import java.io.IOException
3+
import java.io.{Closeable, IOException}
44
import java.nio.file.{Files, Paths}
55
import java.nio.file.attribute.PosixFilePermission
66
import java.util.concurrent.atomic.AtomicInteger
@@ -15,6 +15,26 @@ import libdaemonjvm.server.Lock
1515
object TestServer {
1616
val delay = 2.seconds
1717
def runTestClients = false
18+
19+
def runServer(incomingConn: () => Closeable): Unit = {
20+
val count = new AtomicInteger
21+
while (true) {
22+
println("Waiting for clients")
23+
val c = incomingConn()
24+
val idx = count.incrementAndGet()
25+
val runnable: Runnable = { () =>
26+
println(s"New incoming connection $idx, closing it in $delay")
27+
Thread.sleep(delay.toMillis)
28+
println(s"Closing incoming connection $idx")
29+
c.close()
30+
println(s"Closed incoming connection $idx")
31+
}
32+
val t = new Thread(runnable)
33+
t.start()
34+
Thread.sleep(1000L) // meh, wait for server to be actually listening
35+
}
36+
}
37+
1838
def main(args: Array[String]): Unit = {
1939
val path = Paths.get("data-dir")
2040
if (!Properties.isWin) {
@@ -29,10 +49,9 @@ object TestServer {
2949
)
3050
}
3151
val files = LockFiles.under(path, "libdaemonjvm\\test-server-client\\pipe")
32-
val incomingConn = Lock.tryAcquire(files) match {
33-
case Left(e) => throw e
34-
case Right(Left(s)) => () => s.accept()
35-
case Right(Right(s)) => () => s.accept()
52+
Lock.tryAcquire(files)(s => runServer(() => s.fold(_.accept(), _.accept()))) match {
53+
case Left(e) => throw e
54+
case Right(()) =>
3655
}
3756

3857
def clientRunnable(idx: Int): Runnable = { () =>
@@ -59,22 +78,5 @@ object TestServer {
5978
runClient(3)
6079
runClient(4)
6180
}
62-
63-
val count = new AtomicInteger
64-
while (true) {
65-
println("Waiting for clients")
66-
val c = incomingConn()
67-
val idx = count.incrementAndGet()
68-
val runnable: Runnable = { () =>
69-
println(s"New incoming connection $idx, closing it in $delay")
70-
Thread.sleep(delay.toMillis)
71-
println(s"Closing incoming connection $idx")
72-
c.close()
73-
println(s"Closed incoming connection $idx")
74-
}
75-
val t = new Thread(runnable)
76-
t.setDaemon(true)
77-
t.start()
78-
}
7981
}
8082
}

tests/test/src/libdaemonjvm/tests/LockTests.scala

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,23 @@ class LockTests extends munit.FunSuite {
140140
}
141141
}
142142

143+
test("locked") {
144+
TestUtil.withTestDir { dir =>
145+
val files = TestUtil.lockFiles(dir)
146+
val e = files.withLock {
147+
TestUtil.tryAcquire(files) { maybeChannel =>
148+
maybeChannel match {
149+
case Left(e: LockError.Locked) =>
150+
case Left(otherError) =>
151+
throw new Exception("Unexpected error type (expected Locked)", otherError)
152+
case Right(channel) =>
153+
sys.error("Opening new server channel should have failed")
154+
}
155+
}
156+
Right(())
157+
}
158+
expect(e.isRight)
159+
}
160+
}
161+
143162
}

tests/test/src/libdaemonjvm/tests/TestUtil.scala

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import java.util.concurrent.CountDownLatch
1313
import java.net.Socket
1414
import java.util.concurrent.atomic.AtomicBoolean
1515
import libdaemonjvm.internal.SocketFile
16+
import scala.util.control.NonFatal
1617

1718
object TestUtil {
1819
private lazy val testDirBase = {
@@ -67,31 +68,41 @@ object TestUtil {
6768
files: LockFiles,
6869
proc: LockProcess
6970
)(f: Either[LockError, Either[ServerSocket, ServerSocketChannel]] => T): T = {
70-
var maybeServerChannel: Either[LockError, Either[ServerSocket, ServerSocketChannel]] = null
71-
var acceptThreadOpt = Option.empty[Thread]
72-
val accepting = new CountDownLatch(1)
73-
val shouldStop = new AtomicBoolean(false)
71+
var serverChannel: Either[ServerSocket, ServerSocketChannel] = null
72+
var acceptThreadOpt = Option.empty[Thread]
73+
val accepting = new CountDownLatch(1)
74+
val shouldStop = new AtomicBoolean(false)
7475
try {
75-
maybeServerChannel = Lock.tryAcquire(files, proc)
76-
if (Properties.isWin)
77-
// Windows named pipes seem no to accept clients unless accept is being called on the server socket
78-
acceptThreadOpt =
79-
maybeServerChannel.toOption.flatMap(_.left.toOption.map(acceptAndDiscard(
80-
_,
81-
accepting,
82-
() => shouldStop.get()
83-
)))
84-
for (t <- acceptThreadOpt) {
85-
t.start()
86-
accepting.await()
87-
Thread.sleep(1000L) // waiting so that the accept call below effectively awaits client... :|
76+
val maybeServerChannel = Lock.tryAcquire(files, proc) {
77+
serverChannel = SocketHandler.server(files.socketPaths)
78+
if (Properties.isWin)
79+
// Windows named pipes seem no to accept clients unless accept is being called on the server socket
80+
acceptThreadOpt =
81+
serverChannel.left.toOption.map(acceptAndDiscard(
82+
_,
83+
accepting,
84+
() => shouldStop.get()
85+
))
86+
for (t <- acceptThreadOpt) {
87+
t.start()
88+
accepting.await()
89+
// waiting so that the accept call below effectively awaits client... :|
90+
Thread.sleep(
91+
1000L
92+
)
93+
}
94+
serverChannel
8895
}
8996
f(maybeServerChannel)
9097
}
9198
finally {
9299
shouldStop.set(true)
93-
SocketFile.canConnect(files.socketPaths) // unblock the server thread last accept
94-
for (e <- Option(maybeServerChannel); channel <- e)
100+
try SocketFile.canConnect(files.socketPaths) // unblock the server thread last accept
101+
catch {
102+
case NonFatal(e) =>
103+
System.err.println(s"Ignoring $e while trying to unblock last accept")
104+
}
105+
for (channel <- Option(serverChannel))
95106
channel.merge.close()
96107
}
97108
}

0 commit comments

Comments
 (0)