Skip to content

fix(studio-server): keep long file paths from blocking saves - #6611

Merged
jrusso1020 merged 1 commit into
heygen-com:mainfrom
AksharP5:studio-file-paths
Oct 30, 2026
Merged

jrusso1020 merged 1 commit into
heygen-com:mainfrom
AksharP5:studio-file-paths

Conversation

@AksharP5

Copy link
Copy Markdown
Contributor

What

Studio can create and read a file in a nested project folder, then reject its saves and deletes with backup failed: ENAMETOOLONG. A legal 180-byte relative path becomes one 265-byte backup filename.

The backup journal now hashes encoded paths longer than 200 characters. Save and delete succeed with recoverable prior bytes, while shorter backup names and fail-closed behavior remain unchanged.

Why

The old key includes every parent folder in one base64url filename. Bounding that key leaves room for the timestamp and collision suffix. Pruning recognizes both old and new keys so older snapshots still count toward the per-file limit.

Related work

Fixes #6610

#1388 introduced the journal; #3957/#3961 enforce backup failures. The source-filename temp suffix limit in #4777 and Windows staging-directory work in #6492 are separate. No rendering, SDK or parser changes.

How

One helper owns backup naming and retention. Regression tests exercise save/delete through the file routes with a nested path and a UTF-8 filename, separate long paths with identical basenames, and retention of existing base64-named snapshots. The package docs describe backup responses and long paths.

Before / after

Same request sequence and file Baseline Fixed
Create introduction.txt at the 180-byte relative path shown here HTTP 201 HTTP 201
Read it HTTP 200, before HTTP 200, before
Save after using the read ETag HTTP 500, backup failed: ENAMETOOLONG HTTP 200
Bytes after save before after
Save backup bytes No backup before
Delete that file HTTP 500, backup failed: ENAMETOOLONG HTTP 200
File exists after delete true false
Delete backup bytes No backup after

Complete reproduction

Run from this repository on Node 24 and Bun, using upstream main 8b1f88b3c06175f43fbdbe1af6e53933847cb195 for the baseline:

git checkout 8b1f88b3c06175f43fbdbe1af6e53933847cb195
bun install --frozen-lockfile
bun run build

Save the following as repro.mjs in the repository root, then run node repro.mjs. It creates a synthetic temporary project and exercises the public createStudioApi() over loopback HTTP with real filesystem operations. No browser, renderer, mocked filesystem or external media is involved.

import { createServer } from 'node:http';
import { once } from 'node:events';
import { mkdtempSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';

const { createStudioApi } = await import(pathToFileURL(join(process.cwd(), 'packages/studio-server/dist/index.js')));
const projectDir = mkdtempSync(join(tmpdir(), 'hf-file-operations-repro-'));
const project = { id: 'demo', dir: projectDir };
const unavailable = () => { throw new Error('Unexpected unrelated adapter call'); };
const api = createStudioApi({
  listProjects: () => [project],
  resolveProject: id => id === 'demo' ? project : null,
  bundle: unavailable,
  lint: unavailable,
  runtimeUrl: '/runtime.js',
  rendersDir: unavailable,
  startRender: unavailable,
});
const server = createServer(async (req, res) => {
  try {
    const chunks = [];
    for await (const chunk of req) chunks.push(chunk);
    const response = await api.fetch(new Request(`http://${req.headers.host}${req.url}`, {
      method: req.method,
      headers: req.headers,
      ...(chunks.length ? { body: Buffer.concat(chunks) } : {}),
    }));
    res.writeHead(response.status, Object.fromEntries(response.headers));
    res.end(Buffer.from(await response.arrayBuffer()));
  } catch (error) {
    res.writeHead(500);
    res.end(String(error));
  }
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const origin = `http://127.0.0.1:${server.address().port}`;
const request = async (path, init) => {
  const response = await fetch(`${origin}/projects/demo/${path}`, init);
  return { status: response.status, body: await response.json() };
};
const longPath = 'campaigns/2026/autumn-product-launch/client-review/round-03/approved-deliverables/social-media/vertical/english-united-kingdom/subtitles-enabled/product-onboarding/introduction.txt';
try {
  const url = `files/${longPath}`;
  const created = await request(url, { method: 'POST', body: 'before' });
  const opened = await request(url);
  const saved = await request(url, { method: 'PUT', headers: { 'If-Match': opened.body.version }, body: 'after' });
  const afterSave = readFileSync(join(projectDir, longPath), 'utf8');
  const saveBackup = saved.body.backupPath ? readFileSync(join(projectDir, saved.body.backupPath), 'utf8') : null;
  const deleted = await request(url, { method: 'DELETE' });
  const deleteBackup = deleted.body.backupPath ? readFileSync(join(projectDir, deleted.body.backupPath), 'utf8') : null;
  const result = { projectDir, longPath, pathBytes: Buffer.byteLength(longPath), created, opened, saved, afterSave, saveBackup, deleted, existsAfterDelete: existsSync(join(projectDir, longPath)), deleteBackup };
  console.log(JSON.stringify(result, null, 2));
} finally {
  server.closeAllConnections();
  await new Promise(resolve => server.close(resolve));
}

For the fixed comparison, keep the same repro.mjs and run:

git fetch https://github.com/AksharP5/hyperframes.git studio-file-paths
git checkout --detach FETCH_HEAD
bun run --cwd packages/studio-server build
node repro.mjs

Test plan

Verified head: a0ff087990820ec182622577516e58ddcaf0a5be. The fetched commit tree matches the tested index exactly; GitHub GraphQL and REST both verify its signature.

  • Full workspace bun run build after the authorized frozen dependency install; bun.lock unchanged.
  • Studio-server rebuilt before the dependent CLI suite; studio-server typecheck passed.
  • All 186 targeted backup, file, batch-mutation and rename-reference tests passed across the initial run and isolated retry; all 43 CLI Studio integration tests passed on retry. The initial runs hit two unchanged timing assertions under concurrent build/typecheck load (rename 3-second budget; CLI lint endpoint 20-second timeout). No timeout was changed.
  • The two new file-route cases and long-path history case failed on the baseline, then passed with the fix. Legacy-backup retention is also covered.
  • Manual loopback HTTP reproduction above: identical inputs, real files, rebuilt package; save/delete now return 200 and retain the exact prior bytes.
  • Documentation updated. No new code comments; citation and comment-ratchet checks passed.
  • Oxlint, oxfmt, test reachability, normal Lefthook pre-commit (including its broader typechecks and Fallow audit) and commit-msg hooks passed.

No Windows or browser UI run is claimed. Mintlify validation was unavailable because mint is not installed; the MDX change was inspected directly.

@mintlify

mintlify Bot commented Oct 28, 2026

Copy link
Copy Markdown

Preview approval required

This pull request comes from a fork, so Mintlify won't build a preview until someone with write access approves commit a0ff087.

Approve preview

@AksharP5

AksharP5 commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Independent GPT-6 Astra high review of exact head a0ff087990820ec182622577516e58ddcaf0a5be found no actionable introduced findings.

The reviewer independently passed 120 focused tests and 29 controls. Real HTTP comparisons reproduced the long-path save/delete failure on the merge-base and successful operations at this head, with the exact prior bytes backed up. Short names, UTF-8 paths, legacy retention, same-time filename collisions and source preservation when backups fail also passed.

The review reused verified existing build artifacts; it did not repeat a fresh workspace build, native Windows/macOS, browser UI or local Mintlify validation. All 12 required checks pass at this head; the final check snapshot has no failed or pending jobs. Jrusso approved this exact head after source review and bounded path controls. His filesystem integration attempt was blocked by missing dependencies; the independently completed test results above remain the test evidence. GitHub reports APPROVED and MERGEABLE, with no unresolved review threads. No merge performed.

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at a0ff087990820ec182622577516e58ddcaf0a5be.

I checked the bounded backup key, legacy retention matching and file-route tests. Long names use a SHA-256 key while short names keep the existing format.

Attempted vitest backupJournal.test.ts files.longPaths.test.ts: collection blocked by missing hono and @hyperframes/core. No filesystem integration pass completed. Fresh independent source and bounded path controls also passed.

Need: yes · Size: ok · Reuse: existing implementation seam · Simpler: already minimal · Structure: fine
Risk: medium - backup identity and retention.

Verdict: APPROVE
Reasoning: I checked the bounded backup key, legacy retention matching and file-route tests. Long names use a SHA-256 key while short names keep the existing format.

Reasoning: I checked the bounded backup key, legacy retention matching and file-route tests. Long names use a SHA-256 key while short names keep the existing format.

@jrusso1020
jrusso1020 added this pull request to the merge queue Oct 30, 2026
Merged via the queue into heygen-com:main with commit 3245b76 Oct 30, 2026
71 checks passed
SanHsien added a commit to SanHsien/hyperframes that referenced this pull request Nov 2, 2026
…com#6605 heygen-com#6611 heygen-com#6831 heygen-com#6800

Windows debug.log lock, PowerShell UTF-16 batch JSON, long backup names,
--variables-file readFile override, Figma motion key collisions. Rest deferred
with per-commit evidence; 72 PRs pinned in monitored.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Signed-off-by: SanHsien <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Studio cannot save or delete legal long paths because backup filenames exceed filesystem limits

2 participants