perf(kite3d): serve the manifest from memory and drop .kite3d from it - #21
Draft
pythonlearner1025 wants to merge 1 commit into
Draft
pythonlearner1025 wants to merge 1 commit into
pythonlearner1025 wants to merge 1 commit into
Conversation
The hash cache stopped the byte reads, but GET /api/files still walked the whole tree on every call. About 12,700 readdir and stat calls, all serial, cost 374 ms. The server now holds the manifest in memory. The watcher already maintains hashes on change, add and unlink, so the same events maintain the manifest. A request reads the map and resolves only the paths an event has marked. Without a watcher no event arrives, so the request walks the tree instead and rebuilds the map from it. isIncludedPath did two jobs. It chose what the manifest lists, and it gated every /files route through safeProjectPath. It is now two named predicates. isServableProjectPath keeps the old rule and still gates the file routes and the watcher. isManifestPath drops `.kite3d`, the server's own scratch area, which held 9,181 files and 1,870 MB in the owner's project. The editor reads those files by path and never looks them up in the manifest, so nothing loses access to them. The remaining walk reads eight files at a time. Serial reads left the disk idle while the hash ran. Sixteen and thirty two measured the same as eight. Measured on a clone of the owner's game project: GET /api/files 374 ms to 2.4 ms. Startup 8985 ms to 2069 ms. In a headless editor, Play 660 ms to 165 ms. The manifest of PR 20 minus its `.kite3d` rows is byte for byte identical to the new manifest, SHA-256 3c5a2bd843fab3197a032656ad8ba8229462bbc3c367151165c72f10ebf52f8c. 3,496 rows survive, 9,181 dropped rows are all `.kite3d`. Verified: npm run build, npm run typecheck, npm run lint, test:kite3d (11 passed), test:runtime (1 passed), test:editor (8 passed). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T623SnndzSVwCSmuQrVRj2
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.
What part this touches
kite3d devstarts a local HTTP server. The editor is its only client.GET /api/filesreturns the project manifest. The editor calls it on project load, on every scene save, and on every Play.The server already runs a chokidar watcher over the project. On add, change and unlink it hashes the changed file, updates
knownHashes, and broadcasts an SSE event.One predicate,
isIncludedPath, did two unrelated jobs. It chose what the manifest lists and what the watcher reports. It was also the authorization gate for every/filesroute, throughsafeProjectPath:That thrown message becomes HTTP 403. So the manifest filter and the file permission were the same line.
The problem
#20 stopped the byte reads. The walk survived.
buildManifeststill ran onereaddirper directory and onestatper file, all serial, on every request. About 12,700 entries at roughly 30 microseconds each is the whole cost:Startup still hashed the whole tree, 8,985 ms, because the boot pass seeds
knownHashes.More than half of that tree is not the project.
.kite3dis the server's own scratch area, andisIncludedPathlet it in on purpose:In the owner's project that directory held 9,181 files and 1,870 MB, 54.6 percent of everything scanned: agent logs, video references, Blender rounds, capture rounds.
The fix
The server holds the manifest in memory, keyed by path.
The watcher maintains it. The same debounced timer that broadcasts an event now reads the path once and writes the result into the manifest, instead of hashing only to broadcast.
scheduleEventmarks the path stale when it fires. Every route that changes a file reaches that function, so a read landing inside the 150 ms debounce still sees the truth.GET /api/filesresolves the marked paths, then returns the map. Cost follows what changed, not project size.Without a watcher no event ever arrives, so a request walks the tree and rebuilds the map from it. That is the same code path as #20, and the map doubles as its hash cache.
isIncludedPathsplits into two honest names.isServableProjectPathkeeps the old rule and still gates the file routes and the watcher.isManifestPathadds one condition: not.kite3d.The remaining walk reads eight files at a time.
Answering the startup question from the report. An in-memory manifest does answer presence, so
knownHashes.has(path)is no longer the only way to tell a real unlink from a removed directory. The startup hash still has to stay, for a different reason:That line drops a watcher event when the bytes did not change. With no seeded hash the comparison never matches, so every first touch of a file would reload it in the editor even when nothing changed. A build step that rewrites 500 unchanged assets would fire 500 reloads. A hashless boot index would also just move the full hash to the first request, since
/api/filesneeds a sha256 on every row.So startup still hashes. It now hashes 1,558 MB instead of 3,428 MB, with eight readers instead of one, which is the 4.3x below.
The risk trade
The manifest now trusts the watcher. A missed event persists until the next event on that path, where before a page reload re-walked and repaired it.
That trust already existed. Live reload has always depended on the watcher alone, so a missed event already meant a stale editor. This widens the blast radius from one file's content to one file's manifest row.
The watcher failing to start is the one case that is fully covered.
createDevServeralready catches it and warns, andcurrentManifestwalks wheneverwatcheris undefined.Dropping
.kite3dfrom the manifest is the riskier half. What I checked before taking it:The file routes are unaffected. They gate on
isServableProjectPath, which is unchanged, soGET,PUTandDELETEof.kite3d/state.json,.kite3d/console.logand.kite3d/check.jsonall still work. This was the trap: deleting the.kite3dexception outright would have 403'd the editor's own three writers.The watcher still reports
.kite3d. So the editor still receives change events for those paths, andViewerInstanceManagerre-seeds its hash from the event payload. Two editor tabs still agree.console.logandcheck.jsonwrites re-read the file for theirIf-Matchbefore writing, so they never depended on the manifest.state.jsonfalls back toIf-Match: *on its first write and uses the write response after that.Play builds
fileRevisionsfrom the manifest. No runtime asset lives in.kite3d, so nothing loses a?v=.The Files panel filters dot paths out of its visible grid already, but its second pass did not.
.kite3d/journal.jsonland friends were rendered as strayaria-labelbuttons. They are gone now.I rejected the wider version of this change: making the watcher skip
.kite3dtoo. It would save watcher work, but it would cut the SSE events that keep a second editor tab's hash forstate.jsonin sync, and repairing that needs an editor-side change to adopt the conflict hash. Not worth it for a directory that fsevents watches for free.Tests
Suites run, mirroring
.github/workflows/ci.yml:The 8 editor tests are the proof for the
.kite3dhalf. They drive real dev servers and poll.kite3d/state.json,.kite3d/console.logand.kite3d/check.jsonover HTTP throughout, and one of them fails the run on any response of 400 or more.One test is new, in
packages/kite3d/test/server.test.ts:lists an externally added file and forgets an externally deleted oneIt writes a file outside the editor, waits for the add event, asserts the manifest lists it with the right hash, deletes it, waits for the unlink event, and asserts the manifest has dropped it.
Removing the two lines in
refreshManifestPaththat write the manifest fails it, and fails the test from #20 as well:Said plainly, because the report asked: this test failed correctly on its first version. The test in #20 did not. Its first version restored the modification time with a
Date, which drops the sub-millisecond part and moved the time by itself, so it passed without the fix. That one was rewritten to pin a whole second.Correctness on the real project. The whole
/api/filesresponse was captured from #20 and from this branch against the same 12,701 file clone. The manifest deliberately changed, so the digests cannot match outright. Removing the.kite3drows from #20's response makes them match exactly:Speed on the same clone:
GET /api/filesfell from 374 ms to 2.4 ms. Startup fell from 8,985 ms to 2,069 ms.The concurrency limit was chosen by measurement, not taste. Same tree, same hashing work, readers varied:
Manual walkthrough, headless Chromium, real editor. A fresh
kite3d initcarryingtools/,docs/, and three capture directories under.kite3d/, the same shapes as the owner's project. Same fixture for both runs:Play fell from 660 ms to 165 ms. I opened the editor, viewed the Files panel, pressed Play, and compared the screenshots at each step. The scene, the Objects list, the Inspector and the Files tree render the same.
One number did not move, and it is not the manifest.
/api/fileson load stayed at about 980 ms in both runs. That request queues behind the 2.2 MB editor bundle on the same single-threaded server. It is worth a separate look.Edges checked:
A file added, changed or deleted outside the editor still reaches the manifest. That is the new test.
A checkpoint restore still works. It suppresses watcher events and rewrites paths the server never wrote, so it keeps a real walk on the after side, and the map is rebuilt from that walk.
.kite3dfiles are still readable and writable over HTTP. The 8 editor tests exercise all three of them..kite3dchange events still broadcast.refreshManifestPathhashes any servable path and only declines to store the ones the manifest does not list.Publish is untouched. It uses a different
buildManifest, inpackages/kite3d/src/manifest.ts.Not covered: a watcher that silently drops an event. The manifest row for that path stays stale until the next event on it. Restarting
kite3d devrepairs it.Deploy
The
kite3dnpm package ships it, together with #20. The change is one file inpackages/kite3d/src, compiled bynpm run build:kite3dintopackages/kite3d/dist.No editor bundle change. No engine change. No backend change. No migration.
Merge #20 first, then this branch. Release with the repository script:
Rollback: revert this commit alone to keep #20's hash cache, then release again.
Users on an older
kite3dare unaffected. The dev server is local, so nothing is deployed to a host.🤖 Generated with Claude Code
https://claude.ai/code/session_01T623SnndzSVwCSmuQrVRj2