Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a3719ae
Stop cutting off SSE streams after 30 seconds of silence
elias-ba Aug 15, 2026
c4b957e
Send a keepalive on streaming responses
elias-ba Aug 15, 2026
8a5b925
Check the keepalive reaches the wire
elias-ba Aug 15, 2026
93ef024
Say what actually went wrong when a service fails
elias-ba Aug 15, 2026
18e828b
Stop a run when the client goes away
elias-ba Aug 15, 2026
11c429c
Close what the third review found
elias-ba Aug 16, 2026
faef7df
Give websockets the same patience, and stop hiding a real warning
elias-ba Aug 16, 2026
8e25fe6
Tighten and sweep the temp payload files
elias-ba Aug 16, 2026
f36a480
Say what the temp file holds without naming it
elias-ba Aug 16, 2026
f19706b
Stop echo reflecting the whole payload
elias-ba Aug 16, 2026
68c0998
Mask sensitive values on their way out of a service
elias-ba Aug 16, 2026
5b21ba8
Close the routes the first mask left open
elias-ba Aug 16, 2026
281c4ca
Test that the exit mask holds, not just that echo behaves
elias-ba Aug 16, 2026
621ac48
Close what the third review found
elias-ba Aug 16, 2026
5185fff
Mask the event stream too
elias-ba Aug 16, 2026
54469f2
Use the shared mask instead of three hand-rolled lists
elias-ba Aug 16, 2026
6c5cc66
Trim the comments back to what earns its place
elias-ba Aug 16, 2026
ae877e7
Make closing a websocket actually stop the run
elias-ba Aug 16, 2026
b0c9c78
Restore the proof that the right value reaches the payload
elias-ba Aug 16, 2026
e7a18c6
copy logger before walking it
hanna-paasivirta Aug 17, 2026
9559cd3
handle killed processes and unparseable output
hanna-paasivirta Aug 17, 2026
bee229a
Fix websocket cancellation, exempt index writers
hanna-paasivirta Aug 17, 2026
5cd47f4
merge cancel-abandoned-runs, keep the ws.data key
hanna-paasivirta Aug 17, 2026
5cc4cc5
Merge pull request #635 from OpenFn/echo-mask-payload
hanna-paasivirta Aug 17, 2026
5f13f4f
drop the duplicate ws-cancel test
hanna-paasivirta Aug 17, 2026
6a83249
merge typed-service-failures
hanna-paasivirta Aug 17, 2026
c70db75
Merge pull request #632 from OpenFn/cancel-abandoned-runs
hanna-paasivirta Aug 17, 2026
1842a99
Merge pull request #631 from OpenFn/typed-service-failures
hanna-paasivirta Aug 17, 2026
2564c2b
Merge pull request #630 from OpenFn/sse-heartbeat
hanna-paasivirta Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cancel-abandoned-runs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"apollo": patch
---

Stop a service run when the client disconnects, so an abandoned request no
longer keeps calling the model
9 changes: 9 additions & 0 deletions .changeset/echo-mask-payload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"apollo": patch
---

Mask sensitive values on their way out of a service, rather than relying on
each one to remember: service loggers mask what they emit, echo masks what it
returns, and the error envelope masks the exception text. The shared mask now
covers every field the server may fill in, and no longer matches ordinary
hyphenated words
6 changes: 6 additions & 0 deletions .changeset/restore-sse-idle-timeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"apollo": patch
---

Raise the server's socket idle timeout back to 255s so long-running SSE
streams are no longer cut off while a model is thinking
6 changes: 6 additions & 0 deletions .changeset/sse-heartbeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"apollo": patch
---

Send a periodic keepalive on streaming responses so a stream that is working
but quiet is no longer mistaken for a dead one
6 changes: 6 additions & 0 deletions .changeset/typed-service-failures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"apollo": patch
---

Report service failures with a real code and message instead of "Unknown error",
and stop a failed spawn from hanging the request
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,8 @@ SSE to clients.
against the `apollo-mappings` Pinecone index (collections `loinc-mappings-v2`,
`snomed-mappings`). `embed_loinc_dataset` / `embed_snomed_dataset` populate it.
- `status/` - Health check: validates Anthropic, OpenAI and Pinecone keys.
- `echo/` - Test service that returns its input; useful for verifying the server
- `echo/` - Test service that returns its input (with server-set values such as
`api_key` masked); useful for verifying the server
pipeline.

Note: there are **three distinct Pinecone indexes** — `docsite` (OpenFn docs),
Expand Down
144 changes: 120 additions & 24 deletions platform/src/bridge.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import readline from "node:readline";
import path from "node:path";
import { spawn } from "node:child_process";
import { rm } from "node:fs/promises";
import { chmod, rm } from "node:fs/promises";
import { getInternalToken } from "./auth/internal-token";
import {
emptyResult,
malformedResult,
subprocessCancelled,
subprocessFailed,
subprocessKilled,
subprocessSpawnFailed,
} from "./util/errors";
import pkg from "../../package.json";

// A line a service logged on purpose, as opposed to whatever else lands on a
// stream. Only these are forwarded to the caller.
const LOG_LINE = /^(INFO|DEBUG|ERROR|WARNING):/;

/**
Run a python script
Each script will be run in its own thread because
Expand All @@ -17,22 +29,39 @@ export const run = async (
port: number, // needed for self-calling services in pythonland
args: any = {},
onLog?: (str: string) => void,
onEvent?: (type: string, payload: any /* string or json tbh */) => void
onEvent?: (type: string, payload: any /* string or json tbh */) => void,
// Aborted when the client goes away
signal?: AbortSignal
) => {
return new Promise<JSON | null>(async (resolve, reject) => {
const id = crypto.randomUUID();
const id = crypto.randomUUID();

const tmpfile = path.resolve(`tmp/data/${id}-{}.json`);
const tmpfile = path.resolve(`tmp/data/${id}-{}.json`);

const inputPath = tmpfile.replace("{}", "input");
const outputPath = tmpfile.replace("{}", "output");
const inputPath = tmpfile.replace("{}", "input");
const outputPath = tmpfile.replace("{}", "output");

// console.log("Initing input file at", inputPath);
// Outside the promise, deliberately. The Promise constructor only catches a
// synchronous throw from its executor, so an await that rejects in there -
// a full disk, a read-only tmp - leaves the promise pending for ever and
// the caller's stream open. Out here, run() is async and simply rejects.
try {
await Bun.write(inputPath, JSON.stringify(args));

// console.log("Initing output file at", outputPath);
// The payload can hold values that belong to the deployment rather than
// the caller, and only the close handler removes this file - so a process
// that dies first leaves one behind.
await chmod(inputPath, 0o600);

await Bun.write(outputPath, "");
} catch (error) {
// Removed rather than left behind by a half-finished setup, for the same
// reason it is 0600 above.
await rm(inputPath).catch(() => {});
await rm(outputPath).catch(() => {});
throw subprocessSpawnFailed(scriptName, error);
}

return new Promise<JSON | null>((resolve, reject) => {
const proc = spawn(
"poetry",
[
Expand All @@ -56,17 +85,46 @@ export const run = async (
}
);

proc.on("error", async (err) => {
console.log(err);
// Nothing was spawned, so no "close" is coming - without settling here the
// request stays open until something upstream gives up
proc.on("error", (err) => {
console.error("Failed to start python process", err);
reject(subprocessSpawnFailed(scriptName, err));
});

// `poetry run` execs into python rather than forking it, so this pid is the
// interpreter and a plain signal reaches it. Killing it closes the socket to
// Anthropic, which stops generation on the streaming calls; a non-streaming
// call is already submitted and gets billed whatever we do here.
let cancelled = false;
let hardKill: ReturnType<typeof setTimeout> | undefined;

const onAbort = () => {
cancelled = true;
console.warn(`cancelling ${scriptName}: client went away`);
proc.kill("SIGTERM");

// Python installs no SIGTERM handler, so termination is immediate. This
// is only for a child wedged somewhere that never sees it.
hardKill = setTimeout(() => proc.kill("SIGKILL"), 5_000);
hardKill.unref?.();
};

if (signal) {
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener("abort", onAbort, { once: true });
}
}

const rl = readline.createInterface({
input: proc.stdout,
crlfDelay: Infinity,
});
rl.on("line", (line) => {
// Then divert any logs from a logger object to the websocket
if (/^(INFO|DEBUG|ERROR|WARNING)\:/.test(line)) {
if (LOG_LINE.test(line)) {
// Divert the log line locally
console.log(line);
// TODO I'd love to break the log line up in to JSON actually
Expand All @@ -92,21 +150,30 @@ export const run = async (
});
rl2.on("line", (line) => {
console.error(line);
// /Divert all errors to the websocket
onLog?.(line);

// Only forward what a service logged deliberately, the same rule stdout
// follows. Everything else on stderr is the interpreter talking: raw
// tracebacks carrying server paths, source lines, and whatever a frame
// held - which for a service is the payload.
if (LOG_LINE.test(line)) {
onLog?.(line);
}
});

proc.on("close", async (code) => {
proc.on("close", async (code, closeSignal) => {
// Clean up readline interfaces immediately to prevent race conditions
rl.close();
rl2.close();

if (code) {
console.error("Python process exited with code", code);
reject(code);
if (hardKill) {
clearTimeout(hardKill);
}
const result = Bun.file(outputPath);
const text = await result.text();
signal?.removeEventListener("abort", onAbort);

// Read before cleaning up, and clean up on every exit path
const text = await Bun.file(outputPath)
.text()
.catch(() => "");

try {
await rm(inputPath);
Expand All @@ -116,12 +183,41 @@ export const run = async (
console.error(e);
}

// We killed it on purpose, so this is not a service failure
if (cancelled) {
return reject(subprocessCancelled(scriptName, closeSignal ?? "SIGTERM"));
}

if (code) {
console.error("Python process exited with code", code);
return reject(subprocessFailed(scriptName, code));
}

// A child killed by a signal reports a null code, so without this the
// OOM killer - the likeliest way a service dies without exiting - would
// be reported as an empty result and the signal thrown away.
if (closeSignal) {
console.error(`Python process killed by ${closeSignal}`);
return reject(subprocessKilled(scriptName, closeSignal));
}

if (text) {
resolve(JSON.parse(text));
} else {
console.warn("No data returned from pythonland");
resolve(null);
// Parsed inside the try: this handler is async, so a throw here
// becomes an unhandled rejection and the run never settles at all.
// A half-written file is what a crash mid-dump leaves behind.
try {
return resolve(JSON.parse(text));
} catch (e) {
console.error(`Unreadable output from ${scriptName}`);
console.error(e);
return reject(malformedResult(scriptName));
}
}

// entry.py writes a result on every path it completes, including its own
// error envelopes, so an empty file means the run died
console.warn("No data returned from pythonland");
return reject(emptyResult(scriptName));
});

return;
Expand Down
21 changes: 19 additions & 2 deletions platform/src/middleware/healthcheck.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Elysia } from "elysia";
import pkg from "../../../package.json" assert { type: "json" };
import { run } from '../bridge';
import { toErrorPayload } from '../util/errors';

export default async (app: Elysia) => {
app.get("/livez", () => {
Expand All @@ -12,8 +13,24 @@ export default async (app: Elysia) => {
});
});
app.get("/status", async () => {
const status = await run ('status', 0, {} as any) as any;
return new Response(status, {
// run() rejects now where it used to resolve null, and this is the one
// caller outside the services routes. Without the catch a spawn failure
// leaves the route throwing, so the health endpoint answers with Elysia's
// generic 500 and reports to Sentry rather than saying what is wrong.
let status: unknown;
try {
status = await run("status", 0, {} as any);
} catch (error) {
const payload = toErrorPayload(error);
return new Response(JSON.stringify(payload), {
status: payload.code,
headers: {
"Content-Type": "application/json",
},
});
}

return new Response(status as any, {
status: 200,
headers: {
"Content-Type": "application/json",
Expand Down
Loading