Skip to content
Open
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
9 changes: 7 additions & 2 deletions app/learn/[course]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { getAllCourses, getCourseBySlug, getQuizzesForCourse } from "@/lib/learn/loader";
import { LANGUAGE_LABEL } from "@/lib/learn/types";

Expand Down Expand Up @@ -33,8 +34,12 @@ export default async function CoursePage({ params }: { params: Promise<{ course:

return (
<div className="container mx-auto px-4 py-10 max-w-3xl">
<Link href="/learn" className="text-xs text-muted-foreground hover:text-foreground">
← Learn
<Link
href="/learn"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
Learn
</Link>
<header className="mt-3 mb-8">
<h1 className="text-3xl md:text-4xl font-semibold tracking-tight">{data.title}</h1>
Expand Down
34 changes: 26 additions & 8 deletions components/learn/LessonWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@

import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { CodeEditor } from "./CodeEditor";
import { LessonContent } from "./LessonContent";
import { runAll, runSingle } from "@/lib/learn/runner";
import { terminateAllRunners } from "@/lib/learn/multi-runner";
import type { RunResult, RunnerProgress } from "@/lib/learn/runner-types";
import type { Lesson, LessonLanguage } from "@/lib/learn/types";
import { LANGUAGE_FILE_EXTENSION, LANGUAGE_LABEL } from "@/lib/learn/types";
import { ALL_LESSON_LANGUAGES, LANGUAGE_FILE_EXTENSION, LANGUAGE_LABEL } from "@/lib/learn/types";
import type { TestRunOutcome } from "@/lib/learn/runner";

// Cheap runtime guard for values pulled out of localStorage, where the stored
// string could be stale, manually edited, or missing entirely.
const VALID_LANGUAGES: ReadonlySet<string> = new Set(ALL_LESSON_LANGUAGES);

interface LessonWorkspaceProps {
lesson: Lesson;
prevHref: string | null;
Expand Down Expand Up @@ -71,7 +77,7 @@ function loadLanguagePref(): LessonLanguage | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(LANGUAGE_PREF_KEY);
if (!raw) return null;
if (!raw || !VALID_LANGUAGES.has(raw)) return null;
return raw as LessonLanguage;
} catch {
return null;
Expand Down Expand Up @@ -165,6 +171,15 @@ export function LessonWorkspace({
return () => clearTimeout(handle);
}, [code, lesson.courseSlug, lesson.slug, language]);

// Tear down language workers when the component unmounts (route change, etc).
// Without this, pending jobs and the main-thread kill timer stay alive after
// the user navigates away.
useEffect(() => {
return () => {
terminateAllRunners();
};
}, []);

const onProgress = useCallback((p: RunnerProgress) => {
if (p.phase === "loading-toolchain") {
setStatus({ kind: "loading-toolchain", message: p.message ?? "Downloading runtime…" });
Expand Down Expand Up @@ -241,9 +256,10 @@ export function LessonWorkspace({
<div className="mb-4">
<Link
href={courseHref}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
← {courseTitle}
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
{courseTitle}
</Link>
</div>
<LessonContent body={lesson.body} />
Expand All @@ -259,19 +275,21 @@ export function LessonWorkspace({
{prevHref ? (
<Link
href={prevHref}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
← {prevTitle ?? "Previous"}
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
{prevTitle ?? "Previous"}
</Link>
) : (
<span />
)}
{nextHref ? (
<Link
href={nextHref}
className="text-sm text-foreground hover:text-foreground/80 transition-colors"
className="inline-flex items-center gap-1.5 text-sm text-foreground hover:text-foreground/80 transition-colors"
>
{nextTitle ?? "Next"} →
{nextTitle ?? "Next"}
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
) : (
<span />
Expand Down
9 changes: 7 additions & 2 deletions components/learn/QuizWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useCallback, useMemo, useState } from "react";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import type { Quiz, QuizQuestion } from "@/lib/learn/types";
Expand Down Expand Up @@ -117,8 +118,12 @@ export function QuizWorkspace({ quiz, courseTitle, courseHref }: QuizWorkspacePr

return (
<div className="container mx-auto px-4 py-10 max-w-3xl">
<Link href={courseHref} className="text-xs text-muted-foreground hover:text-foreground">
← {courseTitle}
<Link
href={courseHref}
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
{courseTitle}
</Link>
<header className="mt-3 mb-6">
<div className="text-xs uppercase tracking-wider text-muted-foreground">Quiz</div>
Expand Down
70 changes: 44 additions & 26 deletions public/learn-runtime/python-runner.worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,34 @@ async function loadPyodideInstance(postProgress) {
message: "Downloading Python runtime (~10 MB the first time, cached after)…",
});
pyodidePromise = (async () => {
// Pyodide is shipped as a classic IIFE that adds globals to self.
// We use importScripts because this worker is loaded as a module worker
// and importScripts is unavailable there — fall back to dynamic eval of
// the fetched source as a classic script via Function. Simpler: use a
// dynamic import of the ESM build if available, otherwise self-attach.
// pyodide 0.27+ ships an ESM bundle too.
const mod = await import(`${PYODIDE_BASE}pyodide.mjs`);
const loadPyodide = mod.loadPyodide ?? self.loadPyodide;
if (typeof loadPyodide !== "function") {
throw new Error("Failed to locate loadPyodide entry");
try {
// pyodide 0.27+ ships an ESM bundle; dynamic import that.
const mod = await import(`${PYODIDE_BASE}pyodide.mjs`);
const loadPyodide = mod.loadPyodide ?? self.loadPyodide;
if (typeof loadPyodide !== "function") {
throw new Error("Failed to locate loadPyodide entry");
}
const py = await loadPyodide({ indexURL: PYODIDE_BASE });
return py;
} catch (err) {
// Reset the cached promise so a future call re-attempts the load
// instead of re-awaiting an already-rejected promise. Without this a
// single CDN failure permanently bricks the runner for the session.
pyodidePromise = null;
throw err;
}
const py = await loadPyodide({ indexURL: PYODIDE_BASE });
return py;
})();
}
pyodideReady = await pyodidePromise;
return pyodideReady;
try {
pyodideReady = await pyodidePromise;
return pyodideReady;
} catch (err) {
// Belt-and-suspenders: also reset here in case some other call awaited
// the same promise before the inner catch ran.
pyodidePromise = null;
pyodideReady = null;
throw err;
}
}

self.addEventListener("message", async (event) => {
Expand All @@ -52,8 +63,14 @@ self.addEventListener("message", async (event) => {
const postProgress = (progress) => post({ id, type: "progress", progress });
const startedAt = performance.now();

let stdout = "";
let stderr = "";
// Collect stdout/stderr as byte arrays and decode once at the end. Appending
// characters one-at-a-time to a JS string is O(n²) overall, which becomes
// visible even on a few KB of program output.
const stdoutBytes = [];
const stderrBytes = [];
const textDecoder = new TextDecoder();
const decodeOut = () => textDecoder.decode(new Uint8Array(stdoutBytes));
const decodeErr = () => textDecoder.decode(new Uint8Array(stderrBytes));

try {
const py = await loadPyodideInstance(postProgress);
Expand All @@ -77,29 +94,28 @@ self.addEventListener("message", async (event) => {
});
py.setStdout({
raw: (byte) => {
stdout += String.fromCharCode(byte);
stdoutBytes.push(byte);
},
isatty: false,
});
py.setStderr({
raw: (byte) => {
stderr += String.fromCharCode(byte);
stderrBytes.push(byte);
},
isatty: false,
});

postProgress({ phase: "running" });
try {
// Wrap user code so unhandled exceptions don't poison subsequent runs.
// pyodide.runPython is synchronous so this is fine.
// pyodide.runPython is synchronous so any exception lands in our catch.
py.runPython(source);
post({
id,
type: "result",
result: {
ok: true,
stdout,
stderr,
stdout: decodeOut(),
stderr: decodeErr(),
exitCode: 0,
durationMs: performance.now() - startedAt,
},
Expand All @@ -108,14 +124,16 @@ self.addEventListener("message", async (event) => {
// Pyodide wraps Python exceptions as JS PythonError with .message.
const message = err instanceof Error ? err.message : String(err);
const isSyntax = /SyntaxError|IndentationError/.test(message);
const stderrText = decodeErr();
const sep = stderrText === "" || stderrText.endsWith("\n") ? "" : "\n";
post({
id,
type: "result",
result: {
ok: false,
errorKind: isSyntax ? "compile" : "runtime",
stdout,
stderr: stderr + (stderr.endsWith("\n") || stderr === "" ? "" : "\n") + message,
stdout: decodeOut(),
stderr: stderrText + sep + message,
message,
durationMs: performance.now() - startedAt,
},
Expand All @@ -129,8 +147,8 @@ self.addEventListener("message", async (event) => {
result: {
ok: false,
errorKind: "internal",
stdout,
stderr: stderr + message,
stdout: decodeOut(),
stderr: decodeErr() + message,
message,
durationMs: performance.now() - startedAt,
},
Expand Down