From ae4d6e446d2bde187f5141ee2d1d2ef86375f67d Mon Sep 17 00:00:00 2001 From: Michael Allwright Date: Mon, 5 Oct 2026 16:04:41 +0200 Subject: [PATCH 1/3] Accept precompile in boot.json, add canvas handlers --- .../ClientSideTestRunner/kernel/Kernel.ts | 10 ++++++-- .../kernel/kernelClient.ts | 24 +++++++++++++++---- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/app/javascript/components/editor/ClientSideTestRunner/kernel/Kernel.ts b/app/javascript/components/editor/ClientSideTestRunner/kernel/Kernel.ts index 223dc5360f..076a8e480c 100644 --- a/app/javascript/components/editor/ClientSideTestRunner/kernel/Kernel.ts +++ b/app/javascript/components/editor/ClientSideTestRunner/kernel/Kernel.ts @@ -104,8 +104,9 @@ export class Kernel { } }) - const { env, preload, licence } = self.config - await self.#client.boot(entry.sysroot, toEnv(env), preload, licence) + const { env, licence } = self.config + const precompile = self.config.precompile ?? self.config.preload ?? [] + await self.#client.boot(entry.sysroot, toEnv(env), precompile, licence) // The sysroot carries the language's tools but not the runner itself, so // /opt/test-runner is untarred on top of it. Part of boot rather than of a @@ -220,6 +221,11 @@ export class Kernel { // Nothing we run reads stdin. streamIn: () => undefined, streamClosed: () => {}, + // Nor draws. + createCanvas: () => { + throw new KernelError('Test runs have no canvas') + }, + destroyCanvas: () => {}, } } diff --git a/app/javascript/components/editor/ClientSideTestRunner/kernel/kernelClient.ts b/app/javascript/components/editor/ClientSideTestRunner/kernel/kernelClient.ts index bac1e16236..91672d0104 100644 --- a/app/javascript/components/editor/ClientSideTestRunner/kernel/kernelClient.ts +++ b/app/javascript/components/editor/ClientSideTestRunner/kernel/kernelClient.ts @@ -24,7 +24,11 @@ export interface Subscription { done: Promise } -/** The handlers the kernel calls back into. Every one is required. */ +/** + * The handlers the kernel calls back into. Every one the kernel knows of is + * required, and ones it does not know of are ignored, so the canvas pair is + * passed to every kernel even though only newer ones ask for it. + */ export interface Interface { streamOut(stream: number, data: ArrayBuffer): void | Promise streamErr(stream: number, data: ArrayBuffer): void | Promise @@ -32,6 +36,13 @@ export interface Interface { stream: number ): (ArrayBuffer | undefined) | Promise streamClosed(stream: number): void + createCanvas( + pid: number, + canvas: number, + title: string, + events: MessagePort + ): OffscreenCanvas | Promise + destroyCanvas(canvas: number): void } export interface KernelClient { @@ -39,7 +50,7 @@ export interface KernelClient { boot( sysroot: string, env: string[], - preload: string[], + precompile: string[], licence?: Licence[] ): Request untar(path: string, data: ArrayBuffer): Request @@ -59,9 +70,14 @@ export type KernelClientConstructor = new (options: { handlers: Interface }) => KernelClient -/** The kernel's boot.json, served beside sysroot.tar. */ +/** + * The kernel's boot.json, served beside sysroot.tar. Sysroots published for + * earlier kernels call precompile preload, and are immutable, so either is + * read. + */ export type BootConfig = { env: Record - preload: string[] + precompile?: string[] + preload?: string[] licence?: Licence[] } From 4df8b4cf2743d66a14ec9e5c0786a92994b66ad2 Mon Sep 17 00:00:00 2001 From: Michael Allwright Date: Mon, 5 Oct 2026 18:58:35 +0200 Subject: [PATCH 2/3] Forward Content-Encoding for test runner artifacts --- app/controllers/pages_controller.rb | 6 ++++++ test/controllers/pages_controller_test.rb | 19 ++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 7e58ee5ed6..1925036833 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -122,6 +122,12 @@ def test_runner_artifact response.set_header("Cross-Origin-Embedder-Policy", "require-corp") response.set_header("Cross-Origin-Resource-Policy", "same-origin") + # Artifacts are stored brotli-encoded (see exercism/clientside-tooling) and + # the browser decodes them itself, as long as the header travels with the + # bytes. Nothing between here and the browser compresses for us: these + # responses are no-transform. + response.set_header("Content-Encoding", object.content_encoding) if object.content_encoding.present? + send_data object.body.read, type: artifact_content_type(key), disposition: :inline rescue Aws::S3::Errors::ServiceError # A missing key reads as AccessDenied rather than NoSuchKey, because the app diff --git a/test/controllers/pages_controller_test.rb b/test/controllers/pages_controller_test.rb index dadaa433f6..303f200ee1 100644 --- a/test/controllers/pages_controller_test.rb +++ b/test/controllers/pages_controller_test.rb @@ -38,7 +38,7 @@ class PagesControllerTest < ActionDispatch::IntegrationTest test "test runner artifacts carry no page headers" do Exercism.config.stubs(:respond_to?).with(:aws_test_runners_bucket).returns(true) Exercism.config.stubs(:aws_test_runners_bucket).returns("bucket") - object = stub(body: StringIO.new("// kernel")) + object = stub(body: StringIO.new("// kernel"), content_encoding: nil) Exercism.s3_client.stubs(:get_object). with(bucket: "bucket", key: "test-runners/kernel/abc/kernel.js"). returns(object) @@ -54,6 +54,23 @@ class PagesControllerTest < ActionDispatch::IntegrationTest refute_includes response.headers["Vary"].to_s, "Turbo-Frame" end + # Artifacts are stored brotli-encoded. The header has to reach the browser, + # or it gets the compressed bytes as the file. + test "test runner artifacts keep their content encoding" do + Exercism.config.stubs(:respond_to?).with(:aws_test_runners_bucket).returns(true) + Exercism.config.stubs(:aws_test_runners_bucket).returns("bucket") + object = stub(body: StringIO.new("brotli bytes"), content_encoding: "br") + Exercism.s3_client.stubs(:get_object). + with(bucket: "bucket", key: "test-runners/sysroot/cpp/19.0.0/1/sysroot.tar"). + returns(object) + + get "/test-runners/sysroot/cpp/19.0.0/1/sysroot.tar" + + assert_response :ok + assert_equal "br", response.headers["Content-Encoding"] + assert_equal "application/x-tar", response.media_type + end + test "health_check works" do user = create :user, :system From abb8f9a9772f3e3217d57efd495d7041601161bc Mon Sep 17 00:00:00 2001 From: Jeremy Walker Date: Thu, 22 Oct 2026 12:37:43 +0900 Subject: [PATCH 3/3] Serve test runner artifacts as no-transform The artifacts are stored brotli-encoded at the highest quality, so no-transform stops Cloudflare re-encoding them on the way to the browser. The artifact tests now put real objects in the LocalStack test runners bucket instead of stubbing Exercism.config and the S3 client. Co-Authored-By: Claude Opus 5.5 --- app/controllers/pages_controller.rb | 8 +++- test/controllers/pages_controller_test.rb | 49 ++++++++++++++--------- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 1925036833..6f9f751adc 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -112,8 +112,12 @@ def test_runner_artifact # latest.json is the only mutable object - it is how a language is pointed # at a new build - so it gets a short life. Everything it points at lives # under a uuid and never changes. + # + # no-transform keeps Cloudflare from re-encoding them: they are stored + # brotli-encoded at the highest quality, and the bytes should reach the + # browser as published. cache_control = key.end_with?("latest.json") ? "public, max-age=60" : "public, max-age=31536000, immutable" - response.set_header("Cache-Control", cache_control) + response.set_header("Cache-Control", "#{cache_control}, no-transform") # The kernel runs in a worker spawned by a cross-origin isolated page, and a # worker script has to declare a policy compatible with its owner's - this @@ -125,7 +129,7 @@ def test_runner_artifact # Artifacts are stored brotli-encoded (see exercism/clientside-tooling) and # the browser decodes them itself, as long as the header travels with the # bytes. Nothing between here and the browser compresses for us: these - # responses are no-transform. + # responses are no-transform (above). response.set_header("Content-Encoding", object.content_encoding) if object.content_encoding.present? send_data object.body.read, type: artifact_content_type(key), disposition: :inline diff --git a/test/controllers/pages_controller_test.rb b/test/controllers/pages_controller_test.rb index 303f200ee1..2aa040007e 100644 --- a/test/controllers/pages_controller_test.rb +++ b/test/controllers/pages_controller_test.rb @@ -24,30 +24,26 @@ class PagesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to "http://test.exercism.org/dashboard" end - # The bucket is not configured outside production, so there is nothing to - # serve. It must answer rather than raise: the editor asks for a manifest on - # every page load, and an unhandled routing error fails any system test that - # opens the editor. - test "test runner artifacts 404 when no bucket is configured" do - get "/test-runners/ruby/latest.json" + # A key that is not in the bucket is how a track without a client-side runner + # looks. It must answer rather than raise: the editor asks for a manifest on + # every page load, and an unhandled error fails any system test that opens + # the editor. + test "test runner artifacts 404 when the object is missing" do + get "/test-runners/missing/latest.json" assert_response :not_found end # The kernel worker takes its CSP from the artifact response, and that # response is cached for a year, so the page-only headers must not be on it. test "test runner artifacts carry no page headers" do - Exercism.config.stubs(:respond_to?).with(:aws_test_runners_bucket).returns(true) - Exercism.config.stubs(:aws_test_runners_bucket).returns("bucket") - object = stub(body: StringIO.new("// kernel"), content_encoding: nil) - Exercism.s3_client.stubs(:get_object). - with(bucket: "bucket", key: "test-runners/kernel/abc/kernel.js"). - returns(object) + upload_test_runner_artifact("kernel/abc/kernel.js", "// kernel") get "/test-runners/kernel/abc/kernel.js" assert_response :ok assert_equal "text/javascript", response.media_type - assert_includes response.headers["Cache-Control"], "immutable" + assert_equal %w[immutable max-age=31536000 no-transform public], response.headers["Cache-Control"].split(", ").sort + assert_nil response.headers["Content-Encoding"] assert_nil response.headers["Content-Security-Policy-Report-Only"] assert_nil response.headers["Link"] assert_nil response.headers["Exercism-Body-Class"] @@ -57,18 +53,23 @@ class PagesControllerTest < ActionDispatch::IntegrationTest # Artifacts are stored brotli-encoded. The header has to reach the browser, # or it gets the compressed bytes as the file. test "test runner artifacts keep their content encoding" do - Exercism.config.stubs(:respond_to?).with(:aws_test_runners_bucket).returns(true) - Exercism.config.stubs(:aws_test_runners_bucket).returns("bucket") - object = stub(body: StringIO.new("brotli bytes"), content_encoding: "br") - Exercism.s3_client.stubs(:get_object). - with(bucket: "bucket", key: "test-runners/sysroot/cpp/19.0.0/1/sysroot.tar"). - returns(object) + upload_test_runner_artifact("sysroot/cpp/19.0.0/1/sysroot.tar", "brotli bytes", content_encoding: "br") get "/test-runners/sysroot/cpp/19.0.0/1/sysroot.tar" assert_response :ok assert_equal "br", response.headers["Content-Encoding"] assert_equal "application/x-tar", response.media_type + assert_equal "brotli bytes", response.body + end + + test "latest.json gets a short life" do + upload_test_runner_artifact("cpp/latest.json", "{}") + + get "/test-runners/cpp/latest.json" + + assert_response :ok + assert_equal %w[max-age=60 no-transform public], response.headers["Cache-Control"].split(", ").sort end test "health_check works" do @@ -108,4 +109,14 @@ class PagesControllerTest < ActionDispatch::IntegrationTest assert_response :not_found end end + + private + def upload_test_runner_artifact(path, body, content_encoding: nil) + Exercism.s3_client.put_object( + bucket: Exercism.config.aws_test_runners_bucket, + key: "test-runners/#{path}", + body:, + content_encoding: + ) + end end