Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,9 @@ LANGFUSE_BASE_URL=https://cloud.langfuse.com
# ZILLIZ_URI = https://in01-XXXXXXXXXXXXX.aws-us-west-2.vectordb.zillizcloud.com:XXXXX
# ZILLIZ_TOKEN =db_admin:password (or ApiKey)

# Turn this on to use the fork server mechanism
# APOLLO_FORK_SERVER=true

# macOS only: required when APOLLO_FORK_SERVER=true
# OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES

186 changes: 186 additions & 0 deletions platform/src/bridge.fork.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import readline from "node:readline";
import net from "node:net";
import path from "node:path";
import { spawn, type ChildProcess } from "node:child_process";
import { 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";

// Only these lines are forwarded to the caller (mirrors bridge.ts).
const LOG_LINE = /^(INFO|DEBUG|ERROR|WARNING):/;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we share this between the two bridges?


export const USE_FORK_SERVER =
(process.env.APOLLO_FORK_SERVER ?? "").toLowerCase() === "true";

const onLines = (stream: NodeJS.ReadableStream, fn: (line: string) => void) =>
readline.createInterface({ input: stream, crlfDelay: Infinity }).on("line", fn);

// --- the warm master: spawned once, forks a child per request ----------------

type Master = { proc: ChildProcess; socketPath: string };
let master: Promise<Master> | null = null;
let masterProc: ChildProcess | null = null;

// Singleton; a failed start clears the slot so the next request retries.
function forkMaster(): Promise<Master> {
return (master ??= startMaster().catch((err) => {
master = null;
throw err;
}));
}

function startMaster(): Promise<Master> {
const socketPath = path.resolve(`tmp/fork_server-${process.pid}.sock`);
const proc = spawn(
"poetry",
["run", "python", "services/entry.fork.py", "--socket", socketPath],
{
env: {
...process.env,
APOLLO_INTERNAL_TOKEN: getInternalToken(),
APOLLO_VERSION: pkg.version,
},
}
);
masterProc = proc;
onLines(proc.stderr!, (line) => console.error(`fork-server: ${line}`));

return new Promise<Master>((resolve, reject) => {
const fail = (err: Error) => (clearTimeout(timer), reject(subprocessSpawnFailed("fork_server", err)));
const timer = setTimeout(() => fail(new Error("not READY in time")), 60_000);

onLines(proc.stdout!, (line) => {
console.log(`fork-server: ${line}`);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably remove this? Interestingly I don;'t see many logs from it, is that just because it's the master?

if (line.startsWith("READY")) {
clearTimeout(timer);
resolve({ proc, socketPath });
}
});
proc.on("error", fail);
proc.on("exit", (code, sig) => {
console.error(`fork-server exited (code=${code} signal=${sig})`);
master = masterProc = null; // next request starts a fresh one
rm(socketPath).catch(() => {});
fail(new Error(`fork-server exited before ready (code=${code} signal=${sig})`));
});
});
}

// Don't leave an orphaned master when the server process goes.
process.once("exit", () => masterProc?.kill("SIGTERM"));

// --- per-request: ask the master to fork a child, stream it back -------------

export async function runForked(
scriptName: string,
port: number,
inputPath: string,
outputPath: string,
onLog?: (str: string) => void,
onEvent?: (type: string, payload: any) => void,
signal?: AbortSignal
): Promise<JSON | null> {
const cleanup = () =>
Promise.all([rm(inputPath).catch(() => {}), rm(outputPath).catch(() => {})]);

let active: Master;
try {
active = await forkMaster();
} catch (err) {
await cleanup();
throw err;
}

return new Promise<JSON | null>((resolve, reject) => {
const conn = net.createConnection({ path: active.socketPath });
let childPid: number | null = null;
let exitCode: number | null = null;
let cancelled = false;
let hardKill: ReturnType<typeof setTimeout> | undefined;

const killChild = () => {
if (childPid == null) return; // killed once the PID: line arrives
try {
process.kill(childPid, "SIGTERM");
} catch {}
hardKill = setTimeout(() => {
try {
process.kill(childPid!, "SIGKILL");
} catch {}
}, 5_000);
hardKill.unref?.();
};

const onAbort = () => {
cancelled = true;
console.warn(`cancelling ${scriptName}: client went away`);
killChild();
conn.destroy();
};
if (signal) {
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
}

conn.on("connect", () =>
conn.write(JSON.stringify({ service: scriptName, input: inputPath, output: outputPath, port }) + "\n")
);

// The child's stream: PID:/EXIT: control lines around the service's own stdout.
onLines(conn, (line) => {
if (line.startsWith("PID:")) {
childPid = Number(line.slice(4)) || null;
if (cancelled) killChild();
} else if (line.startsWith("EXIT:")) {
exitCode = Number(line.slice(5));
} else if (LOG_LINE.test(line)) {
console.log(line);
onLog?.(line);
} else if (line.startsWith("EVENT:")) {
const [, type, ...rest] = line.split(":");
let payload: any = rest.join(":");
try {
payload = JSON.parse(payload);
} catch {
// not json, forward the raw string
}
onEvent?.(type, payload);
}
});

const settle = async (finish: () => void) => {
if (hardKill) clearTimeout(hardKill);
signal?.removeEventListener("abort", onAbort);
await cleanup();
finish();
};

conn.on("error", (err) =>
settle(() => reject(subprocessSpawnFailed(scriptName, err)))
);

conn.on("close", async () => {
const text = await Bun.file(outputPath).text().catch(() => "");
await settle(() => {
if (cancelled) return reject(subprocessCancelled(scriptName, "SIGTERM"));
// No EXIT line => the child died mid-flight (crash/OOM/signal).
if (exitCode === null) return reject(subprocessKilled(scriptName, "UNKNOWN"));
if (exitCode !== 0) return reject(subprocessFailed(scriptName, exitCode));
if (!text) return reject(emptyResult(scriptName));
try {
resolve(JSON.parse(text));
} catch {
reject(malformedResult(scriptName));
}
});
});
});
}
5 changes: 5 additions & 0 deletions platform/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
subprocessSpawnFailed,
} from "./util/errors";
import pkg from "../../package.json";
import { USE_FORK_SERVER, runForked } from "./bridge.fork";

// A line a service logged on purpose, as opposed to whatever else lands on a
// stream. Only these are forwarded to the caller.
Expand Down Expand Up @@ -61,6 +62,10 @@ export const run = async (
throw subprocessSpawnFailed(scriptName, error);
}

if (USE_FORK_SERVER) {
return runForked(scriptName, port, inputPath, outputPath, onLog, onEvent, signal);
}

return new Promise<JSON | null>((resolve, reject) => {
const proc = spawn(
"poetry",
Expand Down
142 changes: 142 additions & 0 deletions services/entry.fork.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Fork entry for the Python side (used when APOLLO_FORK_SERVER=true; see
platform/src/bridge.fork.ts). Run as a script, never imported.

Pays the heavy imports once, then forks a fresh child per request. Each child
inherits the imports via copy-on-write and imports entry.py AFTER forking, so
entry.py's own init runs in the child where it's fork-safe - entry.py is left
untouched. The parent only imports and never inits, so it stays forkable.

macOS: set OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES in your env (see .env.example),
or a forked child that touches an Objective-C framework aborts. No-op on Linux.

Protocol (NDJSON over a unix socket, one connection per request):
bridge -> master : {"service","input","output","port"}
child -> bridge : PID:<pid> then the service's stdout then EXIT:<code>
"""

import argparse
import importlib
import json
import os
import signal
import socket
import sys
import traceback

import sentry_sdk # for the per-child service tag + flush (see run_child)

# Warm entry.py's heavy deps so forked children inherit them (copy-on-write).
# Mirrors entry.py's imports; a missing one is just imported per-child - slower,
# never wrong.
PRELOAD = (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to pull this preload list out of here and into a yaml file, with some comments to explain what it is and what it's doing

Also, since 95% of calls will be through global_chat, can we add its heaviest dependencies here? Claude suggests Pinecone and openai, but maybe there are others.

"sentry_sdk",
"anthropic",
"langfuse",
"langfuse_util",
"util",
"opentelemetry.instrumentation.anthropic",
"opentelemetry.instrumentation.threading",
)


def preload() -> int:
"""Warm the shared deps, plus any opt-in services. Services are opt-in
(APOLLO_FORK_PRELOAD_SERVICES) because some do I/O at import and would hang
the parent; the child imports the rest on demand."""
for module in PRELOAD:
importlib.import_module(module)

names = [s.strip() for s in os.environ.get("APOLLO_FORK_PRELOAD_SERVICES", "").split(",") if s.strip()]
loaded = 0
for name in names:
try:
importlib.import_module(f"{name}.{name}")
loaded += 1
except Exception as e:
print(f"entry.fork: could not preload {name}: {e}", file=sys.stderr, flush=True)
return loaded


def run_child(conn: socket.socket, req: dict) -> None:
"""In the forked child: stream through the socket, run one job, exit."""
# Route stdout to the socket so the service's logging reaches the bridge.
# Leave stderr on fd 2 (the master's stderr pipe) so Python tracebacks still
# reach the server logs, the way the spawn path forwards them.
os.dup2(conn.fileno(), 1)
sys.stdout = os.fdopen(1, "w", buffering=1)

print(f"PID:{os.getpid()}") # first, so the bridge can cancel this child

code = 0
try:
import entry # post-fork: entry.py's init runs here (fork-safe), deps warm

sentry_sdk.set_tag("service", req["service"]) # entry.main() tags it; call() doesn't
entry.call(
service=req["service"],
input_path=req.get("input"),
output_path=req.get("output"),
apollo_port=req.get("port"),
)
except Exception:
traceback.print_exc()
code = 1
finally:
try:
sentry_sdk.flush(timeout=2)
except Exception:
pass
langfuse = getattr(sys.modules.get("entry"), "langfuse", None)
if langfuse is not None:
try:
langfuse.flush()
except Exception:
pass
print(f"EXIT:{code}")
os._exit(code)


def read_request(conn: socket.socket) -> dict | None:
"""Read one newline-terminated JSON request; None if there isn't a usable one."""
line = b""
while not line.endswith(b"\n"):
chunk = conn.recv(65536)
if not chunk:
break
line += chunk
try:
return json.loads(line) if line else None
except json.JSONDecodeError:
return None


def serve(sock_path: str) -> None:
signal.signal(signal.SIGCHLD, signal.SIG_IGN) # auto-reap children
count = preload()

if os.path.exists(sock_path):
os.unlink(sock_path)
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(sock_path)
server.listen(128)
print(f"READY services={count}", flush=True) # handshake the bridge waits for

while True:
try:
conn, _ = server.accept()
except InterruptedError:
continue
req = read_request(conn)
if req is None:
conn.close()
continue
if os.fork() == 0:
server.close() # the child must not hold the listening socket
run_child(conn, req) # never returns
conn.close() # parent drops its copy; the child owns the reply


if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--socket", required=True)
serve(parser.parse_args().socket)
Loading