Repository navigation
Conversation
60c383a to
4abe67d
Compare
|
@claude review |
|
Claude finished @ghackett's task in 4m 27s —— View job ReviewCompared against This is a clean, well-scoped port of §3.23's editor half. The service side ( One real finding from reading the new
A few smaller observations, none blocking:
VerificationI was not able to run |
|
🤖 Addressed the review above in beed056.
|
870885e to
02083ac
Compare
The editor opens no file of the project's and writes none: fs.read, fs.write (expect_mtime, refused stale with the file untouched) and fs.watch / fs.unwatch pushing file-changed are served by service/files.py's Files, confined by files.allowed, every read, write and stat on a worker thread behind a Deferred; remotefiles.py is the client's end and editor.py's open_file, _save, save_all, _reload_from_disk, _check_external and _retarget_open run over it, GtkSource.File, FileLoader and FileSaver gone. A text over the frame cap crosses chunked either way (split_request / join_request beside split_reply). tests/test_files.py, test_remotefiles.py, the protocol pins, two chunking cases in test_api_server.py; check_editor_save.py, and check_editor_narrow.py against a scratch service. The pathless allowlist loses the editor's three sites. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_011sbC3wuGjnM1AysiE7RjJ8
…d is still judged _on_loaded's failure branch returned before _settle_pending_change, so a file-changed queued behind a reload whose read then failed (the file gone or binary between the monitor and the read) was dropped: the buffer never learnt the file was gone. The reload branch settles it now; check_editor_save.py drives the race. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_011sbC3wuGjnM1AysiE7RjJ8
… pane's watches end with its tab, a seeded watch, the saver's rules The three must-fixes: a buffer the file never filled is not saved and its view is not editable until the first fill (a Ctrl+S during the first read wrote an empty buffer over the file); EditorPane.shutdown drops every watch, called by TerminalTab.release_editor where the window closes a tab for good and when the window is destroyed, and the watcher holds a bound-method listener weakly (a dropped pane and its buffers lived on through their watches); fs.watch takes the client's mtime seed, the first stat compares against it on a thread and a file that already differs is a file-changed at once, and reset re-sends the current seed (a change between the read and the watch, or while the client was away, was lost). The should-fixes: the write follows the saver it replaced (in place for a hard link, a read-only directory or another owner's file, else a temporary file with the old mode and one replace, a new file under the umask); the compare runs right before the write and the reply's mtime is the written descriptor's; saves are serialized; the request chunk budget is 4 x FILE_TEXT_MAX per connection; the implicit trailing newline is stripped on fill and put back on save; confinement runs on the worker against roots from the main loop, on the resolved file. Nits: the load-guard docstrings and the dead cap, name-free service msgids, a non-blocking read open with the kind off the descriptor, a watches-per-client bound, mtime and size compared, the banner when a save's encoding differs from the read's. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_011sbC3wuGjnM1AysiE7RjJ8
…te load installs no watch A 0444 file of the user's own in a writable directory took the replace branch and was swapped out; it is refused Permission denied as g_file_replace refused it. A read or write landing after EditorPane.shutdown filled the buffer and watched the file again (a restored tab closed soon after its files reopened); shutdown sets _shut and the landings install nothing. A chunked request's frames and its text go out in one I/O job; a save of a clean buffer during a silent reload is a no-op. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_011sbC3wuGjnM1AysiE7RjJ8
9a93011 to
a9d2b2c
Compare
The third chunk of Phase 2 of the split-service spec (
~/specs/collins/split-service-and-client.md§3.23 "Files over the API", the "Editor" paragraph): the editor panel opens no file of the project's and writes none. Every open is anfs.read, every save anfs.writethe service refusesstalewhen the file moved underneath the buffer, and every file monitor a watch installed on the service pushingfile-changed.GtkSource.File,FileLoaderandFileSaverare gone fromeditor.py. Stacked on PR 610 (split/2.2-freshness-blobsat its final 2a472f4), itself on PR 608 (split/2.1-git-over-apiat 2192055); the three commits were rebased onto it once it opened, withcore.py,protocol.pyandtest_api_server.pymerged to carry both chunks.What the service serves (
collins/service/files.py)fs.read {path, max}: the file's bytes decoded as UTF-8 or, when they are not UTF-8, as latin-1 with theencodingflagged (so the save writes them back the same way);binary(a NUL in the first 8 KiB) with an empty text; themtime(microseconds, asgit.info'sindex_mtime) the save that follows expects, and thesize. Refusedrefusedovermax(5 MiB,protocol.FILE_TEXT_MAX, at most), for a path that is not a regular file, and outside every root;gonefor a file that is not there.fs.write {path, text, expect_mtime, encoding}: the text written the wayGtkSource.FileSaver(g_file_replace) wrote it: in place (open, compare off the descriptor, write, truncate, fsync) for a file that is one of several hard links, one in a directory the service cannot write, or one another user or group owns; otherwise by a temporary file beside it with the old mode and oneos.replace, compared again right before the replace; a file that is not there is created0o666under the umask. A symlink is followed to the file it names (a link inside the project never turns into a copy). The compare is againstexpect_mtime: a file that moved is refusedstale(PR 608's error code) and left exactly as it is;expect_mtime: nullwrites regardless (the Overwrite the user confirmed, a new file). The reply's mtime and size are read off the written descriptor. A text latin-1 cannot carry is written as UTF-8 and the reply says so.fs.watch {path, kind: file, handle, mtime}/fs.unwatch {handle}: oneGio.FileMonitorper client and handle (_FileWatch, the editor's monitor moved here with its 300 ms debounce; at most 512 per client), seeded by a first stat on a thread compared against the client'smtime(the file as it last read or wrote it): a file that already differs is onefile-changed {handle, path, mtime, size, gone}at once (a change between the read and the watch, or while the client was disconnected), then one per burst whose stat moved. A handle watched again replaces its earlier watch; a client going away drops its watches.kind: diris in the table for PR-2.4 and refused here.localclient): the roots are computed on the main loop and the check runs on the worker (files.confined), on the path resolved through its symlinks right before the open, so a link swapped between the request and the read points nowhere outside. The read's open is non-blocking and the file's kind is read off the descriptor (a FIFO swapped in is refused, not waited on). Nothing blocks the main loop: every read, write and stat runs on a daemon thread behind aprotocol.Deferredsettled atPRIORITY_DEFAULT, and a worker that raises settles afailedrefusal with the exception's words, so a client is never left waiting.ServiceCoreroutes the four types tocore.filesand shuts the watches down with the feed's. The service's refusal words name no file (the client puts the name in front).The transport: chunking both ways
A file is at most 5 MiB and a frame 1 MiB, so
fs.read's reply andfs.write's request can both exceed the cap. PR 608's chunked reply (stdoutasTAG_BLOBframes ahead of a slim reply) is generalised:protocol.split_message/join_messagechunk the first ofCHUNKED_FIELDS(stdout,text) a message holds, as<field>_chunked/<field>_bytes;split_reply/join_replykeep their names, andsplit_request/join_requestare the client's and the server's halves for a request.api/client.pysends a request's frames ahead of the slim request on the same connection (_send_request);api/server.pybuffersTAG_BLOBframes per connection by stream (a budget of 4 ×FILE_TEXT_MAXbytes per connection, the oldest transfer dropped past it) and joins a request before validating it, refusing one short of its bytesinvalid. §3.2's rule ("never send a frame over 1 MiB; larger transfers are chunked",0x03either way) already said so; this is its request half.The client (
collins/remotefiles.py,collins/editor.py)remotefiles.read/writeare the two blocking calls (the sync channel, from a worker thread);is_staletells the refusal apart;refusal_wordstranslates the service's msgid for the banner.remotefiles.Watchermints a handle per watch, sendsfs.watch(with the caller's mtime seed, kept current byupdate) /fs.unwatchbysend, hands eachfile-changedfor a handle to its listener (a bound method is held weakly,weakref.WeakMethod, and a dead one's watch is dropped at the next event) andreset()re-sends every live watch with its current seed after a reconnect (the service's are gone with it, and so would be anything written meanwhile: the seed catches it);install(link)inApp._install_file_transport,reset()inApp._on_connected.editor.py:_start_loadreads on a thread (_off_main: a daemon thread, the answer landed atGLib.PRIORITY_DEFAULT) and_on_loadedfills the buffer outside the undo history (_fill), keepingmtime,sizeandencodingon the_OpenFile, stripping the file's final newline when the buffer's implicit trailing newline is on (whatFileLoaderdid;_do_saveputs it back, so no file shows an empty last line and a save adds a missing final newline) and marking the bufferfilled: the view is not editable until then, and a save before it is refused ("still loading") rather than writing an empty buffer over the file. A refusal's words land in the banner, closing a fresh open's tab and keeping a reload's;load_idstill makes a superseded read (a rename's) a no-op. The language guess reads the shebang off the text the service answered (editorfiles.first_line) andshould_highlighttakes the reply's size._save(Ctrl+S) expects the last mtime and_on_savedraises the "changed on disk" dialog onstale, whose Overwrite saves withexpect_mtime: null;save_alland the close flows' Save pass null from the start (the user's explicit consent, as before); saves are serialized (a second Ctrl+S during one waits and goes next with the mtime the first answered); a save written in another encoding than the read's is told._watch_external_changesinstalls the watch seeded withopened.mtimethrough the bound method_on_file_changed(handle →_OpenFilein_watched);_check_externaljudges afile-changedagainstopened.mtimeandsize: a clean buffer reloads silently (cursor kept), a dirty one is told,gonemarks it dirty and says so. An event that lands while a save or load is in flight waits (pending_change) for the reply's mtime, so the editor's own write never reads as a change._retarget_openre-watches the new path (a rename keeps the mtime);_teardown_pageunwatches one file andEditorPane.shutdownevery one, called byTerminalTab.release_editorwhere the window closes a tab for good and when the window is destroyed.editorfiles.read_first_line,should_highlight(path)andload_guard(the editor's three disk reads) are gone:first_line(text)andshould_highlight(size)are pure, and the load guard's three answers (a regular file, the size cap, the NUL sniff) arefs.read's.install(link)runs behind thefilescapability of the service's hello (App._install_file_transport, on every connect like_install_git_transport); without it no watch is sent, and the reads and writes an open or a save still sends come back refused into the banner.The pathless allowlist
Six entries leave the PR-2.3 group (
editor:EditorPane._watch_external_changes:…monitor_file,editorfiles:read_first_line:open,editorfiles:should_highlight:Path.stat, the threeeditorfiles:load_guard:*sites PR 608's wider walker found);request_root:Path.is_dir,set_agent_files:Path.is_file,image_guard's andgitsidebar._file_menu_items' stay, noted asfs.statreads (PR-2.4), left where the list put them. Nothing was added.Tests and checks
tests/test_files.py: a read's fields; a watch seeded with an older mtime reports the change at once (a matching or null seed none, a vanished filegone); watches per client bounded; a write through a hard link updates both names, into a read-only directory writes in place (and stays stale-safe), a recreated file gets the umask's mode, the reply's mtime is the written file's own; confinement checked on the worker against the resolved file (a link out refused for read and write, a link in read); a FIFO refused, not waited on; latin-1 flagged; binary flagged with no text; a read over 5 MiB refused (and over the request's ownmax; at the cap exactly, read); a directory and a missing file refused; read, write and watch confined for a non-local client, admitted under a store root, a symlink out refused; a write round-trips with the mtime the next read has and leaves no temp file; a write with a moved mtime refusedstaleand the file untouched, null writing regardless; a vanished file with an expectation stale; the mode kept and a symlink followed; latin-1 written back and UTF-8 when it cannot carry the text; a failurefailedwith the reason; a read is a Deferred settled later on the dispatcher; a worker that raises settlesfailed; a watch reports a change once per debounce with the file's stat (two writes, one event; a burst that changed nothing, none), the real monitor fires, a deletion isgone, a handle re-watched replaces,unwatchand a client going away drop, a directory kind is refused.tests/test_remotefiles.py(the calls and their records,staletold apart, the watcher's handles, seeds and routing,updateandresetre-sending the live watches with their current seed, a refused watch logged and kept, a listener that raises contained, no watch sent with no link installed, a bound-method listener held weakly and its watch dropped when it dies),tests/test_api_server.py(afs.writeover the frame cap arrives in chunks, is joined and written, and thefs.readof it comes back chunked; a chunked request short of its bytes isinvalidand writes nothing),tests/test_protocol.pypins the five new types, thefilescap and every field's bounds;tests/test_editorfiles.pyfollows the two pure helpers.scripts/check_editor_save.py(new, registered inrun_e2e.pyat an estimated weight): a realEditorPaneagainst a scratch service throughe2e_service.harness_link(): open (the text with no empty last line, the mtime, a watch, the language, the view editable once filled), edit, an external write over the dirty buffer raising the Reload banner with the edit kept, Reload bringing the disk's text and mtime, a save after it landing on disk with no banner and no reload from the editor's own write, a clean buffer following the disk silently with the cursor kept, a stale save asking (the dialog stubbed) with nothing written until Overwrite, a deleted file told and marked dirty, a change queued behind a failed reload still judged, a save before the first read lands refused with the file untouched, a change between the read and the watch reaching the buffer (the seed), the pane'sshutdowndropping every watch, a closed tab's watch gone, a binary file refused with a banner and no tab. 57 checks.scripts/check_editor_narrow.pygains the same two lines (a scratch service andremotefiles.install) so its opens have somewhere to read from; no assertion, argument or expected value changed.After the Opus review (the second push)
The review's three must-fixes and should-fixes 4 to 9 are in: a save of a never-filled buffer refused and the view non-editable until the first fill (1);
EditorPane.shutdownfrom the tab's close and weak listeners in the watcher (2);fs.watch'smtimeseed, compared on a thread, delivered at once, re-sent byreset(3); the saver's in-place / temp-file / new-file rules with the mode, owner and hard links kept (4); the compare right before the write and the reply's mtime off the written descriptor (5); serialized saves (6); the request chunk budget (7); the implicit trailing newline (8); confinement on the worker against roots from the main loop (9). Nits: the load-guard docstrings and the dead cap, the doubled error text (the service's msgids name no file), the non-blocking read open with the kind read off the descriptor, a watches-per-client bound, mtime and size compared, and the banner when a save's encoding differs from the read's. Not taken:Files._laterstays besideGitFeed._later(their refusals differ, and one shared helper is a later tidy).The verification pass added two: a read-only file of the user's own (0444 in a writable directory) is refused
failed"Permission denied" rather than swapped out by the replace (whatg_file_replacedid; tested), and a read or write that lands afterEditorPane.shutdownfills nothing and installs no watch (_shut; the e2e slows a read and shuts the pane under it). Plus: a chunked request'sTAG_BLOBframes and its slim text go out in one I/O job, so two threads' large saves never interleave on the connection; Ctrl+S on a clean buffer mid silent reload is a no-op instead of a spurious stale dialog. A caveat worth knowing: the in-place branch (a hard link, a read-only directory, another owner's file) writes then truncates, so a crash in the middle of that write leaves the new head and the old tail — the saver this replaces was no safer on that branch, and the temp-and-replace branch is atomic.Deviations from §3.23, each with its reason
split_request/join_request, the server's per-connection chunk buffer): §3.23 givesfs.reada 5 MiBmaxandfs.writethe same text, and PR 608's chunking covered replies alone. Without it a file over 1 MiB would open and never save. The rule is §3.2's; only the request half is new.fs.writecarriesencodingand the reply says which was written: §3.23's read flags latin-1 "so the save writes them back the same way", which needs the save to be told; a text latin-1 cannot carry falls back to UTF-8 rather than failing the save (what the user typed is worth more than the file's old encoding).fs.readanswersgonefor a missing file rather thanrefused:goneis the code for "what the request names does not exist".kind: diris infs.watch's table and refused: the wire shape is PR-2.4's to serve, pinned now so the field does not change under it.check_editor_narrow.pychanged by its call path only (a scratch service and the module install), as §5 allows: an editor with no link to read from cannot open the two files the check opens.filescap joinsCAPABILITIESbesidegit, is advertised inhelloand gates the client's install the way PR 608'sgitcap does.What PR-2.4 and PR-2.5 need to know
files.Filesis the home forfs.list/fs.walk/fs.statandkind: dirwatches (_FileWatchis a file's; a directory's wantsmonitor_directoryand adir-changed {handle, path}event type added to the table).remotefiles.Watcherkeys by handle and takes any listener, so a tree's watch can share it.editor.request_root'sPath.is_diris the one editor site left on the allowlist, yours withfs.stat.editorfiles.load_guard/image_guardstill exist and read the disk; nothing in the editor callsload_guardany more._retarget_open) re-watches the new path and keepsopened.mtime(a rename keeps the file's mtime); iffs.renameon the service lands the file at a different mtime, hand it back in the reply so the editor can take it.fs.trashinfiles.pyis untouched by this PR (PR 608's review moves it onto a thread).Files._later(message, name, work)is the Deferred pattern with the refusal on a raised worker;files.file_stat/files.mtime_usare the stat and the wire's mtime unit.Verification
python3 -m pytest tests/ -q(9000 passed) andruff check collins/ tests/clean; the full headless e2e suite green except the known local-onlycheck_notifications.pysound-row case ("the combo sits on Custom…");check_editor_narrow.py22/22 andcheck_editor_save.py57/57 under the headless shell; by hand through the harness on scratchXDG_*dirs: open, edit, write the file externally, see the Reload banner, reload, save (whatcheck_editor_save.pydrives through the real widgets against the scratch service), a 2 MiB file written chunked and read back chunked (test_api_server.py).🤖 Generated with Claude Code
https://claude.ai/code/session_011sbC3wuGjnM1AysiE7RjJ8