diff --git a/Documentation/ArchitectureDecisions/0015-adascript-async-tasks-and-coroutines.md b/Documentation/ArchitectureDecisions/0015-adascript-async-tasks-and-coroutines.md new file mode 100644 index 000000000..e57a107eb --- /dev/null +++ b/Documentation/ArchitectureDecisions/0015-adascript-async-tasks-and-coroutines.md @@ -0,0 +1,340 @@ +# ADR-0015: Add structured asynchronous tasks to AdaScript + +- Status: Accepted +- Date: 2026-09-24 +- Implementation: Partial (worktree foundation; not released) + +## Implementation status + +Last verified: 2026-09-24 in the `codex/adascript-async` worktree. This +implementation has not been released. + +Implemented and covered by focused tests in this worktree: + +- [x] Explicit `async func`, `await`, `Tasks.start`, one-shot promises, and + nested coroutine results. +- [x] Serialized per-world task pumping, fair bounded dispatch, task/parent/ + owner/trace identities, and view/world/module cancellation. +- [x] Game-clock and monotonic real-time timers. +- [x] Async asset load/save results and a real slow-load test while ECS frames + continue. +- [x] Atomic background text save and a 256 KiB-per-chunk streamed save with + an atomic commit and cancellation cleanup. +- [x] Runtime leases for borrowed query rows and reflected resource views; + `@nonsendable` type metadata, typed-parameter checks, and runtime capture + validation across aliases, lists, returns, and one-shot results. +- [x] AdaUI action continuation, view disposal, and view-generation retirement; + Editor keyword, declaration, and completion support. +- [x] Native `async`/`await` tokens, AST lowering, and direct-call effect checks + in `gravity-lang` revision `24695757a0ba5638b3633004a2166b7878c116de`. + The AdaEngine runtime still owns task scheduling and suspension policy. + +Remaining before this ADR is fully implemented: + +- [ ] Isolate a single coroutine's VM trap without aborting the shared Gravity + VM. The current dependency has no public recovery API. The runtime instead + quarantines that module and requires reload, with one task/owner/trace + diagnostic. +- [ ] Full static effect and borrowed-value analysis through aliases, map + entries, and imported method declarations; typed result descriptors and + source maps for generated async continuations. +- [ ] Engine-owned, incremental snapshots of arbitrary ECS data, beyond the + available bounded streaming writer. +- [ ] A game time-scale resource, complete Editor diagnostics, and platform + runtime proof on browser/WASI, Linux, Windows, and Apple mobile targets. + +The synchronous `AdaScriptAssetsBridge` operations remain available for +compatibility. New awaitables use the native asynchronous asset operations. + +## Context + +AdaScript authors need to load shop content while gameplay continues, save +large data without holding a frame, wait for timers, and write sequential +event-driven flows such as waiting for a user's confirmation. Per-frame state +machines can express some of these flows, but do not provide a general answer +for asynchronous I/O or nested waits. + +[ADR-0007](0007-ada-script-runtime-and-hot-reload.md) serializes VM entry and +keeps engine-to-script callbacks synchronous. Its borrowed query rows, +component and resource views, world context, and command capabilities expire +when a callback returns. This remains true when a script starts an asynchronous +task. A Swift `Task` may change OS threads; a VM fiber can suspend a stack but +cannot perform I/O, schedule itself, or make borrowed ECS values safe to retain. + +## Decision + +### Source language and task entry points + +Declare a suspending function explicitly with `async func`. It may call a +normal function directly and another async function with `await`. The compiler +rejects `await` in a normal function, an async call used without `await` or an +explicit start, and `async` on an engine lifecycle callback whose current +contract is synchronous. The declaration's async effect is part of its +signature for calls, imports, overrides, editor diagnostics, and generated +interfaces. Async methods use the same modifier before `func`. + +The following is proposed AdaScript, not currently executable: + +```ada +async func request_confirmation() { + System.print("Will ask the user"); + var confirmed = await wait_confirmation(); + + if (confirmed) { + System.print("User confirmed"); + } else { + System.print("User cancelled"); + } +} +``` + +From an async function, `await request_confirmation()` waits for that child. +From a synchronous UI action or system callback, +`Tasks.start(request_confirmation())` explicitly starts it and returns a task +handle. `Tasks.start(asyncCall)` is a compiler-recognized spawn expression: it +does not evaluate `asyncCall` as an ordinary synchronous call. It schedules the +new function for the next safe script dispatch point, avoiding reentrant VM +entry during the originating callback. A bare async call in synchronous code +is an error; tasks are never silently discarded. The handle supports status +inspection and cancellation. An unawaited task started with `Tasks.start` is +still owned by the current lifecycle scope and reports an unhandled failure. +`return` completes an async function with its value; an awaited caller receives +that value. Starting a task never blocks until its first suspension. + +`@system.update(context)`, scriptable-object lifecycle methods, AdaUI body +evaluation, and existing UI event callbacks remain synchronous. They may +start tasks, passing detached input values. They cannot pass a live callback +context or its borrowed capabilities into an async function. The eventual +result is applied through a fresh permitted callback, event, or deferred +command; a resumed coroutine does not acquire implicit world access. Starting +the same task on every frame is an authoring error to avoid, not behavior the +runtime silently deduplicates. + +### Coroutine context and execution + +The unit of identity is a logical task, not an OS thread. Each task records a +stable task ID, parent task ID, owner scope, world and module identity, module +generation, cancellation state, selected clock and deadline when applicable, +and trace ID. The scheduler tracks `created`, `ready`, `running`, `suspended`, +`completed`, `failed`, and `cancelled` states. Completion is accepted at most +once. Parent-child links propagate cancellation and make child failures +observable. A task cannot resume after a terminal state. + +AdaScript runs only while entered through the serialized VM coordinator. The +runtime may implement a suspended continuation with Gravity fibers or a +compiler-generated state machine; neither mechanism is part of the public +AdaScript API. The runtime must retain VM closures and stack values for the +task's generation while it is suspended, and release them on completion or +cancellation. No task is pinned to a particular OS thread. A Swift worker +receives immutable or otherwise safely owned, `Sendable` native request data, +never a VM object, borrowed ECS bridge, or raw pointer into script memory. + +At `await`, the script continuation is suspended and the active invocation +scope ends. The operation completes outside the VM and enqueues a detached +value or error. The script scheduler checks task ownership, cancellation, +generation, and completion state before reentering the VM at a safe dispatch +point. The completion callback itself must never enter the VM. UI work resumes +under its UI actor; ECS changes are published through the appropriate world +scheduler and access declarations. Native work must not hold the VM boundary +while awaiting I/O, `WorldActor`, or main-actor dispatch. This extends, rather +than weakens, the lock ordering in ADR-0007. + +The scheduler processes ready continuations in stable enqueue order within a +world and generation. A completion is never delivered inline from a native +callback. Gameplay need not pause while a task waits, and a task may resume on +a later dispatch cycle rather than the exact frame in which I/O completed. +No cross-world ordering is promised. Per-dispatch resume counts and task +counts are bounded to protect frame time and memory; exceeding a bound yields +a diagnostic or typed resource-limit failure instead of unbounded queue growth. + +Task-local native tracing metadata may be restored at each resume, but task +identity and authorization travel explicitly in the native task record. A +Swift thread-local or `TaskLocal` value alone is not the bridge contract. + +### Ownership, cancellation, and hot reload + +Every root task has a lifecycle owner: a view instance, scriptable-object +instance, system/world, or another explicit runtime scope. `Tasks.start` +inherits that owner unless the API explicitly accepts a longer-lived owner. +No unowned process-global task is created implicitly. Child tasks inherit +their parent's owner and cancellation. Closing a view, detaching an object, +stopping its world, or replacing its module generation cancels its tasks. + +Cancellation prevents any later script continuation or ECS publication. The +runtime requests cancellation of native work when supported, but cancellation +is not a rollback guarantee: a file write or network request may already have +committed. A late completion from a cancelled task or retired generation is +discarded and recorded at most once. New code never resumes an old generation's +fiber. A source-compatible hot reload may preserve explicit persistent game +state as ADR-0007 allows, but not suspended stacks. A save that must outlive a +view should be owned by a world-level service, with completion exposed to the +new generation as an engine-owned result rather than by reviving an old fiber. + +One-shot waits, including UI confirmation, register the waiter and publish the +prompt atomically so an immediate response cannot be lost. Only the first +response completes the wait. A user choosing Cancel returns `false`; owner +disposal cancels the task and does not pretend that the user chose Cancel. + +### Built-in awaitable capabilities + +The first implementation must exercise the same task mechanism with these +capabilities, rather than providing unrelated callback-only shortcuts: + +1. **Asset loading:** `await Assets.loadAsync(...)` performs eligible disk, + network, and CPU decoding away from the script dispatch. Renderer or UI + finalization still runs on its required actor. Existing asset type, cache, + and virtual-path rules apply. Platforms without an operation return a + typed unsupported-operation failure. +2. **Large saves:** `await Saves.writeAsync(...)` takes an immutable string + before background encoding and I/O. `Saves.begin(path)` with awaited + `appendAsync(chunk)` and `finishAsync()` provides a bounded incremental + path for large data; each chunk is at most 256 KiB. General ECS snapshot + production is still planned. On supported filesystems, commit by atomic + replacement so failure does not expose a partial save. The result reports + whether commit occurred, including when cancellation races with commit. +3. **Timers:** `await Time.sleep(seconds)` uses game time, obeying pause and + time scale. A separately named real-time sleep uses a monotonic clock and + advances while game time is paused. Its continuation still waits for the + next permitted script dispatch; a paused world need not run script code + until resumed. Durations must be finite and nonnegative. Neither timer + blocks a thread or resumes a fiber without a script dispatch point. +4. **Event waits and coroutines:** a one-shot awaitable may be completed by a + UI event or engine event. `wait_confirmation()` returns a Boolean choice; + a coroutine can compose several awaits while preserving local variables. + Event subscriptions are released on completion or cancellation. + +The names above describe the intended public surface. Their signatures and +type descriptors must be generated from or reconciled with the owning native +capabilities; the bridge must not invent separate stringly asset or event +registries. AdaScript callers see detached values or stable engine-owned +handles, not native mutable objects. + +### Suspension policy + +`@nonsendable` is a type-level suspension policy. It marks a script class, +struct, or enum whose instances cannot enter an async frame or cross an +`await`. Native callback-scoped bridge types declare the equivalent policy at +their binding site. This policy describes script lifetime; it is independent +of Swift's `Sendable` conformance used to synchronize the bridge internally. + +The compiler rejects async parameters explicitly typed as `@nonsendable`, +async methods whose receiver has that policy, and async effects on callbacks +registered by `@system`, `@view`, `@scriptable`, or `@rpc` descriptors. It does not infer borrowing +from variable names such as `context` or ban method names in an ordinary +class. The runtime validates actual task arguments, nested lists, returned +values, and promise completions, catching untyped aliases. A borrowed ECS +lease also expires at callback exit as a final runtime guard. Map captures +currently fail closed until entries can be traversed and checked. + +### Error contract and authoring diagnostics + +In the first slice, fallible asynchronous capabilities return a tagged result +with success/value/error cases, described as `Result` in +signatures. Its descriptor carries the concrete value and error types even if +AdaScript's initial source syntax cannot spell generic types. `await` unwraps +scheduling, not the operation's failure. Success and failure must be inspected +explicitly; the error carries a stable code, message, and safe contextual data. +Adding AdaScript `try`/`catch` is a separate language decision and must not be +assumed from Swift syntax or Gravity's `Fiber.try`. A non-fallible wait such as +the user's confirmation can return its ordinary value. Cancellation of the +task is distinct from such a value and normally stops its continuation. + +The compiler and runtime enforce separate categories: + +| Category | Examples | Required behavior | +| --- | --- | --- | +| Static effect error | `await` in `func`, async call without `await` or `Tasks.start`, unsupported async lifecycle signature | Reject with source range and fix guidance. | +| Borrow escape | Borrowed ECS/UI value captured across `await` | Reject statically; invalidate and diagnose erased aliases at runtime. | +| Operation failure | I/O, HTTP, decoding, insufficient space, unsupported platform, invalid path | Deliver typed result; do not crash the VM. | +| Lifetime failure | Parent or owner cancelled, world stopped, stale module generation, expired event source | Cancel or discard; never resume an invalid continuation. | +| Scheduler violation | Double completion, resume after terminal state, reentrant VM entry, quota exceeded | Reject completion and emit a structured runtime diagnostic. | +| Script failure | VM trap, invalid bridge use, execution-budget failure | Fail the task, cancel its children, and isolate the failing invocation under ADR-0007. | + +Async task diagnostics include task and parent IDs, owner, module generation, +trace ID, originating source range, current await site, operation kind, +terminal state, and underlying native error code when safe to expose. They +must not log credentials or private payloads. An unhandled failure from a +root task must be visible in runtime and Editor diagnostics; repeated failures +are rate limited without hiding their count. An awaited child failure is +reported once with its causal chain. Release builds retain safety checks for +generation, cancellation, and borrowed leases. + +### Platform behavior + +The same AdaScript semantics apply on macOS, iOS/iPadOS, Linux, Windows, and +browser/WASI targets. Native operations may have platform-specific backends or +return `unsupportedOperation`; no platform may implement `await` by blocking +its UI, render, or script thread. Browser completion enters through the host +event loop. File save support follows each platform's filesystem capability; +WASM must not silently route to an unavailable synchronous save path. + +## Implementation plan + +1. Add async effects and `await`/`Tasks.start` parsing, lowering, source maps, + and static borrowed-value validation to the AdaScript compiler and Editor + language services. +2. Introduce task records, owner scopes, continuation scheduling, cancellation, + quotas, and structured diagnostics in the AdaScript runtime. Prove the VM + continuation backend can resume safely across dispatches before exposing + the language feature. +3. Add detached native awaitable bridges for timers and one-shot events, then + asset loading and large saves. Reuse native AdaAssets operations where + applicable; keep the existing synchronous APIs source-compatible until a + deliberate migration. +4. Integrate task cancellation with view/object/world teardown and module hot + reload. Expose task state and failures in Editor diagnostics. +5. Validate native and browser paths, including real frame progress during + I/O, bounded snapshot work, pause/time-scale timers, cancellation races, + and late completions after reload. + +## Validation requirements + +- Compiler tests reject every illegal async effect and borrowed-value escape, + including values passed through helpers and containers where analyzable. +- Runtime tests prove nested await, local-variable preservation, parent-child + cancellation, exactly-once completion, stable per-world resume order, and + no VM entry from a native completion callback. +- An integration test loads shop data while gameplay frames continue and + publishes the result through a fresh script callback. +- A large-save test proves snapshot consistency, nonblocking frame progress, + atomic committed output, and truthful cancellation/commit reporting. +- Timer tests cover game pause, time scale, monotonic real time, invalid + durations, and cancellation. +- UI tests cover confirmation versus owner cancellation, immediate response, + duplicate response, and a closed view. +- Hot-reload and teardown tests prove retired generations never resume and + release fibers, subscriptions, and native tasks. +- macOS plus browser runtime smoke tests exercise actual scheduling; a Swift + build or source parse alone does not establish the async behavior. + +## Consequences + +AdaScript gains sequential asynchronous authoring without making ECS callback +contexts persistent or letting background workers enter the VM. The engine +owns task lifetime and diagnostic identity. Implementing this requires new +compiler semantics, a runtime scheduler, native async bridges, and Editor +support; the presence of Gravity fibers alone is insufficient. + +## Rejected alternatives + +### Expose `Fiber` directly as the async API + +Manual `Fiber.call()` requires authors to schedule every continuation and +does not supply I/O, ownership, cancellation, or ECS lifetime checks. + +### Infer `async` from an `await` expression + +Implicit effect propagation obscures whether a caller can suspend and makes +imports, overrides, and diagnostics harder to reason about. `async func` is +required. + +### Resume AdaScript directly from a worker completion + +That can reenter the globally serialized VM, bypass the appropriate UI/world +scheduler, and deliver stale results after owner teardown or hot reload. + +### Retain a callback context across suspension + +Borrowed ECS and UI capabilities expire at callback exit. Extending their +lifetime would bypass scheduler access declarations and allow stale native +references to survive world mutation. diff --git a/Documentation/ArchitectureDecisions/README.md b/Documentation/ArchitectureDecisions/README.md index c55838876..963666d4b 100644 --- a/Documentation/ArchitectureDecisions/README.md +++ b/Documentation/ArchitectureDecisions/README.md @@ -15,6 +15,8 @@ describes the intended design even when its implementation is still planned. - **Planned**: no production slice of the decision has shipped. - **Partial (foundation shipped)**: a tested production slice has shipped, but the ADR's own implementation checklist still has open requirements. +- **Partial (worktree foundation; not released)**: a tested implementation is + available in an isolated worktree, with remaining ADR requirements open. - **Implemented**: every normative requirement in the ADR is shipped and its validation is recorded. @@ -32,6 +34,7 @@ describes the intended design even when its implementation is still planned. | [ADR-0008](0008-adascript-projects-on-ipados.md) | Accepted | Partial (project foundation shipped) | Portable AdaScript projects, iPadOS runtime sessions, Files/iCloud, and Git ownership | | [ADR-0009](0009-adascript-runtime-configuration.md) | Accepted | Partial (foundation shipped) | Declarative entry plans, plugin presets, typed settings, and runtime-window configuration | | [ADR-0010](0010-adascript-native-adaui-extension-registry.md) | Accepted | Planned | Versioned descriptors and host factories for native AdaUI views and modifiers used by AdaScript | +| [ADR-0015](0015-adascript-async-tasks-and-coroutines.md) | Accepted | Partial (worktree foundation; not released) | Structured AdaScript async functions, awaitables, task ownership, timers, background I/O, and safe coroutine resumption | ## Multiplayer decisions diff --git a/Documentation/DocCTheme/theme.js b/Documentation/DocCTheme/theme.js index c07defe52..caa5314a0 100644 --- a/Documentation/DocCTheme/theme.js +++ b/Documentation/DocCTheme/theme.js @@ -13,7 +13,7 @@ "use strict"; const keywords = new Set([ - "_args", "_func", "and", "break", "case", "class", "const", "continue", "default", "else", "enum", "event", "extern", "false", + "_args", "_func", "and", "async", "await", "break", "case", "class", "const", "continue", "default", "else", "enum", "event", "extern", "false", "file", "for", "func", "if", "import", "in", "internal", "is", "lazy", "module", "not", "null", "or", "private", "public", "repeat", "return", "static", "struct", "super", "switch", "true", "undefined", "var", "while" ]); diff --git a/Editor/Sources/GravityLanguageCore/GravityBuiltins.swift b/Editor/Sources/GravityLanguageCore/GravityBuiltins.swift index 344916c4d..9d72718bb 100644 --- a/Editor/Sources/GravityLanguageCore/GravityBuiltins.swift +++ b/Editor/Sources/GravityLanguageCore/GravityBuiltins.swift @@ -134,6 +134,8 @@ enum GravityBuiltins { static let globalCandidates: [GravityCompletionCandidate] = [ GravityCompletionCandidate(detail: "Function declaration", insertText: "func name() {\n \n}", kind: .snippet, label: "func", sortText: "10"), + GravityCompletionCandidate(detail: "Suspending function declaration", insertText: "async func name() {\n \n}", kind: .snippet, label: "async func", sortText: "10"), + GravityCompletionCandidate(detail: "Wait for an async task", insertText: "await ", kind: .keyword, label: "await", sortText: "10"), GravityCompletionCandidate(detail: "Class declaration", insertText: "class Name {\n \n}", kind: .snippet, label: "class", sortText: "11"), GravityCompletionCandidate(detail: "Variable declaration", insertText: "var ", kind: .keyword, label: "var", sortText: "12"), GravityCompletionCandidate(detail: "Return statement", insertText: "return ", kind: .keyword, label: "return", sortText: "13"), @@ -148,6 +150,9 @@ enum GravityBuiltins { GravityCompletionCandidate(detail: "Flexible AdaUI space", insertText: "Spacer()", kind: .class, label: "Spacer", sortText: "18"), GravityCompletionCandidate(detail: "AdaUI divider", insertText: "Divider()", kind: .class, label: "Divider", sortText: "18"), GravityCompletionCandidate(detail: "AdaEngine asset manager", insertText: "Assets", kind: .variable, label: "Assets", sortText: "18"), + GravityCompletionCandidate(detail: "AdaScript task scheduler", insertText: "Tasks", kind: .class, label: "Tasks", sortText: "18"), + GravityCompletionCandidate(detail: "AdaScript timers", insertText: "Time", kind: .class, label: "Time", sortText: "18"), + GravityCompletionCandidate(detail: "Background save operations", insertText: "Saves", kind: .class, label: "Saves", sortText: "18"), GravityCompletionCandidate( detail: "Three-dimensional vector", insertText: "Vector3(0, 0, 0)", diff --git a/Editor/Sources/GravityLanguageCore/GravityDocumentAnalyzer.swift b/Editor/Sources/GravityLanguageCore/GravityDocumentAnalyzer.swift index dd413b860..03e2304d6 100644 --- a/Editor/Sources/GravityLanguageCore/GravityDocumentAnalyzer.swift +++ b/Editor/Sources/GravityLanguageCore/GravityDocumentAnalyzer.swift @@ -177,7 +177,8 @@ struct GravityDocumentAnalyzer { if let symbol = declarationSymbol( keyword: token.text, nameToken: tokens[nameIndex], - memberContext: memberContext + memberContext: memberContext, + isAsync: token.text == "func" && index > 0 && tokens[index - 1].text == "async" ) { symbols.append(symbol) } @@ -186,13 +187,20 @@ struct GravityDocumentAnalyzer { return symbols } - private static func declarationSymbol(keyword: String, nameToken: GravityToken, memberContext: Bool) -> GravitySymbol? { + private static func declarationSymbol( + keyword: String, + nameToken: GravityToken, + memberContext: Bool, + isAsync: Bool + ) -> GravitySymbol? { let kind: GravitySymbolKind let detail: String switch keyword { case "func": kind = memberContext ? .method : .function - detail = memberContext ? "AdaScript method" : "AdaScript function" + detail = isAsync + ? (memberContext ? "AdaScript async method" : "AdaScript async function") + : (memberContext ? "AdaScript method" : "AdaScript function") case "var": kind = memberContext ? .property : .variable detail = memberContext ? "AdaScript property" : "AdaScript variable" diff --git a/Editor/Sources/GravityLanguageCore/GravitySemanticAnalyzer.swift b/Editor/Sources/GravityLanguageCore/GravitySemanticAnalyzer.swift index 602145e0f..5434dc002 100644 --- a/Editor/Sources/GravityLanguageCore/GravitySemanticAnalyzer.swift +++ b/Editor/Sources/GravityLanguageCore/GravitySemanticAnalyzer.swift @@ -127,7 +127,7 @@ enum GravitySemanticAnalyzer { } private static let keywords: Set = [ - "break", "case", "class", "const", "continue", "else", "enum", "event", "extern", "false", "for", "func", "if", "import", "in", "null", + "async", "await", "break", "case", "class", "const", "continue", "else", "enum", "event", "extern", "false", "for", "func", "if", "import", "in", "null", "private", "public", "repeat", "return", "static", "struct", "switch", "true", "var", "while", ] } diff --git a/Editor/Tests/AdaEditorTests/GravityLanguageSemanticTests.swift b/Editor/Tests/AdaEditorTests/GravityLanguageSemanticTests.swift index 3dcfbd770..3ebc66a68 100644 --- a/Editor/Tests/AdaEditorTests/GravityLanguageSemanticTests.swift +++ b/Editor/Tests/AdaEditorTests/GravityLanguageSemanticTests.swift @@ -5,6 +5,16 @@ import Testing @Suite("AdaScript semantic language features") struct GravityLanguageSemanticTests { + @Test("Async declarations remain navigable in AdaScript") + func asyncFunctionsAreRecognized() { + let service = GravityLanguageService() + let source = "async func requestConfirmation() { var answer = await wait_confirmation(); }" + let analysis = service.analyze(text: source) + #expect(analysis.symbols.contains { $0.name == "requestConfirmation" && $0.detail == "AdaScript async function" }) + let completions = service.completions(text: "as", position: GravitySourcePosition(line: 0, utf16Column: 2)) + #expect(completions.contains { $0.label == "async func" }) + } + @Test("Annotated lifecycle parameters expose typed host APIs") func annotatedLifecycleCompletion() { let service = GravityLanguageService(hostConstructors: [ diff --git a/Package.resolved b/Package.resolved index 85738cbfd..ca3e181b3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,12 @@ { - "originHash" : "b83b34920567534236c7c70e03b228cee5eded94eae609ac62d22062d8406414", + "originHash" : "f13fa586907165b53a90c3c4eefba5286a68b5f6eab2ae5cac888dd55944fe42", "pins" : [ { "identity" : "gravity-lang", "kind" : "remoteSourceControl", "location" : "https://github.com/AdaEngine/gravity-lang.git", "state" : { - "revision" : "ecfb4a53d719163ff84f84bc245315140c075229", - "version" : "0.9.9" + "revision" : "24695757a0ba5638b3633004a2166b7878c116de" } }, { diff --git a/Package.swift b/Package.swift index fcfc21685..5e0142549 100644 --- a/Package.swift +++ b/Package.swift @@ -1300,7 +1300,7 @@ let package = Package( package.dependencies += [ .package( url: "https://github.com/AdaEngine/gravity-lang.git", - exact: "0.9.9" + revision: "24695757a0ba5638b3633004a2166b7878c116de" ), .package(url: "https://github.com/apple/swift-collections", from: "1.3.0"), .package(url: "https://github.com/apple/swift-log", from: "1.8.0"), diff --git a/Sources/AdaScriptCompilerCore/AdaScriptAssetsLowerer.swift b/Sources/AdaScriptCompilerCore/AdaScriptAssetsLowerer.swift index 9b3fd4d13..7154219d3 100644 --- a/Sources/AdaScriptCompilerCore/AdaScriptAssetsLowerer.swift +++ b/Sources/AdaScriptCompilerCore/AdaScriptAssetsLowerer.swift @@ -15,19 +15,20 @@ public enum AdaScriptAssetsLowerer { var typedCalls: [Int: (typeName: String, annotationRange: Range)] = [:] for index in tokens.indices where tokens[index].text == "var" { + let assetStart = tokens.indices.contains(index + 5) && tokens[index + 5].text == "await" ? index + 6 : index + 5 guard - tokens.indices.contains(index + 8), + tokens.indices.contains(assetStart + 3), tokens[index + 2].text == ":", tokens[index + 3].kind == .identifier, tokens[index + 4].text == "=", - tokens[index + 5].text == "Assets", - tokens[index + 6].text == ".", - ["load", "preload"].contains(tokens[index + 7].text), - tokens[index + 8].text == "(" + tokens[assetStart].text == "Assets", + tokens[assetStart + 1].text == ".", + ["load", "preload", "loadAsync"].contains(tokens[assetStart + 2].text), + tokens[assetStart + 3].text == "(" else { continue } - typedCalls[index + 5] = ( + typedCalls[assetStart] = ( typeName: tokens[index + 3].text, annotationRange: tokens[index + 2].startOffset.. = [] + ) throws -> [AdaScriptAsyncDeclaration] { + var lexer = Lexer(source: source) + let tokens = lexer.lex() + let markedTypes = try nonSendableTypes.union(AdaScriptNonSendableTypes.declared(in: source, path: path)) + var result: [AdaScriptAsyncDeclaration] = [] + + for index in tokens.indices where tokens[index].text == "async" { + let token = tokens[index] + guard tokens.indices.contains(index + 3), tokens[index + 1].text == "func", + tokens[index + 2].kind == .identifier, tokens[index + 3].text == "(", + let close = closing(index + 3, tokens: tokens, open: "(", close: ")") else { + throw error(path, token.line, "expected 'async func name(...)'") + } + let ownerType = try enclosingType(at: index, tokens: tokens, path: path) + if let ownerType, markedTypes.contains(ownerType) { + throw error(path, token.line, "async method captures @nonsendable type '\(ownerType)'") + } + for parameter in parameters(in: tokens[(index + 4).. String? { + var scopes: [Scope] = [] + var segment = 0 + for cursor in 0..) -> [(name: String, typeName: String?)] { + guard !tokens.isEmpty else { + return [] + } + let items = Array(tokens) + var result: [(name: String, typeName: String?)] = [] + var start = 0 + var depth = 0 + for cursor in 0...items.count { + if cursor < items.count { + if ["(", "[", "{"].contains(items[cursor].text) { depth += 1 } + if [")", "]", "}"].contains(items[cursor].text) { depth -= 1 } + } + guard cursor == items.count || (items[cursor].text == "," && depth == 0) else { continue } + if start < cursor, items[start].kind == .identifier { + let typeIndex = items[start.. Int? { + var depth = 0 + for index in opening.. AdaScriptAsyncSyntaxError { + .init(path: path, line: line, message: message) + } +} diff --git a/Sources/AdaScriptCompilerCore/AdaScriptNonSendableTypes.swift b/Sources/AdaScriptCompilerCore/AdaScriptNonSendableTypes.swift new file mode 100644 index 000000000..85444ede4 --- /dev/null +++ b/Sources/AdaScriptCompilerCore/AdaScriptNonSendableTypes.swift @@ -0,0 +1,46 @@ +/// Suspension policy declared at the AdaScript type, independent of parameter names. +public enum AdaScriptNonSendableTypes { + /// Returns classes, structs, and enums annotated with `@nonsendable`. + public static func declared(in source: String, path: String) throws -> Set { + var lexer = Lexer(source: source) + let tokens = lexer.lex() + var names = Set() + + for index in tokens.indices where tokens[index].text == "@" { + guard tokens.indices.contains(index + 1), tokens[index + 1].text == "nonsendable" else { + continue + } + var cursor = index + 2 + while tokens.indices.contains(cursor), tokens[cursor].text == "@" { + cursor += 2 + if tokens.indices.contains(cursor), tokens[cursor].text == "(" { + guard let end = closingParenthesis(at: cursor, tokens: tokens) else { + throw AdaScriptAsyncSyntaxError(path: path, line: tokens[index].line, message: "unterminated annotation after @nonsendable") + } + cursor = end + 1 + } + } + guard tokens.indices.contains(cursor + 1), + ["class", "struct", "enum"].contains(tokens[cursor].text), + tokens[cursor + 1].kind == .identifier else { + throw AdaScriptAsyncSyntaxError(path: path, line: tokens[index].line, message: "@nonsendable must annotate a type declaration") + } + names.insert(tokens[cursor + 1].text) + } + return names + } + + private static func closingParenthesis(at opening: Int, tokens: [Token]) -> Int? { + var depth = 0 + for index in opening.. Void)? + @GSExportableIgnore static func make( reportDiagnostic: @escaping @Sendable (String) -> Void @@ -32,13 +40,19 @@ final class AdaScriptAssetsBridge: @unchecked Sendable { let strings = values.dropFirst().compactMap { $0.isString ? $0.toString : nil } switch operation.toString { case "load": - guard let path = strings.first else { return "" } + guard let path = strings.first else { + return "" + } return resolve(path, handleChanges: true) case "loadTyped": - guard strings.count == 2 else { return "" } + guard strings.count == 2 else { + return "" + } return resolve(typeName: strings[0], path: strings[1], handleChanges: true) case "save": - guard strings.count == 2 else { return "" } + guard strings.count == 2 else { + return "" + } return store(reference: strings[0], path: strings[1]) ? strings[0] : "" default: reportDiagnostic("Unknown AdaScript Assets operation '\(operation.toString)'") @@ -46,9 +60,89 @@ final class AdaScriptAssetsBridge: @unchecked Sendable { } } + func begin(_ arguments: GSValue) -> AdaScriptAsyncOperation { + let operation = AdaScriptAsyncOperation() + operation.onCompletion = onWake + guard arguments.isList else { + operation.complete(.failure("invalidArguments", message: "Async Assets operation requires a list")) + return operation + } + let values = arguments.toList + guard let name = values.first, name.isString else { + operation.complete(.failure("invalidArguments", message: "Async Assets operation is missing its name")) + return operation + } + let strings = values.dropFirst().compactMap { $0.isString ? $0.toString : nil } + switch name.toString { + case "loadAsync": + guard let path = strings.first, let type = AssetsManager.inferAssetType(at: path) else { + operation.complete(.failure("unknownAssetType", message: "Cannot infer an asset type")) + return operation + } + beginLoad(type: type, path: path, operation: operation) + case "loadTypedAsync": + guard strings.count == 2, let type = AssetsManager.getAssetType(named: strings[0]) else { + operation.complete(.failure("unknownAssetType", message: "Unknown asset type")) + return operation + } + beginLoad(type: type, path: strings[1], operation: operation) + case "saveAsync": + guard strings.count == 2, + let asset = handlesLock.withLock({ handles.values.first(where: { $0.assetPath == strings[0] })?.untypedAsset }) else { + operation.complete(.failure("unknownAsset", message: "Cannot save an unloaded asset")) + return operation + } + let path = strings[1] + let scopeID = AppWorldsExecutionContext.currentID + operation.cancellationIsAdvisory = true + let task = Task { + do { + try Task.checkCancellation() + try await AppWorldsExecutionContext.$currentID.withValue(scopeID) { + func saveOpened(_ opened: A) async throws { + try await AssetsManager.save(opened, at: path) + } + try await saveOpened(asset) + } + operation.complete(.success(path, committed: true)) + } catch { + operation.complete(.failure("saveFailed", message: error.localizedDescription)) + } + } + operation.installCancellationAction { task.cancel() } + default: + operation.complete(.failure("unknownOperation", message: "Unknown async Assets operation")) + } + return operation + } + + @GSExportableIgnore + private func beginLoad(type: any Asset.Type, path: String, operation: AdaScriptAsyncOperation) { + let cacheKey = String(reflecting: type) + "\u{0}" + path + if handlesLock.withLock({ handles[cacheKey] != nil }) { + operation.complete(.success(path)) + return + } + let scopeID = AppWorldsExecutionContext.currentID + let task = Task { + do { + try Task.checkCancellation() + let handle = try await AppWorldsExecutionContext.$currentID.withValue(scopeID) { + try await AssetsManager.loadErased(type, at: path, handleChanges: true) + } + try Task.checkCancellation() + handlesLock.withLock { handles[cacheKey] = handle } + operation.complete(.success(path)) + } catch { + operation.complete(.failure("loadFailed", message: error.localizedDescription)) + } + } + operation.installCancellationAction { task.cancel() } + } + @GSExportableIgnore private func store(reference: String, path: String) -> Bool { - guard let handle = handles.values.first(where: { $0.assetPath == reference }), let asset = handle.untypedAsset else { + guard let asset = handlesLock.withLock({ handles.values.first(where: { $0.assetPath == reference })?.untypedAsset }) else { reportDiagnostic("Cannot save unloaded AdaScript asset '\(reference)'") return false } @@ -93,7 +187,7 @@ final class AdaScriptAssetsBridge: @unchecked Sendable { ) -> String { let typeName = String(reflecting: type) let cacheKey = typeName + "\u{0}" + path - if handles[cacheKey] != nil { + if handlesLock.withLock({ handles[cacheKey] != nil }) { return path } #if WASM @@ -102,7 +196,7 @@ final class AdaScriptAssetsBridge: @unchecked Sendable { #else do { let handle = try AssetsManager.loadErasedSync(type, at: path, handleChanges: handleChanges) - handles[cacheKey] = handle + handlesLock.withLock { handles[cacheKey] = handle } return path } catch { reportDiagnostic("Cannot load AdaScript asset '\(path)': \(error)") @@ -110,18 +204,17 @@ final class AdaScriptAssetsBridge: @unchecked Sendable { } #endif } - } enum AdaScriptAssetRuntime { static func bind( to virtualMachine: GravityVirtualMachine, - reportDiagnostic: @escaping @Sendable (String) -> Void + reportDiagnostic: @escaping @Sendable (String) -> Void, + wake: (@Sendable () -> Void)? = nil ) throws { try virtualMachine.bindClass(with: AdaScriptAssetsBridge.self) - virtualMachine.setValue( - AdaScriptAssetsBridge.make(reportDiagnostic: reportDiagnostic), - forKey: "__adaAssets" - ) + let bridge = AdaScriptAssetsBridge.make(reportDiagnostic: reportDiagnostic) + bridge.onWake = wake + virtualMachine.setValue(bridge, forKey: "__adaAssets") } } diff --git a/Sources/AdaScripting/AdaScriptAsyncBridge.swift b/Sources/AdaScripting/AdaScriptAsyncBridge.swift new file mode 100644 index 000000000..4dc44a291 --- /dev/null +++ b/Sources/AdaScripting/AdaScriptAsyncBridge.swift @@ -0,0 +1,297 @@ +@_spi(AdaEngine) import AdaAssets +import Foundation +import Gravity + +@GSExportable("AdaAsyncResult") +// Fields are set before publication to a worker or the VM and never mutated afterward. +final class AdaScriptAsyncResult: @unchecked Sendable, AdaScriptSuspensionSafeBridge { + @GSExportableIgnore + private var successful = false + + @GSExportableIgnore + private var output = "" + + @GSExportableIgnore + private var code = "" + + @GSExportableIgnore + private var detail = "" + + @GSExportableIgnore + private var didCommit = false + + func isSuccess() -> Bool { successful } + func value() -> String { output } + func errorCode() -> String { code } + func message() -> String { detail } + func committed() -> Bool { didCommit } + + @GSExportableIgnore + static func success(_ value: String = "", committed: Bool = false) -> AdaScriptAsyncResult { + let result = AdaScriptAsyncResult() + result.successful = true + result.output = value + result.didCommit = committed + return result + } + + @GSExportableIgnore + static func failure(_ code: String, message: String) -> AdaScriptAsyncResult { + let result = AdaScriptAsyncResult() + result.code = code + result.detail = message + return result + } +} + +@GSExportable("AdaAsyncOperation") +// Completion state and the cancellation callback are protected by `lock`. +final class AdaScriptAsyncOperation: @unchecked Sendable, AdaScriptSuspensionSafeBridge { + @GSExportableIgnore + private let lock = NSLock() + + @GSExportableIgnore + private var completedResult: AdaScriptAsyncResult? + + @GSExportableIgnore + var onCompletion: (@Sendable () -> Void)? + + @GSExportableIgnore + private var cancelAction: (@Sendable () -> Void)? + + @GSExportableIgnore + var cancellationIsAdvisory = false + + func isDone() -> Bool { lock.withLock { completedResult != nil } } + + func result() -> AdaScriptAsyncResult { + lock.withLock { completedResult } ?? .failure("notReady", message: "Async operation is still running") + } + + func cancel() -> Bool { + let action = lock.withLock { completedResult == nil ? cancelAction : nil } + guard let action else { + return false + } + action() + if !cancellationIsAdvisory { + complete(.failure("cancelled", message: "Async operation was cancelled")) + } + return true + } + + @GSExportableIgnore + func installCancellationAction(_ action: @escaping @Sendable () -> Void) { + lock.withLock { + if completedResult == nil { cancelAction = action } + } + } + + @GSExportableIgnore + func complete(_ result: AdaScriptAsyncResult) { + let accepted = lock.withLock { () -> Bool in + guard completedResult == nil else { + return false + } + completedResult = result + cancelAction = nil + return true + } + if accepted { onCompletion?() } + } +} + +/// Host operations return detached results; completion never enters the VM. +@GSExportable("AdaAsyncHost") +// Timer and writer registries are entered only under AdaScriptRuntimeCoordinator; +// workers mutate their separate, lock-protected operation objects. +final class AdaScriptAsyncHost: @unchecked Sendable { + @GSExportableIgnore + private final class WeakSaveWriter { + weak var value: AdaScriptSaveWriter? + + init(_ value: AdaScriptSaveWriter) { self.value = value } + } + + @GSExportableIgnore + private struct GameTimer { + let operation: AdaScriptAsyncOperation + let worldID: String + var remaining: Double + } + + @GSExportableIgnore + private var gameTimers: [GameTimer] = [] + + @GSExportableIgnore + private var writersByOwner: [String: [WeakSaveWriter]] = [:] + + @GSExportableIgnore + var onWake: (@Sendable () -> Void)? + + @GSExportableIgnore + var ownerProvider: (@Sendable () -> String?)? + + func sleep(_ seconds: Double) -> AdaScriptAsyncOperation { + let operation = AdaScriptAsyncOperation() + operation.onCompletion = onWake + guard seconds.isFinite, seconds >= 0 else { + operation.complete(.failure("invalidDuration", message: "Timer duration must be finite and nonnegative")) + return operation + } + guard let ownerID = ownerProvider?(), ownerID.hasPrefix("world:"), + let boundary = ownerID.range(of: ":system:") else { + operation.complete(.failure("missingGameClock", message: "Time.sleep requires a world-owned task")) + return operation + } + let worldID = String(ownerID[.. AdaScriptAsyncOperation { + let operation = AdaScriptAsyncOperation() + operation.onCompletion = onWake + guard seconds.isFinite, seconds >= 0 else { + operation.complete(.failure("invalidDuration", message: "Timer duration must be finite and nonnegative")) + return operation + } + let task = Task { + if Task.isCancelled { + operation.complete(.failure("cancelled", message: "Real-time timer was cancelled")) + return + } + do { + try await Task.sleep(for: .seconds(seconds)) + operation.complete(.success()) + } catch { + operation.complete(.failure("cancelled", message: "Real-time timer was cancelled")) + } + } + operation.installCancellationAction { task.cancel() } + return operation + } + + func writeText(_ path: String, _ text: String) -> AdaScriptAsyncOperation { + let operation = AdaScriptAsyncOperation() + operation.onCompletion = onWake + operation.cancellationIsAdvisory = true + #if WASM + operation.complete(.failure("unsupportedOperation", message: "Background file saves are unavailable in the current WebAssembly filesystem")) + return operation + #else + guard let url = writableURL(for: path) else { + operation.complete(.failure("invalidPath", message: "Save path must remain within a writable virtual root")) + return operation + } + let task = Task { + do { + try Task.checkCancellation() + let data = Data(text.utf8) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + operation.complete(.success(path, committed: true)) + } catch { + operation.complete(.failure("writeFailed", message: error.localizedDescription)) + } + } + operation.installCancellationAction { task.cancel() } + return operation + #endif + } + + func beginSave(_ path: String) -> AdaScriptSaveWriter { + let writer: AdaScriptSaveWriter + #if WASM + writer = AdaScriptSaveWriter.make( + path: path, + url: nil, + errorCode: "unsupportedOperation", + onWake: onWake + ) + #else + let url = writableURL(for: path) + writer = AdaScriptSaveWriter.make( + path: path, + url: url, + errorCode: url == nil ? "invalidPath" : nil, + onWake: onWake + ) + #endif + if let ownerID = ownerProvider?() { + writersByOwner[ownerID, default: []].append(WeakSaveWriter(writer)) + } + return writer + } + + @GSExportableIgnore + private func writableURL(for path: String) -> URL? { + guard path.hasPrefix("@user://") || path.hasPrefix("@cache://"), + !path.split(separator: "/").contains("..") else { + return nil + } + let url = AssetsManager.resolveAssetURL(at: path) + let virtualRoot = path.hasPrefix("@user://") ? "@user://" : "@cache://" + let root = AssetsManager.resolveAssetURL(at: virtualRoot).standardizedFileURL.path + guard url.standardizedFileURL.path.hasPrefix(root + "/") else { + return nil + } + let resolvedRoot = URL(fileURLWithPath: root).resolvingSymlinksInPath().standardizedFileURL.path + let resolvedParent = url.deletingLastPathComponent().resolvingSymlinksInPath().standardizedFileURL.path + guard resolvedParent == resolvedRoot || resolvedParent.hasPrefix(resolvedRoot + "/") else { + return nil + } + return url + } + + @GSExportableIgnore + func advanceGameTime(by deltaTime: Double, forWorld worldID: String) { + guard deltaTime.isFinite, deltaTime > 0 else { + return + } + for index in gameTimers.indices where gameTimers[index].worldID == worldID { + gameTimers[index].remaining -= deltaTime + } + for timer in gameTimers where timer.worldID == worldID && timer.remaining <= 0 { + timer.operation.complete(.success()) + } + gameTimers.removeAll(where: { $0.worldID == worldID && $0.remaining <= 0 }) + } + + @GSExportableIgnore + func cancelAll() { + for timer in gameTimers { + timer.operation.complete(.failure("cancelled", message: "Timer owner was destroyed")) + } + gameTimers.removeAll() + for writers in writersByOwner.values { + for writer in writers { writer.value?.cancel() } + } + writersByOwner.removeAll() + } + + @GSExportableIgnore + func cancelGameTimers(forWorld worldID: String) { + for timer in gameTimers where timer.worldID == worldID { + timer.operation.complete(.failure("cancelled", message: "World was destroyed")) + } + gameTimers.removeAll(where: { $0.worldID == worldID }) + let owners = writersByOwner.keys.filter { $0.hasPrefix(worldID + ":system:") } + for ownerID in owners { + cancelSaveWriters(forOwner: ownerID) + } + } + + @GSExportableIgnore + func cancelSaveWriters(forOwner ownerID: String) { + for writer in writersByOwner.removeValue(forKey: ownerID) ?? [] { + writer.value?.cancel() + } + } +} diff --git a/Sources/AdaScripting/AdaScriptInputBridge.swift b/Sources/AdaScripting/AdaScriptInputBridge.swift index 7221244fd..1e64118cd 100644 --- a/Sources/AdaScripting/AdaScriptInputBridge.swift +++ b/Sources/AdaScripting/AdaScriptInputBridge.swift @@ -3,7 +3,7 @@ import Gravity /// A callback-scoped input snapshot. Access and invalidation are serialized by AdaScriptRuntimeCoordinator. @GSExportable("AdaInputActions") -final class AdaScriptInputBridge: @unchecked Sendable { +final class AdaScriptInputBridge: @unchecked Sendable, AdaScriptNonSendableBridge { private var snapshot: Input? @GSExportableIgnore diff --git a/Sources/AdaScripting/AdaScriptNetworkBridge.swift b/Sources/AdaScripting/AdaScriptNetworkBridge.swift index 863835dcb..dd043e70d 100644 --- a/Sources/AdaScripting/AdaScriptNetworkBridge.swift +++ b/Sources/AdaScripting/AdaScriptNetworkBridge.swift @@ -19,7 +19,9 @@ enum AdaScriptNetworkBridge { authority: authority, visibility: .allPeers, fields: try schema.fields.compactMap { field in - guard let network = field.network else { return nil } + guard let network = field.network else { + return nil + } return NetworkFieldDescriptor( tag: network.tag, wireType: wireType(for: field.defaultValue), @@ -178,7 +180,7 @@ final class AdaScriptNetworkCommandFactory: @unchecked Sendable { } @GSExportable("AdaMultiplayer") -final class AdaScriptMultiplayerAPI: @unchecked Sendable { +final class AdaScriptMultiplayerAPI: @unchecked Sendable, AdaScriptNonSendableBridge { @GSExportableIgnore private var runtime: Ref? diff --git a/Sources/AdaScripting/AdaScriptSaveWriter.swift b/Sources/AdaScripting/AdaScriptSaveWriter.swift new file mode 100644 index 000000000..b665d49a0 --- /dev/null +++ b/Sources/AdaScripting/AdaScriptSaveWriter.swift @@ -0,0 +1,184 @@ +import Foundation +import Gravity + +/// An engine-owned temporary save that accepts bounded chunks off the VM thread. +@GSExportable("AdaSaveWriter") +// VM-owned configuration is published once; cancellation is locked and file +// operations are serialized by AdaScriptSaveStreamState. +final class AdaScriptSaveWriter: @unchecked Sendable, AdaScriptSuspensionSafeBridge { + @GSExportableIgnore + private var state: AdaScriptSaveStreamState? + + @GSExportableIgnore + private var path = "" + + @GSExportableIgnore + private var configurationError: String? + + @GSExportableIgnore + private var onWake: (@Sendable () -> Void)? + + @GSExportableIgnore + private let stateLock = NSLock() + + @GSExportableIgnore + private var didCancel = false + + @GSExportableIgnore + static func make( + path: String, + url: URL?, + errorCode: String?, + onWake: (@Sendable () -> Void)? + ) -> AdaScriptSaveWriter { + let writer = AdaScriptSaveWriter() + writer.path = path + writer.configurationError = errorCode + writer.onWake = onWake + if let url { writer.state = AdaScriptSaveStreamState(destination: url) } + return writer + } + + deinit { + if let state { Task { await state.cancel() } } + } + + func append(_ text: String) -> AdaScriptAsyncOperation { + let operation = makeOperation() + guard !stateLock.withLock({ didCancel }) else { + operation.complete(.failure("cancelled", message: "Save writer was cancelled")) + return operation + } + guard let state else { + operation.complete(.failure(configurationError ?? "invalidWriter", message: "Save writer is unavailable")) + return operation + } + guard text.utf8.count <= 262_144 else { + operation.complete(.failure("chunkTooLarge", message: "Save chunks must be 256 KiB or smaller")) + return operation + } + let task = Task { + do { + try await state.append(text) + operation.complete(.success()) + } catch is CancellationError { + operation.complete(.failure("cancelled", message: "Save chunk was cancelled")) + } catch { + operation.complete(.failure("writeFailed", message: error.localizedDescription)) + } + } + operation.installCancellationAction { + task.cancel() + Task { await state.cancel() } + } + return operation + } + + func finish() -> AdaScriptAsyncOperation { + let operation = makeOperation() + operation.cancellationIsAdvisory = true + guard !stateLock.withLock({ didCancel }) else { + operation.complete(.failure("cancelled", message: "Save writer was cancelled")) + return operation + } + guard let state else { + operation.complete(.failure(configurationError ?? "invalidWriter", message: "Save writer is unavailable")) + return operation + } + let path = path + let task = Task { + do { + try await state.finish() + operation.complete(.success(path, committed: true)) + } catch is CancellationError { + operation.complete(.failure("cancelled", message: "Save commit was cancelled")) + } catch { + operation.complete(.failure("writeFailed", message: error.localizedDescription)) + } + } + operation.installCancellationAction { task.cancel() } + return operation + } + + func cancel() { + stateLock.withLock { didCancel = true } + if let state { Task { await state.cancel() } } + } + + @GSExportableIgnore + private func makeOperation() -> AdaScriptAsyncOperation { + let operation = AdaScriptAsyncOperation() + operation.onCompletion = onWake + return operation + } +} + +private actor AdaScriptSaveStreamState { + private enum Status { + case open + case committed + case cancelled + } + + private let destination: URL + private let temporary: URL + private var handle: FileHandle? + private var status: Status = .open + + init(destination: URL) { + self.destination = destination + temporary = destination.deletingLastPathComponent() + .appendingPathComponent(".ada-save-\(UUID().uuidString).tmp") + } + + func append(_ text: String) throws { + try Task.checkCancellation() + guard status == .open else { throw SaveStreamError.closed } + try openIfNeeded() + try Task.checkCancellation() + try handle?.write(contentsOf: Data(text.utf8)) + } + + func finish() throws { + try Task.checkCancellation() + guard status == .open else { throw SaveStreamError.closed } + try openIfNeeded() + try handle?.close() + handle = nil + try Task.checkCancellation() + let fileManager = FileManager.default + if fileManager.fileExists(atPath: destination.path) { + _ = try fileManager.replaceItemAt(destination, withItemAt: temporary) + } else { + try fileManager.moveItem(at: temporary, to: destination) + } + status = .committed + } + + func cancel() { + guard status == .open else { + return + } + try? handle?.close() + handle = nil + try? FileManager.default.removeItem(at: temporary) + status = .cancelled + } + + private func openIfNeeded() throws { + guard handle == nil else { + return + } + let fileManager = FileManager.default + try fileManager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + guard fileManager.createFile(atPath: temporary.path, contents: nil) else { + throw SaveStreamError.cannotCreateTemporaryFile + } + handle = try FileHandle(forWritingTo: temporary) + } +} + +private enum SaveStreamError: Error { + case cannotCreateTemporaryFile + case closed +} diff --git a/Sources/AdaScripting/AdaScriptSuspensionPolicy.swift b/Sources/AdaScripting/AdaScriptSuspensionPolicy.swift new file mode 100644 index 000000000..74995d101 --- /dev/null +++ b/Sources/AdaScripting/AdaScriptSuspensionPolicy.swift @@ -0,0 +1,84 @@ +import Gravity + +/// Native bridge types with callback-borrowed state opt into this policy. +/// The AdaScript spelling is `@nonsendable` on a script-owned type declaration. +protocol AdaScriptNonSendableBridge {} + +/// Native handles whose own synchronization and lifetime permit suspension. +protocol AdaScriptSuspensionSafeBridge {} + +/// The VM serializes registration and validation through AdaScriptRuntimeCoordinator. +final class AdaScriptSuspensionPolicy: @unchecked Sendable { + private struct Matcher { + let name: String + let matches: (GSValue) -> Bool + } + + private var borrowed: [Matcher] = [] + private var safeHandles: [Matcher] = [] + private let scriptNonSendableTypes: Set + + init(scriptNonSendableTypes: Set) { + self.scriptNonSendableTypes = scriptNonSendableTypes + } + + func bindBorrowed( + _ type: T.Type, + to virtualMachine: GravityVirtualMachine + ) throws { + try virtualMachine.bindClass(with: type) + borrowed.append(Matcher(name: type.runtimeName, matches: { $0.toObjectOf(type) != nil })) + } + + func bindSafe( + _ type: T.Type, + to virtualMachine: GravityVirtualMachine + ) throws { + try virtualMachine.bindClass(with: type) + safeHandles.append(Matcher(name: type.runtimeName, matches: { $0.toObjectOf(type) != nil })) + } + + func validate(_ value: GSValue) -> String? { + violation(in: value, depth: 0) + } + + private func violation(in value: GSValue, depth: Int) -> String? { + guard depth < 16 else { + return "capture nesting exceeds the suspension limit" + } + if value.isNull || value.isUndefined || value.isBool || value.isInteger || value.isDouble || value.isString { + return nil + } + if value.isList { + for element in value.toList { + if let reason = violation(in: element, depth: depth + 1) { + return reason + } + } + return nil + } + if value.isMap { + return "map captures require a detached value" + } + if value.isFiber || value.isClosure { + return "VM closures and fibers cannot cross a suspension boundary" + } + if let type = borrowed.first(where: { $0.matches(value) }) { + return "@nonsendable native type '\(type.name)' cannot cross a suspension boundary" + } + if value.isInstance || value.isStruct { + let className = value.toClass.name + let declaredName = scriptNonSendableTypes.contains(className) ? className : value.name + if scriptNonSendableTypes.contains(declaredName) { + return "@nonsendable type '\(declaredName)' cannot cross a suspension boundary" + } + } + if value.xData != nil { + if safeHandles.contains(where: { $0.matches(value) }) { + return nil + } + return "native value '\(value.name)' has no suspension policy" + } + return nil + } +} diff --git a/Sources/AdaScripting/AdaScriptTaskRuntime.swift b/Sources/AdaScripting/AdaScriptTaskRuntime.swift new file mode 100644 index 000000000..27d93e464 --- /dev/null +++ b/Sources/AdaScripting/AdaScriptTaskRuntime.swift @@ -0,0 +1,379 @@ +import Foundation +import Gravity + +/// Owns VM-rooted AdaScript tasks and resumes them only at script dispatch points. +@GSExportable("AdaTaskRuntime") +// Task records are accessed under AdaScriptRuntimeCoordinator; native workers +// only invoke the immutable wake callback and never enter the VM. +final class AdaScriptTaskRuntime: @unchecked Sendable { + @GSExportableIgnore + private struct TaskEntry { + let ownerID: String + let parentID: Int? + let traceID: String + let value: GSValue + let nativeOperation: AdaScriptAsyncOperation? + } + + @GSExportableIgnore + private weak var virtualMachine: GravityVirtualMachine? + + @GSExportableIgnore + private var tasks: [Int: TaskEntry] = [:] + + @GSExportableIgnore + var currentOwnerID: String? + + @GSExportableIgnore + private var currentTaskID: Int? + + @GSExportableIgnore + private var lastResumedID = 0 + + @GSExportableIgnore + private(set) var isVMAborted = false + + @GSExportableIgnore + private var nextIdentifier = 1 + + @GSExportableIgnore + private var reportDiagnostic: @Sendable (String) -> Void = { _ in } + + @GSExportableIgnore + private var suspensionPolicy: AdaScriptSuspensionPolicy? + + @GSExportableIgnore + private let maximumTasks = 1024 + + @GSExportableIgnore + private let maximumResumesPerDispatch = 256 + + @GSExportableIgnore + var onWake: (@Sendable () -> Void)? + + @GSExportableIgnore + static func make( + virtualMachine: GravityVirtualMachine, + reportDiagnostic: @escaping @Sendable (String) -> Void, + suspensionPolicy: AdaScriptSuspensionPolicy + ) -> AdaScriptTaskRuntime { + let runtime = AdaScriptTaskRuntime() + runtime.virtualMachine = virtualMachine + runtime.reportDiagnostic = reportDiagnostic + runtime.suspensionPolicy = suspensionPolicy + return runtime + } + + func validateCapture(_ values: GSValue) -> Bool { + guard values.isList, let suspensionPolicy else { + reportDiagnostic("ADASCRIPT_NONSENDABLE invalid suspension capture") + return false + } + for value in values.toList { + if let reason = suspensionPolicy.validate(value) { + reportDiagnostic("ADASCRIPT_NONSENDABLE \(reason)") + return false + } + } + return true + } + + /// Registers a task; script execution begins at the next dispatch point. + func start(_ task: GSValue) -> Int { + guard !isVMAborted else { + reportDiagnostic("ADASCRIPT_VM_ABORTED reload the script module before starting another task") + return -1 + } + guard task.hasMethod(named: "resume") else { + reportDiagnostic("Tasks.start requires an async task") + return -1 + } + guard let ownerID = currentOwnerID else { + reportDiagnostic("Tasks.start requires a live lifecycle owner") + return -1 + } + guard tasks.count < maximumTasks, let virtualMachine else { + reportDiagnostic("AdaScript task limit exceeded") + return -1 + } + let identifier = nextIdentifier + nextIdentifier += 1 + let traceID = currentTaskID.flatMap { tasks[$0]?.traceID } ?? UUID().uuidString + let nativeOperation = task.callMethod(named: "nativeOperation", with: [])?.toObjectOf(AdaScriptAsyncOperation.self) + tasks[identifier] = TaskEntry( + ownerID: ownerID, + parentID: currentTaskID, + traceID: traceID, + value: task, + nativeOperation: nativeOperation + ) + virtualMachine.setValue(task, forKey: rootName(identifier)) + onWake?() + return identifier + } + + func wake() { + onWake?() + } + + @GSExportableIgnore + func pump(onlyWorld worldID: String? = nil) { + guard !isVMAborted else { + return + } + guard let virtualMachine else { + return + } + let sorted = tasks.keys.sorted().filter { identifier in + guard let worldID else { + return true + } + return tasks[identifier]?.ownerID.hasPrefix(worldID + ":system:") == true + } + let identifiers = Array((sorted.filter { $0 > lastResumedID } + sorted.filter { $0 <= lastResumedID }).prefix(maximumResumesPerDispatch)) + if let last = identifiers.last { lastResumedID = last } + for identifier in identifiers { + guard let entry = tasks[identifier] else { continue } + let previousOwner = currentOwnerID + let previousTask = currentTaskID + currentOwnerID = entry.ownerID + currentTaskID = identifier + defer { + currentOwnerID = previousOwner + currentTaskID = previousTask + } + let task = entry.value + _ = task.callMethod(named: "resume", with: []) + guard let outcome = task.callMethod(named: "isComplete", with: []), outcome.isBool else { + quarantineVM(after: identifier, entry: entry) + return + } + if outcome.toBoolean { + if task.callMethod(named: "didFail", with: [])?.toBoolean == true { + quarantineVM(after: identifier, entry: entry) + return + } + cancelDescendants(of: identifier, in: virtualMachine) + finish(identifier, in: virtualMachine) + onWake?() + } + } + } + + @GSExportableIgnore + func cancelAll() { + guard let virtualMachine else { + tasks.removeAll() + return + } + for identifier in tasks.keys.sorted() { + _ = tasks[identifier]?.value.callMethod(named: "cancel", with: []) + finish(identifier, in: virtualMachine) + } + } + + @GSExportableIgnore + var activeTaskCount: Int { tasks.count } + + @GSExportableIgnore + func cancel(ownerID: String) { + guard let virtualMachine else { + return + } + for identifier in tasks.keys.sorted() where tasks[identifier]?.ownerID == ownerID { + _ = tasks[identifier]?.value.callMethod(named: "cancel", with: []) + finish(identifier, in: virtualMachine) + } + } + + @GSExportableIgnore + func cancel(worldID: String) { + guard let virtualMachine else { + return + } + for identifier in tasks.keys.sorted() where tasks[identifier]?.ownerID.hasPrefix(worldID + ":system:") == true { + _ = tasks[identifier]?.value.callMethod(named: "cancel", with: []) + finish(identifier, in: virtualMachine) + } + } + + @GSExportableIgnore + private func finish(_ identifier: Int, in virtualMachine: GravityVirtualMachine) { + tasks.removeValue(forKey: identifier) + virtualMachine.setValue(GSValue(nullIn: virtualMachine), forKey: rootName(identifier)) + } + + @GSExportableIgnore + private func cancelDescendants(of parentID: Int, in virtualMachine: GravityVirtualMachine) { + let children = tasks.keys.sorted().filter { tasks[$0]?.parentID == parentID } + for identifier in children { + cancelDescendants(of: identifier, in: virtualMachine) + _ = tasks[identifier]?.value.callMethod(named: "cancel", with: []) + finish(identifier, in: virtualMachine) + } + } + + @GSExportableIgnore + private func rootName(_ identifier: Int) -> String { + "__ada_live_task_\(identifier)" + } + + @GSExportableIgnore + private func taskDescription(_ identifier: Int, entry: TaskEntry) -> String { + "id=\(identifier) parent=\(entry.parentID.map { String($0) } ?? "none") owner=\(entry.ownerID) trace=\(entry.traceID)" + } + + @GSExportableIgnore + private func quarantineVM(after identifier: Int, entry: TaskEntry) { + isVMAborted = true + for task in tasks.values { + _ = task.nativeOperation?.cancel() + } + tasks.removeAll() + reportDiagnostic("ADASCRIPT_VM_ABORTED \(taskDescription(identifier, entry: entry)); reload the script module") + onWake?() + } +} + +enum AdaScriptTaskPrelude { + static let source = """ + extern var __adaTasks; + extern var __adaAsync; + + class __AdaTask { + var fiber = null; + var value = null; + var done = false; + var cancelled = false; + var started = false; + var operation = null; + + func resume() { + if (done || cancelled) { return true; } + if (fiber == null) { return false; } + fiber.try(); + if (fiber.isDone()) { done = true; } + return done; + } + + func complete(value) { + if (done || cancelled) { return false; } + if (!__adaTasks.validateCapture([value])) { cancel(); return false; } + self.value = value; + done = true; + __adaTasks.wake(); + return true; + } + + func capture(values) { + if (!__adaTasks.validateCapture(values)) { cancel(); } + } + + func cancel() { + if (done || cancelled) { return false; } + cancelled = true; + if (operation != null) { operation.cancel(); } + __adaTasks.wake(); + return true; + } + + func status() { + if (cancelled) { return "cancelled"; } + if (didFail()) { return "failed"; } + if (done) { return "completed"; } + if (started) { return "running"; } + return "created"; + } + + func isComplete() { + return done || cancelled; + } + + func didFail() { + return fiber != null && fiber.status() == 1; + } + + func nativeOperation() { + return operation; + } + } + + class Tasks { + static func start(task) { + if (task.cancelled) { return task; } + if (!task.started) { + var identifier = __adaTasks.start(task); + if (identifier < 0) { task.cancel(); return task; } + task.started = true; + } + return task; + } + + static func promise() { + return __AdaTask(); + } + + static func nextFrame() { + var task = __AdaTask(); + task.fiber = Fiber.create({ task.done = true; }); + return task; + } + } + + func __adaTaskFromOperation(operation) { + var task = __AdaTask(); + task.operation = operation; + task.fiber = Fiber.create({ + while (!operation.isDone()) { Fiber.yield(); } + task.value = operation.result(); + task.done = true; + }); + return task; + } + + class Time { + static func sleep(seconds) { + return __adaTaskFromOperation(__adaAsync.sleep(seconds)); + } + + static func sleepRealTime(seconds) { + return __adaTaskFromOperation(__adaAsync.sleepRealTime(seconds)); + } + } + + class Saves { + static func writeAsync(path, text) { + return __adaTaskFromOperation(__adaAsync.writeText(path, text)); + } + + static func begin(path) { + var writer = __AdaSaveWriter(); + writer.native = __adaAsync.beginSave(path); + return writer; + } + } + + class __AdaSaveWriter { + var native = null; + + func appendAsync(chunk) { + return __adaTaskFromOperation(native.append(chunk)); + } + + func finishAsync() { + return __adaTaskFromOperation(native.finish()); + } + + func cancel() { native.cancel(); } + } + + func __adaAwait(task) { + Tasks.start(task); + while (!task.done && !task.cancelled) { + Fiber.yield(); + } + if (task.cancelled) { return null; } + return task.value; + } + """ +} diff --git a/Sources/AdaScripting/AdaScriptView.swift b/Sources/AdaScripting/AdaScriptView.swift index 038281cac..5fa57eb76 100644 --- a/Sources/AdaScripting/AdaScriptView.swift +++ b/Sources/AdaScripting/AdaScriptView.swift @@ -128,6 +128,8 @@ public struct AdaScriptView: View { "scaleFactor": .double(Double(scaleFactor)), "userInterfaceIdiom": .string(userInterfaceIdiom.adaScriptName), ]) + let revision = $revision + resolvedStorage.onTaskProgress = { revision.wrappedValue += 1 } if let error = resolvedStorage.error { return AnyView( Text("Ada Script view error: \(error)") @@ -135,7 +137,6 @@ public struct AdaScriptView: View { .padding(12) ) } - let revision = $revision guard let model = resolvedStorage.model else { throw AdaScriptError.invalidManifest("@view '\(identifier)' did not produce a view tree") } @@ -195,6 +196,12 @@ public enum AdaScriptViewRegistry { } next[view.identifier] = Registration(moduleName: moduleName, runtime: runtime) } + let previousRuntimes = registrations.values + .filter { $0.moduleName == moduleName } + .map(\.runtime) + for previous in previousRuntimes { + previous.retire() + } registrations = next } @@ -227,8 +234,13 @@ final class AdaScriptViewModuleRuntime: @unchecked Sendable { // swiftlint:disable:next weak_delegate private let delegate: AnnotatedGravityRuntimeDelegate private let virtualMachine: GravityVirtualMachine + private let taskRuntime: AdaScriptTaskRuntime + private let asyncHost: AdaScriptAsyncHost private let exportedParameters: [String] + @MainActor private var storages: [WeakAdaScriptViewStorage] = [] + @MainActor private var isRetired = false + init(sources: [AdaScriptSource], views: [AdaScriptViewMetadata], exportedParameters: [String] = []) throws { guard exportedParameters.allSatisfy({ $0.range(of: "^[A-Za-z_][A-Za-z0-9_]*$", options: .regularExpression) != nil }) else { throw UIDiagnostic("Exported UI parameter names must be stored-property identifiers.") @@ -240,6 +252,9 @@ final class AdaScriptViewModuleRuntime: @unchecked Sendable { ) let networkCommands = try AdaScriptSchemaParser.parseNetworkCommands(sources: sources) let module = try GravityScriptModuleResolver.resolve(sources) + for view in views { + try module.requireSynchronousCallback(className: view.className, method: "body", annotation: "@view") + } self.factoryNamesByIdentifier = Dictionary( uniqueKeysWithValues: views.enumerated() .map { index, view in @@ -251,9 +266,23 @@ final class AdaScriptViewModuleRuntime: @unchecked Sendable { let delegate = AnnotatedGravityRuntimeDelegate(module: module) self.delegate = delegate - self.virtualMachine = try AdaScriptRuntimeCoordinator.lock.withLock { + let runtimeBundle = try AdaScriptRuntimeCoordinator.lock.withLock { let virtualMachine = GravityVirtualMachine(settings: .init(), delegate: delegate) - try virtualMachine.bindClass(with: AdaScriptViewBridge.self) + let suspensionPolicy = AdaScriptSuspensionPolicy(scriptNonSendableTypes: module.nonSendableTypeNames) + let taskRuntime = AdaScriptTaskRuntime.make( + virtualMachine: virtualMachine, + reportDiagnostic: delegate.append, + suspensionPolicy: suspensionPolicy + ) + let asyncHost = AdaScriptAsyncHost() + asyncHost.onWake = { taskRuntime.wake() } + asyncHost.ownerProvider = { taskRuntime.currentOwnerID } + try virtualMachine.bindClass(with: AdaScriptTaskRuntime.self) + try suspensionPolicy.bindSafe(AdaScriptAsyncResult.self, to: virtualMachine) + try suspensionPolicy.bindSafe(AdaScriptAsyncOperation.self, to: virtualMachine) + try virtualMachine.bindClass(with: AdaScriptAsyncHost.self) + try suspensionPolicy.bindSafe(AdaScriptSaveWriter.self, to: virtualMachine) + try suspensionPolicy.bindBorrowed(AdaScriptViewBridge.self, to: virtualMachine) try AdaScriptComponentRuntime.bind( to: virtualMachine, constructors: componentConstructors, @@ -266,8 +295,14 @@ final class AdaScriptViewModuleRuntime: @unchecked Sendable { AdaScriptNetworkCommandFactory.make(schemas: networkCommands, reportDiagnostic: delegate.append), forKey: "__adaNetworkFactory" ) - try AdaScriptAssetRuntime.bind(to: virtualMachine, reportDiagnostic: delegate.append) + try AdaScriptAssetRuntime.bind( + to: virtualMachine, + reportDiagnostic: delegate.append, + wake: { taskRuntime.wake() } + ) virtualMachine.setValue(AdaScriptViewBridge(), forKey: "adaUIBuilder") + virtualMachine.setValue(taskRuntime, forKey: "__adaTasks") + virtualMachine.setValue(asyncHost, forKey: "__adaAsync") let factories = views.enumerated() .map { index, view in @@ -280,7 +315,8 @@ final class AdaScriptViewModuleRuntime: @unchecked Sendable { } .joined(separator: "\n") let binary = virtualMachine.loadGravityFile( - from: AdaScriptComponentRuntime.prelude(constructors: componentConstructors) + from: AdaScriptTaskPrelude.source + "\n" + + AdaScriptComponentRuntime.prelude(constructors: componentConstructors) + AdaScriptNetworkBridge.prelude(commands: networkCommands) + module.entrySource + "\n" + factories + "\n" + getters ) @@ -291,13 +327,52 @@ final class AdaScriptViewModuleRuntime: @unchecked Sendable { guard delegate.errors.isEmpty else { throw AdaScriptError.compilation(delegate.errors) } - return virtualMachine + return (virtualMachine, taskRuntime, asyncHost) + } + self.virtualMachine = runtimeBundle.0 + self.taskRuntime = runtimeBundle.1 + self.asyncHost = runtimeBundle.2 + taskRuntime.onWake = { [weak self] in + Task { @MainActor [weak self] in self?.dispatchTasks() } + } + } + + deinit { + AdaScriptRuntimeCoordinator.lock.withLock { + taskRuntime.cancelAll() + asyncHost.cancelAll() } } @MainActor func makeStorage(identifier: String) throws -> AdaScriptViewStorage { - try AdaScriptViewStorage(runtime: self, identifier: identifier) + let storage = try AdaScriptViewStorage(runtime: self, identifier: identifier) + storages.append(WeakAdaScriptViewStorage(storage)) + return storage + } + + @MainActor + private func dispatchTasks() { + guard !isRetired else { + return + } + AdaScriptRuntimeCoordinator.lock.withLock { taskRuntime.pump() } + storages.removeAll(where: { $0.storage == nil }) + for storage in storages.compactMap(\.storage) { + storage.invalidateAfterTaskProgress() + } + } + + @MainActor + func retire() { + guard !isRetired else { + return + } + isRetired = true + AdaScriptRuntimeCoordinator.lock.withLock { + taskRuntime.cancelAll() + asyncHost.cancelAll() + } } func validate(identifier: String) throws { @@ -360,7 +435,13 @@ final class AdaScriptViewModuleRuntime: @unchecked Sendable { identifier: String, environment: [String: ReflectedFieldValue] ) throws -> AdaScriptViewModel { - try AdaScriptRuntimeCoordinator.lock.withLock { + guard !isRetired else { + throw AdaScriptError.invalidManifest("Ada Script view belongs to a retired module generation") + } + guard !taskRuntime.isVMAborted else { + throw AdaScriptError.invalidManifest("Ada Script VM stopped after a coroutine failure; reload the module") + } + return try AdaScriptRuntimeCoordinator.lock.withLock { guard let metadata = viewsByIdentifier[identifier] else { throw AdaScriptError.invalidManifest("Unknown @view id '\(identifier)'") } @@ -383,7 +464,7 @@ final class AdaScriptViewModuleRuntime: @unchecked Sendable { let value = instance.callMethod(named: "body", with: []), let bridge = value.toObjectOf(AdaScriptViewBridge.self) else { - let diagnostic = delegate.errors.last.map { ": \($0)" } ?? "" + let diagnostic = delegate.errors.isEmpty ? "" : ": \(delegate.errors.suffix(5).joined(separator: "; "))" throw AdaScriptError.invalidManifest("@view '\(identifier)' body() must return a View value\(diagnostic)") } return bridge.model @@ -391,8 +472,17 @@ final class AdaScriptViewModuleRuntime: @unchecked Sendable { } @MainActor - func perform(instance: GSValue, action: String, identifier: String) throws { + func perform(instance: GSValue, action: String, identifier: String, ownerID: String) throws { + guard !isRetired else { + throw AdaScriptError.invalidManifest("Ada Script view belongs to a retired module generation") + } + guard !taskRuntime.isVMAborted else { + throw AdaScriptError.invalidManifest("Ada Script VM stopped after a coroutine failure; reload the module") + } try AdaScriptRuntimeCoordinator.lock.withLock { + let previousOwner = taskRuntime.currentOwnerID + taskRuntime.currentOwnerID = "view:\(ownerID)" + defer { taskRuntime.currentOwnerID = previousOwner } guard instance.hasMethod(named: action) else { throw AdaScriptError.invalidManifest("Unknown action '\(action)' in @view '\(identifier)'") } @@ -402,16 +492,30 @@ final class AdaScriptViewModuleRuntime: @unchecked Sendable { } } } + + func cancelTasks(ownerID: String) { + AdaScriptRuntimeCoordinator.lock.withLock { + taskRuntime.cancel(ownerID: "view:\(ownerID)") + asyncHost.cancelSaveWriters(forOwner: "view:\(ownerID)") + } + } + + @MainActor + var activeTaskCount: Int { + AdaScriptRuntimeCoordinator.lock.withLock { taskRuntime.activeTaskCount } + } } @MainActor final class AdaScriptViewStorage { var error: (any Error)? + var onTaskProgress: (@MainActor () -> Void)? private(set) var model: AdaScriptViewModel? private let identifier: String private let instance: GSValue private let runtime: AdaScriptViewModuleRuntime + private let ownerID = UUID().uuidString private var environment: [String: ReflectedFieldValue] = [:] init(runtime: AdaScriptViewModuleRuntime, identifier: String) throws { @@ -422,6 +526,8 @@ final class AdaScriptViewStorage { self.model = nil } + deinit { runtime.cancelTasks(ownerID: ownerID) } + private var inputs: [String: UIValue] = [:] func updateInputs(_ values: [String: UIValue]) throws { @@ -444,10 +550,22 @@ final class AdaScriptViewStorage { } func perform(action: String) throws { - try runtime.perform(instance: instance, action: action, identifier: identifier) + try runtime.perform(instance: instance, action: action, identifier: identifier, ownerID: ownerID) model = try runtime.evaluate(instance: instance, identifier: identifier, environment: environment) error = nil } + + func invalidateAfterTaskProgress() { + model = nil + onTaskProgress?() + } +} + +@MainActor +private final class WeakAdaScriptViewStorage { + weak var storage: AdaScriptViewStorage? + + init(_ storage: AdaScriptViewStorage) { self.storage = storage } } extension UserInterfaceIdiom { diff --git a/Sources/AdaScripting/AdaScriptViewBridge.swift b/Sources/AdaScripting/AdaScriptViewBridge.swift index 9ae150eaa..797ccb496 100644 --- a/Sources/AdaScripting/AdaScriptViewBridge.swift +++ b/Sources/AdaScripting/AdaScriptViewBridge.swift @@ -5,7 +5,7 @@ import Foundation import Gravity @GSExportable("__AdaUIView") -final class AdaScriptViewBridge: @unchecked Sendable { +final class AdaScriptViewBridge: @unchecked Sendable, AdaScriptNonSendableBridge { @GSExportableIgnore let model: AdaScriptViewModel diff --git a/Sources/AdaScripting/AdaScripting.docc/AdaScriptAnnotations.md b/Sources/AdaScripting/AdaScripting.docc/AdaScriptAnnotations.md index a3183cc19..dd2a66344 100644 --- a/Sources/AdaScripting/AdaScripting.docc/AdaScriptAnnotations.md +++ b/Sources/AdaScripting/AdaScripting.docc/AdaScriptAnnotations.md @@ -6,6 +6,23 @@ The same spelling can have different roles: `@component` declares a struct, while `@component(required: true)` binds an existing component to a scriptable instance. +## Suspension policy + +`@nonsendable` marks a class, struct, or enum whose values must not be captured +by an async task or delivered through an awaited result. A typed async +parameter or async method on a marked type is rejected while compiling; +untyped values are checked when the task is created. AdaEngine marks its +callback-scoped world, query, resource, and input bridge types the same way. +This is a suspension lifetime rule, separate from Swift's `Sendable` protocol. + +```ada +@nonsendable +class TemporarySelection {} + +// Invalid: the parameter cannot live in an async function. +async func inspect(selection: TemporarySelection) {} +``` + ## Systems and scheduling | Annotation | Target | Effect | diff --git a/Sources/AdaScripting/AdaScripting.docc/AdaScriptLanguage.md b/Sources/AdaScripting/AdaScripting.docc/AdaScriptLanguage.md index c98be54af..b3edc22c5 100644 --- a/Sources/AdaScripting/AdaScripting.docc/AdaScriptLanguage.md +++ b/Sources/AdaScripting/AdaScripting.docc/AdaScriptLanguage.md @@ -292,6 +292,86 @@ Add `@previewable` when that view should appear in AdaEditor Preview. See for the supported view constructors, modifiers, and preview workflow. +## Asynchronous work + +Declare a function that can suspend with `async func`. Use `await` inside that +function; a synchronous action starts it explicitly with `Tasks.start(...)`. +The action returns immediately while the game or UI continues updating. + +```ada +async func loadShop() { + var result = await Assets.loadAsync("@res://Catalog/shop.json"); + if (result.isSuccess()) { + System.print("Shop asset: " + result.value()); + } else { + System.print(result.errorCode() + ": " + result.message()); + } +} + +@system class ShopSystem { + var started = false; + + func update(context) { + if (!started) { + started = true; + Tasks.start(loadShop()); + } + } +} +``` + +`Assets.loadAsync` and `Assets.saveAsync` use the native asset registry and +return an awaitable result. `Saves.writeAsync("@user://save.txt", text)` writes +an immutable string in the background and reports `committed()` after its +atomic file replacement. For data that cannot be copied in one callback, use +bounded chunks: + +```ada +async func saveLargeData() { + var writer = Saves.begin("@user://save.txt"); + var first = await writer.appendAsync("first chunk"); + if (!first.isSuccess()) { writer.cancel(); return; } + var committed = await writer.finishAsync(); + if (!committed.committed()) { System.print(committed.message()); } +} +``` + +Each chunk is limited to 256 KiB. The writer appends to a temporary file and +replaces the destination only after `finishAsync()` succeeds. Build large ECS +snapshots in bounded pieces; do not retain a query row or live component view +in a task. + +Mark a script-owned type with `@nonsendable` when its instances are tied to a +callback or another short lifetime. AdaScript rejects a typed async parameter +or method that would capture that type. Before a task starts, the runtime also +checks actual arguments, nested lists, returned values, and promise results; +this catches borrowed engine values passed through an untyped alias. Maps are +conservatively rejected until their entries can be inspected. See +. + +`await Time.sleep(seconds)` advances with the scheduler's `deltaTime` and stops +advancing when that scheduler is paused. AdaEngine does not yet expose a +separate game time-scale resource. +`await Time.sleepRealTime(seconds)` uses a monotonic clock; continuation still +waits for a script dispatch point. `Tasks.promise()` creates a one-shot wait +that an action can resolve with `complete(value)`. A Boolean `false` from a +confirmation is a user choice; closing its view cancels the waiting task. +`Tasks.start(...)` returns a handle with `status()` and `cancel()`. + +AdaScript callbacks such as `update(context)`, `body()`, and UI actions remain +synchronous. Do not mark them `async`; start a separate async function and pass +detached values. Callback contexts, queries, resources, and commands expire at +callback exit. Awaited work resumes in the serialized script runtime, not on +the native I/O worker. A task started by a view is cancelled when that view +is disposed or its module is replaced. Cancelling a save does not roll back +a file that was already committed. + +Fallible operations return a result with `isSuccess()`, `value()`, +`errorCode()`, and `message()`; inspect it after `await`. A script VM trap +currently stops that module and cancels its pending tasks. Diagnostics include +the failing task, owner, and trace ID; reload the module to continue. Other +module instances and native gameplay remain separate. + ## System context `update(context)` receives a scoped system context. `context.deltaTime` is the diff --git a/Sources/AdaScripting/AnnotatedGravityQueryView.swift b/Sources/AdaScripting/AnnotatedGravityQueryView.swift index 4e7ba56c4..84d069397 100644 --- a/Sources/AdaScripting/AnnotatedGravityQueryView.swift +++ b/Sources/AdaScripting/AnnotatedGravityQueryView.swift @@ -1,12 +1,19 @@ @_spi(Scripting) import AdaECS import Gravity +/// Access is serialized by AdaScriptRuntimeCoordinator while a callback is active. +final class AnnotatedGravityQueryLease: @unchecked Sendable { + var isActive = true +} + @GSExportable("AdaQuery") -final class AnnotatedGravityQueryBridge: @unchecked Sendable { +final class AnnotatedGravityQueryBridge: @unchecked Sendable, AdaScriptNonSendableBridge { private let cursor: DynamicQueryCursor private let row: AnnotatedGravityQueryRow private let virtualMachine: GravityVirtualMachine private var iterationIndex = 0 + private let lease: AnnotatedGravityQueryLease + private let reportDiagnostic: @Sendable (String) -> Void @GSExportableIgnore static func make( @@ -31,15 +38,22 @@ final class AnnotatedGravityQueryBridge: @unchecked Sendable { ) { self.cursor = cursor self.virtualMachine = virtualMachine + self.lease = AnnotatedGravityQueryLease() + self.reportDiagnostic = reportDiagnostic self.row = AnnotatedGravityQueryRow.make( cursor: cursor, componentAccesses: componentAccesses, + lease: lease, reportDiagnostic: reportDiagnostic, virtualMachine: virtualMachine ) } func iterate(_ previous: GSValue) -> GSValue { + guard lease.isActive else { + reportDiagnostic("Query capability is no longer valid") + return GSValue(boolean: false, in: virtualMachine) + } if previous.isNull || previous.isUndefined { cursor.reset() iterationIndex = 0 @@ -54,27 +68,39 @@ final class AnnotatedGravityQueryBridge: @unchecked Sendable { func next(_: Int) -> AnnotatedGravityQueryRow { row } + + @GSExportableIgnore + func invalidate() { lease.isActive = false } } @GSExportable("AdaQueryRow") -final class AnnotatedGravityQueryRow: @unchecked Sendable { - var id: Int { cursor.entityID } +final class AnnotatedGravityQueryRow: @unchecked Sendable, AdaScriptNonSendableBridge { + var id: Int { + guard lease.isActive else { + reportDiagnostic("Query row is no longer valid") + return -1 + } + return cursor.entityID + } private let componentViews: [String: AnnotatedGravityComponentView] private let cursor: DynamicQueryCursor private let reportDiagnostic: @Sendable (String) -> Void private let virtualMachine: GravityVirtualMachine + private let lease: AnnotatedGravityQueryLease @GSExportableIgnore static func make( cursor: DynamicQueryCursor, componentAccesses: [AnnotatedComponentAccess], + lease: AnnotatedGravityQueryLease, reportDiagnostic: @escaping @Sendable (String) -> Void, virtualMachine: GravityVirtualMachine ) -> AnnotatedGravityQueryRow { AnnotatedGravityQueryRow( cursor: cursor, componentAccesses: componentAccesses, + lease: lease, reportDiagnostic: reportDiagnostic, virtualMachine: virtualMachine ) @@ -83,12 +109,14 @@ final class AnnotatedGravityQueryRow: @unchecked Sendable { private init( cursor: DynamicQueryCursor, componentAccesses: [AnnotatedComponentAccess], + lease: AnnotatedGravityQueryLease, reportDiagnostic: @escaping @Sendable (String) -> Void, virtualMachine: GravityVirtualMachine ) { self.cursor = cursor self.reportDiagnostic = reportDiagnostic self.virtualMachine = virtualMachine + self.lease = lease self.componentViews = Dictionary( uniqueKeysWithValues: componentAccesses.map { access in ( @@ -96,6 +124,7 @@ final class AnnotatedGravityQueryRow: @unchecked Sendable { AnnotatedGravityComponentView.make( cursor: cursor, access: access, + lease: lease, reportDiagnostic: reportDiagnostic, virtualMachine: virtualMachine ) @@ -105,6 +134,10 @@ final class AnnotatedGravityQueryRow: @unchecked Sendable { } func get(_ component: String, _ field: String) -> GSValue { + guard lease.isActive else { + reportDiagnostic("Query row is no longer valid") + return GSValue(nullIn: virtualMachine) + } guard let componentView = componentViews[component] else { reportDiagnostic("Unknown query component alias '\(component)'") return GSValue(nullIn: virtualMachine) @@ -114,6 +147,10 @@ final class AnnotatedGravityQueryRow: @unchecked Sendable { @discardableResult func set(_ component: String, _ field: String, _ value: GSValue) -> Bool { + guard lease.isActive else { + reportDiagnostic("Query row is no longer valid") + return false + } guard let componentView = componentViews[component] else { reportDiagnostic("Unknown query component alias '\(component)'") return false @@ -122,27 +159,34 @@ final class AnnotatedGravityQueryRow: @unchecked Sendable { } func component(named alias: String) -> AnnotatedGravityComponentView? { - componentViews[alias] + guard lease.isActive else { + reportDiagnostic("Query row is no longer valid") + return nil + } + return componentViews[alias] } } @GSExportable("AdaComponent") -final class AnnotatedGravityComponentView: @unchecked Sendable { +final class AnnotatedGravityComponentView: @unchecked Sendable, AdaScriptNonSendableBridge { private let access: AnnotatedComponentAccess private let cursor: DynamicQueryCursor private let reportDiagnostic: @Sendable (String) -> Void private let virtualMachine: GravityVirtualMachine + private let lease: AnnotatedGravityQueryLease @GSExportableIgnore static func make( cursor: DynamicQueryCursor, access: AnnotatedComponentAccess, + lease: AnnotatedGravityQueryLease, reportDiagnostic: @escaping @Sendable (String) -> Void, virtualMachine: GravityVirtualMachine ) -> AnnotatedGravityComponentView { AnnotatedGravityComponentView( cursor: cursor, access: access, + lease: lease, reportDiagnostic: reportDiagnostic, virtualMachine: virtualMachine ) @@ -151,6 +195,7 @@ final class AnnotatedGravityComponentView: @unchecked Sendable { private init( cursor: DynamicQueryCursor, access: AnnotatedComponentAccess, + lease: AnnotatedGravityQueryLease, reportDiagnostic: @escaping @Sendable (String) -> Void, virtualMachine: GravityVirtualMachine ) { @@ -158,9 +203,14 @@ final class AnnotatedGravityComponentView: @unchecked Sendable { self.access = access self.reportDiagnostic = reportDiagnostic self.virtualMachine = virtualMachine + self.lease = lease } func get(_ fieldName: String) -> GSValue { + guard lease.isActive else { + reportDiagnostic("Component view is no longer valid") + return GSValue(nullIn: virtualMachine) + } guard let field = access.fields[fieldName], let value = cursor.read(componentAt: access.componentIndex, field: field) @@ -176,6 +226,10 @@ final class AnnotatedGravityComponentView: @unchecked Sendable { @discardableResult func set(_ fieldName: String, _ value: GSValue) -> Bool { + guard lease.isActive else { + reportDiagnostic("Component view is no longer valid") + return false + } guard let field = access.fields[fieldName] else { reportDiagnostic("Unknown field '\(access.alias).\(fieldName)'") return false diff --git a/Sources/AdaScripting/AnnotatedGravityResourceView.swift b/Sources/AdaScripting/AnnotatedGravityResourceView.swift index 1540b9fb0..b91a9f96d 100644 --- a/Sources/AdaScripting/AnnotatedGravityResourceView.swift +++ b/Sources/AdaScripting/AnnotatedGravityResourceView.swift @@ -2,11 +2,12 @@ import Gravity @GSExportable("AdaResource") -final class AnnotatedGravityResourceView: @unchecked Sendable { +final class AnnotatedGravityResourceView: @unchecked Sendable, AdaScriptNonSendableBridge { private let fields: [String: ReflectedComponentField] private let parameter: DynamicResource private let reportDiagnostic: @Sendable (String) -> Void private let virtualMachine: GravityVirtualMachine + private var isActive = true @GSExportableIgnore static func make( @@ -36,10 +37,18 @@ final class AnnotatedGravityResourceView: @unchecked Sendable { } func available() -> Bool { - parameter.isAvailable + guard isActive else { + reportDiagnostic("Resource view is no longer valid") + return false + } + return parameter.isAvailable } func get(_ fieldName: String) -> GSValue { + guard isActive else { + reportDiagnostic("Resource view is no longer valid") + return GSValue(nullIn: virtualMachine) + } guard let field = fields[fieldName], let value = parameter.read(field: field) else { reportDiagnostic("Unknown or unavailable resource field '\(fieldName)'") return GSValue(nullIn: virtualMachine) @@ -49,6 +58,10 @@ final class AnnotatedGravityResourceView: @unchecked Sendable { @discardableResult func set(_ fieldName: String, _ value: GSValue) -> Bool { + guard isActive else { + reportDiagnostic("Resource view is no longer valid") + return false + } guard let field = fields[fieldName] else { reportDiagnostic("Unknown resource field '\(fieldName)'") return false @@ -62,4 +75,7 @@ final class AnnotatedGravityResourceView: @unchecked Sendable { } return true } + + @GSExportableIgnore + func invalidate() { isActive = false } } diff --git a/Sources/AdaScripting/AnnotatedGravityScriptPlugin.swift b/Sources/AdaScripting/AnnotatedGravityScriptPlugin.swift index b1909ad42..126a76fbb 100644 --- a/Sources/AdaScripting/AnnotatedGravityScriptPlugin.swift +++ b/Sources/AdaScripting/AnnotatedGravityScriptPlugin.swift @@ -21,6 +21,11 @@ public final class AdaScriptPlugin: Plugin, @unchecked Sendable { runtime.diagnostics } + /// Number of suspended or ready AdaScript tasks owned by this module. + public var activeAsyncTaskCount: Int { + runtime.activeAsyncTaskCount + } + private let plans: [AnnotatedSystemPlan] private let dataSchemas: [AdaScriptDataSchema] private let networkCommands: [AdaScriptNetworkCommandSchema] @@ -80,6 +85,12 @@ public final class AdaScriptPlugin: Plugin, @unchecked Sendable { resourceBindings: resourceBindings, systemCapabilities: capabilities ) + for plan in plans { + try module.requireSynchronousCallback(className: plan.className, method: "update", annotation: "@system") + } + for binding in rpcMethodBindings { + try module.requireSynchronousCallback(className: binding.systemName, method: binding.commandName, annotation: "@rpc") + } if let startupSystemIdentifier { guard let startupIndex = plans.firstIndex(where: { $0.identifier == startupSystemIdentifier }) else { throw AdaScriptError.invalidManifest( @@ -163,6 +174,7 @@ public final class AdaScriptPlugin: Plugin, @unchecked Sendable { return } } + app.main.schedulers.addSystem(AdaScriptTaskPumpSystem(pluginIdentifier: name, runtime: runtime), for: .update) for plan in plans { do { let prepared = try Self.prepare(plan, pluginIdentifier: name, world: app.main) @@ -180,6 +192,11 @@ public final class AdaScriptPlugin: Plugin, @unchecked Sendable { } } + @MainActor + public func destroy(for app: borrowing AppWorlds) { + runtime.cancelTasks(forWorld: "world:\(ObjectIdentifier(app.main).hashValue)") + } + private static func prepare( _ plan: AnnotatedSystemPlan, pluginIdentifier: String, @@ -313,7 +330,9 @@ public final class AdaScriptPlugin: Plugin, @unchecked Sendable { if matches.count == 1, let component = matches.first?.value { return resolvedNativeComponent(component) } - guard let descriptor = world.runtimeComponentDescriptor(named: name) else { return nil } + guard let descriptor = world.runtimeComponentDescriptor(named: name) else { + return nil + } return ResolvedAnnotatedComponent(identifier: descriptor.componentID, fields: descriptor.fields) } @@ -440,7 +459,9 @@ private enum AnnotatedResourceBridge { } func invalidate() { - if case let .input(bridge) = self { + if case let .reflected(bridge) = self { + bridge.invalidate() + } else if case let .input(bridge) = self { bridge.invalidate() } else if case let .multiplayer(bridge) = self { bridge.invalidate() @@ -460,6 +481,34 @@ struct AnnotatedComponentAccess: Sendable { let fields: [String: ReflectedComponentField] } +private struct AdaScriptTaskPumpSystem: System { + private let deltaTime = Res() + private let pluginIdentifier: String + private let runtime: AnnotatedGravityRuntime? + + var systemIdentifier: String { Self.makeIdentifier(plugin: pluginIdentifier) } + var queries: SystemQueries { SystemQueries(queries: [deltaTime]) } + + init(world _: World) { + pluginIdentifier = "Unconfigured" + runtime = nil + } + + init(pluginIdentifier: String, runtime: AnnotatedGravityRuntime) { + self.pluginIdentifier = pluginIdentifier + self.runtime = runtime + } + + static func makeIdentifier(plugin: String) -> String { "AdaScripting.TaskPump.\(plugin)" } + + func update(context: UpdateContext) async { + runtime?.pumpTasks( + deltaTime: Double(deltaTime.wrappedValue?.deltaTime ?? 0), + worldID: "world:\(ObjectIdentifier(context.world).hashValue)" + ) + } +} + private struct AnnotatedGravityScriptSystem: System { private let deltaTime = Res() private let pluginIdentifier: String @@ -474,7 +523,13 @@ private struct AnnotatedGravityScriptSystem: System { } var systemDependencies: [SystemDependency] { - preparedSystem?.dependencies ?? [] + guard let preparedSystem else { + return [] + } + let pumpDependency: [SystemDependency] = preparedSystem.scheduler == .update + ? [.after(AdaScriptTaskPumpSystem.makeIdentifier(plugin: pluginIdentifier))] + : [] + return pumpDependency + preparedSystem.dependencies } var queries: SystemQueries { @@ -509,7 +564,7 @@ private struct AnnotatedGravityScriptSystem: System { "AdaScripting.System.\(plugin.utf8.count):\(plugin)\(system.utf8.count):\(system)" } - func update(context _: UpdateContext) async { + func update(context: UpdateContext) async { guard let preparedSystem, let runtime else { return } @@ -550,7 +605,8 @@ private struct AnnotatedGravityScriptSystem: System { remoteCommands: remoteCommands, rpcCalls: rpcCalls, resources: resources, - world: world + world: world, + ownerID: "world:\(ObjectIdentifier(context.world).hashValue):system:\(preparedSystem.identifier)" ) } } @@ -563,8 +619,17 @@ private final class AnnotatedGravityRuntime: @unchecked Sendable { private let delegate: AnnotatedGravityRuntimeDelegate private let runtimeComponents: [RuntimeComponentDescriptor] private let virtualMachine: GravityVirtualMachine + private let taskRuntime: AdaScriptTaskRuntime + private let asyncHost: AdaScriptAsyncHost private var instances: [String: GSValue] = [:] + deinit { + AdaScriptRuntimeCoordinator.lock.withLock { + taskRuntime.cancelAll() + asyncHost.cancelAll() + } + } + init( module: ResolvedGravityScriptModule, componentConstructors: [AdaScriptLinkedComponentConstructor], @@ -580,15 +645,31 @@ private final class AnnotatedGravityRuntime: @unchecked Sendable { let virtualMachine = GravityVirtualMachine(settings: .init(), delegate: delegate) self.virtualMachine = virtualMachine - try virtualMachine.bindClass(with: AnnotatedGravitySystemContext.self) - try virtualMachine.bindClass(with: AdaScriptInputBridge.self) - try virtualMachine.bindClass(with: AnnotatedGravityWorldContext.self) - try virtualMachine.bindClass(with: AnnotatedGravityCommandsBridge.self) - try virtualMachine.bindClass(with: AnnotatedGravityQueryBridge.self) - try virtualMachine.bindClass(with: AnnotatedGravityQueryRow.self) - try virtualMachine.bindClass(with: AnnotatedGravityComponentView.self) - try virtualMachine.bindClass(with: AnnotatedGravityResourceView.self) - try virtualMachine.bindClass(with: AdaScriptMultiplayerAPI.self) + let suspensionPolicy = AdaScriptSuspensionPolicy(scriptNonSendableTypes: module.nonSendableTypeNames) + let taskRuntime = AdaScriptTaskRuntime.make( + virtualMachine: virtualMachine, + reportDiagnostic: delegate.append, + suspensionPolicy: suspensionPolicy + ) + self.taskRuntime = taskRuntime + let asyncHost = AdaScriptAsyncHost() + self.asyncHost = asyncHost + asyncHost.onWake = { taskRuntime.wake() } + asyncHost.ownerProvider = { taskRuntime.currentOwnerID } + try virtualMachine.bindClass(with: AdaScriptTaskRuntime.self) + try suspensionPolicy.bindSafe(AdaScriptAsyncResult.self, to: virtualMachine) + try suspensionPolicy.bindSafe(AdaScriptAsyncOperation.self, to: virtualMachine) + try virtualMachine.bindClass(with: AdaScriptAsyncHost.self) + try suspensionPolicy.bindSafe(AdaScriptSaveWriter.self, to: virtualMachine) + try suspensionPolicy.bindBorrowed(AnnotatedGravitySystemContext.self, to: virtualMachine) + try suspensionPolicy.bindBorrowed(AdaScriptInputBridge.self, to: virtualMachine) + try suspensionPolicy.bindBorrowed(AnnotatedGravityWorldContext.self, to: virtualMachine) + try suspensionPolicy.bindBorrowed(AnnotatedGravityCommandsBridge.self, to: virtualMachine) + try suspensionPolicy.bindBorrowed(AnnotatedGravityQueryBridge.self, to: virtualMachine) + try suspensionPolicy.bindBorrowed(AnnotatedGravityQueryRow.self, to: virtualMachine) + try suspensionPolicy.bindBorrowed(AnnotatedGravityComponentView.self, to: virtualMachine) + try suspensionPolicy.bindBorrowed(AnnotatedGravityResourceView.self, to: virtualMachine) + try suspensionPolicy.bindBorrowed(AdaScriptMultiplayerAPI.self, to: virtualMachine) try virtualMachine.bindClass(with: AdaScriptNetworkCommandFactory.self) try virtualMachine.bindClass(with: AdaScriptNetworkCommandValue.self) try virtualMachine.bindClass(with: AdaScriptNetworkValueBridge.self) @@ -600,7 +681,11 @@ private final class AnnotatedGravityRuntime: @unchecked Sendable { runtimeDescriptors: runtimeComponents, reportDiagnostic: delegate.append ) - try AdaScriptAssetRuntime.bind(to: virtualMachine, reportDiagnostic: delegate.append) + try AdaScriptAssetRuntime.bind( + to: virtualMachine, + reportDiagnostic: delegate.append, + wake: { taskRuntime.wake() } + ) virtualMachine.setValue( AdaScriptNetworkCommandFactory.make( schemas: networkCommands, @@ -609,9 +694,12 @@ private final class AnnotatedGravityRuntime: @unchecked Sendable { forKey: "__adaNetworkFactory" ) virtualMachine.setValue(AdaScriptViewBridge(), forKey: "adaUIBuilder") + virtualMachine.setValue(taskRuntime, forKey: "__adaTasks") + virtualMachine.setValue(asyncHost, forKey: "__adaAsync") let binary = virtualMachine.loadGravityFile( - from: AdaScriptComponentRuntime.prelude(constructors: componentConstructors) + from: AdaScriptTaskPrelude.source + "\n" + + AdaScriptComponentRuntime.prelude(constructors: componentConstructors) + AdaScriptNetworkBridge.prelude(commands: networkCommands) + module.entrySource ) @@ -658,14 +746,22 @@ private final class AnnotatedGravityRuntime: @unchecked Sendable { remoteCommands: [(propertyName: String, value: GSValue)], rpcCalls: [(commandName: String, fieldNames: [String], payloads: [AdaScriptRemoteCommandPayload])], resources: [(propertyName: String, resource: AnnotatedResourceBridge)], - world: AnnotatedGravityWorldContext + world: AnnotatedGravityWorldContext, + ownerID: String ) { AdaScriptRuntimeCoordinator.lock.lock() defer { AdaScriptRuntimeCoordinator.lock.unlock() } + let previousOwner = taskRuntime.currentOwnerID + taskRuntime.currentOwnerID = ownerID + defer { taskRuntime.currentOwnerID = previousOwner } defer { world.invalidate() + for (_, query) in queries { query.invalidate() } for (_, resource) in resources { resource.invalidate() } } + guard !taskRuntime.isVMAborted else { + return + } guard let instance = instances[className] else { return @@ -707,6 +803,20 @@ private final class AnnotatedGravityRuntime: @unchecked Sendable { _ = instance.callMethod(named: "update", with: [context]) } + func pumpTasks(deltaTime: Double, worldID: String) { + AdaScriptRuntimeCoordinator.lock.withLock { + asyncHost.advanceGameTime(by: deltaTime, forWorld: worldID) + taskRuntime.pump(onlyWorld: worldID) + } + } + + func cancelTasks(forWorld worldID: String) { + AdaScriptRuntimeCoordinator.lock.withLock { + taskRuntime.cancel(worldID: worldID) + asyncHost.cancelGameTimers(forWorld: worldID) + } + } + func makeQueryBridge( cursor: DynamicQueryCursor, componentAccesses: [AnnotatedComponentAccess] @@ -776,6 +886,10 @@ private final class AnnotatedGravityRuntime: @unchecked Sendable { AdaScriptRuntimeCoordinator.lock.withLock { delegate.errors } } + var activeAsyncTaskCount: Int { + AdaScriptRuntimeCoordinator.lock.withLock { taskRuntime.activeTaskCount } + } + func appendDiagnostic(_ message: String) { AdaScriptRuntimeCoordinator.lock.withLock { delegate.append(message) } } diff --git a/Sources/AdaScripting/AnnotatedGravityScriptSupport.swift b/Sources/AdaScripting/AnnotatedGravityScriptSupport.swift index 74655a06f..d3ee0f624 100644 --- a/Sources/AdaScripting/AnnotatedGravityScriptSupport.swift +++ b/Sources/AdaScripting/AnnotatedGravityScriptSupport.swift @@ -3,7 +3,7 @@ import AdaUtils import Gravity @GSExportable("AdaSystemContext") -final class AnnotatedGravitySystemContext: @unchecked Sendable { +final class AnnotatedGravitySystemContext: @unchecked Sendable, AdaScriptNonSendableBridge { let deltaTime: Double let world: AnnotatedGravityWorldContext diff --git a/Sources/AdaScripting/AnnotatedGravityWorldContext.swift b/Sources/AdaScripting/AnnotatedGravityWorldContext.swift index 705ba2534..e91d964d3 100644 --- a/Sources/AdaScripting/AnnotatedGravityWorldContext.swift +++ b/Sources/AdaScripting/AnnotatedGravityWorldContext.swift @@ -2,7 +2,7 @@ import Gravity @GSExportable("AdaWorldContext") -final class AnnotatedGravityWorldContext: @unchecked Sendable { +final class AnnotatedGravityWorldContext: @unchecked Sendable, AdaScriptNonSendableBridge { let commands: AnnotatedGravityCommandsBridge @GSExportableIgnore @@ -24,7 +24,7 @@ final class AnnotatedGravityWorldContext: @unchecked Sendable { } @GSExportable("AdaCommands") -final class AnnotatedGravityCommandsBridge: @unchecked Sendable { +final class AnnotatedGravityCommandsBridge: @unchecked Sendable, AdaScriptNonSendableBridge { private var commands: Commands? private let reportDiagnostic: @Sendable (String) -> Void private let runtimeComponents: [String: RuntimeComponentDescriptor] diff --git a/Sources/AdaScripting/GravityAttachedDataView.swift b/Sources/AdaScripting/GravityAttachedDataView.swift index 0eda5e840..9101da798 100644 --- a/Sources/AdaScripting/GravityAttachedDataView.swift +++ b/Sources/AdaScripting/GravityAttachedDataView.swift @@ -2,7 +2,7 @@ import Gravity @GSExportable("AdaAttachedComponent") -final class GravityAttachedComponentView: @unchecked Sendable { +final class GravityAttachedComponentView: @unchecked Sendable, AdaScriptNonSendableBridge { private let componentType: any Component.Type private let descriptor: ReflectedComponentDescriptor? private let entityID: Entity.ID @@ -79,7 +79,7 @@ final class GravityAttachedComponentView: @unchecked Sendable { } @GSExportable("AdaAttachedResource") -final class GravityAttachedResourceView: @unchecked Sendable { +final class GravityAttachedResourceView: @unchecked Sendable, AdaScriptNonSendableBridge { private let fields: [String: ReflectedComponentField] private let optional: Bool private let reportDiagnostic: @Sendable (String) -> Void diff --git a/Sources/AdaScripting/GravityScriptModule.swift b/Sources/AdaScripting/GravityScriptModule.swift index 5ff070c4e..3c4a38cab 100644 --- a/Sources/AdaScripting/GravityScriptModule.swift +++ b/Sources/AdaScripting/GravityScriptModule.swift @@ -48,36 +48,36 @@ struct ResolvedGravityScriptModule: Sendable { let entrySource: String let sourcesByPath: [String: Source] let pathsByFileID: [UInt32: String] + let nonSendableTypeNames: Set + let asyncDeclarations: [AdaScriptAsyncDeclaration] + + func requireSynchronousCallback(className: String, method: String, annotation: String) throws { + guard let declaration = asyncDeclarations.first(where: { $0.ownerType == className && $0.name == method }) else { + return + } + throw AdaScriptError.invalidManifest( + "\(annotation) callback '\(className).\(method)' at line \(declaration.line) cannot be async" + ) + } } enum GravityScriptModuleResolver { static func resolve(_ sources: [AdaScriptSource]) throws -> ResolvedGravityScriptModule { - var parsedSources: [String: ParsedSource] = [:] + var sourceByPath: [String: AdaScriptSource] = [:] + var preliminarySources: [String: ParsedSource] = [:] for source in sources { let path = try canonicalSourcePath(source.path) - guard parsedSources[path] == nil else { + guard sourceByPath[path] == nil else { throw AdaScriptError.duplicateSourcePath(path) } - let loweredViewSource: String - do { - loweredViewSource = try AdaScriptViewBuilderLowerer.lower(source: source.source, path: path) - } catch let error as AdaScriptViewBuilderError { - throw AdaScriptError.invalidManifest(error.description) - } - let loweredAssetsSource = AdaScriptAssetsLowerer.lower(source: loweredViewSource) - let schemas = try AdaScriptSchemaParser.parse(sources: [source]) - let loweredComponentSource = AdaScriptComponentLowerer.lower( - source: loweredAssetsSource, - schemas: schemas - ) - let loweredSource = AdaScriptNetworkLowerer.lower(source: loweredComponentSource) - var scanner = AdaScriptSourceScanner(source: loweredSource, path: path) - parsedSources[path] = try scanner.scan() + sourceByPath[path] = source + var scanner = AdaScriptSourceScanner(source: source.source, path: path) + preliminarySources[path] = try scanner.scan() } - let sortedPaths = parsedSources.keys.sorted() + let sortedPaths = sourceByPath.keys.sorted() let discoveryRoots = sortedPaths.filter { path in - guard let annotations = parsedSources[path]?.annotations else { + guard let annotations = preliminarySources[path]?.annotations else { return false } return !annotations.isDisjoint(with: rootAnnotations) @@ -90,13 +90,64 @@ enum GravityScriptModuleResolver { for root in roots { try visit( root, - parsedSources: parsedSources, + parsedSources: preliminarySources, states: &states, stack: &stack, orderedPaths: &orderedPaths ) } + var nonSendableTypeNames = Set() + for path in orderedPaths { + guard let source = sourceByPath[path] else { + continue + } + do { + nonSendableTypeNames.formUnion(try AdaScriptNonSendableTypes.declared(in: source.source, path: path)) + } catch let error as AdaScriptAsyncSyntaxError { + throw AdaScriptError.invalidManifest(error.description) + } + } + + var asyncDeclarations: [AdaScriptAsyncDeclaration] = [] + for path in orderedPaths { + guard let source = sourceByPath[path] else { + continue + } + do { + let declarations = try AdaScriptAsyncDeclarationScanner.declarations( + in: source.source, + path: path, + nonSendableTypes: nonSendableTypeNames + ) + asyncDeclarations += declarations + } catch let error as AdaScriptAsyncSyntaxError { + throw AdaScriptError.invalidManifest(error.description) + } + } + + var parsedSources: [String: ParsedSource] = [:] + for path in sortedPaths { + guard let source = sourceByPath[path] else { + continue + } + let loweredViewSource: String + do { + loweredViewSource = try AdaScriptViewBuilderLowerer.lower(source: source.source, path: path) + } catch let error as AdaScriptViewBuilderError { + throw AdaScriptError.invalidManifest(error.description) + } + let loweredAssetsSource = AdaScriptAssetsLowerer.lower(source: loweredViewSource) + let schemas = try AdaScriptSchemaParser.parse(sources: [source]) + let loweredComponentSource = AdaScriptComponentLowerer.lower( + source: loweredAssetsSource, + schemas: schemas + ) + let loweredSource = AdaScriptNetworkLowerer.lower(source: loweredComponentSource) + var scanner = AdaScriptSourceScanner(source: loweredSource, path: path) + parsedSources[path] = try scanner.scan() + } + var sourcesByPath: [String: ResolvedGravityScriptModule.Source] = [:] var pathsByFileID: [UInt32: String] = [:] for (offset, path) in sortedPaths.enumerated() { @@ -121,7 +172,9 @@ enum GravityScriptModuleResolver { return ResolvedGravityScriptModule( entrySource: entrySource, sourcesByPath: sourcesByPath, - pathsByFileID: pathsByFileID + pathsByFileID: pathsByFileID, + nonSendableTypeNames: nonSendableTypeNames, + asyncDeclarations: asyncDeclarations ) } diff --git a/Sources/AdaScripting/GravityScriptableObject.swift b/Sources/AdaScripting/GravityScriptableObject.swift index 37c16fa75..81bd749f0 100644 --- a/Sources/AdaScripting/GravityScriptableObject.swift +++ b/Sources/AdaScripting/GravityScriptableObject.swift @@ -349,7 +349,7 @@ private final class GravityScriptableObject: ScriptableObject, @unchecked Sendab } @GSExportable("AdaScriptableContext") -private final class GravityScriptableLifecycleContext: @unchecked Sendable { +private final class GravityScriptableLifecycleContext: @unchecked Sendable, AdaScriptNonSendableBridge { let deltaTime: Double let entityID: Int /// Stable world identity for module state scoped to one running scene. @@ -383,6 +383,14 @@ private final class GravityScriptableLifecycleContext: @unchecked Sendable { } private final class GravityScriptableModuleRuntime: @unchecked Sendable { + private enum LifecycleCallback: String, CaseIterable { + case ready + case update + case fixedUpdate + case event + case destroy + } + private let factoryNamesByClass: [String: String] private let getterNamesByClass: [String: [String: String]] // The runtime owns its delegate for exactly the VM lifetime; this is not a callback back-reference. @@ -395,6 +403,15 @@ private final class GravityScriptableModuleRuntime: @unchecked Sendable { init(sources: [AdaScriptSource], schemas: [AdaScriptObjectSchema]) throws { let componentConstructors = AdaScriptComponentRuntime.linkedConstructors() let module = try GravityScriptModuleResolver.resolve(sources) + for schema in schemas { + for callback in LifecycleCallback.allCases { + try module.requireSynchronousCallback( + className: schema.className, + method: callback.rawValue, + annotation: "@scriptable" + ) + } + } let factoryNamesByClass = Dictionary( uniqueKeysWithValues: schemas.enumerated() .map { index, schema in diff --git a/Sources/AdaUI/DSL/PropertyWrappers/EnvironmentValues.swift b/Sources/AdaUI/DSL/PropertyWrappers/EnvironmentValues.swift index eee3e0929..0d3de7a86 100644 --- a/Sources/AdaUI/DSL/PropertyWrappers/EnvironmentValues.swift +++ b/Sources/AdaUI/DSL/PropertyWrappers/EnvironmentValues.swift @@ -26,13 +26,13 @@ public struct Environment: PropertyStoragable, UpdatableProperty { public init(_ keyPath: KeyPath) { // Record which environment keys this wrapper reads so the node can skip // invalidation when only unrelated keys change (Phase 4 subscription tracking). - var capturedIDs = Set() - EnvironmentValues._recordKeyAccess = { capturedIDs.insert($0) } - _ = EnvironmentValues()[keyPath: keyPath] - EnvironmentValues._recordKeyAccess = nil + let recorder = EnvironmentKeyAccessRecorder() + EnvironmentValues.$_recordKeyAccess.withValue(recorder) { + _ = EnvironmentValues()[keyPath: keyPath] + } let storage = ViewContextStorage() - storage.subscribedKeyIDs = capturedIDs + storage.subscribedKeyIDs = recorder.capturedKeys self.container = storage self.readValue = { $0.values[keyPath: keyPath] } } @@ -42,13 +42,13 @@ public struct Environment: PropertyStoragable, UpdatableProperty { extension Environment where Value: Observable & AnyObject { public init(_ observable: Value.Type) where Value: Observable & AnyObject { - var capturedIDs = Set() - EnvironmentValues._recordKeyAccess = { capturedIDs.insert($0) } - _ = EnvironmentValues().observableStorage - EnvironmentValues._recordKeyAccess = nil + let recorder = EnvironmentKeyAccessRecorder() + EnvironmentValues.$_recordKeyAccess.withValue(recorder) { + _ = EnvironmentValues().observableStorage + } let storage = ViewContextStorage() - storage.subscribedKeyIDs = capturedIDs + storage.subscribedKeyIDs = recorder.capturedKeys self.container = storage self.readValue = { container in // Return the injected observable directly. diff --git a/Sources/AdaUtils/Environment/EnvironmentValues.swift b/Sources/AdaUtils/Environment/EnvironmentValues.swift index 9949d5f76..7e0e7e380 100644 --- a/Sources/AdaUtils/Environment/EnvironmentValues.swift +++ b/Sources/AdaUtils/Environment/EnvironmentValues.swift @@ -5,6 +5,8 @@ // Created by Vladislav Prusakov on 30.05.2025. // +import Foundation + @attached(accessor) @attached(peer, names: prefixed(__Key_)) public macro Entry() = #externalMacro(module: "AdaEngineMacros", type: "EntryMacro") @@ -56,6 +58,22 @@ public protocol EnvironmentKey { static var defaultValue: Value { get } } +/// Collects environment keys within one task-local view-property initialization. +package final class EnvironmentKeyAccessRecorder: @unchecked Sendable { + private let lock = NSLock() + private var keys = Set() + + package init() {} + + package func record(_ key: ObjectIdentifier) { + lock.withLock { _ = keys.insert(key) } + } + + package var capturedKeys: Set { + lock.withLock { keys } + } +} + /// A collection of environment values propagated through a view hierarchy. /// /// AdaEngine exposes a collection of values to your app’s views in an EnvironmentValues structure. @@ -111,16 +129,14 @@ public struct EnvironmentValues: Sendable { /// Creates an environment values instance. public init() {} - /// When non-nil, every subscript READ reports the accessed key's ObjectIdentifier here. - /// Used once during `@Environment` initialisation to discover which keys it subscribes to. - /// All accesses occur on the main actor; `nonisolated(unsafe)` avoids a spurious concurrency error - /// from the nonisolated subscript getter context. - nonisolated(unsafe) package static var _recordKeyAccess: ((ObjectIdentifier) -> Void)? + /// Scoped to the task building an `@Environment` property so another + /// scheduler cannot mutate its capture set concurrently. + @TaskLocal package static var _recordKeyAccess: EnvironmentKeyAccessRecorder? /// Accesses the environment value associated with a custom key. public subscript(_ type: K.Type) -> K.Value { get { - unsafe Self._recordKeyAccess?(ObjectIdentifier(type)) + Self._recordKeyAccess?.record(ObjectIdentifier(type)) return (self.values[ObjectIdentifier(type)] as? K.Value) ?? K.defaultValue } set { diff --git a/Tests/AdaScriptingTests/AdaScriptAsyncDeclarationScannerTests.swift b/Tests/AdaScriptingTests/AdaScriptAsyncDeclarationScannerTests.swift new file mode 100644 index 000000000..8425dc479 --- /dev/null +++ b/Tests/AdaScriptingTests/AdaScriptAsyncDeclarationScannerTests.swift @@ -0,0 +1,31 @@ +import AdaScriptCompilerCore +import Testing + +@Suite("AdaScript async declaration metadata") +struct AdaScriptAsyncDeclarationScannerTests { + @Test("Records global functions and methods for callback contracts") + func scansDeclarations() throws { + let declarations = try AdaScriptAsyncDeclarationScanner.declarations(in: """ + async func fetch(item) { return await Assets.loadAsync(item); } + class Shop { async func refresh() {} } + """, path: "Shop.ada") + #expect(declarations.map(\.name) == ["fetch", "refresh"]) + #expect(declarations.map(\.ownerType) == [nil, "Shop"]) + } + + @Test("Rejects a marked receiver and typed marked parameter") + func rejectsNonSendableDeclarations() { + #expect(throws: AdaScriptAsyncSyntaxError.self) { + try AdaScriptAsyncDeclarationScanner.declarations(in: """ + @nonsendable class Borrowed {} + async func inspect(value: Borrowed) {} + """, path: "Invalid.ada") + } + #expect(throws: AdaScriptAsyncSyntaxError.self) { + try AdaScriptAsyncDeclarationScanner.declarations( + in: "@nonsendable class Borrowed { async func inspect() {} }", + path: "Invalid.ada" + ) + } + } +} diff --git a/Tests/AdaScriptingTests/AdaScriptAsyncTestSupport.swift b/Tests/AdaScriptingTests/AdaScriptAsyncTestSupport.swift new file mode 100644 index 000000000..ba2c9c28b --- /dev/null +++ b/Tests/AdaScriptingTests/AdaScriptAsyncTestSupport.swift @@ -0,0 +1,31 @@ +import AdaAssets +import AdaECS +@testable import AdaScripting +import Foundation + +@Component +struct AsyncPosition { + var value: Double +} + +// The delayed decoder proves that ECS frames advance while native asset work waits. +final class AsyncTextAsset: Asset, @unchecked Sendable { + var assetMetaInfo: AssetMetaInfo? + let value: String + + init(value: String) { self.value = value } + init(from decoder: any AssetDecoder) async throws { + let decoded = try decoder.decode(String.self) + try await Task.sleep(for: .milliseconds(80)) + value = decoded + } + func encodeContents(with encoder: any AssetEncoder) throws { try encoder.encode(value) } + static func extensions() -> [String] { ["asynctext"] } +} + +func waitForOperation(_ operation: AdaScriptAsyncOperation) async throws -> AdaScriptAsyncResult { + for _ in 0..<100 where !operation.isDone() { + try await Task.sleep(for: .milliseconds(10)) + } + return operation.result() +} diff --git a/Tests/AdaScriptingTests/AdaScriptAsyncTests.swift b/Tests/AdaScriptingTests/AdaScriptAsyncTests.swift new file mode 100644 index 000000000..073eda986 --- /dev/null +++ b/Tests/AdaScriptingTests/AdaScriptAsyncTests.swift @@ -0,0 +1,587 @@ +@_spi(Internal) import AdaApp +import AdaAssets +import AdaECS +@testable import AdaScripting +import Foundation +import Testing + +@Suite("AdaScript asynchronous tasks", .serialized) +struct AdaScriptAsyncTests { + @Test("Game timers advance only with scheduler time") + func gameTimerUsesDeltaTime() { + let host = AdaScriptAsyncHost() + host.ownerProvider = { "world:test:system:timer" } + let timer = host.sleep(1) + host.advanceGameTime(by: 0.4, forWorld: "world:test") + #expect(!timer.isDone()) + host.advanceGameTime(by: 0, forWorld: "world:test") + #expect(!timer.isDone()) + host.advanceGameTime(by: 0.6, forWorld: "world:test") + #expect(timer.isDone()) + #expect(timer.result().isSuccess()) + #expect(host.sleep(-1).result().errorCode() == "invalidDuration") + let cancelled = host.sleep(100) + #expect(cancelled.cancel()) + #expect(cancelled.result().errorCode() == "cancelled") + host.ownerProvider = { "world:other:system:timer" } + let otherWorldTimer = host.sleep(0.2) + host.advanceGameTime(by: 1, forWorld: "world:test") + #expect(!otherWorldTimer.isDone()) + host.advanceGameTime(by: 0.2, forWorld: "world:other") + #expect(otherWorldTimer.result().isSuccess()) + #expect(AdaScriptAsyncHost().sleep(1).result().errorCode() == "missingGameClock") + } + + @Test("Real-time timers complete without a world update") + func realTimeTimerCompletes() async throws { + let timer = AdaScriptAsyncHost().sleepRealTime(0.01) + try await Task.sleep(for: .milliseconds(30)) + #expect(timer.result().isSuccess()) + } + + @Test("A background save rejects paths outside writable roots") + func rejectsSavePathTraversal() { + let result = AdaScriptAsyncHost().writeText("@user://../escape.txt", "unsafe").result() + #expect(!result.isSuccess()) + #expect(result.errorCode() == "invalidPath") + } + + @Test("A save cannot follow a directory link outside the user root") + @MainActor + func rejectsSymlinkEscape() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("AdaScriptAsyncLink-\(UUID().uuidString)", isDirectory: true) + let user = root.appendingPathComponent("User", isDirectory: true) + let outside = root.appendingPathComponent("Outside", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: user, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: user.appendingPathComponent("Link"), withDestinationURL: outside) + await AppWorldsExecutionContext.$currentID.withValue(UUID()) { + await AssetsManager.setProjectDirectories( + ProjectDirectories(source: root, assetsDirectory: root.appendingPathComponent("Assets"), userDataDirectory: user, cacheDirectory: root.appendingPathComponent("Cache")) + ) + let result = AdaScriptAsyncHost().writeText("@user://Link/escape.txt", "unsafe").result() + #expect(result.errorCode() == "invalidPath") + #expect(!FileManager.default.fileExists(atPath: outside.appendingPathComponent("escape.txt").path)) + } + } + + @Test("An async declaration coexists with a system") + @MainActor + func compilesAsyncDeclaration() throws { + AsyncPosition.registerComponent() + _ = try AdaScriptPlugin(source: """ + async func value() { return 3; } + var started = false; + @system class EmptySystem { + @query(AsyncPosition) var positions; + func update(context) {} + } + """) + } + + @Test("Imported async calls require await, while unused sources stay independent") + func checksOnlyReachableAsyncFunctions() throws { + #expect(throws: AdaScriptError.self) { + try AdaScriptPlugin(sources: [ + AdaScriptSource(path: "Main.ada", source: """ + import { work } from "./Helper"; + @system class S { func update(context) { work(); } } + """), + AdaScriptSource(path: "Helper.ada", source: "async func work() { return 1; }") + ], name: "ImportedAsync") + } + _ = try AdaScriptPlugin(sources: [ + AdaScriptSource(path: "Main.ada", source: """ + func work() { return 1; } + @system class S { func update(context) { work(); } } + """), + AdaScriptSource(path: "Unused.ada", source: "async func work() { return 2; }") + ], name: "UnreachableAsync") + } + + @Test("An async function yields without stopping ECS updates") + @MainActor + func resumesAcrossUpdates() async throws { + AsyncPosition.registerComponent() + let plugin = try AdaScriptPlugin(source: """ + var phase = 0; + var started = false; + + async func advance() { + phase = 1; + await Tasks.nextFrame(); + phase = 2; + } + + @system(scheduler: "update") + class AsyncSystem { + @query(AsyncPosition) + var positions; + func update(context) { + if (!started) { + Tasks.start(advance()); + started = true; + } + for (var row in positions) { + row.asyncPosition.value = phase; + } + } + } + """) + let world = World(name: "AdaScript async test") + let entity = world.spawn { AsyncPosition(value: -1) } + plugin.setup(in: AppWorlds(main: world)) + + await world.runScheduler(.update) + #expect(world.get(AsyncPosition.self, from: entity.id)?.value == 0) + await world.runScheduler(.update) + #expect(world.get(AsyncPosition.self, from: entity.id)?.value == 1) + await world.runScheduler(.update) + await world.runScheduler(.update) + #expect(world.get(AsyncPosition.self, from: entity.id)?.value == 2) + #expect(plugin.diagnostics.isEmpty) + } + + @Test("Nested async calls return a value after suspension") + @MainActor + func awaitsChildResult() async throws { + AsyncPosition.registerComponent() + let plugin = try AdaScriptPlugin(source: """ + var started = false; + var result = 0; + async func child() { + await Tasks.nextFrame(); + return 7; + } + async func parent() { result = await child(); } + @system class ParentSystem { + @query(AsyncPosition) var positions; + func update(context) { + if (!started) { started = true; Tasks.start(parent()); } + for (var row in positions) { row.asyncPosition.value = result; } + } + } + """) + let world = World(name: "AdaScript nested async") + let entity = world.spawn { AsyncPosition(value: 0) } + plugin.setup(in: AppWorlds(main: world)) + for _ in 0..<10 { await world.runScheduler(.update) } + #expect(world.get(AsyncPosition.self, from: entity.id)?.value == 7) + #expect(plugin.diagnostics.isEmpty) + } + + @Test("A promise resumes its waiter after a later update") + @MainActor + func waitsForConfirmation() async throws { + AsyncPosition.registerComponent() + let plugin = try AdaScriptPlugin(source: """ + var started = false; + var count = 0; + var answer = Tasks.promise(); + + async func ask() { + var confirmed = await answer; + if (confirmed) { count = 7; } else { count = -7; } + } + + @system class ConfirmationSystem { + @query(AsyncPosition) var positions; + func update(context) { + if (!started) { + started = true; + Tasks.start(ask()); + } else if (count == 0) { + answer.complete(true); + } + for (var row in positions) { row.asyncPosition.value = count; } + } + } + """) + let world = World(name: "AdaScript confirmation test") + let entity = world.spawn { AsyncPosition(value: 0) } + plugin.setup(in: AppWorlds(main: world)) + for _ in 0..<5 { await world.runScheduler(.update) } + #expect(world.get(AsyncPosition.self, from: entity.id)?.value == 7) + #expect(plugin.diagnostics.isEmpty) + } + + @Test("A save writes atomically without stopping updates") + @MainActor + func writesTextInBackground() async throws { + AsyncPosition.registerComponent() + let root = FileManager.default.temporaryDirectory.appendingPathComponent("AdaScriptAsyncSave-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try await AppWorldsExecutionContext.$currentID.withValue(UUID()) { + await AssetsManager.setProjectDirectories( + ProjectDirectories( + source: root, + assetsDirectory: root.appendingPathComponent("Assets", isDirectory: true), + userDataDirectory: root.appendingPathComponent("User", isDirectory: true), + cacheDirectory: root.appendingPathComponent("Cache", isDirectory: true) + ) + ) + let plugin = try AdaScriptPlugin(source: """ + var started = false; + var saveStatus = 0; + async func save() { + var result = await Saves.writeAsync("@user://Save/data.txt", "saved in background"); + if (result.isSuccess() && result.committed()) { + saveStatus = 1; + } else { + saveStatus = -1; + } + } + @system class SaveSystem { + @query(AsyncPosition) var positions; + func update(context) { + if (!started) { started = true; Tasks.start(save()); } + for (var row in positions) { row.asyncPosition.value = saveStatus; } + } + } + """) + let world = World(name: "AdaScript background save") + let entity = world.spawn { AsyncPosition(value: 0) } + plugin.setup(in: AppWorlds(main: world)) + let destination = root.appendingPathComponent("User/Save/data.txt") + for _ in 0..<30 where world.get(AsyncPosition.self, from: entity.id)?.value == 0 { + await world.runScheduler(.update) + try await Task.sleep(for: .milliseconds(10)) + } + #expect(world.get(AsyncPosition.self, from: entity.id)?.value == 1) + let content = try String(contentsOf: destination, encoding: .utf8) + #expect(content == "saved in background", Comment(rawValue: "Actual save: \(content)")) + #expect(plugin.diagnostics.isEmpty) + } + } + + @Test("A stream writes large data in bounded chunks and replaces atomically") + @MainActor + func streamsLargeSave() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("AdaScriptAsyncStream-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try await AppWorldsExecutionContext.$currentID.withValue(UUID()) { + let user = root.appendingPathComponent("User", isDirectory: true) + await AssetsManager.setProjectDirectories( + ProjectDirectories(source: root, assetsDirectory: root.appendingPathComponent("Assets"), userDataDirectory: user, cacheDirectory: root.appendingPathComponent("Cache")) + ) + let destination = user.appendingPathComponent("Saves/large.txt") + try FileManager.default.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + try "old".write(to: destination, atomically: true, encoding: .utf8) + let writer = AdaScriptAsyncHost().beginSave("@user://Saves/large.txt") + let chunk = String(repeating: "x", count: 262_144) + for _ in 0..<16 { + let result = try await waitForOperation(writer.append(chunk)) + #expect(result.isSuccess()) + } + let committed = try await waitForOperation(writer.finish()) + #expect(committed.isSuccess() && committed.committed()) + let data = try Data(contentsOf: destination) + #expect(data.count == 4_194_304) + #expect(data.first == 120 && data.last == 120) + } + } + + @Test("AdaScript can await a streamed save") + @MainActor + func streamsSaveFromScript() async throws { + AsyncPosition.registerComponent() + let root = FileManager.default.temporaryDirectory.appendingPathComponent("AdaScriptAsyncScriptStream-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try await AppWorldsExecutionContext.$currentID.withValue(UUID()) { + await AssetsManager.setProjectDirectories( + ProjectDirectories( + source: root, + assetsDirectory: root.appendingPathComponent("Assets"), + userDataDirectory: root.appendingPathComponent("User"), + cacheDirectory: root.appendingPathComponent("Cache") + ) + ) + let plugin = try AdaScriptPlugin(source: """ + var started = false; + var saveStatus = 0; + async func save() { + var writer = Saves.begin("@user://Saves/stream.txt"); + var first = await writer.appendAsync("hello "); + var second = await writer.appendAsync("world"); + var last = await writer.finishAsync(); + if (first.isSuccess() && second.isSuccess() && last.committed()) { saveStatus = 1; } + else { saveStatus = -1; } + } + @system class SaveSystem { + @query(AsyncPosition) var positions; + func update(context) { + if (!started) { started = true; Tasks.start(save()); } + for (var row in positions) { row.asyncPosition.value = saveStatus; } + } + } + """) + let world = World(name: "AdaScript streamed save") + let entity = world.spawn { AsyncPosition(value: 0) } + plugin.setup(in: AppWorlds(main: world)) + for _ in 0..<40 where world.get(AsyncPosition.self, from: entity.id)?.value == 0 { + await world.runScheduler(.update) + try await Task.sleep(for: .milliseconds(10)) + } + #expect(world.get(AsyncPosition.self, from: entity.id)?.value == 1) + let text = try String(contentsOf: root.appendingPathComponent("User/Saves/stream.txt"), encoding: .utf8) + #expect(text == "hello world") + #expect(plugin.diagnostics.isEmpty) + } + } + + @Test("Cancelling a streamed save retains the previous file") + @MainActor + func cancelsStreamWithoutCommit() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("AdaScriptAsyncStreamCancel-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try await AppWorldsExecutionContext.$currentID.withValue(UUID()) { + let user = root.appendingPathComponent("User", isDirectory: true) + await AssetsManager.setProjectDirectories( + ProjectDirectories(source: root, assetsDirectory: root.appendingPathComponent("Assets"), userDataDirectory: user, cacheDirectory: root.appendingPathComponent("Cache")) + ) + let destination = user.appendingPathComponent("Saves/data.txt") + try FileManager.default.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + try "previous".write(to: destination, atomically: true, encoding: .utf8) + let writer = AdaScriptAsyncHost().beginSave("@user://Saves/data.txt") + #expect(try await waitForOperation(writer.append("new data")).isSuccess()) + writer.cancel() + #expect(writer.append("late chunk").result().errorCode() == "cancelled") + try await Task.sleep(for: .milliseconds(20)) + #expect(try String(contentsOf: destination, encoding: .utf8) == "previous") + } + } + + @Test("An asset loads asynchronously while systems keep updating") + @MainActor + func loadsAssetInBackground() async throws { + AsyncPosition.registerComponent() + AssetsManager.registerAssetType(AsyncTextAsset.self) + let root = FileManager.default.temporaryDirectory.appendingPathComponent("AdaScriptAsyncLoad-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try await AppWorldsExecutionContext.$currentID.withValue(UUID()) { + await AssetsManager.setProjectDirectories( + ProjectDirectories( + source: root, + assetsDirectory: root.appendingPathComponent("Assets", isDirectory: true), + userDataDirectory: root.appendingPathComponent("User", isDirectory: true), + cacheDirectory: root.appendingPathComponent("Cache", isDirectory: true) + ) + ) + try await AssetsManager.save(AsyncTextAsset(value: "shop catalog"), at: "@res://Catalog/shop.asynctext") + let plugin = try AdaScriptPlugin(source: """ + var started = false; + var loadStatus = 0; + async func loadShop() { + var result = await Assets.loadAsync("@res://Catalog/shop.asynctext"); + if (result.isSuccess()) { + var saved = await Assets.saveAsync(result.value(), "@user://Copied/shop.asynctext"); + if (saved.isSuccess()) { loadStatus = 1; } else { loadStatus = -2; } + } else { loadStatus = -1; } + } + @system class ShopSystem { + @query(AsyncPosition) var positions; + func update(context) { + if (!started) { started = true; Tasks.start(loadShop()); } + for (var row in positions) { row.asyncPosition.value = loadStatus; } + } + } + """) + let world = World(name: "AdaScript async shop load") + let entity = world.spawn { AsyncPosition(value: 0) } + plugin.setup(in: AppWorlds(main: world)) + for _ in 0..<4 { await world.runScheduler(.update) } + #expect(world.get(AsyncPosition.self, from: entity.id)?.value == 0) + for _ in 0..<30 where world.get(AsyncPosition.self, from: entity.id)?.value == 0 { + await world.runScheduler(.update) + try await Task.sleep(for: .milliseconds(10)) + } + #expect(world.get(AsyncPosition.self, from: entity.id)?.value == 1) + #expect(plugin.diagnostics.isEmpty) + let copied = try await AssetsManager.load(AsyncTextAsset.self, at: "@user://Copied/shop.asynctext") + #expect(copied.asset.value == "shop catalog") + } + } + + @Test("A UI action resumes after confirmation without blocking the view") + @MainActor + func resumesViewActionAfterConfirmation() async throws { + let sources = [ + AdaScriptSource(path: "Confirmation.ada", source: """ + @view class ConfirmationView { + var label = "idle"; + var answer = Tasks.promise(); + + async func requestConfirmation() { + label = "waiting"; + var confirmed = await answer; + if (confirmed) { label = "confirmed"; } + else { label = "cancelled"; } + } + + func ask() { Tasks.start(self.requestConfirmation()); } + func confirm() { answer.complete(true); } + func body() { Text(label); } + } + """) + ] + let runtime = try AdaScriptViewModuleRuntime(sources: sources, views: AdaScriptViewScanner.declarations(in: sources)) + let storage = try runtime.makeStorage(identifier: "ConfirmationView") + try storage.updateEnvironment([:]) + try storage.perform(action: "ask") + for _ in 0..<30 { + await Task.yield() + try storage.updateEnvironment([:]) + if case .text("waiting") = storage.model?.content { break } + } + guard case .text("waiting") = storage.model?.content else { + Issue.record("Confirmation coroutine did not suspend") + return + } + try storage.perform(action: "confirm") + try storage.perform(action: "confirm") + for _ in 0..<30 { + await Task.yield() + try storage.updateEnvironment([:]) + if case .text("confirmed") = storage.model?.content { break } + } + guard case .text("confirmed") = storage.model?.content else { + Issue.record("Confirmation coroutine did not resume") + return + } + } + + @Test("Disposing a view cancels its suspended tasks") + @MainActor + func cancelsViewOwnedTasks() async throws { + let sources = [ + AdaScriptSource(path: "Pending.ada", source: """ + @view class PendingView { + var answer = Tasks.promise(); + async func waitForever() { await answer; } + func ask() { Tasks.start(self.waitForever()); } + func body() { Text("Pending"); } + } + """) + ] + let runtime = try AdaScriptViewModuleRuntime(sources: sources, views: AdaScriptViewScanner.declarations(in: sources)) + var storage: AdaScriptViewStorage? = try runtime.makeStorage(identifier: "PendingView") + try storage?.updateEnvironment([:]) + try storage?.perform(action: "ask") + #expect(runtime.activeTaskCount > 0) + weak var weakStorage = storage + storage = nil + for _ in 0..<10 where weakStorage != nil { await Task.yield() } + #expect(weakStorage == nil) + #expect(runtime.activeTaskCount == 0) + } + + @Test("Retiring a view generation discards its coroutine") + @MainActor + func cancelsRetiredViewTasks() throws { + let sources = [ + AdaScriptSource(path: "Reload.ada", source: """ + @view class ReloadView { + var pending = Tasks.promise(); + async func wait() { await pending; } + func begin() { Tasks.start(self.wait()); } + func body() { Text("Active"); } + } + """) + ] + let runtime = try AdaScriptViewModuleRuntime(sources: sources, views: AdaScriptViewScanner.declarations(in: sources)) + let storage = try runtime.makeStorage(identifier: "ReloadView") + try storage.updateEnvironment([:]) + try storage.perform(action: "begin") + #expect(runtime.activeTaskCount == 1) + runtime.retire() + #expect(runtime.activeTaskCount == 0) + #expect(throws: AdaScriptError.self) { try storage.perform(action: "begin") } + } + + @Test("A query row cannot be used after suspension") + @MainActor + func rejectsBorrowedRowAfterAwait() async throws { + AsyncPosition.registerComponent() + let plugin = try AdaScriptPlugin(source: """ + var started = false; + var retained = null; + async func writeLater() { + await Tasks.nextFrame(); + retained.asyncPosition.value = 9; + } + @system class BorrowSystem { + @query(AsyncPosition) var positions; + func update(context) { + if (!started) { + started = true; + for (var row in positions) { + retained = row; + Tasks.start(writeLater()); + } + } + } + } + """) + let world = World(name: "AdaScript borrowed row test") + let entity = world.spawn { AsyncPosition(value: 3) } + plugin.setup(in: AppWorlds(main: world)) + for _ in 0..<5 { await world.runScheduler(.update) } + #expect(world.get(AsyncPosition.self, from: entity.id)?.value == 3) + #expect(plugin.diagnostics.contains(where: { $0.contains("no longer valid") })) + } + + @Test("Destroying a world cancels its pending AdaScript tasks") + @MainActor + func cancelsWorldOwnedTasks() async throws { + let plugin = try AdaScriptPlugin(source: """ + var started = false; + var signal = Tasks.promise(); + async func waitForSignal() { await signal; } + @system class PendingSystem { + func update(context) { + if (!started) { started = true; Tasks.start(waitForSignal()); } + } + } + """) + let world = World(name: "AdaScript cancelled world") + let app = AppWorlds(main: world) + plugin.setup(in: app) + await world.runScheduler(.update) + #expect(plugin.activeAsyncTaskCount == 1) + plugin.destroy(for: app) + #expect(plugin.activeAsyncTaskCount == 0) + } + + @Test("A failed coroutine quarantines its VM with a task diagnostic") + @MainActor + func reportsCoroutineFailure() async throws { + AsyncPosition.registerComponent() + let plugin = try AdaScriptPlugin(source: """ + var started = false; + var marker = 0; + async func fail() { Fiber.abort("expected coroutine failure"); } + async func succeed() { marker = 1; } + @system class FailureSystem { + @query(AsyncPosition) var positions; + func update(context) { + if (!started) { + started = true; + Tasks.start(fail()); + Tasks.start(succeed()); + } + for (var row in positions) { row.asyncPosition.value = marker; } + } + } + """) + let world = World(name: "AdaScript failed coroutine") + let entity = world.spawn { AsyncPosition(value: 0) } + plugin.setup(in: AppWorlds(main: world)) + await world.runScheduler(.update) + await world.runScheduler(.update) + await world.runScheduler(.update) + #expect(plugin.diagnostics.contains(where: { $0.contains("expected coroutine failure") })) + #expect(plugin.diagnostics.contains(where: { $0.contains("ADASCRIPT_VM_ABORTED") && $0.contains("trace=") })) + #expect(world.get(AsyncPosition.self, from: entity.id)?.value == 0) + } +} diff --git a/Tests/AdaScriptingTests/AdaScriptNonSendableTests.swift b/Tests/AdaScriptingTests/AdaScriptNonSendableTests.swift new file mode 100644 index 000000000..d26e517a5 --- /dev/null +++ b/Tests/AdaScriptingTests/AdaScriptNonSendableTests.swift @@ -0,0 +1,118 @@ +@testable import AdaApp +import AdaECS +@testable import AdaScripting +import Testing + +@Suite("AdaScript suspension policy", .serialized) +struct AdaScriptNonSendableTests { + @Test("Annotated engine callbacks remain synchronous") + @MainActor + func rejectsAsyncLifecycleCallbacks() throws { + #expect(throws: AdaScriptError.self) { + try AdaScriptPlugin(source: "@system class S { async func update(anyName) {} }") + } + let sources = [AdaScriptSource(path: "View.ada", source: "@view class V { async func body() { Text(\"x\"); } }")] + #expect(throws: AdaScriptError.self) { + try AdaScriptViewModuleRuntime(sources: sources, views: AdaScriptViewScanner.declarations(in: sources)) + } + let scriptable = AdaScriptObjectSchema( + identifier: "test.async-scriptable-callback", + className: "ScriptableValue", + version: 1, + aliases: [], + fields: [:] + ) + #expect(throws: AdaScriptError.self) { + try AdaScriptObjectRegistration.register( + schemas: [scriptable], + sources: [ + AdaScriptSource( + path: "Scriptable.ada", + source: "@scriptable(id: \"test.async-scriptable-callback\", version: 1)\nclass ScriptableValue { async func ready(anyName) {} }" + ) + ], + moduleName: "AsyncScriptableCallbackTest" + ) + } + } + + @Test("Borrowed context and query aliases cannot enter a coroutine") + @MainActor + func rejectsBorrowedCaptures() async throws { + AsyncPosition.registerComponent() + let plugin = try AdaScriptPlugin(source: """ + var attempted = false; + async func waitFor(value) { await Tasks.nextFrame(); } + @system class BorrowSystem { + @query(AsyncPosition) var positions; + func update(anyName) { + if (!attempted) { + attempted = true; + var renamed = anyName; + Tasks.start(waitFor(renamed)); + for (var row in positions) { Tasks.start(waitFor([row])); } + } + } + } + """) + let world = World(name: "Borrowed suspension test") + world.spawn { AsyncPosition(value: 1) } + plugin.setup(in: AppWorlds(main: world)) + await world.runScheduler(.update) + #expect(plugin.activeAsyncTaskCount == 0) + #expect(plugin.diagnostics.contains(where: { $0.contains("ADASCRIPT_NONSENDABLE") && $0.contains("AdaSystemContext") })) + #expect(plugin.diagnostics.contains(where: { $0.contains("ADASCRIPT_NONSENDABLE") && $0.contains("AdaQueryRow") })) + } + + @Test("Script-owned @nonsendable types are rejected even through untyped parameters") + @MainActor + func rejectsScriptMarkedType() async throws { + let plugin = try AdaScriptPlugin(source: """ + @nonsendable class BorrowedValue {} + var attempted = false; + async func waitFor(value) { await Tasks.nextFrame(); } + @system class BorrowSystem { + func update(anyName) { + if (!attempted) { + attempted = true; + Tasks.start(waitFor(BorrowedValue())); + } + } + } + """) + let world = World(name: "Marked suspension test") + plugin.setup(in: AppWorlds(main: world)) + await world.runScheduler(.update) + #expect(plugin.activeAsyncTaskCount == 0) + #expect(plugin.diagnostics.contains(where: { $0.contains("@nonsendable type 'BorrowedValue'") })) + } + + @Test("An imported @nonsendable type protects typed async parameters") + func rejectsImportedMarkedType() { + #expect(throws: AdaScriptError.self) { + try AdaScriptPlugin(sources: [ + AdaScriptSource(path: "Main.ada", source: """ + import { BorrowedValue } from "./Borrowed"; + async func inspect(value: BorrowedValue) { await Tasks.nextFrame(); } + @system class S { func update(anyName) {} } + """), + AdaScriptSource(path: "Borrowed.ada", source: "@nonsendable class BorrowedValue {}") + ], name: "ImportedBorrowedType") + } + } + + @Test("A promise cannot deliver a borrowed callback value") + @MainActor + func rejectsBorrowedPromiseResult() async throws { + let plugin = try AdaScriptPlugin(source: """ + var signal = Tasks.promise(); + @system class S { + func update(anyName) { signal.complete(anyName); } + } + """) + let world = World(name: "Borrowed promise result") + plugin.setup(in: AppWorlds(main: world)) + await world.runScheduler(.update) + #expect(plugin.diagnostics.contains(where: { $0.contains("ADASCRIPT_NONSENDABLE") && $0.contains("AdaSystemContext") })) + } +} diff --git a/Tests/AdaUITests/EnvironmentPropagationTests.swift b/Tests/AdaUITests/EnvironmentPropagationTests.swift index ff03f9760..f871ba88e 100644 --- a/Tests/AdaUITests/EnvironmentPropagationTests.swift +++ b/Tests/AdaUITests/EnvironmentPropagationTests.swift @@ -3,12 +3,12 @@ // AdaEngine // -import Testing -@testable import AdaUI @testable import AdaPlatform +@testable import AdaUI @testable import AdaUtils -import Observation import Math +import Observation +import Testing // MARK: - Test environment keys @@ -29,10 +29,13 @@ private struct TestThemeKey: ThemeKey { } extension EnvironmentValues { + // The same-file view fixtures access these keys outside this extension. + // swiftlint:disable:next strict_fileprivate fileprivate var testCounter: Int { get { self[CounterKey.self] } set { self[CounterKey.self] = newValue } } + // swiftlint:disable:next strict_fileprivate fileprivate var testLabel: String { get { self[LabelKey.self] } set { self[LabelKey.self] = newValue } @@ -71,7 +74,6 @@ private struct ObservableEnvironmentView: View { @MainActor @Suite("Environment propagation optimizations") struct EnvironmentPropagationTests { - init() async throws { try Application.prepareForTest() } @@ -96,16 +98,30 @@ struct EnvironmentPropagationTests { @Test("@Environment observable subscribes only to observable storage") func observableEnvironmentCapturesObservableStorageKeyID() { - var capturedIDs = Set() - EnvironmentValues._recordKeyAccess = { capturedIDs.insert($0) } - _ = EnvironmentValues().observableStorage - EnvironmentValues._recordKeyAccess = nil + let recorder = EnvironmentKeyAccessRecorder() + EnvironmentValues.$_recordKeyAccess.withValue(recorder) { + _ = EnvironmentValues().observableStorage + } let wrapper = Environment(ObservableEnvironmentModel.self) - #expect(wrapper.container.subscribedKeyIDs == capturedIDs) + #expect(wrapper.container.subscribedKeyIDs == recorder.capturedKeys) #expect(!wrapper.container.subscribedKeyIDs.contains(ObjectIdentifier(CounterKey.self))) } + @Test("Concurrent environment reads do not enter another task's key recorder") + func keyRecorderIsTaskLocal() async { + let recorder = EnvironmentKeyAccessRecorder() + await EnvironmentValues.$_recordKeyAccess.withValue(recorder) { + // A detached worker deliberately does not inherit this task-local recorder. + let worker = Task.detached { + for _ in 0..<1_000 { _ = EnvironmentValues().testLabel } + } + for _ in 0..<1_000 { _ = EnvironmentValues().testCounter } + await worker.value + } + #expect(recorder.capturedKeys == Set([ObjectIdentifier(CounterKey.self)])) + } + // MARK: version guard @Test("updateEnvironment is a no-op when version is unchanged") @@ -317,5 +333,4 @@ struct EnvironmentPropagationTests { #expect(probe.values.last == 1) withExtendedLifetime(tester) {} } - }