fix(server): idempotent fileRead({ifExists: true}) for race-prone batch reads#36
Open
nsyring wants to merge 1 commit intoagent0ai:mainfrom
Open
fix(server): idempotent fileRead({ifExists: true}) for race-prone batch reads#36nsyring wants to merge 1 commit intoagent0ai:mainfrom
nsyring wants to merge 1 commit intoagent0ai:mainfrom
Conversation
…ch reads
Two pathologies were silently logging 404s in DevTools and routing through
installFetchProxy's retry path on every space switch and config-load:
1. Batch readers in spaces/storage.js (readSpace, parseWidgetFiles batch,
listSpaces) walk the path index and then read all matched files. A
widget or manifest can be deleted between the index walk and the read
(concurrent space deletion, file_explorer rename, watchdog catching
up), failing the entire batch.
2. Optional config readers across the codebase load files like
~/conf/dashboard.yaml, ~/conf/onscreen-agent.yaml,
~/conf/personality.system.include.md, ~/user.yaml. On a fresh user
account these never existed, so the load wraps the call in
try/catch + isMissingFileError and treats 404 as "use defaults". The
404 is suppressed in JS but still logged by the browser before
JavaScript can intercept it.
This PR adds an opt-in `ifExists: true` option to fileRead, mirroring the
shape of fileDelete({ifExists: true}) (PR agent0ai#35). Server returns 200; missing
paths appear under `skipped[]`; the singular form returns
{content: null, encoding: null, path: null, skipped: [requested]}. Strict
semantics remain the default for user-initiated reads and known-must-exist
paths so a real "this resource is gone" diagnostic still surfaces.
Server side:
- normalizeReadRequests reads options.ifExists; missing paths go into
skipped[] instead of throwing 404. Other resolution failures (empty
path, directory-instead-of-file, public-only, permission) still throw.
- readAppFiles surfaces skipped on the result envelope when non-empty
and additionally catches ENOENT from fs.readFileSync when ifExists is
set, closing the race where the path index says the file is there
but it has been removed externally since the last scan.
- readAppFile keeps its singular contract; returns content: null /
encoding: null / path: null when the only path was skipped.
- file_read endpoint reads ifExists from POST body or GET query
(?ifExists=1) so the bare-path GET form keeps working without
forcing every idempotent read into POST.
Client side:
- createFileReadRequest accepts a third positional argument for the
bare-path/array forms and an ifExists field on the object forms.
Strict bodies/queries byte-identical to today.
- fileRead branches: idempotent reads bypass the file-read batching
queue and call directly into call("file_read", ...). Mixing strict
and idempotent modes in one batch would change the strict caller's
behaviour, so the bypass keeps each idempotent call's semantic
isolated. The queue's batching benefit is preserved for the strict
default.
Migrated callers:
Pathology 1 (race-prone batch reads in spaces/storage.js):
- readSpace(...) — manifest + widgets batch
- parseWidgetFiles(...) batch — widget batch
- listSpaces(...) bulk — manifest+widgets across all spaces
Pathology 2 (optional config readers; previously try/catch + 404):
- agent/storage.js loadAgentPersonality
- user/storage.js readUserConfig
- dashboard_welcome/dashboard-prefs.js loadDashboardPrefs
- onscreen_agent/storage.js loadOnscreenAgentConfig + loadOnscreenAgentHistory
- admin/views/agent/storage.js loadAdminChatConfig + loadAdminChatHistory
- login_hooks/login-hooks.js hasFirstLoginMarker (fileRead fallback path)
- panels/panel-index.js listPanels batch
Each of these had its own copy of the isMissingFileError(...) helper for
the same regex. Where the helper has no remaining users in a file, this
PR removes it; module_remove and other strict callers keep their copies.
server/api/AGENTS.md documents the new option alongside the existing
file_write operation-modes documentation.
Strict callers untouched: readWidgetFile, readManifestFile's duplicateSpace
caller, all single-file reads paired with a known-existing target.
ee2f57c to
fd1d78b
Compare
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix(server): idempotent
fileRead({ ifExists: true })for race-prone batch readsSummary
fileReadis currently always strict: reading a path that no longer exists throws HTTP 404. Several callers in the codebase already work around this with patterns likeresult.files.find(...) || files[0] || nullbecause their use case (load all manifests in a space-list, batch-read all widgets while another tab might be deleting one) is naturally idempotent. Today these callers can either pre-check existence (extra round trip) or accept that the browser logsPOST /api/file_read 404whenever the pathIndex disagrees with disk.This PR adds an opt-in
ifExists: trueoption tofileRead, mirroring the symmetric option just landed onfileDelete. The server returns200. Missing paths appear underskipped[]; the singular form returns{ content: null, encoding: null, path: null, skipped: [requested] }. Strict semantics remain the default for user-initiated reads and known-must-exist paths.After:
Why
Concrete reproduction: leaving a space, while a sibling tab or the file_explorer has just deleted a widget, generates a red
POST http://127.0.0.1:NNNNN/api/file_read 404 (Not Found)in the DevTools console. The trigger is the batchfileRead({ files: [manifestPath, ...widgetPaths] })inreadSpace(...)andlistSpaces(...). The path list comes fromlistSpaceWidgetPaths(...), which itself walks the path index — so a file that has been removed between the index walk and the read fails the entire batch. The downstream code atreadSpace:1778already accepts a partialfiles[]array (files.find(...) || files[0] || null), so the strict 404 is over-conservative for this use case. Without this PR, the call also routes throughinstallFetchProxy(...)'s retry path, which on a slow filesystem produces a brief UI hitch when the user changes spaces rapidly.The same race window exists for any pathIndex-walk-then-read pattern — and there are several in
spaces/storage.js. This PR migrates the three race-prone batch readers (readSpace,parseWidgetFiles-batch read,listSpacesbulk read) toifExists: true. User-initiated single-file reads (readWidgetFile,readManifestFile's sole caller induplicateSpace) stay strict so a real "this resource is gone" diagnostic still surfaces if the user is acting on a fresh-cached but actually-deleted resource.The architectural shape of this PR is symmetric to the recently-shipped
fileDelete({ ifExists: true })PR (#35): same option name, sameskippedsemantics on the result envelope, same race-defense pattern (catchENOENTfromfs.readFileSyncand add toskippedinstead of letting it surface as a 500). RFC-style idempotent-read is a recognized HTTP convention and theIf-Match/If-None-Matchheaders are precedent for caller-controlled conditional reads.What changed
server/lib/customware/file_access.js(+59 / -17)normalizeReadRequests(options)now readsoptions.ifExists. When the flag is set, paths that resolve to nothing on disk are recorded in askipped[]array and not added to therequests[]list. The function's return shape becomes{ requests, skipped }(was:requests[]). All other resolution-failure cases (empty path, directory-instead-of-file, public-only path, read-permission) still throw, regardless ofifExists.readAppFiles(...)adapts to the new return shape, surfacesskippedon its own result envelope when non-empty, and additionally catchesENOENTfromfs.readFileSyncwhenifExistsis set — closing the race where the path index says the file is there but it has been removed externally since the last scan.readAppFile(...)keeps its singular contract. WhenifExistsskips the only path, the singular form returns{ content: null, encoding: null, path: null, skipped: [requested] }so callers can branch onresult.path === null.server/api/file_read.js(+23 / -0)The endpoint now reads the option from either the POST body (
payload.ifExists === true) or a GET query parameter (?ifExists=1/?ifExists=true/?if_exists=1). The query-string form keeps the single-path GET shape working — a bare-path read withifExistsdoes not have to switch to POST.app/L0/_all/mod/_core/framework/js/api-client.js(+38 / -5)createFileReadRequest(pathOrFiles, encoding, options)accepts a third positional argument for the bare-path / array forms (fileRead("~/x", "utf8", { ifExists: true })) and anifExistsfield on the input object for the{path}/{files}forms (fileRead({ path: "~/x", ifExists: true })). Strict bodies are byte-identical to today.fileRead(...)now branches: idempotent reads bypass the file-read batching queue and call directly intocall("file_read", ...). The queue is designed to merge concurrent strict reads into a single batch, but mixing strict and idempotent modes in one batch would change the strict caller's behaviour — bypassing the queue keeps each idempotent call's semantic isolated. The bypass is rare (only callers that explicitly opt in) so the queue's batching benefit is preserved for the strict default.JSDoc updated to document the new option, the singular and batch result shapes, and the queue-bypass behaviour.
app/L0/_all/mod/_core/spaces/storage.js(+22 / -5)Three batch readers in
spaces/storage.jsnow passifExists: truebecause the downstream code already tolerates a partialfiles[]array:readSpace(...)— manifest + widgets batch read;files.find(...) || files[0] || nullhandles missing entriesparseWidgetFiles-batch caller — keys files by path through a Map; missing entries simply do not appear in the lookuplistSpaces(...)bulk read — same Map-keyed shapeStrict callers untouched:
readWidgetFile(...),readManifestFile(...)'sduplicateSpacecaller, all single-file reads that are paired with a known-existing target.server/api/AGENTS.md(+1 / -0)Documents the new option for
file_readalongside the existingfile_writeoperation-modes documentation. Sits next to thefile_deleteifExistsdoc that PR #35 introduces; the two operations now share an opt-in idempotency pattern.Backward compatibility
ifExists, every endpoint and helper behaviour is byte-identical to today. All existing callers (single-path reads, batch reads where the caller actually does need 404 to mean "missing") are unchanged.readAppFiles(...)helper's return shape now includes an optionalskipped[]field, but only whenifExists: trueand at least one path was missing. Strict callers always get{ count, files }exactly as before.readAppFile(...)singular-form helper returns{ content: null, ... }instead ofundefinedwhen anifExistscall's only path was missing. This is unreachable from any current strict caller and well-defined for new idempotent callers.Test plan
node --checkon every modified file passes{path}POST,{files}POST — strict bodies/queries are byte-identical to today; idempotent variants addifExists: true(POST body) orifExists: "1"(GET query) next to the path/files fieldnpm run desktop:packbuild:POST /api/file_read 404 (Not Found)in DevTools console; the manifest+widgets batch returns 200 withskipped: [...]insteadfileRead("~/missing.txt")withoutifExistsstill throws 404 (strict semantics preserved)Relationship to PR #35
This PR pairs with
fix/file-delete-if-exists-semantics(#35), which introduced the same option name and result shape onfileDelete. The two PRs can be merged in either order. They share the design: caller-controlled opt-in to RFC-style idempotency, body-or-query-string transport,skipped[]on the result envelope, and a server-side defence against pathIndex-vs-disk races (force: trueonrmSyncfor delete,ENOENTcatch onreadFileSyncfor read).If both PRs land,
server/api/AGENTS.mdwill end up with the twoifExistsdoc lines next to each other; merge resolution is trivial.Out of scope (possible follow-ups)
file_copyandfile_move. Those have stricter semantics around source/target existence (a copy of a missing source has no defined output), so they need a separate design pass — not part of this PR.file_listandfile_pathsalready tolerate missing folders gracefully today, so they do not need anifExistsoption.skippedpaths. The server returnsskippedso callers can branch on it, but no migrated caller currently inspects the field beyond "at least one entry was missing". A future PR could log or count skipped reads to help diagnose stale-cache patterns.isNotFoundError(...)cleanup.spaces/storage.jsstill exports/uses this helper for callers that have not been migrated; a follow-up could either remove it once all idempotent-read callers have migrated or generalize it for non-read operations that genuinely need the distinction.🤖 Generated with Claude Code