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