Results of reads/accepts that were in flight when a resource was detached
were delivered to the old owner's dead port, and accepted conns were
registered on (and leaked with) the old client entry. Each resource now
carries a generation-tagged channel; stale completions are deposited into
a backlog the next owner's bridge drains first, serialized so stream order
is preserved. TTL expiry and cancel close backlogged conns.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Envelopes flushed by the worker when a client key registers arrive before
ready and were dropped by the temporary handshake handler.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Several worker.test.ts tests (WorkerSSHSession.resize()/close() flagged
as flaky in CI, plus other close()-message, callback-firing, and
pumpStreamToPort tests) waited a flat setTimeout(r, 10) after
postMessage before asserting the message arrived — the same race
already fixed for closed-state checks in b917cd8. Under CI load the
10ms sleep can elapse before the message is delivered, causing
sporadic AssertionErrors.
Added a generic waitFor(predicate, timeoutMs?) helper that polls via
setImmediate, replaced every fixed sleep with a poll on the actual
condition, and consolidated the four pre-existing
`while (!x.closed) await setImmediate()` spin-loops from b917cd8 onto
the same helper for consistency.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every job in the old checks.yml/test-node.yml/test-browser.yml
independently repeated checkout + tailscale submodule clone + npm ci,
and 5 of 7 also independently rebuilt the whole workspace via Turbo.
There was no caching anywhere.
Live smoke tests against this Gitea instance found actions/cache
(and setup-node's built-in cache: npm, same API) times out against
the built-in cache proxy - a server-side issue, not fixable from
workflow YAML - and actions/upload-artifact/download-artifact v4
refuse to run at all (GHES detection). v3 of the artifact actions
work correctly, so they're used as the handoff mechanism instead.
.gitea/workflows/checks.yml, test-node.yml, and test-browser.yml are
merged into one ci.yml. A new `install` job does the real work once
(submodule clone, npm ci, build) and hands node_modules plus Turbo's
local cache off via v3 artifacts to typecheck/typetest/node-tests/
browser-tests (all `needs: install`), instead of every job reinstalling
and rebuilding from scratch. lint/format never touched the submodule
or build output, so they drop that step and stay independent for fast
feedback. The shared checkout+submodule+setup-node preamble is now a
composite action, .gitea/actions/setup/action.yml (composite actions
require the repo to already be checked out, so actions/checkout stays
a separate first step in every job). A concurrency group cancels
superseded runs on the same ref.
The node_modules archive step must include per-package node_modules
overrides, not just the root: packages/xml and packages/vfs pin a
newer local TypeScript than the workspace root, installed by npm as
nested packages/{xml,vfs}/node_modules/typescript. Missing those in
the archive caused those two packages to silently typecheck against
the wrong TypeScript version.
Cross-run caching (reusing a previous run's install/build) isn't
available until the Gitea instance's cache backend is fixed
server-side - that's a separate, out-of-scope follow-up.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace racy `setTimeout(r, 10)` waits with a `setImmediate` poll on
the `.closed` property. The 10 ms sleep was insufficient under load,
causing `readFrom()`/`read()`/`write()`/`accept()` to run before the
"closed" message was delivered, resulting in "packet conn closed"
instead of the expected "already closed" error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix `suggegestedName` typo in showSaveFilePicker call (extra "ge");
the browser silently ignored the unknown key so the Save dialog opened
with a blank filename on Chrome/Edge
- Defer URL.revokeObjectURL to setTimeout() so Safari's download
manager has a task boundary to open the object URL before revocation
- Add AI_CHANGES.md entry for the @webnet/react/@webnet/utils integration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- example-app: replace local useClient duplicate with @webnet/react, add
useLocalStorage to persist SharedWorker preference across page reloads
- example-app: replace local fmtSize duplicate with @webnet/utils/fmtSize,
use download() util in WaitingFileDebug instead of inline logic
- test-app: add @webnet/utils dependency and expose it on window alongside
other globals for console testing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extracts the useClient and useLocalStorage hooks into @webnet/react,
and the download, upload, readBlob, and fmtSize utilities into @webnet/utils,
so they can be shared by tailshare and other apps without living inside
a single app package.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add instructions for agents to apply `Agentic` + `Agent/<model-line>`
labels to every PR they open (creating the labels via `tea labels create`
if absent), and to auto-finalise PRs without user prompting when the
work is trivial or was fully specified upfront.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- IpnClientHandle.disconnect() JSDoc clarifies that the main-thread path
terminates IPN while the worker path is a soft detach
- connectWithFallback logs a console.warn with the caught error when falling
back from the SharedWorker path, so unexpected fallbacks are diagnosable
- IpnMainThreadHandle constructor comment explains that pre-run() callbacks
are intentionally dropped (run() snapshots current state on subscribe)
- lock?.release ?? null -> lock ? () => lock.release() : null to avoid
passing an unbound method reference
- Browser test: first test adds stateStorage: "memory" to avoid leaving IDB
state between runs
- Browser test: new test covers the IDB lock-contention path — acquires the
lock manually then confirms connectMainThread rejects immediately
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- WorkerConfig.wasmUrl now accepts string | ArrayBuffer | ArrayBufferView,
allowing tests (and callers) to pass raw WASM bytes without going through
a URL fetch (useful in Node.js where file:// fetch is unsupported)
- Restructured connect.test.ts to share a single WASM+IPN instance across
tests via before()/after() hooks, preventing uncaught Go goroutine errors
that fired after sequential shutdown of multiple WASM runtimes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- IpnClientHandle interface: shared contract for both SharedWorker client and
main-thread handle (connectionMode, store, state, running, fileOps, run,
disconnect)
- IpnMainThreadHandle: wraps IPN + Redux store; all IpnClient methods proxy
directly; run() fires user callbacks with current state and delegates future
state changes via runWithStore; disconnect() releases the lock and shuts down
- connectMainThread: IndexedDB-backed state uses a web lock keyed to the DB
name ("tsconnect-idb:<name>"), rejecting immediately if held; in-memory
state skips the lock entirely
- connectWithFallback: tries SharedWorker first, falls back to main thread if
unavailable, disabled (disableSharedWorker option), or on worker init failure
- useBuildIpnWorker updated to use connectWithFallback, returns IpnClientHandle
- Tests: 6 Node tests verifying main-thread init and fallback behaviour; 4
browser tests (Chromium + Firefox) verifying worker path and explicit
disable fallback via esbuild-bundled inline build
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds connectMainThread(config) for browsers that do not support
SharedWorker (e.g. Chrome on Android before 2025). It mirrors the
worker's init path but runs in the main thread, returning an IPN
instance directly instead of a worker-proxy client.
Uses navigator.locks.request with { ifAvailable: true } to protect
against concurrent tabs; rejects immediately if the lock is already
held. The lock is released when the returned IPN.shutdown() resolves.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verify that the second close() call returns false (the #closed guard path
added in the previous commit).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WorkerConn/WorkerTCPListener/WorkerPacketConn/WorkerSSHSession.close() sent a
"close" message but never called port.close(). The port stayed active until the
worker sent a "closed" reply, which never happens in tests. Node.js's --test
runner uses process.on('exit') rather than process.exit(), so an undrained event
loop (open MessagePort handles) caused the test process to hang indefinitely.
Fix: close() now calls this.#port.close() and immediately rejects pending
promises. WorkerSSHSession gains a #closed guard to make close() idempotent.
Test cleanup updated to call close() methods rather than leaking ports.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Always base PRs on origin/main (fetch before branching)
- Watch CI results after push and address errors
- Spawn autonomous Sonnet-class code review when PR work is complete
- Send push notification to user when work settles or needs unblocking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The setFileOpsConfig handler in worker.ts and the setFileOpsConfig method
in client.ts previously used duplicate inline type literals. Extracting
FileOpsLimits from protocol.ts ties both to a single source of truth so
the compiler catches any future skew.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add fileOpsMaxFiles, fileOpsMaxTotalSize, and fileOpsMaxFileSize to
WorkerConfig so callers can set initial Taildrop limits at startup rather
than having to call setFileOpsConfig() after connecting. The options are
passed straight through to FsaFileOps.createFromOpfs(). Runtime changes
via setFileOpsConfig() continue to work as before.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
src/index.ts imports ../dist/wasm_exec.js which only exists after build.sh
runs; the generic typecheck task only declares ^build (dependencies' build)
as a prerequisite, leaving the package's own dist absent in a fresh env.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a ControlledFileOps interface (UserIPNFileOps + observable limits +
onChange) satisfied by InMemoryFileOps and FsaFileOps. Bind its state
into a new fileOps Redux slice so file storage metrics and limits are
observable via store.getState().fileOps and broadcast to all clients.
In tsconnect-worker: hoist the FsaFileOps instance to module scope,
wire bindFileOpsToStore after init, and handle a new setFileOpsConfig
call that lets clients reconfigure maxFiles/maxTotalSize/maxFileSize
at runtime. Limit changes propagate back via the existing action
broadcast path so all connected clients see updated state immediately.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
tsx hardcodes keepNames:true in its esbuild transform, injecting a __name
helper at module scope. Playwright's page.evaluate() serializes callbacks
via .toString(), so the helper is absent in the browser context. Inject a
matching polyfill via addInitScript on every new page.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- serveDirectory: replace regex path traversal guard with resolve+relative
check, which is simpler to reason about and handles encoded edge cases
- drop test:browser:coverage scripts; c8 only covers Node orchestration
code, not browser-executed code inside page.evaluate() — the resulting
lcov is nearly empty and would confuse CI coverage dashboards
- guard server?.close() in after() hooks so teardown doesn't throw a
TypeError if the before() hook failed to start the server
- extract setupLoopback() helper inside each page.evaluate() in
transport.browser.ts to avoid verbatim repetition between tests
- add comment in helpers.browser.ts explaining the IDB readonly-transaction
trick that ensures setState()'s async write has settled before opening
a second IndexedDBState instance
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Uses Playwright as a library within node:test (not @playwright/test) to
keep the same runner and script conventions. Browser test files use the
*.browser.ts extension so the existing src/**/*.test.ts glob picks up zero
browser tests, leaving the regular test suite unaffected.
Key pieces:
- .npmrc: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 prevents binary downloads on
npm ci; browsers are installed explicitly in CI via playwright install
- @webnet/browser-test-utils: new private package exporting forBrowsers()
(iterates chromium + firefox, handles browser lifecycle within node:test
suite/before/after) and a serveDirectory() helper (minimal http.createServer
that serves a built dist/ or out/ directory so the browser can fetch ES
modules via dynamic import())
- test:browser / test:browser:coverage scripts added to transport, vfs,
tsconnect following the same c8 + lcov pattern as test:coverage
- turbo.json: test:browser and test:browser:coverage tasks depend on build +
^build (dist/ must exist before the browser can import from it)
- .gitea/workflows/test-browser.yml: CI pipeline that installs browsers with
--with-deps then runs npm run test:browser
Integration tests:
- DataChannelTransport: real RTCPeerConnection loopback (both peers in one
page context), exercises send/receive and close propagation
- FsaVFS: OPFS round-trip (writeFile/readFile), stat, readdir, delete
- IndexedDBState: multi-instance persistence (write via instance 1, open
fresh instance 2 and verify IDB round-trip), empty-DB initialisation
- FsaFileOps: write/read/stat/remove cycle and rename/listFiles over OPFS
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>