detached b996bb5

feat(statefile): write the state file at most once per second

commit
b996bb592d603cdd3e0d0cacf8fd2543852a5f74
author
brice · · 2026-10-21 07:10 UTC
committer
brice · · 2026-10-21 07:13 UTC
parent
f8e0ac9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +105 -5
M internal/adapters/statefile/statefile.go +40 -5
@@ -12,6 +12,7 @@ import (
1212 "path/filepath"
1313 "strconv"
1414 "strings"
15+ "time"
1516
1617 "github.com/bnema/neferwl/internal/adapters/workspaceid"
1718 "github.com/bnema/neferwl/internal/ports"
@@ -141,8 +142,13 @@ func window(w ports.WindowState, ids *workspaceid.IDs) Window {
141142 return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, WorkspaceID: ids.ID(w.WorkspaceID, w.WorkspaceName), Visible: w.Visible, Floating: w.Floating, StashIndex: w.StashIndex, StashCount: w.StashCount, Column: w.Column, Row: w.Row, Hidden: w.Hidden}
142143 }
143144
144-// Run writes every state it receives to path until ctx ends, then removes
145-// the file: a stale state must not outlive the session.
145+// writeInterval is the least time between two writes of the file.
146+const writeInterval = time.Second
147+
148+// Run writes the states it receives to path until ctx ends, then removes
149+// the file: a stale state must not outlive the session. It writes at most
150+// once per writeInterval: a state after a quiet interval is written at once,
151+// others are coalesced and the latest is written when the interval ends.
146152 func Run(ctx context.Context, path string, states <-chan ports.State, ids *workspaceid.IDs, log zerowrap.Logger) error {
147153 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
148154 return err
@@ -150,15 +156,44 @@ func Run(ctx context.Context, path string, states <-chan ports.State, ids *works
150156 // A file left by a crashed session on this socket is not ours.
151157 _ = os.Remove(path)
152158 defer os.Remove(path)
159+ flush := func(st ports.State) {
160+ if err := write(path, fromPorts(st, ids)); err != nil {
161+ // Scripts lose their state; the session goes on.
162+ log.Warn().Err(err).Str("path", path).Msg("write state")
163+ }
164+ }
165+ timer := time.NewTimer(0)
166+ defer timer.Stop()
167+ <-timer.C
168+ var (
169+ last time.Time
170+ pending ports.State
171+ waiting bool
172+ )
153173 for {
174+ var due <-chan time.Time
175+ if waiting {
176+ due = timer.C
177+ }
154178 select {
155179 case <-ctx.Done():
156180 return nil
157181 case st := <-states:
158- if err := write(path, fromPorts(st, ids)); err != nil {
159- // Scripts lose their state; the session goes on.
160- log.Warn().Err(err).Str("path", path).Msg("write state")
182+ if waiting {
183+ pending = st
184+ continue
185+ }
186+ if wait := writeInterval - time.Since(last); !last.IsZero() && wait > 0 {
187+ pending, waiting = st, true
188+ timer.Reset(wait)
189+ continue
161190 }
191+ flush(st)
192+ last = time.Now()
193+ case <-due:
194+ waiting = false
195+ flush(pending)
196+ last = time.Now()
162197 }
163198 }
164199 }
@@ -12,6 +12,7 @@ import (
12 "path/filepath"12 "path/filepath"
13 "strconv"13 "strconv"
14 "strings"14 "strings"
15+ "time"
15 16
16 "github.com/bnema/neferwl/internal/adapters/workspaceid"17 "github.com/bnema/neferwl/internal/adapters/workspaceid"
17 "github.com/bnema/neferwl/internal/ports"18 "github.com/bnema/neferwl/internal/ports"
@@ -141,8 +142,13 @@ func window(w ports.WindowState, ids *workspaceid.IDs) Window {
141 return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, WorkspaceID: ids.ID(w.WorkspaceID, w.WorkspaceName), Visible: w.Visible, Floating: w.Floating, StashIndex: w.StashIndex, StashCount: w.StashCount, Column: w.Column, Row: w.Row, Hidden: w.Hidden}142 return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, WorkspaceID: ids.ID(w.WorkspaceID, w.WorkspaceName), Visible: w.Visible, Floating: w.Floating, StashIndex: w.StashIndex, StashCount: w.StashCount, Column: w.Column, Row: w.Row, Hidden: w.Hidden}
142 }143 }
143 144
144-// Run writes every state it receives to path until ctx ends, then removes145+// writeInterval is the least time between two writes of the file.
145-// the file: a stale state must not outlive the session.146+const writeInterval = time.Second
147+
148+// Run writes the states it receives to path until ctx ends, then removes
149+// the file: a stale state must not outlive the session. It writes at most
150+// once per writeInterval: a state after a quiet interval is written at once,
151+// others are coalesced and the latest is written when the interval ends.
146 func Run(ctx context.Context, path string, states <-chan ports.State, ids *workspaceid.IDs, log zerowrap.Logger) error {152 func Run(ctx context.Context, path string, states <-chan ports.State, ids *workspaceid.IDs, log zerowrap.Logger) error {
147 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {153 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
148 return err154 return err
@@ -150,15 +156,44 @@ func Run(ctx context.Context, path string, states <-chan ports.State, ids *works
150 // A file left by a crashed session on this socket is not ours.156 // A file left by a crashed session on this socket is not ours.
151 _ = os.Remove(path)157 _ = os.Remove(path)
152 defer os.Remove(path)158 defer os.Remove(path)
159+ flush := func(st ports.State) {
160+ if err := write(path, fromPorts(st, ids)); err != nil {
161+ // Scripts lose their state; the session goes on.
162+ log.Warn().Err(err).Str("path", path).Msg("write state")
163+ }
164+ }
165+ timer := time.NewTimer(0)
166+ defer timer.Stop()
167+ <-timer.C
168+ var (
169+ last time.Time
170+ pending ports.State
171+ waiting bool
172+ )
153 for {173 for {
174+ var due <-chan time.Time
175+ if waiting {
176+ due = timer.C
177+ }
154 select {178 select {
155 case <-ctx.Done():179 case <-ctx.Done():
156 return nil180 return nil
157 case st := <-states:181 case st := <-states:
158- if err := write(path, fromPorts(st, ids)); err != nil {182+ if waiting {
159- // Scripts lose their state; the session goes on.183+ pending = st
160- log.Warn().Err(err).Str("path", path).Msg("write state")184+ continue
185+ }
186+ if wait := writeInterval - time.Since(last); !last.IsZero() && wait > 0 {
187+ pending, waiting = st, true
188+ timer.Reset(wait)
189+ continue
161 }190 }
191+ flush(st)
192+ last = time.Now()
193+ case <-due:
194+ waiting = false
195+ flush(pending)
196+ last = time.Now()
162 }197 }
163 }198 }
164 }199 }
M internal/adapters/statefile/statefile_test.go +65 -0
@@ -9,6 +9,7 @@ import (
99 "reflect"
1010 "strings"
1111 "testing"
12+ "testing/synctest"
1213 "time"
1314
1415 "github.com/bnema/neferwl/internal/adapters/workspaceid"
@@ -187,3 +188,67 @@ func TestWorkspaceGeometryFields(t *testing.T) {
187188 }
188189 }
189190 }
191+
192+// The file is written at most once per second: the first state and one after
193+// a quiet second at once, a burst within the second as one trailing write of
194+// the latest state.
195+func TestRunRateLimit(t *testing.T) {
196+ synctest.Test(t, func(t *testing.T) {
197+ path := filepath.Join(t.TempDir(), "s", "wayland-9.json")
198+ states := make(chan ports.State)
199+ ctx, cancel := context.WithCancel(context.Background())
200+ done := make(chan error, 1)
201+ go func() { done <- Run(ctx, path, states, workspaceid.WithPrefix("0a1b2c3d"), zerowrap.Default()) }()
202+ output := func() string {
203+ t.Helper()
204+ synctest.Wait()
205+ st, err := Read(path)
206+ if err != nil {
207+ return ""
208+ }
209+ return st.Output
210+ }
211+ send := func(name string) { states <- ports.State{Output: name} }
212+
213+ send("a")
214+ if got := output(); got != "a" {
215+ t.Fatalf("first state not written at once: %q", got)
216+ }
217+ // A burst within the second: nothing yet, then the latest only.
218+ time.Sleep(200 * time.Millisecond)
219+ send("b")
220+ send("c")
221+ send("d")
222+ if got := output(); got != "a" {
223+ t.Fatalf("written within the second: %q", got)
224+ }
225+ time.Sleep(799 * time.Millisecond)
226+ if got := output(); got != "a" {
227+ t.Fatalf("written before the second ended: %q", got)
228+ }
229+ time.Sleep(time.Millisecond)
230+ if got := output(); got != "d" {
231+ t.Fatalf("trailing write: %q, want d", got)
232+ }
233+ // Nothing more is pending: the file stays as it is.
234+ if err := os.Remove(path); err != nil {
235+ t.Fatal(err)
236+ }
237+ time.Sleep(5 * time.Second)
238+ if got := output(); got != "" {
239+ t.Fatalf("a write with no new state: %q", got)
240+ }
241+ // After a quiet period the next state is immediate.
242+ send("e")
243+ if got := output(); got != "e" {
244+ t.Fatalf("state after a quiet period: %q", got)
245+ }
246+ cancel()
247+ if err := <-done; err != nil {
248+ t.Fatal(err)
249+ }
250+ if _, err := os.Stat(path); !os.IsNotExist(err) {
251+ t.Fatal("state file left behind", err)
252+ }
253+ })
254+}
@@ -9,6 +9,7 @@ import (
9 "reflect"9 "reflect"
10 "strings"10 "strings"
11 "testing"11 "testing"
12+ "testing/synctest"
12 "time"13 "time"
13 14
14 "github.com/bnema/neferwl/internal/adapters/workspaceid"15 "github.com/bnema/neferwl/internal/adapters/workspaceid"
@@ -187,3 +188,67 @@ func TestWorkspaceGeometryFields(t *testing.T) {
187 }188 }
188 }189 }
189 }190 }
191+
192+// The file is written at most once per second: the first state and one after
193+// a quiet second at once, a burst within the second as one trailing write of
194+// the latest state.
195+func TestRunRateLimit(t *testing.T) {
196+ synctest.Test(t, func(t *testing.T) {
197+ path := filepath.Join(t.TempDir(), "s", "wayland-9.json")
198+ states := make(chan ports.State)
199+ ctx, cancel := context.WithCancel(context.Background())
200+ done := make(chan error, 1)
201+ go func() { done <- Run(ctx, path, states, workspaceid.WithPrefix("0a1b2c3d"), zerowrap.Default()) }()
202+ output := func() string {
203+ t.Helper()
204+ synctest.Wait()
205+ st, err := Read(path)
206+ if err != nil {
207+ return ""
208+ }
209+ return st.Output
210+ }
211+ send := func(name string) { states <- ports.State{Output: name} }
212+
213+ send("a")
214+ if got := output(); got != "a" {
215+ t.Fatalf("first state not written at once: %q", got)
216+ }
217+ // A burst within the second: nothing yet, then the latest only.
218+ time.Sleep(200 * time.Millisecond)
219+ send("b")
220+ send("c")
221+ send("d")
222+ if got := output(); got != "a" {
223+ t.Fatalf("written within the second: %q", got)
224+ }
225+ time.Sleep(799 * time.Millisecond)
226+ if got := output(); got != "a" {
227+ t.Fatalf("written before the second ended: %q", got)
228+ }
229+ time.Sleep(time.Millisecond)
230+ if got := output(); got != "d" {
231+ t.Fatalf("trailing write: %q, want d", got)
232+ }
233+ // Nothing more is pending: the file stays as it is.
234+ if err := os.Remove(path); err != nil {
235+ t.Fatal(err)
236+ }
237+ time.Sleep(5 * time.Second)
238+ if got := output(); got != "" {
239+ t.Fatalf("a write with no new state: %q", got)
240+ }
241+ // After a quiet period the next state is immediate.
242+ send("e")
243+ if got := output(); got != "e" {
244+ t.Fatalf("state after a quiet period: %q", got)
245+ }
246+ cancel()
247+ if err := <-done; err != nil {
248+ t.Fatal(err)
249+ }
250+ if _, err := os.Stat(path); !os.IsNotExist(err) {
251+ t.Fatal("state file left behind", err)
252+ }
253+ })
254+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close