From d414fd273375eb2a82e7811851e883eb89db1a55 Mon Sep 17 00:00:00 2001 From: toorux Date: Sat, 17 Oct 2026 00:45:20 +0800 Subject: [PATCH] fix: handle unreachable GitHub routes and save from search fields --- tests/file_code_workspace_fixture.tsx | 9 +++++++++ tests/test_github_fallback.py | 28 +++++++++++++++++++++++++++ tools/bootstrap.py | 5 +++++ ui/src/file-code-engine.ts | 2 +- 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/tests/file_code_workspace_fixture.tsx b/tests/file_code_workspace_fixture.tsx index da4505d..7b9be2f 100644 --- a/tests/file_code_workspace_fixture.tsx +++ b/tests/file_code_workspace_fixture.tsx @@ -59,6 +59,15 @@ const until=async(f:()=>unknown)=>{for(let n=0;n<150;n++){if(f())return;await wa const [save,close]=[...document.querySelectorAll('.file-editor-heading button')];save.click();await until(()=>text.startsWith('// // ')&&save.disabled&&!close.disabled); if(text.replaceAll('\r\n','').includes('\n'))throw Error('Saving changed CRLF line endings'); const host=document.createElement('div');host.style.cssText='position:fixed;left:-5000px;width:500px;height:300px';document.body.append(host);let modified=false; + let saves=0; + const writable=window.FramelyCodeEditor!.mount({parent:host,path:'save.txt',value:'hello hello',readOnly:false,label:'Save shortcut test',phrases:{},onChange:()=>{modified=true;},onSave:()=>{saves++;}});writable.find(); + host.querySelector('[name="Toggle replace"]')!.click(); + const mac=/Mac|iPhone|iPad/.test(navigator.platform); + for(const target of [host.querySelector('.cm-content')!,...host.querySelectorAll('.file-code-search input')]){ + target.focus();const before=saves,event=new KeyboardEvent('keydown',{key:'s',code:'KeyS',ctrlKey:!mac,metaKey:mac,bubbles:true,cancelable:true});target.dispatchEvent(event); + if(saves!==before+1||!event.defaultPrevented)throw Error('Save shortcut failed in '+(target.getAttribute('name')??'document')); + } + if(modified)throw Error('Save shortcut changed document text');writable.destroy(); const readonly=window.FramelyCodeEditor!.mount({parent:host,path:'readonly.txt',value:'hello hello',readOnly:true,label:'Readonly test',phrases:{},onChange:()=>{modified=true;},onSave:()=>{}});readonly.find(); const panel=host.querySelector('.file-code-search')!,readonlyFind=panel.querySelector('[name=Find]')!;set(readonlyFind,'hello');panel.querySelector('[name="Toggle replace"]')!.click(); if(!panel.querySelector('[name=Replace]')!.disabled||!panel.querySelector('[name="replace all"]')!.disabled)throw Error('Read-only replacement allowed'); diff --git a/tests/test_github_fallback.py b/tests/test_github_fallback.py index 8915516..5c0d614 100644 --- a/tests/test_github_fallback.py +++ b/tests/test_github_fallback.py @@ -1,5 +1,6 @@ """Focused network fallback tests: no public network or device access.""" import http.server +import errno import importlib.util import io import os @@ -20,6 +21,33 @@ spec.loader.exec_module(network) class FallbackTests(unittest.TestCase): + def test_unreachable_network_retries_once_but_local_io_errors_do_not(self): + url = 'https://github.com/a/b/releases/download/v1/archive' + for code in (errno.ENETDOWN, errno.ENETUNREACH, errno.EHOSTUNREACH): + for wrapped in (False, True): + with self.subTest(code=code, wrapped=wrapped): + calls = [] + def attempt(fallback): + calls.append(fallback) + error = OSError(code, 'simulated network failure') + raise urllib.error.URLError(error) if wrapped else error + with self.assertRaisesRegex(RuntimeError, 'primary route failed.*fallback failed'): + network.github_attempt(url, attempt, enabled=True) + self.assertEqual(calls, [False, True]) + calls.clear() + with self.assertRaises((OSError, urllib.error.URLError)): + network.github_attempt(url, attempt, enabled=False) + self.assertEqual(calls, [False]) + for code in (errno.ENOSPC, errno.EACCES, errno.ENOENT, errno.EIO): + with self.subTest(code=code): + calls = [] + def attempt(fallback): + calls.append(fallback) + raise OSError(code, 'simulated local failure') + with self.assertRaises(OSError): + network.github_attempt(url, attempt, enabled=True) + self.assertEqual(calls, [False]) + def test_q2f_checksum_failure_does_not_retry(self): spec = importlib.util.spec_from_file_location('q2f', ROOT / 'tools/q2f-runtime.py') runtime = importlib.util.module_from_spec(spec); spec.loader.exec_module(runtime) diff --git a/tools/bootstrap.py b/tools/bootstrap.py index e5ab5af..4523b03 100644 --- a/tools/bootstrap.py +++ b/tools/bootstrap.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Shared device-side installation engine, also embedded in the desktop installer.""" import argparse +import errno import hashlib import json import os @@ -73,6 +74,10 @@ def retryable(error): or error.headers.get('Retry-After') is not None)) if isinstance(error, urllib.error.URLError): return retryable(error.reason) + # These routing failures are plain OSError, not ConnectionError. Keep an + # explicit network-only allowlist so disk/permission failures never retry. + if isinstance(error, OSError) and error.errno in (errno.ENETDOWN, errno.ENETUNREACH, errno.EHOSTUNREACH): + return True return isinstance(error, (socket.gaierror, TimeoutError, ConnectionError, http.client.IncompleteRead, http.client.RemoteDisconnected)) diff --git a/ui/src/file-code-engine.ts b/ui/src/file-code-engine.ts index b025f28..d6b59c2 100644 --- a/ui/src/file-code-engine.ts +++ b/ui/src/file-code-engine.ts @@ -26,7 +26,7 @@ export function mount(options:CodeEditorOptions){ lineNumbers(),highlightActiveLineGutter(),highlightSpecialChars(),history(),foldGutter(),drawSelection(),indentOnInput(),bracketMatching(),syntaxHighlighting(highlighting),highlightActiveLine(),highlightSelectionMatches(),syntax.of(language(languageForPath(options.path))),indentUnit.of(' '),EditorState.tabSize.of(4),endings.of(EditorState.lineSeparator.of(separator(options.value))), editable.of([EditorState.readOnly.of(options.readOnly),EditorView.editable.of(!options.readOnly)]),wrapping.of([]), search({createPanel:createSearchPanel}),EditorState.phrases.of(options.phrases),EditorView.contentAttributes.of({'aria-label':options.label,'data-framely-editor-input':''}), - keymap.of([{key:'Mod-s',run:()=>{options.onSave();return true;}},...defaultKeymap,...historyKeymap,...searchKeymap,...foldKeymap,indentWithTab]), + keymap.of([{key:'Mod-s',scope:'editor search-panel',run:()=>{options.onSave();return true;}},...defaultKeymap,...historyKeymap,...searchKeymap,...foldKeymap,indentWithTab]), EditorView.updateListener.of(update=>{if(update.docChanged&&!updating)options.onChange(update.state.sliceDoc());if(update.docChanged||update.selectionSet)cursor(update.state);}), EditorView.theme({'&':{height:'100%',backgroundColor:'#1c1e22',color:'#dce1e8',fontSize:'14px'},'.cm-scroller':{fontFamily:'ui-monospace,SFMono-Regular,Consolas,monospace',overflow:'auto',lineHeight:'1.7'},'.cm-content':{padding:'18px 0'},'.cm-line':{padding:'0 20px'},'.cm-gutters':{backgroundColor:'#1c1e22',color:'#7f8998',borderRight:'none',padding:'0 4px 0 12px'},'.cm-activeLineGutter':{backgroundColor:'transparent',color:'#e0e6ef'},'.cm-activeLine':{backgroundColor:'#ffffff05'},'.cm-cursor':{borderLeftColor:'#b9d8f4',borderLeftWidth:'2px'},'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection':{backgroundColor:'#42638480'},'.cm-panels':{backgroundColor:'#25282f',color:'#e0e2e6'},'.cm-textfield':{backgroundColor:'#1c1d20',color:'#e0e2e6',border:'1px solid #656b76'},'.cm-button':{backgroundImage:'none',backgroundColor:'#35383e',color:'#e0e2e6',border:'1px solid #656b76'}},{dark:true}) ]})});