|
| 1 | +package network.crypta.fs.readiness; |
| 2 | + |
| 3 | +import java.io.IOException; |
| 4 | +import java.io.StringReader; |
| 5 | +import java.nio.charset.StandardCharsets; |
| 6 | +import java.nio.file.AtomicMoveNotSupportedException; |
| 7 | +import java.nio.file.Files; |
| 8 | +import java.nio.file.NoSuchFileException; |
| 9 | +import java.nio.file.Path; |
| 10 | +import java.nio.file.StandardCopyOption; |
| 11 | +import java.nio.file.StandardOpenOption; |
| 12 | +import java.util.List; |
| 13 | +import java.util.Objects; |
| 14 | +import java.util.Optional; |
| 15 | +import java.util.Properties; |
| 16 | +import network.crypta.fs.AppDirs; |
| 17 | +import network.crypta.fs.AppEnv; |
| 18 | +import network.crypta.fs.ServiceDirs; |
| 19 | + |
| 20 | +/** |
| 21 | + * Reads, writes, and clears the launcher readiness file in the runtime directory. |
| 22 | + * |
| 23 | + * <p>The format is intentionally tiny: a UTF-8 properties-style file with one versioned ready |
| 24 | + * payload. Writers replace the file via a temporary sibling so the launcher does not observe a |
| 25 | + * partially written readiness signal, and readers treat missing or invalid content as "not ready" |
| 26 | + * instead of failing startup coordination. |
| 27 | + */ |
| 28 | +public final class LauncherReadinessFiles { |
| 29 | + /** Canonical readiness filename placed directly under the resolved runtime directory. */ |
| 30 | + public static final String FILE_NAME = "platform-ui.properties"; |
| 31 | + |
| 32 | + /** Suffix used for the sibling temporary file during replace-in-place writes. */ |
| 33 | + private static final String TEMP_SUFFIX = ".tmp"; |
| 34 | + |
| 35 | + /** Property key that carries the readiness-file schema version. */ |
| 36 | + private static final String VERSION_KEY = "version"; |
| 37 | + |
| 38 | + /** Properties key that marks the readiness state published by the daemon. */ |
| 39 | + private static final String STATE_KEY = "state"; |
| 40 | + |
| 41 | + /** Property key that carries the launcher-facing UI listen port. */ |
| 42 | + private static final String UI_PORT_KEY = "ui.port"; |
| 43 | + |
| 44 | + /** Property key that carries the launcher-facing UI root path. */ |
| 45 | + private static final String UI_ROOT_KEY = "ui.root"; |
| 46 | + |
| 47 | + /** Utility holder; use the static helpers instead of instantiating this type. */ |
| 48 | + private LauncherReadinessFiles() {} |
| 49 | + |
| 50 | + /** |
| 51 | + * Stable snapshot of a parsed readiness file and the concrete file generation it came from. |
| 52 | + * |
| 53 | + * <p>The launcher uses this to ensure it validates readiness contents against the same file |
| 54 | + * generation it actually read, even when the daemon replaces the file atomically during startup. |
| 55 | + * |
| 56 | + * @param info parsed readiness payload |
| 57 | + * @param lastModifiedTime the file's last-modified timestamp in milliseconds for the read |
| 58 | + * generation |
| 59 | + * @param fileKey filesystem file key for the read generation when available, otherwise {@code |
| 60 | + * null} |
| 61 | + */ |
| 62 | + public record ReadinessSnapshot( |
| 63 | + LauncherReadinessInfo info, long lastModifiedTime, Object fileKey) { |
| 64 | + /** |
| 65 | + * Creates a snapshot for one concrete readiness-file generation. |
| 66 | + * |
| 67 | + * @param info parsed readiness payload from the observed generation |
| 68 | + * @param lastModifiedTime last-modified timestamp in milliseconds for that generation |
| 69 | + * @param fileKey filesystem file key for that generation, or {@code null} when unavailable |
| 70 | + */ |
| 71 | + public ReadinessSnapshot { |
| 72 | + Objects.requireNonNull(info); |
| 73 | + } |
| 74 | + } |
| 75 | + |
| 76 | + /** |
| 77 | + * Resolves the readiness-file path beneath the supplied runtime directory. |
| 78 | + * |
| 79 | + * @param runDir resolved runtime directory |
| 80 | + * @return readiness-file path under {@code runDir} |
| 81 | + */ |
| 82 | + public static Path resolve(Path runDir) { |
| 83 | + return Objects.requireNonNull(runDir).resolve(FILE_NAME); |
| 84 | + } |
| 85 | + |
| 86 | + /** |
| 87 | + * Resolves the readiness-file path for the current process environment. |
| 88 | + * |
| 89 | + * <p>This uses the same {@link AppEnv}/{@link AppDirs}/{@link ServiceDirs} directory logic as |
| 90 | + * runtime bootstrap, so the desktop launcher does not need to hard-code platform-specific run |
| 91 | + * directory rules. |
| 92 | + * |
| 93 | + * @return readiness-file path for the current process' resolved runtime directory |
| 94 | + */ |
| 95 | + @SuppressWarnings("unused") |
| 96 | + public static Path resolveCurrentProcessReadinessFile() { |
| 97 | + AppEnv env = new AppEnv(); |
| 98 | + Path runDir = |
| 99 | + env.isServiceMode() |
| 100 | + ? new ServiceDirs().resolve().runDir() |
| 101 | + : new AppDirs().resolve().runDir(); |
| 102 | + return resolve(runDir); |
| 103 | + } |
| 104 | + |
| 105 | + /** |
| 106 | + * Deletes a previously published readiness file if one exists. |
| 107 | + * |
| 108 | + * @param readinessFile concrete readiness-file path |
| 109 | + * @throws IOException if deletion fails for reasons other than the file being absent |
| 110 | + */ |
| 111 | + public static void clear(Path readinessFile) throws IOException { |
| 112 | + Objects.requireNonNull(readinessFile); |
| 113 | + Files.deleteIfExists(readinessFile); |
| 114 | + Files.deleteIfExists(tempPath(readinessFile)); |
| 115 | + } |
| 116 | + |
| 117 | + /** |
| 118 | + * Writes a readiness payload using best-effort atomic replacement. |
| 119 | + * |
| 120 | + * @param readinessFile concrete readiness-file path |
| 121 | + * @param info readiness payload to persist |
| 122 | + * @throws IOException if the temporary file cannot be written or moved into place |
| 123 | + */ |
| 124 | + public static void write(Path readinessFile, LauncherReadinessInfo info) throws IOException { |
| 125 | + Objects.requireNonNull(readinessFile); |
| 126 | + Objects.requireNonNull(info); |
| 127 | + |
| 128 | + Path tempFile = tempPath(readinessFile); |
| 129 | + List<String> lines = |
| 130 | + List.of( |
| 131 | + VERSION_KEY + "=" + info.version(), |
| 132 | + STATE_KEY + "=" + info.state(), |
| 133 | + UI_PORT_KEY + "=" + info.uiPort(), |
| 134 | + UI_ROOT_KEY + "=" + info.uiRoot()); |
| 135 | + Files.write( |
| 136 | + tempFile, |
| 137 | + lines, |
| 138 | + StandardCharsets.UTF_8, |
| 139 | + StandardOpenOption.CREATE, |
| 140 | + StandardOpenOption.TRUNCATE_EXISTING, |
| 141 | + StandardOpenOption.WRITE); |
| 142 | + moveIntoPlace(tempFile, readinessFile); |
| 143 | + } |
| 144 | + |
| 145 | + /** |
| 146 | + * Reads a readiness payload if a valid ready file exists. |
| 147 | + * |
| 148 | + * @param readinessFile concrete readiness-file path |
| 149 | + * @return parsed readiness payload, or empty when the file is missing or invalid |
| 150 | + * @throws IOException if the file exists but cannot be read |
| 151 | + */ |
| 152 | + public static Optional<LauncherReadinessInfo> read(Path readinessFile) throws IOException { |
| 153 | + return readSnapshot(readinessFile).map(ReadinessSnapshot::info); |
| 154 | + } |
| 155 | + |
| 156 | + /** |
| 157 | + * Reads a readiness payload and returns it with same-generation file metadata. |
| 158 | + * |
| 159 | + * <p>If the file is replaced while it is being read, this method retries a small number of times |
| 160 | + * and otherwise reports "not ready" so callers do not combine stale contents with fresh metadata. |
| 161 | + * |
| 162 | + * @param readinessFile concrete readiness-file path |
| 163 | + * @return parsed readiness snapshot, or empty when the file is missing, invalid, or changed |
| 164 | + * during the read attempt |
| 165 | + * @throws IOException if the file exists but cannot be read |
| 166 | + */ |
| 167 | + public static Optional<ReadinessSnapshot> readSnapshot(Path readinessFile) throws IOException { |
| 168 | + Objects.requireNonNull(readinessFile); |
| 169 | + for (int attempt = 0; attempt < 3; attempt++) { |
| 170 | + if (!Files.isRegularFile(readinessFile)) { |
| 171 | + return Optional.empty(); |
| 172 | + } |
| 173 | + |
| 174 | + try { |
| 175 | + var before = |
| 176 | + Files.readAttributes(readinessFile, java.nio.file.attribute.BasicFileAttributes.class); |
| 177 | + if (!before.isRegularFile()) { |
| 178 | + return Optional.empty(); |
| 179 | + } |
| 180 | + |
| 181 | + String content = Files.readString(readinessFile, StandardCharsets.UTF_8); |
| 182 | + var after = |
| 183 | + Files.readAttributes(readinessFile, java.nio.file.attribute.BasicFileAttributes.class); |
| 184 | + if (!isSameObservedGeneration(before, after)) { |
| 185 | + continue; |
| 186 | + } |
| 187 | + |
| 188 | + Optional<LauncherReadinessInfo> info = parse(content); |
| 189 | + return info.map( |
| 190 | + value -> |
| 191 | + new ReadinessSnapshot(value, after.lastModifiedTime().toMillis(), after.fileKey())); |
| 192 | + } catch (NoSuchFileException _) { |
| 193 | + return Optional.empty(); |
| 194 | + } |
| 195 | + } |
| 196 | + return Optional.empty(); |
| 197 | + } |
| 198 | + |
| 199 | + /** |
| 200 | + * Replaces the destination readiness file with the prepared temporary sibling. |
| 201 | + * |
| 202 | + * <p>The method prefers an atomic move, so the launcher never observes a partially written file, |
| 203 | + * but it falls back to a normal replacement when the target filesystem does not support atomic |
| 204 | + * renames. |
| 205 | + * |
| 206 | + * @param tempFile populated temporary sibling file |
| 207 | + * @param readinessFile final readiness-file destination |
| 208 | + * @throws IOException if neither move strategy succeeds |
| 209 | + */ |
| 210 | + private static void moveIntoPlace(Path tempFile, Path readinessFile) throws IOException { |
| 211 | + try { |
| 212 | + Files.move( |
| 213 | + tempFile, |
| 214 | + readinessFile, |
| 215 | + StandardCopyOption.ATOMIC_MOVE, |
| 216 | + StandardCopyOption.REPLACE_EXISTING); |
| 217 | + } catch (AtomicMoveNotSupportedException _) { |
| 218 | + Files.move(tempFile, readinessFile, StandardCopyOption.REPLACE_EXISTING); |
| 219 | + } |
| 220 | + } |
| 221 | + |
| 222 | + /** |
| 223 | + * Resolves the temporary sibling path used while writing a new readiness generation. |
| 224 | + * |
| 225 | + * @param readinessFile final readiness-file destination |
| 226 | + * @return sibling temporary path next to {@code readinessFile} |
| 227 | + */ |
| 228 | + private static Path tempPath(Path readinessFile) { |
| 229 | + return readinessFile.resolveSibling(readinessFile.getFileName() + TEMP_SUFFIX); |
| 230 | + } |
| 231 | + |
| 232 | + /** |
| 233 | + * Checks whether two attribute reads still refer to the same on-disk file generation. |
| 234 | + * |
| 235 | + * <p>When the filesystem exposes stable file keys, they are the primary identity signal. The |
| 236 | + * timestamp, size, and creation-time fallback keeps the check useful on filesystems that do not |
| 237 | + * expose file keys. |
| 238 | + * |
| 239 | + * @param before attributes captured before reading file contents |
| 240 | + * @param after attributes captured after reading file contents |
| 241 | + * @return {@code true} when both attribute sets describe the same observed generation |
| 242 | + */ |
| 243 | + private static boolean isSameObservedGeneration( |
| 244 | + java.nio.file.attribute.BasicFileAttributes before, |
| 245 | + java.nio.file.attribute.BasicFileAttributes after) { |
| 246 | + Object beforeFileKey = before.fileKey(); |
| 247 | + Object afterFileKey = after.fileKey(); |
| 248 | + if (beforeFileKey != null && afterFileKey != null) { |
| 249 | + return beforeFileKey.equals(afterFileKey); |
| 250 | + } |
| 251 | + return before.lastModifiedTime().equals(after.lastModifiedTime()) |
| 252 | + && before.size() == after.size() |
| 253 | + && before.creationTime().equals(after.creationTime()); |
| 254 | + } |
| 255 | + |
| 256 | + /** |
| 257 | + * Parses one readiness payload from the UTF-8 properties text content. |
| 258 | + * |
| 259 | + * <p>Unsupported versions, malformed numbers, unknown states, and invalid root paths are all |
| 260 | + * treated as "not ready" so callers can fall back without surfacing parser-specific failures. |
| 261 | + * |
| 262 | + * @param content UTF-8 properties-style readiness content |
| 263 | + * @return parsed readiness payload, or empty when the content is invalid for the current schema |
| 264 | + * @throws IOException if the {@link Properties} reader reports an I/O failure |
| 265 | + */ |
| 266 | + private static Optional<LauncherReadinessInfo> parse(String content) throws IOException { |
| 267 | + Properties properties = new Properties(); |
| 268 | + try (var reader = new StringReader(content)) { |
| 269 | + properties.load(reader); |
| 270 | + } |
| 271 | + |
| 272 | + Integer version = parsePositiveInt(properties.getProperty(VERSION_KEY)); |
| 273 | + Integer uiPort = parsePositiveInt(properties.getProperty(UI_PORT_KEY)); |
| 274 | + String state = trimToNull(properties.getProperty(STATE_KEY)); |
| 275 | + String uiRoot = trimToNull(properties.getProperty(UI_ROOT_KEY)); |
| 276 | + if (version == null |
| 277 | + || version != LauncherReadinessInfo.VERSION_1 |
| 278 | + || uiPort == null |
| 279 | + || !LauncherReadinessInfo.READY_STATE.equals(state)) { |
| 280 | + return Optional.empty(); |
| 281 | + } |
| 282 | + |
| 283 | + try { |
| 284 | + return Optional.of( |
| 285 | + new LauncherReadinessInfo( |
| 286 | + version, |
| 287 | + state, |
| 288 | + uiPort, |
| 289 | + uiRoot != null ? uiRoot : LauncherReadinessInfo.DEFAULT_UI_ROOT)); |
| 290 | + } catch (IllegalArgumentException _) { |
| 291 | + return Optional.empty(); |
| 292 | + } |
| 293 | + } |
| 294 | + |
| 295 | + /** |
| 296 | + * Parses a strictly positive integer from a readiness property. |
| 297 | + * |
| 298 | + * @param value raw property value |
| 299 | + * @return positive integer value, or {@code null} when the property is missing or invalid |
| 300 | + */ |
| 301 | + private static Integer parsePositiveInt(String value) { |
| 302 | + String normalized = trimToNull(value); |
| 303 | + if (normalized == null) { |
| 304 | + return null; |
| 305 | + } |
| 306 | + try { |
| 307 | + int parsed = Integer.parseInt(normalized); |
| 308 | + return parsed > 0 ? parsed : null; |
| 309 | + } catch (NumberFormatException _) { |
| 310 | + return null; |
| 311 | + } |
| 312 | + } |
| 313 | + |
| 314 | + /** |
| 315 | + * Trims a readiness property and normalizes blank values to {@code null}. |
| 316 | + * |
| 317 | + * @param value raw property value |
| 318 | + * @return trimmed value, or {@code null} when the input is missing or blank |
| 319 | + */ |
| 320 | + private static String trimToNull(String value) { |
| 321 | + if (value == null) { |
| 322 | + return null; |
| 323 | + } |
| 324 | + String trimmed = value.trim(); |
| 325 | + return trimmed.isEmpty() ? null : trimmed; |
| 326 | + } |
| 327 | +} |
0 commit comments