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.
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.
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:
- Find the PID listening on
:3000by reading/proc/net/tcpand walking/proc/*/fd/*for the matching socket inode. PTRACE_ATTACHto the process and snapshot its registers.- 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 sequence0F 05 CC— that'ssyscall; int3. - Set
RAX=3(close),RDI=<the listening fd>,RIP=<scratch>, thenPTRACE_CONT. The target executes one syscall and immediately traps on theint3. The kernel removes the socket from the listen table — the port is now free. - Restore registers and original bytes.
PTRACE_DETACHand sendSIGSTOP. 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.
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.
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 | shThe script puts park in /usr/local/bin (use --bin-dir ~/.local/bin if you don't want sudo). Once it's done:
park --helpOr via Go:
go install github.com/mr-vaibh/park/cmd/park@latest
# you'll need $(go env GOPATH)/bin on your PATHOr from source:
git clone https://github.com/mr-vaibh/park
cd park
go build -o park ./cmd/parkSingle 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.
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.
- Linux x86_64 is the fully-supported target. Both
parkandpark resumework, 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 usesselect/poll/kqueuewith 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/httpor other runtimes that look up kqueue events byudata(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, usepark release <pid>to clean up rather thanpark 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.
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:
- 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. - 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+listenrecreates the fd at the same number, park injects akevent()syscall to re-register the new listener with each kqueue the target was holding. - Sets the listener non-blocking. Async loops require
O_NONBLOCKon the listening fd; the freshly-created socket is blocking by default, so park injectsfcntl(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=1on resume. If your server needs an exotic combination (TCP_FASTOPEN, customSO_LINGER, etc.), the resumed listener won't have it. Reading these out of the original fd before close requiresgetsockoptinjection; 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 listwill only show the parked port. - PID reuse. State files are keyed by PID. We snapshot
/proc/<pid>/statstart time so a futurepark resumecould refuse to touch a recycled PID, but the check isn't enforced yet. - Permissions. Park needs
PTRACE_ATTACHrights on the target. On modern distros that means either matching UID andyama.ptrace_scope <= 1, orCAP_SYS_PTRACE. Park gives you the exact fix in the error message when it can't attach.
- 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.
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.
| 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.
$ 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.
# 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.
All of the following are shipped in v1.0:
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
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
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
Live TUI dashboard showing all parked processes, auto-refreshing:
park ui # Ctrl+C to exit
- Linux/arm64 and macOS support.
getsockoptintrospection so resume preserves every socket option.
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