Skip to content

mr-vaibh/park

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

23 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

park

Go Report Card CI Latest Release License: MIT

Pause a process AND release its TCP port. Resume both later, picking up exactly where you left off.

Think Ctrl+Z for servers — but unlike Ctrl+Z, the port actually frees so other things can use it while the process is parked.

The problem

You're running npm run dev on :3000. You need :3000 for thirty seconds to test something else. Today, your only option is to kill the dev server, losing its in-memory state, build watcher cache, websocket clients, and warm-up time. When you bring it back, everything has to rebuild.

park lets you do this instead:

$ park 3000
parked pid 84211 (was listening on :3000)
  cmdline: node /usr/local/bin/npm run dev
  resume with: park resume 3000

$ # do whatever you need with :3000
$ python -m http.server 3000
...

$ park resume 3000
resumed pid 84211 on :3000

Same PID. Same memory. Same open files. Same websocket clients (well, the ones that didn't time out). The dev server has no idea anything happened.

How it works

The trick is ptrace + syscall injection. Park does not modify, recompile, restart, LD_PRELOAD, or instrument the target process in any way. It works on npm run dev, python manage.py runserver, cargo run, a random Java binary, anything.

When you run park 3000:

  1. Find the PID listening on :3000 by reading /proc/net/tcp and walking /proc/*/fd/* for the matching socket inode.
  2. PTRACE_ATTACH to the process and snapshot its registers.
  3. Find a few bytes of executable scratch space (the [vdso] page is always mapped and convenient), save the original bytes, and overwrite with the x86_64 sequence 0F 05 CC — that's syscall; int3.
  4. Set RAX=3 (close), RDI=<the listening fd>, RIP=<scratch>, then PTRACE_CONT. The target executes one syscall and immediately traps on the int3. The kernel removes the socket from the listen table — the port is now free.
  5. Restore registers and original bytes.
  6. PTRACE_DETACH and send SIGSTOP. The process is frozen. Save state to ~/.park/<pid>.json.

park resume 3000 reverses it: attach, inject socket() + setsockopt() + bind() + listen() (same address, same options), dup2() the new fd onto the original fd number so the application's stored fd is still valid, detach, SIGCONT. Done.

This is the load-bearing piece of the project. It lives in internal/ptrace/ptrace_linux_amd64.go. The rest is plumbing.

Why not just SIGSTOP?

kill -STOP freezes the process but does not free the port. The kernel still considers the listen socket bound, so anything else trying to bind to :3000 gets EADDRINUSE. Worse, incoming SYN packets queue up against the frozen socket and either time out or get RST'd, depending on backlog.

park works because closing the file descriptor — from inside the target process's own context — is what tells the kernel to actually release the listening socket. Anything short of that leaves the port owned by a corpse.

Install

One-liner (downloads the latest release binary, falls back to go install if your platform doesn't have a prebuilt yet):

curl -fsSL https://raw.githubusercontent.com/mr-vaibh/park/main/install.sh | sh

The script puts park in /usr/local/bin (use --bin-dir ~/.local/bin if you don't want sudo). Once it's done:

park --help

Or via Go:

go install github.com/mr-vaibh/park/cmd/park@latest
# you'll need $(go env GOPATH)/bin on your PATH

Or from source:

git clone https://github.com/mr-vaibh/park
cd park
go build -o park ./cmd/park

Single static binary, no runtime dependencies.

Linux one-time setup so park can attach to non-descendant processes:

echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope

(Or persist it: echo 'kernel.yama.ptrace_scope = 0' | sudo tee /etc/sysctl.d/10-ptrace.conf && sudo sysctl --system.)

macOS one-time setup — task_for_pid() needs the cs.debugger entitlement. Build park yourself and ad-hoc sign it:

git clone https://github.com/mr-vaibh/park
cd park
make build-darwin
make sign       # ad-hoc codesign with the debugger entitlement
sudo mv park /usr/local/bin/

The signing uses ad-hoc identity (no Apple Developer account needed) — it just adds the entitlement that lets task_for_pid succeed on processes you own.

Commands

park <port>                         freeze the listener on <port> and release the port
park resume <port|pid>              resume a parked process and rebind its port
park list                           show parked processes
park release <pid>                  wake without rebinding (process runs, listener gone)
park release --kill <pid>           kill the parked process (SIGTERM)
park release --kill --force <pid>   kill with SIGKILL (unblockable)

park resume accepts either the port number or the PID. State is keyed by PID under the hood, but ports are easier to remember.

Known limits

  • Linux x86_64 is the fully-supported target. Both park and park resume work, end-to-end, with a green CI integration test.
  • macOS arm64 (Apple Silicon) works for asyncio-style frameworks via a CGO Mach injection layer (internal/ptrace/ptrace_darwin_arm64.go). Verified working: python -m http.server, uvicorn / FastAPI, Flask development server, anything that uses select/poll/kqueue with fd-based event lookup. The full park → release port → resume cycle preserves the target's PID, memory, and warm state.
  • macOS arm64 does NOT yet work for Go's net/http or other runtimes that look up kqueue events by udata (a runtime-internal pointer we can't recover). Park reports success and the port frees, but the Go server's accept loop won't pick up new connections after resume. For Go on macOS, use park release <pid> to clean up rather than park resume.
  • Linux x86_64 only for the live ptrace path on Linux. Linux/arm64 needs an arch-specific syscall stub; patches welcome. See internal/ptrace/ptrace_linux_other.go.
  • macOS amd64 (Intel Macs) is stubbed. Same shape as the arm64 path but with x86_64 syscall conventions; patches welcome.

How macOS support works (technical)

macOS support is genuinely different from the Linux ptrace path because Mach lacks Linux's clean "stop a thread, modify it, restart it" model. park's macOS implementation:

  1. Doesn't hijack any of the target's existing threads. Instead it creates a brand-new injection thread inside the target via thread_create_running() and runs the syscall stub on that thread. The original threads are stopped at the thread level (thread_suspend) so they don't race with us, then resumed verbatim when we're done. They're never inspected or modified — no register state to save, no PC to restore, no kernel in-syscall flag to corrupt.
  2. Re-registers the listener with kqueue on resume. When park closes the listening fd, the kernel removes any kqueue registrations referencing it. On resume, after socket+bind+listen recreates the fd at the same number, park injects a kevent() syscall to re-register the new listener with each kqueue the target was holding.
  3. Sets the listener non-blocking. Async loops require O_NONBLOCK on the listening fd; the freshly-created socket is blocking by default, so park injects fcntl(F_SETFL, O_NONBLOCK) before resuming.
  • Pending accept-queue connections are dropped. When park closes the listening fd, any TCP connections already in the kernel's accept queue (handshake done, waiting for accept()) are RST'd. This is inherent to the close-to-free-port design — the port can't be freed without closing the socket, and closing the socket drops queued connections.
  • TCP listeners only. UDP sockets, Unix domain sockets, and connected TCP sockets are out of scope. The infrastructure is generic enough to extend, but the use case isn't.
  • Socket option introspection is best-effort. v1 assumes SO_REUSEADDR=1 on resume. If your server needs an exotic combination (TCP_FASTOPEN, custom SO_LINGER, etc.), the resumed listener won't have it. Reading these out of the original fd before close requires getsockopt injection; added in v1.5.
  • Multi-listener processes: if a process listens on more than one port, parking one of them will close that one fd; the others keep working. That's the right behavior, but park list will only show the parked port.
  • PID reuse. State files are keyed by PID. We snapshot /proc/<pid>/stat start time so a future park resume could refuse to touch a recycled PID, but the check isn't enforced yet.
  • Permissions. Park needs PTRACE_ATTACH rights on the target. On modern distros that means either matching UID and yama.ptrace_scope <= 1, or CAP_SYS_PTRACE. Park gives you the exact fix in the error message when it can't attach.

Safety

  • Refuses pid 0, pid 1, and known init/sshd/login process names even if you own them.
  • Refuses processes owned by another UID.
  • Restores the target's registers and the bytes it overwrote in the [vdso] page on every detach, even on error paths. The target should be unable to tell it was ptraced.
  • All state is per-user under ~/.park/. No daemon, no root, no setuid.

Benchmarks

park resume brings a server back in under 50ms — no matter how long the original cold start takes. The entire park/resume cycle is dominated by kernel overhead (ptrace attach, socket syscalls), not application startup.

Resume vs cold start

Framework Cold start park resume Speedup
Next.js (npm run dev) 5-15s ~40ms 125-375x
Vite (npm run dev) 1-3s ~40ms 25-75x
Django (manage.py runserver) 2-4s ~40ms 50-100x
Flask (flask run) 0.5-1s ~40ms 12-25x
FastAPI (uvicorn) 1-2s ~40ms 25-50x
Go net/http 0.1-0.5s ~40ms 2-12x

Cold start times include module loading, template compilation, build watchers, and warm-up. park resume skips all of that — same PID, same memory, same warm caches.

Internal benchmarks

$ go test -bench=. -benchmem ./...

BenchmarkSave              9,477    119,990 ns/op    2,164 B/op    17 allocs/op
BenchmarkLoadByPID        94,096     12,039 ns/op    1,864 B/op    16 allocs/op
BenchmarkEncodeSockaddr   51,018,832    23 ns/op       16 B/op     1 allocs/op
BenchmarkParseProcNet      6,832,135   176 ns/op      320 B/op     2 allocs/op

State operations (save/load) take microseconds. Sockaddr encoding (the hot path during syscall injection) takes 23 nanoseconds.

Tests

# unit tests + benchmarks
go test -race ./...
go test -bench=. -benchmem ./...

# coverage
go test -cover ./...

# integration test (Linux only, needs ptrace permission)
go test -tags=integration ./cmd/park/...

Unit tests cover all parsing logic, state persistence, and sockaddr encoding. The integration test spawns a tiny HTTP server, parks it, asserts the port is genuinely free by binding something else to it, resumes the server, and asserts it serves traffic again — same PID throughout.

Commands (v2)

All of the following are shipped in v1.0:

park <port> --for <duration>

Auto-resume after a timer. Parks the process, then spawns a background job that sleeps and resumes automatically:

park 3000 --for 30s    # frees :3000, resumes after 30 seconds
park 8080 --for 5m     # frees :8080, resumes after 5 minutes

park <port> --hold

Accept TCP connections on the freed port while parked. Clients don't get CONNECTION_REFUSED — they connect, wait, and when you resume (or Ctrl+C the hold), the real server picks back up:

park 3000 --hold       # frees :3000, accepts connections in foreground
                       # Ctrl+C or `park resume 3000` to stop holding

park swap <port1> <port2>

Atomically swap which processes own two ports:

park swap 3000 4000    # process A (was :3000) now on :4000
                       # process B (was :4000) now on :3000

park ui

Live TUI dashboard showing all parked processes, auto-refreshing:

park ui                # Ctrl+C to exit
  • Linux/arm64 and macOS support.
  • getsockopt introspection so resume preserves every socket option.

Layout

cmd/park/                     CLI entry point and subcommands
internal/ptrace/              Platform-specific syscall injection
  ptrace.go                     Cross-platform Session interface
  ptrace_linux_amd64.go         The real implementation (~300 LoC)
  ptrace_linux_other.go         Other Linux arches: clear "not yet" error
  ptrace_darwin.go              macOS: clear "not yet" error
internal/portfind/            Find PID/fd owning a TCP port
internal/procinfo/            Cmdline / cwd / safety checks
internal/state/               ~/.park/<pid>.json persistence

About

Pause a process and free its TCP port. Resume later, same PID and memory intact. Ctrl+Z for servers.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors