Skip to content

feat!: lint through an injected file system in a host-supplied worker - #21

Merged
timonwong merged 6 commits into
mainfrom
feat/workspace-fs-bridge
Sep 23, 2026
Merged

timonwong merged 6 commits into
mainfrom
feat/workspace-fs-bridge

Conversation

@timonwong

Copy link
Copy Markdown
Member

Summary

vscode-shellcheck is moving its experimental wasm runtime to vscode.workspace.fs, so documents of any URI scheme (remote, virtual) can be linted, with VS Code for the Web as a later step. This makes the package file-system agnostic: no shipped entry imports node:*, and the guest only sees files through a ShellCheckFileSystem the host passes with each lint.

Changes

  • New API: createShellCheck({ module, createWorker }).lint({ args, stdin, env, fs }, { signal }), one lint at a time in FIFO order. Aborting a running lint terminates its Worker and the next lint respawns it.
  • ./worker exports startWorker(port). The host creates the Worker, so the package never touches worker_threads or DOM Worker APIs.
  • Sync bridge: the Worker blocks in Atomics.wait on one SharedArrayBuffer per Worker while the host thread services async stat / readFile / readDirectory (1 MiB chunks, per-lint cache). The read-only preopen is built on it. .. above / is ENOTCAPABLE; symlinks are left to the file-system backend.
  • BUILD_INFO is compiled in; ./build-info, ./node, run() and the node:fs preopen are removed. build-info.json is no longer in the tarball (still attached to GitHub Releases).
  • Release workflow publishes prerelease versions under the next dist-tag and marks the GitHub Release as a prerelease. Version bumped to 0.2.0-next.0.
  • ADR 0006 (file-system agnostic package, why not ms-vscode.wasm-wasi-core or @vscode/sync-api-*), an amendment to ADR 0005 (Worker and bridge now live here; scheduling policy stays in the host), and a note on ADR 0003 (the earlier "reactor is slower" evidence came from a differently built third-party package).

Performance

npm run bench (median ms per lint, mount with .shellcheckrc + one sourced file):

Node lines 0.1.1 node:fs new bridge Δ native
22 23 91.2 95.8 +5.0% 34.0
22 307 693.5 715.5 +3.2% 202.2
22 1503 4361.7 4322.5 −0.9% 1295.5
24 23 120.8 122.2 +1.1% 39.0
24 307 761.2 768.9 +1.0% 204.7
24 1503 4267.4 4292.1 +0.6% 1233.9

Small scripts vary by about ±5% run to run. The Worker polls for up to 1 ms before sleeping on each answer; without it, small scripts were 11–13% slower. +RTS -A64m was measured and is 10–45% slower, so it is not used.

Reviewer notes

  • Browsers only expose SharedArrayBuffer under cross-origin isolation, which the web follow-up will have to deal with.
  • readDirectory is covered only by bridge and preopen tests; this ShellCheck build never lists directories.

readBuildInfo() read dist/build-info.json with node:fs, which ties the
build info to Node and to a file that has to travel with the module.
`npm run build` now turns dist/build-info.json into a typed BUILD_INFO
constant, exported from the package entry, and fails if the JSON does
not describe dist/shellcheck.wasm or the pinned ShellCheck version.
build-info.json stays a GitHub Release asset but leaves the tarball.

BREAKING CHANGE: the ./build-info subpath and readBuildInfo() are
removed; import BUILD_INFO from the package entry instead.
The extension is moving to lint documents of any URI scheme by reading
them through vscode.workspace.fs, and later to run on the web, so the
package can no longer reach for node:fs or hand back a synchronous
runner that blocks the caller.

createShellCheck({ module, createWorker }) now owns one Worker at a
time and runs lints through it in FIFO order. Each lint may carry a
ShellCheckFileSystem (async stat, readFile, readDirectory) that is
mounted read-only at guest `/`. The guest's synchronous WASI calls reach
it through a SharedArrayBuffer bridge: the Worker posts a request and
waits in Atomics.wait while the caller's thread runs the async call,
with payloads over 1 MiB sent in chunks and answers cached for the rest
of the lint. `..` is folded lexically and may not leave `/`; symlink
containment is now the file system's job. An AbortSignal drops a queued
lint or terminates the Worker of the running one; a lost Worker is
replaced on the next lint. The Worker side ships as `./worker`
(startWorker), and no shipped module imports a Node built-in.

BREAKING CHANGE: the `./node` entry (loadModule, createReadOnlyPreopen,
wasmPath) and the synchronous run() export are removed. Use
createShellCheck() with a Worker whose entry calls startWorker(), and
pass the files ShellCheck may read as LintRequest.fs. LintResult carries
stdout and stderr as strings.
The bridge adds a round trip to the caller's thread for every file
ShellCheck touches, so the redesign has to show it costs no more than
10% over the 0.1.1 node:fs runner. `npm run bench` installs 0.1.1 into
.cache/bench, runs both in worker_threads Workers on generated 23, 307
and 1503-line scripts with a .shellcheckrc and a sourced file, and
reports native ShellCheck and a `+RTS -A64m` run as data points.
A version with a prerelease suffix (0.2.0-next.0) would otherwise become
`latest` on npm and a regular GitHub Release. Publish it with
`--tag next` and mark its GitHub Release as a prerelease; stable
versions publish as before.
Rewrite the README around createShellCheck, the Worker entry and the
ShellCheckFileSystem contract, including the web requirement of
cross-origin isolation for SharedArrayBuffer. Add ADR 0006 (the package
is file-system agnostic), amend ADR 0005 now that the Worker and bridge
live in the package while policy stays in the host, and note in ADR
0003 that the evidence against a reactor build measured a third-party
build rather than the reactor model.
@timonwong
timonwong merged commit 30d7241 into main Sep 23, 2026
3 checks passed
@timonwong
timonwong deleted the feat/workspace-fs-bridge branch September 23, 2026 09:58
timonwong added a commit to vscode-shellcheck/vscode-shellcheck that referenced this pull request Sep 29, 2026
Supersedes #1952. This branch contains all of #1952's commits; #1952
stays open until this lands.

## Summary

Adds the experimental `shellcheck.runtime: "wasm"` from #1952, but the
wasm runtime now reads every file through `vscode.workspace.fs` instead
of `node:fs`. Documents of any URI scheme can be linted in wasm mode,
including virtual workspaces. Nothing on the wasm path uses `node:fs`,
`node:path` or `process.platform`, so VS Code for the Web (#478) only
needs a Worker adapter and cross-origin isolation later.

It consumes `@vscode-shellcheck/shellcheck-wasm@0.2.0-next.0`
(vscode-shellcheck/shellcheck-wasm#21). That package now owns the Worker
protocol and a SharedArrayBuffer bridge that turns the guest's
synchronous WASI file calls into async `stat` / `readFile` /
`readDirectory` calls on the extension host thread.

## Changes

- **Worker:** `src/runtime/wasm/worker.ts` is now a 5-line `parentPort`
adapter around the package's `startWorker`. The compiled Module is read
with `workspace.fs.readFile` and posted to the Worker. #1952's
`DataCloneError` fallback is gone: it was only defensive, and posting
the Module works in the Electron extension host.
- **File system adapter** (`workspace-fs.ts`): serves guest paths from
the mount root over `workspace.fs` and maps `FileSystemError` codes.
Symlinks are followed wherever the file system provider follows them;
the realpath containment of #1952 is gone with `node:fs`.
- **Mounts** (all schemes): a document inside a workspace folder mounts
that folder; otherwise it mounts its own directory; `untitled:`
documents get stdin only. `PWD` is computed on `Uri.path`, which
replaces the win32 host-to-guest path mapping.
- **Scheduling:** one lint in flight, latest request only per document
(a newer request aborts the running one), the active editor's document
jumps the queue, and a 30 s watchdog via `AbortSignal`. A lost Worker or
a guest trap fails only that lint; the next lint gets a fresh Worker.
- **Virtual workspaces:** `capabilities.virtualWorkspaces` is
`"limited"`. With `runtime=native`, documents that are neither `file:`
nor `untitled:` are skipped and logged, with no prompt.
`ignoreFileSchemes` (git, gitfs, output) still applies to both runtimes.
- **Docs:** `docs/plans/wasm-runtime-workspace-fs.md` records the
design, including why `ms-vscode.wasm-wasi-core` was rejected.
`docs/plans/wasm-runtime.md` is marked superseded.

## Reviewer notes

- The bridge costs at most +5% per lint compared with the 0.1.1
`node:fs` runner (measured on Node 22/24; numbers in
vscode-shellcheck/shellcheck-wasm#21).
- The case-insensitive workspace-folder prefix match in `toGuestPath`
exists for Windows drive-letter and path casing. It is only covered by
unit tests, so the Windows CI job is the real check.
- The native-runtime skip in virtual workspaces has no automated test,
because the test harness cannot open a virtual workspace.
felipecrs pushed a commit to vscode-shellcheck/vscode-shellcheck that referenced this pull request Sep 29, 2026
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.2.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant