Skip to content

feat: UNIX socket control plane for porter daemon - #12

Merged
dougEfresh merged 2 commits into
mainfrom
socket-bug
Jun 4, 2026
Merged

feat: UNIX socket control plane for porter daemon#12
dougEfresh merged 2 commits into
mainfrom
socket-bug

Conversation

@dougEfresh

Copy link
Copy Markdown
Contributor

Summary

Adds a REST API over a UNIX domain socket for runtime management of the porter daemon: scheduled task CRUD, daemon health, and worker pool observability.

API endpoints

GET  /api/health
GET  /api/scheduled-tasks
GET  /api/scheduled-tasks/:id
POST /api/scheduled-tasks
DELETE /api/scheduled-tasks/:id
POST /api/scheduled-tasks/:id/pause
POST /api/scheduled-tasks/:id/resume
POST /api/scheduled-tasks/:id/fire
GET  /api/scheduled-tasks/:id/runs
GET  /api/workers

CLI

porter task list|get|create|delete|pause|resume|fire|runs
porter status
porter help

Socket lifecycle

Systemd manages the socket directory via RuntimeDirectory=porter-%i. Each template
instance gets its own isolated directory:

porter@projects-me-pi-porter  →  /run/user/1000/porter-projects-me-pi-porter/porter.sock
porter-dev                    →  /run/user/1000/porter-dev/porter.sock

The daemon binds the socket directly with Bun.serve({ unix }).

Why not systemd socket activation?

Bun.serve({ fd }) was proposed in oven-sh/bun#2852
but the PR was closed without merging. Connections open successfully on the
passed fd but Bun never dispatches to the HTTP handler.

Design decisions

  • No edit/update endpoint: delete + recreate for changes, no nextRun ambiguity
  • Hooks stay strings: KISS, no arrays or env maps
  • Session auto-create: POST creates session rows so FK constraints pass
  • PORTER_SOCKET env var: set by the systemd service unit, consumed by both daemon and CLI
  • Limit clamping: ?limit on runs endpoint capped at 500, rejects negatives

Bun.serve({fd}) was proposed in oven-sh/bun#2852 but never merged.
Connections open but Bun never dispatches to the handler when using fd.

Switch to direct UNIX socket binding with Bun.serve({unix}).
Systemd manages directory lifecycle via RuntimeDirectory=porter-%i,
giving each template instance its own isolated socket directory.

Also: use unified fetch handler instead of routes (avoids TS fd+routes
type incompatibility), clamp runs limit to 500, and prefer PORTER_SOCKET
env var for socket path resolution in the daemon.
@dougEfresh

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR migrates the control plane from systemd socket-activation (LISTEN_FDS) to direct UNIX socket binding with per-instance runtime directories. systemd units set PORTER_SOCKET to %t/porter-%i/porter.sock and create %t/porter-%i with RuntimeDirectory/RuntimeDirectoryMode. ControlServer.start() now computes the socket path, removes stale socket files, and calls Bun.serve({ unix: socketPath, routes }). Route handlers use BunRequest and read path params from req.params.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4a427414-8c26-417d-9e64-38c30060ad2f

📥 Commits

Reviewing files that changed from the base of the PR and between c8ccdd1 and 233c44c.

📒 Files selected for processing (4)
  • docs/control-plane.md
  • resources/systemd/porter-dev.service
  • resources/systemd/porter@.service
  • runtime/src/control-server.ts

Comment thread docs/control-plane.md
Comment thread runtime/src/control-server.ts Outdated
Comment on lines +96 to +98
if (id && url.pathname === `/api/scheduled-tasks/${id}/runs` && req.method === 'GET') {
const limit = Number.parseInt(url.searchParams.get('limit') ?? '50', 10);
return this.handleGetTaskRuns(id, limit);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reject negative limit values instead of normalizing them to the default.

The PR contract says negatives should be rejected, but limit=-1 currently falls through to handleGetTaskRuns() and comes back as 50. That makes bad input look valid and hides caller bugs.

Possible localized fix
       if (id && url.pathname === `/api/scheduled-tasks/${id}/runs` && req.method === 'GET') {
         const limit = Number.parseInt(url.searchParams.get('limit') ?? '50', 10);
+        if (Number.isFinite(limit) && limit < 0) {
+          return errorJson('limit must be non-negative', 400);
+        }
         return this.handleGetTaskRuns(id, limit);
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (id && url.pathname === `/api/scheduled-tasks/${id}/runs` && req.method === 'GET') {
const limit = Number.parseInt(url.searchParams.get('limit') ?? '50', 10);
return this.handleGetTaskRuns(id, limit);
if (id && url.pathname === `/api/scheduled-tasks/${id}/runs` && req.method === 'GET') {
const limit = Number.parseInt(url.searchParams.get('limit') ?? '50', 10);
if (Number.isFinite(limit) && limit < 0) {
return errorJson('limit must be non-negative', 400);
}
return this.handleGetTaskRuns(id, limit);

Comment on lines +112 to +119
const socketPath =
process.env.PORTER_SOCKET ||
(() => {
const runtimeDir = process.env.XDG_RUNTIME_DIR;
const stateDir = process.env.PORTER_STATE_DIR || `${process.env.HOME}/.local/state/porter`;
const base = runtimeDir ? `${runtimeDir}/porter` : stateDir;
return `${base}/porter.sock`;
})();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve the explicit unix override.

start() now ignores ControlServerOptions.unix and always recomputes the socket path from env/fallbacks. Any caller that passes a custom socket path will silently bind somewhere else.

Comment thread runtime/src/control-server.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
runtime/src/control-server.ts (2)

68-75: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Create the socket directory before binding.

The fallback branch builds a path under PORTER_STATE_DIR/HOME, but nothing creates that parent directory first. Outside the systemd RuntimeDirectory case, first boot turns into ENOENT theater instead of a control socket.

Possible localized fix
+import { mkdir } from 'node:fs/promises';
+import { dirname } from 'node:path';
+
   async start(): Promise<void> {
     const socketPath =
       process.env.PORTER_SOCKET ||
       (() => {
         const runtimeDir = process.env.XDG_RUNTIME_DIR;
         const stateDir = process.env.PORTER_STATE_DIR || `${process.env.HOME}/.local/state/porter`;
         const base = runtimeDir ? `${runtimeDir}/porter` : stateDir;
         return `${base}/porter.sock`;
       })();
+
+    await mkdir(dirname(socketPath), { recursive: true });
 
     try {
       await Bun.file(socketPath).delete();

As per coding guidelines: "Use Node node:fs only for directory operations like readdir/mkdir when not practical via Bun helpers".

Also applies to: 86-87


165-175: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject non-object JSON bodies before validation.

req.json() can legally give you null, and validateCreateTask(input) then dereferences input.id, so a bad client payload becomes a 500. Return 400 unless the parsed body is an object.

Possible localized fix
   private async handleCreateTask(req: BunRequest<'/api/scheduled-tasks'>): Promise<Response> {
     let body: unknown;
     try {
       body = await req.json();
     } catch {
       return errorJson('invalid JSON body', 400);
     }
+
+    if (!body || typeof body !== 'object' || Array.isArray(body)) {
+      return errorJson('JSON body must be an object', 400);
+    }
 
     const input = body as Record<string, unknown>;
     const validation = validateCreateTask(input);
     if (validation) return errorJson(validation, 400);

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4bb57266-1280-4336-b7af-015f43299fe8

📥 Commits

Reviewing files that changed from the base of the PR and between 233c44c and b631896.

📒 Files selected for processing (1)
  • runtime/src/control-server.ts

@dougefresher dougefresher deleted a comment from coderabbitai Bot Jun 3, 2026
@dougEfresh
dougEfresh merged commit 0645b86 into main Jun 4, 2026
3 checks passed
@dougEfresh
dougEfresh deleted the socket-bug branch June 4, 2026 09:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant