Skip to content
Merged
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ jobs:
- name: Vet
run: go vet ./...

# Windows-only process and lock behaviour the unix legs cannot cover.
- name: Test (windows-specific)
run: go test ./internal/filelock/ ./internal/server/ -run "TestWindows|TestLock" -v

lint:
name: Lint
runs-on: ubuntu-latest
Expand Down
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Keep it that way.
| `internal/tokens` | Local persistence of per-session auth tokens |
| `internal/workspace` | Per-cwd draft, queue, history, and last-session id |
| `internal/settings` | Persisted front-end preferences (theme, bell, notify, plain, verbosity, thinking, resume, reduce-motion) at `~/.bodek/config.json`; flag > file > default resolution with a theme-only `BODEK_THEME` env override (`BODEK_CONFIG` relocates the file); odek's config is untouched |
| `internal/filelock` | Cross-process advisory lock (`<path>.lock`, flock / LockFileEx) around the stores' read-merge-write; best-effort, gives up after 2s |
| `internal/watchdog` | Orphan guard: re-execs a tiny watchdog that terminates `odek serve` when the bodek parent dies for any reason (per-platform impls) |
| `internal/tui` | The Bubble Tea model, update loop, panels, and view |
| `internal/update` | Self-upgrade: fetch and swap in the latest GitHub release binary |
Expand Down Expand Up @@ -236,8 +237,11 @@ feat(tui): compact tool steps with Ctrl+E details toggle
stays. Approvals render above a live composer: bare `a`/`d`/`t` decide
only while the composer draft is empty (a non-empty draft, paste, or any
modifier routes to the composer), and `Alt+A`/`Alt+D`/`Alt+T`
decide. On Alt-less terminals (macOS Option-as-UTF-8), `^U` clears the
whole draft first, restoring the bare-key path — never leave such a
decide. On Alt-less terminals (macOS Option-as-UTF-8), emptying the draft
(Backspace, or `^W` word-wise — `^U` pages the transcript while a card
is up, and Shift+Delete is a no-op there) restores the bare-key path;
with the find bar or a drawer editor open, a bare letter is swallowed
with a hint and Esc closes the field first — never leave such a
terminal with no reachable approval binding. Text, paste, cursor keys, and Enter retain composer behavior. Friction
captures `apprTyped` only after explicit `Alt+A`; Escape returns to the draft
without deciding. Keep the editor rune/row bounded and reset it on head changes. Clarify questions capture the
Expand Down
26 changes: 26 additions & 0 deletions cmd/bodek/bughunt_regress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
Expand Down Expand Up @@ -88,3 +89,28 @@ func TestRegressHelpPrintsUsageOnce(t *testing.T) {
t.Fatalf("usage printed %d times", n)
}
}

// Two instances saving different preferences at once keep both.
func TestRegressConcurrentSettingsSavesKeepBoth(t *testing.T) {
t.Setenv("BODEK_CONFIG", filepath.Join(t.TempDir(), "config.json"))
for i := 0; i < 20; i++ {
a, err := parseConfig(nil, io.Discard)
if err != nil {
t.Fatal(err)
}
b, _ := parseConfig(nil, io.Discard)
a.persist.Theme = fmt.Sprintf("theme-%d", i)
b.persist.Verbosity = fmt.Sprintf("v-%d", i)
done := make(chan struct{})
go func() { _ = a.save(); close(done) }()
_ = b.save()
<-done
got, err := settings.Load()
if err != nil {
t.Fatal(err)
}
if got.Theme != a.persist.Theme || got.Verbosity != b.persist.Verbosity {
t.Fatalf("round %d lost a save: %+v", i, got)
}
}
}
3 changes: 3 additions & 0 deletions cmd/bodek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/muesli/termenv"

"github.com/BackendStack21/bodek/internal/client"
"github.com/BackendStack21/bodek/internal/filelock"
"github.com/BackendStack21/bodek/internal/server"
"github.com/BackendStack21/bodek/internal/settings"
"github.com/BackendStack21/bodek/internal/tui"
Expand Down Expand Up @@ -74,6 +75,8 @@ func (c *config) save() error {
if c.persistDisabled {
return fmt.Errorf("settings file could not be parsed; changes are not saved this run")
}
// Another instance must not save between this load and the rename.
defer filelock.Lock(settings.Path())()
cur, err := settings.Load()
if err != nil {
// A missing file loads clean; an error means the file became
Expand Down
12 changes: 11 additions & 1 deletion internal/client/bughunt_round3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func TestRegressPromptFrameBounds(t *testing.T) {
}
select {
case n := <-got:
if n > maxFrameBytes {
if n > MaxFrameBytes {
t.Fatalf("frame %d bytes exceeds the cap", n)
}
case <-time.After(5 * time.Second):
Expand Down Expand Up @@ -90,3 +90,13 @@ func TestRegressCloseDoesNotWaitOutStuckWrite(t *testing.T) {
}
<-sent
}

func TestRegressCheckPromptSize(t *testing.T) {
if err := CheckPromptSize("hi", PromptOpts{}); err != nil {
t.Fatalf("small prompt refused: %v", err)
}
big := []Attachment{{Name: "a", Content: strings.Repeat("\n", 5<<20)}} // \n encodes to 2 bytes
if err := CheckPromptSize("hi", PromptOpts{Attachments: big}); !errors.Is(err, ErrFrameTooLarge) {
t.Fatalf("err = %v, want ErrFrameTooLarge", err)
}
}
43 changes: 32 additions & 11 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -536,15 +536,27 @@ type PromptOpts struct {
// SendPrompt submits a task. Session continuity is automatic on a single
// connection; SessionID+AuthToken resume a saved conversation.
func (c *Client) SendPrompt(content string, opts PromptOpts) error {
return c.send(prompt{
return c.send(promptFrame(content, opts))
}

// CheckPromptSize reports ErrFrameTooLarge for a prompt whose frame odek
// would refuse, so callers can keep the draft and attachments instead of
// committing a turn that cannot be sent.
func CheckPromptSize(content string, opts PromptOpts) error {
_, err := encodeFrame(promptFrame(content, opts))
return err
}

func promptFrame(content string, opts PromptOpts) prompt {
return prompt{
Type: "prompt",
Content: content,
Thinking: opts.Thinking,
Model: opts.Model,
SessionID: opts.SessionID,
AuthToken: opts.AuthToken,
Attachments: opts.Attachments,
})
}
}

// approval is the client→server response to an approval_request.
Expand Down Expand Up @@ -627,32 +639,41 @@ func (c *Client) SendSkillPromptResponse(action, skillName string) error {
// prompt on a slow link.
var writeTimeout = 60 * time.Second

// maxFrameBytes is odek serve's inbound WebSocket message cap (8 MiB). A
// MaxFrameBytes is odek serve's inbound WebSocket message cap (8 MiB). A
// larger frame makes odek drop the whole connection — the prompt is lost
// behind a bare "connection lost" — so send refuses it with a reason.
const maxFrameBytes = 8 << 20
const MaxFrameBytes = 8 << 20

// ErrFrameTooLarge reports a frame over odek's inbound message cap.
var ErrFrameTooLarge = errors.New("message exceeds odek serve's 8 MiB frame limit")

// send marshals v and writes it as one frame, serialized against other writers.
func (c *Client) send(v any) error {
frame, err := encodeFrame(v)
if err != nil {
return err
}
c.wmu.Lock()
defer c.wmu.Unlock()
_ = c.conn.SetWriteDeadline(time.Now().Add(writeTimeout))
return ws.Message.Send(c.conn, string(frame)) // string → text frame, as JSON.Send
}

// encodeFrame marshals one wire frame, refusing one over MaxFrameBytes.
func encodeFrame(v any) ([]byte, error) {
// No HTML escaping: < > & would each grow to six bytes, so attached
// source files could blow past the frame cap that their size passed.
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return err
return nil, err
}
frame := bytes.TrimSuffix(buf.Bytes(), []byte("\n"))
if len(frame) > maxFrameBytes {
return fmt.Errorf("%w (%.1f MiB encoded) — attach fewer or smaller files", ErrFrameTooLarge, float64(len(frame))/(1<<20))
if len(frame) > MaxFrameBytes {
return nil, fmt.Errorf("%w (%.1f MiB encoded) — attach fewer or smaller files", ErrFrameTooLarge, float64(len(frame))/(1<<20))
}
c.wmu.Lock()
defer c.wmu.Unlock()
_ = c.conn.SetWriteDeadline(time.Now().Add(writeTimeout))
return ws.Message.Send(c.conn, string(frame)) // string → text frame, as JSON.Send
return frame, nil
}

// closeGrace bounds Close's close-frame write. A send stuck on a half-open
Expand Down
60 changes: 60 additions & 0 deletions internal/filelock/filelock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Package filelock serializes read-merge-write cycles on bodek's shared
// stores (~/.bodek tokens, workspace, settings) across processes. Each store
// already merges the on-disk state before replacing it, but two instances
// could still interleave between the read and the rename and drop the
// other's update. An advisory lock on a sidecar "<path>.lock" closes that
// window; the OS releases it if the holder dies.
package filelock

import (
"os"
"path/filepath"
"time"
)

// wait bounds how long Lock blocks on a peer. Persistence is best-effort
// and must never hang the TUI, so after it the write proceeds unlocked.
var wait = 2 * time.Second

// tryLockFn is tryLock, swappable so tests can fake an unsupported mount.
var tryLockFn = tryLock

// retryEvery is the polling interval while a peer holds the lock.
const retryEvery = 10 * time.Millisecond

// Lock takes the exclusive lock for path and returns its release. It never
// fails: when the lock file cannot be opened or a peer holds it past wait,
// the returned release is a no-op and the caller proceeds as before.
func Lock(path string) (unlock func()) {
if path == "" {
return func() {}
}
// Two links to one store must share its lock; the very first write
// must not run unlocked for want of the directory.
if target, err := filepath.EvalSymlinks(path); err == nil {
path = target
}
_ = os.MkdirAll(filepath.Dir(path), 0o700)
f, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return func() {}
}
deadline := time.Now().Add(wait)
for {
acquired, busy := tryLockFn(f)
if acquired {
return func() {
unlockFile(f)
_ = f.Close()
}
}
// Only a peer holding the lock is worth waiting for: a filesystem
// without locking (NFS nolocks, some SMB/FUSE) fails every try, and
// spinning there would stall each save by the full wait.
if !busy || time.Now().After(deadline) {
_ = f.Close()
return func() {}
}
time.Sleep(retryEvery)
}
}
105 changes: 105 additions & 0 deletions internal/filelock/filelock_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package filelock

import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)

// Lock excludes a second holder until released (flock locks are per open
// file description, so two Locks in one process still contend).
func TestLockExcludesUntilReleased(t *testing.T) {
path := filepath.Join(t.TempDir(), "store.json")
unlock := Lock(path)
got := make(chan struct{})
go func() {
u := Lock(path)
close(got)
u()
}()
select {
case <-got:
t.Fatal("second Lock acquired while the first was held")
case <-time.After(200 * time.Millisecond):
}
unlock()
select {
case <-got:
case <-time.After(wait + time.Second):
t.Fatal("second Lock never acquired after release")
}
}

// A peer holding the lock past wait degrades to an unlocked write instead
// of hanging persistence.
func TestLockGivesUpAfterWait(t *testing.T) {
old := wait
wait = 50 * time.Millisecond
t.Cleanup(func() { wait = old })
path := filepath.Join(t.TempDir(), "store.json")
unlock := Lock(path)
defer unlock()
start := time.Now()
Lock(path)()
if time.Since(start) > 2*time.Second {
t.Fatal("Lock blocked far past its wait")
}
}

// An unopenable lock path and an empty path are no-ops, never panics.
func TestLockBestEffort(t *testing.T) {
Lock("")()
file := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(file, nil, 0o600); err != nil {
t.Fatal(err)
}
Lock(filepath.Join(file, "store.json"))() // parent is a file: cannot open
}

// Serialized increments from many goroutines never lose an update.
func TestLockSerializesReadModifyWrite(t *testing.T) {
path := filepath.Join(t.TempDir(), "counter")
if err := os.WriteFile(path, nil, 0o600); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
defer Lock(path)()
b, _ := os.ReadFile(path)
_ = os.WriteFile(path, append(b, 'x'), 0o600)
}()
}
wg.Wait()
b, _ := os.ReadFile(path)
if n := strings.Count(string(b), "x"); n != 20 {
t.Fatalf("lost updates: %d of 20", n)
}
}

// A filesystem without locking fails every try: Lock must give up at once
// rather than stall each save by the full wait.
func TestLockUnsupportedReturnsImmediately(t *testing.T) {
old := tryLockFn
tryLockFn = func(*os.File) (bool, bool) { return false, false }
t.Cleanup(func() { tryLockFn = old })
start := time.Now()
Lock(filepath.Join(t.TempDir(), "store.json"))()
if d := time.Since(start); d > wait/2 {
t.Fatalf("Lock spun %v on an unsupported filesystem", d)
}
}

// The first write creates the store directory, so it is locked too.
func TestLockCreatesMissingDir(t *testing.T) {
path := filepath.Join(t.TempDir(), "new", "store.json")
defer Lock(path)()
if _, err := os.Stat(path + ".lock"); err != nil {
t.Fatalf("lock file not created: %v", err)
}
}
23 changes: 23 additions & 0 deletions internal/filelock/filelock_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//go:build unix

package filelock

import (
"errors"
"os"
"syscall"
)

// tryLock reports whether the lock was taken, and if not whether a peer
// holds it (worth retrying) rather than locking being unsupported.
func tryLock(f *os.File) (acquired, busy bool) {
err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
if err == nil {
return true, false
}
return false, errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EINTR)
}

func unlockFile(f *os.File) {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
}
Loading
Loading