Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ service registry, no process management, no discovery — those belong to the ap
| `go test -fuzz FuzzReadFrame ./framing` | Fuzz the frame reader |
| `go vet ./...` | Static analysis |
| `gofmt -l .` | List unformatted files (must be empty) |
| `GOOS=darwin GOARCH=arm64 go build ./...` | Cross-compile macOS — mandatory before every commit |
| `GOOS=windows GOARCH=amd64 go build ./...` | Cross-compile Windows (from Phase 2) |

**Quality is local.** There is no test CI. `.github/workflows/` holds deploy and release
workflows only; running the gate is the author's job, on every platform the change touches.
Expand Down Expand Up @@ -64,7 +66,7 @@ These are not preferences. A change that breaks one is a defect, not a trade-off

## Tech Stack

- Go 1.24+, `CGO_ENABLED=0`
- Go 1.25+, `CGO_ENABLED=0`
- Standard library only on Unix; `github.com/Microsoft/go-winio` on Windows
- Apache-2.0

Expand Down
24 changes: 19 additions & 5 deletions CONTRIBUTE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,27 @@ Quality is local. `.github/workflows/` carries deploy and release workflows only
this repository verifies your change for you after you push.

```sh
gofmt -l . # must print nothing
go vet ./... # must be clean
go test -race ./... # must pass
gofmt -l . # must print nothing
go vet ./... # must be clean
go test -race ./... # must pass

GOOS=darwin GOARCH=arm64 go build ./... # cross-compile every supported platform
GOOS=darwin GOARCH=amd64 go build ./...
GOOS=windows GOARCH=amd64 go build ./... # from Phase 2 on
```

A change that touches platform-specific code must be run on that platform before merge. Claiming
"CI will catch it" is not available here, by design.
**Cross-compile every supported platform, always.** It needs no hardware, takes a second, and
catches the entire class of "this function does not exist on that OS" — which is exactly how a
`syscall.Getpeereid` that never existed reached `main` once. Compiling is not testing, but a
platform file that does not compile is not a testing gap, it is a broken build.

A change that touches platform-specific code should be run on that platform before merge.
Claiming "CI will catch it" is not available here, by design.

**Where you cannot run a platform, say so in the PR.** macOS is currently in that position: the
maintainer has no Mac, so the darwin path is cross-compiled and vetted but never executed. An
honest gap that is written down can be closed by someone who has the hardware; a gap that is
implied to be covered cannot.

## Before You Open a PR

Expand Down
2 changes: 1 addition & 1 deletion GUIDELINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Tech Stack

- Runtime: Go 1.24+, `CGO_ENABLED=0` for builds
- Runtime: Go 1.25+, `CGO_ENABLED=0` for builds
- Language: Go
- Dependencies: standard library only on Unix; `github.com/Microsoft/go-winio` on Windows
- License: Apache-2.0
Expand Down
32 changes: 30 additions & 2 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,36 @@
- Design carried over from `four-file-cloud/docs/interprocess-go-concept.md` at D5
(Implementable): six closed API decisions, conformance vectors V1-V4, per-phase acceptance
criteria.
- Phase 1 Unix core: `local_socket` package (`Name`, `NameKind`, `Filesystem`, `Namespaced`,
`UserScoped`, `Listen`, `Dial`, `ListenOptions`, `DialOptions`, `AccessPolicy`,
`PeerIdentity`, error sentinels) — Unix domain sockets on Linux and macOS, Decision 2
runtime-directory resolution with owner/mode validation, the safe six-step stale-socket
cleanup, and peer identity via `SO_PEERCRED` (Linux) / `getpeereid` (macOS) (#1).
- `examples/echo` demonstrating a local echo server and client (#1).

### Changed
- `ListenOptions.RemoveOnClose` replaced by `KeepOnClose` with inverted meaning (#1). The zero
value now releases the socket on `Close`, so a service restarts on default settings; the old
opt-in meant every caller had to set two options or hit `ErrAlreadyInUse` on restart. It also
matches Go (`net.UnixListener` unlinks a socket it created) and Rust `interprocess`, where name
release is part of local-socket semantics. `ReclaimStale` stays opt-in, deliberately: remove
what this process created, never touch what it did not.

### Fixed
- `peer_darwin.go` called `syscall.Getpeereid`, which does not exist in the standard library on
any platform — the macOS build never compiled (#1). Replaced with
`golang.org/x/sys/unix.GetsockoptXucred(fd, SOL_LOCAL, LOCAL_PEERCRED)`, the actual macOS
primitive; `struct xucred` carries no PID, so `PeerIdentity.PID` stays zero there as documented.
Adds `golang.org/x/sys` as the single dependency. Cross-compilation for every supported platform
is now part of the documented local gate, because it catches this whole class for free.
- Criterion 1.4 (`ErrNoRuntimeDir`) was unreachable: the test skipped itself whenever
`/run/user/$UID` existed, which is every systemd host (#1). The candidate chain is now injectable
and the test covers both an empty set and a set where every candidate fails validation, plus the
assertion that nothing is written into a rejected directory.

### Technical Details
- Module path: `github.com/four-bytes/interprocess-go`
- Go 1.24 minimum; standard library only on Unix, `Microsoft/go-winio` on Windows
- No code yet — Phase 1 (Unix core) is the first implementation issue
- Go 1.25 minimum (raised from 1.24: `golang.org/x/sys` v0.47.0 declares `go 1.25.0`, so a 1.24
toolchain cannot build this module); `golang.org/x/sys` on Darwin, `Microsoft/go-winio` on Windows
- Phase 1 (Unix core) implemented (#1); Windows (Phase 2), interop (Phase 3) and framing
(Phase 4) remain.
29 changes: 22 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,29 @@ remove the only access control this library has on Unix. See [`GUIDELINES.md`](G

## Platform support

| Platform | Transport | Status |
|---|---|---|
| Linux | Unix domain socket | Phase 1 |
| macOS | Unix domain socket | Phase 1 |
| Windows | Named pipe (`go-winio`) | Phase 2 |
| Platform | Transport | Status | Verification |
|---|---|---|---|
| Linux | Unix domain socket | Phase 1 | suite run with `-race` |
| macOS | Unix domain socket | Phase 1 | **compiles and vets only — runtime untested** |
| Windows | Named pipe (`go-winio`) | Phase 2 | — |

### macOS is untested at runtime

Be aware of this before depending on the macOS path. The code cross-compiles and vets cleanly for
`darwin/arm64` and `darwin/amd64`, and every cross-platform code path is covered by the suite on
Linux. What has never executed on a Mac:

- `peerCred` via `getsockopt(SOL_LOCAL, LOCAL_PEERCRED)` — the returned UID and GID are unverified
- the Darwin-only `$TMPDIR` step of the runtime-directory precedence
- socket and directory mode enforcement on APFS

The maintainer has no macOS hardware. This is stated rather than papered over: a green
cross-compile proves the code exists and type-checks, not that it behaves. Reports from macOS
users are welcome, and a verified run is what moves this row to parity with Linux.

Each platform is verified by running the suite on it. A change to platform-specific code is not
mergeable until it has been run there — see [`CONTRIBUTE.md`](CONTRIBUTE.md).
A change to platform-specific code should be run on that platform before merge — see
[`CONTRIBUTE.md`](CONTRIBUTE.md). Where that is impossible, say so in the PR rather than implying
coverage that does not exist.

## Framing

Expand Down
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ rather than an option. Small enough to audit in an afternoon.
- [ ] Stale-socket reclaim, restricted to owned sockets
- [ ] `PeerIdentity` via `SO_PEERCRED` / `getpeereid`
- [ ] `examples/echo`
- [ ] Suite verified locally on Linux and macOS with `-race`
- [x] Suite verified locally on Linux with `-race`
- [ ] macOS runtime verification — blocked, no hardware; cross-compile and vet are green

14 acceptance criteria — see `docs/TESTING.md`.

Expand Down
4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ type ListenOptions struct {
Access AccessPolicy
RuntimeDir string
ReclaimStale bool
RemoveOnClose bool
KeepOnClose bool
PipeSecurity *PipeSecurity
MaxInstances int
}
Expand Down Expand Up @@ -219,7 +219,7 @@ Before binding to a file socket:
5. Bind the UDS.
6. Set and verify restrictive mode bits.

On `Close()` the listener should remove its own socket file. Rust `interprocess` also treats automatic name release on listener drop as part of local socket semantics; the Go variant should satisfy that expectation with an explicit `Close()`.
On `Close()` the listener removes its own socket file by default (`KeepOnClose` opts out). Rust `interprocess` also treats automatic name release on listener drop as part of local socket semantics; the Go variant should satisfy that expectation with an explicit `Close()`.

### Windows Security

Expand Down
6 changes: 3 additions & 3 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,17 +125,17 @@ criterion.
| 1.1 | `Listen` and `Dial` work for `Filesystem`, `Namespaced` and `UserScoped` on Linux and macOS. |
| 1.2 | Name validation matches vector **V3** exactly, including the 200-character case and the Decision 6 truncation output. |
| 1.3 | Runtime directory resolution follows Decision 2 in order, and **every** candidate is rejected unless it exists, is a directory, is owned by the current UID, and has no group or world write bit. A test sets `$TMPDIR` to a `0777` directory on Linux and asserts it is skipped. |
| 1.4 | With no valid candidate, `Listen` fails with `ErrNoRuntimeDir` and creates nothing. |
| 1.4 | With no valid candidate, `Listen` fails with `ErrNoRuntimeDir` and creates nothing. The candidate chain is injected in the test — driving it through the environment leaves `/run/user/$UID` in the list on every systemd host, so the test would skip itself exactly where it matters. |
| 1.5 | After `Listen`, the runtime directory is mode `0700` and the socket file is mode `0600`, asserted by `os.Stat`. |
| 1.6 | Stale-socket reclaim follows the documented six steps. A test writes a **regular file** at the socket path and asserts `ErrStaleCleanupUnsafe` — only an owned socket is ever removed. |
| 1.7 | `Close()` removes the listener's own socket file; a second `Listen` on the same name then succeeds. |
| 1.7 | `Close()` removes the listener's own socket file **with no options set**; a second `Listen` on the same name then succeeds. `KeepOnClose` opts out. A default that cannot restart is a defect, not caution. |
| 1.8 | `Close()` causes a blocked `Accept()` to return, and the returned error satisfies `errors.Is(err, net.ErrClosed)`. |
| 1.9 | `Dial` honours context cancellation and `DialOptions.Timeout`; a cancelled dial returns promptly and leaks no goroutine (`goleak` or an equivalent check). |
| 1.10 | `PeerIdentity()` returns the correct UID and GID (`SO_PEERCRED` on Linux, `getpeereid` on macOS). |
| 1.11 | Echo across 64 concurrent clients, 1 MiB per client, with no data corruption and no race under `-race`. |
| 1.12 | Restart after a simulated crash (process killed, socket file left behind) succeeds. |
| 1.13 | `examples/echo` builds and runs on both platforms. |
| 1.14 | Suite verified locally on Linux and macOS with `-race` enabled. |
| 1.14 | Suite verified locally on Linux with `-race`. macOS: cross-compiled and vetted for `darwin/arm64` and `darwin/amd64`; **runtime behaviour unverified** — no macOS hardware available. See the platform matrix note below. |

### Phase 2: Windows

Expand Down
86 changes: 86 additions & 0 deletions examples/echo/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Four Bytes

// Command echo demonstrates interprocess-go/local_socket with a simple
// request/echo loop: a listener echoes every byte it receives back to the
// sender.
//
// go run ./examples/echo -mode server
// go run ./examples/echo -mode client
package main

import (
"context"
"flag"
"io"
"log"
"os"

localsocket "github.com/four-bytes/interprocess-go/local_socket"
)

func main() {
mode := flag.String("mode", "server", "server or client")
name := flag.String("name", "echo", "socket identifier (UserScoped)")
runtimeDir := flag.String("runtime-dir", "", "explicit runtime directory (server only)")
flag.Parse()

switch *mode {
case "server":
runServer(*name, *runtimeDir)
case "client":
runClient(*name)
default:
log.Fatalf("unknown mode %q (want server or client)", *mode)
}
}

func runServer(name, runtimeDir string) {
ln, err := localsocket.Listen(
localsocket.UserScoped(name),
localsocket.ListenOptions{RuntimeDir: runtimeDir, ReclaimStale: true},
)
if err != nil {
log.Fatal(err)
}
defer ln.Close()
log.Printf("listening on %s", ln.Addr())

for {
c, err := ln.Accept()
if err != nil {
log.Fatal(err)
}
go func(c io.ReadWriteCloser) {
defer c.Close()
if _, err := io.Copy(c, c); err != nil {
log.Printf("echo: %v", err)
}
}(c)
}
}

func runClient(name string) {
// The client resolves the name through the default runtime directory, so
// both sides meet on the same endpoint. For a custom server runtime
// directory, pass the exact path the server printed instead of a name.
c, err := localsocket.Dial(context.Background(), localsocket.UserScoped(name), localsocket.DialOptions{})
if err != nil {
log.Fatal(err)
}
defer c.Close()

msg := []byte("hello over a local socket\n")
if _, err := c.Write(msg); err != nil {
log.Fatal(err)
}
// Read exactly the echoed bytes back. The server keeps the connection open
// for further messages, so copying until EOF would block forever.
buf := make([]byte, len(msg))
if _, err := io.ReadFull(c, buf); err != nil {
log.Fatal(err)
}
if _, err := os.Stdout.Write(buf); err != nil {
log.Fatal(err)
}
}
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
module github.com/four-bytes/interprocess-go

go 1.24
go 1.25.0

require golang.org/x/sys v0.47.0
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
50 changes: 50 additions & 0 deletions local_socket/cleanup_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Four Bytes

//go:build unix

package localsocket

import (
"errors"
"io/fs"
"net"
"os"
"syscall"
"time"
)

// staleDialTimeout bounds the liveness probe when reclaiming a stale socket.
const staleDialTimeout = 200 * time.Millisecond

// prepareSocketPath implements steps 3 and 4 of the documented six-step
// cleanup: if an entry exists at the socket path, validate its type and owner,
// then remove only a socket file that is owned by us and provably stale. A
// regular file, directory, symlink or foreign-owned socket is never removed.
func prepareSocketPath(path string, reclaimStale bool) error {
info, err := os.Lstat(path)
if errors.Is(err, fs.ErrNotExist) {
return nil // step 3: nothing there, bind directly
}
if err != nil {
return err
}

// Step 3: validate file type and owner. Never follow a symlink.
st, ok := info.Sys().(*syscall.Stat_t)
if !ok || info.Mode()&os.ModeSocket == 0 || int(st.Uid) != os.Getuid() {
return ErrStaleCleanupUnsafe
}

// Step 4: an owned socket. Reclaim only if requested; the liveness probe
// distinguishes a live listener from a stale one.
if !reclaimStale {
return ErrAlreadyInUse
}
conn, err := net.DialTimeout("unix", path, staleDialTimeout)
if err == nil {
conn.Close()
return ErrAlreadyInUse
}
return os.Remove(path)
}
44 changes: 44 additions & 0 deletions local_socket/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Four Bytes

// Package localsocket provides local, connection-oriented byte streams over a
// unified API: Unix domain sockets on Linux and macOS, named pipes on Windows.
// It abstracts the local transport only; framing, discovery and process
// management belong to the application.
package localsocket

import "errors"

// Sentinel errors. Every one is inspectable via errors.Is; the library never
// swallows a security-relevant underlying error.
var (
// ErrInvalidName reports a name that failed validation (empty, wrong
// character set, or containing path separators).
ErrInvalidName = errors.New("invalid local socket name")

// ErrUnsupportedName reports a name kind that the current platform does
// not support (for example, a filesystem path on Windows).
ErrUnsupportedName = errors.New("unsupported name type on this platform")

// ErrPermissionDenied reports that the endpoint could not be reached or
// bound because of a permission failure.
ErrPermissionDenied = errors.New("local socket permission denied")

// ErrAlreadyInUse reports that the endpoint already has a live listener
// (or an entry that Listen is not permitted to reclaim).
ErrAlreadyInUse = errors.New("local socket already in use")

// ErrStaleCleanupUnsafe reports that an entry exists at the socket path
// which stale cleanup refuses to remove: a regular file, a directory, a
// symbolic link, or a socket not owned by the current UID.
ErrStaleCleanupUnsafe = errors.New("refusing unsafe stale socket cleanup")

// ErrNoRuntimeDir reports that no runtime-directory candidate passed
// ownership and mode validation. Nothing is created in this case.
ErrNoRuntimeDir = errors.New("no runtime directory passed ownership and mode validation")

// ErrPeerIdentityUnsupported reports that peer credentials are not
// available on this platform. Callers must handle it rather than reading
// the zero value of PeerIdentity as "nobody".
ErrPeerIdentityUnsupported = errors.New("peer identity unavailable on this platform")
)
16 changes: 16 additions & 0 deletions local_socket/listener.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Four Bytes

package localsocket

import "net"

// Listener is the interface returned by Listen. It embeds net.Listener and adds
// the name the listener was created with. Every value returned by Listen
// satisfies it.
type Listener interface {
net.Listener

// LocalSocketName returns the Name the listener was created with.
LocalSocketName() Name
}
Loading