2a47b293
fix: erase stale selection pixels on lifecycle resets
a73x 2026-08-18 12:27
Commit message
web/mux.js
| Old | New | ||
|---|---|---|---|
| @@ -200,7 +200,7 @@ class Tile { | |||
| 200 | this.ws = new WebSocket(`ws://${location.host}/ws/${this.idx}`); | 200 | this.ws = new WebSocket(`ws://${location.host}/ws/${this.idx}`); |
| 201 | this.ws.binaryType = 'arraybuffer'; | 201 | this.ws.binaryType = 'arraybuffer'; |
| 202 | this.ws.onopen = () => { | 202 | this.ws.onopen = () => { |
| 203 | this.clearSelection(false); | 203 | this.clearSelection(); |
| 204 | this.wsOpened = true; | 204 | this.wsOpened = true; |
| 205 | this.wsBackoffMs = 0; // a socket that opened earns a fresh schedule | 205 | this.wsBackoffMs = 0; // a socket that opened earns a fresh schedule |
| 206 | this.wsFailures = 0; | 206 | this.wsFailures = 0; |
| @@ -261,6 +261,7 @@ class Tile { | |||
| 261 | resetCore(why) { | 261 | resetCore(why) { |
| 262 | this.clearSelection(false); | 262 | this.clearSelection(false); |
| 263 | if (this.core.mux_init(80, 24) !== 0) { | 263 | if (this.core.mux_init(80, 24) !== 0) { |
| 264 | this.clearBackingCanvas(); | ||
| 264 | this.replayDead = true; | 265 | this.replayDead = true; |
| 265 | this.setStatus('stuck', 'core failed'); | 266 | this.setStatus('stuck', 'core failed'); |
| 266 | return; | 267 | return; |
| @@ -269,6 +270,7 @@ class Tile { | |||
| 269 | this.scrollPages = 0; | 270 | this.scrollPages = 0; |
| 270 | this.renderBadge(); // scrollPages changed; the mask must lift with it | 271 | this.renderBadge(); // scrollPages changed; the mask must lift with it |
| 271 | this.drawScale = 0; // force the backing store to be re-sized | 272 | this.drawScale = 0; // force the backing store to be re-sized |
| 273 | this.reflow(); // paint only after mux_init made the replacement core valid | ||
| 272 | this.replayFailed(`re-initialized the core after ${why}`, 'replay failed'); | 274 | this.replayFailed(`re-initialized the core after ${why}`, 'replay failed'); |
| 273 | } | 275 | } |
| 274 | 276 | ||
| @@ -304,7 +306,7 @@ class Tile { | |||
| 304 | // control message included. A tile that gave up on replaying must | 306 | // control message included. A tile that gave up on replaying must |
| 305 | // not be talked back into asking for the same frame again. | 307 | // not be talked back into asking for the same frame again. |
| 306 | if (this.replayDead) return; | 308 | if (this.replayDead) return; |
| 307 | this.clearSelection(false); | 309 | this.clearSelection(); |
| 308 | const cols = this.zoomed ? this.zoomCols() : 1; | 310 | const cols = this.zoomed ? this.zoomCols() : 1; |
| 309 | const rows = this.zoomed ? this.zoomRows() : 1; | 311 | const rows = this.zoomed ? this.zoomRows() : 1; |
| 310 | const n = this.core.mux_attach_payload(cols, rows, fresh ? 1 : 0); | 312 | const n = this.core.mux_attach_payload(cols, rows, fresh ? 1 : 0); |
| @@ -612,10 +614,11 @@ class Tile { | |||
| 612 | } | 614 | } |
| 613 | 615 | ||
| 614 | clearSelection(repaint = true) { | 616 | clearSelection(repaint = true) { |
| 617 | const hadSelection = this.selection !== null; | ||
| 615 | this.selection = null; | 618 | this.selection = null; |
| 616 | this.stopSelectionDrag(); | 619 | this.stopSelectionDrag(); |
| 617 | this.releaseSelectionCopyUi(); | 620 | this.releaseSelectionCopyUi(); |
| 618 | if (repaint && this.core) this.reflow(); | 621 | if (repaint && hadSelection && this.core) this.reflow(); |
| 619 | } | 622 | } |
| 620 | 623 | ||
| 621 | stopSelectionDrag() { | 624 | stopSelectionDrag() { |
| @@ -677,6 +680,14 @@ class Tile { | |||
| 677 | autoScrollSelection() {} | 680 | autoScrollSelection() {} |
| 678 | 681 | ||
| 679 | // --- painting --- | 682 | // --- painting --- |
| 683 | clearBackingCanvas() { | ||
| 684 | // resetCore calls this only when mux_init failed, so it must not consult | ||
| 685 | // any geometry in the invalid old core. | ||
| 686 | this.ctx.setTransform(1, 0, 0, 1, 0, 0); | ||
| 687 | this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); | ||
| 688 | this.drawScale = 0; | ||
| 689 | } | ||
| 690 | |||
| 680 | // A wall tile shows a full 80+ column grid in ~420 CSS pixels, so it | 691 | // A wall tile shows a full 80+ column grid in ~420 CSS pixels, so it |
| 681 | // must be drawn small. Two ways to do that, and only one is legible: | 692 | // must be drawn small. Two ways to do that, and only one is legible: |
| 682 | // draw at full logical size into a big bitmap and let CSS squash it | 693 | // draw at full logical size into a big bitmap and let CSS squash it |
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -390,7 +390,7 @@ function browserShell(source) { | |||
| 390 | getContext() { | 390 | getContext() { |
| 391 | return { | 391 | return { |
| 392 | measureText: () => ({ width: 8, fontBoundingBoxAscent: 11, fontBoundingBoxDescent: 3 }), | 392 | measureText: () => ({ width: 8, fontBoundingBoxAscent: 11, fontBoundingBoxDescent: 3 }), |
| 393 | setTransform() {}, fillRect() {}, fillText() {}, | 393 | setTransform() {}, clearRect() {}, fillRect() {}, fillText() {}, |
| 394 | }; | 394 | }; |
| 395 | } | 395 | } |
| 396 | focus() { activeElement = this; } | 396 | focus() { activeElement = this; } |
| @@ -839,6 +839,40 @@ async function verifySelectionShell(shell, html) { | |||
| 839 | JSON.stringify(coordinate.tile.cellAtPointer({ clientX: -50, clientY: 999 })), | 839 | JSON.stringify(coordinate.tile.cellAtPointer({ clientX: -50, clientY: 999 })), |
| 840 | JSON.stringify({ col: 0, viewRow: 4, row: 44 }), | 840 | JSON.stringify({ col: 0, viewRow: 4, row: 44 }), |
| 841 | ); | 841 | ); |
| 842 | coordinate.tile.drawScale = 0.5; | ||
| 843 | h.context.window.devicePixelRatio = 2; | ||
| 844 | check( | ||
| 845 | 'fractional canvas scale maps CSS pixels without applying DPR', | ||
| 846 | JSON.stringify(coordinate.tile.cellAtPointer({ clientX: 23, clientY: 36 })), | ||
| 847 | JSON.stringify({ col: 3, viewRow: 2, row: 42 }), | ||
| 848 | ); | ||
| 849 | h.context.window.devicePixelRatio = 1; | ||
| 850 | const withoutScaleDivisor = shell | ||
| 851 | .replace('/ this.drawScale / METRICS.w', '/ METRICS.w') | ||
| 852 | .replace('/ this.drawScale / METRICS.h', '/ METRICS.h'); | ||
| 853 | check( | ||
| 854 | 'fractional-scale mutation removes both scale divisors', | ||
| 855 | shell.includes('/ this.drawScale / METRICS.w') | ||
| 856 | && shell.includes('/ this.drawScale / METRICS.h') | ||
| 857 | && !withoutScaleDivisor.includes('/ this.drawScale / METRICS.w') | ||
| 858 | && !withoutScaleDivisor.includes('/ this.drawScale / METRICS.h'), | ||
| 859 | true, | ||
| 860 | ); | ||
| 861 | const mutatedScale = browserShell(withoutScaleDivisor); | ||
| 862 | const mutatedTile = new mutatedScale.Tile( | ||
| 863 | 5, 'scale mutation', mutatedScale.document.createElement('div'), '', | ||
| 864 | ); | ||
| 865 | mutatedTile.core = { mux_cols: () => 10, mux_rows: () => 5 }; | ||
| 866 | mutatedTile.drawScale = 0.5; | ||
| 867 | mutatedTile.viewStartRow = 40; | ||
| 868 | mutatedTile.canvas.rect = { left: 10, top: 20, width: 80, height: 70 }; | ||
| 869 | mutatedScale.context.window.devicePixelRatio = 2; | ||
| 870 | check( | ||
| 871 | 'removing the draw-scale divisor changes real pointer mapping', | ||
| 872 | JSON.stringify(mutatedTile.cellAtPointer({ clientX: 23, clientY: 36 })) | ||
| 873 | !== JSON.stringify({ col: 3, viewRow: 2, row: 42 }), | ||
| 874 | true, | ||
| 875 | ); | ||
| 842 | 876 | ||
| 843 | const ignored = makeTile(); | 877 | const ignored = makeTile(); |
| 844 | ignored.tile.zoomed = false; | 878 | ignored.tile.zoomed = false; |
| @@ -1203,6 +1237,66 @@ async function verifySelectionShell(shell, html) { | |||
| 1203 | }; | 1237 | }; |
| 1204 | reattach.tile.sendAttach(false); | 1238 | reattach.tile.sendAttach(false); |
| 1205 | check('reattach clears selection text from prior session', reattach.tile.selection, null); | 1239 | check('reattach clears selection text from prior session', reattach.tile.selection, null); |
| 1240 | |||
| 1241 | const openPaint = makeTile(); | ||
| 1242 | delete openPaint.tile.reflow; | ||
| 1243 | const openOps = []; | ||
| 1244 | openPaint.tile.core.mux_mark_all_dirty = () => { openOps.push('mark'); }; | ||
| 1245 | openPaint.tile.paintLive = () => { openOps.push('live'); }; | ||
| 1246 | openPaint.tile.paintScroll = () => { openOps.push('scroll'); }; | ||
| 1247 | openPaint.tile.selection = { | ||
| 1248 | anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 50, text: 'painted', | ||
| 1249 | }; | ||
| 1250 | openPaint.tile.connect(); | ||
| 1251 | openPaint.tile.ws.onopen(); | ||
| 1252 | check('socket open erases retained live selection pixels once', openOps.join('|'), 'mark|live'); | ||
| 1253 | |||
| 1254 | const attachPaint = makeTile(); | ||
| 1255 | delete attachPaint.tile.reflow; | ||
| 1256 | const attachOps = []; | ||
| 1257 | attachPaint.tile.scrollPages = 2; | ||
| 1258 | attachPaint.tile.core.mux_mark_all_dirty = () => { attachOps.push('mark'); }; | ||
| 1259 | attachPaint.tile.paintLive = () => { attachOps.push('live'); }; | ||
| 1260 | attachPaint.tile.paintScroll = () => { attachOps.push('scroll'); }; | ||
| 1261 | attachPaint.tile.selection = { | ||
| 1262 | anchor: { row: 20, col: 1 }, active: { row: 21, col: 2 }, requestId: 51, text: 'history pixels', | ||
| 1263 | }; | ||
| 1264 | attachPaint.tile.sendAttach(false); | ||
| 1265 | check('reattach erases retained history selection pixels once', attachOps.join('|'), 'mark|scroll'); | ||
| 1266 | |||
| 1267 | const resetPaint = makeTile(); | ||
| 1268 | delete resetPaint.tile.reflow; | ||
| 1269 | const resetOps = []; | ||
| 1270 | resetPaint.tile.scrollPages = 2; | ||
| 1271 | resetPaint.tile.core.mux_mark_all_dirty = () => { resetOps.push('mark'); }; | ||
| 1272 | resetPaint.tile.paintLive = () => { resetOps.push('live'); }; | ||
| 1273 | resetPaint.tile.paintScroll = () => { resetOps.push('scroll'); }; | ||
| 1274 | resetPaint.tile.selection = { | ||
| 1275 | anchor: { row: 20, col: 1 }, active: { row: 21, col: 2 }, requestId: 52, text: 'old core', | ||
| 1276 | }; | ||
| 1277 | resetPaint.tile.resetCore('paint safety test'); | ||
| 1278 | check('successful destructive reset repaints only the new live core', resetOps.join('|'), 'mark|live'); | ||
| 1279 | |||
| 1280 | const failedResetPaint = makeTile(); | ||
| 1281 | delete failedResetPaint.tile.reflow; | ||
| 1282 | const failedResetOps = []; | ||
| 1283 | failedResetPaint.tile.canvas.width = 640; | ||
| 1284 | failedResetPaint.tile.canvas.height = 480; | ||
| 1285 | failedResetPaint.tile.ctx.setTransform = (...args) => { failedResetOps.push(`transform:${args.join(',')}`); }; | ||
| 1286 | failedResetPaint.tile.ctx.clearRect = (...args) => { failedResetOps.push(`clear:${args.join(',')}`); }; | ||
| 1287 | failedResetPaint.tile.core.mux_init = () => -1; | ||
| 1288 | failedResetPaint.tile.core.mux_mark_all_dirty = () => { throw new Error('invalid old core read'); }; | ||
| 1289 | failedResetPaint.tile.paintLive = () => { throw new Error('invalid old live paint'); }; | ||
| 1290 | failedResetPaint.tile.paintScroll = () => { throw new Error('invalid old scroll paint'); }; | ||
| 1291 | failedResetPaint.tile.selection = { | ||
| 1292 | anchor: { row: 1, col: 1 }, active: { row: 1, col: 2 }, requestId: 53, text: 'must disappear', | ||
| 1293 | }; | ||
| 1294 | failedResetPaint.tile.resetCore('failed paint safety test'); | ||
| 1295 | check( | ||
| 1296 | 'failed destructive reset clears backing pixels without reading invalid core', | ||
| 1297 | failedResetOps.join('|'), | ||
| 1298 | 'transform:1,0,0,1,0,0|clear:0,0,640,480', | ||
| 1299 | ); | ||
| 1206 | } | 1300 | } |
| 1207 | 1301 | ||
| 1208 | // --- wire builders (layouts golden-pinned in protocol.zig) --- | 1302 | // --- wire builders (layouts golden-pinned in protocol.zig) --- |