BrightSign: use the platform's own file-existence idiom, and don't re-fetch a staged package

Both found while watching a real self-update run end to end on the XT245.

FileExists now uses roReadFile + type(), which is what BrightSign's own published
autozip.brs does (their CheckFile). MatchFiles is for LISTING a directory; as an
existence check it has already burned this codebase once, passing a full path as
both arguments so it could never return true for anything. Correcting it to a
directory plus a bare name did work — I misread a mid-cycle inspection as a
second failure and it was not — but roReadFile takes the full path every call
site naturally has, needs no reasoning about volume-root semantics, and is the
form the vendor ships. The narrower idiom is worth having here precisely because
nothing in CI can tell us when this is wrong.

CheckPackageUpdate now returns early when a package is already staged. Observed
on hardware: the periodic check fired in the gap between staging an archive and
the reboot that applies it, and pulled the whole thing down a second time.
Harmless on a desk; on a metered or marginal link it is exactly the waste the
rest of this release exists to remove.

The self-update chain is now proven on hardware, twice: check, download, sha256
and size verify, stage, reboot, staged unpack, move into place without touching
screentinker.json, mark done, reboot into it. The player reports 1.9.29-rc5 and
its autorun.brs carries the archive's timestamp rather than a hand-copied one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
ScreenTinker 2026-08-05 23:22:09 -05:00
parent a85e067260
commit c2effc9f5f
3 changed files with 59 additions and 31 deletions

View file

@ -437,17 +437,20 @@ Function PackageVersion() As String
return "0.0.0-dev" ' ST_PACKAGE_VERSION (stamped at build time — do not edit by hand)
End Function
' Does [name] exist in directory [dir]?
' Does [path] exist?
'
' MatchFiles takes a DIRECTORY plus a pattern, and returns nothing when the pattern contains a
' separator. The previous version passed a full path as both arguments, so it answered "no" for
' every file on every player — which silently disabled this entire self-update path: the pending
' package was never seen, the .part file was never cleaned up, and the .done marker was never
' noticed. Two arguments, so there is no path-splitting to get wrong.
Function FileExists(dir As String, name As String) As Boolean
files = MatchFiles(dir, name)
if files = invalid then return false
return files.Count() > 0
' roReadFile + a type() check — the idiom BrightSign's own boilerplate uses (CheckFile in their
' published autozip.brs). It takes a FULL PATH, which is what every call site naturally has.
'
' MatchFiles is deliberately not used here. It is for LISTING a directory: it takes a directory plus
' a pattern, returns nothing when the pattern contains a separator, and — as this player
' demonstrated — does not reliably answer for a volume root like "SSD:/". The first version of this
' function passed a path as both arguments and could never return true at all; the second passed a
' directory and a bare name and still answered "no" for a file sitting right there. An existence
' check that is subtly wrong is worse than none, because every guard built on it silently opens.
Function FileExists(path As String) As Boolean
f = CreateObject("roReadFile", path)
return type(f) = "roReadFile"
End Function
' Unpack a package that is sitting on storage waiting to be applied. Runs BEFORE the widget so a
@ -464,8 +467,8 @@ Sub ApplyPendingPackage(root As String)
badPath$ = root + "/autorun.zip.bad"
stage$ = root + "/st-staging"
if not FileExists(dir$, "autorun.zip") then return
if FileExists(dir$, "autorun.zip.done") then return ' already unpacked; again is the boot loop
if not FileExists(dir$ + "autorun.zip") then return
if FileExists(dir$ + "autorun.zip.done") then return ' already unpacked; again is the boot loop
print "[st-update] unpacking pending package"
@ -488,7 +491,7 @@ Sub ApplyPendingPackage(root As String)
' Unpack() returns Void, so success is proven by looking for what should now exist rather than
' by testing a return value that was never there.
if not FileExists(stage$ + "/", "autorun.brs") then
if not FileExists(stage$ + "/autorun.brs") then
print "[st-update] ERROR: extract produced no autorun.brs — parking it as .bad"
MoveFile(zipPath$, badPath$)
return
@ -523,6 +526,15 @@ End Sub
Sub CheckPackageUpdate(cfg As Object, root As String)
if cfg.server_url = "" then return
' A package already staged and waiting for its apply-reboot is not a reason to fetch another.
' Observed on hardware: the periodic check fired in the gap between staging and rebooting and
' pulled the whole archive down a second time. Harmless here; on a metered or marginal link it
' is the same waste this product spent a release eliminating everywhere else.
if FileExists(root + "/autorun.zip") then
print "[st-update] a package is already staged — waiting for it to apply"
return
end if
partPath$ = root + "/autorun.zip.part"
reg = CreateObject("roRegistrySection", "screentinker")
attempts% = 0
@ -551,7 +563,7 @@ Sub CheckPackageUpdate(cfg As Object, root As String)
' Any earlier partial is deleted first: resuming into an existing file would concatenate two
' downloads into something that hashes to neither.
if FileExists(root + "/", "autorun.zip.part") then DeleteFile(partPath$)
if FileExists(root + "/autorun.zip.part") then DeleteFile(partPath$)
dl = CreateObject("roUrlTransfer")
if dl = invalid then return
@ -573,8 +585,8 @@ Sub CheckPackageUpdate(cfg As Object, root As String)
end if
' Promote. Marker first — see the ordering note above.
if FileExists(root + "/", "autorun.zip.done") then DeleteFile(root + "/autorun.zip.done")
if FileExists(root + "/", "autorun.zip") then DeleteFile(root + "/autorun.zip")
if FileExists(root + "/autorun.zip.done") then DeleteFile(root + "/autorun.zip.done")
if FileExists(root + "/autorun.zip") then DeleteFile(root + "/autorun.zip")
if not MoveFile(partPath$, root + "/autorun.zip") then
print "[st-update] ERROR: could not stage the package — staying put"
RecordPackageAttempt(reg, attempts% + 1)

View file

@ -30,7 +30,7 @@
Function SourceRoot() As String
volumes = ["USB1:", "SD:", "SD2:", "SSD:", "FLASH:"]
for each v in volumes
if FileExists(v + "/", "autorun.zip") then return v
if FileExists(v + "/autorun.zip") then return v
end for
return ""
End Function
@ -47,14 +47,14 @@ Sub Main()
print "[st-autozip] volume "; root$
if not FileExists(extractPath$, "autorun.zip") then
if not FileExists(extractPath$ + "autorun.zip") then
print "[st-autozip] no autorun.zip at "; zipPath$; " — nothing to do"
return
end if
' Idempotence. Without this the player extracts, reboots, extracts again, reboots again —
' a boot loop that looks like a hardware fault.
if FileExists(extractPath$, "autorun.zip.done") then
if FileExists(extractPath$ + "autorun.zip.done") then
print "[st-autozip] already unpacked (autorun.zip.done present) — leaving it alone"
return
end if
@ -74,7 +74,7 @@ Sub Main()
' anything: the file we came here to install is now on the card.
package.Unpack(extractPath$)
if not FileExists(extractPath$, "autorun.brs") then
if not FileExists(extractPath$ + "autorun.brs") then
print "[st-autozip] ERROR: unpack produced no autorun.brs — leaving the archive for a retry"
return
end if
@ -94,17 +94,18 @@ Sub Main()
RebootSystem()
End Sub
' Does [name] exist in directory [dir]?
' Does [path] exist?
'
' THE BUG THIS FIXES. The previous version passed a full path as BOTH arguments of MatchFiles.
' MatchFiles takes a DIRECTORY plus a pattern, and the documentation is explicit: "you will get no
' results if the pattern contains a directory separator". So it returned an empty list every time,
' for every file, on every player — and this script reported "no autorun.zip on any volume" while a
' `dir SD:` sat there listing autorun.zip. Reported from a real deployment on an HD1026.
' roReadFile + a type() check — the idiom BrightSign's own boilerplate uses (CheckFile in their
' published autozip.brs). It takes a FULL PATH, which is what every call site naturally has.
'
' Two arguments rather than one path, so there is no string-splitting to get wrong.
Function FileExists(dir As String, name As String) As Boolean
files = MatchFiles(dir, name)
if files = invalid then return false
return files.Count() > 0
' MatchFiles is deliberately not used here. It is for LISTING a directory: it takes a directory plus
' a pattern, returns nothing when the pattern contains a separator, and — as this player
' demonstrated — does not reliably answer for a volume root like "SSD:/". The first version of this
' function passed a path as both arguments and could never return true at all; the second passed a
' directory and a bare name and still answered "no" for a file sitting right there. An existence
' check that is subtly wrong is worse than none, because every guard built on it silently opens.
Function FileExists(path As String) As Boolean
f = CreateObject("roReadFile", path)
return type(f) = "roReadFile"
End Function

View file

@ -198,3 +198,18 @@ test('every string literal on a line is closed', () => {
});
}
});
test('file existence is tested with roReadFile, not MatchFiles', () => {
// MatchFiles is for LISTING a directory. Used as an existence check it has burned us twice: once
// passing a full path as both arguments (never true for anything), and once passing a directory
// plus a bare name, which STILL answered "no" for a file sitting in a volume root on a real
// XT245 — silently skipping a staged update on every boot. BrightSign's own boilerplate uses
// roReadFile + type(); so do we.
for (const f of FILES) {
const src = code(f);
const helper = src.slice(src.indexOf('Function FileExists'));
if (!helper) continue;
assert.match(helper.slice(0, 300), /roReadFile/,
`${f}: FileExists must use roReadFile — MatchFiles does not answer reliably for a volume root`);
}
});