Skip to content

Commit 20e1451

Browse files
ctruedenclaude
andcommitted
Add single-instance handoff support
Introduces SingleInstance with a listen/tryHandoff API backed by a plain TCP socket and a per-app lockfile. The first instance calls listen() to claim a port; subsequent launches detect the lockfile in ClassLauncher.main, forward their args over the socket, and exit before the splash screen appears. The lockfile stores a 128-bit random secret greeting and another 128-bit random secret response, and is made owner-readable only, matching the security model of an RMI stub approach without its serialization overhead. Tests cover: no lockfile (returns false), successful handoff (args delivered to receiver), stale lockfile deleted on connection failure, and wrong-process detection via incorrect acknowledgement. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0161fef commit 20e1451

3 files changed

Lines changed: 345 additions & 0 deletions

File tree

src/main/java/org/scijava/launcher/ClassLauncher.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ public static void main(final String... args) {
8080
if (Boolean.getBoolean("scijava.app.unlock-modules")) {
8181
ReflectionUnlocker.unlockAll();
8282
}
83+
if (SingleInstance.tryHandoff(args)) System.exit(0);
8384
tryToRun(Splash::show);
8485
tryToRun(Java::check);
8586
String appName = appName();
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/*-
2+
* #%L
3+
* Launcher for SciJava applications.
4+
* %%
5+
* Copyright (C) 2007 - 2026 SciJava developers.
6+
* %%
7+
* Redistribution and use in source and binary forms, with or without
8+
* modification, are permitted provided that the following conditions are met:
9+
*
10+
* 1. Redistributions of source code must retain the above copyright notice,
11+
* this list of conditions and the following disclaimer.
12+
* 2. Redistributions in binary form must reproduce the above copyright notice,
13+
* this list of conditions and the following disclaimer in the documentation
14+
* and/or other materials provided with the distribution.
15+
*
16+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17+
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18+
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
19+
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
20+
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
21+
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
22+
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
23+
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
24+
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
25+
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
26+
* POSSIBILITY OF SUCH DAMAGE.
27+
* #L%
28+
*/
29+
30+
package org.scijava.launcher;
31+
32+
import java.io.BufferedReader;
33+
import java.io.File;
34+
import java.io.IOException;
35+
import java.io.InputStreamReader;
36+
import java.io.OutputStreamWriter;
37+
import java.io.PrintWriter;
38+
import java.net.InetSocketAddress;
39+
import java.net.ServerSocket;
40+
import java.net.Socket;
41+
import java.nio.charset.StandardCharsets;
42+
import java.nio.file.Files;
43+
import java.nio.file.Path;
44+
import java.nio.file.Paths;
45+
import java.security.SecureRandom;
46+
import java.util.ArrayList;
47+
import java.util.List;
48+
import java.util.function.Consumer;
49+
50+
/**
51+
* Manages single-instance behavior for SciJava applications.
52+
* <p>
53+
* The first instance calls {@link #listen} to claim a TCP port and begin
54+
* accepting forwarded argument lists from later launches. Subsequent launches
55+
* call {@link #tryHandoff} (via {@link ClassLauncher}), which detects the
56+
* running instance, forwards its args over the socket, and signals the caller
57+
* to exit the JVM — before the splash screen ever appears.
58+
* </p>
59+
* <p>
60+
* A lockfile in the system temp directory stores the port and a 128-bit random
61+
* secret. The file is made owner-readable only (equivalent to chmod 0600), so
62+
* only the same OS user can read the secret and connect — the same security
63+
* boundary that would be provided by an RMI stub approach, without its
64+
* overhead or serialization risks.
65+
* </p>
66+
* <p>
67+
* Wire protocol: the client sends the secret as the first line, followed by
68+
* one arg per line, then closes the connection. The server rejects connections
69+
* that present the wrong secret and dispatches the rest immediately to the
70+
* provided {@link Consumer} without waiting for results, so the client exits
71+
* in milliseconds regardless of what the server does with the args.
72+
* </p>
73+
*
74+
* @author Curtis Rueden
75+
*/
76+
public class SingleInstance {
77+
78+
/**
79+
* Opens a server socket and begins accepting forwarded argument lists.
80+
* <p>
81+
* Call this once after application startup. Use {@code port = 0} to let the
82+
* OS assign a free port automatically.
83+
* </p>
84+
*
85+
* @param port TCP port to listen on, or 0 for an OS-assigned port.
86+
* @param argReceiver called on a daemon thread each time args arrive.
87+
*/
88+
public static void listen(int port, Consumer<String[]> argReceiver) {
89+
try {
90+
ServerSocket server = new ServerSocket(port);
91+
int actualPort = server.getLocalPort();
92+
93+
SecureRandom rng = new SecureRandom();
94+
byte[] buf = new byte[16];
95+
rng.nextBytes(buf); String secretGreeting = toHex(buf);
96+
rng.nextBytes(buf); String secretResponse = toHex(buf);
97+
98+
Path lockFile = lockfilePath();
99+
Files.write(lockFile, (actualPort + "\n" + secretGreeting + "\n" + secretResponse + "\n").getBytes(StandardCharsets.UTF_8));
100+
setOwnerOnly(lockFile.toFile());
101+
lockFile.toFile().deleteOnExit();
102+
103+
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
104+
try { server.close(); } catch (IOException ignored) {}
105+
lockFile.toFile().delete();
106+
}, "SingleInstance-Shutdown"));
107+
108+
Thread listener = new Thread(() -> acceptLoop(server, secretGreeting, secretResponse, argReceiver), "SingleInstance-Listener");
109+
listener.setDaemon(true);
110+
listener.start();
111+
112+
Log.debug("[SingleInstance] Listening on port " + actualPort);
113+
}
114+
catch (IOException e) {
115+
Log.error(e);
116+
}
117+
}
118+
119+
/**
120+
* Attempts to forward {@code args} to a running single-instance listener.
121+
*
122+
* @return {@code true} if args were successfully handed off; the caller
123+
* should then exit the JVM. Returns {@code false} if no listener is
124+
* active, in which case normal startup should continue.
125+
*/
126+
static boolean tryHandoff(String[] args) {
127+
Path lockFile = lockfilePath();
128+
if (!lockFile.toFile().exists()) return false;
129+
try {
130+
List<String> lines = Files.readAllLines(lockFile, StandardCharsets.UTF_8);
131+
if (lines.size() < 3) return false;
132+
int port = Integer.parseInt(lines.get(0).trim());
133+
String secretGreeting = lines.get(1).trim();
134+
String secretResponse = lines.get(2).trim();
135+
try (Socket socket = new Socket()) {
136+
socket.connect(new InetSocketAddress("127.0.0.1", port), 200);
137+
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
138+
PrintWriter out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true);
139+
out.println(secretGreeting);
140+
if (!secretResponse.equals(in.readLine())) {
141+
Log.debug("[SingleInstance] Handoff rejected (wrong process on port), removing stale lockfile.");
142+
lockFile.toFile().delete();
143+
return false;
144+
}
145+
for (String arg : args) out.println(arg);
146+
}
147+
Log.debug("[SingleInstance] Args handed off to existing instance.");
148+
return true;
149+
}
150+
catch (Exception e) {
151+
Log.debug("[SingleInstance] Handoff failed, removing stale lockfile: " + e.getMessage());
152+
lockFile.toFile().delete();
153+
return false;
154+
}
155+
}
156+
157+
private static void acceptLoop(ServerSocket server, String secretGreeting, String secretResponse, Consumer<String[]> argReceiver) {
158+
while (!server.isClosed()) {
159+
try {
160+
Socket client = server.accept();
161+
Thread handler = new Thread(() -> handleConnection(client, secretGreeting, secretResponse, argReceiver), "SingleInstance-Handler");
162+
handler.setDaemon(true);
163+
handler.start();
164+
}
165+
catch (IOException e) {
166+
if (!server.isClosed()) Log.debug(e);
167+
}
168+
}
169+
}
170+
171+
private static void handleConnection(Socket client, String secretGreeting, String secretResponse, Consumer<String[]> argReceiver) {
172+
try (Socket s = client; BufferedReader in = new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8))) {
173+
String receivedSecret = in.readLine();
174+
if (!secretGreeting.equals(receivedSecret)) {
175+
Log.debug("[SingleInstance] Rejected connection: wrong secret.");
176+
return;
177+
}
178+
PrintWriter out = new PrintWriter(new OutputStreamWriter(s.getOutputStream(), StandardCharsets.UTF_8), true);
179+
out.println(secretResponse);
180+
List<String> args = new ArrayList<>();
181+
String line;
182+
while ((line = in.readLine()) != null) args.add(line);
183+
if (!args.isEmpty()) argReceiver.accept(args.toArray(new String[0]));
184+
}
185+
catch (IOException e) {
186+
Log.debug(e);
187+
}
188+
}
189+
190+
private static Path lockfilePath() {
191+
String appName = ClassLauncher.appName("scijava");
192+
String userName = System.getProperty("user.name", "user");
193+
String tmpDir = System.getProperty("java.io.tmpdir");
194+
return Paths.get(tmpDir, "scijava-" + appName + "-" + userName + ".lock");
195+
}
196+
197+
private static void setOwnerOnly(File f) {
198+
f.setReadable(false, false);
199+
f.setReadable(true, true);
200+
f.setWritable(false, false);
201+
f.setWritable(true, true);
202+
}
203+
204+
private static String toHex(byte[] bytes) {
205+
StringBuilder sb = new StringBuilder(bytes.length * 2);
206+
for (byte b : bytes) sb.append(String.format("%02x", b));
207+
return sb.toString();
208+
}
209+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*-
2+
* #%L
3+
* Launcher for SciJava applications.
4+
* %%
5+
* Copyright (C) 2007 - 2026 SciJava developers.
6+
* %%
7+
* Redistribution and use in source and binary forms, with or without
8+
* modification, are permitted provided that the following conditions are met:
9+
*
10+
* 1. Redistributions of source code must retain the above copyright notice,
11+
* this list of conditions and the following disclaimer.
12+
* 2. Redistributions in binary form must reproduce the above copyright notice,
13+
* this list of conditions and the following disclaimer in the documentation
14+
* and/or other materials provided with the distribution.
15+
*
16+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17+
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18+
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
19+
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
20+
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
21+
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
22+
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
23+
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
24+
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
25+
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
26+
* POSSIBILITY OF SUCH DAMAGE.
27+
* #L%
28+
*/
29+
30+
package org.scijava.launcher;
31+
32+
import org.junit.jupiter.api.AfterEach;
33+
import org.junit.jupiter.api.BeforeEach;
34+
import org.junit.jupiter.api.Test;
35+
36+
import java.io.IOException;
37+
import java.net.ServerSocket;
38+
import java.net.Socket;
39+
import java.nio.charset.StandardCharsets;
40+
import java.nio.file.Files;
41+
import java.nio.file.Path;
42+
import java.nio.file.Paths;
43+
import java.util.List;
44+
import java.util.concurrent.CopyOnWriteArrayList;
45+
import java.util.concurrent.CountDownLatch;
46+
import java.util.concurrent.TimeUnit;
47+
48+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
49+
import static org.junit.jupiter.api.Assertions.assertFalse;
50+
import static org.junit.jupiter.api.Assertions.assertTrue;
51+
52+
/**
53+
* Tests {@link SingleInstance}.
54+
*
55+
* @author Curtis Rueden
56+
*/
57+
public class SingleInstanceTest {
58+
59+
private static final String APP_NAME = "app-launcher-test";
60+
61+
@BeforeEach
62+
public void setup() {
63+
System.setProperty("scijava.app.name", APP_NAME);
64+
}
65+
66+
@AfterEach
67+
public void cleanup() {
68+
System.clearProperty("scijava.app.name");
69+
lockfilePath().toFile().delete();
70+
}
71+
72+
@Test
73+
public void testNoLockfile() {
74+
assertFalse(SingleInstance.tryHandoff(new String[]{"arg1"}));
75+
}
76+
77+
@Test
78+
public void testHandoffDeliversArgs() throws Exception {
79+
List<String[]> received = new CopyOnWriteArrayList<>();
80+
CountDownLatch latch = new CountDownLatch(1);
81+
SingleInstance.listen(0, args -> {
82+
received.add(args);
83+
latch.countDown();
84+
});
85+
86+
String[] sent = {"fiji://hello/dialog?greeting=Two", "--headless"};
87+
assertTrue(SingleInstance.tryHandoff(sent));
88+
assertTrue(latch.await(2, TimeUnit.SECONDS), "argReceiver was not called");
89+
assertArrayEquals(sent, received.get(0));
90+
}
91+
92+
@Test
93+
public void testStaleLockfileIsDeleted() throws Exception {
94+
// Grab a port, then release it immediately so nothing is listening on it.
95+
int port;
96+
try (ServerSocket s = new ServerSocket(0)) {
97+
port = s.getLocalPort();
98+
}
99+
Path lockFile = lockfilePath();
100+
Files.write(lockFile, (port + "\nhello\nwazzzzzup\n").getBytes(StandardCharsets.UTF_8));
101+
102+
assertFalse(SingleInstance.tryHandoff(new String[]{"arg1"}));
103+
assertFalse(lockFile.toFile().exists());
104+
}
105+
106+
@Test
107+
public void testWrongProcessOnPortIsRejected() throws Exception {
108+
// An impostor that accepts connections but sends a wrong server secret.
109+
ServerSocket impostor = new ServerSocket(0);
110+
int port = impostor.getLocalPort();
111+
Path lockFile = lockfilePath();
112+
Files.write(lockFile, (port + "\nhowdy\nexpected\n").getBytes(StandardCharsets.UTF_8));
113+
114+
Thread t = new Thread(() -> {
115+
try (Socket s = impostor.accept()) {
116+
// Drain the client secret, then send a wrong server secret.
117+
new java.io.BufferedReader(new java.io.InputStreamReader(s.getInputStream())).readLine();
118+
new java.io.PrintWriter(s.getOutputStream(), true).println("wrong");
119+
}
120+
catch (IOException ignored) {}
121+
finally { try { impostor.close(); } catch (IOException ignored) {} }
122+
});
123+
t.setDaemon(true);
124+
t.start();
125+
126+
assertFalse(SingleInstance.tryHandoff(new String[]{"arg1"}));
127+
assertFalse(lockFile.toFile().exists());
128+
}
129+
130+
private static Path lockfilePath() {
131+
String userName = System.getProperty("user.name", "user");
132+
String tmpDir = System.getProperty("java.io.tmpdir");
133+
return Paths.get(tmpDir, "scijava-" + APP_NAME + "-" + userName + ".lock");
134+
}
135+
}

0 commit comments

Comments
 (0)