summaryrefslogtreecommitdiffstats
path: root/.auto/wip-renamekeep.patch
blob: 2c3344c2a7c11407b86148d85c0fb056425b5824 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
diff --git a/idatui/domain.py b/idatui/domain.py
index b5081cf..386b3e8 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -642,6 +642,11 @@ class ListingModel:
     """
 
     PAGE = 500  # heads per server call (well under the tool's 2000 cap)
+    #: Heads refreshed together when a rename makes their text stale. One server
+    #: call per block, so a viewport costs one round trip rather than forty --
+    #: and the same size as a load page, so refreshing everything costs about
+    #: what rebuilding everything would have.
+    TEXT_BLOCK = 500
 
     def __init__(self, program: "Program", seg_start: int, seg_end: int,
                  name: str | None = None):
@@ -659,6 +664,19 @@ class ListingModel:
         # demand. _row_at[i] is the logical row where physical head i starts.
         self._row_at: list[int] = []
         self._head_eas: list[int] = []      # parallel to _heads, for bisect
+        #: Which name generation each head's TEXT was rendered at, parallel to
+        #: _heads. A rename bumps :attr:`_text_gen`; the rows themselves stay
+        #: (their addresses and row numbers are unchanged) and are re-rendered a
+        #: block at a time when something asks for them. See invalidate_text.
+        self._head_gen: list[int] = []
+        self._text_gen = 0
+        #: Whether a rename has ever staled this model. Until one has, every
+        #: read takes exactly the path it always did.
+        self._renamed = False
+        #: Set if a text refresh came back with a different head sequence, which
+        #: means something DID move the walk. Program.listing() throws the model
+        #: away when it sees this, so the next read rebuilds from scratch.
+        self.stale_structure = False
         self._rows = 0                      # total logical rows loaded
         self._ubytes: dict[int, bytes] = {}  # lazily-read bytes for those rows
         self._next: int | None = seg_start  # next address to fetch from
@@ -739,6 +757,7 @@ class ListingModel:
         cur = payload.get("cursor", {}) if isinstance(payload, dict) else {}
         page = self._build_page(rows)
         with self._lock:
+            gen = self._text_gen
             for h in page:
                 # Banner/label rows (function headers, separators, code labels)
                 # are display-only; don't index them so navigation lands on the
@@ -747,6 +766,7 @@ class ListingModel:
                     self._by_ea.setdefault(h.ea, self._rows)
                 self._row_at.append(self._rows)
                 self._head_eas.append(h.ea)
+                self._head_gen.append(gen)
                 self._heads.append(h)
                 self._rows += self._span(h)
             nxt = cur.get("next")
@@ -878,6 +898,78 @@ class ListingModel:
     def __len__(self) -> int:
         return self.loaded()
 
+    def invalidate_text(self) -> None:
+        """A rename changed how rows READ, not which rows exist.
+
+        Item boundaries are untouched by a rename, so every row keeps its
+        address and its row number — which the edit path already relies on, since
+        it restores the cursor by INDEX afterwards. Dropping the whole model
+        instead means the next jump re-walks the segment from its start: 6.4
+        seconds on bash's .text, after every single rename.
+
+        So keep the walk and mark the rendered text stale; :meth:`_ensure_text`
+        re-renders a block at a time, and refuses to splice anything back if the
+        head sequence has moved under it (which a rename cannot do, but a
+        mis-routed structural edit could).
+        """
+        with self._lock:
+            self._text_gen += 1
+            self._renamed = True
+
+    def _ensure_text(self, j0: int, j1: int) -> None:
+        """Re-render physical heads [j0, j1) if a rename staled them.
+
+        The block is snapped out to whole ADDRESS groups. A function start emits
+        three banner rows and its code row at the same ea, and a labelled
+        instruction emits two -- so a block boundary that fell inside one of
+        those groups would refetch the whole group and never line up again.
+        """
+        with self._lock:
+            gen = self._text_gen
+            n = len(self._heads)
+            j0 = max(j0, 0)
+            j1 = min(j1, n)
+            if j1 <= j0:
+                return
+            head_gen = self._head_gen
+            if all(head_gen[j] == gen for j in range(j0, j1)):
+                return
+            blk = self.TEXT_BLOCK
+            a = (j0 // blk) * blk
+            b = min(((j1 - 1) // blk + 1) * blk, n)
+            eas = self._head_eas
+            while a > 0 and eas[a - 1] == eas[a]:
+                a -= 1
+            while b < n and eas[b - 1] == eas[b]:
+                b += 1
+            last = self._heads[b - 1]
+            lo = eas[a]
+            hi = last.ea + max(last.size, 1)
+            want = [(h.ea, h.kind) for h in self._heads[a:b]]
+        try:
+            payload = self._prog.client.call(
+                "heads", addr=hex(lo), end=hex(hi),
+                count=min(len(want) + 64, 2000), annotate=True)
+        except Exception:  # noqa: BLE001 -- keep the old text rather than blank
+            return
+        rows = payload.get("heads", []) if isinstance(payload, dict) else []
+        page = self._build_page(rows)[:len(want)]
+        with self._lock:
+            if self._text_gen != gen or len(self._heads) < b:
+                return
+            if [(h.ea, h.kind) for h in page] != want:
+                # Something moved the walk, which a rename cannot do -- so this
+                # was not one. Say so and let Program.listing() rebuild, rather
+                # than sit here re-fetching a block that will never line up (and
+                # showing the old names while doing it).
+                self.stale_structure = True
+                for j in range(a, b):
+                    self._head_gen[j] = gen
+                return
+            self._heads[a:b] = page
+            for j in range(a, b):
+                self._head_gen[j] = gen
+
     def get(self, i: int) -> Head | None:
         with self._lock:
             if not (0 <= i < self._rows):
@@ -885,8 +977,23 @@ class ListingModel:
             j, off = self._phys(i)
             if j < 0:
                 return None
-            span = self._span(self._heads[j])
-            h = self._heads[j]
+            stale = self._renamed and self._head_gen[j] != self._text_gen
+            if not stale:
+                span = self._span(self._heads[j])
+                h = self._heads[j]
+        if stale:
+            # A rename staled this row's text; re-render its block (one call for
+            # the block around it, so a viewport costs one round trip). Only
+            # this path re-takes the lock -- the ordinary read stays atomic.
+            self._ensure_text(j, j + 1)
+            with self._lock:
+                if not (0 <= i < self._rows):
+                    return None
+                j, off = self._phys(i)
+                if j < 0:
+                    return None
+                span = self._span(self._heads[j])
+                h = self._heads[j]
         # Synthesis reads bytes, so do it OUTSIDE the lock: an RPC under the
         # model lock deadlocks the page loader that is filling it.
         return self._row_head(j, off) if span > 1 else h
@@ -894,6 +1001,16 @@ class ListingModel:
     def window(self, start: int, count: int) -> list[Head]:
         """``count`` logical rows from ``start`` (synthesising undefined ones)."""
         self.ensure(start + count)
+        with self._lock:
+            # _renamed stays set once a rename has happened; _ensure_text then
+            # does the precise, range-limited staleness check. Before the first
+            # rename this is one boolean and the read is exactly as it was.
+            dirty = self._renamed
+            if dirty:
+                j0 = max(self._phys(max(start, 0))[0], 0)
+                j1 = self._phys(max(min(self._rows, start + count) - 1, 0))[0] + 1
+        if dirty:
+            self._ensure_text(j0, j1)
         with self._lock:
             rows = min(self._rows, start + count)
             spans = [self._phys(i) for i in range(max(start, 0), max(rows, 0))]
@@ -1223,6 +1340,8 @@ class Program:
         start, end, name = seg
         with self._lock:
             m = self._listings.get(start)
+            if m is not None and m.stale_structure:
+                m = None        # a refresh found the walk had moved; start over
             if m is None:
                 m = ListingModel(self, start, end, name)
                 self._listings[start] = m
@@ -1406,15 +1525,24 @@ class Program:
 
     def bump_names(self) -> None:
         """Signal that symbol names changed (a rename). Disasm/listing names are
-        live in the IDB, so clearing the cached rows is enough for those;
-        decompilation is generation-checked and force-recompiled lazily."""
+        live in the IDB, so the cached rows have to be re-rendered; decompilation
+        is generation-checked and force-recompiled lazily.
+
+        The listing keeps its WALK. A rename cannot move an item boundary, so
+        every row keeps its address and its row number -- the edit path already
+        assumes exactly that, since it restores the cursor by index afterwards.
+        Dropping the segment model instead made the reload re-walk it from the
+        start, which is 6.4 seconds on bash after every rename.
+        """
         with self._lock:
             self._name_gen += 1
             models = list(self._disasm.values())
-            self._listings.clear()  # listing head rows cache names -> refetch
+            listings = list(self._listings.values())
             self._pc_nums.clear()   # a reformat moves every literal on its line
         for m in models:
             m.invalidate()
+        for lm in listings:
+            lm.invalidate_text()
 
     def bump_items(self) -> None:
         """Signal that item/function STRUCTURE changed (define code/data/func,