Run prefilter/postfilter shells via single-threaded worker processes

Forking and waitpid()-ing for the -C/-c shell filters used to happen
directly in the multi-threaded tiling threads, which made fork() expensive
(full COW of the parent's address space) and unsafe (other threads could
hold libc/malloc locks at fork time). Now we pre-fork a small pool of
single-threaded worker processes once, before any tiling or reader threads
are spawned, and the tiling threads ask a worker to fork+exec the shell
child on their behalf, receiving the input/output pipe fds back via
SCM_RIGHTS. The fork() and waitpid() happen entirely inside the
single-threaded worker, where they are cheap and only block that worker.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Erica Fischer
2026-05-30 14:50:37 -07:00
co-authored by Cursor
parent cb6cacef15
commit 4f4214ee23
4 changed files with 443 additions and 106 deletions
+9
View File
@@ -67,6 +67,7 @@
#include "attribute.hpp"
#include "thread.hpp"
#include "platform.hpp"
#include "plugin.hpp"
static int low_detail = 12;
static int full_detail = -1;
@@ -3714,6 +3715,14 @@ int main(int argc, char **argv) {
signal(SIGPIPE, SIG_IGN);
// Pre-fork the filter worker pool while we are still single-threaded.
// Spawning them now (before any reader or tiling threads) means each
// worker is a clean, single-threaded process, so its later fork()s
// for the prefilter/postfilter shells are cheap and safe.
if (prefilter != NULL || postfilter != NULL) {
filter_workers_init(CPUS);
}
files_open_at_start = open(get_null_device(), O_RDONLY | O_CLOEXEC);
if (files_open_at_start < 0) {
perror("open /dev/null");
+396 -72
View File
@@ -16,6 +16,9 @@
#include <cmath>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/socket.h>
#include <sys/uio.h>
#include <stdint.h>
#include <sqlite3.h>
#include <limits.h>
#include "main.hpp"
@@ -371,106 +374,436 @@ serial_feature parse_feature(json_pull *jp, int z, unsigned x, unsigned y, std::
}
}
static pthread_mutex_t pipe_lock = PTHREAD_MUTEX_INITIALIZER;
// ----------------------------------------------------------------------------
// Filter worker processes.
//
// Forking and waiting for the prefilter / postfilter shell processes used to
// happen directly in the tiling threads. fork() from a multithreaded process
// is expensive (the parent's full virtual address space has to be copy-on-write
// duplicated) and unsafe (other threads may hold libc / malloc locks at the
// moment of fork, which can deadlock the child before exec). Instead, we now
// pre-fork a small pool of single-threaded worker processes once, before any
// tiling threads have been started. When a tiling thread wants to run a
// filter, it asks one of these workers (over a unix-domain socketpair) to
// fork+exec the shell child on its behalf and to send back the input/output
// pipe file descriptors via SCM_RIGHTS. The tiling thread then reads/writes
// those pipes exactly as before, but the fork() and the subsequent waitpid()
// for the shell child happen inside the single-threaded worker, where they
// are cheap and safe.
// ----------------------------------------------------------------------------
void setup_filter(const char *filter, int *write_to, int *read_from, pid_t *pid, unsigned z, unsigned x, unsigned y) {
// This will create two pipes, a new thread, and a new process.
//
// The new process will read from one pipe and write to the other, and execute the filter.
// The new thread will write the GeoJSON to the pipe that leads to the filter.
// The original thread will read the GeoJSON from the filter and convert it back into vector tiles.
namespace {
if (pthread_mutex_lock(&pipe_lock) != 0) {
perror("pthread_mutex_lock (pipe)");
exit(EXIT_PTHREAD);
struct filter_worker {
int sock = -1; // parent end of socketpair to this worker
pid_t pid = -1;
};
struct filter_pool {
std::vector<filter_worker> workers;
std::vector<size_t> free_list;
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
bool initialized = false;
};
filter_pool g_pool;
// Read/write helpers that loop over short reads/writes and EINTR.
ssize_t read_fully(int fd, void *buf, size_t n) {
char *p = (char *) buf;
size_t got = 0;
while (got < n) {
ssize_t r = read(fd, p + got, n - got);
if (r == 0) {
return (ssize_t) got;
}
if (r < 0) {
if (errno == EINTR) {
continue;
}
return -1;
}
got += (size_t) r;
}
return (ssize_t) got;
}
ssize_t write_fully(int fd, const void *buf, size_t n) {
const char *p = (const char *) buf;
size_t sent = 0;
while (sent < n) {
ssize_t r = write(fd, p + sent, n - sent);
if (r < 0) {
if (errno == EINTR) {
continue;
}
return -1;
}
sent += (size_t) r;
}
return (ssize_t) sent;
}
// Send a one-byte payload along with two file descriptors via SCM_RIGHTS.
// Returns the number of payload bytes sent (1) on success, -1 on error.
ssize_t send_two_fds(int sock, int fd1, int fd2) {
char dummy = 0;
struct iovec iov;
iov.iov_base = &dummy;
iov.iov_len = 1;
char ctrl[CMSG_SPACE(sizeof(int) * 2)];
memset(ctrl, 0, sizeof(ctrl));
struct msghdr msg;
memset(&msg, 0, sizeof(msg));
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = ctrl;
msg.msg_controllen = sizeof(ctrl);
struct cmsghdr *cm = CMSG_FIRSTHDR(&msg);
cm->cmsg_level = SOL_SOCKET;
cm->cmsg_type = SCM_RIGHTS;
cm->cmsg_len = CMSG_LEN(sizeof(int) * 2);
int *fdptr = (int *) CMSG_DATA(cm);
fdptr[0] = fd1;
fdptr[1] = fd2;
while (true) {
ssize_t r = sendmsg(sock, &msg, 0);
if (r < 0 && errno == EINTR) {
continue;
}
return r;
}
}
// Receive a one-byte payload along with two file descriptors via SCM_RIGHTS.
// Returns 1 on success (and writes the fds into *fd1, *fd2), 0 on EOF, -1 on error.
ssize_t recv_two_fds(int sock, int *fd1, int *fd2) {
char dummy = 0;
struct iovec iov;
iov.iov_base = &dummy;
iov.iov_len = 1;
char ctrl[CMSG_SPACE(sizeof(int) * 2)];
memset(ctrl, 0, sizeof(ctrl));
struct msghdr msg;
memset(&msg, 0, sizeof(msg));
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = ctrl;
msg.msg_controllen = sizeof(ctrl);
ssize_t r;
while (true) {
r = recvmsg(sock, &msg, 0);
if (r < 0 && errno == EINTR) {
continue;
}
break;
}
if (r <= 0) {
return r;
}
int pipe_orig[2], pipe_filtered[2];
struct cmsghdr *cm = CMSG_FIRSTHDR(&msg);
if (cm == NULL || cm->cmsg_level != SOL_SOCKET || cm->cmsg_type != SCM_RIGHTS || cm->cmsg_len != CMSG_LEN(sizeof(int) * 2)) {
errno = EPROTO;
return -1;
}
int *fdptr = (int *) CMSG_DATA(cm);
*fd1 = fdptr[0];
*fd2 = fdptr[1];
return r;
}
// Main loop of a worker process. Reads filter requests from its socket,
// forks/execs the shell command, hands the input/output pipe fds back to
// the parent via SCM_RIGHTS, waits for the shell child to exit, then
// reports completion to the parent.
[[noreturn]] void worker_main(int sock) {
while (true) {
uint32_t flen;
ssize_t r = read_fully(sock, &flen, sizeof(flen));
if (r == 0) {
// Parent closed the socket; we are done.
_exit(0);
}
if (r < 0) {
perror("filter worker: read header");
_exit(1);
}
std::string filter(flen, '\0');
if (flen > 0 && read_fully(sock, &filter[0], flen) != (ssize_t) flen) {
perror("filter worker: read filter string");
_exit(1);
}
uint32_t coords[3];
if (read_fully(sock, coords, sizeof(coords)) != (ssize_t) sizeof(coords)) {
perror("filter worker: read coords");
_exit(1);
}
unsigned z = coords[0];
unsigned x = coords[1];
unsigned y = coords[2];
int pipe_orig[2];
int pipe_filtered[2];
if (pipe(pipe_orig) < 0) {
perror("pipe (original features)");
exit(EXIT_OPEN);
perror("filter worker: pipe (original features)");
_exit(1);
}
if (pipe(pipe_filtered) < 0) {
perror("pipe (filtered features)");
exit(EXIT_OPEN);
perror("filter worker: pipe (filtered features)");
_exit(1);
}
std::string z_str = std::to_string(z);
std::string x_str = std::to_string(x);
std::string y_str = std::to_string(y);
*pid = fork();
if (*pid < 0) {
perror("fork");
exit(EXIT_PTHREAD);
} else if (*pid == 0) {
// child
pid_t pid = fork();
if (pid < 0) {
perror("filter worker: fork");
close(pipe_orig[0]);
close(pipe_orig[1]);
close(pipe_filtered[0]);
close(pipe_filtered[1]);
_exit(1);
} else if (pid == 0) {
// shell child
if (dup2(pipe_orig[0], 0) < 0) {
perror("dup child stdin");
exit(EXIT_OPEN);
_exit(EXIT_OPEN);
}
if (dup2(pipe_filtered[1], 1) < 0) {
perror("dup child stdout");
exit(EXIT_OPEN);
_exit(EXIT_OPEN);
}
if (close(pipe_orig[1]) != 0) {
perror("close output to filter");
exit(EXIT_CLOSE);
}
if (close(pipe_filtered[0]) != 0) {
perror("close input from filter");
exit(EXIT_CLOSE);
}
if (close(pipe_orig[0]) != 0) {
perror("close dup input of filter");
exit(EXIT_CLOSE);
}
if (close(pipe_filtered[1]) != 0) {
perror("close dup output of filter");
exit(EXIT_CLOSE);
close(pipe_orig[0]);
close(pipe_orig[1]);
close(pipe_filtered[0]);
close(pipe_filtered[1]);
close(sock);
execlp("sh", "sh", "-c", filter.c_str(), "sh", z_str.c_str(), x_str.c_str(), y_str.c_str(), NULL);
perror("filter worker: exec");
_exit(EXIT_PTHREAD);
}
// XXX close other fds?
// Worker (between parent tippecanoe and shell child).
close(pipe_orig[0]);
close(pipe_filtered[1]);
if (execlp("sh", "sh", "-c", filter, "sh", z_str.c_str(), x_str.c_str(), y_str.c_str(), NULL) != 0) {
perror("exec");
// Hand the parent's-side fds back to the parent. After this
// the parent owns pipe_orig[1] and pipe_filtered[0] in its
// own fd table; we close our copies so EOFs propagate
// correctly when the parent closes its ends.
ssize_t s = send_two_fds(sock, pipe_orig[1], pipe_filtered[0]);
close(pipe_orig[1]);
close(pipe_filtered[0]);
if (s < 0) {
// Parent likely went away. Reap the shell child and exit.
int stat_loc;
while (waitpid(pid, &stat_loc, 0) < 0 && errno == EINTR) {
}
_exit(0);
}
// Block here until the shell child finishes. This is exactly
// the wait that used to block a tiling thread; now it only
// blocks this single-threaded worker, which has no other
// work to do anyway.
int stat_loc;
while (waitpid(pid, &stat_loc, 0) < 0) {
if (errno == EINTR) {
continue;
}
perror("filter worker: waitpid");
break;
}
// Tell the parent we are done. A single byte is enough; the
// existing code did not inspect the shell's exit status.
char done = 0;
if (write_fully(sock, &done, 1) < 0) {
// Parent gone; just exit quietly.
_exit(0);
}
}
}
size_t acquire_worker() {
if (pthread_mutex_lock(&g_pool.mtx) != 0) {
perror("pthread_mutex_lock (filter pool)");
exit(EXIT_PTHREAD);
}
if (!g_pool.initialized) {
fprintf(stderr, "Internal error: filter worker pool used before init\n");
exit(EXIT_IMPOSSIBLE);
}
while (g_pool.free_list.empty()) {
if (pthread_cond_wait(&g_pool.cond, &g_pool.mtx) != 0) {
perror("pthread_cond_wait (filter pool)");
exit(EXIT_PTHREAD);
}
}
size_t idx = g_pool.free_list.back();
g_pool.free_list.pop_back();
if (pthread_mutex_unlock(&g_pool.mtx) != 0) {
perror("pthread_mutex_unlock (filter pool)");
exit(EXIT_PTHREAD);
}
return idx;
}
void release_worker(size_t idx) {
if (pthread_mutex_lock(&g_pool.mtx) != 0) {
perror("pthread_mutex_lock (filter pool)");
exit(EXIT_PTHREAD);
}
g_pool.free_list.push_back(idx);
if (pthread_cond_signal(&g_pool.cond) != 0) {
perror("pthread_cond_signal (filter pool)");
exit(EXIT_PTHREAD);
}
if (pthread_mutex_unlock(&g_pool.mtx) != 0) {
perror("pthread_mutex_unlock (filter pool)");
exit(EXIT_PTHREAD);
}
}
} // namespace
// Spawn the filter worker pool. Must be called from a single-threaded
// context (i.e. before any tiling or reader threads have been created)
// so that the workers themselves are forked from a clean, single-threaded
// process state. Calling this more than once is a no-op.
void filter_workers_init(size_t n) {
if (g_pool.initialized) {
return;
}
if (n < 1) {
n = 1;
}
g_pool.workers.resize(n);
g_pool.free_list.reserve(n);
for (size_t i = 0; i < n; i++) {
int sv[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) < 0) {
perror("socketpair (filter worker)");
exit(EXIT_PTHREAD);
}
pid_t pid = fork();
if (pid < 0) {
perror("fork (filter worker)");
exit(EXIT_PTHREAD);
} else if (pid == 0) {
// Worker process.
close(sv[0]);
// Close the parent ends of any sibling workers we
// inherited from earlier iterations of this loop.
for (size_t j = 0; j < i; j++) {
close(g_pool.workers[j].sock);
}
worker_main(sv[1]);
// worker_main is [[noreturn]].
} else {
// parent
if (close(pipe_orig[0]) != 0) {
perror("close filter-side reader");
close(sv[1]);
if (fcntl(sv[0], F_SETFD, FD_CLOEXEC) != 0) {
perror("cloexec (filter worker socket)");
exit(EXIT_CLOSE);
}
if (close(pipe_filtered[1]) != 0) {
perror("close filter-side writer");
exit(EXIT_CLOSE);
g_pool.workers[i].sock = sv[0];
g_pool.workers[i].pid = pid;
g_pool.free_list.push_back(i);
}
if (fcntl(pipe_orig[1], F_SETFD, FD_CLOEXEC) != 0) {
perror("cloxec output to filter");
exit(EXIT_CLOSE);
}
if (fcntl(pipe_filtered[0], F_SETFD, FD_CLOEXEC) != 0) {
perror("cloxec input from filter");
exit(EXIT_CLOSE);
}
if (pthread_mutex_unlock(&pipe_lock) != 0) {
perror("pthread_mutex_unlock (pipe_lock)");
g_pool.initialized = true;
}
void setup_filter(const char *filter, int *write_to, int *read_from, filter_handle *handle, unsigned z, unsigned x, unsigned y) {
// Send the request to a worker process which will fork+exec the shell
// child and send back the input/output pipe fds via SCM_RIGHTS. The
// fork happens entirely inside the single-threaded worker, so the
// parent's tiling threads never have to fork.
size_t idx = acquire_worker();
int sock = g_pool.workers[idx].sock;
uint32_t flen = (uint32_t) strlen(filter);
if (write_fully(sock, &flen, sizeof(flen)) < 0 ||
(flen > 0 && write_fully(sock, filter, flen) < 0)) {
perror("write filter request");
exit(EXIT_PTHREAD);
}
uint32_t coords[3] = {(uint32_t) z, (uint32_t) x, (uint32_t) y};
if (write_fully(sock, coords, sizeof(coords)) < 0) {
perror("write filter coords");
exit(EXIT_PTHREAD);
}
*write_to = pipe_orig[1];
*read_from = pipe_filtered[0];
int wfd = -1, rfd = -1;
ssize_t r = recv_two_fds(sock, &wfd, &rfd);
if (r <= 0) {
fprintf(stderr, "Failed to receive filter pipe fds from worker\n");
exit(EXIT_PTHREAD);
}
if (fcntl(wfd, F_SETFD, FD_CLOEXEC) != 0) {
perror("cloexec output to filter");
exit(EXIT_CLOSE);
}
if (fcntl(rfd, F_SETFD, FD_CLOEXEC) != 0) {
perror("cloexec input from filter");
exit(EXIT_CLOSE);
}
*write_to = wfd;
*read_from = rfd;
handle->worker_idx = (ssize_t) idx;
}
void wait_filter(filter_handle handle) {
if (handle.worker_idx < 0) {
return;
}
int sock = g_pool.workers[handle.worker_idx].sock;
char done;
while (true) {
ssize_t r = read(sock, &done, 1);
if (r == 1) {
break;
}
if (r == 0) {
fprintf(stderr, "filter worker exited unexpectedly\n");
exit(EXIT_PTHREAD);
}
if (errno == EINTR) {
continue;
}
perror("read filter completion");
exit(EXIT_PTHREAD);
}
release_worker((size_t) handle.worker_idx);
}
std::vector<mvt_layer> filter_layers(const char *filter, std::vector<mvt_layer> &layers, unsigned z, unsigned x, unsigned y, std::vector<std::map<std::string, layermap_entry>> *layermaps, size_t tiling_seg, std::vector<std::vector<std::string>> *layer_unmaps, int extent) {
int write_to, read_from;
pid_t pid;
setup_filter(filter, &write_to, &read_from, &pid, z, x, y);
filter_handle handle;
setup_filter(filter, &write_to, &read_from, &handle, z, x, y);
writer_arg wa;
wa.write_to = write_to;
@@ -489,16 +822,7 @@ std::vector<mvt_layer> filter_layers(const char *filter, std::vector<mvt_layer>
std::vector<mvt_layer> nlayers = parse_layers(read_from, z, x, y, layermaps, tiling_seg, layer_unmaps, extent);
while (1) {
int stat_loc;
if (waitpid(pid, &stat_loc, 0) < 0) {
perror("waitpid for filter\n");
exit(EXIT_PTHREAD);
}
if (WIFEXITED(stat_loc) || WIFSIGNALED(stat_loc)) {
break;
}
}
wait_filter(handle);
void *ret;
if (pthread_join(writer, &ret) != 0) {
+14 -1
View File
@@ -1,4 +1,17 @@
struct key_pool;
// Identifies which worker process is currently running a filter on this
// thread's behalf. Returned by setup_filter and consumed by wait_filter.
struct filter_handle {
ssize_t worker_idx = -1;
};
std::vector<mvt_layer> filter_layers(const char *filter, std::vector<mvt_layer> &layer, unsigned z, unsigned x, unsigned y, std::vector<std::map<std::string, layermap_entry>> *layermaps, size_t tiling_seg, std::vector<std::vector<std::string>> *layer_unmaps, int extent);
void setup_filter(const char *filter, int *write_to, int *read_from, pid_t *pid, unsigned z, unsigned x, unsigned y);
void setup_filter(const char *filter, int *write_to, int *read_from, filter_handle *handle, unsigned z, unsigned x, unsigned y);
void wait_filter(filter_handle handle);
serial_feature parse_feature(json_pull *jp, int z, unsigned x, unsigned y, std::vector<std::map<std::string, layermap_entry>> *layermaps, size_t tiling_seg, std::vector<std::vector<std::string>> *layer_unmaps, bool filters, key_pool &key_pool);
// Pre-fork the pool of single-threaded filter worker processes used by
// setup_filter / wait_filter. Must be called from a single-threaded context
// (i.e. before any reader or tiling threads are spawned).
void filter_workers_init(size_t n);
+3 -12
View File
@@ -1770,7 +1770,7 @@ long long write_tile(decompressor *geoms, std::atomic<long long> *geompos_in, ch
}
int prefilter_write = -1, prefilter_read = -1;
pid_t prefilter_pid = 0;
filter_handle prefilter_handle;
FILE *prefilter_fp = NULL;
pthread_t prefilter_writer;
run_prefilter_args rpa; // here so it stays in scope until joined
@@ -1783,7 +1783,7 @@ long long write_tile(decompressor *geoms, std::atomic<long long> *geompos_in, ch
}
if (prefilter != NULL) {
setup_filter(prefilter, &prefilter_write, &prefilter_read, &prefilter_pid, z, tx, ty);
setup_filter(prefilter, &prefilter_write, &prefilter_read, &prefilter_handle, z, tx, ty);
prefilter_fp = fdopen(prefilter_write, "w");
if (prefilter_fp == NULL) {
perror("freopen prefilter");
@@ -2294,16 +2294,7 @@ long long write_tile(decompressor *geoms, std::atomic<long long> *geompos_in, ch
perror("close output from prefilter");
exit(EXIT_CLOSE);
}
while (1) {
int stat_loc;
if (waitpid(prefilter_pid, &stat_loc, 0) < 0) {
perror("waitpid for prefilter\n");
exit(EXIT_PTHREAD);
}
if (WIFEXITED(stat_loc) || WIFSIGNALED(stat_loc)) {
break;
}
}
wait_filter(prefilter_handle);
void *ret;
if (pthread_join(prefilter_writer, &ret) != 0) {
perror("pthread_join prefilter writer");