Skip to content

Commit 29cb714

Browse files
mxmlnknclaudeshcheklein
authored
Use the block size determined by asyncssh if nothing is specified (#52)
* Use server-reported block sizes for SSHFile when none is specified When the server implements limits@openssh.com, use its max_read_len / max_write_len as the block size for opened files (OpenSSH reports 255 KiB, roughly 3x-5x the previous defaults, measured ~3x faster reads in #49). When the extension is not supported, asyncssh synthesizes 16 KiB floors with a zero max_packet_len -- keep the tuned 48 KiB / 240 KiB defaults there instead of degrading to the floors. An explicitly passed block_size wins as before. Also drop a shadowed duplicate seekable() definition left over from merging #50 and #51. Fixes #49 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * tests: cover SSHFile block size selection test_determine_block_size pins all three cases (server-reported limits, synthesized floors with zero max_packet_len, asyncssh without the limits API); test_open_block_size checks end to end that a server without limits@openssh.com keeps the tuned defaults and that an explicit block_size wins. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Ivan Shcheklein <shcheklein@gmail.com>
1 parent df852af commit 29cb714

2 files changed

Lines changed: 76 additions & 19 deletions

File tree

sshfs/file.py

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,32 +25,41 @@ def __init__(
2525
self.mode = mode
2626
self.max_requests = max_requests or _MAX_SFTP_REQUESTS
2727

28-
if block_size is None:
29-
# "The OpenSSH SFTP server will close the connection
30-
# if it receives a message larger than 256 KB, and
31-
# limits read requests to returning no more than
32-
# 64 KB."
33-
#
34-
# We are going to use the maximum block_size possible
35-
# with a 16KB margin (so instead of sending 256 KB data,
36-
# we'll send 240 KB + headers for write requests)
37-
38-
if self.readable():
39-
block_size = READ_BLOCK_SIZE
40-
else:
41-
block_size = WRITE_BLOCK_SIZE
42-
4328
# The blocksize is often used with constructs like
4429
# shutil.copyfileobj(src, dst, length=file.blocksize) and since we are
4530
# using pipelining, we are going to reflect the total size rather than
4631
# a size of chunk to our limits.
47-
self.blocksize = block_size * self.max_requests
32+
self.blocksize = (
33+
None if block_size is None else block_size * self.max_requests
34+
)
4835

4936
self.kwargs = kwargs
5037

5138
self._file = sync(self.loop, self._open_file)
5239
self._closed = False
5340

41+
def _determine_block_size(self, channel):
42+
# Use the limits reported by the server (limits@openssh.com) to
43+
# get the best performance. A zero max_packet_len means the
44+
# server never reported limits and the read/write lengths are
45+
# asyncssh's synthesized 16 KiB floors -- fall through to the
46+
# larger defaults below instead of degrading to them.
47+
limits = getattr(channel, "limits", None)
48+
if limits and limits.max_packet_len:
49+
if self.readable():
50+
return limits.max_read_len
51+
return limits.max_write_len
52+
53+
# "The OpenSSH SFTP server will close the connection
54+
# if it receives a message larger than 256 KB, and
55+
# limits read requests to returning no more than
56+
# 64 KB."
57+
#
58+
# We are going to use the maximum block_size possible
59+
# with a 16KB margin (so instead of sending 256 KB data,
60+
# we'll send 240 KB + headers for write requests)
61+
return READ_BLOCK_SIZE if self.readable() else WRITE_BLOCK_SIZE
62+
5463
@wrap_exceptions
5564
async def _open_file(self):
5665
# TODO: this needs to keep a reference to the
@@ -60,6 +69,10 @@ async def _open_file(self):
6069
# it's operations but the pool it thinking this
6170
# channel is freed.
6271
async with self.fs._pool.get() as channel:
72+
if self.blocksize is None:
73+
self.blocksize = (
74+
self._determine_block_size(channel) * self.max_requests
75+
)
6376
return await channel.open(
6477
self.path,
6578
self.mode,
@@ -80,9 +93,6 @@ async def _open_file(self):
8093
def readable(self):
8194
return "r" in self.mode or "+" in self.mode
8295

83-
def seekable(self):
84-
return "r" in self.mode or "w" in self.mode
85-
8696
def seekable(self):
8797
return True
8898

tests/test_sshfs.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66
from concurrent import futures
77
from datetime import datetime, timedelta, timezone
88
from pathlib import Path
9+
from types import SimpleNamespace
910

1011
import fsspec
1112
import pytest
1213
from asyncssh.sftp import SFTPAttrs, SFTPFailure
1314
from importlib_metadata import entry_points
1415

1516
from sshfs import SSHFileSystem
17+
from sshfs.file import SSHFile
18+
from sshfs.utils import READ_BLOCK_SIZE, WRITE_BLOCK_SIZE
1619

1720
_STATIC = (Path(__file__).parent / "static").resolve()
1821
USERS = {"user": _STATIC / "user.key"}
@@ -336,6 +339,50 @@ def test_exceptions(fs, remote_dir):
336339
fs.makedirs(remote_dir + "/dir/a/b/c")
337340

338341

342+
def test_open_block_size(fs, remote_dir):
343+
# mockssh (paramiko) does not implement limits@openssh.com, so the
344+
# tuned defaults must survive asyncssh's synthesized 16 KiB floors.
345+
fs.touch(remote_dir + "/a.txt")
346+
with fs.open(remote_dir + "/a.txt", "rb") as file:
347+
assert file.blocksize == READ_BLOCK_SIZE * file.max_requests
348+
with fs.open(remote_dir + "/b.txt", "wb") as file:
349+
assert file.blocksize == WRITE_BLOCK_SIZE * file.max_requests
350+
# An explicit block_size always wins.
351+
with fs.open(remote_dir + "/c.txt", "wb", block_size=4096) as file:
352+
assert file.blocksize == 4096 * file.max_requests
353+
354+
355+
def test_determine_block_size():
356+
reader = SimpleNamespace(readable=lambda: True)
357+
writer = SimpleNamespace(readable=lambda: False)
358+
determine = SSHFile._determine_block_size
359+
360+
# The server reported its limits: use them.
361+
reported = SimpleNamespace(
362+
limits=SimpleNamespace(
363+
max_packet_len=262144,
364+
max_read_len=261120,
365+
max_write_len=131072,
366+
)
367+
)
368+
assert determine(reader, reported) == 261120
369+
assert determine(writer, reported) == 131072
370+
371+
# No limits@openssh.com support: asyncssh synthesizes 16 KiB
372+
# read/write floors with max_packet_len == 0; keep the defaults.
373+
synthesized = SimpleNamespace(
374+
limits=SimpleNamespace(
375+
max_packet_len=0, max_read_len=16384, max_write_len=16384
376+
)
377+
)
378+
assert determine(reader, synthesized) == READ_BLOCK_SIZE
379+
assert determine(writer, synthesized) == WRITE_BLOCK_SIZE
380+
381+
# asyncssh without the limits API at all.
382+
assert determine(reader, SimpleNamespace()) == READ_BLOCK_SIZE
383+
assert determine(writer, SimpleNamespace()) == WRITE_BLOCK_SIZE
384+
385+
339386
def test_open_rw(fs, remote_dir):
340387
data = b"dvc.org"
341388

0 commit comments

Comments
 (0)