Skip to content

Commit f09b02d

Browse files
committed
feat: adding a "read-only" connection if an instance is already running
1 parent 528983f commit f09b02d

13 files changed

Lines changed: 222 additions & 11 deletions

File tree

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "patchboard"]
2+
path = patchboard
3+
url = git@github.com:OffPeakEngineer/patchboard.git

README.md

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ http://localhost:8080
3737

3838
Run the same binary on additional LAN machines and they should discover each other automatically. If the advertised browser URL needs to differ from the listen address, set `PSSTD_ADVERTISE_HTTP`.
3939

40+
If you start `psstd` again while an instance is already running on the machine, the second process does not open another writer on the same store or publish a duplicate node. When the existing `PSSTD_DB` is locked, or the configured gossip port is already bound, it joins as a terminal-only mirror with a temporary database and renders the cluster view in your terminal.
41+
4042
## Configuration
4143

4244
```bash
@@ -145,12 +147,6 @@ Traefik supports query-param matchers in router rules, so this keeps link clicks
145147

146148
That example also includes a low-priority host-only fallback route. If Traefik sees no matching `psstd_node` value, it sends the request to a shared `psstd-any` service instead of pinning fallback traffic to one node. Keep the `psstd-any` Endpoints list limited to nodes that should receive unrouted fallback traffic.
147149

148-
## Health Check
149-
150-
```bash
151-
curl http://localhost:8080/healthz
152-
```
153-
154150
## Notes
155151

156152
psstd uses peer-to-peer state sharing and a small local store internally, but those are implementation details for the dashboard. It is not intended to be a general-purpose distributed database or key-value API.

main.go

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net"
88
"net/http"
99
"os"
10+
"path/filepath"
1011
"strings"
1112
"time"
1213

@@ -44,9 +45,18 @@ func main() {
4445
// ── Local state ─────────────────────────────────────────────────────────
4546
db, err := pebble.Open(dbPath, &pebble.Options{})
4647
if err != nil {
48+
if pebbleLockHeld(err) {
49+
log.Printf("psstd already appears to own %s; starting terminal mirror instead", dbPath)
50+
runTerminalMirror(hostname, gossipAddr, seeds)
51+
return
52+
}
4753
log.Fatalf("pebble open: %v", err)
4854
}
49-
defer db.Close()
55+
defer func() {
56+
if db != nil {
57+
db.Close()
58+
}
59+
}()
5060
if err := purgeOfflineDifferentVersion(db, appVersion); err != nil {
5161
log.Printf("stale version purge: %v", err)
5262
}
@@ -63,6 +73,15 @@ func main() {
6373

6474
list, err := memberlist.Create(cfg)
6575
if err != nil {
76+
if addressInUse(err) {
77+
if closeErr := db.Close(); closeErr != nil {
78+
log.Printf("db close before terminal mirror: %v", closeErr)
79+
}
80+
db = nil
81+
log.Printf("psstd already appears to be listening on %s; starting terminal mirror instead", gossipAddr)
82+
runTerminalMirror(hostname, gossipAddr, seeds)
83+
return
84+
}
6685
log.Fatalf("memberlist create: %v", err)
6786
}
6887
delegate.broadcasts.NumNodes = func() int { return list.NumMembers() }
@@ -92,10 +111,6 @@ func main() {
92111
if webEnabled {
93112
mux := http.NewServeMux()
94113
mux.HandleFunc("/", makeHandler(db, hostname))
95-
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
96-
w.WriteHeader(200)
97-
fmt.Fprintln(w, "ok")
98-
})
99114
log.Printf("psstd version=%s node=%s http=%s advertise=%s gossip=%s web=true", appVersion, hostname, httpAddr, webURL, gossipAddr)
100115
if err := http.ListenAndServe(httpAddr, mux); err != nil {
101116
log.Fatalf("http: %v", err)
@@ -106,6 +121,71 @@ func main() {
106121
}
107122
}
108123

124+
func pebbleLockHeld(err error) bool {
125+
msg := strings.ToLower(err.Error())
126+
return strings.Contains(msg, "lock") &&
127+
(strings.Contains(msg, "resource temporarily unavailable") ||
128+
strings.Contains(msg, "held") ||
129+
strings.Contains(msg, "being used") ||
130+
strings.Contains(msg, "already in use") ||
131+
strings.Contains(msg, "access is denied"))
132+
}
133+
134+
func addressInUse(err error) bool {
135+
msg := strings.ToLower(err.Error())
136+
return strings.Contains(msg, "address already in use") ||
137+
strings.Contains(msg, "bind: only one usage of each socket address")
138+
}
139+
140+
func runTerminalMirror(hostname, gossipAddr string, seeds []string) {
141+
tmpDir, err := os.MkdirTemp("", "psstd-view-*")
142+
if err != nil {
143+
log.Fatalf("terminal mirror temp db: %v", err)
144+
}
145+
defer os.RemoveAll(tmpDir)
146+
147+
db, err := pebble.Open(filepath.Join(tmpDir, "data"), &pebble.Options{})
148+
if err != nil {
149+
log.Fatalf("terminal mirror db: %v", err)
150+
}
151+
defer db.Close()
152+
153+
delegate := newKVDelegate(db, appVersion)
154+
cfg := memberlist.DefaultLANConfig()
155+
cfg.Name = fmt.Sprintf("%s-view-%d", hostname, os.Getpid())
156+
cfg.BindAddr = "0.0.0.0"
157+
cfg.BindPort = 0
158+
cfg.Delegate = delegate
159+
cfg.Logger = log.New(os.Stderr, "[memberlist:view] ", log.LstdFlags)
160+
161+
list, err := memberlist.Create(cfg)
162+
if err != nil {
163+
log.Fatalf("terminal mirror memberlist: %v", err)
164+
}
165+
defer list.Shutdown()
166+
delegate.broadcasts.NumNodes = func() int { return list.NumMembers() }
167+
168+
allSeeds := terminalMirrorSeeds(gossipAddr, seeds)
169+
if n, err := list.Join(allSeeds); err != nil {
170+
log.Printf("terminal mirror join warning (joined %d): %v", n, err)
171+
} else {
172+
log.Printf("terminal mirror joined cluster, %d peer(s)", n)
173+
}
174+
175+
terminalRenderLoop(db)
176+
}
177+
178+
func terminalMirrorSeeds(gossipAddr string, seeds []string) []string {
179+
out := append([]string{}, seeds...)
180+
host, port := splitHostPort(gossipAddr)
181+
if host != "" && host != "0.0.0.0" && host != "::" {
182+
out = append(out, net.JoinHostPort(host, fmt.Sprintf("%d", port)))
183+
}
184+
out = append(out, net.JoinHostPort("127.0.0.1", fmt.Sprintf("%d", port)))
185+
out = append(out, net.JoinHostPort("localhost", fmt.Sprintf("%d", port)))
186+
return append(out, discoverPeers()...)
187+
}
188+
109189
// ── Stats loop ───────────────────────────────────────────────────────────────
110190

111191
func statsLoop(hostname, webURL, version string, db *pebble.DB, d *kvDelegate) {

patchboard

Submodule patchboard added at deb71aa

tasks/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Tasks
2+
3+
This folder is a Patchboard task board. Tasks are Markdown files, and the
4+
folder containing a task is its workflow state.
5+
6+
## States
7+
8+
- `backlog/`
9+
- `ready/`
10+
- `doing/`
11+
- `blocked/`
12+
- `done/`
13+
- `archived/`
14+
15+
Move a task file between folders to change its state. Git history is the audit
16+
trail.
17+
18+
## Task Shape
19+
20+
~~~markdown
21+
---
22+
id: task-YYYYMMDD-short-name
23+
title: Short, concrete task title
24+
status: backlog
25+
priority: medium
26+
owner: your-name
27+
created: YYYY-MM-DD
28+
---
29+
30+
## Problem
31+
32+
What needs to change, and why?
33+
34+
## Done when
35+
36+
- The expected behavior is implemented
37+
- Relevant tests or checks pass
38+
~~~
39+
40+
The folder is authoritative for status. If frontmatter includes `status`,
41+
it should match the parent folder.
42+
43+
## Code Annotations
44+
45+
Link code comments back to tasks with square brackets:
46+
47+
~~~text
48+
TODO[task-YYYYMMDD-short-name]: describe the follow-up
49+
FIXME[task-YYYYMMDD-short-name]: describe the known problem
50+
~~~
51+
52+
Unlinked annotations such as `TODO:`, `XXX:`, and `WARN:` are useful inventory,
53+
but they do not fail lint until they reference a task ID.

tasks/archived/.gitkeep

Whitespace-only changes.

tasks/backlog/.gitkeep

Whitespace-only changes.

tasks/blocked/.gitkeep

Whitespace-only changes.

tasks/doing/.gitkeep

Whitespace-only changes.

tasks/done/.gitkeep

Whitespace-only changes.

0 commit comments

Comments
 (0)