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.providers;
020
021import javax.inject.Named;
022import javax.inject.Singleton;
023
024import java.io.IOException;
025import java.io.UncheckedIOException;
026import java.net.URI;
027import java.nio.channels.FileChannel;
028import java.nio.file.Files;
029import java.nio.file.Path;
030import java.nio.file.Paths;
031import java.nio.file.StandardOpenOption;
032import java.util.Iterator;
033import java.util.LinkedHashMap;
034import java.util.Map;
035import java.util.concurrent.ConcurrentHashMap;
036import java.util.concurrent.ConcurrentMap;
037
038import org.eclipse.aether.named.NamedLock;
039import org.eclipse.aether.named.NamedLockKey;
040import org.eclipse.aether.named.support.FileLockNamedLock;
041import org.eclipse.aether.named.support.NamedLockFactorySupport;
042import org.eclipse.aether.named.support.NamedLockSupport;
043
044import static org.eclipse.aether.named.support.Retry.retry;
045
046/**
047 * Named locks factory of {@link FileLockNamedLock}s. This is a bit of special implementation, as it
048 * expects locks names to be proper URI string representations (use {@code file:} protocol for default
049 * file system).
050 *
051 * @since 1.7.3
052 */
053@Singleton
054@Named(FileLockNamedLockFactory.NAME)
055public class FileLockNamedLockFactory extends NamedLockFactorySupport {
056    public static final String NAME = "file-lock";
057
058    // Logic borrowed from Commons-Lang3: we really need only this, to decide do we "delete on close" or not
059    private static final boolean IS_WINDOWS =
060            System.getProperty("os.name", "unknown").startsWith("Windows");
061
062    /**
063     * Tweak: on Windows, the presence of <em>StandardOpenOption#DELETE_ON_CLOSE</em> causes concurrency issues. This
064     * flag allows to have it removed from effective flags, at the cost that lockfile directory becomes crowded
065     * with 0 byte sized lock files that are never cleaned up. Default value is {@code true} on non-Windows OS.
066     * See <a href="https://bugs.openjdk.org/browse/JDK-8252883">JDK-8252883</a> for Windows related bug. Users
067     * on Windows can still force "delete on close" by explicitly setting this property to {@code true}.
068     *
069     * @see <a href="https://bugs.openjdk.org/browse/JDK-8252883">JDK-8252883</a>
070     * @configurationSource {@link System#getProperty(String, String)}
071     * @configurationType {@link java.lang.Boolean}
072     * @configurationDefaultValue true
073     */
074    public static final String SYSTEM_PROP_DELETE_LOCK_FILES = "aether.named.file-lock.deleteLockFiles";
075
076    private static final boolean DELETE_LOCK_FILES =
077            Boolean.parseBoolean(System.getProperty(SYSTEM_PROP_DELETE_LOCK_FILES, Boolean.toString(!IS_WINDOWS)));
078
079    /**
080     * Tweak: on Windows, the presence of <em>StandardOpenOption#DELETE_ON_CLOSE</em> causes concurrency issues. This
081     * flag allows to implement similar fix as referenced JDK bug report: retry and hope the best. Default value is
082     * 5 attempts (will retry 4 times).
083     *
084     * @see <a href="https://bugs.openjdk.org/browse/JDK-8252883">JDK-8252883</a>
085     * @configurationSource {@link System#getProperty(String, String)}
086     * @configurationType {@link java.lang.Integer}
087     * @configurationDefaultValue 5
088     */
089    public static final String SYSTEM_PROP_ATTEMPTS = "aether.named.file-lock.attempts";
090
091    private static final int ATTEMPTS = Integer.parseInt(System.getProperty(SYSTEM_PROP_ATTEMPTS, "5"));
092
093    /**
094     * Tweak: When {@link #SYSTEM_PROP_ATTEMPTS} used, the amount of milliseconds to sleep between subsequent retries. Default
095     * value is 50 milliseconds.
096     *
097     * @configurationSource {@link System#getProperty(String, String)}
098     * @configurationType {@link java.lang.Long}
099     * @configurationDefaultValue 50
100     */
101    public static final String SYSTEM_PROP_SLEEP_MILLIS = "aether.named.file-lock.sleepMillis";
102
103    private static final long SLEEP_MILLIS = Long.parseLong(System.getProperty(SYSTEM_PROP_SLEEP_MILLIS, "50"));
104
105    /**
106     * Maximum number of idle (not currently locked) FileChannels to keep open for reuse. Keeping channels open
107     * avoids repeated {@code open}/{@code creat} syscalls, but each open channel consumes a file descriptor.
108     * On systems with low FD limits (e.g., macOS defaults to 256), large reactor builds with thousands of
109     * unique artifacts can exhaust the limit. This cap bounds idle channel retention; channels for actively
110     * held locks are never evicted.
111     *
112     * @configurationSource {@link System#getProperty(String, String)}
113     * @configurationType {@link java.lang.Integer}
114     * @configurationDefaultValue 200
115     */
116    public static final String SYSTEM_PROP_MAX_CACHED_CHANNELS = "aether.named.file-lock.maxCachedChannels";
117
118    private static final int MAX_CACHED_CHANNELS =
119            Integer.parseInt(System.getProperty(SYSTEM_PROP_MAX_CACHED_CHANNELS, "200"));
120
121    private final ConcurrentMap<NamedLockKey, FileChannel> fileChannels;
122
123    /**
124     * LRU pool of idle (unlocked) channels available for reuse. Access-ordered: the least recently used
125     * entry is evicted first when the pool exceeds {@link #MAX_CACHED_CHANNELS}. Guarded by its own
126     * monitor; never held while performing I/O.
127     */
128    private final LinkedHashMap<NamedLockKey, FileChannel> idleChannels;
129
130    public FileLockNamedLockFactory() {
131        this.fileChannels = new ConcurrentHashMap<>();
132        this.idleChannels = new LinkedHashMap<>(64, 0.75f, true); // access-order
133    }
134
135    @Override
136    protected NamedLockSupport createLock(final NamedLockKey key) {
137        Path path = Paths.get(URI.create(key.name()));
138        // Try to reclaim an idle channel first (avoids open syscall)
139        FileChannel fileChannel;
140        synchronized (idleChannels) {
141            fileChannel = idleChannels.remove(key);
142        }
143        if (fileChannel != null && fileChannel.isOpen()) {
144            fileChannels.put(key, fileChannel);
145        } else {
146            fileChannel = fileChannels.computeIfAbsent(key, k -> openFileChannel(key, path));
147            if (!fileChannel.isOpen()) {
148                // Channel was closed externally (I/O error, NFS hiccup, etc.). Evict the stale entry
149                // and open a fresh one. remove(key, fileChannel) is atomic: it only removes if the
150                // value is still this exact (stale) instance, avoiding races with other threads that
151                // may have already replaced it.
152                fileChannels.remove(key, fileChannel);
153                fileChannel = fileChannels.computeIfAbsent(key, k -> openFileChannel(key, path));
154            }
155        }
156        return new FileLockNamedLock(key, fileChannel, this);
157    }
158
159    private FileChannel openFileChannel(NamedLockKey key, Path path) {
160        try {
161            Files.createDirectories(path.getParent());
162            FileChannel channel = retry(
163                    ATTEMPTS,
164                    SLEEP_MILLIS,
165                    () -> {
166                        if (DELETE_LOCK_FILES) {
167                            return FileChannel.open(
168                                    path,
169                                    StandardOpenOption.READ,
170                                    StandardOpenOption.WRITE,
171                                    StandardOpenOption.CREATE,
172                                    StandardOpenOption.DELETE_ON_CLOSE);
173                        } else {
174                            return FileChannel.open(
175                                    path, StandardOpenOption.READ, StandardOpenOption.WRITE, StandardOpenOption.CREATE);
176                        }
177                    },
178                    null,
179                    null);
180
181            if (channel == null) {
182                throw new IllegalStateException(
183                        "Could not open file channel for '" + key + "' after " + ATTEMPTS + " attempts; giving up");
184            }
185            return channel;
186        } catch (InterruptedException e) {
187            Thread.currentThread().interrupt();
188            throw new RuntimeException("Interrupted while opening file channel for '" + key + "'", e);
189        } catch (IOException e) {
190            throw new UncheckedIOException("Failed to open file channel for '" + key + "'", e);
191        }
192    }
193
194    @Override
195    protected void destroyLock(final NamedLock namedLock) {
196        NamedLockKey key = namedLock.key();
197        FileChannel channel = fileChannels.remove(key);
198        if (channel == null) {
199            return;
200        }
201        // Move the channel to the idle pool for reuse by future createLock() calls.
202        // Evict the least recently used idle channel if the pool is full.
203        FileChannel evicted = null;
204        synchronized (idleChannels) {
205            idleChannels.put(key, channel);
206            if (idleChannels.size() > MAX_CACHED_CHANNELS) {
207                Iterator<Map.Entry<NamedLockKey, FileChannel>> it =
208                        idleChannels.entrySet().iterator();
209                evicted = it.next().getValue();
210                it.remove();
211            }
212        }
213        if (evicted != null) {
214            try {
215                evicted.close();
216            } catch (IOException e) {
217                logger.warn("Failed to close evicted file channel", e);
218            }
219        }
220    }
221
222    @Override
223    protected void doShutdown() {
224        for (FileChannel channel : fileChannels.values()) {
225            try {
226                channel.close();
227            } catch (IOException e) {
228                logger.warn("Failed to close file channel", e);
229            }
230        }
231        fileChannels.clear();
232        synchronized (idleChannels) {
233            for (FileChannel channel : idleChannels.values()) {
234                try {
235                    channel.close();
236                } catch (IOException e) {
237                    logger.warn("Failed to close idle file channel", e);
238                }
239            }
240            idleChannels.clear();
241        }
242    }
243}