diff --git a/build/AGENTS.md b/build/AGENTS.md index 459b9aed628..afb00c298cd 100644 --- a/build/AGENTS.md +++ b/build/AGENTS.md @@ -68,8 +68,7 @@ Usage: plugin via `--load=`. - Suppress an intentional non-visit with `// NOLINT(jsg-visit-for-gc)` plus a comment explaining why the field is safe to skip (see `src/workerd/api/streams/queue.h` - for `ByteQueue::Entry::store` and `src/workerd/api/node/diagnostics-channel.h` - for `Channel::name`). + for `ByteQueue::Entry::store`). ### Incremental check rollout diff --git a/src/workerd/api/node/diagnostics-channel.h b/src/workerd/api/node/diagnostics-channel.h index ffb6cfc457f..04a6350b3f2 100644 --- a/src/workerd/api/node/diagnostics-channel.h +++ b/src/workerd/api/node/diagnostics-channel.h @@ -73,10 +73,9 @@ class Channel: public jsg::Object { } }; - // jsg::Name has a private visitForGc and is visited through NameWrapper - // rather than through the GcVisitor::visit() overload set, so we cannot - // and do not visit it from Channel::visitForGc. - jsg::Name name; // NOLINT(jsg-visit-for-gc) + // Not GC-visited: jsg::Name's visitForGc is private. The symbol handle (if + // any) is a strong root for the Channel's lifetime. + jsg::Name name; kj::HashMap, MessageCallback> subscribers; kj::Table> stores; diff --git a/src/workerd/jsg/AGENTS.md b/src/workerd/jsg/AGENTS.md index 496a7a27244..21e96964c43 100644 --- a/src/workerd/jsg/AGENTS.md +++ b/src/workerd/jsg/AGENTS.md @@ -78,8 +78,8 @@ class MyType: public jsg::Object { These rules MUST be followed when writing or modifying JSG code: 1. **MUST implement `visitForGc()`** on any Resource Type holding `Ref`, `V8Ref`, - `JsRef`, `Function`, `Promise`, `Promise::Resolver`, or - `Name` — see `README.md` §GC-Visitable Types for the complete list + `JsRef`, `Function`, `Promise`, or `Promise::Resolver` — see + `README.md` §GC-Visitable Types for the complete list 2. **MUST visit ALL GC-visitable fields** — missing one causes GC corruption 3. **MUST NOT store `v8::Local` or `JsValue` types as class members** — use `V8Ref` or `JsRef` for persistence diff --git a/src/workerd/jsg/README.md b/src/workerd/jsg/README.md index be8a83f73ba..347c4ab82cf 100644 --- a/src/workerd/jsg/README.md +++ b/src/workerd/jsg/README.md @@ -235,13 +235,11 @@ All types that must be visited in `visitForGc()` if held as Resource Type member | `jsg::JsRef` | Persistent ref to JsValue type | | `jsg::Optional` | When `T` is GC-visitable | | `jsg::LenientOptional` | When `T` is GC-visitable | -| `jsg::Name` | Property name (string or symbol) | | `jsg::Function` | Wrapped JS/C++ function | | `jsg::Promise` | JS promise wrapper | | `jsg::Promise::Resolver` | Promise resolver | -| `jsg::Sequence` | Iterable sequence | +| `jsg::Sequence` | When `T` is GC-visitable (use `visitor.visitAll`) | | `jsg::Generator` | Sync generator | -| `jsg::AsyncGenerator` | Async generator | | `kj::Maybe` | When `T` is GC-visitable | **Not GC-visitable** (compile error if visited): @@ -250,6 +248,12 @@ This is intentionally weak and does NOT keep its target alive during GC. Attempting to `visitor.visit()` a weak ref field is a compile error — the correct signal that weak references should not be traced. Do not include them in `visitForGc()`. +`jsg::Name` is also not visitable (private `visitForGc`): its symbol handle is +a strong root, and a `v8::Symbol` cannot form a JS↔C++ cycle. + +`jsg::AsyncGenerator` is likewise not visitable (no `visitForGc`); holders +keep its handles as strong roots. + ## Weak References `jsg::WeakRef` provides a non-owning, automatically-invalidated reference diff --git a/tools/clang-tidy/BUILD.bazel b/tools/clang-tidy/BUILD.bazel index 231e99a222b..066ff61de64 100644 --- a/tools/clang-tidy/BUILD.bazel +++ b/tools/clang-tidy/BUILD.bazel @@ -113,6 +113,22 @@ sh_test( ], ) +sh_test( + name = "visit-for-gc-test", + srcs = ["visit-for-gc-test.sh"], + data = [ + ":visit-for-gc-negative-test.c++", + ":visit-for-gc-positive-test.c++", + ":workerd-lint", + "//tools:clang-tidy", + ], + tags = ["no-asan"], + target_compatible_with = [ + "@platforms//os:linux", + "@platforms//cpu:x86_64", + ], +) + # Tests the `custom-iocontext-run-manual-capture` query-based check, which is # defined in the workerd `.clang-tidy` config rather than in the plugin. The # test drives clang-tidy against the real merged config, so it does not need the diff --git a/tools/clang-tidy/visit-for-gc-negative-test.c++ b/tools/clang-tidy/visit-for-gc-negative-test.c++ new file mode 100644 index 00000000000..0c7f7096d01 --- /dev/null +++ b/tools/clang-tidy/visit-for-gc-negative-test.c++ @@ -0,0 +1,160 @@ +// Copyright (c) 2017-2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Negative fixtures for jsg-visit-for-gc: nothing below may produce a +// diagnostic. + +namespace workerd::jsg { + +class GcVisitor; + +template +class Ref { + public: + void visitForGc(GcVisitor& visitor) {} +}; + +// Mirrors the real jsg::Name: visitForGc is private. +class Name { + private: + void visitForGc(GcVisitor& visitor) {} +}; + +template +class Promise { + public: + class Resolver { + public: + void visitForGc(GcVisitor& visitor) {} + }; + + void visitForGc(GcVisitor& visitor) {} +}; + +template +class Generator { + public: + void visitForGc(GcVisitor& visitor) {} +}; + +// Mirrors the real jsg::AsyncGenerator: no visitForGc. +template +class AsyncGenerator {}; + +template +class Sequence {}; + +class GcVisitor { + public: + template + void visit(Args&&... args) {} + template + void visitAll(T& collection) {} +}; + +class Object { + public: + void visitForGc(GcVisitor& visitor) {} +}; + +// Records inside namespace jsg are framework internals and are skipped. +class FrameworkInternal { + public: + Ref unvisited; +}; + +} // namespace workerd::jsg + +namespace kj { + +template +class Maybe { + public: + bool operator==(decltype(nullptr)) const { + return true; + } +}; + +template +class OneOf {}; + +} // namespace kj + +namespace jsg = workerd::jsg; + +struct Widget: public jsg::Object {}; + +// Case N1: every visitable field is visited. +struct AllVisited: public jsg::Object { + jsg::Ref ref; + kj::Maybe> maybeRef; + kj::OneOf> stateful; + + void visitForGc(jsg::GcVisitor& visitor) { + visitor.visit(ref, maybeRef, stateful); + } +}; + +struct NestedState { + jsg::Ref func; +}; + +// Case N2: a parent's visitForGc may reach into a directly-held nested +// struct member. +struct ParentReachesNested: public jsg::Object { + NestedState state; + + void visitForGc(jsg::GcVisitor& visitor) { + visitor.visit(state.func); + } +}; + +// Case N3: a plain standalone holder is not demanded against. +struct StandalonePlainHolder { + jsg::Ref strongRoot; +}; + +// Case N4: jsg::Name is not demanded; its private visitForGc makes visiting +// impossible, and the symbol handle is a strong root. +struct UnvisitedNameField: public jsg::Object { + jsg::Name name; + + void visitForGc(jsg::GcVisitor& visitor) {} +}; + +// Case N5: KNOWN BLIND SPOT, locked as current behavior: any mention of the +// field inside the body counts as a visit, even a comparison. +struct MentionOnlyCountsAsVisit: public jsg::Object { + kj::Maybe> mentioned; + + void visitForGc(jsg::GcVisitor& visitor) { + if (mentioned == nullptr) { + return; + } + } +}; + +// Case N6: visited resolver fields are accepted. +struct VisitedResolver: public jsg::Object { + jsg::Promise::Resolver resolver; + kj::Maybe::Resolver> maybeResolver; + + void visitForGc(jsg::GcVisitor& visitor) { + visitor.visit(resolver, maybeResolver); + } +}; + +// Case N7: visited Generator; Sequence via visitAll; non-visitable element +// Sequence and AsyncGenerator held strong. +struct GeneratorAndSequence: public jsg::Object { + jsg::Generator gen; + jsg::Sequence plainSeq; + jsg::Sequence> refSeq; + jsg::AsyncGenerator asyncGen; + + void visitForGc(jsg::GcVisitor& visitor) { + visitor.visit(gen); + visitor.visitAll(refSeq); + } +}; diff --git a/tools/clang-tidy/visit-for-gc-positive-test.c++ b/tools/clang-tidy/visit-for-gc-positive-test.c++ new file mode 100644 index 00000000000..8a5b08c2ec7 --- /dev/null +++ b/tools/clang-tidy/visit-for-gc-positive-test.c++ @@ -0,0 +1,129 @@ +// Copyright (c) 2017-2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Positive fixtures for jsg-visit-for-gc: each case must produce exactly one +// diagnostic; visit-for-gc-test.sh asserts the exact total. + +namespace workerd::jsg { + +class GcVisitor; + +template +class Ref { + public: + void visitForGc(GcVisitor& visitor) {} +}; + +template +class Promise { + public: + class Resolver { + public: + void visitForGc(GcVisitor& visitor) {} + }; + + void visitForGc(GcVisitor& visitor) {} +}; + +template +class Generator { + public: + void visitForGc(GcVisitor& visitor) {} +}; + +template +class Sequence {}; + +class GcVisitor { + public: + template + void visit(Args&&... args) {} + template + void visitAll(T& collection) {} +}; + +class Object { + public: + void visitForGc(GcVisitor& visitor) {} +}; + +} // namespace workerd::jsg + +namespace kj { + +template +class Maybe { + public: + bool operator==(decltype(nullptr)) const { + return true; + } +}; + +template +class OneOf {}; + +} // namespace kj + +namespace jsg = workerd::jsg; + +struct Widget: public jsg::Object {}; + +// Case P1: visitForGc exists but misses a jsg::Ref field. +struct MissedRefField: public jsg::Object { + jsg::Ref visited; + jsg::Ref missed; + + void visitForGc(jsg::GcVisitor& visitor) { + visitor.visit(visited); + } +}; + +// Case P2: no visitForGc of its own; jsg::Object's empty default misses the +// field. +struct NoVisitMethod: public jsg::Object { + jsg::Ref orphaned; +}; + +// Case P4: unvisited kj::Maybe> field (FirstArg container). +struct MissedMaybeRef: public jsg::Object { + kj::Maybe> maybeRef; + + void visitForGc(jsg::GcVisitor& visitor) {} +}; + +// Case P5: unvisited kj::OneOf with a visitable alternative (AnyArg +// container). +struct MissedOneOf: public jsg::Object { + kj::OneOf> stateful; + + void visitForGc(jsg::GcVisitor& visitor) {} +}; + +// Case P6: unvisited jsg::Promise::Resolver field. +struct MissedResolver: public jsg::Object { + jsg::Promise::Resolver resolver; + + void visitForGc(jsg::GcVisitor& visitor) {} +}; + +// Case P7: unvisited kj::Maybe::Resolver> field. +struct MissedMaybeResolver: public jsg::Object { + kj::Maybe::Resolver> maybeResolver; + + void visitForGc(jsg::GcVisitor& visitor) {} +}; + +// Case P8: unvisited jsg::Generator field. +struct MissedGenerator: public jsg::Object { + jsg::Generator gen; + + void visitForGc(jsg::GcVisitor& visitor) {} +}; + +// Case P9: unvisited jsg::Sequence with a visitable element type. +struct MissedSequence: public jsg::Object { + jsg::Sequence> seq; + + void visitForGc(jsg::GcVisitor& visitor) {} +}; diff --git a/tools/clang-tidy/visit-for-gc-test.sh b/tools/clang-tidy/visit-for-gc-test.sh new file mode 100755 index 00000000000..fc8e13b0384 --- /dev/null +++ b/tools/clang-tidy/visit-for-gc-test.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash + +# Copyright (c) 2017-2026 Cloudflare, Inc. +# Licensed under the Apache 2.0 license found in the LICENSE file or at: +# https://opensource.org/licenses/Apache-2.0 + +set -euo pipefail + +readonly ROOT="${TEST_SRCDIR}/${TEST_WORKSPACE}" +readonly CLANG_TIDY="${ROOT}/tools/clang_tidy" +readonly PLUGIN="${ROOT}/tools/clang-tidy/libworkerd-lint.so" +readonly POSITIVE="${ROOT}/tools/clang-tidy/visit-for-gc-positive-test.c++" +readonly NEGATIVE="${ROOT}/tools/clang-tidy/visit-for-gc-negative-test.c++" +readonly CHECKS="-*,jsg-visit-for-gc" + +set +e +positive_output=$("${CLANG_TIDY}" "--load=${PLUGIN}" --checks="${CHECKS}" \ + --warnings-as-errors='*' "${POSITIVE}" -- -std=c++23 2>&1) +positive_status=$? +set -e + +if [[ ${positive_status} -eq 0 ]]; then + printf '%s\n' "Expected jsg-visit-for-gc to reject the positive fixtures." >&2 + printf '%s\n' "${positive_output}" >&2 + exit 1 +fi + +expect_diag() { + local needle="$1" + local description="$2" + if [[ "${positive_output}" != *"${needle}"* ]]; then + printf 'Missing expected diagnostic (%s): %s\n' "${description}" "${needle}" >&2 + printf '%s\n' "${positive_output}" >&2 + exit 1 + fi +} + +expect_diag "field 'missed' of visitable type" "P1: unvisited jsg::Ref field" +expect_diag \ + "field 'orphaned' of visitable type 'jsg::Ref' is not visited in visitForGc (class has no visitForGc method)" \ + "P2: resource with no visitForGc" +expect_diag "field 'maybeRef' of visitable type" "P4: unvisited kj::Maybe" +expect_diag "field 'stateful' of visitable type" "P5: unvisited kj::OneOf alternative" +expect_diag "field 'resolver' of visitable type" "P6: unvisited Promise::Resolver" +expect_diag "field 'maybeResolver' of visitable type" "P7: unvisited Maybe" +expect_diag "field 'gen' of visitable type" "P8: unvisited jsg::Generator" +expect_diag "field 'seq' of visitable type" "P9: unvisited jsg::Sequence of visitable elements" + +# Exact-count lock: one diagnostic per positive case, no more, no fewer. +expected_count=8 +actual_count=$(grep -c "\[jsg-visit-for-gc" <<<"${positive_output}" || true) +if [[ "${actual_count}" -ne "${expected_count}" ]]; then + printf 'Expected exactly %s jsg-visit-for-gc diagnostics, got %s\n' \ + "${expected_count}" "${actual_count}" >&2 + printf '%s\n' "${positive_output}" >&2 + exit 1 +fi + +negative_output=$("${CLANG_TIDY}" "--load=${PLUGIN}" --checks="${CHECKS}" \ + --warnings-as-errors='*' "${NEGATIVE}" -- -std=c++23 2>&1) + +if [[ "${negative_output}" == *"jsg-visit-for-gc"* ]]; then + printf '%s\n' "Expected the negative fixtures to be accepted." >&2 + printf '%s\n' "${negative_output}" >&2 + exit 1 +fi diff --git a/tools/clang-tidy/visit-for-gc.c++ b/tools/clang-tidy/visit-for-gc.c++ index 80524793014..e752eb07225 100644 --- a/tools/clang-tidy/visit-for-gc.c++ +++ b/tools/clang-tidy/visit-for-gc.c++ @@ -35,28 +35,50 @@ bool endsWithQualified(llvm::StringRef qualifiedName, llvm::StringRef suffix) { return qualifiedName[sep - 1] == ':' && qualifiedName[sep - 2] == ':'; } -// Visitable leaf templates: each holds a GC root and must be visited. +// Visitable leaf templates: each holds a GC root and has a public visitForGc. +// jsg::AsyncGenerator is absent: it has no visitForGc, so holders cannot +// visit one; its handles are strong roots. const llvm::StringRef kVisitableLeafTemplates[] = { "jsg::Ref", "jsg::V8Ref", "jsg::JsRef", "jsg::Function", "jsg::Promise", "jsg::HashableV8Ref", - "jsg::MemoizedIdentity", + "jsg::MemoizedIdentity", "jsg::Generator", }; -// Non-template visitable leaf types. +// Non-template visitable leaf types. jsg::Name is deliberately absent: its +// visitForGc is private (friend-only), so no holder can visit one; the symbol +// handle is a strong root, and a v8::Symbol cannot form a JS<->C++ cycle, so +// visitation is never required. const llvm::StringRef kVisitableLeafTypes[] = { - "jsg::Name", "jsg::Value", "jsg::Data", }; +// jsg::Promise::Resolver's printed qualified name embeds the +// specialization arguments, so match the parent record's template instead. +bool isPromiseResolver(const clang::CXXRecordDecl *rd) { + if (rd->getName() != "Resolver") return false; + const auto *parent = llvm::dyn_cast(rd->getDeclContext()); + if (parent == nullptr) return false; + std::string fqn; + if (const auto *spec = + llvm::dyn_cast(parent)) { + fqn = spec->getSpecializedTemplate()->getQualifiedNameAsString(); + } else { + fqn = parent->getQualifiedNameAsString(); + } + return endsWithQualified(fqn, "jsg::Promise"); +} + // Container templates whose visitability is determined by their type // arguments. `FirstArg` containers visit one element; `AnyArg` containers // (variants) are visitable if any element type is. enum class ContainerKind { None, FirstArg, AnyArg }; +// jsg::Sequence is a kj::Array subclass without its own visitForGc: visitable +// iff its element type is (via visitAll). const llvm::StringRef kFirstArgContainers[] = { "kj::Maybe", "kj::Array", "kj::Vector", - "jsg::Optional", "jsg::LenientOptional", + "jsg::Optional", "jsg::LenientOptional", "jsg::Sequence", }; const llvm::StringRef kAnyArgContainers[] = { @@ -93,6 +115,9 @@ bool isVisitableType(clang::QualType qt) { for (auto suffix : kVisitableLeafTypes) { if (endsWithQualified(fqn, suffix)) return true; } + if (const auto *rd = llvm::dyn_cast(rt->getDecl())) { + if (isPromiseResolver(rd)) return true; + } } // Template specialization: dispatch on outer template name. diff --git a/tools/clang-tidy/visit-for-gc.h b/tools/clang-tidy/visit-for-gc.h index 5b27dce5ec7..65897921db0 100644 --- a/tools/clang-tidy/visit-for-gc.h +++ b/tools/clang-tidy/visit-for-gc.h @@ -14,12 +14,23 @@ namespace clang_tidy { // Clang-tidy check that validates JSG resource types correctly visit their // GC roots. Flags fields of visitable types (jsg::Ref, jsg::V8Ref, jsg::JsRef, -// jsg::Function, jsg::Promise, jsg::Value, etc., plus -// kj::Maybe/Array/Vector/OneOf and jsg::Optional wrappers thereof) that are -// not visited in the class's visitForGc() method. +// jsg::Function, jsg::Promise, jsg::Promise::Resolver, jsg::Generator, +// jsg::Value, etc., plus kj::Maybe/Array/Vector/OneOf, jsg::Optional, and +// jsg::Sequence wrappers thereof) that are not visited in the class's +// visitForGc() method. // -// This check helps prevent GC-related bugs where JavaScript objects are -// prematurely collected because the C++ side failed to mark them as reachable. +// Model: an unvisited handle is a strong root — bounded retention, never +// use-after-free. Visiting enables JS<->C++ cycle collection but is only safe +// when the holder is re-traversed every GC cycle from a live wrapper, a +// traversal property this per-record check cannot decide. It therefore only +// demands visits where the framework guarantees traversal, and never forbids +// one. Never fix a diagnostic by blindly adding a visit; deliberate strong +// roots get NOLINT(jsg-visit-for-gc) plus a reason. +// +// Deliberate limitations: any mention of a field in the body counts as a +// visit (accepts the KJ_IF_SOME/KJ_SWITCH_ONEOF binding idiom); templated +// bodies are only checked where instantiated; plain holders without +// visitForGc are only diagnosed when a visible body reaches into them. class VisitForGcCheck : public clang::tidy::ClangTidyCheck { public: VisitForGcCheck(clang::StringRef Name, clang::tidy::ClangTidyContext *Context)