Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion app/controllers/pages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -122,6 +126,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 (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
rescue Aws::S3::Errors::ServiceError
# A missing key reads as AccessDenied rather than NoSuchKey, because the app
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: () => {},
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,22 +24,33 @@ export interface Subscription {
done: Promise<void>
}

/** 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<void>
streamErr(stream: number, data: ArrayBuffer): void | Promise<void>
streamIn(
stream: number
): (ArrayBuffer | undefined) | Promise<ArrayBuffer | undefined>
streamClosed(stream: number): void
createCanvas(
pid: number,
canvas: number,
title: string,
events: MessagePort
): OffscreenCanvas | Promise<OffscreenCanvas>
destroyCanvas(canvas: number): void
}

export interface KernelClient {
readFile(path: string): Request<ArrayBuffer>
boot(
sysroot: string,
env: string[],
preload: string[],
precompile: string[],
licence?: Licence[]
): Request<void>
untar(path: string, data: ArrayBuffer): Request<void>
Expand All @@ -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<string, string>
preload: string[]
precompile?: string[]
preload?: string[]
licence?: Licence[]
}
54 changes: 41 additions & 13 deletions test/controllers/pages_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,36 +24,54 @@ 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"))
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"]
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
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
user = create :user, :system

Expand Down Expand Up @@ -91,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
Loading