tecs.watch

Watches content for changes and hands settled ones to reload handlers.

tecs.watch.on("shader", function(change: tecs.watch.Change): nil
    print("reload " .. change.path)
end)
tecs.watch.add(tecs.files.assetPath("shaders/lit.wgsl"))
tecs.watch.install({intervalSeconds = 0.25})

A host advances the watcher by calling poll once per turn and dispatch once per logical update. poll looks at the watched paths when the interval has elapsed and queues what has settled; dispatch runs the handlers over that queue. Keeping the two apart is what stops a handler from running inside a half-completed phase: a reload replaces a resource other systems are reading, so it belongs at a point the frame has declared, not wherever a poll happened to land.

Settling#

An editor commonly saves by truncating and rewriting, or by writing a temporary file and renaming it over the target. A watcher that dispatched the first change it saw would hand a reload handler half a file. So a change is dispatched only after the same size and modification time have been observed settle extra times, and a path that is missing, empty, or a directory when a scan lands is treated as a save in progress rather than as a change.

A path that changes and changes back inside the settle window dispatches nothing, because the bytes on disk are the bytes that were already there.

What is watched#

Every watched path is registered explicitly through add. Content reads use nupp.io.files, which does not maintain a process-wide set of paths. The caller that loaded a file therefore decides whether to watch it. No root fence is needed because every watched path is named directly.

The queue#

Settled changes wait in a bounded queue, and repeated changes to one path coalesce while they wait. capacity bounds distinct pending paths; an overflow is logged and left for a later scan rather than growing without limit. A handler that raises reaches the log and does not stop the rest of the batch, because a handler raising has said the file is not usable yet, which is the same answer as waiting for the next save.

Polling rather than platform notification#

This polls nupp.io.files.info and does not use a platform change-notification service. A notification says a write happened, not that the writer finished, so the settle policy would still have to stat the file. Polling also keeps platform notification threads outside the Lua virtual machine and fits the bounded set of paths a game explicitly registered.

Module contents

Types

TypeKindDescription
ChangerecordOne settled content change.
ChangeHandlertypeReceives one settled change.
ConfigtypeSettings copied by install.
SourcetypeReads one path's size and modification time.

Functions

FunctionKindDescription
addfunctionRegisters a path to watch, and reports whether it was newly registered.
dispatchfunctionRuns the registered handlers over every queued change.
dispatchedfunctionReturns how many changes have reached a handler since install.
forgetfunctionStops watching one path and forgets its state.
installfunctionStarts watching, taking every registered path's current state as its baseline.
isInstalledfunctionReturns whether the watcher is running.
kindsfunctionReturns the content kinds that have a handler, in sorted order.
onfunctionRegisters the handler for a content kind.
pendingfunctionReturns how many settled changes are waiting for dispatch.
pollfunctionScans when the interval has elapsed, and does nothing otherwise.
resetfunctionForgets every path, handler and queued change, and stops watching.
scanfunctionLooks at every watched path once, whatever the interval says.
uninstallfunctionStops watching and drops whatever was queued.
unsettledfunctionReturns the watched paths whose latest change has not settled, sorted.
watchingfunctionReturns the watched paths, in sorted order.

Values

ValueKindDescription
DOCUMENTvariableRead-only.
SHADERvariableRead-only.

Types#

Changerecord#

record Change
    path: string
    kind: string
end

One settled content change.

Fields

path#
path: string

Read-only. Names the changed path, exactly as it was registered.

kind#
kind: string

Read-only. Names the content kind the path was registered under. The built-in loaders use "image", "sound", "font", "shader", "model" and "document", which are the tecs.assets kind names.

ChangeHandlertype#

type ChangeHandler = function(change: Change): nil

Receives one settled change.

Configtype#

type Config = {
    intervalSeconds: number?,
    settle: integer?,
    capacity: integer?,
    clock: (function(): number)?,
    source: Source?
}

Settings copied by install.

intervalSeconds sets the delay between polls and defaults to 0.5. settle requires that many extra matching scans after a change is first seen and defaults to 1, so one scan observes and the next confirms; zero queues immediately. capacity bounds distinct pending paths and defaults to 1024. clock reads seconds from a monotonic origin and defaults to nupp.time, and source reads one path's state and defaults to nupp.io.files.info. The last two exist for a test or a headless tool that drives its own time and its own filesystem; a game supplies neither.

Sourcetype#

type Source = function(path: string): (integer, number)

Reads one path's size and modification time.

A path that is missing, or that has become a directory, answers -1, -1 rather than raising: some editors save by renaming a temporary over the target, so a scan landing in the gap has to read that as not settled.

Functions#

addfunction#

function add(path: string, kind: string?): boolean

Registers a path to watch, and reports whether it was newly registered.

The path's current state becomes the baseline, so a file registered on frame ten does not dispatch on frame eleven. Registering a path already watched moves its kind and leaves its baseline alone, which is what a second loader naming the same file should do.

Registration does not require an installed watcher. A loader registers what it loaded whenever it loads it, and install takes the whole set up.

Arguments

NameTypeDescription
pathstring

the file to watch

kindstring?

the content kind a change dispatches under, defaulting to "document" and to "shader" for a .glsl or .wgsl suffix

Returns

TypeDescription
boolean

whether this call added the path rather than updating it

Raises

  • when path is empty

dispatchfunction#

function dispatch(): integer

Runs the registered handlers over every queued change.

Call this once per logical update, at a point the frame has declared. A handler that raises is logged and the batch continues, because one file that is not usable yet says nothing about the next.

Returns

TypeDescription
integer

how many handlers ran and returned

dispatchedfunction#

function dispatched(): integer

Returns how many changes have reached a handler since install.

Returns

TypeDescription
integer

the cumulative count of handler calls that returned

forgetfunction#

function forget(path: string): boolean

Stops watching one path and forgets its state.

A change already queued for the path still dispatches, because it settled while the path was watched and a handler that has been promised a reload gets one.

Arguments

NameTypeDescription
pathstring

the file to stop watching

Returns

TypeDescription
boolean

whether the path was being watched

installfunction#

function install(config: Config?): nil

Starts watching, taking every registered path's current state as its baseline.

Changes made before this call do not dispatch, which is what lets a build register paths as it loads them and install once everything is loaded.

Arguments

NameTypeDescription
configConfig?

the polling options, or nil for the documented defaults

Returns

TypeDescription
nil

Raises

  • when settle is negative or capacity is not a positive integer

isInstalledfunction#

function isInstalled(): boolean

Returns whether the watcher is running.

Returns

TypeDescription
boolean

true after install and before uninstall

kindsfunction#

function kinds(): {string}

Returns the content kinds that have a handler, in sorted order.

Returns

TypeDescription
{string}

a fresh caller-owned list

onfunction#

function on(kind: string, handler: ChangeHandler?): nil

Registers the handler for a content kind.

Re-registering a kind replaces its handler, and nil removes it. Handlers outlive install and uninstall, so a build that stops and starts watching registers them once.

Arguments

NameTypeDescription
kindstring

the content kind to register under

handlerChangeHandler?

the handler to run, or nil to remove the current one

Returns

TypeDescription
nil

Raises

  • when kind is empty

pendingfunction#

function pending(): integer

Returns how many settled changes are waiting for dispatch.

Returns

TypeDescription
integer

the number of distinct queued paths

pollfunction#

function poll(): integer

Scans when the interval has elapsed, and does nothing otherwise.

This is what a host calls once per turn. It never dispatches, so it is safe anywhere in a turn.

Returns

TypeDescription
integer

how many changes the scan queued, and zero when it was too early

resetfunction#

function reset(): nil

Forgets every path, handler and queued change, and stops watching.

A test that installs the watcher needs this between cases. A game does not: uninstall already stops the polling and keeps what was registered.

Returns

TypeDescription
nil

scanfunction#

function scan(): integer

Looks at every watched path once, whatever the interval says.

Call this directly when a headless tool or a test controls the timing. A change that has now settled is queued rather than dispatched, because the handler runs where the frame says it runs.

Returns

TypeDescription
integer

how many changes this scan queued, and zero when nothing is running

uninstallfunction#

function uninstall(): nil

Stops watching and drops whatever was queued.

Registered paths and handlers survive, so a later install resumes with the same set and takes a fresh baseline.

Returns

TypeDescription
nil

unsettledfunction#

function unsettled(): {string}

Returns the watched paths whose latest change has not settled, sorted.

Returns

TypeDescription
{string}

a fresh caller-owned list

watchingfunction#

function watching(): {string}

Returns the watched paths, in sorted order.

Returns

TypeDescription
{string}

a fresh caller-owned list

Values#

DOCUMENTvariable#

const DOCUMENT

Read-only. Names the kind a file registered without one is watched as.

SHADERvariable#

const SHADER

Read-only. Names the kind a shader source is watched as.