From f9e6fb839a9bfc19be9de008db0419826d837709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 29 Oct 2026 11:15:42 +0100 Subject: [PATCH 1/2] Suggest the closest service for an unknown service name A designator naming a service that is not defined now ends its error with a hint naming the closest defined service, when one is within a small edit distance (case-insensitive Levenshtein, at most a third of the name's length and at least 2): Socket "http" refers to a service "mainn", but no such service is defined. Did you mean "main"? This covers every lookup through lookup_service and lookup_actor_class: sockets, bindings, globalOutbound, cacheApiOutbound and tails. --- src/workerd/server/server/config-test.rs | 31 ++++++++++++++ src/workerd/server/server/config.rs | 51 +++++++++++++++++++++--- 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/src/workerd/server/server/config-test.rs b/src/workerd/server/server/config-test.rs index 68256665735..9a6122b27c3 100644 --- a/src/workerd/server/server/config-test.rs +++ b/src/workerd/server/server/config-test.rs @@ -227,6 +227,37 @@ fn lookup_of_a_missing_service_is_reported() { ); } +#[test] +fn lookup_of_a_misspelled_service_suggests_the_closest_name() { + let services = leaf_services(); + let (report, errors) = collecting_reporter(); + lookup_service(&services, &report, &designator("nett", None)); + lookup_actor_class(&services, &report, &designator("NET", None)); + assert_eq!( + *errors.borrow(), + [ + "Worker \"w\"'s binding \"b\" refers to a service \"nett\", but no such service \ + is defined. Did you mean \"net\"?", + "Worker \"w\"'s binding \"b\" refers to a service \"NET\", but no such service \ + is defined. Did you mean \"net\"?", + ] + ); +} + +#[test] +fn did_you_mean_suggests_only_close_names() { + let names = ["main", "internet", "storage"]; + assert_eq!(did_you_mean("mian", names), " Did you mean \"main\"?"); + assert_eq!(did_you_mean("MAIN", names), " Did you mean \"main\"?"); + assert_eq!( + did_you_mean("interet", names), + " Did you mean \"internet\"?" + ); + assert_eq!(did_you_mean("database", names), ""); + assert_eq!(did_you_mean("main", names), ""); + assert_eq!(did_you_mean("x", []), ""); +} + #[test] fn lookup_of_a_leaf_with_an_entrypoint_is_reported() { let services = leaf_services(); diff --git a/src/workerd/server/server/config.rs b/src/workerd/server/server/config.rs index 9be554cc1e0..f75488f9469 100644 --- a/src/workerd/server/server/config.rs +++ b/src/workerd/server/server/config.rs @@ -893,6 +893,49 @@ impl<'a> WorkflowEngine<'a> { } } +/// The Levenshtein distance between `a` and `b`, ignoring ASCII case. +fn edit_distance(a: &str, b: &str) -> usize { + let a: Vec = a.bytes().map(|c| c.to_ascii_lowercase()).collect(); + let b: Vec = b.bytes().map(|c| c.to_ascii_lowercase()).collect(); + let mut prev: Vec = (0..=b.len()).collect(); + let mut cur = vec![0; b.len() + 1]; + for (i, &ca) in a.iter().enumerate() { + cur[0] = i + 1; + for (j, &cb) in b.iter().enumerate() { + let cost = usize::from(ca != cb); + cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost); + } + std::mem::swap(&mut prev, &mut cur); + } + prev[b.len()] +} + +/// A hint for an error about a name that is not defined: ` Did you mean "x"?`, naming the closest +/// of `candidates`, or empty when none is close enough to be worth suggesting. +pub fn did_you_mean<'a>(name: &str, candidates: impl IntoIterator) -> String { + let threshold = (name.len() / 3).max(2); + let best = candidates + .into_iter() + .filter(|candidate| *candidate != name) + .map(|candidate| (edit_distance(name, candidate), candidate)) + .filter(|(distance, _)| *distance <= threshold) + .min_by_key(|(distance, _)| *distance); + best.map_or_else(String::new, |(_, candidate)| { + format!(" Did you mean \"{candidate}\"?") + }) +} + +/// The error for a designator whose service is not defined, with a hint naming the closest +/// service that is. +fn no_such_service( + context: &str, + target: &str, + services: &LinkedHashMap, +) -> String { + let hint = did_you_mean(target, services.keys().map(String::as_str)); + format!("{context} refers to a service \"{target}\", but no such service is defined.{hint}") +} + /// What a `ServiceDesignator` resolves to, or the invalid-config stand-in after the error was /// reported. pub fn lookup_service( @@ -907,9 +950,7 @@ pub fn lookup_service( error_context: context, } = designator; let Some(service) = services.get(target) else { - report.error(format!( - "{context} refers to a service \"{target}\", but no such service is defined." - )); + report.error(no_such_service(context, target, services)); return Rc::new(InvalidConfigChannel); }; let props = configured_props(props_json.as_deref()); @@ -966,9 +1007,7 @@ pub fn lookup_actor_class( error_context: context, } = designator; let Some(service) = services.get(target) else { - report.error(format!( - "{context} refers to a service \"{target}\", but no such service is defined." - )); + report.error(no_such_service(context, target, services)); return Rc::new(InvalidConfigActorClass); }; let props = configured_props(props_json.as_deref()); From 5236c6993bd617ae655552f5116766c9e29a26f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 29 Oct 2026 11:15:42 +0100 Subject: [PATCH 2/2] Add workerd check `workerd check ` loads a config the way `serve` does, .capnp or wrangler.json(c), builds every service and compiles every Worker, and resolves every socket's service, but binds no socket and joins no cluster. It reports every error rather than only the first, then either "N config errors found." with exit status 1, or "Config OK: N services, N sockets.". --directory-path and --external-addr are checked against the config as `serve` checks them. A socket without an address is a warning, since `serve` takes it from --socket-addr or --socket-fd. --watch and --inspector-addr are usage errors rather than silently ignored. The socket designator lookup `start` did inline moves into socket_channel, which `check` shares. A shell test runs the binary on a valid config, one with three errors, an unmatched override and a wrangler.jsonc. --- README.md | 7 ++ src/workerd/server/AGENTS.md | 8 +- src/workerd/server/cli/args-test.rs | 27 +++++++ src/workerd/server/cli/args.rs | 20 +++++ src/workerd/server/cli/lib.rs | 39 ++++++++++ src/workerd/server/server/entry.rs | 78 ++++++++++++++++--- src/workerd/server/server/run.rs | 74 +++++++++++++++--- src/workerd/server/tests/check/BUILD.bazel | 18 +++++ src/workerd/server/tests/check/check-test.sh | 63 +++++++++++++++ src/workerd/server/tests/check/errors.capnp | 22 ++++++ src/workerd/server/tests/check/ok.capnp | 25 ++++++ src/workerd/server/tests/check/ok.js | 5 ++ src/workerd/server/tests/check/throws.js | 1 + .../server/tests/check/wrangler/index.js | 5 ++ .../tests/check/wrangler/wrangler.jsonc | 7 ++ 15 files changed, 375 insertions(+), 24 deletions(-) create mode 100644 src/workerd/server/tests/check/BUILD.bazel create mode 100755 src/workerd/server/tests/check/check-test.sh create mode 100644 src/workerd/server/tests/check/errors.capnp create mode 100644 src/workerd/server/tests/check/ok.capnp create mode 100644 src/workerd/server/tests/check/ok.js create mode 100644 src/workerd/server/tests/check/throws.js create mode 100644 src/workerd/server/tests/check/wrangler/index.js create mode 100644 src/workerd/server/tests/check/wrangler/wrangler.jsonc diff --git a/README.md b/README.md index 8204b119d7b..31bc6203f0a 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,13 @@ To serve your config, do: `workerd serve my-config.capnp` +To check a config without serving it, do: + +`workerd check my-config.capnp` + +This builds every service and compiles every Worker, but binds no socket. It reports every +error rather than only the first, and exits with status 1 if there was any. + For more details about command-line usage, use `workerd --help`. Prebuilt binaries are distributed via `npm`. Run `npx workerd ...` to use these. If you're running a prebuilt binary, you'll need to make sure your system has the right dependencies installed: diff --git a/src/workerd/server/AGENTS.md b/src/workerd/server/AGENTS.md index 16a31a8ea43..c1ab083c1fb 100644 --- a/src/workerd/server/AGENTS.md +++ b/src/workerd/server/AGENTS.md @@ -4,7 +4,7 @@ The `workerd` binary and its server. `:workerd` (`workerd.rs`, this package) is a Rust binary over three crates in this directory: -- `cli/` -- the `workerd-cli` crate (declared in this package's `BUILD.bazel`, sources globbed from `cli/`): parses the command line (clap), produces the encoded config (schema files are compiled by this package's `config-compiler.c++`), handles `compile`, `--watch` and re-exec, and hands `serve`, `test` (which `fuzzilli` and `make-pyodide-baseline-snapshot` are built on) and `bench` to the server crate's `entry` module. +- `cli/` -- the `workerd-cli` crate (declared in this package's `BUILD.bazel`, sources globbed from `cli/`): parses the command line (clap), produces the encoded config (schema files are compiled by this package's `config-compiler.c++`), handles `compile`, `--watch` and re-exec, and hands `serve`, `check`, `test` (which `fuzzilli` and `make-pyodide-baseline-snapshot` are built on) and `bench` to the server crate's `entry` module. - `server/` -- the `workerd-server` crate: the single-tenant server. It interprets the config, builds the service graph, binds the sockets, runs the listeners, actors and dynamic workers, drains on SIGTERM, and runs `workerd test` and `workerd bench`. `Error` is `cxx::KjError`; `bridge.rs` is the one module that allows `unsafe`. - `factory/` -- C++: `WorkerFactory` (`worker-factory*`), everything that needs the isolate, driven by the server through the cxx bridge in `server/bridge.rs`; and `bootstrap.{h,c++}`, the C++ half of a command. `with_process_context` sets up the process's logging (the JSON logger, when the config asks for structured logging) on the C++ stack around the Rust command; `bootstrap` sets up perfetto, autogates and V8 on the Rust-owned event loop and builds the `WorkerFactory`, which owns them, from the encoded config and the command-line options for `server/entry.rs`, which owns the loop (`kj_rs_tokio::Runtime`) and blocks on `server_serve` / `server_test` / `server_bench`. - `cluster/` -- the `cluster` crate and, in `cluster/logic/`, `cluster-logic`: cluster mode (`Config.cluster`, experimental), Durable Objects whose shared state is an S3-compatible bucket. Leases, ownership, LTX capture and restore; pure Rust, driven by `server/cluster.rs`. `cluster-logic` holds decisions only, with no dependencies to do I/O with. @@ -67,8 +67,8 @@ Bazel labels: `//src/workerd/server:workerd`, `//src/workerd/server:workerd-cli` | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `lib.rs` | Crate root: `Error` / `Result` | | `bridge.rs` | The cxx bridge (`workerd::server`) to `factory/worker-factory.h` and `factory/bootstrap.h`. `extern "C++"`: the factory (compile, start request, bench, actors and storage, RPC, tokens, inspector, `factory_spawn`), the bootstrap, the process's logging and its exit; `extern "Rust"`: the channel objects, the channel factory, the pending command. Ownership rules in its `//!` | -| `entry.rs` | `serve` / `test` / `bench`: the process's schedule (`with_process_context`, the `kj_rs_tokio::Runtime`, `bootstrap`, `block_on`, exit), `server_serve` / `server_test` / `server_bench`, the `Watch` future the command line supplies, printing config errors and the exit status they make, SIGTERM drain, `--watch` reload, shutdown order (settle tasks, drop the factory and with it V8 and the perfetto session, then the Runtime) | -| `run.rs` | `run()` / `test()` / `bench()`: bind sockets, start the inspector (before any isolate exists), build the graph, start the listeners (not for a config the `Reporter` refuses), drain, control-fd events, the check that every `--directory-path` / `--external-addr` override named a service | +| `entry.rs` | `serve` / `check` / `test` / `bench`: the process's schedule (`with_process_context`, the `kj_rs_tokio::Runtime`, `bootstrap`, `block_on`, exit), `server_serve` / `server_check` / `server_test` / `server_bench`, the `Watch` future the command line supplies, printing config errors and the exit status they make, SIGTERM drain, `--watch` reload, shutdown order (settle tasks, drop the factory and with it V8 and the perfetto session, then the Runtime) | +| `run.rs` | `run()` / `test()` / `bench()`, and `check()`, which only builds the graph: bind sockets, start the inspector (before any isolate exists), build the graph, start the listeners (not for a config the `Reporter` refuses), drain, control-fd events, the check that every `--directory-path` / `--external-addr` override named a service | | `bench.rs` | `workerd bench`'s options and report (`io/bench.capnp`): a group for each `bench()` handler `run::bench` runs (through the factory's `worker_bench`), progress logged as `workerd test` logs it, the environment, and the report as a table or as JSON | | `config.rs` | `Server`: the service graph from the config in three passes (actor configs, construction, link); `Factory`, the Rust owner of the C++ `WorkerFactory`; `Reporter`; resolves channel tokens and debug-port requests | | `channels.rs` | The handles C++ holds and the runtime reaches into the server through: `SubrequestChannel` (an `Rc`), `ActorClassChannel` (an `Rc`), `ChannelFactory` (a worker's `LinkedChannels`), `WorkerStub`, and the `Weak` back-pointers (`ServerHandle`, `ActorNamespaceHandle`, `ActorHooks`). Single-threaded | @@ -99,7 +99,7 @@ Bazel labels: `//src/workerd/server:workerd`, `//src/workerd/server:workerd-cli` - **Rust unit tests**: a module's tests are in the `-test.rs` beside it (`config-test.rs` for `config.rs`, `listen/mod-test.rs` for `listen/mod.rs`), compiled only into the crates' test targets (`//src/workerd/server/server:workerd-server_test`, `//src/workerd/server:workerd-cli_test`); see `src/rust/AGENTS.md`, UNIT TEST FILES. The server crate's test binary links `//src/workerd/util:setup-async-io` because the C++ it reaches calls `kj::setupAsyncIo()`. - **C++ unit tests** (`*-test.c++`, `kj_test`): alarm scheduler, channel tokens, container client, facet tree index, fallback service, JSON logger, actor IDs. - **`server-test.c++`** (`//src/workerd/server:server-test@`): the server end to end, in the test process. Each `TestServer` builds a `WorkerFactory` on the test's V8, timer, network and a temp directory, and hands it to `InProcessServer` (`server/in_process.rs`, through the server crate's bridge), which runs `run::run` / `run::test` / `run::bench` on it and queues the config errors and warnings for the test. No connection but UDP's leaves the process: the config's socket and external addresses become `loopback:` names, the test connects to the server's sockets and accepts the server's outbound connections by name (the `network` services' too), and the factory's own C++ clients (Docker, the debug port) dial the same names through the test's `kj::Network`. The factory's timer is the test's mock `kj::TimerImpl`, which `test.wait(n)` advances; the server's own delays run on it too (`Factory::sleep` / `Factory::now`, `server/config.rs`). An unexpected error log, config error or warning fails the case, as does a factory still referenced once the server is closed. Add a case here for every new config error or warning. -- **`tests/`**: `server-harness.mjs`, a Node.js harness spawning the `workerd` binary for E2E tests; it reads the `listen` events off `--control-fd`. Subdirectories: `compile-tests`, `container-client`, `container-shutdown`, `extensions`, `inspector`, `module-fallback`, `python`, `socket-close`, `structured-logging`, `udp-connect`, `udp-connect-ts-streams`, `udp-datagram-drop`, `unsafe-eval`, `unsafe-module`, `weakref`, `wrapped-binding-serialization`. Every `.wd-test` in the repository also runs through `workerd test` (`wd_test` in `build/wd_test.bzl`, over `:workerd`), and every `wd_bench()` config through `workerd bench --quick`, in its `@smoke` tests (`build/wd_bench.bzl`). +- **`tests/`**: `server-harness.mjs`, a Node.js harness spawning the `workerd` binary for E2E tests; it reads the `listen` events off `--control-fd`. Subdirectories: `check` (`workerd check`, as a shell test), `compile-tests`, `container-client`, `container-shutdown`, `extensions`, `inspector`, `module-fallback`, `python`, `socket-close`, `structured-logging`, `udp-connect`, `udp-connect-ts-streams`, `udp-datagram-drop`, `unsafe-eval`, `unsafe-module`, `weakref`, `wrapped-binding-serialization`. Every `.wd-test` in the repository also runs through `workerd test` (`wd_test` in `build/wd_test.bzl`, over `:workerd`), and every `wd_bench()` config through `workerd bench --quick`, in its `@smoke` tests (`build/wd_bench.bzl`). - **kj-hyper contract tests** (`src/rust/cxx/kj-hyper/tests`): HTTP and WebSocket semantics against kj's interfaces. - `:rust-io-link-check` inspects the linked binary's symbols (unix): kj's own `setupAsyncIo()` must not be linked in. diff --git a/src/workerd/server/cli/args-test.rs b/src/workerd/server/cli/args-test.rs index 4787dcaa452..80b6afbf4dc 100644 --- a/src/workerd/server/cli/args-test.rs +++ b/src/workerd/server/cli/args-test.rs @@ -42,6 +42,33 @@ fn wd_test_invocation() { ); } +#[test] +fn check_invocation() { + let Command::Check { + config, + const_name, + serve_or_test, + } = parse(&[ + "check", + "config.capnp", + "myConfig", + "--experimental", + "-dstorage=/tmp/x", + ]) + .command + else { + panic!("expected check") + }; + assert_eq!(config.config_file, "config.capnp"); + assert_eq!(const_name.const_name.as_deref(), Some("myConfig")); + assert!(serve_or_test.experimental); + assert_eq!(serve_or_test.directory_overrides.len(), 1); + // `check` serves nothing, so it takes none of `serve`'s socket options. + assert!( + Main::try_parse_from(["workerd", "check", "c.capnp", "--socket-addr=http=*:0"]).is_err() + ); +} + #[test] fn serve_options() { let Command::Serve { diff --git a/src/workerd/server/cli/args.rs b/src/workerd/server/cli/args.rs index 2a809003f84..3b2997eb8e6 100644 --- a/src/workerd/server/cli/args.rs +++ b/src/workerd/server/cli/args.rs @@ -77,6 +77,26 @@ pub enum Command { serve: ServeArgs, }, + /// check a config and report every problem, without serving + #[command( + long_about = "Loads a config and reports every problem with it, without serving.", + after_long_help = "Accepts the same as `serve`. Every service is built and \ + every Worker compiled, so errors in a Worker's top-level code are reported too, but no \ + socket is bound, so a check can run next to a server using the same ports. Every \ + error is reported, rather than only the first, followed by their count, or by \ + \"Config OK\" if there were none. Exits with status 1 if there was any error." + )] + Check { + #[command(flatten)] + config: ConfigFileArgs, + + #[command(flatten)] + const_name: ConstNameArg, + + #[command(flatten)] + serve_or_test: ServeOrTestArgs, + }, + /// create a self-contained binary #[command( long_about = "Builds a self-contained binary from a config.", diff --git a/src/workerd/server/cli/lib.rs b/src/workerd/server/cli/lib.rs index 67d262307ea..2142d0db69f 100644 --- a/src/workerd/server/cli/lib.rs +++ b/src/workerd/server/cli/lib.rs @@ -129,6 +129,17 @@ fn run_command(args: args::Main, executable: Option) -> workerd_serv watching(process), ) } + args::Command::Check { + config, + const_name, + serve_or_test, + } => run_check( + verbose, + config, + const_name.const_name, + &serve_or_test, + executable, + ), args::Command::Compile { config, const_name, @@ -193,6 +204,34 @@ fn run_command(args: args::Main, executable: Option) -> workerd_serv } } +/// `workerd check`: every error in the config, without serving it. Options that only make sense +/// for a running server are usage errors rather than silently ignored. +fn run_check( + verbose: bool, + config: args::ConfigFileArgs, + const_name: Option, + serve_or_test: &args::ServeOrTestArgs, + executable: Option, +) -> workerd_server::Result { + type T = args::Main; + + check_serve_or_test::(serve_or_test); + if serve_or_test.watch { + usage_error::("--watch: `check` does not serve, so has nothing to reload."); + } + if serve_or_test.inspector_addr.is_some() { + usage_error::("--inspector-addr: `check` does not serve, so starts no inspector."); + } + let process = Process::new(executable, None, Vec::new()); + let config = load_config::(config, const_name, &process); + entry::check( + verbose, + config.into_words(), + serve_or_test.into(), + run_options(serve_or_test).overrides, + ) +} + /// `workerd compile`: the config, encoded, either on its own (`--config-only`) or appended to a /// copy of this executable. fn run_compile( diff --git a/src/workerd/server/server/entry.rs b/src/workerd/server/server/entry.rs index edd0678c3fa..b5a9d77be80 100644 --- a/src/workerd/server/server/entry.rs +++ b/src/workerd/server/server/entry.rs @@ -2,17 +2,18 @@ // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 -//! The `serve`, `test` and `bench` commands: the command line's Rust half, and the process's -//! schedule. +//! The `serve`, `check`, `test` and `bench` commands: the command line's Rust half, and the +//! process's schedule. //! //! The command line (cli/lib.rs) parses the arguments into [`RunOptions`] and calls -//! [`serve`] / [`test`] / [`bench`]. Each creates the event loop -- a `kj_rs_tokio::Runtime`, the -//! KJ loop with tokio running whenever it sleeps -- has the C++ bootstrap -//! (bootstrap.c++) set up the process (logging, perfetto, V8) and build the worker factory on that -//! loop, then blocks on `server_serve` / `server_test` / `server_bench`, which run the server -//! (`run.rs`). Every config error is printed, and the server then does not serve; unless -//! `--watch` is on, in which case it serves what it can, waits for the config to change and the -//! process re-executes itself. +//! [`serve`] / [`check`] / [`test`] / [`bench`]. Each creates the event loop -- a +//! `kj_rs_tokio::Runtime`, the KJ loop with tokio running whenever it sleeps -- has the C++ +//! bootstrap (bootstrap.c++) set up the process (logging, perfetto, V8) and build the worker +//! factory on that loop, then blocks on `server_serve` / `server_check` / `server_test` / +//! `server_bench`, which run the server (`run.rs`). Every config error is printed, and the server +//! then does not serve; unless `--watch` is on, in which case it serves what it can, waits for +//! the config to change and the process re-executes itself. `check` never serves: it builds the +//! services, prints every error and then how many there were. //! //! Exit: status 0 when the run completes, 1 if any error was reported or the run failed (the //! failure is printed as `*** Uncaught exception ***`). However the run ended, the factory has @@ -20,6 +21,7 @@ //! set the process ends right there, without running the remaining destructors, like //! `kj::ProcessContext::exit()`; with it, the Runtime is torn down and `main` returns the status. +use std::cell::Cell; use std::convert::Infallible; use std::future::Future; use std::rc::Rc; @@ -35,6 +37,7 @@ use crate::bench; use crate::bridge::ffi; use crate::channels::AbortReason; use crate::config::Factory; +use crate::config::Overrides; use crate::config::Reporter; use crate::log::Severity; use crate::run::RunOptions; @@ -58,6 +61,18 @@ pub fn serve( run(verbose, config, options, None, None, command) } +/// Runs `workerd check` (see the module doc): reports every config error, then their count or +/// that the config is OK. Nothing is served. The exit status, if the process is to go on. +pub fn check( + verbose: bool, + config: Vec, + options: ffi::ServeOrTestOptions, + overrides: Overrides, +) -> Result { + let command = move |factory, structured| server_check(factory, overrides, structured); + run(verbose, config, options, None, None, command) +} + /// Runs `workerd test` (see the module doc); the exit status, if the process is to go on. /// `patterns` are the `` and `` globs. pub fn test( @@ -240,6 +255,51 @@ async fn server_serve( shutdown } +/// `workerd check`: builds `factory`'s config without serving it (`run::check`), then reports +/// how many config errors there were, or that the config is OK. +async fn server_check( + factory: KjOwn, + overrides: Overrides, + structured: bool, +) -> Result<()> { + crate::log::install(); + let errors = Rc::new(Cell::new(0usize)); + let counted = Rc::clone(&errors); + let counting = Reporter::new( + Box::new(move |message| { + counted.set(counted.get() + 1); + error(structured, &message); + }), + Box::new(move |message| report(structured, Severity::Warning, &message)), + // Nothing is served, so there is nothing to refuse. + true, + ); + let factory = Rc::new(Factory::new(factory)); + let result = crate::run::check(Rc::clone(&factory), &overrides, counting).await; + let shutdown = shutdown(factory).await; + let summary = result?; + shutdown?; + let plural = |n: usize| if n == 1 { "" } else { "s" }; + match errors.get() { + 0 => report( + structured, + Severity::Info, + &format!( + "Config OK: {} service{}, {} socket{}.", + summary.services, + plural(summary.services), + summary.sockets, + plural(summary.sockets) + ), + ), + count => error( + structured, + &format!("{count} config error{} found.", plural(count)), + ), + } + Ok(()) +} + /// Tears the run down once the server is gone: ends the factory's background tasks, then drops /// the factory and, with it, the last of the isolates, V8 and the perfetto session. A factory /// still shared at this point is a leaked service. diff --git a/src/workerd/server/server/run.rs b/src/workerd/server/server/run.rs index 184b0b571a1..43de6c5eda7 100644 --- a/src/workerd/server/server/run.rs +++ b/src/workerd/server/server/run.rs @@ -4,6 +4,8 @@ //! `workerd serve`, `workerd test` and `workerd bench`: bind the sockets, build the service //! graph, listen, and either wait for the drain signal or run the tests or benchmarks. +//! +//! `workerd check` builds the graph only. use std::collections::HashMap; use std::future::Future; @@ -177,17 +179,7 @@ async fn start( continue; }; let name = text(sock.get_name())?; - let mut errors = Vec::new(); - let designator = Designator::from_reader( - sock.get_service().map_err(capnp_error)?, - format!("Socket \"{name}\""), - &mut errors, - ) - .map_err(capnp_error)?; - for error in errors { - server.report().error(error); - } - let channel = server.lookup(&designator); + let channel = socket_channel(&server, sock, &name)?; ports.push((name, listen::bound_port(&bound)?)); if let Some(listener) = listener(Rc::clone(&context), sock, bound, &address, channel, &server)? @@ -222,6 +214,25 @@ async fn start( })) } +/// The channel socket `name` serves, after reporting any error in its service designator. +fn socket_channel( + server: &Server, + sock: socket::Reader<'_>, + name: &str, +) -> Result> { + let mut errors = Vec::new(); + let designator = Designator::from_reader( + sock.get_service().map_err(capnp_error)?, + format!("Socket \"{name}\""), + &mut errors, + ) + .map_err(capnp_error)?; + for error in errors { + server.report().error(error); + } + Ok(server.lookup(&designator)) +} + /// Runs this instance's part in the cluster: the peer port, the lease, and compaction. They run for /// the life of the process; a lease the instance cannot keep ends it. fn start_cluster(factory: &Rc, node: &Rc, listener: tokio::net::TcpListener) { @@ -438,6 +449,47 @@ pub async fn run( Ok(ffi::factory_shutdown_containers(server.factory().raw()).await?) } +/// What `workerd check` found in a config. +pub struct CheckSummary { + pub services: usize, + pub sockets: usize, +} + +/// `workerd check`: builds the service graph, compiling every Worker, and resolves every +/// socket's service, without binding a socket or joining a cluster. Config errors go to +/// `report`. +/// +/// A socket without an address is a warning rather than an error: `workerd serve` accepts its +/// address on the command line. +pub async fn check( + factory: Rc, + overrides: &Overrides, + report: Reporter, +) -> Result { + let message = factory.config()?; + let config = message.get_root::().map_err(capnp_error)?; + let sockets = config.get_sockets().map_err(capnp_error)?; + for sock in sockets { + if !sock.has_address() { + let name = text(sock.get_name())?; + report.warning(format!( + "Socket \"{name}\" has no address in the config, so `workerd serve` needs one \ + on the command line with `--socket-addr` or `--socket-fd`." + )); + } + } + let server = + Server::start(Rc::clone(&factory), overrides, report, HashMap::new(), None).await?; + report_unmatched_overrides(&server, overrides, config, false)?; + for sock in sockets { + socket_channel(&server, sock, &text(sock.get_name())?)?; + } + Ok(CheckSummary { + services: config.get_services().map_err(capnp_error)?.len() as usize, + sockets: sockets.len() as usize, + }) +} + /// The entrypoints `workerd test` or `workerd bench` runs a handler of: the channel of each, /// and its name ("service" or "service:entrypoint"). type Targets = Vec<(Rc, String)>; diff --git a/src/workerd/server/tests/check/BUILD.bazel b/src/workerd/server/tests/check/BUILD.bazel new file mode 100644 index 00000000000..953a20f0336 --- /dev/null +++ b/src/workerd/server/tests/check/BUILD.bazel @@ -0,0 +1,18 @@ +load("@rules_shell//shell:sh_test.bzl", "sh_test") + +sh_test( + name = "check-test", + size = "small", + srcs = ["check-test.sh"], + args = [ + "$(location //src/workerd/server:workerd)", + "$(location ok.capnp)", + ], + data = glob([ + "*.capnp", + "*.js", + "wrangler/*", + ]) + [ + "//src/workerd/server:workerd", + ], +) diff --git a/src/workerd/server/tests/check/check-test.sh b/src/workerd/server/tests/check/check-test.sh new file mode 100755 index 00000000000..85e26d4db27 --- /dev/null +++ b/src/workerd/server/tests/check/check-test.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +# `workerd check`: its exit status, and the errors, warnings and summary it prints. +# +# $1 -> workerd binary path +# $2 -> path to ok.capnp, beside the test's other configs + +set -euo pipefail + +WORKERD_BINARY=$1 +CONFIGS=$(dirname "$2") +OUTPUT=$TEST_TMPDIR/output +FAILED=0 + +# Runs `workerd check` on the given arguments, expecting the exit status $1, and leaves its +# output in $OUTPUT. +check() { + local expected=$1 + shift + local status=0 + "$WORKERD_BINARY" check "$@" > "$OUTPUT" 2>&1 || status=$? + if [[ $status != "$expected" ]]; then + echo "FAIL: \`workerd check $*\` exited with status $status, expected $expected:" >&2 + cat "$OUTPUT" >&2 + FAILED=1 + fi +} + +# Expects $OUTPUT to contain the line $1. +expect_line() { + if ! grep -qxF -- "$1" "$OUTPUT"; then + echo "FAIL: expected the line: $1" >&2 + echo "in the output:" >&2 + cat "$OUTPUT" >&2 + FAILED=1 + fi +} + +check 0 "$CONFIGS/ok.capnp" +expect_line 'Socket "admin" has no address in the config, so `workerd serve` needs one on the command line with `--socket-addr` or `--socket-fd`.' +expect_line "Config OK: 2 services, 2 sockets." + +# Every error is reported, not only the first: a Worker whose top-level code throws, and two +# misspelled service names, each with a hint. +check 1 "$CONFIGS/errors.capnp" +expect_line "service broken: Uncaught Error: failed at startup" +expect_line "Worker \"main\"'s binding \"OTHER\" refers to a service \"mian\", but no such service is defined. Did you mean \"main\"?" +expect_line 'Socket "http" refers to a service "mainn", but no such service is defined. Did you mean "main"?' +expect_line "3 config errors found." + +# Overrides are checked against the config, as `serve` checks them. +check 1 "$CONFIGS/ok.capnp" --directory-path=nope=/tmp +expect_line 'Config did not define any disk service named "nope" to match the override provided on the command line.' +expect_line "1 config error found." + +check 0 "$CONFIGS/wrangler/wrangler.jsonc" +expect_line "Config OK: 1 service, 1 socket." + +# Options for a running server are refused rather than ignored. +check 1 "$CONFIGS/ok.capnp" --watch +expect_line 'error: --watch: `check` does not serve, so has nothing to reload.' + +exit $FAILED diff --git a/src/workerd/server/tests/check/errors.capnp b/src/workerd/server/tests/check/errors.capnp new file mode 100644 index 00000000000..e41f8374812 --- /dev/null +++ b/src/workerd/server/tests/check/errors.capnp @@ -0,0 +1,22 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const config :Workerd.Config = ( + services = [ + (name = "main", worker = .main), + (name = "broken", worker = .broken), + ], + sockets = [ + (name = "http", address = "localhost:1", http = (), service = "mainn"), + ], +); + +const main :Workerd.Worker = ( + compatibilityDate = "2025-01-01", + modules = [(name = "main.js", esModule = embed "ok.js")], + bindings = [(name = "OTHER", service = "mian")], +); + +const broken :Workerd.Worker = ( + compatibilityDate = "2025-01-01", + modules = [(name = "main.js", esModule = embed "throws.js")], +); diff --git a/src/workerd/server/tests/check/ok.capnp b/src/workerd/server/tests/check/ok.capnp new file mode 100644 index 00000000000..26a3cb49228 --- /dev/null +++ b/src/workerd/server/tests/check/ok.capnp @@ -0,0 +1,25 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const config :Workerd.Config = ( + services = [ + (name = "main", worker = .main), + (name = "other", worker = .other), + ], + sockets = [ + # Port 1 is privileged: `check` binds no socket, so it never tries. + (name = "http", address = "localhost:1", http = (), service = "main"), + # No address: `serve` needs --socket-addr, which `check` warns about. + (name = "admin", http = (), service = "other"), + ], +); + +const main :Workerd.Worker = ( + compatibilityDate = "2025-01-01", + modules = [(name = "main.js", esModule = embed "ok.js")], + bindings = [(name = "OTHER", service = "other")], +); + +const other :Workerd.Worker = ( + compatibilityDate = "2025-01-01", + modules = [(name = "main.js", esModule = embed "ok.js")], +); diff --git a/src/workerd/server/tests/check/ok.js b/src/workerd/server/tests/check/ok.js new file mode 100644 index 00000000000..a187155c36c --- /dev/null +++ b/src/workerd/server/tests/check/ok.js @@ -0,0 +1,5 @@ +export default { + async fetch() { + return new Response('ok'); + }, +}; diff --git a/src/workerd/server/tests/check/throws.js b/src/workerd/server/tests/check/throws.js new file mode 100644 index 00000000000..cb5f15b9920 --- /dev/null +++ b/src/workerd/server/tests/check/throws.js @@ -0,0 +1 @@ +throw new Error('failed at startup'); diff --git a/src/workerd/server/tests/check/wrangler/index.js b/src/workerd/server/tests/check/wrangler/index.js new file mode 100644 index 00000000000..a187155c36c --- /dev/null +++ b/src/workerd/server/tests/check/wrangler/index.js @@ -0,0 +1,5 @@ +export default { + async fetch() { + return new Response('ok'); + }, +}; diff --git a/src/workerd/server/tests/check/wrangler/wrangler.jsonc b/src/workerd/server/tests/check/wrangler/wrangler.jsonc new file mode 100644 index 00000000000..9cf9ada8425 --- /dev/null +++ b/src/workerd/server/tests/check/wrangler/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "name": "check-wrangler", + "main": "index.js", + "compatibility_date": "2025-01-01", + // Scanned as plain modules, so the test needs no esbuild. + "no_bundle": true, +}