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

  1. Track lifecycle: do not enqueue writes after close; use a `closed` flag and skip or buffer-and-drain before closing.
  2. Order operations so all writes await before calling `close()` (await each `write` in the producer before ending the stream).
  3. Guard writes with a null check that returns early/throws a typed 'closed' error the caller can distinguish from real I/O errors.
  4. 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

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


AI-assisted analysis of Eugeny/tabby@14e2d60b9b (2026-08-12). Data as JSON: /api/errors/3e8c1b35c97ccc5a. Report an issue: GitHub.