Skip to content

Commit edc4411

Browse files
RhysSullivanGriffin Evansgrfwings
authored
DB File Lock (rebase of #1050 by @grfwings) (#1175)
* Kill daemon child if parent times out * Safer migration * Make flip idempotent, remove reduntant db copies, fix auth.json mutating eraly, derive expected counts from plan arrays * Fix various correctness issues * Add sqlite lock db ownership primitive and test * Use pathToFile not string concatenation for dir * Add owned-database.ts entry point * Implement new database ownership process in cli and desktop sidecar * Delete startup lock and v1->v2 migration lock * Remove double-probe from v1-v2 migration * Add integration tests and update errors, comments * Don't change scope -> tenant mappings * Add polling to desktop sidecar and fix infinite loop in findDataDirOwnershipHeld * Inculde keychainBackups in migration journal * Add guard to JSON.parse in readMigrationJournal * Add coverage for corrupt journals, increase timeout for SIGKILL recovery sweep * readMigrationJournal now distinguishes missing, valid, and unreadable journals * writeSidecarManifest should create server-control before writing server.json * Desktop can't attach to CLI daemon because it might be foreground; revert to old behavior * Add test for SIGKILL after journal write and after secret write * Shared executor should wait behind a lifecycle hook so getExecutor() cannot reopen while a previous handle is still disposing * Fix loadSharedHandle being able to poison future getExecutor() calls --------- Co-authored-by: Griffin Evans <griffin@ibm.com> Co-authored-by: Griffin <38366015+grfwings@users.noreply.github.com>
1 parent 3343303 commit edc4411

21 files changed

Lines changed: 2943 additions & 657 deletions

apps/cli/src/daemon-state.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ import { FileSystem, Path } from "effect";
55
import type { PlatformError } from "effect/PlatformError";
66
import * as Effect from "effect/Effect";
77

8+
// ---------------------------------------------------------------------------
9+
// Daemon discovery + spawn-dedup state — hints, not DB safety.
10+
//
11+
// DaemonRecord (daemon-<host>-<port>.json) and DaemonPointer
12+
// (daemon-active-<host>-<scope>.json) tell a CLI invocation which port/PID to
13+
// attach to, and DaemonStartLock (daemon-active-<host>-<scope>.json.lock)
14+
// avoids spawning daemons that would only lose the ownership race and die.
15+
// None of them gate access to data.db: that is the data-dir ownership lock in
16+
// @executor-js/local (apps/local/src/db/data-dir-ownership.ts), held for the
17+
// life of the serving process. These files are pure optimization — if they are
18+
// missing or stale, a redundant daemon may spawn, fail to acquire ownership,
19+
// and exit; correctness is never at risk.
20+
// ---------------------------------------------------------------------------
21+
822
// ---------------------------------------------------------------------------
923
// Types
1024
// ---------------------------------------------------------------------------

apps/cli/src/daemon.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import { describe, expect, it } from "@effect/vitest";
2+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
23
import { createServer, type Server } from "node:http";
34
import type { AddressInfo } from "node:net";
5+
import { tmpdir } from "node:os";
6+
import { join } from "node:path";
47
import * as Effect from "effect/Effect";
58

69
import {
710
canAutoStartLocalDaemonForHost,
811
isDevCliEntrypoint,
912
isExecutorServerReachable,
1013
planServiceInstall,
14+
spawnDetached,
15+
terminateSpawnedDetachedProcess,
1116
} from "./daemon";
1217

1318
describe("isDevCliEntrypoint", () => {
@@ -50,6 +55,48 @@ describe("canAutoStartLocalDaemonForHost", () => {
5055
});
5156
});
5257

58+
const waitForFile = (path: string): Effect.Effect<boolean> =>
59+
Effect.gen(function* () {
60+
for (let attempt = 0; attempt < 40; attempt++) {
61+
if (existsSync(path)) return true;
62+
yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 50)));
63+
}
64+
return false;
65+
});
66+
67+
describe("spawnDetached", () => {
68+
it.effect("can terminate the spawned detached process", () =>
69+
Effect.gen(function* () {
70+
const workDir = mkdtempSync(join(tmpdir(), "executor-daemon-spawn-"));
71+
const readyMarker = join(workDir, "ready");
72+
const terminatedMarker = join(workDir, "terminated");
73+
74+
try {
75+
const child = yield* Effect.acquireRelease(
76+
spawnDetached({
77+
command: process.execPath,
78+
args: [
79+
"-e",
80+
"const fs = require('node:fs'); fs.writeFileSync(process.env.READY_FILE, 'ok'); process.on('SIGTERM', () => { fs.writeFileSync(process.env.TERMINATED_FILE, 'ok'); process.exit(0); }); setInterval(() => {}, 1000)",
81+
],
82+
env: { ...process.env, READY_FILE: readyMarker, TERMINATED_FILE: terminatedMarker },
83+
}),
84+
(child) => terminateSpawnedDetachedProcess(child).pipe(Effect.ignore),
85+
);
86+
87+
expect(child.pid).toBeGreaterThan(0);
88+
const ready = yield* waitForFile(readyMarker);
89+
expect(ready).toBe(true);
90+
yield* terminateSpawnedDetachedProcess(child);
91+
const terminated = yield* waitForFile(terminatedMarker);
92+
expect(terminated).toBe(true);
93+
} finally {
94+
rmSync(workDir, { recursive: true, force: true });
95+
}
96+
}),
97+
);
98+
});
99+
53100
describe("isExecutorServerReachable", () => {
54101
it.effect("probes the unauthenticated /api/health endpoint without forwarding a credential", () =>
55102
Effect.gen(function* () {

apps/cli/src/daemon.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export interface DaemonSpawnSpec {
1717
readonly args: ReadonlyArray<string>;
1818
}
1919

20+
export interface SpawnedDetachedProcess {
21+
readonly pid: number;
22+
}
23+
2024
export interface ExecutorServerReachabilityInput {
2125
readonly baseUrl: string;
2226
}
@@ -140,7 +144,7 @@ export const spawnDetached = (input: {
140144
readonly command: string;
141145
readonly args: ReadonlyArray<string>;
142146
readonly env: Record<string, string | undefined>;
143-
}): Effect.Effect<void, Error> =>
147+
}): Effect.Effect<SpawnedDetachedProcess, Error> =>
144148
Effect.try({
145149
try: () => {
146150
const child = spawn(input.command, [...input.args], {
@@ -149,13 +153,36 @@ export const spawnDetached = (input: {
149153
env: input.env,
150154
});
151155
child.unref();
156+
if (typeof child.pid !== "number") {
157+
child.kill();
158+
throw new Error("Failed to spawn daemon process: child pid was not assigned");
159+
}
160+
return { pid: child.pid };
152161
},
153162
catch: (cause) =>
154163
cause instanceof Error
155164
? cause
156165
: new Error(`Failed to spawn daemon process: ${String(cause)}`),
157166
});
158167

168+
const signalPid = (pid: number): Effect.Effect<void, Error> =>
169+
Effect.try({
170+
try: () => {
171+
process.kill(pid, "SIGTERM");
172+
},
173+
catch: (cause) =>
174+
cause instanceof Error
175+
? cause
176+
: new Error(`Failed to terminate daemon process ${pid}: ${String(cause)}`),
177+
});
178+
179+
export const terminateSpawnedDetachedProcess = (
180+
child: SpawnedDetachedProcess,
181+
): Effect.Effect<void, Error> =>
182+
process.platform === "win32"
183+
? signalPid(child.pid)
184+
: signalPid(-child.pid).pipe(Effect.catch(() => signalPid(child.pid)));
185+
159186
const waitForCondition = <E, R>(input: {
160187
readonly check: Effect.Effect<boolean, E, R>;
161188
readonly expected: boolean;

apps/cli/src/local-server-manifest.test.ts

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,10 @@ import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import { Path } from "effect";
77
import * as Effect from "effect/Effect";
8-
import * as Exit from "effect/Exit";
98

109
import { normalizeExecutorServerConnection } from "@executor-js/sdk/shared";
1110
import {
12-
acquireLocalServerStartLock,
1311
readLocalServerManifest,
14-
releaseLocalServerStartLock,
1512
removeLocalServerManifestIfOwnedBy,
1613
resolveExecutorDataDir,
1714
writeLocalServerManifest,
@@ -65,25 +62,6 @@ describe("local server manifest", () => {
6562
}).pipe(Effect.provide(BunServices.layer)),
6663
);
6764

68-
it.effect("serializes local startup with a stale-aware lock", () =>
69-
Effect.gen(function* () {
70-
const dataDir = mkdtempSync(join(tmpdir(), "executor-local-server-lock-"));
71-
process.env.EXECUTOR_DATA_DIR = dataDir;
72-
73-
try {
74-
const first = yield* acquireLocalServerStartLock();
75-
const second = yield* Effect.exit(acquireLocalServerStartLock());
76-
77-
expect(Exit.isFailure(second)).toBe(true);
78-
yield* releaseLocalServerStartLock(first);
79-
const third = yield* acquireLocalServerStartLock();
80-
yield* releaseLocalServerStartLock(third);
81-
} finally {
82-
rmSync(dataDir, { recursive: true, force: true });
83-
}
84-
}).pipe(Effect.provide(BunServices.layer)),
85-
);
86-
8765
it.effect("resolves the data dir from EXECUTOR_DATA_DIR", () =>
8866
Effect.gen(function* () {
8967
const dataDir = mkdtempSync(join(tmpdir(), "executor-local-server-dir-"));
Lines changed: 13 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { homedir } from "node:os";
22
import { resolve } from "node:path";
3-
import { FileSystem, Option, Path, Schema } from "effect";
3+
import { FileSystem, Path } from "effect";
44
import type { PlatformError } from "effect/PlatformError";
55
import * as Effect from "effect/Effect";
66

@@ -9,11 +9,19 @@ import {
99
serializeExecutorLocalServerManifest,
1010
type ExecutorLocalServerManifest,
1111
} from "@executor-js/sdk/shared";
12-
import { isPidAlive } from "./daemon-state";
1312

14-
export interface LocalServerStartLock {
15-
readonly path: string;
16-
}
13+
// ---------------------------------------------------------------------------
14+
// server-control/server.json — a discovery/attach HINT, not an ownership proof.
15+
//
16+
// It records where a live local server is listening (origin + bearer) so other
17+
// CLI invocations and the desktop app can attach instead of spawning a
18+
// duplicate. The actual "only one process may open data.db" guarantee lives at
19+
// the DB layer: the data-dir ownership lock in @executor-js/local
20+
// (apps/local/src/db/data-dir-ownership.ts), acquired before any serving DB
21+
// handle exists. If this manifest is missing, stale, or malformed, the worst
22+
// outcome is a lost friendly attach — the kernel lock still refuses a second
23+
// owner, so the database stays safe.
24+
// ---------------------------------------------------------------------------
1725

1826
export const resolveExecutorDataDir = (path: Path.Path): string =>
1927
resolve(process.env.EXECUTOR_DATA_DIR ?? path.join(homedir(), ".executor"));
@@ -24,9 +32,6 @@ const serverControlDir = (path: Path.Path): string =>
2432
const localServerManifestPath = (path: Path.Path): string =>
2533
path.join(serverControlDir(path), "server.json");
2634

27-
const localServerStartLockPath = (path: Path.Path): string =>
28-
path.join(serverControlDir(path), "startup.lock");
29-
3035
export const readLocalServerManifest = (): Effect.Effect<
3136
ExecutorLocalServerManifest | null,
3237
never,
@@ -94,61 +99,3 @@ export const removeLocalServerManifest = (): Effect.Effect<
9499
const path = yield* Path.Path;
95100
yield* fs.remove(localServerManifestPath(path), { force: true });
96101
});
97-
98-
const StartupLockPayload = Schema.Struct({
99-
pid: Schema.Number,
100-
});
101-
102-
const decodeStartupLockPayload = Schema.decodeUnknownOption(
103-
Schema.fromJsonString(StartupLockPayload),
104-
);
105-
106-
const parseLockPid = (raw: string): number | null => {
107-
const decoded = decodeStartupLockPayload(raw);
108-
return Option.isSome(decoded) ? decoded.value.pid : null;
109-
};
110-
111-
export const acquireLocalServerStartLock = (): Effect.Effect<
112-
LocalServerStartLock,
113-
Error,
114-
FileSystem.FileSystem | Path.Path
115-
> =>
116-
Effect.gen(function* () {
117-
const fs = yield* FileSystem.FileSystem;
118-
const path = yield* Path.Path;
119-
yield* fs.makeDirectory(serverControlDir(path), { recursive: true });
120-
121-
const lockPath = localServerStartLockPath(path);
122-
const lockPayload = `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`;
123-
124-
const tryAcquire = () =>
125-
fs.writeFileString(lockPath, lockPayload, { flag: "wx" }).pipe(
126-
Effect.as(true),
127-
Effect.catchCause(() => Effect.succeed(false)),
128-
);
129-
130-
if (yield* tryAcquire()) return { path: lockPath };
131-
132-
const existingRaw = yield* fs
133-
.readFileString(lockPath)
134-
.pipe(Effect.catchCause(() => Effect.succeed(null)));
135-
if (existingRaw !== null) {
136-
const existingPid = parseLockPid(existingRaw);
137-
if (existingPid !== null && !isPidAlive(existingPid)) {
138-
yield* fs.remove(lockPath, { force: true });
139-
if (yield* tryAcquire()) return { path: lockPath };
140-
}
141-
}
142-
143-
return yield* Effect.fail(
144-
new Error("Another local Executor server startup is already in progress."),
145-
);
146-
});
147-
148-
export const releaseLocalServerStartLock = (
149-
lock: LocalServerStartLock,
150-
): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
151-
Effect.gen(function* () {
152-
const fs = yield* FileSystem.FileSystem;
153-
yield* fs.remove(lock.path, { force: true });
154-
});

0 commit comments

Comments
 (0)