Skip to content

Commit cc67662

Browse files
authored
feat(startup): Add launcher readiness protocol (#1232)
Add a versioned readiness file under the resolved runtime directory and publish it after the legacy HTTP shell finishes normal startup. Update the desktop launcher to prefer the structured readiness signal, keep narrow legacy fallback handling, and cover the new startup coordination paths in focused tests and README wording.
1 parent e12f9f6 commit cc67662

24 files changed

Lines changed: 2120 additions & 33 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,10 @@ Build the portable distribution and run the Swing launcher without installing sy
127127
build/cryptad-dist/bin/cryptad-launcher # Windows: cryptad-launcher.bat
128128
```
129129

130-
The launcher starts the daemon, streams live logs, detects the FProxy port from lines like
131-
`Starting FProxy on ...:<port>`, and opens `http://localhost:<port>/` on the first successful start.
130+
The launcher starts the daemon, streams live logs, waits for a structured readiness file under the
131+
resolved runtime directory (currently `<runDir>/platform-ui.properties`), and opens
132+
`http://localhost:<port>/` on the first successful start. If the readiness file cannot be used,
133+
the launcher can still fall back to the legacy `Starting FProxy on ...:<port>` log line.
132134

133135
Shortcuts (global):
134136
- ↑/↓ one row; PgUp/PgDn one page.

adapter-http-legacy-admin/src/main/java/network/crypta/clients/http/SimpleToadletServer.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,18 @@ public void createFproxy() {
483483
this);
484484
}
485485

486+
/**
487+
* Returns the configured HTTP listen port used by the current shell instance.
488+
*
489+
* <p>The launcher readiness protocol consumes this value only after the shell finishes its normal
490+
* startup sequence.
491+
*
492+
* @return configured HTTP port
493+
*/
494+
public int listenPort() {
495+
return port;
496+
}
497+
486498
private static synchronized void initializeFProxyRandom(RandomnessPort randomnessPort) {
487499
FProxyToadlet.random = new byte[32];
488500
randomnessPort.fillSecureRandom(FProxyToadlet.random);
@@ -1268,7 +1280,8 @@ public void start() {
12681280
maybeGetNetworkInterface();
12691281
thread.start();
12701282
LOG.info("Starting FProxy on {}:{}", bindTo, port);
1271-
// Keep a plain stdout line for the launcher parser when INFO logs are filtered by default.
1283+
// Keep a plain stdout line as a compatibility fallback when structured launcher readiness is
1284+
// unavailable.
12721285
System.out.println("Starting FProxy on " + bindTo + ":" + port);
12731286
}
12741287
}
@@ -1719,6 +1732,7 @@ public BookmarkManager getBookmarks() {
17191732
*
17201733
* @return array of {@link FreenetURI} entries; empty when no bookmarks exist.
17211734
*/
1735+
@SuppressWarnings("unused")
17221736
public FreenetURI[] getBookmarkURIs() {
17231737
if (bookmarkManager == null) return new FreenetURI[0];
17241738
return bookmarkManager.getBookmarkURIs();

adapter-http-legacy-admin/src/main/java/network/crypta/clients/http/bridge/SimpleToadletServerHttpShellContainer.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ public void createFproxy() {
7777
delegate.createFproxy();
7878
}
7979

80+
@Override
81+
public int listenPort() {
82+
return delegate.listenPort();
83+
}
84+
8085
@Override
8186
public void removeStartupToadlet() {
8287
delegate.removeStartupToadlet();
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
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

Comments
 (0)