1/**
2 * Whether this server is going down only to be replaced by an update. Shutdown
3 * keeps the managed tunnel across those restarts instead of releasing it.
4 */
5import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts";
6import * as Clock from "effect/Clock";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12
13import * as ServerConfig from "../config.ts";
14import {
15 SERVICE_STATE_FILE,
16 SERVICE_STOP_MARKER_FILE,
17 serviceStateHasPendingUpdate,
18} from "./serviceProtocol.ts";
19
20// The desktop app stops its backends within seconds of writing the marker.
21const DESKTOP_UPDATE_RESTART_MARKER_TTL = Duration.minutes(1);
22
23// The launcher owns this durable state, so read it directly both when a trial
24// decides whether it owns pre-activation cleanup and while a server tears down.
25export const pendingServiceUpdateExists = Effect.gen(function* () {
26 const config = yield* ServerConfig.ServerConfig;
27 const fs = yield* FileSystem.FileSystem;
28 const path = yield* Path.Path;
29 const runtimeDir = path.join(config.baseDir, "runtime");
30 const stateText = yield* fs
31 .readFileString(path.join(runtimeDir, SERVICE_STATE_FILE))
32 .pipe(Effect.option);
33 return Option.isSome(stateText) && serviceStateHasPendingUpdate(stateText.value);
34});
35
36// A pending update alone is not proof a replacement server is coming: an
37// explicit launcher stop (`t3 service uninstall`, `systemctl stop`,
38// `launchctl bootout`) during
39// the pending window also tears this server down. The launcher marks that case
40// just before it signals the child, so pending + no marker is the handoff.
41export const pendingUpdateHandoffExists = Effect.gen(function* () {
42 if (!(yield* pendingServiceUpdateExists)) {
43 return false;
44 }
45 const config = yield* ServerConfig.ServerConfig;
46 const fs = yield* FileSystem.FileSystem;
47 const path = yield* Path.Path;
48 const runtimeDir = path.join(config.baseDir, "runtime");
49 const stopping = yield* fs
50 .exists(path.join(runtimeDir, SERVICE_STOP_MARKER_FILE))
51 .pipe(Effect.orElseSucceed(() => false));
52 return !stopping;
53});
54
55// The desktop app writes its marker right before it stops this server to
56// install an update, whether a remote client or the local app started it.
57// Reading consumes it, so shutdown checks it first. Only a fresh marker counts,
58// so a marker the server never read (a hard kill) cannot keep the tunnel on a
59// later quit.
60export const desktopUpdateRestartPending = Effect.gen(function* () {
61 const config = yield* ServerConfig.ServerConfig;
62 const fs = yield* FileSystem.FileSystem;
63 const path = yield* Path.Path;
64 const markerPath = path.join(config.baseDir, "runtime", DESKTOP_UPDATE_RESTART_MARKER_FILE);
65 const marker = yield* fs.stat(markerPath).pipe(Effect.option);
66 if (Option.isNone(marker)) {
67 return false;
68 }
69 yield* fs.remove(markerPath).pipe(Effect.ignore);
70 const now = yield* Clock.currentTimeMillis;
71 return Option.match(marker.value.mtime, {
72 onNone: () => false,
73 onSome: (writtenAt) =>
74 now - writtenAt.getTime() < Duration.toMillis(DESKTOP_UPDATE_RESTART_MARKER_TTL),
75 });
76});