withoutOptional and unsupportedOptional replace the local copies in the
fallback tests; the protocol packages need the same reduction to put their
servers' fallback paths under the suite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every one of these callers needs the listing to answer the request, and the
stat plus readdir it falls back to are required operations, so there is no
reason to stop at an implementation that has the combined call but cannot serve
it for this path. The default policy stops there because for most operations a
fallback is materially different from the native one; for this one it is only
slower.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A stream that fails after the native call resolved is deliberately not retried,
and cancelling the fallback's output while a read is in flight must still
release the source. Both were argued from the code rather than demonstrated.
The root path overlaps every other path, so it is rejected at either end of a
copy; that followed from the guard without being stated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An implementation that answers unsupported at runtime was not expressible: the
capability flags only said present or absent, so the suite could not check that
such a rejection carries the right code and leaves the filesystem alone.
The fallbacks are then run against the same suite with the optional operations
first removed and then rejecting, which is what shows they can stand in for
them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both clients failed the boundary behaviour the conformance suite now requires.
WebDAV answers a range at or past the end of a file with 416, which RFC 9110
defines as unsatisfiable and the client reported as an unexpected status; at
this level the window is simply empty. FTP carries no end bound on REST, so an
end below the start transferred from the start to the end of the file instead
of nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Serving a byte range needs an empty window and an unanswerable request to be
distinguishable, so a start at or past the end of a file and an end below the
start return an empty stream rather than failing. The suite did not say so, and
NodeVFS passed both bounds to createReadStream, which rejects an inverted range
with a plain RangeError.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The optional operation arrived with three byte-identical copies of the same
fallback, one per call site.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Callers of an optional AsyncVFS operation each had to decide what to do when
an implementation lacked it, so the same fallbacks were written repeatedly and
diverged. withFallbacks presents every optional operation as available, so a
consumer can call it without branching on method presence, and the operations
that cannot be built from the required ones say so with the unsupported code
rather than appearing absent.
The policy chooses when a fallback may stand in. Only an unsupported rejection
ever triggers one: any other failure means the operation was attempted, and
retrying it another way would hide that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each client already had a native response meaning the operation does not exist
here, and each discarded it: SFTP OP_UNSUPPORTED and FTP 502 surfaced as bare
protocol errors, WebDAV 501 hit the unexpected-status branch, and SMB2
STATUS_NOT_SUPPORTED reached callers as forbidden, which claims a permission
problem the server never reported.
Without this a server emits the status and its own client throws the meaning
away, so no caller can act on it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Optional AsyncVFS operations could only be declared by method presence, which
is global to an instance and cannot express a backend that performs an
operation for some shares, paths or negotiated protocol versions and not
others. Those cases were forced onto forbidden, which claims the caller lacks
permission when the backend said the operation does not exist here.
The outbound wire mappings are exhaustive over VFSErrorCode, so each one names
its protocol's own unsupported response.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
listDrivePeers returned every peer holding PeerCapabilityTaildriveSharer,
which is an ACL grant usually given to a whole group or tag, so offline
peers and peers with no reachable peerAPI were included. Bump the
tailscale submodule to the fix and correct the TS doc comments, which
claimed the result was peers exposing a share.
The submodule bump also picks up the removal of the obsolete ssh, fetch
and setExitNodeEnabled wasm bridge APIs, already on the fork's webnet
branch and unused here.
Closes#143
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The union merge driver kept both the pre- and post-rebase versions of the
tsconnect shutdown entry; the older one predates the dead-handle change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The child-process probes, the missing shutdown() in the tsconnect
integration teardown, and the *.probe.ts tsconfig excludes all existed
only to keep the late "Go program has already exited" errors away from
node:test. That bug is fixed, so drop the workarounds; the restored
in-process shutdowns are the regression test for it.
Refs #147, #152
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With _resume no longer throwing after exit, a call into a dead Go handle
resolves with undefined instead of failing — a read loop would spin on it
rather than break out. Conn, PacketConn and TCPListener now report closed
once their runtime has exited, so those calls raise ClosedError and
close() is a no-op.
Refs #147
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wasm_exec.js keeps the Go scheduler's setTimeout armed and every js.FuncOf
callback registered across program exit, and _resume throws "Go program has
already exited" when they fire. After a clean shutdown() that surfaces as
uncaught errors and unhandled rejections seconds later: a scheduler timeout,
DERP WebSocket close listeners, and a fetch continuation.
initIPN now clears the scheduled timeouts from runtime.wasmExit and makes
_resume a no-op once the runtime has exited.
Fixes#147
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TSCONNECT_TEST_USER_AUTH_KEY and TSCONNECT_TEST_EXIT_NODE_ID gate the Taildrop
and exit node tests; both are optional and the suite is green without them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The integration suite only exercised a fraction of IpnClient: reaching Running,
localAPI, service advertisement and a TCP listen/dial pair. Everything else was
either unit-tested against mocks (serveDrive, listDrivePeers, upgradeTls) or
only ever checked by hand.
Adds real two-node coverage for:
- UDP listen/writeTo/readFrom, both directions
- listenICMP lifecycle plus an echo request/reply round-trip
- listenTLS + dialTLS with a self-signed CA, the negative case where the cert
does not chain to the supplied CA, and upgradeTls on a live TCP conn
- taildrop end-to-end: listFileTargets, sendFile, waitingFiles, openWaitingFile
and deleteWaitingFile
- serveDrive/listDrivePeers over a real netmap
- suggestExitNode, and setExitNode gated on TSCONNECT_TEST_EXIT_NODE_ID
listenTLS works here because it takes the cert and key directly rather than
going through ACME, so it does not need a control plane that issues certs. Only
Funnel needs that, and it stays manual.
Taildrop requires both ends to be untagged: canPutFile wants IsSelfUntagged()
or an ACL-granted PeerCapabilityFileSharingSend, and the shared auth key
registers nodes as tag:test. Those tests run their own pair from
TSCONNECT_TEST_USER_AUTH_KEY and skip when it is unset.
shutdown() runs in a child process (src/shutdown.probe.ts): it exits the whole
Go runtime, and the runtime leaves scheduler timeouts behind that throw "Go
program has already exited" afterwards, which node:test attributes to whichever
hook created them. For the same reason the shared suite no longer calls
shutdown() in its teardown. See webnet/webnet#147.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The truncation this branch fixed was only caught incidentally, by the
cancellation test happening to re-read afterwards. Reading through a default
reader does not reproduce it either: the entry is detached only once a consumer
transfers the chunk's buffer, which is what postMessage to a worker does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suite gained no coverage of readFileRange's error contract when the
per-package tests for it were removed, and none of copy replacing an existing
directory, which NodeVFS merged into instead.
SMB2 copy could recurse without bound when the destination was inside the
source, and could not overwrite a destination of the other type.
The DAV client read every 405 as is-a-directory; RFC 4918 only gives it that
meaning for GET and PUT, so any other method now keeps a generic error.
The FTP server relayed the backing filesystem's prose as 550 text, leaving the
client to guess the code from wording that MemoryVFS never produced. It now
sends conventional wording per code, so the client no longer needs to accept
not-found in place of forbidden.
SFTP marks the VFS code it embeds in SSH_FX_FAILURE messages, so a third-party
server's prose can no longer be decoded as one of ours.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suite caught four client bugs: copy() and move() defaulted to overwriting
the destination, delete() only checked for a non-empty directory when
recursive was explicitly false, readdir() of a file returned an empty listing
instead of rejecting, and setProps() merged rather than replaced.
The server reported is-a-directory as 409, which the client read back as
already-exists; RFC 4918 calls for 405 on a collection, which is what the GET
handler already returns.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
readFile enqueued the entry's own array, and a consumer reading through a byte
stream transfers the chunk's buffer, detaching it. Reading a file over WebDAV
and cancelling the stream truncated the stored file to zero bytes.
The conformance suite compared payloads with assert.deepEqual, so this surfaced
as node building a diff between a 128 KiB array and an empty one, which
exhausts memory and takes the process down before the failure is reported.
Compare byte ranges directly instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suite caught two client bugs: delete() accepted the share root, and copy()
ignored opts.overwrite so it always clobbered the destination, and could not
copy directories.
The mock server also mis-called #respond on two missing-parent paths, ignored
CreateOptions.DIRECTORY_FILE / NON_DIRECTORY_FILE, and ignored ReplaceIfExists
on rename, so it never produced the errors a real server would.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suite caught two bugs. statusToVFSError guessed the VFS error code from
the request verb for SSH_FX_FAILURE replies, discarding the code that
vfsErrorToStatus embeds in the message, so most invalid-operation errors
surfaced as a bare SFTPError. Opening a directory for reading also succeeded,
deferring is-a-directory to the first READ rather than rejecting readFile().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suite caught two client bugs: move() only checked for an existing
destination when { overwrite: false } was passed explicitly, so a default move
silently clobbered it, and it reported already-exists rather than
precondition-failed. replyToVFSError also discarded the server's explicit
"not a directory" / "is a directory" text, reporting not-found instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Expose the suite from a test-only `@webnet/vfs/conformance` entry point and
adopt it for MemoryVFS and NodeVFS.
NodeVFS rejected neither reading a directory nor deleting the filesystem root,
both of which the suite requires; fix both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Freeze build-time options: configAtBuildRef is now snapshotted before
useWorker/workerConfig/builderParams are derived, so toggling SharedWorker
or fileOps after startup no longer switches the active connection path or
silently restarts the main-thread IPN mid-session.
- Include useWorker and fileOps in requireRestart so changes to those fields
show the restart prompt.
- Cross-tab restart guidance: when connectionMode is "worker", the restart
line now tells the user to close all Tailshare tabs rather than just
reloading the current one, since the SharedWorker ignores config from
tabs that join after it has already started.
- Use workerClient.connectionMode (the actual mode from connectWithFallback,
which reflects the fallback path) rather than re-deriving it from flags.
- Remove the duplicate AI_CHANGES.md entry for the SharedWorker PR.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
configAtBuildRef was local to TailscaleConfig, so navigating away from
/tailscale and back would remount the component and reset the ref, making
the restart-required banner disappear even if the config had changed.
Moving configAtBuild into IpnPrepareContext keeps it alive for the
lifetime of the provider.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extract useSharedWorkerAvailable() into @webnet/react
- Add explicit defaults (useWorker: true, fileOps: "memory") in parseConfig
so consumers don't repeat them
- Restore markAutostart tri-state: undefined → latches to true on first
Running, false → never autostart, true → autostart
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Same fix as @webnet/react: @types/eslint-scope and eslint built-in types
have conflicting Scope definitions that break tsc --noEmit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove markAutostart: autostart is now only triggered by the explicit
checkbox, not silently latched on first connect
- Show spinner instead of disabled "Enable Tailscale" button while worker
is connecting (ipn is null but willBuild is true)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Restore missing turbo platform packages in package-lock.json (darwin-64,
darwin-arm64, linux-arm64, windows-64, windows-arm64) so npm ci passes
- Fix building state in TailscaleStateButton to not require NoState
- Disable OPFS option in NativeSelect when SharedWorker unavailable
- Fix "Adresses" typo → "Addresses"
- Extend useBuildIpnWorker first overload to accept null (implementation
already handles it; fixes TS error in IpnContext.tsx)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- use @webnet/tsconnect-worker (useBuildIpnWorker) so multiple tabs share one
IPN instance via SharedWorker, with automatic fallback to main-thread mode
- replace IPN concrete class with IpnClient interface throughout; context
now provides IpnClientHandle (extends IpnClient) from the worker hook
- remove ipnPrepare Redux slice; replace with IpnPrepareContext reading/writing
a single tailshare:config localStorage key as JSON (consolidates the former
ipn#hostname, ipn#controlURL, ipn#authKey, ipn#exitNode, ipn#auto keys plus
new useWorker and fileOps fields)
- add fileOps NativeSelect in config modal (memory/opfs); OPFS passes
fileOps:true to WorkerConfig so received files persist across reloads; shows
a warning if OPFS is selected without worker mode active
- add SharedWorker toggle in config modal (when available); current connection
mode (worker/main-thread) displayed inline
- remove alreadyRunning multi-tab lock path (SharedWorker handles coordination)
- main-thread fallback uses InMemoryState to avoid cross-tab state conflicts
- workerAvailable detection uses useSyncExternalStore to avoid SSR mismatch
- delete local hooks/useLocalStorage.ts and hooks/useClient.ts, replaced by
@webnet/react (added as dependency)
- CaCertProvider simplified to fetch on mount (no longer depends on ipnPrepare)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>