From 0072f91971c01f3ce9f280fc6ce61915ff75ac24 Mon Sep 17 00:00:00 2001 From: Emmanuel Schanzer Date: Mon, 15 Jun 2026 10:38:55 -0500 Subject: [PATCH 1/4] sread: terminate read loops on EOF instead of spinning forever The s-expression reader's inner loops (sread_line, sread_block_comment, sread_atom, sread_string) only broke on specific characters and never checked for nil, which io.read(1) returns at EOF. On an empty or truncated input the reader looped forever at 100% CPU. sread() also dispatched a nil peek into sread_atom, so even a 0-byte file would spin. This was the root of the intermittent `make` hang on "Make pathway-tocs.js": a 0-byte .workbook-lessons.rkt.kp fed sread_file, which never returned. Treat nil (EOF) as terminating in every loop, and have sread() return false when the input is exhausted. Verified: a 0-byte file now returns false immediately, a valid list still parses identically, and a truncated (unterminated) list returns without hanging. Co-Authored-By: Claude Opus 4.8 --- lib/maker/sread.lua | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/maker/sread.lua b/lib/maker/sread.lua index ae12a62e576..21e7cf7c98f 100644 --- a/lib/maker/sread.lua +++ b/lib/maker/sread.lua @@ -9,7 +9,7 @@ local function sread_line(i) while true do local c = i:read(1) - if c == '\r' or c == '\n' then + if c == nil or c == '\r' or c == '\n' then -- nil == EOF break end end @@ -18,7 +18,9 @@ end local function sread_block_comment(i) while true do local c = i:read(1) - if c == '|' then + if c == nil then -- EOF inside an unterminated block comment + break + elseif c == '|' then c = i:read(1) if c == '#' then break @@ -66,8 +68,8 @@ local function sread_atom(i) in_escape_p = true i:read(1) table.insert(result, c) - elseif c == ' ' or c == '\t' or c == '\n' or c == '\r' or c == '(' or c == '[' or c == ')' or c == ']' or c == ';' then - break + elseif c == nil or c == ' ' or c == '\t' or c == '\n' or c == '\r' or c == '(' or c == '[' or c == ')' or c == ']' or c == ';' then + break -- nil == EOF terminates the atom else i:read(1) table.insert(result, c) @@ -97,7 +99,9 @@ local function sread_string(i) local in_escape_p = false while true do local c = i:read(1) - if in_escape_p then + if c == nil then -- EOF inside an unterminated string + break + elseif in_escape_p then in_escape_p = false table.insert(result, c) elseif c == '\\' then @@ -116,7 +120,9 @@ function sread(i) sread_ignorespaces(i) local c = buf_peek_char(i) local result = false - if c == '(' or c == '[' then + if c == nil then -- EOF: no s-expression to read (empty/exhausted input) + result = false + elseif c == '(' or c == '[' then i:read(1) result = sread_list(i) elseif c == ')' or c == ']' then From 59a2bc6b1f8595eaac540d5612ac6dc718c00bfb Mon Sep 17 00:00:00 2001 From: Emmanuel Schanzer Date: Mon, 15 Jun 2026 10:40:10 -0500 Subject: [PATCH 2/4] make-pathway-tocs: skip courses with an empty/unparseable lessons cache The loop guarded only on file_exists_p, then fed the file to sread_file and iterated the result with ipairs. A 0-byte .workbook-lessons.rkt.kp slipped past the existence check; with the sread EOF fix it now returns false, which ipairs() can't iterate. Guard on the parsed result: if sread_file doesn't return a table, warn and skip the course. Verified by emptying __sample's .kp and running the script -- it now skips __sample with a warning and emits a well-formed pathway-tocs.js for the remaining courses, instead of hanging or crashing. Co-Authored-By: Claude Opus 4.8 --- lib/maker/make-pathway-tocs.lua | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/maker/make-pathway-tocs.lua b/lib/maker/make-pathway-tocs.lua index 3bbd0be4d45..440a8e15667 100755 --- a/lib/maker/make-pathway-tocs.lua +++ b/lib/maker/make-pathway-tocs.lua @@ -23,6 +23,13 @@ for _,course in ipairs(all_courses) do local this_course_lessons_file = course_dir .. '.cached/.workbook-lessons.rkt.kp' if not file_exists_p(this_course_lessons_file) then goto continue end local lesson_units = sread_file(this_course_lessons_file) + -- An empty or malformed .kp makes sread_file return a non-table (false on + -- empty/exhausted input). Skip the course rather than indexing into it. + if type(lesson_units) ~= 'table' then + io.stderr:write('WARNING: skipping pathway-toc for ' .. course .. + ' -- empty/unparseable ' .. this_course_lessons_file .. '\n') + goto continue + end o:write(' \"' .. course .. '\": [\n') for _,lunit in ipairs(lesson_units) do local unit_name = lunit[1] From 43296f73cf413d7351edd46593afe726db015e1c Mon Sep 17 00:00:00 2001 From: Emmanuel Schanzer Date: Mon, 15 Jun 2026 10:41:56 -0500 Subject: [PATCH 3/4] massage-course: never leave .workbook-lessons.rkt.kp empty or partial make-workbook-lessons-list.lua opens its output with 'w', truncating the target to 0 bytes the instant it starts; a crash/interrupt then leaves a 0-byte file behind, which a later incremental build's reader consumed (the source of the pathway-tocs hang). The no-lesson-order branch also created a deliberately empty file via `touch`. Publish atomically: write to a .tmp and mv it into place only on success, so an interrupted run leaves the previous good file intact rather than an empty one. For the no-lesson-order case, write a valid empty list "()" instead of a 0-byte touch. Verified: bash -n passes, "()" parses as an empty list, and a simulated mid-populate failure leaves the prior file contents untouched. Co-Authored-By: Claude Opus 4.8 --- lib/maker/massage-course.sh | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/maker/massage-course.sh b/lib/maker/massage-course.sh index c0c22fb4780..1097b4bc809 100755 --- a/lib/maker/massage-course.sh +++ b/lib/maker/massage-course.sh @@ -72,12 +72,22 @@ for pl in $proglangs; do fi done + # Publish .workbook-lessons.rkt.kp atomically. make-workbook-lessons-list.lua + # opens its output with 'w' (truncates to 0 immediately), so a crash or + # interrupt would leave a 0-byte file that a later build's reader chokes on. + # Write a temp and mv it into place only on success; never leave it empty. + kp=.cached/.workbook-lessons.rkt.kp if test ! -f lesson-order.txt; then echo WARNING: No lesson-order.txt in pathway $targetpathway touch lesson-order.txt - touch .cached/.workbook-lessons.rkt.kp + printf '()\n' > "$kp" # valid empty s-expression, not a 0-byte file else - $TOPDIR/${MAKE_DIR}make-workbook-lessons-list.lua lesson-order.txt .cached/.workbook-lessons.rkt.kp $pl + if $TOPDIR/${MAKE_DIR}make-workbook-lessons-list.lua lesson-order.txt "$kp.tmp" $pl; then + mv -f "$kp.tmp" "$kp" + else + echo "ERROR: make-workbook-lessons-list failed for $targetpathway; keeping previous $kp" >&2 + rm -f "$kp.tmp" + fi fi cd .. From 08967bc3a670cb78055735b6766a3d09dc2bb43b Mon Sep 17 00:00:00 2001 From: Dorai Sitaram Date: Mon, 15 Jun 2026 14:53:10 -0400 Subject: [PATCH 4/4] make-workbook-lessons-list.lua: reduce prob of output-file creation being interruptible --- lib/maker/make-workbook-lessons-list.lua | 63 ++++++++++++++---------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/lib/maker/make-workbook-lessons-list.lua b/lib/maker/make-workbook-lessons-list.lua index dc8607c3d51..b69a35dd659 100755 --- a/lib/maker/make-workbook-lessons-list.lua +++ b/lib/maker/make-workbook-lessons-list.lua @@ -1,16 +1,13 @@ #! /usr/bin/env lua --- last modified 2026-03-10 +-- last modified 2026-06-15 local inf, outf, pl = ... -local i = io.open(inf, 'r') -local o = io.open(outf, 'w') - local current_unit = 'NO_UNIT' local current_unit_lessons = {} -local function write_unit() +local function write_unit(o) if #current_unit_lessons > 0 then o:write('( "' .. current_unit .. '"\n') for _,y in ipairs(current_unit_lessons) do @@ -26,28 +23,42 @@ if pl ~= 'pyret' and pl ~= 'none' then proglang = '-' .. pl end -o:write('(\n') - -for x0 in i:lines() do - local x = x0 - x = x:gsub('^%s+', '') - x = x:gsub(';.*', '') - x = x:gsub('%s+$', '') - if x == '' then goto continue end - if x:find('^%[') then - x = x:gsub('%[%s*(.-)%s*%]', '%1') - write_unit() - current_unit = x - current_unit_lessons = {} - else - x = x .. proglang - table.insert(current_unit_lessons, x) +local function get_all_lines(inf) + local the_lines = {} + for L in io.lines(inf) do + table.insert(the_lines, L) end - ::continue:: + return the_lines end -write_unit() -o:write(')\n') +local function write_all_units(inf, outf) + local the_lines = get_all_lines(inf) + local o = io.open(outf, 'w') + + o:write('(\n') + + for _,x0 in ipairs(the_lines) do + local x = x0 + x = x:gsub('^%s+', '') + x = x:gsub(';.*', '') + x = x:gsub('%s+$', '') + if x == '' then goto continue end + if x:find('^%[') then + x = x:gsub('%[%s*(.-)%s*%]', '%1') + write_unit(o) + current_unit = x + current_unit_lessons = {} + else + x = x .. proglang + table.insert(current_unit_lessons, x) + end + ::continue:: + end + write_unit(o) + + o:write(')\n') + + o:close() +end -i:close() -o:close() +write_all_units(inf, outf)