| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "net/http" |
| 7 | "strings" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | func takeoverViewLocallyOwned(view SessionTakeoverView) bool { |
| 12 | return view.Mirrored || view.Holder == "external" || view.Holder == "other" |
| 13 | } |
| 14 | |
| 15 | // reconcileRemoteTabReclaimOwnership keeps an ambiguous reclaim response from |
| 16 | // changing input authority. Only a successful, generation-fenced ownership |
| 17 | // probe may update the spectator pin. |
| 18 | func (a *App) reconcileRemoteTabReclaimOwnership( |
| 19 | tabID string, |
| 20 | client *http.Client, |
| 21 | base, expectedPath string, |
| 22 | stillCurrent func(*remoteTab) bool, |
| 23 | ) { |
| 24 | a.goRemoteTabSafe("reclaimOwnershipProbe", func() { |
| 25 | probeCtx, probeCancel := context.WithTimeout(context.Background(), 15*time.Second) |
| 26 | defer probeCancel() |
| 27 | view, err := takeoverOwnership(probeCtx, client, base, expectedPath) |
| 28 | if err != nil { |
| 29 | return |
| 30 | } |
| 31 | locallyOwned := takeoverViewLocallyOwned(view) |
| 32 | a.remoteTabMu.Lock() |
| 33 | current := a.remoteTabs[tabID] |
| 34 | if !stillCurrent(current) || current.session.takenOver == locallyOwned { |
| 35 | a.remoteTabMu.Unlock() |
| 36 | return |
| 37 | } |
| 38 | current.session.takenOver = locallyOwned |
| 39 | meta := remoteTabMetaLocked(current) |
| 40 | a.remoteTabMu.Unlock() |
| 41 | a.emitRemoteEvent("remote-tab:updated", meta) |
| 42 | }) |
| 43 | } |
| 44 | |
| 45 | // remoteTabOwnershipState fences a session's return from a local writer back |
| 46 | // to Serve. Both fields share that lifetime: a committing reclaim sets them |
| 47 | // and the re-hydrated surface clears them. |
| 48 | // |
| 49 | // reclaimRevision rejects /status payloads reserved before the reclaim |
| 50 | // completed — they still carry the pre-reclaim takenOver=true and would |
| 51 | // re-pin the spectator banner after ownership returned. readyBarrierPending |
| 52 | // defers the re-hydration barrier while a turn is in flight, because firing |
| 53 | // it mid-turn bumps the frontend connection generation, orphans the |
| 54 | // optimistic submission, and leaves a zombie "processing" indicator beside |
| 55 | // the rendered reply. |
| 56 | type remoteTabOwnershipState struct { |
| 57 | reclaimRevision uint64 |
| 58 | readyBarrierPending bool |
| 59 | } |
| 60 | |
| 61 | // remoteTabReclaimObservation is the tab state a reclaim fences against: it |
| 62 | // releases remoteTabMu for a long poll, then dereferences tab. |
| 63 | type remoteTabReclaimObservation struct { |
| 64 | tab *remoteTab |
| 65 | gen uint64 |
| 66 | runtimeRevision uint64 |
| 67 | selectionRevision uint64 |
| 68 | } |
| 69 | |
| 70 | // observeRemoteTabForReclaim snapshots the tab a reclaim fences against. The |
| 71 | // tab can close or reconnect between the command-target read and this |
| 72 | // snapshot; either is a disconnected tab, not a nil or retired binding. |
| 73 | func (a *App) observeRemoteTabForReclaim(tabID string, client *http.Client) (remoteTabReclaimObservation, error) { |
| 74 | a.remoteTabMu.Lock() |
| 75 | defer a.remoteTabMu.Unlock() |
| 76 | tab := a.remoteTabs[tabID] |
| 77 | if tab == nil || tab.client != client { |
| 78 | return remoteTabReclaimObservation{}, fmt.Errorf("remote tab %q is not connected", tabID) |
| 79 | } |
| 80 | return remoteTabReclaimObservation{ |
| 81 | tab: tab, gen: tab.gen, |
| 82 | runtimeRevision: tab.runtime.revision, selectionRevision: tab.selectionRevision, |
| 83 | }, nil |
| 84 | } |
| 85 | |
| 86 | // remoteSessionTakenOver reports whether a session-entry refusal means the |
| 87 | // session is owned by a local runtime on the serve host. The tab then |
| 88 | // attaches as a read-only spectator instead of dying with the 409. All three |
| 89 | // refusal shapes match: the explicit takeover wording (mirrored session), the |
| 90 | // plain lease wording ("in use by another Reasonix process" — the holder is a |
| 91 | // local window/CLI whose transcript the file-backed /history serves anyway, |
| 92 | // and whose lease /reclaim can take back), and the final-format writer |
| 93 | // wording ("session writer is owned by another runtime" — the identity's |
| 94 | // writer.lock lives with a local runtime). |
| 95 | func remoteSessionTakenOver(err error) bool { |
| 96 | if err == nil { |
| 97 | return false |
| 98 | } |
| 99 | msg := err.Error() |
| 100 | if strings.Contains(msg, "taken over by a local Reasonix") { |
| 101 | return true |
| 102 | } |
| 103 | if strings.Contains(msg, "writer is owned by another runtime") { |
| 104 | return true |
| 105 | } |
| 106 | return strings.Contains(msg, "in use by another Reasonix process") |
| 107 | } |
| 108 |