Skip to content

Commit f964945

Browse files
committed
refactor daemon lifecycle to effect filesystem services
1 parent 6cde6cc commit f964945

4 files changed

Lines changed: 227 additions & 148 deletions

File tree

apps/cli/src/daemon-state.ts

Lines changed: 57 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
21
import { homedir } from "node:os";
3-
import { join } from "node:path";
2+
import { FileSystem, Path } from "@effect/platform";
3+
import type { PlatformError } from "@effect/platform/Error";
4+
import * as Effect from "effect/Effect";
45

56
// ---------------------------------------------------------------------------
67
// Types
@@ -30,40 +31,47 @@ export const canonicalDaemonHost = (hostname: string): string => {
3031
// Paths
3132
// ---------------------------------------------------------------------------
3233

33-
const resolveDaemonDataDir = (): string => process.env.EXECUTOR_DATA_DIR ?? join(homedir(), ".executor");
34+
const resolveDaemonDataDir = (path: Path.Path): string =>
35+
process.env.EXECUTOR_DATA_DIR ?? path.join(homedir(), ".executor");
3436

3537
const sanitizeHostForPath = (hostname: string): string => hostname.replaceAll(/[^a-z0-9.-]+/gi, "_");
3638

37-
const daemonRecordPath = (input: { hostname: string; port: number }): string => {
39+
const daemonRecordPath = (path: Path.Path, input: { hostname: string; port: number }): string => {
3840
const host = sanitizeHostForPath(canonicalDaemonHost(input.hostname));
39-
return join(resolveDaemonDataDir(), `daemon-${host}-${input.port}.json`);
41+
return path.join(resolveDaemonDataDir(path), `daemon-${host}-${input.port}.json`);
4042
};
4143

4244
// ---------------------------------------------------------------------------
4345
// Persistence
4446
// ---------------------------------------------------------------------------
4547

46-
export const writeDaemonRecord = async (input: {
48+
export const writeDaemonRecord = (input: {
4749
hostname: string;
4850
port: number;
4951
pid: number;
5052
scopeDir: string | null;
51-
}): Promise<void> => {
52-
const path = daemonRecordPath({ hostname: input.hostname, port: input.port });
53-
const dir = resolveDaemonDataDir();
54-
await mkdir(dir, { recursive: true });
55-
56-
const payload: DaemonRecord = {
57-
version: 1,
58-
hostname: canonicalDaemonHost(input.hostname),
59-
port: input.port,
60-
pid: input.pid,
61-
startedAt: new Date().toISOString(),
62-
scopeDir: input.scopeDir,
63-
};
64-
65-
await writeFile(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
66-
};
53+
}): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
54+
Effect.gen(function* () {
55+
const fs = yield* FileSystem.FileSystem;
56+
const path = yield* Path.Path;
57+
const dataDir = resolveDaemonDataDir(path);
58+
59+
yield* fs.makeDirectory(dataDir, { recursive: true });
60+
61+
const payload: DaemonRecord = {
62+
version: 1,
63+
hostname: canonicalDaemonHost(input.hostname),
64+
port: input.port,
65+
pid: input.pid,
66+
startedAt: new Date().toISOString(),
67+
scopeDir: input.scopeDir,
68+
};
69+
70+
yield* fs.writeFileString(
71+
daemonRecordPath(path, { hostname: input.hostname, port: input.port }),
72+
`${JSON.stringify(payload, null, 2)}\n`,
73+
);
74+
});
6775

6876
const parseRecord = (raw: string): DaemonRecord | null => {
6977
let parsed: unknown;
@@ -103,23 +111,29 @@ const parseRecord = (raw: string): DaemonRecord | null => {
103111
};
104112
};
105113

106-
export const readDaemonRecord = async (input: {
114+
export const readDaemonRecord = (input: {
107115
hostname: string;
108116
port: number;
109-
}): Promise<DaemonRecord | null> => {
110-
const path = daemonRecordPath({ hostname: input.hostname, port: input.port });
111-
try {
112-
const raw = await readFile(path, "utf8");
117+
}): Effect.Effect<DaemonRecord | null, never, FileSystem.FileSystem | Path.Path> =>
118+
Effect.gen(function* () {
119+
const fs = yield* FileSystem.FileSystem;
120+
const path = yield* Path.Path;
121+
const raw = yield* fs.readFileString(daemonRecordPath(path, input)).pipe(
122+
Effect.catchAll(() => Effect.succeed(null)),
123+
);
124+
if (raw === null) return null;
113125
return parseRecord(raw);
114-
} catch {
115-
return null;
116-
}
117-
};
126+
});
118127

119-
export const removeDaemonRecord = async (input: { hostname: string; port: number }): Promise<void> => {
120-
const path = daemonRecordPath({ hostname: input.hostname, port: input.port });
121-
await rm(path, { force: true });
122-
};
128+
export const removeDaemonRecord = (input: {
129+
hostname: string;
130+
port: number;
131+
}): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
132+
Effect.gen(function* () {
133+
const fs = yield* FileSystem.FileSystem;
134+
const path = yield* Path.Path;
135+
yield* fs.remove(daemonRecordPath(path, input), { force: true });
136+
});
123137

124138
// ---------------------------------------------------------------------------
125139
// Process helpers
@@ -135,6 +149,11 @@ export const isPidAlive = (pid: number): boolean => {
135149
}
136150
};
137151

138-
export const terminatePid = (pid: number): void => {
139-
process.kill(pid, "SIGTERM");
140-
};
152+
export const terminatePid = (pid: number): Effect.Effect<void, Error> =>
153+
Effect.try({
154+
try: () => {
155+
process.kill(pid, "SIGTERM");
156+
},
157+
catch: (cause) =>
158+
cause instanceof Error ? cause : new Error(`Failed to terminate pid ${pid}: ${String(cause)}`),
159+
});

apps/cli/src/daemon.ts

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { spawn } from "node:child_process";
2+
import * as Clock from "effect/Clock";
3+
import * as Effect from "effect/Effect";
24

35
// ---------------------------------------------------------------------------
46
// Types
@@ -82,37 +84,61 @@ export const spawnDetached = (input: {
8284
readonly command: string;
8385
readonly args: ReadonlyArray<string>;
8486
readonly env: Record<string, string | undefined>;
85-
}): void => {
86-
const child = spawn(input.command, [...input.args], {
87-
detached: true,
88-
stdio: "ignore",
89-
env: input.env,
87+
}): Effect.Effect<void, Error> =>
88+
Effect.try({
89+
try: () => {
90+
const child = spawn(input.command, [...input.args], {
91+
detached: true,
92+
stdio: "ignore",
93+
env: input.env,
94+
});
95+
child.unref();
96+
},
97+
catch: (cause) =>
98+
cause instanceof Error
99+
? cause
100+
: new Error(`Failed to spawn daemon process: ${String(cause)}`),
90101
});
91-
child.unref();
92-
};
93102

94-
export const waitForReachable = async (input: {
95-
readonly check: () => Promise<boolean>;
103+
const waitForCondition = <E, R>(input: {
104+
readonly check: Effect.Effect<boolean, E, R>;
105+
readonly expected: boolean;
96106
readonly timeoutMs: number;
97107
readonly intervalMs: number;
98-
}): Promise<boolean> => {
99-
const deadline = Date.now() + input.timeoutMs;
100-
while (Date.now() < deadline) {
101-
if (await input.check()) return true;
102-
await new Promise((resolve) => setTimeout(resolve, input.intervalMs));
103-
}
104-
return false;
105-
};
108+
}): Effect.Effect<boolean, E, R> =>
109+
Effect.gen(function* () {
110+
const startedAt = yield* Clock.currentTimeMillis;
111+
while (true) {
112+
const reachable = yield* input.check;
113+
if (reachable === input.expected) return true;
114+
115+
const now = yield* Clock.currentTimeMillis;
116+
if (now - startedAt >= input.timeoutMs) return false;
117+
118+
yield* Effect.sleep(input.intervalMs);
119+
}
120+
});
106121

107-
export const waitForUnreachable = async (input: {
108-
readonly check: () => Promise<boolean>;
122+
export const waitForReachable = <E, R>(input: {
123+
readonly check: Effect.Effect<boolean, E, R>;
109124
readonly timeoutMs: number;
110125
readonly intervalMs: number;
111-
}): Promise<boolean> => {
112-
const deadline = Date.now() + input.timeoutMs;
113-
while (Date.now() < deadline) {
114-
if (!(await input.check())) return true;
115-
await new Promise((resolve) => setTimeout(resolve, input.intervalMs));
116-
}
117-
return false;
118-
};
126+
}): Effect.Effect<boolean, E, R> =>
127+
waitForCondition({
128+
check: input.check,
129+
expected: true,
130+
timeoutMs: input.timeoutMs,
131+
intervalMs: input.intervalMs,
132+
});
133+
134+
export const waitForUnreachable = <E, R>(input: {
135+
readonly check: Effect.Effect<boolean, E, R>;
136+
readonly timeoutMs: number;
137+
readonly intervalMs: number;
138+
}): Effect.Effect<boolean, E, R> =>
139+
waitForCondition({
140+
check: input.check,
141+
expected: false,
142+
timeoutMs: input.timeoutMs,
143+
intervalMs: input.intervalMs,
144+
});

0 commit comments

Comments
 (0)