mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
3 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a700ef7767 |
feat(rpc): fs.readBinaryRange + ResourceBridge fast path (#134, 0.4.83)
True partial reads in the daemon protocol so the bridge no longer
loads the whole file into memory just to slice — meaningful for
streaming video, large PDFs, and constrained-memory remote hosts.
Server side
- proto: ReadBinaryRangeParams { path, offset, length, expectedMtime? }
+ ReadBinaryRangeResult { contentBase64, mtime, size }. Size always
reports the TOTAL on-disk file size so HTTP Content-Range
responses can build `bytes start-end/<total>` without a separate
stat round-trip.
- handlers: FsReadBinaryRange uses os.File.ReadAt (pread on POSIX,
no full-file load). Reads past EOF clamp silently. Negative offset
or length are rejected as InvalidParams. expectedMtime != 0
failure surfaces as PreconditionFailed so range-aware callers can
detect a mid-read edit and restart from offset 0.
- registration in cmd/main.go so server.info advertises the method
in capabilities (existing dispatcher.Methods() machinery
auto-discovers from disp.Handle calls).
- 9 new unit tests (happy / clamp / past-EOF / mtime mismatch /
mtime match / negative offset / negative length / directory /
outside-vault / not-found).
Plugin side
- proto/types.ts: TS mirror of params + result, MethodMap row,
MethodName union arm.
- RpcRemoteFsClient.readBinaryRange(path, offset, length,
expectedMtime?) — reshapes contentBase64 → Buffer, returns
{ data, mtime, size }.
- ResourceBridge: new optional FetchBinaryRangeFn callback in
start(); when present AND the request carries an explicit
`bytes=N-M` Range header, bridge issues a single fs.readBinaryRange
RPC instead of fetching+slicing the whole file. `bytes=N-` /
`bytes=-N` (which need total size to parse) still fall through
to the legacy full-file path. New parseExplicitByteRange helper
is exported and unit-tested. On any range-fetcher error the
bridge logs and falls back transparently.
- main.ts: makeBinaryRangeFetcherIfSupported() mirrors the existing
thumbnail capability gate — wires the callback only when
conn.info.capabilities includes 'fs.readBinaryRange'. Older
daemons keep working unchanged via the legacy slice path.
- 11 new unit tests covering the fast-path callback wiring, the
parser, fall-through cases (no callback / non-explicit form /
fetcher throws / no Range header), 416 on past-EOF start, and
daemon-side EOF clamping.
Backwards compatibility
- ResourceBridge.start signature gained an optional 3rd param;
existing 1-arg and 2-arg call sites compile unchanged.
- Older daemons that don't register fs.readBinaryRange simply
don't advertise the capability → makeBinaryRangeFetcherIfSupported
returns null → bridge stays on the existing full-file path.
Closes #134
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
9358a81ff9 |
feat(plugin): write-conflict detection via expectedMtime (Tier 2-C)
Two clients on the same remote vault could silently overwrite each other's edits: each writes through `fs.write` with no precondition, last-writer-wins. The proto already had `expectedMtime` in the write params (rejected with PreconditionFailed = -32020 when the remote mtime moved), but the plugin never set it. - `RemoteFsClient.writeBinary` gains an optional `expectedMtime` arg. `RpcRemoteFsClient` forwards it into the proto call; `SftpRemoteFsClient` ignores it (SFTP can't enforce a precondition atomically — conflict detection is RPC-only, by design). - `SftpDataAdapter.writeBuffer` looks up `readCache.peek(remote)` before a write. When there's a hit, the cached mtime goes through as `expectedMtime` so a concurrent edit on the remote rejects the write. First-touch writes (no cache entry) skip the precondition, avoiding false positives on files we never read. - New optional `onWriteConflict?: (vaultPath) => Promise<boolean>` on the adapter. On PreconditionFailed it asks the callback; true retries without `expectedMtime` (overwrite), false re-throws. - `WriteConflictModal` (new): two-button Obsidian Modal that prompts "Overwrite remote / Cancel". Closing without picking resolves to false so the promise always settles. - main.ts wires the modal as the adapter's onWriteConflict in `patchAdapter`. The PreconditionFailed check is duck-typed on `e.code === -32020` so the adapter doesn't have to import RpcError from the transport layer. Tests: 7 new SftpDataAdapter cases (cached mtime forwarded, no mtime forwarded for first-touch, overwrite retries without precondition, cancel re-throws, no callback re-throws, non- precondition error bypasses callback) + 1 RpcRemoteFsClient case for the new arg threading. 247 total green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
dcfa2c0fa5 |
feat(plugin): RpcClient + RpcRemoteFsClient transport layer
Plumbing for the daemon-backed transport that will replace SftpClient
in a follow-up PR. Nothing here is wired into the runtime yet — every
new module is tree-shaken out of the production bundle until the
adapter switchover lands.
src/transport/framing.ts
- FramedDuplex wraps any Node Duplex (TCP socket, ssh2 stream) with
LSP-style framing:
Content-Length: <N>\r\n
\r\n
<N bytes of JSON>
- Emits 'message', 'close', 'error'. Copies the message body out of
the shared rolling buffer so later shrinks don't mutate it.
- Configurable maxMessageBytes (default 16 MiB). Oversized payloads
close the stream with an error.
- Tolerates bare LF framing for lenient senders, matching the
parallel tolerance in the Go daemon's ReadFrame.
- Tests feed one byte at a time to exercise the partial-frame path.
src/transport/RpcError.ts
- Small typed Error carrying a code, optional data payload, and an
`is(code)` helper that plays nicely with the ErrorCode constants
already shipped in proto/types.ts.
src/transport/RpcClient.ts
- Correlates outbound call(method, params) requests with their reply
by numeric id. Returns a typed Result<M> via Params<M>/Result<M>.
- Demultiplexes concurrent calls.
- onNotification(method, cb) dispatches server-push events (fs.changed
lands in a later phase); returns a disposer.
- Stream close rejects every pending call with an RpcError(-32603),
so no handler hangs when the SSH tunnel drops. onClose listeners
see the underlying error when there was one.
- Malformed server messages are dropped rather than killing the
session — the only way a pending call is never answered is via the
close path, which always fires.
src/adapter/RemoteFsClient.ts
- Narrow interface for what SftpDataAdapter actually needs from its
client: lifecycle (isAlive + onClose), read side (stat/exists/
list/readBinary), and write side (writeBinary/mkdirp/remove/
rmdir/rename/copy). Deliberately smaller than the full SFTP
surface — atomic-write and parent-dir creation are the server's
responsibility under the α model.
src/adapter/RpcRemoteFsClient.ts
- Implementation backed by RpcClient. Forwards each method to the
matching daemon call, translating the wire DTOs (proto.Stat /
proto.Entry / base64 string) into the existing RemoteStat /
RemoteEntry / Buffer shapes so the adapter above is unchanged.
- stat returns null from the daemon → thrown as an RpcError with
ErrorCode.FileNotFound so the adapter sees the same "missing"
signal it used to get from ssh2.
- mkdirp → fs.mkdir{ recursive: true }; rmdir defaults recursive=false.
Tests (vitest)
- tests/framing.test.ts (7): duplex pair round-trip, multi-frame
pipelining, byte-at-a-time reassembly, missing Content-Length,
oversized-message rejection, bare-LF tolerance, close() refuses
further writes.
- tests/RpcClient.test.ts (11): id correlation, error envelope →
RpcError with matching code, out-of-order concurrent calls,
notification delivery + disposer, pending-call rejection on
close, onClose handler with/without error, call() after close,
malformed payloads ignored without hanging other calls.
- tests/RpcRemoteFsClient.test.ts (11): DTO conversion for stat /
list, exists / readBinary / writeBinary round-trip, mkdirp →
fs.mkdir recursive, rmdir default, rename / copy param shapes,
isAlive mirrors RpcClient.isClosed, stat(null) → FileNotFound.
Verification
- cd plugin && npx tsc --noEmit clean
- cd plugin && npm test 108 / 108 pass (29 new)
- cd plugin && npm run build bundle unchanged at 369 KB
(transport modules are not
reachable from runtime code
until Phase 5-D.2 wires
them in).
Follow-up
- Phase 5-D.2: SshTunnel (ssh2 openssh_forwardOutStreamLocal wrap) +
SftpDataAdapter refactor to RemoteFsClient + runtime wiring.
|