diff --git a/src/Makefile b/src/Makefile
index 1a02cef8cb28aafa74abc63f76a47b8e18961e9f..8314f48af7a32dc1e599172d5ce2cd5fda22ecab 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -24,7 +24,7 @@ libapk.so.$(libapk_soname)-objs := \
 	adb.o adb_comp.o adb_walk_adb.o adb_walk_genadb.o adb_walk_gentext.o adb_walk_text.o apk_adb.o \
 	atom.o balloc.o blob.o commit.o common.o context.o crypto.o crypto_$(CRYPTO).o ctype.o \
 	database.o hash.o extract_v2.o extract_v3.o fs_fsys.o fs_uvol.o \
-	io.o io_gunzip.o io_url_$(URL_BACKEND).o tar.o package.o pathbuilder.o print.o solver.o \
+	io.o io_gunzip.o io_url_$(URL_BACKEND).o tar.o package.o pathbuilder.o print.o process.o solver.o \
 	trust.o version.o
 
 ifneq ($(URL_BACKEND),wget)
diff --git a/src/apk_process.h b/src/apk_process.h
new file mode 100644
index 0000000000000000000000000000000000000000..5f04c6c92dba0e85eba2097237608347df1b7fe8
--- /dev/null
+++ b/src/apk_process.h
@@ -0,0 +1,38 @@
+/* apk_process.h - Alpine Package Keeper (APK)
+ *
+ * Copyright (C) 2008-2024 Timo Teräs <timo.teras@iki.fi>
+ * All rights reserved.
+ *
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#ifndef APK_PROCESS_H
+#define APK_PROCESS_H
+
+#include <sys/types.h>
+#include "apk_defines.h"
+#include "apk_blob.h"
+
+struct apk_out;
+struct apk_istream;
+
+struct apk_process {
+	int pipe_stdin[2], pipe_stdout[2], pipe_stderr[2];
+	pid_t pid;
+	const char *argv0;
+	struct apk_out *out;
+	struct apk_istream *is;
+	apk_blob_t is_blob;
+	unsigned int is_eof : 1;
+	struct buf {
+		uint16_t len;
+		uint8_t buf[1022];
+	} buf_stdout, buf_stderr;
+};
+
+int apk_process_init(struct apk_process *p, const char *argv0, struct apk_out *out, struct apk_istream *is);
+pid_t apk_process_fork(struct apk_process *p);
+int apk_process_run(struct apk_process *p);
+int apk_process_cleanup(struct apk_process *p);
+
+#endif
diff --git a/src/meson.build b/src/meson.build
index 6bd99e0ee7ac2a8c15aca6dbe7b73f51a1345ebb..b9445836c49dcd4ba81e80314291a3fb5ad28009 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -31,6 +31,7 @@ libapk_src = [
 	'package.c',
 	'pathbuilder.c',
 	'print.c',
+	'process.c',
 	'solver.c',
 	'tar.c',
 	'trust.c',
diff --git a/src/process.c b/src/process.c
new file mode 100644
index 0000000000000000000000000000000000000000..4a3d9381ccaf0e2bc9bcc6d52bc82a63e673152b
--- /dev/null
+++ b/src/process.c
@@ -0,0 +1,169 @@
+/* pid.c - Alpine Package Keeper (APK)
+ *
+ * Copyright (C) 2024 Timo Teräs <timo.teras@iki.fi>
+ * All rights reserved.
+ *
+ * SPDX-License-Identifier: GPL-2.0-only
+ */
+
+#include <poll.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <unistd.h>
+#include <sys/wait.h>
+
+#include "apk_io.h"
+#include "apk_process.h"
+#include "apk_print.h"
+
+static void close_fd(int *fd)
+{
+	if (*fd <= 0) return;
+	close(*fd);
+	*fd = -1;
+}
+
+static void set_non_blocking(int fd)
+{
+	if (fd >= 0) fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);
+}
+
+int apk_process_init(struct apk_process *p, const char *argv0, struct apk_out *out, struct apk_istream *is)
+{
+	*p = (struct apk_process) {
+		.argv0 = argv0,
+		.is = is,
+		.out = out,
+	};
+	if (IS_ERR(is)) return -PTR_ERR(is);
+
+	if (is) pipe2(p->pipe_stdin, O_CLOEXEC);
+	else {
+		p->pipe_stdin[0] = open("/dev/null", O_RDONLY);
+		p->pipe_stdin[1] = -1;
+	}
+
+	pipe2(p->pipe_stdout, O_CLOEXEC);
+	pipe2(p->pipe_stderr, O_CLOEXEC);
+
+	set_non_blocking(p->pipe_stdin[1]);
+	set_non_blocking(p->pipe_stdout[0]);
+	set_non_blocking(p->pipe_stderr[0]);
+
+	return 0;
+}
+
+static int buf_process(struct buf *b, int fd, struct apk_out *out, const char *prefix, const char *argv0)
+{
+	ssize_t n = read(fd, &b->buf[b->len], sizeof b->buf - b->len);
+	if (n <= 0) {
+		if (b->len) {
+			apk_out_fmt(out, prefix, "%s: %.*s", argv0, (int)b->len, b->buf);
+			b->len = 0;
+		}
+		return 0;
+	}
+
+	b->len += n;
+
+	uint8_t *pos, *lf, *end = &b->buf[b->len];
+	for (pos = b->buf; (lf = memchr(pos, '\n', end - pos)) != NULL; pos = lf + 1) {
+		apk_out_fmt(out, prefix, "%s: %.*s", argv0, (int)(lf - pos), pos);
+	}
+
+	b->len = end - pos;
+	memmove(b->buf, pos, b->len);
+	return 1;
+}
+
+pid_t apk_process_fork(struct apk_process *p)
+{
+	pid_t pid = fork();
+	if (pid < 0) return pid;
+	if (pid == 0) {
+		dup2(p->pipe_stdin[0], STDIN_FILENO);
+		dup2(p->pipe_stdout[1], STDOUT_FILENO);
+		dup2(p->pipe_stderr[1], STDERR_FILENO);
+		close_fd(&p->pipe_stdin[1]);
+		close_fd(&p->pipe_stdout[0]);
+		close_fd(&p->pipe_stderr[0]);
+		return pid;
+	} else {
+		p->pid = pid;
+	}
+	close_fd(&p->pipe_stdin[0]);
+	close_fd(&p->pipe_stdout[1]);
+	close_fd(&p->pipe_stderr[1]);
+	return pid;
+}
+
+int apk_process_run(struct apk_process *p)
+{
+	struct pollfd fds[3] = {
+		{ .fd = p->pipe_stdout[0], .events = POLLIN },
+		{ .fd = p->pipe_stderr[0], .events = POLLIN },
+		{ .fd = p->pipe_stdin[1],  .events = POLLOUT },
+	};
+
+	while (fds[0].fd >= 0 || fds[1].fd >= 0 || fds[2].fd >= 0) {
+		if (poll(fds, ARRAY_SIZE(fds), -1) <= 0) continue;
+		if (fds[0].revents) {
+			if (!buf_process(&p->buf_stdout, p->pipe_stdout[0], p->out, NULL, p->argv0)) {
+				fds[0].fd = -1;
+				close_fd(&p->pipe_stdout[0]);
+			}
+		}
+		if (fds[1].revents) {
+			if (!buf_process(&p->buf_stderr, p->pipe_stderr[0], p->out, "", p->argv0)) {
+				fds[1].fd = -1;
+				close_fd(&p->pipe_stderr[0]);
+			}
+		}
+		if (fds[2].revents == POLLOUT) {
+			if (!p->is_blob.len) {
+				switch (apk_istream_get_all(p->is, &p->is_blob)) {
+				case 0:
+					break;
+				case -APKE_EOF:
+					p->is_eof = 1;
+					goto stdin_close;
+				default:
+					goto stdin_close;
+				}
+			}
+			int n = write(p->pipe_stdin[1], p->is_blob.ptr, p->is_blob.len);
+			if (n < 0) {
+				if (errno == EWOULDBLOCK) break;
+				goto stdin_close;
+			}
+			p->is_blob.ptr += n;
+			p->is_blob.len -= n;
+		}
+		if (fds[2].revents & POLLERR) {
+		stdin_close:
+			close_fd(&p->pipe_stdin[1]);
+			fds[2].fd = -1;
+		}
+	}
+	return apk_process_cleanup(p);
+}
+
+int apk_process_cleanup(struct apk_process *p)
+{
+	char buf[APK_EXIT_STATUS_MAX_SIZE];
+	int status = 0;
+
+	if (p->is) apk_istream_close(p->is);
+	close_fd(&p->pipe_stdin[1]);
+	close_fd(&p->pipe_stdout[0]);
+	close_fd(&p->pipe_stderr[0]);
+
+	while (waitpid(p->pid, &status, 0) < 0 && errno == EINTR);
+
+	if (apk_exit_status_str(status, buf, sizeof buf)) {
+		apk_err(p->out, "%s: %s", p->argv0, buf);
+		return -1;
+	}
+	if (p->is && !p->is_eof) return -2;
+	return 0;
+}
diff --git a/test/unit/main.c b/test/unit/main.c
index 29ebc2620f44195a62efe67f722a5c8e1494d879..d250edab1df77fdac37a5d861246a1506043e7d4 100644
--- a/test/unit/main.c
+++ b/test/unit/main.c
@@ -1,3 +1,4 @@
+#include <signal.h>
 #include "apk_test.h"
 
 static int num_tests;
@@ -13,5 +14,6 @@ void test_register(const char *name, UnitTestFunction f)
 
 int main(void)
 {
+	signal(SIGPIPE, SIG_IGN);
 	return _cmocka_run_group_tests("unit_tests", all_tests, num_tests, NULL, NULL);
 }
diff --git a/test/unit/meson.build b/test/unit/meson.build
index bc5e00d1903bba557b555daf1804c29bb3b0cc7e..f7fc3863163d2edc79e862bcdc3511f89219a1fa 100644
--- a/test/unit/meson.build
+++ b/test/unit/meson.build
@@ -5,6 +5,7 @@ if cmocka_dep.found()
 unit_test_src = [
 	'blob_test.c',
 	'package_test.c',
+	'process_test.c',
 	'version_test.c',
 	'main.c'
 ]
diff --git a/test/unit/process_test.c b/test/unit/process_test.c
new file mode 100644
index 0000000000000000000000000000000000000000..4c9a638c9fa1c8a5ea3bc35ce008bcf4a3d38dee
--- /dev/null
+++ b/test/unit/process_test.c
@@ -0,0 +1,130 @@
+#include <stdio.h>
+#include <stdlib.h>
+#include <unistd.h>
+
+#include "apk_test.h"
+#include "apk_print.h"
+#include "apk_process.h"
+#include "apk_io.h"
+
+#define writestr(fd, str) write(fd, str, sizeof(str)-1)
+
+struct cached_out {
+	struct apk_out out;
+	char buf_err[256], buf_out[256];
+};
+
+static void open_out(struct cached_out *co)
+{
+	co->out = (struct apk_out) {
+		.out = fmemopen(co->buf_out, sizeof co->buf_out, "w"),
+		.err = fmemopen(co->buf_err, sizeof co->buf_err, "w"),
+	};
+	assert_non_null(co->out.out);
+	assert_non_null(co->out.err);
+}
+
+static void assert_output_equal(struct cached_out *co, const char *expected_err, const char *expected_out)
+{
+	fputc(0, co->out.out);
+	fclose(co->out.out);
+	fputc(0, co->out.err);
+	fclose(co->out.err);
+
+	assert_string_equal(co->buf_err, expected_err);
+	assert_string_equal(co->buf_out, expected_out);
+}
+
+APK_TEST(pid_logging) {
+	struct cached_out co;
+	struct apk_process p;
+
+	open_out(&co);
+	assert_int_equal(0, apk_process_init(&p, "test0", &co.out, NULL));
+	if (apk_process_fork(&p) == 0) {
+		writestr(STDERR_FILENO, "error1\nerror2\n");
+		writestr(STDOUT_FILENO, "hello1\nhello2\n");
+		close(STDOUT_FILENO);
+		usleep(10000);
+		writestr(STDERR_FILENO, "more\nlastline");
+		exit(0);
+	}
+
+	assert_int_equal(0, apk_process_run(&p));
+	assert_output_equal(&co,
+		"test0: error1\n"
+		"test0: error2\n"
+		"test0: more\n"
+		"test0: lastline\n",
+
+		"test0: hello1\n"
+		"test0: hello2\n");
+}
+
+APK_TEST(pid_error_exit) {
+	struct cached_out co;
+	struct apk_process p;
+
+	open_out(&co);
+	assert_int_equal(0, apk_process_init(&p, "test1", &co.out, NULL));
+	if (apk_process_fork(&p) == 0) {
+		exit(100);
+	}
+
+	assert_int_equal(-1, apk_process_run(&p));
+	assert_output_equal(&co,
+		"ERROR: test1: exited with error 100\n",
+		"");
+}
+
+APK_TEST(pid_input_partial) {
+	struct cached_out co;
+	struct apk_process p;
+
+	open_out(&co);
+	assert_int_equal(0, apk_process_init(&p, "test2", &co.out, apk_istream_from_file(AT_FDCWD, "/dev/zero")));
+	if (apk_process_fork(&p) == 0) {
+		char buf[1024];
+		int left = 128*1024;
+		while (left) {
+			int n = read(STDIN_FILENO, buf, min(left, sizeof buf));
+			if (n <= 0) exit(100);
+			left -= n;
+		}
+		writestr(STDOUT_FILENO, "success\n");
+		exit(0);
+	}
+
+	assert_int_equal(-2, apk_process_run(&p));
+	assert_output_equal(&co,
+		"",
+		"test2: success\n");
+}
+
+APK_TEST(pid_input_full) {
+	struct cached_out co;
+	struct apk_process p;
+
+	open_out(&co);
+	assert_int_equal(0, apk_process_init(&p, "test3", &co.out, apk_istream_from_file(AT_FDCWD, "version.data")));
+	if (apk_process_fork(&p) == 0) {
+		char buf[1024];
+		writestr(STDOUT_FILENO, "start reading!\n");
+		usleep(10000);
+		while (1) {
+			int n = read(STDIN_FILENO, buf, sizeof buf);
+			if (n < 0) exit(100);
+			if (n == 0) break;
+		}
+		writestr(STDOUT_FILENO, "success\n");
+		exit(0);
+	}
+
+	assert_int_equal(0, apk_process_run(&p));
+	assert_output_equal(&co,
+		"",
+		"test3: start reading!\n"
+		"test3: success\n");
+}
+
+// FIXME: add test for subprocess _istream