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    protected volatile Process process;
094
095    protected final AtomicInteger requestId = new AtomicInteger();
096    protected final Map<Integer, CompletableFuture<List<String>>> responses = new ConcurrentHashMap<>();
097
098    /**
099     * 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}