diff --git a/.changes/fix-fs-read-lines-error.md b/.changes/fix-fs-read-lines-error.md new file mode 100644 index 000000000..e4618efd8 --- /dev/null +++ b/.changes/fix-fs-read-lines-error.md @@ -0,0 +1,6 @@ +--- +fs: patch +fs-js: patch +--- + +Fixed `readTextFileLines` yielding empty lines forever when reading fails (e.g. on a directory). The read error is now reported: the iterator rejects and the file is closed. diff --git a/.changes/fix-fs-read-lines-return.md b/.changes/fix-fs-read-lines-return.md new file mode 100644 index 000000000..5259efe48 --- /dev/null +++ b/.changes/fix-fs-read-lines-return.md @@ -0,0 +1,6 @@ +--- +fs: patch +fs-js: patch +--- + +Fixed `readTextFileLines` leaving the file open until the webview is destroyed when a `for await` loop over it exits early (`break`, `return` or `throw`). The iterator now implements `return()`, which closes the file. diff --git a/packages/api-e2e/test/specs/fs.spec.ts b/packages/api-e2e/test/specs/fs.spec.ts index 0d0d21993..738395aaa 100644 --- a/packages/api-e2e/test/specs/fs.spec.ts +++ b/packages/api-e2e/test/specs/fs.spec.ts @@ -234,6 +234,57 @@ describePlugin('fs', () => { expect(lines).toEqual(['one', 'two', 'three']) }) + it('readTextFileLines closes the file when a loop exits early', async () => { + const result = await tauri(async (api, path) => { + const baseDir = api.fs.BaseDirectory.AppData + await api.fs.writeTextFile(path, 'one\ntwo\nthree', { baseDir }) + const lines = await api.fs.readTextFileLines(path, { baseDir }) + // the iterator keeps the id of the open file in `rid` + const state = lines as unknown as { rid: number | null } + let rid: number | null = null + let first: string | null = null + for await (const line of lines) { + first = line + rid = state.rid + break + } + let closeError: string | null = null + try { + await api.core.invoke('plugin:resources|close', { rid }) + } catch (error) { + closeError = String(error) + } + // iterating again starts over + const all: string[] = [] + for await (const line of lines) { + all.push(line) + } + return { first, ridAfter: state.rid, closeError, all } + }, `${dir}/lines-break.txt`) + expect(result.first).toBe('one') + expect(result.ridAfter).toBeNull() + // the resource was already closed by the iterator + expect(result.closeError).toMatch(/resource id \d+ is invalid/) + expect(result.all).toEqual(['one', 'two', 'three']) + }) + + it('readTextFileLines rejects when the file cannot be read', async () => { + // a directory: opening it fails on Windows, reading it fails elsewhere, + // which used to yield empty lines forever + const message = await tauriError(async (api, path) => { + const baseDir = api.fs.BaseDirectory.AppData + await api.fs.mkdir(path, { baseDir, recursive: true }) + const lines = await api.fs.readTextFileLines(path, { baseDir }) + for (let i = 0; i < 1000; i++) { + const { done } = await lines.next() + if (done) return + } + throw new Error('readTextFileLines kept yielding lines for a directory') + }, `${dir}/lines-dir`) + expect(message).not.toMatch(/kept yielding/) + expect(message).toMatch(/failed to (read line|open file)/) + }) + it('FileHandle supports write, seek, read, stat and truncate', async () => { const result = await tauri(async (api, path) => { const baseDir = api.fs.BaseDirectory.AppData diff --git a/plugins/fs/api-iife.js b/plugins/fs/api-iife.js index fe949f75b..1cb8b78d0 100644 --- a/plugins/fs/api-iife.js +++ b/plugins/fs/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_PLUGIN_FS__=function(e,n,t){"use strict";var i;function o(e){return{isFile:e.isFile,isDirectory:e.isDirectory,isSymlink:e.isSymlink,size:e.size,mtime:null!==e.mtime?new Date(e.mtime):null,atime:null!==e.atime?new Date(e.atime):null,birthtime:null!==e.birthtime?new Date(e.birthtime):null,readonly:e.readonly,fileAttributes:e.fileAttributes,dev:e.dev,ino:e.ino,mode:e.mode,nlink:e.nlink,uid:e.uid,gid:e.gid,rdev:e.rdev,blksize:e.blksize,blocks:e.blocks}}e.SeekMode=void 0,(i=e.SeekMode||(e.SeekMode={}))[i.Start=0]="Start",i[i.Current=1]="Current",i[i.End=2]="End";class r extends t.Resource{async read(e){if(0===e.byteLength)return 0;const n=await t.invoke("plugin:fs|read",{rid:this.rid,len:e.byteLength}),i=function(e){const n=new Uint8ClampedArray(e),t=n.byteLength;let i=0;for(let e=0;ee instanceof URL?e.toString():e),options:i,onEvent:r}),c=new s(a);return()=>{c.close()}}return Object.defineProperty(e,"BaseDirectory",{enumerable:!0,get:function(){return n.BaseDirectory}}),e.FileHandle=r,e.copyFile=async function(e,n,i){if(e instanceof URL&&"file:"!==e.protocol||n instanceof URL&&"file:"!==n.protocol)throw new TypeError("Must be a file URL.");await t.invoke("plugin:fs|copy_file",{fromPath:e instanceof URL?e.toString():e,toPath:n instanceof URL?n.toString():n,options:i})},e.create=async function(e,n){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");const i=await t.invoke("plugin:fs|create",{path:e instanceof URL?e.toString():e,options:n});return new r(i)},e.exists=async function(e,n){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");return await t.invoke("plugin:fs|exists",{path:e instanceof URL?e.toString():e,options:n})},e.lstat=async function(e,n){return o(await t.invoke("plugin:fs|lstat",{path:e instanceof URL?e.toString():e,options:n}))},e.mkdir=async function(e,n){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await t.invoke("plugin:fs|mkdir",{path:e instanceof URL?e.toString():e,options:n})},e.open=a,e.readDir=async function(e,n){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");return await t.invoke("plugin:fs|read_dir",{path:e instanceof URL?e.toString():e,options:n})},e.readFile=async function(e,n){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");const i=await t.invoke("plugin:fs|read_file",{path:e instanceof URL?e.toString():e,options:n});return i instanceof ArrayBuffer?new Uint8Array(i):Uint8Array.from(i)},e.readTextFile=async function(e,n){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");const i=await t.invoke("plugin:fs|read_text_file",{path:e instanceof URL?e.toString():e,options:n}),o=i instanceof ArrayBuffer?i:Uint8Array.from(i);return new TextDecoder(n?.encoding??"utf-8").decode(o)},e.readTextFileLines=async function(e,n){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");const i=e instanceof URL?e.toString():e;return await Promise.resolve({path:i,rid:null,async next(){const e=new TextDecoder(n?.encoding??"utf-8");if(null===this.rid){const o=e.encoding;this.rid=await t.invoke("plugin:fs|read_text_file_lines",{path:i,options:null!=n?{...n,encoding:o}:void 0})}const o=await t.invoke("plugin:fs|read_text_file_lines_next",{rid:this.rid}),r=o instanceof ArrayBuffer?new Uint8Array(o):Uint8Array.from(o),a=1===r[r.byteLength-1];if(a)return this.rid=null,{value:null,done:a};return{value:e.decode(r.slice(0,r.byteLength-1)),done:a}},[Symbol.asyncIterator](){return this}})},e.remove=async function(e,n){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await t.invoke("plugin:fs|remove",{path:e instanceof URL?e.toString():e,options:n})},e.rename=async function(e,n,i){if(e instanceof URL&&"file:"!==e.protocol||n instanceof URL&&"file:"!==n.protocol)throw new TypeError("Must be a file URL.");await t.invoke("plugin:fs|rename",{oldPath:e instanceof URL?e.toString():e,newPath:n instanceof URL?n.toString():n,options:i})},e.size=async function(e,n){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");return await t.invoke("plugin:fs|size",{path:e instanceof URL?e.toString():e,options:n})},e.startAccessingSecurityScopedResource=async function(e){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await t.invoke("plugin:fs|start_accessing_security_scoped_resource",{path:e instanceof URL?e.toString():e})},e.stat=async function(e,n){return o(await t.invoke("plugin:fs|stat",{path:e instanceof URL?e.toString():e,options:n}))},e.stopAccessingSecurityScopedResource=async function(e){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await t.invoke("plugin:fs|stop_accessing_security_scoped_resource",{path:e instanceof URL?e.toString():e})},e.truncate=async function(e,n,i){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await t.invoke("plugin:fs|truncate",{path:e instanceof URL?e.toString():e,len:n,options:i})},e.watch=async function(e,n,t){return await c(e,n,{delayMs:2e3,...t})},e.watchImmediate=async function(e,n,t){return await c(e,n,{...t,delayMs:void 0})},e.writeFile=async function(e,n,i){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");if(n instanceof ReadableStream){const t=await a(e,{read:!1,create:!0,write:!0,...i}),o=n.getReader();try{for(;;){const{done:e,value:n}=await o.read();if(e)break;await t.write(n)}}finally{o.releaseLock(),await t.close()}}else await t.invoke("plugin:fs|write_file",n,{headers:{path:encodeURIComponent(e instanceof URL?e.toString():e),options:JSON.stringify(i)}})},e.writeTextFile=async function(e,n,i){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");const o=new TextEncoder;await t.invoke("plugin:fs|write_text_file",o.encode(n),{headers:{path:encodeURIComponent(e instanceof URL?e.toString():e),options:JSON.stringify(i)}})},e}({},window.__TAURI__.path,window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"fs",{value:__TAURI_PLUGIN_FS__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_FS__=function(e,t,n){"use strict";var i;function o(e){return{isFile:e.isFile,isDirectory:e.isDirectory,isSymlink:e.isSymlink,size:e.size,mtime:null!==e.mtime?new Date(e.mtime):null,atime:null!==e.atime?new Date(e.atime):null,birthtime:null!==e.birthtime?new Date(e.birthtime):null,readonly:e.readonly,fileAttributes:e.fileAttributes,dev:e.dev,ino:e.ino,mode:e.mode,nlink:e.nlink,uid:e.uid,gid:e.gid,rdev:e.rdev,blksize:e.blksize,blocks:e.blocks}}e.SeekMode=void 0,(i=e.SeekMode||(e.SeekMode={}))[i.Start=0]="Start",i[i.Current=1]="Current",i[i.End=2]="End";class r extends n.Resource{async read(e){if(0===e.byteLength)return 0;const t=await n.invoke("plugin:fs|read",{rid:this.rid,len:e.byteLength}),i=function(e){const t=new Uint8ClampedArray(e),n=t.byteLength;let i=0;for(let e=0;ee instanceof URL?e.toString():e),options:i,onEvent:r}),c=new s(a);return()=>{c.close()}}return Object.defineProperty(e,"BaseDirectory",{enumerable:!0,get:function(){return t.BaseDirectory}}),e.FileHandle=r,e.copyFile=async function(e,t,i){if(e instanceof URL&&"file:"!==e.protocol||t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");await n.invoke("plugin:fs|copy_file",{fromPath:e instanceof URL?e.toString():e,toPath:t instanceof URL?t.toString():t,options:i})},e.create=async function(e,t){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");const i=await n.invoke("plugin:fs|create",{path:e instanceof URL?e.toString():e,options:t});return new r(i)},e.exists=async function(e,t){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");return await n.invoke("plugin:fs|exists",{path:e instanceof URL?e.toString():e,options:t})},e.lstat=async function(e,t){return o(await n.invoke("plugin:fs|lstat",{path:e instanceof URL?e.toString():e,options:t}))},e.mkdir=async function(e,t){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await n.invoke("plugin:fs|mkdir",{path:e instanceof URL?e.toString():e,options:t})},e.open=a,e.readDir=async function(e,t){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");return await n.invoke("plugin:fs|read_dir",{path:e instanceof URL?e.toString():e,options:t})},e.readFile=async function(e,t){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");const i=await n.invoke("plugin:fs|read_file",{path:e instanceof URL?e.toString():e,options:t});return i instanceof ArrayBuffer?new Uint8Array(i):Uint8Array.from(i)},e.readTextFile=async function(e,t){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");const i=await n.invoke("plugin:fs|read_text_file",{path:e instanceof URL?e.toString():e,options:t}),o=i instanceof ArrayBuffer?i:Uint8Array.from(i);return new TextDecoder(t?.encoding??"utf-8").decode(o)},e.readTextFileLines=async function(e,t){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");const i=e instanceof URL?e.toString():e;return await Promise.resolve({path:i,rid:null,async next(){const e=new TextDecoder(t?.encoding??"utf-8");if(null===this.rid){const o=e.encoding;this.rid=await n.invoke("plugin:fs|read_text_file_lines",{path:i,options:null!=t?{...t,encoding:o}:void 0})}let o;try{o=await n.invoke("plugin:fs|read_text_file_lines_next",{rid:this.rid})}catch(e){throw this.rid=null,e}const r=o instanceof ArrayBuffer?new Uint8Array(o):Uint8Array.from(o),a=1===r[r.byteLength-1];if(a)return this.rid=null,{value:null,done:a};return{value:e.decode(r.slice(0,r.byteLength-1)),done:a}},async return(){if(null!==this.rid){const e=this.rid;this.rid=null,await new n.Resource(e).close()}return{value:null,done:!0}},[Symbol.asyncIterator](){return this}})},e.remove=async function(e,t){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await n.invoke("plugin:fs|remove",{path:e instanceof URL?e.toString():e,options:t})},e.rename=async function(e,t,i){if(e instanceof URL&&"file:"!==e.protocol||t instanceof URL&&"file:"!==t.protocol)throw new TypeError("Must be a file URL.");await n.invoke("plugin:fs|rename",{oldPath:e instanceof URL?e.toString():e,newPath:t instanceof URL?t.toString():t,options:i})},e.size=async function(e,t){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");return await n.invoke("plugin:fs|size",{path:e instanceof URL?e.toString():e,options:t})},e.startAccessingSecurityScopedResource=async function(e){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await n.invoke("plugin:fs|start_accessing_security_scoped_resource",{path:e instanceof URL?e.toString():e})},e.stat=async function(e,t){return o(await n.invoke("plugin:fs|stat",{path:e instanceof URL?e.toString():e,options:t}))},e.stopAccessingSecurityScopedResource=async function(e){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await n.invoke("plugin:fs|stop_accessing_security_scoped_resource",{path:e instanceof URL?e.toString():e})},e.truncate=async function(e,t,i){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");await n.invoke("plugin:fs|truncate",{path:e instanceof URL?e.toString():e,len:t,options:i})},e.watch=async function(e,t,n){return await c(e,t,{delayMs:2e3,...n})},e.watchImmediate=async function(e,t,n){return await c(e,t,{...n,delayMs:void 0})},e.writeFile=async function(e,t,i){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");if(t instanceof ReadableStream){const n=await a(e,{read:!1,create:!0,write:!0,...i}),o=t.getReader();try{for(;;){const{done:e,value:t}=await o.read();if(e)break;await n.write(t)}}finally{o.releaseLock(),await n.close()}}else await n.invoke("plugin:fs|write_file",t,{headers:{path:encodeURIComponent(e instanceof URL?e.toString():e),options:JSON.stringify(i)}})},e.writeTextFile=async function(e,t,i){if(e instanceof URL&&"file:"!==e.protocol)throw new TypeError("Must be a file URL.");const o=new TextEncoder;await n.invoke("plugin:fs|write_text_file",o.encode(t),{headers:{path:encodeURIComponent(e instanceof URL?e.toString():e),options:JSON.stringify(i)}})},e}({},window.__TAURI__.path,window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"fs",{value:__TAURI_PLUGIN_FS__})} diff --git a/plugins/fs/guest-js/index.ts b/plugins/fs/guest-js/index.ts index e3d39a754..6062c1912 100644 --- a/plugins/fs/guest-js/index.ts +++ b/plugins/fs/guest-js/index.ts @@ -887,10 +887,17 @@ async function readTextFileLines( }) } - const arr = await invoke( - 'plugin:fs|read_text_file_lines_next', - { rid: this.rid } - ) + let arr: ArrayBuffer | number[] + try { + arr = await invoke( + 'plugin:fs|read_text_file_lines_next', + { rid: this.rid } + ) + } catch (error) { + // the resource is closed on errors, the next iteration starts over + this.rid = null + throw error + } const bytes = arr instanceof ArrayBuffer ? new Uint8Array(arr) : Uint8Array.from(arr) @@ -916,6 +923,17 @@ async function readTextFileLines( } }, + // called when a `for await` loop exits early (`break`, `return` or `throw`) + async return(): Promise> { + if (this.rid !== null) { + const rid = this.rid + this.rid = null + // close the file, otherwise it stays open until the webview is destroyed + await new Resource(rid).close() + } + return { value: null, done: true } + }, + [Symbol.asyncIterator](): AsyncIterableIterator { return this } diff --git a/plugins/fs/src/commands.rs b/plugins/fs/src/commands.rs index 1fb5a69d0..00cf98ad3 100644 --- a/plugins/fs/src/commands.rs +++ b/plugins/fs/src/commands.rs @@ -703,7 +703,12 @@ pub async fn read_text_file_lines_next( bytes.push(false as u8); Ok(bytes) } - Some(Err(_)) => Ok(vec![false as u8]), + Some(Err(e)) => { + // the error may be persistent (e.g. reading a directory), do not report + // an empty line and let the caller loop forever + resource_table.close(rid)?; + Err(format!("failed to read line with error: {e}").into()) + } None => { resource_table.close(rid)?; Ok(vec![true as u8])