Skip to content

Commit 66ce788

Browse files
committed
@remotion/studio-server: Add existence-only file watcher for render outputs
Avoid readFileSync on large binary outputs by tracking path existence only for render queue and file-existence watchers. Prevents ERR_STRING_TOO_LONG when re-encoding to the same path as a watched output file. Made-with: Cursor
1 parent cae0c7e commit 66ce788

File tree

5 files changed

+119
-7
lines changed

5 files changed

+119
-7
lines changed

packages/cli/src/render-queue/queue.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ const processJobIfPossible = async ({
223223

224224
const {unwatch} = StudioServerInternals.installFileWatcher({
225225
file: path.resolve(remotionRoot, nextJob.outName),
226+
existenceOnly: true,
226227
onChange: (event) => {
227228
if (event.type === 'created') {
228229
updateJob(nextJob.id, (job) => ({

packages/studio-server/src/client-render-queue.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const addCompletedClientRender = ({
3535
const filePath = resolveOutputPath(remotionRoot, render.outName);
3636
const {unwatch} = installFileWatcher({
3737
file: filePath,
38+
existenceOnly: true,
3839
onChange: (event) => {
3940
if (event.type === 'created' || event.type === 'deleted') {
4041
updateCompletedClientRender(render.id, {

packages/studio-server/src/file-watcher.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,20 @@ type SharedWatcher = {
1414
unwatch: () => void;
1515
};
1616

17+
const getRegistryKey = (file: string, existenceOnly: boolean): string =>
18+
existenceOnly ? `${file}\0existence-only` : file;
19+
1720
export type FileWatcherRegistry = {
18-
installFileWatcher: (options: {file: string; onChange: OnChange}) => {
21+
installFileWatcher: (options: {
22+
file: string;
23+
onChange: OnChange;
24+
/**
25+
* When true, only created/deleted events are emitted (no reads on change).
26+
* Use for binary or very large files (e.g. render output) where subscribers
27+
* only need to know whether the path exists.
28+
*/
29+
existenceOnly?: boolean;
30+
}) => {
1931
exists: boolean;
2032
unwatch: () => void;
2133
};
@@ -28,11 +40,14 @@ export const createFileWatcherRegistry = (): FileWatcherRegistry => {
2840
const _installFileWatcher = ({
2941
file,
3042
onChange,
43+
existenceOnly = false,
3144
}: {
3245
file: string;
3346
onChange: OnChange;
47+
existenceOnly?: boolean;
3448
}): {exists: boolean; unwatch: () => void} => {
35-
const existing = sharedWatchers.get(file);
49+
const registryKey = getRegistryKey(file, existenceOnly);
50+
const existing = sharedWatchers.get(registryKey);
3651

3752
if (existing) {
3853
existing.subscribers.add(onChange);
@@ -43,7 +58,7 @@ export const createFileWatcherRegistry = (): FileWatcherRegistry => {
4358
existing.subscribers.delete(onChange);
4459
if (existing.subscribers.size === 0) {
4560
existing.unwatch();
46-
sharedWatchers.delete(file);
61+
sharedWatchers.delete(registryKey);
4762
}
4863
},
4964
};
@@ -65,7 +80,15 @@ export const createFileWatcherRegistry = (): FileWatcherRegistry => {
6580

6681
let event: FileChangeEvent | null = null;
6782

68-
if (!shared.existedBefore && existsNow) {
83+
if (existenceOnly) {
84+
if (!shared.existedBefore && existsNow) {
85+
shared.existedBefore = true;
86+
event = {type: 'created', content: ''};
87+
} else if (shared.existedBefore && !existsNow) {
88+
shared.existedBefore = false;
89+
event = {type: 'deleted'};
90+
}
91+
} else if (!shared.existedBefore && existsNow) {
6992
const content = readFileSync(file, 'utf-8');
7093
shared.existedBefore = true;
7194
shared.lastKnownContent = content;
@@ -102,15 +125,15 @@ export const createFileWatcherRegistry = (): FileWatcherRegistry => {
102125
};
103126

104127
fs.watchFile(file, {interval: 100}, listener);
105-
sharedWatchers.set(file, shared);
128+
sharedWatchers.set(registryKey, shared);
106129

107130
return {
108131
exists: existedAtBeginning,
109132
unwatch: () => {
110133
shared.subscribers.delete(onChange);
111134
if (shared.subscribers.size === 0) {
112135
shared.unwatch();
113-
sharedWatchers.delete(file);
136+
sharedWatchers.delete(registryKey);
114137
}
115138
},
116139
};
@@ -119,7 +142,7 @@ export const createFileWatcherRegistry = (): FileWatcherRegistry => {
119142
const _writeFileAndNotifyFileWatchers = (file: string, content: string) => {
120143
writeFileSync(file, content);
121144

122-
const shared = sharedWatchers.get(file);
145+
const shared = sharedWatchers.get(getRegistryKey(file, false));
123146
if (!shared) {
124147
return;
125148
}

packages/studio-server/src/preview-server/file-existence-watchers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const subscribeToFileExistenceWatchers = ({
1717

1818
const {unwatch, exists} = installFileWatcher({
1919
file,
20+
existenceOnly: true,
2021
onChange: (event) => {
2122
if (event.type === 'created') {
2223
waitForLiveEventsListener().then((listener) => {

packages/studio-server/src/test/file-watcher.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,89 @@ test('exists returns false for non-existent file', () => {
179179

180180
w.unwatch();
181181
});
182+
183+
test('existenceOnly does not read the file when content changes', async () => {
184+
const readSpy = spyOn(fs, 'readFileSync');
185+
186+
const cb = mock(() => {});
187+
188+
const w = registry.installFileWatcher({
189+
file: tmpFile,
190+
existenceOnly: true,
191+
onChange: cb,
192+
});
193+
194+
await new Promise((resolve) => setTimeout(resolve, 350));
195+
196+
writeFileSync(tmpFile, 'updated without reading');
197+
198+
await new Promise((resolve) => setTimeout(resolve, 350));
199+
200+
expect(readSpy).not.toHaveBeenCalled();
201+
202+
w.unwatch();
203+
readSpy.mockRestore();
204+
});
205+
206+
test('existenceOnly emits created with empty content when file appears', async () => {
207+
const newFile = path.join(tmpDir, `existence-created-${Date.now()}.txt`);
208+
209+
const cb = mock(() => {});
210+
211+
const w = registry.installFileWatcher({
212+
file: newFile,
213+
existenceOnly: true,
214+
onChange: cb,
215+
});
216+
217+
expect(w.exists).toBe(false);
218+
219+
writeFileSync(newFile, 'hello');
220+
221+
await new Promise((resolve) => setTimeout(resolve, 350));
222+
223+
expect(cb).toHaveBeenCalledWith({type: 'created', content: ''});
224+
225+
w.unwatch();
226+
unlinkSync(newFile);
227+
});
228+
229+
test('existenceOnly emits deleted when file is removed', async () => {
230+
const cb = mock(() => {});
231+
232+
const w = registry.installFileWatcher({
233+
file: tmpFile,
234+
existenceOnly: true,
235+
onChange: cb,
236+
});
237+
238+
expect(w.exists).toBe(true);
239+
240+
unlinkSync(tmpFile);
241+
242+
await new Promise((resolve) => setTimeout(resolve, 350));
243+
244+
expect(cb).toHaveBeenCalledWith({type: 'deleted'});
245+
246+
w.unwatch();
247+
});
248+
249+
test('existenceOnly and content watchers on the same path use separate OS watchers', () => {
250+
const watchFileSpy = spyOn(fs, 'watchFile');
251+
252+
const cb1 = mock(() => {});
253+
const cb2 = mock(() => {});
254+
255+
const w1 = registry.installFileWatcher({
256+
file: tmpFile,
257+
existenceOnly: true,
258+
onChange: cb1,
259+
});
260+
const w2 = registry.installFileWatcher({file: tmpFile, onChange: cb2});
261+
262+
expect(watchFileSpy).toHaveBeenCalledTimes(2);
263+
264+
w1.unwatch();
265+
w2.unwatch();
266+
watchFileSpy.mockRestore();
267+
});

0 commit comments

Comments
 (0)