Eugeny/tabby · error · Error
File handle is closed
Error message
File handle is closed
What it means
Thrown by the SFTPFileAdapter's `write` method when `this.inner` (the underlying `russh.SFTPFile` handle) is null - i.e. after `close()` has already been called on this adapter. `close()` shuts down the inner handle and nulls it; any subsequent write is rejected because there is no live file handle to write to.
Source
Thrown at tabby-ssh/src/session/sftp.ts:34
}
export class SFTPFileHandle {
position = 0
constructor (
private inner: russh.SFTPFile|null,
) { }
async read (): Promise<Uint8Array> {
if (!this.inner) {
return Promise.resolve(new Uint8Array(0))
}
return this.inner.read(256 * 1024)
}
async write (chunk: Uint8Array): Promise<void> {
if (!this.inner) {
throw new Error('File handle is closed')
}
await this.inner.writeAll(chunk)
}
async close (): Promise<void> {
await this.inner?.shutdown()
this.inner = null
}
}
export class SFTPSession {
get closed$ (): Observable<void> { return this.closed }
private closed = new Subject<void>()
private logger: Logger
constructor (private sftp: russh.SFTP, injector: Injector) {
this.logger = injector.get(LogService).create('sftp')
sftp.closed$.subscribe(() => {View on GitHub (pinned to 14e2d60b9b)
Solutions
- Track lifecycle: do not enqueue writes after close; use a `closed` flag and skip or buffer-and-drain before closing.
- Order operations so all writes await before calling `close()` (await each `write` in the producer before ending the stream).
- Guard writes with a null check that returns early/throws a typed 'closed' error the caller can distinguish from real I/O errors.
- Use a single owner for the file handle to prevent concurrent close/write races.
Example fix
// before
async write (chunk: Uint8Array): Promise<void> {
if (!this.inner) throw new Error('File handle is closed')
await this.inner.writeAll(chunk)
}
// caller - serialize close after writes drain
for (const chunk of chunks) await file.write(chunk)
await file.close() // only close after all writes settled Defensive patterns
Strategy: validation
Validate before calling
function isFileHandleOpen (adapter: { inner: unknown }): boolean {
return adapter.inner !== null
}
if (!isFileHandleOpen(fileAdapter)) {
throw new Error('File handle is closed; open a new one before writing')
} Type guard
function isFileHandleOpen<T> (a: { inner: T | null }): a is { inner: T } {
return a.inner !== null
} Try / catch
try {
await file.write(chunk)
} catch (e) {
if (e instanceof Error && e.message === 'File handle is closed') {
// reopen or skip; do not silently lose data
throw e
}
throw e
} Prevention
- Await all writes before calling close() to drain the producer fully.
- Use a single owner for the file handle to avoid concurrent close/write.
- Track a `closed` flag and skip enqueuing writes after close.
- Order finally blocks so close runs only after writes settle.
When it happens
Trigger: Calling `write(chunk)` after `close()` on the same SFTPFileAdapter; a race where close completes before a queued write runs; reuse of a file object whose stream was already ended.
Common situations: Stream pipeline where `close` is invoked by completion/cancel logic but a buffered write still drains; error handler that closes the file then a finally block attempts to flush; double-close followed by a write.
Related errors
- Download cancelled
- ${profile.options.host}: jump host "${profile.options.jumpHo
- No private keys in profile
- WinSCP not found
- Remote rejected opening a shell channel: ${err}
AI-assisted analysis of Eugeny/tabby@14e2d60b9b (2026-08-12).
Data as JSON: /api/errors/3e8c1b35c97ccc5a.
Report an issue: GitHub.