refactor: give remote state publication and reviewed keys one owner

Problem: The generic remote event bridge recovered a tab ID by parsing the
event name to stamp hydration revisions, reviewed-version detection matched a
substring of the source key, and the integrity-reuse comment claimed the SQLite
header detects checkpoints that WAL-mode commits need not record there.

Root cause: Revision stamping was attached to the transport rather than to the
state publisher, and the reviewed-version key format had no named parser.

Fix: Route all twelve state publications through publishRemoteTabState, which
stamps the revision before emitting; emitRemoteEvent no longer inspects state
payloads. Parse reviewed keys with historicalVersionSourceKey, which requires
both a source key and a version. Correct the header comment: file identity,
size and mtime remain the primary change signal.

Verification: The reviewed-key test failed before the parser existed. Full
reasonix/desktop package (717s), remote hydration/publication and source alias
race tests, internal/projectiondb, go vet, gofmt and repolint passed.
This commit is contained in:
zumac authored and zumac committed 2026-10-25 20:22:25 -07:00
1 parent ddd0f11cd0
commit 88c3a736ba
12 files changed
+50 -31

No files matched your search

+7
View File
@@ -7,6 +7,13 @@ import (
"reasonix/desktop/internal/workspacestate"
)
// historicalVersionSourceKey reports whether key names a reviewed version: the
// original source key, ":review:", then that version's fingerprint.
func historicalVersionSourceKey(key string) bool {
source, version, reviewed := strings.Cut(key, ":review:")
return reviewed && source != "" && version != ""
}
func historicalSourceKeyMatches(mappingKey, sourceID string) bool {
return mappingKey == sourceID || strings.HasPrefix(mappingKey, sourceID+":review:")
}
+13 -10
View File
@@ -22,19 +22,22 @@ func (a *App) RemoteTabHydrationState(tabID string) (RemoteTabStateView, error)
return RemoteTabStateView{State: tab.state, Error: tab.err, HydrationRevision: tab.publication.hydrationRevision}, nil
}
// publishRemoteTabState is the only writer of the state channel, so every
// publication carries the tab's next hydration revision. Callers must not hold
// remoteTabMu.
func (a *App) publishRemoteTabState(tabID string, view RemoteTabStateView) {
a.remoteTabMu.Lock()
if tab := a.remoteTabs[tabID]; tab != nil {
tab.publication.hydrationRevision++
view.HydrationRevision = tab.publication.hydrationRevision
}
a.remoteTabMu.Unlock()
a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), view)
}
// emitRemoteEvent bridges a kernel callback to the frontend through the async
// emitter so a slow webview never blocks the kernel.
func (a *App) emitRemoteEvent(name string, payload any) {
if view, ok := payload.(RemoteTabStateView); ok && strings.HasPrefix(name, "remote-tab:") && strings.HasSuffix(name, ":state") {
tabID := strings.TrimSuffix(strings.TrimPrefix(name, "remote-tab:"), ":state")
a.remoteTabMu.Lock()
if tab := a.remoteTabs[tabID]; tab != nil {
tab.publication.hydrationRevision++
view.HydrationRevision = tab.publication.hydrationRevision
}
a.remoteTabMu.Unlock()
payload = view
}
if a.remoteEventHook != nil {
a.remoteEventHook(name, payload)
}
+1 -1
View File
@@ -612,7 +612,7 @@ listingAttempt:
if routeUpdate != nil {
a.emitRemoteEvent("remote-tab:updated", *routeUpdate)
if routeReadyBarrier {
a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", routeUpdate.ID), RemoteTabStateView{State: "ready"})
a.publishRemoteTabState(routeUpdate.ID, RemoteTabStateView{State: "ready"})
}
}
unlockRoute()
+1 -1
View File
@@ -230,7 +230,7 @@ func (a *App) publishRemoteTabAttachedReady(tabID string, gen uint64) bool {
tab.state = "ready"
tab.err = ""
a.remoteTabMu.Unlock()
a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: "ready"})
a.publishRemoteTabState(tabID, RemoteTabStateView{State: "ready"})
tab.routeEventMu.Unlock()
a.applyPendingRemoteTabOpenSelection(tabID)
return true
+1 -1
View File
@@ -289,7 +289,7 @@ func (a *App) adoptRemoteTabFrameCurrent(tabID string, gen uint64, sessionPath s
// The frontend treats ready -> ready as a new surface generation. Emit it
// before the triggering frame is forwarded so that frame is buffered until
// the newly current session snapshot replaces the old transcript.
a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: "ready"})
a.publishRemoteTabState(tabID, RemoteTabStateView{State: "ready"})
}
}
+4 -4
View File
@@ -75,7 +75,7 @@ func (a *App) reconnectRemoteTabGeneration(tabID string, gen uint64) bool {
if cancel != nil {
cancel()
}
a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: "reconnecting"})
a.publishRemoteTabState(tabID, RemoteTabStateView{State: "reconnecting"})
return startRetry
}
@@ -105,7 +105,7 @@ func (a *App) emitRemoteTabStateForGeneration(tabID string, gen uint64, state, e
tab.state = state
tab.err = errMsg
a.remoteTabMu.Unlock()
a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: state, Error: errMsg})
a.publishRemoteTabState(tabID, RemoteTabStateView{State: state, Error: errMsg})
return true
}
@@ -128,7 +128,7 @@ func (a *App) transitionRemoteTabStateLocked(tab *remoteTab, gen uint64, from, s
tab.state = state
tab.err = errMsg
a.remoteTabMu.Unlock()
a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: state, Error: errMsg})
a.publishRemoteTabState(tabID, RemoteTabStateView{State: state, Error: errMsg})
return true
}
@@ -151,7 +151,7 @@ func (a *App) emitRemoteTabStateLocked(tab *remoteTab, state, errMsg string) {
tab.state = state
tab.err = errMsg
a.remoteTabMu.Unlock()
a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: state, Error: errMsg})
a.publishRemoteTabState(tabID, RemoteTabStateView{State: state, Error: errMsg})
}
func (a *App) installRemoteTabAttachPump(ctx context.Context, tabID string, tab *remoteTab, client *http.Client, base, token, targetPath string, installRoute bool) (context.Context, uint64, uint64, error) {
+3 -3
View File
@@ -280,7 +280,7 @@ func (a *App) suspendRemoteTabPumps(hostID, state, errText string) {
if cancel != nil {
cancel()
}
a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tab.id), RemoteTabStateView{State: state, Error: errText})
a.publishRemoteTabState(tab.id, RemoteTabStateView{State: state, Error: errText})
tab.routeEventMu.Unlock()
}
}
@@ -309,7 +309,7 @@ func (a *App) parkRemoteTabsForServer(hostID, workspace, state, errText string)
if cancel != nil {
cancel()
}
a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tab.id), RemoteTabStateView{State: state, Error: errText})
a.publishRemoteTabState(tab.id, RemoteTabStateView{State: state, Error: errText})
tab.routeEventMu.Unlock()
}
return affected
@@ -340,7 +340,7 @@ func (a *App) resumeRemoteTabs(hostID string) {
}
a.remoteTabMu.Unlock()
for _, tabID := range rearmed {
a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: "reconnecting"})
a.publishRemoteTabState(tabID, RemoteTabStateView{State: "reconnecting"})
}
for _, tabID := range tabIDs {
a.goRemoteTabSafe("remoteTabReattach", func() { a.reattachRemoteTab(tabID) })
+2 -5
View File
@@ -1,9 +1,6 @@
package main
import (
"fmt"
"net/http"
)
import "net/http"
// A rejection is one publication transaction: observers of its error must
// already see the restored identity. The route fence also prevents a newer
@@ -33,6 +30,6 @@ func (a *App) completeRemoteTabResumeFailure(tabID string, tab *remoteTab, clien
a.emitRemoteEvent("remote-tab:updated", meta)
a.saveTabsFromRemote()
}
a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: "ready", Error: message})
a.publishRemoteTabState(tabID, RemoteTabStateView{State: "ready", Error: message})
return false
}
+1 -1
View File
@@ -287,7 +287,7 @@ func (a *App) recordRemoteTabSessionStatus(tabID string, client *http.Client, ge
a.emitRemoteEvent("remote-tab:updated", after)
}
if readyBarrier || deferredBarrier {
a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: "ready"})
a.publishRemoteTabState(tabID, RemoteTabStateView{State: "ready"})
}
if pathChanged {
a.goRemoteTabSafe("remoteTabStatusTitle", func() { a.refreshRemoteTabTitle(tabID) })
+1 -2
View File
@@ -2,7 +2,6 @@ package main
import (
"os"
"strings"
"sync"
"reasonix/desktop/internal/workspacestate"
@@ -42,7 +41,7 @@ func adoptedSourceRows(state workspacestate.State) map[string]bool {
func sourceMappingHasPathAlias(mapping workspacestate.SourceMapping) bool {
// A reviewed version is a separate conversation, not another owner of
// the original path. Only its versioned SourceKey may alias that session.
if strings.Contains(mapping.SourceKey, ":review:") {
if historicalVersionSourceKey(mapping.SourceKey) {
return false
}
if mapping.HeadID == "" {
+13
View File
@@ -32,6 +32,19 @@ func TestWorkspaceSourceAliasesKeepReviewedSessionsIndependent(t *testing.T) {
}
}
func TestHistoricalVersionSourceKeyRequiresSourceAndVersion(t *testing.T) {
for key, want := range map[string]bool{
"abc:review:v2": true,
"abc": false,
"abc:review:": false,
":review:v2": false,
} {
if got := historicalVersionSourceKey(key); got != want {
t.Errorf("historicalVersionSourceKey(%q) = %v, want %v", key, got, want)
}
}
}
func TestWorkspaceSourceAliasesRejectAmbiguousPathOwnership(t *testing.T) {
path := filepath.Join(t.TempDir(), "history.jsonl")
state := workspacestate.State{SourceMappings: map[string]workspacestate.SourceMapping{
+3 -3
View File
@@ -58,9 +58,9 @@ func integrityFileState(path string) (integrityCertificate, bool) {
if err != nil && !errors.Is(err, io.EOF) {
return integrityCertificate{}, false
}
// SQLite's header includes the change counter and schema cookie. Include
// them in addition to metadata, so a checkpoint on coarse-mtime platforms
// cannot reuse the proof for an earlier database state.
// The header adds the schema cookie and, outside WAL mode, the change
// counter. A WAL commit need not bump that counter, so file identity, size
// and mtime stay the primary signal that a checkpoint changed the file.
return integrityCertificate{main: main, header: string(header[:n])}, true
}