Skip to content

Commit 5e88523

Browse files
Merge pull request #473 from ScriptedAlchemy/codex/multi-connection-integrity
fix(storage): enforce sole-daemon database ownership
2 parents 15f8781 + e6fe9a3 commit 5e88523

176 files changed

Lines changed: 26843 additions & 10815 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.config/nextest.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,12 @@ filter = 'binary(=core_cli_suite) & test(/^tool_daemon_test::(daemon_socket_is_o
5353
threads-required = "num-cpus"
5454

5555
[[profile.ci.overrides]]
56-
filter = '(binary(=core_cli_suite) & test(/^cli_non_interactive_test::/)) | (binary(=mcp_suite) & test(/^(mcp_cli_serve_test|serve_degraded_mode_test|serve_template_path_test)::/))'
56+
filter = '(binary(=core_cli_suite) & test(/^cli_non_interactive_test::/)) | (binary(=mcp_suite) & test(/^(mcp_cli_serve_test|serve_template_path_test)::/))'
5757
test-group = 'cli-subprocess'
5858
threads-required = "num-cpus"
5959

6060
[[profile.default.overrides]]
61-
filter = '(binary(=core_cli_suite) & test(/^cli_non_interactive_test::/)) | (binary(=mcp_suite) & test(/^(mcp_cli_serve_test|serve_degraded_mode_test|serve_template_path_test)::/))'
61+
filter = '(binary(=core_cli_suite) & test(/^cli_non_interactive_test::/)) | (binary(=mcp_suite) & test(/^(mcp_cli_serve_test|serve_template_path_test)::/))'
6262
test-group = 'cli-subprocess'
6363
threads-required = "num-cpus"
6464

dashboard/smoke.mjs

Lines changed: 169 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ const VIEWPORT_PROFILES = {
3131
};
3232

3333
const DASHBOARD_URL_RE = /(http:\/\/127\.0\.0\.1:\d+\/)/;
34+
const DAEMON_HARNESS_ACTIVE = "TRACEDECAY_DAEMON_HARNESS_ACTIVE";
35+
const DASHBOARD_STARTUP_TIMEOUT_MS = 30_000;
36+
const DASHBOARD_STOP_TIMEOUT_MS = 5_000;
37+
const IS_UNIX = process.platform !== "win32";
3438

3539
function workspaceRoot() {
3640
return fileURLToPath(new URL("..", import.meta.url));
@@ -40,6 +44,49 @@ function withTrailingSlash(url) {
4044
return url.endsWith("/") ? url : `${url}/`;
4145
}
4246

47+
function runnableFile(candidate) {
48+
if (!candidate) return null;
49+
const resolved = path.resolve(candidate);
50+
try {
51+
if (!fs.statSync(resolved).isFile()) return null;
52+
if (IS_UNIX) fs.accessSync(resolved, fs.constants.X_OK);
53+
return resolved;
54+
} catch {
55+
return null;
56+
}
57+
}
58+
59+
function resolveTracedecayBinary() {
60+
const cargoBinary = runnableFile(process.env.CARGO_BIN_EXE_tracedecay);
61+
if (cargoBinary) return cargoBinary;
62+
63+
const suffix = process.platform === "win32" ? ".exe" : "";
64+
const workspaceBinary = path.join(workspaceRoot(), "target", "debug", `tracedecay${suffix}`);
65+
const builtBinary = runnableFile(workspaceBinary);
66+
if (builtBinary) return builtBinary;
67+
68+
throw new Error(`built TraceDecay binary not found at ${workspaceBinary}`);
69+
}
70+
71+
function runUnderIsolatedDaemon() {
72+
const harness = path.join(workspaceRoot(), "scripts", "with-isolated-tracedecay-daemon.sh");
73+
const tracedecayBinary = resolveTracedecayBinary();
74+
const result = spawnSync(
75+
harness,
76+
["--bin", tracedecayBinary, "--", process.execPath, ...process.argv.slice(1)],
77+
{
78+
cwd: workspaceRoot(),
79+
env: process.env,
80+
stdio: "inherit",
81+
},
82+
);
83+
if (result.error) throw result.error;
84+
if (result.signal) {
85+
throw new Error(`isolated daemon harness terminated by ${result.signal}`);
86+
}
87+
return result.status ?? 1;
88+
}
89+
4390
// The dashboard refuses to start without a TraceDecay index, and CI checkouts
4491
// (unlike dev workspaces) have no `.tracedecay/`. Build a tiny throwaway
4592
// project and index it so the smoke run is hermetic everywhere.
@@ -51,71 +98,137 @@ function createSmokeWorkspace() {
5198
);
5299
// stdin is closed so init's interactive `.gitignore` prompt reads EOF and
53100
// proceeds with the default instead of blocking.
54-
const result = spawnSync("cargo", ["run", "--", "init", dir], {
101+
const result = spawnSync(resolveTracedecayBinary(), ["init", dir], {
55102
cwd: workspaceRoot(),
56103
env: process.env,
57104
stdio: ["ignore", "inherit", "inherit"],
58105
});
59-
if (result.status !== 0) {
106+
if (result.error || result.status !== 0) {
60107
fs.rmSync(dir, { recursive: true, force: true });
108+
if (result.error) throw result.error;
61109
throw new Error(`tracedecay init failed for smoke workspace (code ${result.status})`);
62110
}
63111
return dir;
64112
}
65113

66114
async function startDashboardServer(projectPath) {
67-
return new Promise((resolve, reject) => {
68-
const child = spawn(
69-
"cargo",
70-
["run", "--", "dashboard", "--port", "0", "--path", projectPath],
71-
{
72-
cwd: workspaceRoot(),
73-
env: process.env,
74-
stdio: ["ignore", "pipe", "pipe"],
75-
},
76-
);
115+
const child = spawn(
116+
resolveTracedecayBinary(),
117+
["dashboard", "--port", "0", "--path", projectPath],
118+
{
119+
cwd: workspaceRoot(),
120+
env: process.env,
121+
stdio: ["ignore", "pipe", "pipe"],
122+
detached: IS_UNIX,
123+
},
124+
);
77125

78-
let settled = false;
79-
let stderrBuffer = "";
80-
const complete = (handler, value) => {
81-
if (settled) return;
82-
settled = true;
83-
handler(value);
84-
};
126+
let closed = false;
127+
let stderrBuffer = "";
128+
let stopPromise = null;
129+
const exitPromise = new Promise((resolve) => {
130+
child.once("error", (error) => resolve({ error }));
131+
child.once("exit", (code, signal) => resolve({ code, signal }));
132+
});
133+
child.once("close", () => {
134+
closed = true;
135+
});
136+
child.stderr.on("data", (chunk) => {
137+
stderrBuffer += chunk.toString();
138+
});
85139

86-
const stdoutLines = readline.createInterface({ input: child.stdout });
87-
stdoutLines.on("line", (line) => {
88-
process.stdout.write(`[dashboard] ${line}\n`);
89-
const match = line.match(DASHBOARD_URL_RE);
90-
if (match) {
91-
complete(resolve, {
92-
baseUrl: withTrailingSlash(match[1]),
93-
child,
94-
stop: async () => {
95-
child.kill("SIGTERM");
96-
await new Promise((done) => child.once("exit", done));
97-
},
98-
});
99-
}
100-
});
140+
const stdoutLines = readline.createInterface({ input: child.stdout });
141+
const stderrLines = readline.createInterface({ input: child.stderr });
142+
stderrLines.on("line", (line) => process.stderr.write(`[dashboard:stderr] ${line}\n`));
101143

102-
const stderrLines = readline.createInterface({ input: child.stderr });
103-
stderrLines.on("line", (line) => {
104-
stderrBuffer = `${stderrBuffer}${line}\n`;
105-
process.stderr.write(`[dashboard:stderr] ${line}\n`);
106-
});
144+
const processGroupAlive = () => {
145+
if (child.pid === undefined) return false;
146+
if (!IS_UNIX) return child.exitCode === null && child.signalCode === null;
147+
try {
148+
process.kill(-child.pid, 0);
149+
return true;
150+
} catch (error) {
151+
if (error.code === "ESRCH") return false;
152+
if (error.code === "EPERM") return true;
153+
throw error;
154+
}
155+
};
156+
const sendSignal = (signal) => {
157+
if (child.pid === undefined) return;
158+
try {
159+
if (IS_UNIX) process.kill(-child.pid, signal);
160+
else child.kill(signal);
161+
} catch (error) {
162+
if (error.code !== "ESRCH") throw error;
163+
}
164+
};
165+
const waitForStop = async () => {
166+
const deadline = Date.now() + DASHBOARD_STOP_TIMEOUT_MS;
167+
while (Date.now() < deadline) {
168+
if (closed && !processGroupAlive()) return true;
169+
await new Promise((resolve) => setTimeout(resolve, 50));
170+
}
171+
return closed && !processGroupAlive();
172+
};
173+
const stop = () => {
174+
if (stopPromise) return stopPromise;
175+
stopPromise = (async () => {
176+
try {
177+
if (!closed || processGroupAlive()) sendSignal("SIGTERM");
178+
if (!(await waitForStop())) {
179+
sendSignal("SIGKILL");
180+
if (!(await waitForStop())) {
181+
throw new Error("dashboard server did not stop after SIGKILL");
182+
}
183+
}
184+
} finally {
185+
stdoutLines.close();
186+
stderrLines.close();
187+
}
188+
})();
189+
return stopPromise;
190+
};
107191

108-
child.once("error", (err) => {
109-
complete(reject, err);
110-
});
111-
child.once("exit", (code) => {
112-
if (settled) return;
113-
complete(
114-
reject,
115-
new Error(`dashboard server exited before startup (code ${code})\n${stderrBuffer}`),
192+
try {
193+
const baseUrl = await new Promise((resolve, reject) => {
194+
let settled = false;
195+
const complete = (handler, value) => {
196+
if (settled) return;
197+
settled = true;
198+
clearTimeout(timer);
199+
handler(value);
200+
};
201+
const timer = setTimeout(
202+
() => complete(reject, new Error(
203+
`dashboard server startup timed out after ${DASHBOARD_STARTUP_TIMEOUT_MS}ms`,
204+
)),
205+
DASHBOARD_STARTUP_TIMEOUT_MS,
116206
);
207+
stdoutLines.on("line", (line) => {
208+
process.stdout.write(`[dashboard] ${line}\n`);
209+
const match = line.match(DASHBOARD_URL_RE);
210+
if (match) complete(resolve, withTrailingSlash(match[1]));
211+
});
212+
exitPromise.then(({ code, signal, error }) => {
213+
const detail = error?.message ?? `code ${code}${signal ? `, signal ${signal}` : ""}`;
214+
complete(reject, new Error(`dashboard server exited before startup (${detail})`));
215+
});
117216
});
118-
});
217+
return { baseUrl, child, stop };
218+
} catch (error) {
219+
let stopError = null;
220+
try {
221+
await stop();
222+
} catch (caught) {
223+
stopError = caught;
224+
}
225+
const diagnostics = stderrBuffer.trim();
226+
const message = error instanceof Error ? error.message : String(error);
227+
const stopMessage = stopError ? `\nshutdown error: ${stopError.message}` : "";
228+
throw new Error(`${message}${diagnostics ? `\n${diagnostics}` : ""}${stopMessage}`, {
229+
cause: error,
230+
});
231+
}
119232
}
120233

121234
async function waitForAny(page, locators, timeoutMs) {
@@ -357,6 +470,13 @@ async function main() {
357470
}
358471
return profile;
359472
});
473+
474+
if (!explicitUrl && process.env[DAEMON_HARNESS_ACTIVE] !== "1") {
475+
console.log("Starting hermetic foreground TraceDecay daemon for smoke test...");
476+
process.exitCode = runUnderIsolatedDaemon();
477+
return;
478+
}
479+
360480
let server = null;
361481
let workspace = null;
362482

@@ -365,6 +485,7 @@ async function main() {
365485
server = { baseUrl: explicitUrl, stop: async () => {} };
366486
console.log(`Using existing dashboard URL: ${explicitUrl}`);
367487
} else {
488+
console.log("Smoke daemon is ready.");
368489
console.log("Creating hermetic smoke workspace (tracedecay init)...");
369490
workspace = createSmokeWorkspace();
370491
console.log(`Starting \`tracedecay dashboard --port 0 --path ${workspace}\` for smoke test...`);

0 commit comments

Comments
 (0)