View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.eclipse.aether.named.ipc;
20  
21  import java.io.Closeable;
22  import java.io.DataInputStream;
23  import java.io.DataOutputStream;
24  import java.io.EOFException;
25  import java.io.File;
26  import java.io.FileWriter;
27  import java.io.IOException;
28  import java.io.InterruptedIOException;
29  import java.io.OutputStream;
30  import java.io.PrintWriter;
31  import java.io.RandomAccessFile;
32  import java.net.SocketAddress;
33  import java.net.URL;
34  import java.nio.channels.ByteChannel;
35  import java.nio.channels.Channels;
36  import java.nio.channels.FileLock;
37  import java.nio.channels.ServerSocketChannel;
38  import java.nio.channels.SocketChannel;
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.security.SecureRandom;
44  import java.util.ArrayList;
45  import java.util.Arrays;
46  import java.util.Collection;
47  import java.util.List;
48  import java.util.Locale;
49  import java.util.Map;
50  import java.util.Objects;
51  import java.util.concurrent.CompletableFuture;
52  import java.util.concurrent.ConcurrentHashMap;
53  import java.util.concurrent.ExecutionException;
54  import java.util.concurrent.ExecutorService;
55  import java.util.concurrent.Executors;
56  import java.util.concurrent.Future;
57  import java.util.concurrent.TimeUnit;
58  import java.util.concurrent.TimeoutException;
59  import java.util.concurrent.atomic.AtomicInteger;
60  
61  import static org.eclipse.aether.named.ipc.IpcMessages.REQUEST_ACQUIRE;
62  import static org.eclipse.aether.named.ipc.IpcMessages.REQUEST_CLOSE;
63  import static org.eclipse.aether.named.ipc.IpcMessages.REQUEST_CONTEXT;
64  import static org.eclipse.aether.named.ipc.IpcMessages.REQUEST_STOP;
65  import static org.eclipse.aether.named.ipc.IpcMessages.RESPONSE_ACQUIRE;
66  import static org.eclipse.aether.named.ipc.IpcMessages.RESPONSE_CLOSE;
67  import static org.eclipse.aether.named.ipc.IpcMessages.RESPONSE_CONTEXT;
68  import static org.eclipse.aether.named.ipc.IpcMessages.RESPONSE_STOP;
69  
70  /**
71   * Client side implementation.
72   * The client instance is bound to a given maven repository.
73   *
74   * @since 2.0.1
75   */
76  public class IpcClient {
77  
78      static final boolean IS_WINDOWS =
79              System.getProperty("os.name").toLowerCase(Locale.ENGLISH).contains("win");
80  
81      private static final SecureRandom SECURE_RANDOM = new SecureRandom();
82  
83      protected volatile boolean initialized;
84      protected final Path lockPath;
85      protected final Path logPath;
86      protected final Path syncPath;
87      protected final boolean noFork;
88  
89      protected volatile SocketChannel socket;
90      protected volatile DataOutputStream output;
91      protected volatile DataInputStream input;
92      protected volatile Thread receiver;
93      protected volatile Process process;
94  
95      protected final AtomicInteger requestId = new AtomicInteger();
96      protected final Map<Integer, CompletableFuture<List<String>>> responses = new ConcurrentHashMap<>();
97  
98      /**
99       * The bootstrap token shared with the server this client spawned (if it spawned one): the only credential
100      * accepted by the server for a stop request. Remains {@code null} when this client attached to an already
101      * running server, which consequently cannot be stopped from here.
102      */
103     protected volatile String bootstrapToken;
104 
105     IpcClient(Path lockPath, Path logPath, Path syncPath) {
106         this.lockPath = lockPath;
107         this.logPath = logPath;
108         this.syncPath = syncPath;
109         this.noFork = Boolean.parseBoolean(
110                 System.getProperty(IpcServer.SYSTEM_PROP_NO_FORK, Boolean.toString(IpcServer.DEFAULT_NO_FORK)));
111     }
112 
113     void ensureInitialized() throws IOException {
114         if (!initialized) {
115             // caller must block on this method
116             synchronized (this) {
117                 if (!initialized) {
118                     socket = createClient();
119                     ByteChannel wrapper = new ByteChannelWrapper(socket);
120                     input = new DataInputStream(Channels.newInputStream(wrapper));
121                     output = new DataOutputStream(Channels.newOutputStream(wrapper));
122                     receiver = new Thread(this::receive);
123                     receiver.setDaemon(true);
124                     receiver.start();
125                     initialized = true;
126                 }
127             }
128         }
129     }
130 
131     private synchronized DataInputStream getInput() {
132         return input;
133     }
134 
135     private synchronized DataOutputStream getOutput() {
136         return output;
137     }
138 
139     SocketChannel createClient() throws IOException {
140         SocketFamily family =
141                 SocketFamily.valueOf(System.getProperty(IpcServer.SYSTEM_PROP_FAMILY, IpcServer.DEFAULT_FAMILY));
142 
143         Path lockPath = this.lockPath.toAbsolutePath().normalize();
144         Path lockFile =
145                 lockPath.resolve(".maven-resolver-ipc-lock-" + family.name().toLowerCase(Locale.ENGLISH));
146         if (!Files.isRegularFile(lockFile)) {
147             if (!Files.isDirectory(lockFile.getParent())) {
148                 Files.createDirectories(lockFile.getParent());
149             }
150         }
151 
152         try (RandomAccessFile raf = new RandomAccessFile(lockFile.toFile(), "rw")) {
153             try (FileLock lock = raf.getChannel().lock()) {
154                 String line = raf.readLine();
155                 if (line != null) {
156                     try {
157                         SocketAddress address = SocketFamily.fromString(line);
158                         return SocketChannel.open(address);
159                     } catch (IOException e) {
160                         // ignore
161                     }
162                 }
163 
164                 ServerSocketChannel ss = family.openServerSocket();
165                 String tmpaddr = SocketFamily.toString(ss.getLocalAddress());
166                 // the token authorizes stopping the daemon: it must not be guessable by other local users
167                 String rand = Long.toHexString(SECURE_RANDOM.nextLong()) + Long.toHexString(SECURE_RANDOM.nextLong());
168                 String nativeName =
169                         System.getProperty(IpcServer.SYSTEM_PROP_NATIVE_NAME, IpcServer.DEFAULT_NATIVE_NAME);
170                 String syncCmd = IS_WINDOWS ? nativeName + ".exe" : nativeName;
171 
172                 boolean debug = Boolean.parseBoolean(
173                         System.getProperty(IpcServer.SYSTEM_PROP_DEBUG, Boolean.toString(IpcServer.DEFAULT_DEBUG)));
174                 boolean noNative = Boolean.parseBoolean(System.getProperty(
175                         IpcServer.SYSTEM_PROP_NO_NATIVE, Boolean.toString(IpcServer.DEFAULT_NO_NATIVE)));
176                 if (!noNative) {
177                     noNative = !Files.isExecutable(syncPath.resolve(syncCmd));
178                 }
179                 Closeable close;
180                 Path logFile = logPath.resolve("resolver-ipcsync-" + rand + ".log");
181                 List<String> args = new ArrayList<>();
182                 if (noNative) {
183                     if (noFork) {
184                         IpcServer server = IpcServer.runServer(family, tmpaddr, rand);
185                         close = server::close;
186                     } else {
187                         String javaHome = System.getenv("JAVA_HOME");
188                         if (javaHome == null) {
189                             javaHome = System.getProperty("java.home");
190                         }
191                         String javaCmd = IS_WINDOWS ? "bin\\java.exe" : "bin/java";
192                         String java = Paths.get(javaHome)
193                                 .resolve(javaCmd)
194                                 .toAbsolutePath()
195                                 .toString();
196                         args.add(java);
197                         String classpath = getJarPath(getClass()) + File.pathSeparator + getJarPath(IpcServer.class);
198                         args.add("-cp");
199                         args.add(classpath);
200                         String timeout = System.getProperty(IpcServer.SYSTEM_PROP_IDLE_TIMEOUT);
201                         if (timeout != null) {
202                             args.add("-D" + IpcServer.SYSTEM_PROP_IDLE_TIMEOUT + "=" + timeout);
203                         }
204                         args.add("-D" + IpcServer.SYSTEM_PROP_DEBUG + "=" + debug);
205                         args.add(IpcServer.class.getName());
206                         args.add(family.name());
207                         args.add(tmpaddr);
208                         // the bootstrap token is passed via stdin ("-" placeholder in argv): process arguments
209                         // are commonly visible to other local users (e.g. /proc/<pid>/cmdline)
210                         args.add("-");
211                         ProcessBuilder processBuilder = new ProcessBuilder();
212                         ProcessBuilder.Redirect discard = ProcessBuilder.Redirect.to(logFile.toFile());
213                         Files.createDirectories(logPath);
214                         this.process = processBuilder
215                                 .directory(lockFile.getParent().toFile())
216                                 .command(args)
217                                 .redirectOutput(discard)
218                                 .redirectError(discard)
219                                 .start();
220                         writeBootstrapToken(this.process, rand);
221                         close = this.process::destroyForcibly;
222                     }
223                 } else {
224                     args.add(syncPath.resolve(syncCmd).toString());
225                     String timeout = System.getProperty(IpcServer.SYSTEM_PROP_IDLE_TIMEOUT);
226                     if (timeout != null) {
227                         args.add("-D" + IpcServer.SYSTEM_PROP_IDLE_TIMEOUT + "=" + timeout);
228                     }
229                     args.add("-D" + IpcServer.SYSTEM_PROP_DEBUG + "=" + debug);
230                     args.add(family.name());
231                     args.add(tmpaddr);
232                     // see above: the bootstrap token goes via stdin, not argv
233                     args.add("-");
234                     ProcessBuilder processBuilder = new ProcessBuilder();
235                     ProcessBuilder.Redirect discard = ProcessBuilder.Redirect.to(logFile.toFile());
236                     Files.createDirectories(logPath);
237                     this.process = processBuilder
238                             .directory(lockFile.getParent().toFile())
239                             .command(args)
240                             .redirectOutput(discard)
241                             .redirectError(discard)
242                             .start();
243                     writeBootstrapToken(this.process, rand);
244                     close = this.process::destroyForcibly;
245                 }
246 
247                 ExecutorService es = Executors.newSingleThreadExecutor();
248                 Future<String[]> future = es.submit(() -> {
249                     SocketChannel s = ss.accept();
250                     DataInputStream dis = new DataInputStream(Channels.newInputStream(s));
251                     String rand2 = dis.readUTF();
252                     String addr2 = dis.readUTF();
253                     return new String[] {rand2, addr2};
254                 });
255                 String[] res;
256                 try {
257                     res = future.get(5, TimeUnit.SECONDS);
258                 } catch (Exception e) {
259                     try (PrintWriter writer = new PrintWriter(new FileWriter(logFile.toFile(), true))) {
260                         writer.println("Arguments:");
261                         args.forEach(writer::println);
262                         writer.println();
263                         writer.println("Exception:");
264                         e.printStackTrace(writer);
265                     }
266                     close.close();
267                     throw e;
268                 } finally {
269                     es.shutdownNow();
270                     ss.close();
271                 }
272                 if (!Objects.equals(rand, res[0])) {
273                     close.close();
274                     throw new IllegalStateException("IpcServer did not respond with the correct random");
275                 }
276                 this.bootstrapToken = rand;
277 
278                 SocketAddress addr = SocketFamily.fromString(res[1]);
279                 SocketChannel socket = SocketChannel.open(addr);
280 
281                 raf.seek(0);
282                 raf.writeBytes(res[1] + "\n");
283                 return socket;
284             } catch (Exception e) {
285                 throw new RuntimeException("Unable to create and connect to lock server", e);
286             }
287         }
288     }
289 
290     private static void writeBootstrapToken(Process process, String token) throws IOException {
291         try (OutputStream os = process.getOutputStream()) {
292             os.write((token + "\n").getBytes(StandardCharsets.UTF_8));
293         }
294     }
295 
296     private String getJarPath(Class<?> clazz) {
297         String classpath;
298         String className = clazz.getName().replace('.', '/') + ".class";
299         URL resource = clazz.getResource("/" + className);
300         if (resource == null) {
301             throw new IllegalStateException("Unable to find resource for class " + clazz.getName());
302         }
303         String url = resource.toString();
304         if (url.startsWith("jar:")) {
305             url = url.substring("jar:".length(), url.indexOf("!/"));
306             if (url.startsWith("file:")) {
307                 classpath = url.substring("file:".length());
308             } else {
309                 throw new IllegalStateException();
310             }
311         } else if (url.startsWith("file:")) {
312             classpath = url.substring("file:".length(), url.indexOf(className));
313         } else {
314             throw new IllegalStateException();
315         }
316         if (IS_WINDOWS) {
317             if (classpath.startsWith("/")) {
318                 classpath = classpath.substring(1);
319             }
320             classpath = classpath.replace('/', '\\');
321         }
322 
323         return classpath;
324     }
325 
326     void receive() {
327         try {
328             while (true) {
329                 DataInputStream in = getInput();
330                 if (in == null) {
331                     throw new IOException("Connection closed");
332                 }
333                 int id = in.readInt();
334                 int sz = in.readInt();
335                 List<String> s = new ArrayList<>(Math.max(0, Math.min(sz, 1024)));
336                 for (int i = 0; i < sz; i++) {
337                     s.add(in.readUTF());
338                 }
339                 CompletableFuture<List<String>> f = responses.remove(id);
340                 if (f == null) {
341                     continue;
342                 }
343                 if (s.isEmpty()) {
344                     f.completeExceptionally(new IOException("Protocol error: empty response"));
345                     continue;
346                 }
347                 f.complete(s);
348             }
349         } catch (EOFException e) {
350             close(new IOException("Server disconnected", e));
351         } catch (Exception e) {
352             close(e);
353         }
354     }
355 
356     List<String> send(List<String> request, long time, TimeUnit unit) throws TimeoutException, IOException {
357         ensureInitialized();
358         DataOutputStream out = getOutput();
359         if (out == null) {
360             throw new IOException("Connection closed");
361         }
362         int id = requestId.incrementAndGet();
363         CompletableFuture<List<String>> response = new CompletableFuture<>();
364         responses.put(id, response);
365         synchronized (out) {
366             out.writeInt(id);
367             out.writeInt(request.size());
368             for (String s : request) {
369                 out.writeUTF(s);
370             }
371             out.flush();
372         }
373         try {
374             return response.get(time, unit);
375         } catch (InterruptedException e) {
376             responses.remove(id);
377             throw (IOException) new InterruptedIOException("Interrupted").initCause(e);
378         } catch (ExecutionException e) {
379             throw new IOException("Execution error", e);
380         } catch (TimeoutException e) {
381             responses.remove(id);
382             throw e;
383         }
384     }
385 
386     void close() {
387         try {
388             if (noFork) {
389                 stopServer();
390             }
391         } finally {
392             close(new IOException("Closing"));
393         }
394     }
395 
396     synchronized void close(Throwable e) {
397         initialized = false;
398         if (socket != null) {
399             try {
400                 socket.close();
401             } catch (IOException t) {
402                 e.addSuppressed(t);
403             }
404             socket = null;
405             input = null;
406             output = null;
407         }
408         if (receiver != null && Thread.currentThread() != receiver) {
409             receiver.interrupt();
410             try {
411                 receiver.join(1000);
412             } catch (InterruptedException t) {
413                 e.addSuppressed(t);
414             }
415         }
416         responses.values().forEach(f -> f.completeExceptionally(e));
417         responses.clear();
418     }
419 
420     String newContext(boolean shared, long time, TimeUnit unit) throws TimeoutException {
421         RuntimeException error = new RuntimeException("Unable to create new sync context");
422         for (int i = 0; i < 2; i++) {
423             try {
424                 List<String> response = send(Arrays.asList(REQUEST_CONTEXT, Boolean.toString(shared)), time, unit);
425                 if (response.size() != 2 || !RESPONSE_CONTEXT.equals(response.get(0))) {
426                     throw new IOException("Unexpected response: " + response);
427                 }
428                 return response.get(1);
429             } catch (TimeoutException e) {
430                 throw e;
431             } catch (Exception e) {
432                 close(e);
433                 error.addSuppressed(e);
434             }
435         }
436         throw error;
437     }
438 
439     void lock(String contextId, Collection<String> keys, long time, TimeUnit unit) throws TimeoutException {
440         try {
441             List<String> req = new ArrayList<>(keys.size() + 2);
442             req.add(REQUEST_ACQUIRE);
443             req.add(contextId);
444             req.addAll(keys);
445             List<String> response = send(req, time, unit);
446             if (response.size() != 1 || !RESPONSE_ACQUIRE.equals(response.get(0))) {
447                 throw new IOException("Unexpected response: " + response);
448             }
449         } catch (TimeoutException e) {
450             throw e;
451         } catch (Exception e) {
452             close(e);
453             throw new RuntimeException("Unable to perform lock (contextId = " + contextId + ")", e);
454         }
455     }
456 
457     void unlock(String contextId) {
458         try {
459             List<String> response = send(Arrays.asList(REQUEST_CLOSE, contextId), 10, TimeUnit.SECONDS);
460             if (response.size() != 1 || !RESPONSE_CLOSE.equals(response.get(0))) {
461                 throw new IOException("Unexpected response: " + response);
462             }
463         } catch (Exception e) {
464             close(e);
465             throw new RuntimeException("Unable to unlock (contextId = " + contextId + ")", e);
466         }
467     }
468 
469     /**
470      * To be used in tests to stop server immediately. Should not be used outside of tests.
471      */
472     void stopServer() {
473         String token = bootstrapToken;
474         try {
475             List<String> response = send(List.of(REQUEST_STOP, token == null ? "" : token), 30, TimeUnit.SECONDS);
476             if (response.size() != 1 || !RESPONSE_STOP.equals(response.get(0))) {
477                 throw new IOException("Unexpected response: " + response);
478             }
479 
480             Process forkedProcess = process;
481             if (forkedProcess != null) {
482                 if (!forkedProcess.waitFor(30, TimeUnit.SECONDS)) {
483                     forkedProcess.destroyForcibly();
484                     if (!forkedProcess.waitFor(5, TimeUnit.SECONDS)) {
485                         throw new IOException("Unable to stop forked server process");
486                     }
487                 }
488                 process = null;
489             }
490         } catch (Exception e) {
491             close(e);
492             throw new RuntimeException("Unable to stop server", e);
493         }
494     }
495 
496     @Override
497     public String toString() {
498         return "IpcClient{"
499                 + "lockPath=" + lockPath + ","
500                 + "syncServerPath=" + syncPath + ","
501                 + "address='" + getAddress() + "'}";
502     }
503 
504     private String getAddress() {
505         SocketChannel s = socket;
506         if (s == null) {
507             return "[closed]";
508         }
509         try {
510             return SocketFamily.toString(s.getLocalAddress());
511         } catch (IOException e) {
512             return "[not bound]";
513         }
514     }
515 }