Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58ad9a2699 | ||
|
|
f3a9c8036f |
@@ -42,6 +42,7 @@ type Context struct {
|
|||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
sseConnected atomic.Bool
|
sseConnected atomic.Bool
|
||||||
sseDisconnectedAt atomic.Pointer[time.Time]
|
sseDisconnectedAt atomic.Pointer[time.Time]
|
||||||
|
lastSeenAt atomic.Pointer[time.Time]
|
||||||
suspended atomic.Bool
|
suspended atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ func ProfilePage(c *via.Context) {
|
|||||||
via.MaxLen(20, "Must be at most 20 characters"),
|
via.MaxLen(20, "Must be at most 20 characters"),
|
||||||
)
|
)
|
||||||
selectedEmoji := c.Signal(existingEmoji)
|
selectedEmoji := c.Signal(existingEmoji)
|
||||||
|
previewName := c.Computed(func() string {
|
||||||
|
if name := nameField.String(); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return "Your Name"
|
||||||
|
})
|
||||||
|
|
||||||
saveToSession := func() bool {
|
saveToSession := func() bool {
|
||||||
if !c.ValidateAll() {
|
if !c.ValidateAll() {
|
||||||
@@ -68,18 +74,13 @@ func ProfilePage(c *via.Context) {
|
|||||||
h.Button(h.Text("Start Chatting"), saveAndChat.OnClick()),
|
h.Button(h.Text("Start Chatting"), saveAndChat.OnClick()),
|
||||||
)
|
)
|
||||||
|
|
||||||
previewName := nameField.String()
|
|
||||||
if previewName == "" {
|
|
||||||
previewName = "Your Name"
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.Div(h.Class("profile-page"),
|
return h.Div(h.Class("profile-page"),
|
||||||
h.H2(h.Text("Your Profile"), h.DataViewTransition("page-title")),
|
h.H2(h.Text("Your Profile"), h.DataViewTransition("page-title")),
|
||||||
|
|
||||||
// Live preview
|
// Live preview
|
||||||
h.Div(h.Class("profile-preview"),
|
h.Div(h.Class("profile-preview"),
|
||||||
h.Div(h.Class("avatar avatar-lg"), h.Text(selectedEmoji.String())),
|
h.Div(h.Class("avatar avatar-lg"), h.Text(selectedEmoji.String())),
|
||||||
h.Span(h.Class("preview-name"), h.Text(previewName)),
|
h.Span(h.Class("preview-name"), previewName.Text()),
|
||||||
),
|
),
|
||||||
|
|
||||||
h.Div(h.Class("profile-form"),
|
h.Div(h.Class("profile-form"),
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ func main() {
|
|||||||
titleSignal := c.Signal("")
|
titleSignal := c.Signal("")
|
||||||
urlSignal := c.Signal("")
|
urlSignal := c.Signal("")
|
||||||
targetIDSignal := c.Signal("")
|
targetIDSignal := c.Signal("")
|
||||||
|
saveLabel := c.Computed(func() string {
|
||||||
|
if targetIDSignal.String() != "" {
|
||||||
|
return "Update Bookmark"
|
||||||
|
}
|
||||||
|
return "Add Bookmark"
|
||||||
|
})
|
||||||
|
|
||||||
via.Subscribe(c, "bookmarks.events", func(evt CRUDEvent) {
|
via.Subscribe(c, "bookmarks.events", func(evt CRUDEvent) {
|
||||||
if evt.UserID == userID {
|
if evt.UserID == userID {
|
||||||
@@ -205,11 +211,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
bookmarksMu.RUnlock()
|
bookmarksMu.RUnlock()
|
||||||
|
|
||||||
saveLabel := "Add Bookmark"
|
|
||||||
if isEditing {
|
|
||||||
saveLabel = "Update Bookmark"
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.Div(h.Class("min-h-screen bg-base-200"),
|
return h.Div(h.Class("min-h-screen bg-base-200"),
|
||||||
// Navbar
|
// Navbar
|
||||||
h.Div(h.Class("navbar bg-base-100 shadow-sm"),
|
h.Div(h.Class("navbar bg-base-100 shadow-sm"),
|
||||||
@@ -225,7 +226,7 @@ func main() {
|
|||||||
// Form card
|
// Form card
|
||||||
h.Div(h.Class("card bg-base-100 shadow"),
|
h.Div(h.Class("card bg-base-100 shadow"),
|
||||||
h.Div(h.Class("card-body"),
|
h.Div(h.Class("card-body"),
|
||||||
h.H2(h.Class("card-title"), h.Text(saveLabel)),
|
h.H2(h.Class("card-title"), saveLabel.Text()),
|
||||||
h.Div(h.Class("flex flex-col gap-2"),
|
h.Div(h.Class("flex flex-col gap-2"),
|
||||||
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("Title"), titleSignal.Bind()),
|
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("Title"), titleSignal.Bind()),
|
||||||
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("https://example.com"), urlSignal.Bind()),
|
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("https://example.com"), urlSignal.Bind()),
|
||||||
@@ -233,7 +234,7 @@ func main() {
|
|||||||
h.If(isEditing,
|
h.If(isEditing,
|
||||||
h.Button(h.Class("btn btn-ghost"), h.Text("Cancel"), cancelEdit.OnClick()),
|
h.Button(h.Class("btn btn-ghost"), h.Text("Cancel"), cancelEdit.OnClick()),
|
||||||
),
|
),
|
||||||
h.Button(h.Class("btn btn-primary"), h.Text(saveLabel), save.OnClick()),
|
h.Button(h.Class("btn btn-primary"), saveLabel.Text(), save.OnClick()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
28
via.go
28
via.go
@@ -331,15 +331,18 @@ func (v *V) reapOrphanedContexts(suspendAfter, ttl time.Duration) {
|
|||||||
if c.sseConnected.Load() {
|
if c.sseConnected.Load() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var disconnectedFor time.Duration
|
// Use the most recent liveness signal
|
||||||
if dc := c.sseDisconnectedAt.Load(); dc != nil {
|
lastAlive := c.createdAt
|
||||||
disconnectedFor = now.Sub(*dc)
|
if dc := c.sseDisconnectedAt.Load(); dc != nil && dc.After(lastAlive) {
|
||||||
} else {
|
lastAlive = *dc
|
||||||
disconnectedFor = now.Sub(c.createdAt)
|
|
||||||
}
|
}
|
||||||
if disconnectedFor > ttl {
|
if seen := c.lastSeenAt.Load(); seen != nil && seen.After(lastAlive) {
|
||||||
|
lastAlive = *seen
|
||||||
|
}
|
||||||
|
silentFor := now.Sub(lastAlive)
|
||||||
|
if silentFor > ttl {
|
||||||
toReap = append(toReap, c)
|
toReap = append(toReap, c)
|
||||||
} else if disconnectedFor > suspendAfter && !c.suspended.Load() {
|
} else if silentFor > suspendAfter && !c.suspended.Load() {
|
||||||
toSuspend = append(toSuspend, c)
|
toSuspend = append(toSuspend, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -655,6 +658,8 @@ func New() *V {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.reqCtx = r.Context()
|
c.reqCtx = r.Context()
|
||||||
|
now := time.Now()
|
||||||
|
c.lastSeenAt.Store(&now)
|
||||||
|
|
||||||
sse := datastar.NewSSE(w, r, datastar.WithCompression(datastar.WithBrotli(datastar.WithBrotliLevel(5))))
|
sse := datastar.NewSSE(w, r, datastar.WithCompression(datastar.WithBrotli(datastar.WithBrotliLevel(5))))
|
||||||
|
|
||||||
@@ -688,17 +693,22 @@ func New() *V {
|
|||||||
|
|
||||||
go c.Sync()
|
go c.Sync()
|
||||||
|
|
||||||
|
keepalive := time.NewTicker(30 * time.Second)
|
||||||
|
defer keepalive.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-sse.Context().Done():
|
case <-sse.Context().Done():
|
||||||
v.logDebug(c, "SSE connection ended")
|
v.logDebug(c, "SSE connection ended")
|
||||||
c.sseConnected.Store(false)
|
c.sseConnected.Store(false)
|
||||||
now := time.Now()
|
dcNow := time.Now()
|
||||||
c.sseDisconnectedAt.Store(&now)
|
c.sseDisconnectedAt.Store(&dcNow)
|
||||||
return
|
return
|
||||||
case <-c.ctxDisposedChan:
|
case <-c.ctxDisposedChan:
|
||||||
v.logDebug(c, "context disposed, closing SSE")
|
v.logDebug(c, "context disposed, closing SSE")
|
||||||
return
|
return
|
||||||
|
case <-keepalive.C:
|
||||||
|
sse.PatchSignals([]byte("{}"))
|
||||||
case patch := <-c.patchChan:
|
case patch := <-c.patchChan:
|
||||||
switch patch.typ {
|
switch patch.typ {
|
||||||
case patchTypeElements:
|
case patchTypeElements:
|
||||||
|
|||||||
71
via_test.go
71
via_test.go
@@ -418,6 +418,7 @@ func TestReaperCleansOrphanedContexts(t *testing.T) {
|
|||||||
func TestReaperSuspendsContext(t *testing.T) {
|
func TestReaperSuspendsContext(t *testing.T) {
|
||||||
v := New()
|
v := New()
|
||||||
c := newContext("suspend-1", "/", v)
|
c := newContext("suspend-1", "/", v)
|
||||||
|
c.createdAt = time.Now().Add(-30 * time.Minute)
|
||||||
dc := time.Now().Add(-20 * time.Minute)
|
dc := time.Now().Add(-20 * time.Minute)
|
||||||
c.sseDisconnectedAt.Store(&dc)
|
c.sseDisconnectedAt.Store(&dc)
|
||||||
v.registerCtx(c)
|
v.registerCtx(c)
|
||||||
@@ -432,6 +433,7 @@ func TestReaperSuspendsContext(t *testing.T) {
|
|||||||
func TestReaperReapsAfterTTL(t *testing.T) {
|
func TestReaperReapsAfterTTL(t *testing.T) {
|
||||||
v := New()
|
v := New()
|
||||||
c := newContext("reap-1", "/", v)
|
c := newContext("reap-1", "/", v)
|
||||||
|
c.createdAt = time.Now().Add(-3 * time.Hour)
|
||||||
dc := time.Now().Add(-2 * time.Hour)
|
dc := time.Now().Add(-2 * time.Hour)
|
||||||
c.sseDisconnectedAt.Store(&dc)
|
c.sseDisconnectedAt.Store(&dc)
|
||||||
c.suspended.Store(true)
|
c.suspended.Store(true)
|
||||||
@@ -446,6 +448,7 @@ func TestReaperReapsAfterTTL(t *testing.T) {
|
|||||||
func TestReaperIgnoresAlreadySuspended(t *testing.T) {
|
func TestReaperIgnoresAlreadySuspended(t *testing.T) {
|
||||||
v := New()
|
v := New()
|
||||||
c := newContext("already-sus-1", "/", v)
|
c := newContext("already-sus-1", "/", v)
|
||||||
|
c.createdAt = time.Now().Add(-30 * time.Minute)
|
||||||
dc := time.Now().Add(-20 * time.Minute)
|
dc := time.Now().Add(-20 * time.Minute)
|
||||||
c.sseDisconnectedAt.Store(&dc)
|
c.sseDisconnectedAt.Store(&dc)
|
||||||
c.suspended.Store(true)
|
c.suspended.Store(true)
|
||||||
@@ -500,6 +503,74 @@ func TestCleanupCtxIdempotent(t *testing.T) {
|
|||||||
assert.Error(t, err, "context should be removed after cleanup")
|
assert.Error(t, err, "context should be removed after cleanup")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReaperRespectsLastSeenAt(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
c := newContext("seen-1", "/", v)
|
||||||
|
c.createdAt = time.Now().Add(-30 * time.Minute)
|
||||||
|
// Disconnected 20 min ago, but client retried (lastSeenAt) 2 min ago
|
||||||
|
dc := time.Now().Add(-20 * time.Minute)
|
||||||
|
c.sseDisconnectedAt.Store(&dc)
|
||||||
|
seen := time.Now().Add(-2 * time.Minute)
|
||||||
|
c.lastSeenAt.Store(&seen)
|
||||||
|
v.registerCtx(c)
|
||||||
|
|
||||||
|
v.reapOrphanedContexts(15*time.Minute, time.Hour)
|
||||||
|
|
||||||
|
_, err := v.getCtx("seen-1")
|
||||||
|
assert.NoError(t, err, "context with recent lastSeenAt should survive suspend threshold")
|
||||||
|
assert.False(t, c.suspended.Load(), "context should not be suspended")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReaperFallsBackWithoutLastSeenAt(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
c := newContext("noseen-1", "/", v)
|
||||||
|
c.createdAt = time.Now().Add(-30 * time.Minute)
|
||||||
|
dc := time.Now().Add(-20 * time.Minute)
|
||||||
|
c.sseDisconnectedAt.Store(&dc)
|
||||||
|
// no lastSeenAt set — should fall back to sseDisconnectedAt
|
||||||
|
v.registerCtx(c)
|
||||||
|
|
||||||
|
v.reapOrphanedContexts(15*time.Minute, time.Hour)
|
||||||
|
|
||||||
|
got, err := v.getCtx("noseen-1")
|
||||||
|
assert.NoError(t, err, "context should still be in registry (suspended, not reaped)")
|
||||||
|
assert.True(t, got.suspended.Load(), "context should be suspended using sseDisconnectedAt fallback")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReaperReapsWithStaleLastSeenAt(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
c := newContext("stale-seen-1", "/", v)
|
||||||
|
c.createdAt = time.Now().Add(-3 * time.Hour)
|
||||||
|
dc := time.Now().Add(-2 * time.Hour)
|
||||||
|
c.sseDisconnectedAt.Store(&dc)
|
||||||
|
// lastSeenAt is also old — beyond TTL
|
||||||
|
seen := time.Now().Add(-90 * time.Minute)
|
||||||
|
c.lastSeenAt.Store(&seen)
|
||||||
|
c.suspended.Store(true)
|
||||||
|
v.registerCtx(c)
|
||||||
|
|
||||||
|
v.reapOrphanedContexts(15*time.Minute, time.Hour)
|
||||||
|
|
||||||
|
_, err := v.getCtx("stale-seen-1")
|
||||||
|
assert.Error(t, err, "context with stale lastSeenAt beyond TTL should be reaped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLastSeenAtUpdatedOnSSEConnect(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
c := newContext("seen-sse-1", "/", v)
|
||||||
|
v.registerCtx(c)
|
||||||
|
|
||||||
|
assert.Nil(t, c.lastSeenAt.Load(), "lastSeenAt should be nil before SSE connect")
|
||||||
|
|
||||||
|
// Simulate what the SSE handler does after getCtx
|
||||||
|
now := time.Now()
|
||||||
|
c.lastSeenAt.Store(&now)
|
||||||
|
|
||||||
|
got := c.lastSeenAt.Load()
|
||||||
|
assert.NotNil(t, got, "lastSeenAt should be set after SSE connect")
|
||||||
|
assert.WithinDuration(t, now, *got, time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDevModeRemovePersistedFix(t *testing.T) {
|
func TestDevModeRemovePersistedFix(t *testing.T) {
|
||||||
v := New()
|
v := New()
|
||||||
v.cfg.DevMode = true
|
v.cfg.DevMode = true
|
||||||
|
|||||||
Reference in New Issue
Block a user