a73x

2a47b293

fix: erase stale selection pixels on lifecycle resets

a73x   2026-08-18 12:27

Commit message
fix: erase stale selection pixels on lifecycle resets

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) ---