From d4ff998ec4c2038e73f1c41c3d1abca4910319c6 Mon Sep 17 00:00:00 2001 From: SpectralDragon Date: Thu, 24 Sep 2026 13:48:27 +0300 Subject: [PATCH 1/4] Add AdaScript async tasks and awaitable operations --- ...15-adascript-async-tasks-and-coroutines.md | 319 ++++++++++ Documentation/ArchitectureDecisions/README.md | 3 + Documentation/DocCTheme/theme.js | 2 +- .../GravityLanguageCore/GravityBuiltins.swift | 5 + .../GravityDocumentAnalyzer.swift | 14 +- .../GravitySemanticAnalyzer.swift | 2 +- .../GravityLanguageSemanticTests.swift | 10 + .../AdaScriptAssetsLowerer.swift | 27 +- .../AdaScriptAsyncLowerer.swift | 233 +++++++ .../AdaScripting/AdaScriptAssetsBridge.swift | 117 +++- .../AdaScripting/AdaScriptAsyncBridge.swift | 297 +++++++++ .../AdaScripting/AdaScriptSaveWriter.swift | 184 ++++++ .../AdaScripting/AdaScriptTaskRuntime.swift | 354 +++++++++++ Sources/AdaScripting/AdaScriptView.swift | 130 +++- .../AdaScripting.docc/AdaScriptLanguage.md | 72 +++ .../AnnotatedGravityQueryView.swift | 58 +- .../AnnotatedGravityResourceView.swift | 18 +- .../AnnotatedGravityScriptPlugin.swift | 119 +++- .../AdaScripting/GravityScriptModule.swift | 74 ++- .../AdaScriptAsyncLowererTests.swift | 73 +++ .../AdaScriptAsyncTestSupport.swift | 31 + .../AdaScriptAsyncTests.swift | 583 ++++++++++++++++++ 22 files changed, 2657 insertions(+), 68 deletions(-) create mode 100644 Documentation/ArchitectureDecisions/0015-adascript-async-tasks-and-coroutines.md create mode 100644 Sources/AdaScriptCompilerCore/AdaScriptAsyncLowerer.swift create mode 100644 Sources/AdaScripting/AdaScriptAsyncBridge.swift create mode 100644 Sources/AdaScripting/AdaScriptSaveWriter.swift create mode 100644 Sources/AdaScripting/AdaScriptTaskRuntime.swift create mode 100644 Tests/AdaScriptingTests/AdaScriptAsyncLowererTests.swift create mode 100644 Tests/AdaScriptingTests/AdaScriptAsyncTestSupport.swift create mode 100644 Tests/AdaScriptingTests/AdaScriptAsyncTests.swift 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..7c8439d5c --- /dev/null +++ b/Documentation/ArchitectureDecisions/0015-adascript-async-tasks-and-coroutines.md @@ -0,0 +1,319 @@ +# 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; + compiler diagnostics for the basic async effect and callback-context cases. +- [x] AdaUI action continuation, view disposal, and view-generation retirement; + Editor keyword, declaration, and completion support. + +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 methods, aliases, + containers, and imported 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. + +### 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/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.. + let body: Range + let name: String + let parameters: String + let arguments: [String] + let receiver: String + let line: Int + } + + public static func globalFunctionNames(source: String, path: String) throws -> Set { + var lexer = Lexer(source: source) + let tokens = lexer.lex() + let characters = Array(source) + var names = Set() + for index in tokens.indices where tokens[index].text == "async" { + let declaration = try parse(at: index, tokens: tokens, characters: characters, path: path) + if declaration.receiver.isEmpty { names.insert(declaration.name) } + } + return names + } + + public static func lower(source: String, path: String, globalAsyncNames: Set = []) throws -> String { + var lexer = Lexer(source: source) + let tokens = lexer.lex() + let characters = Array(source) + var declarations: [Declaration] = [] + var covered = Set() + + for index in tokens.indices where tokens[index].text == "async" { + guard !covered.contains(index) else { continue } + let declaration = try parse(at: index, tokens: tokens, characters: characters, path: path) + declarations.append(declaration) + for tokenIndex in index.. $1.range.lowerBound }) { + let body = try lowerAwaits( + String(characters[declaration.body]), + path: path, + firstLine: declaration.line + ) + let implementation = "__ada_async_impl_\(declaration.name)_\(declaration.range.lowerBound)" + let call = "\(declaration.receiver)\(implementation)(\(declaration.arguments.joined(separator: ", ")))" + let capturedReceiver = declaration.receiver.isEmpty ? "" : "var __ada_receiver = self;\n " + let replacement = """ + func \(declaration.name)(\(declaration.parameters)) { + \(capturedReceiver)var __ada_task = __AdaTask(); + __ada_task.fiber = Fiber.create({ + __ada_task.value = \(call); + __ada_task.done = true; + }); + return __ada_task; + } + func \(implementation)(\(declaration.parameters)) \(body) + """ + result.replaceSubrange(declaration.range, with: Array(replacement)) + } + return String(result) + } + + private static func parse(at index: Int, tokens: [Token], characters: [Character], path: String) throws -> Declaration { + 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 closeParameters = closing(index + 3, tokens: tokens, open: "(", close: ")"), + tokens.indices.contains(closeParameters + 1), tokens[closeParameters + 1].text == "{", + let closeBody = closing(closeParameters + 1, tokens: tokens, open: "{", close: "}") else { + throw error(path, token.line, "expected 'async func name(...) { ... }'") + } + let method = try isMethod(at: index, tokens: tokens, path: path) + let name = tokens[index + 2].text + if method && ["update", "fixedUpdate", "ready", "event", "body", "destroy"].contains(name) { + throw error(path, token.line, "engine lifecycle method '\(name)' must remain synchronous") + } + let arguments = try parameterNames(Array(tokens[(index + 4).., + tokens: [Token], + path: String + ) throws { + let globalNames = globalAsyncNames.union(declarations.filter { $0.receiver.isEmpty }.map(\.name)) + guard !globalNames.isEmpty else { + return + } + for index in tokens.indices where globalNames.contains(tokens[index].text) { + guard tokens.indices.contains(index + 1), tokens[index + 1].text == "(", + index > 0, tokens[index - 1].text != "func", tokens[index - 1].text != "." else { continue } + let awaited = tokens[index - 1].text == "await" + let started = index >= 4 && tokens[index - 1].text == "(" && tokens[index - 2].text == "start" + && tokens[index - 3].text == "." && tokens[index - 4].text == "Tasks" + guard awaited || started else { + throw error(path, tokens[index].line, "async call '\(tokens[index].text)' requires await or Tasks.start") + } + if let close = closing(index + 1, tokens: tokens, open: "(", close: ")"), close > index + 2, + tokens[(index + 2).. Bool { + var scopes: [Bool] = [] + var segment = 0 + for cursor in 0.. [String] { + guard !tokens.isEmpty else { + return [] + } + var names: [String] = [] + var start = 0 + var depth = 0 + for cursor in 0...tokens.count { + if cursor < tokens.count { + if ["(", "[", "{"].contains(tokens[cursor].text) { depth += 1 } + if [")", "]", "}"].contains(tokens[cursor].text) { depth -= 1 } + } + guard cursor == tokens.count || (tokens[cursor].text == "," && depth == 0) else { continue } + guard start < cursor, tokens[start].kind == .identifier else { + throw error(path, line, "async parameters require named identifiers") + } + names.append(tokens[start].text) + start = cursor + 1 + } + return names + } + + private static func lowerAwaits(_ source: String, path: String, firstLine: Int) throws -> String { + var lexer = Lexer(source: source) + let tokens = lexer.lex() + var result = Array(source) + var replacements: [(Range, String)] = [] + for index in tokens.indices where tokens[index].text == "await" { + guard let end = awaitTargetEnd(after: index, tokens: tokens) else { + throw error(path, firstLine + tokens[index].line - 1, "await requires a task expression") + } + if tokens[(index + 1)...end].contains(where: { $0.text == "await" }) { + throw error(path, firstLine + tokens[index].line - 1, "move nested await expressions into separate statements") + } + let target = String(Array(source)[tokens[index + 1].startOffset.. $1.0.lowerBound }) { + result.replaceSubrange(range, with: Array(replacement)) + } + return String(result) + } + + private static func awaitTargetEnd(after index: Int, tokens: [Token]) -> Int? { + var cursor = index + 1 + guard tokens.indices.contains(cursor), tokens[cursor].kind == .identifier else { + return nil + } + while tokens.indices.contains(cursor + 2), tokens[cursor + 1].text == ".", tokens[cursor + 2].kind == .identifier { + cursor += 2 + } + if tokens.indices.contains(cursor + 1), tokens[cursor + 1].text == "(" { + guard let end = closing(cursor + 1, tokens: tokens, open: "(", close: ")") else { + return nil + } + cursor = end + } + return cursor + } + + private static func closing(_ opening: Int, tokens: [Token], open: String, close: String) -> Int? { + var depth = 0 + for index in opening.. AdaScriptAsyncSyntaxError { + .init(path: path, line: line, message: message) + } +} diff --git a/Sources/AdaScripting/AdaScriptAssetsBridge.swift b/Sources/AdaScripting/AdaScriptAssetsBridge.swift index 00cc8a358..eee6709af 100644 --- a/Sources/AdaScripting/AdaScriptAssetsBridge.swift +++ b/Sources/AdaScripting/AdaScriptAssetsBridge.swift @@ -1,4 +1,6 @@ +@_spi(Internal) import AdaApp import AdaAssets +import Foundation import Gravity /// Synchronous VM facade. AdaAssets keeps the actual typed handle and cache identity. @@ -10,6 +12,12 @@ final class AdaScriptAssetsBridge: @unchecked Sendable { @GSExportableIgnore private var handles: [String: any AnyAssetHandleInfo] = [:] + @GSExportableIgnore + private let handlesLock = NSLock() + + @GSExportableIgnore + var onWake: (@Sendable () -> 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..817344ab4 --- /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 { + @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 { + @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/AdaScriptSaveWriter.swift b/Sources/AdaScripting/AdaScriptSaveWriter.swift new file mode 100644 index 000000000..9d5c2aa32 --- /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 { + @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/AdaScriptTaskRuntime.swift b/Sources/AdaScripting/AdaScriptTaskRuntime.swift new file mode 100644 index 000000000..97b78de1c --- /dev/null +++ b/Sources/AdaScripting/AdaScriptTaskRuntime.swift @@ -0,0 +1,354 @@ +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 let maximumTasks = 1024 + + @GSExportableIgnore + private let maximumResumesPerDispatch = 256 + + @GSExportableIgnore + var onWake: (@Sendable () -> Void)? + + @GSExportableIgnore + static func make( + virtualMachine: GravityVirtualMachine, + reportDiagnostic: @escaping @Sendable (String) -> Void + ) -> AdaScriptTaskRuntime { + let runtime = AdaScriptTaskRuntime() + runtime.virtualMachine = virtualMachine + runtime.reportDiagnostic = reportDiagnostic + return runtime + } + + /// 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; } + self.value = value; + done = true; + __adaTasks.wake(); + return true; + } + + 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.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..f1606ef94 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.") @@ -251,8 +263,17 @@ 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) + let taskRuntime = AdaScriptTaskRuntime.make(virtualMachine: virtualMachine, reportDiagnostic: delegate.append) + let asyncHost = AdaScriptAsyncHost() + asyncHost.onWake = { taskRuntime.wake() } + asyncHost.ownerProvider = { taskRuntime.currentOwnerID } + try virtualMachine.bindClass(with: AdaScriptTaskRuntime.self) + try virtualMachine.bindClass(with: AdaScriptAsyncResult.self) + try virtualMachine.bindClass(with: AdaScriptAsyncOperation.self) + try virtualMachine.bindClass(with: AdaScriptAsyncHost.self) + try virtualMachine.bindClass(with: AdaScriptSaveWriter.self) try virtualMachine.bindClass(with: AdaScriptViewBridge.self) try AdaScriptComponentRuntime.bind( to: virtualMachine, @@ -266,8 +287,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 +307,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 +319,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 +427,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 +456,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 +464,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 +484,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 +518,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 +542,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/AdaScripting.docc/AdaScriptLanguage.md b/Sources/AdaScripting/AdaScripting.docc/AdaScriptLanguage.md index c98be54af..e02096cf0 100644 --- a/Sources/AdaScripting/AdaScripting.docc/AdaScriptLanguage.md +++ b/Sources/AdaScripting/AdaScripting.docc/AdaScriptLanguage.md @@ -292,6 +292,78 @@ 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. + +`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..92ac6257b 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 { 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 } + 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,7 +159,11 @@ 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] } } @@ -132,17 +173,20 @@ final class AnnotatedGravityComponentView: @unchecked Sendable { 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..3859a7c66 100644 --- a/Sources/AdaScripting/AnnotatedGravityResourceView.swift +++ b/Sources/AdaScripting/AnnotatedGravityResourceView.swift @@ -7,6 +7,7 @@ final class AnnotatedGravityResourceView: @unchecked Sendable { 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..5b02fb1a4 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] @@ -163,6 +168,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 +186,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 +324,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 +453,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 +475,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 +517,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 +558,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 +599,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 +613,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,6 +639,17 @@ private final class AnnotatedGravityRuntime: @unchecked Sendable { let virtualMachine = GravityVirtualMachine(settings: .init(), delegate: delegate) self.virtualMachine = virtualMachine + let taskRuntime = AdaScriptTaskRuntime.make(virtualMachine: virtualMachine, reportDiagnostic: delegate.append) + self.taskRuntime = taskRuntime + let asyncHost = AdaScriptAsyncHost() + self.asyncHost = asyncHost + asyncHost.onWake = { taskRuntime.wake() } + asyncHost.ownerProvider = { taskRuntime.currentOwnerID } + try virtualMachine.bindClass(with: AdaScriptTaskRuntime.self) + try virtualMachine.bindClass(with: AdaScriptAsyncResult.self) + try virtualMachine.bindClass(with: AdaScriptAsyncOperation.self) + try virtualMachine.bindClass(with: AdaScriptAsyncHost.self) + try virtualMachine.bindClass(with: AdaScriptSaveWriter.self) try virtualMachine.bindClass(with: AnnotatedGravitySystemContext.self) try virtualMachine.bindClass(with: AdaScriptInputBridge.self) try virtualMachine.bindClass(with: AnnotatedGravityWorldContext.self) @@ -600,7 +670,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 +683,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 +735,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 +792,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 +875,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/GravityScriptModule.swift b/Sources/AdaScripting/GravityScriptModule.swift index 5ff070c4e..a953b0993 100644 --- a/Sources/AdaScripting/GravityScriptModule.swift +++ b/Sources/AdaScripting/GravityScriptModule.swift @@ -52,32 +52,21 @@ struct ResolvedGravityScriptModule: Sendable { 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 +79,58 @@ enum GravityScriptModuleResolver { for root in roots { try visit( root, - parsedSources: parsedSources, + parsedSources: preliminarySources, states: &states, stack: &stack, orderedPaths: &orderedPaths ) } + var globalAsyncNames = Set() + for path in orderedPaths { + guard let source = sourceByPath[path] else { + continue + } + do { + globalAsyncNames.formUnion(try AdaScriptAsyncLowerer.globalFunctionNames(source: source.source, path: path)) + } catch let error as AdaScriptAsyncSyntaxError { + throw AdaScriptError.invalidManifest(error.description) + } + } + + let reachablePaths = Set(orderedPaths) + 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 loweredAsyncSource: String + do { + loweredAsyncSource = try AdaScriptAsyncLowerer.lower( + source: loweredAssetsSource, + path: path, + globalAsyncNames: reachablePaths.contains(path) ? globalAsyncNames : [] + ) + } catch let error as AdaScriptAsyncSyntaxError { + throw AdaScriptError.invalidManifest(error.description) + } + let schemas = try AdaScriptSchemaParser.parse(sources: [source]) + let loweredComponentSource = AdaScriptComponentLowerer.lower( + source: loweredAsyncSource, + 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() { diff --git a/Tests/AdaScriptingTests/AdaScriptAsyncLowererTests.swift b/Tests/AdaScriptingTests/AdaScriptAsyncLowererTests.swift new file mode 100644 index 000000000..9188c6dd6 --- /dev/null +++ b/Tests/AdaScriptingTests/AdaScriptAsyncLowererTests.swift @@ -0,0 +1,73 @@ +import AdaScriptCompilerCore +import Testing + +@Suite("AdaScript async syntax") +struct AdaScriptAsyncLowererTests { + @Test("Preserves locals and continuation calls") + func lowersAsyncFunctionAndAwait() throws { + let source = try AdaScriptAsyncLowerer.lower(source: """ + async func answer(value) { + var confirmed = await wait_confirmation(value); + return confirmed; + } + """, path: "Answer.ada") + #expect(source.contains("Fiber.create")) + #expect(source.contains("__adaAwait(wait_confirmation(value))")) + #expect(source.contains("__ada_async_impl_answer")) + } + + @Test("Rejects await outside an async function") + func rejectsSynchronousAwait() { + #expect(throws: AdaScriptAsyncSyntaxError.self) { + try AdaScriptAsyncLowerer.lower(source: "func update() { await Tasks.nextFrame(); }", path: "Invalid.ada") + } + } + + @Test("Rejects an async lifecycle callback and borrowed context parameter") + func rejectsUnsafeSignatures() { + #expect(throws: AdaScriptAsyncSyntaxError.self) { + try AdaScriptAsyncLowerer.lower(source: "class S { async func update(context) {} }", path: "Invalid.ada") + } + #expect(throws: AdaScriptAsyncSyntaxError.self) { + try AdaScriptAsyncLowerer.lower(source: "async func hold(context) { await Tasks.nextFrame(); }", path: "Invalid.ada") + } + } + + @Test("Rejects unawaited calls and borrowed arguments") + func rejectsUnsafeCalls() { + #expect(throws: AdaScriptAsyncSyntaxError.self) { + try AdaScriptAsyncLowerer.lower(source: """ + async func work() { return 1; } + func start() { work(); } + """, path: "Invalid.ada") + } + #expect(throws: AdaScriptAsyncSyntaxError.self) { + try AdaScriptAsyncLowerer.lower(source: """ + async func work(value) { return value; } + @system class S { + func update(context) { Tasks.start(work(context)); } + } + """, path: "Invalid.ada") + } + } + + @Test("A typed asset load keeps its type when awaited") + func lowersTypedAsyncAssetLoad() throws { + let assets = AdaScriptAssetsLowerer.lower(source: "var item: ShopItem = await Assets.loadAsync(\"@res://shop.item\");") + let lowered = try AdaScriptAsyncLowerer.lower( + source: "async func load() { \(assets) }", + path: "Shop.ada" + ) + #expect(lowered.contains("loadTypedAsync")) + #expect(lowered.contains("__adaAwait(__adaTaskFromOperation")) + } + + @Test("Rejects an unawaited async call imported from another source") + func rejectsCrossSourceUnawaitedCall() throws { + let names = try AdaScriptAsyncLowerer.globalFunctionNames(source: "async func loadShop() {}", path: "Shop.ada") + #expect(names == ["loadShop"]) + #expect(throws: AdaScriptAsyncSyntaxError.self) { + try AdaScriptAsyncLowerer.lower(source: "func begin() { loadShop(); }", path: "Main.ada", globalAsyncNames: names) + } + } +} 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..dddba8af6 --- /dev/null +++ b/Tests/AdaScriptingTests/AdaScriptAsyncTests.swift @@ -0,0 +1,583 @@ +@_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; + async func writeLater(row) { + await Tasks.nextFrame(); + row.asyncPosition.value = 9; + } + @system class BorrowSystem { + @query(AsyncPosition) var positions; + func update(context) { + if (!started) { + started = true; + for (var row in positions) { Tasks.start(writeLater(row)); } + } + } + } + """) + 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) + } +} From c0179aba5d0e3cf47bcc08b0315160173ed34169 Mon Sep 17 00:00:00 2001 From: SpectralDragon Date: Thu, 24 Sep 2026 15:01:45 +0300 Subject: [PATCH 2/4] Use @nonsendable suspension policy for AdaScript async --- ...15-adascript-async-tasks-and-coroutines.md | 29 +++- .../AdaScriptAsyncLowerer.swift | 137 ++++++++++++++---- .../AdaScriptNonSendableTypes.swift | 46 ++++++ .../AdaScripting/AdaScriptAsyncBridge.swift | 4 +- .../AdaScripting/AdaScriptInputBridge.swift | 2 +- .../AdaScripting/AdaScriptNetworkBridge.swift | 6 +- .../AdaScripting/AdaScriptSaveWriter.swift | 2 +- .../AdaScriptSuspensionPolicy.swift | 84 +++++++++++ .../AdaScripting/AdaScriptTaskRuntime.swift | 23 ++- Sources/AdaScripting/AdaScriptView.swift | 18 ++- .../AdaScripting/AdaScriptViewBridge.swift | 2 +- .../AdaScripting.docc/AdaScriptAnnotations.md | 17 +++ .../AdaScripting.docc/AdaScriptLanguage.md | 8 + .../AnnotatedGravityQueryView.swift | 6 +- .../AnnotatedGravityResourceView.swift | 2 +- .../AnnotatedGravityScriptPlugin.swift | 37 +++-- .../AnnotatedGravityScriptSupport.swift | 2 +- .../AnnotatedGravityWorldContext.swift | 4 +- .../GravityAttachedDataView.swift | 4 +- .../AdaScripting/GravityScriptModule.swift | 39 ++++- .../GravityScriptableObject.swift | 19 ++- .../PropertyWrappers/EnvironmentValues.swift | 20 +-- .../Environment/EnvironmentValues.swift | 28 +++- .../AdaScriptAsyncLowererTests.swift | 32 ++-- .../AdaScriptAsyncTests.swift | 10 +- .../AdaScriptNonSendableTests.swift | 118 +++++++++++++++ .../EnvironmentPropagationTests.swift | 35 +++-- 27 files changed, 617 insertions(+), 117 deletions(-) create mode 100644 Sources/AdaScriptCompilerCore/AdaScriptNonSendableTypes.swift create mode 100644 Sources/AdaScripting/AdaScriptSuspensionPolicy.swift create mode 100644 Tests/AdaScriptingTests/AdaScriptNonSendableTests.swift diff --git a/Documentation/ArchitectureDecisions/0015-adascript-async-tasks-and-coroutines.md b/Documentation/ArchitectureDecisions/0015-adascript-async-tasks-and-coroutines.md index 7c8439d5c..89b2b2017 100644 --- a/Documentation/ArchitectureDecisions/0015-adascript-async-tasks-and-coroutines.md +++ b/Documentation/ArchitectureDecisions/0015-adascript-async-tasks-and-coroutines.md @@ -21,7 +21,8 @@ Implemented and covered by focused tests in this worktree: - [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; - compiler diagnostics for the basic async effect and callback-context cases. + `@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. @@ -31,9 +32,12 @@ Remaining before this ADR is fully implemented: 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 methods, aliases, - containers, and imported declarations; typed result descriptors and source - maps for generated async continuations. +- [ ] 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. +- [ ] Move the async syntax and effect representation into a versioned + `gravity-lang` parser/AST release. The Swift source lowerer is an interim + AdaScript adapter; the dependency is currently pinned to `0.9.9`. - [ ] Engine-owned, incremental snapshots of arbitrary ECS data, beyond the available bounded streaming writer. - [ ] A game time-scale resource, complete Editor diagnostics, and platform @@ -205,6 +209,23 @@ 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 diff --git a/Sources/AdaScriptCompilerCore/AdaScriptAsyncLowerer.swift b/Sources/AdaScriptCompilerCore/AdaScriptAsyncLowerer.swift index 6fa70bc06..05f6175de 100644 --- a/Sources/AdaScriptCompilerCore/AdaScriptAsyncLowerer.swift +++ b/Sources/AdaScriptCompilerCore/AdaScriptAsyncLowerer.swift @@ -8,7 +8,15 @@ public struct AdaScriptAsyncSyntaxError: Error, Sendable, Equatable, CustomStrin public var description: String { "\(path):\(line): \(message)" } } -/// Lowers explicit AdaScript async functions to VM fibers before Gravity compilation. +public struct AdaScriptAsyncDeclaration: Equatable, Sendable { + public let name: String + public let ownerType: String? + public let line: Int +} + +/// Interim adapter for the pinned Gravity 0.9.9 parser, which has no async AST. +/// Borrow rules come from `@nonsendable` type metadata and runtime bridge policy; +/// native async syntax/effects belong in a future versioned Gravity release. public enum AdaScriptAsyncLowerer { private struct Declaration { let range: Range @@ -17,31 +25,71 @@ public enum AdaScriptAsyncLowerer { let parameters: String let arguments: [String] let receiver: String + let ownerType: String? let line: Int } + private struct Parameter { + let name: String + let typeName: String? + } + + private enum Scope { + case type(String) + case other + } + public static func globalFunctionNames(source: String, path: String) throws -> Set { + Set(try declarations(in: source, path: path).compactMap { $0.ownerType == nil ? $0.name : nil }) + } + + public static func declarations( + in source: String, + path: String, + nonSendableTypes: Set = [] + ) throws -> [AdaScriptAsyncDeclaration] { var lexer = Lexer(source: source) let tokens = lexer.lex() let characters = Array(source) - var names = Set() + let markedTypes = try nonSendableTypes.union(AdaScriptNonSendableTypes.declared(in: source, path: path)) + var declarations: [AdaScriptAsyncDeclaration] = [] for index in tokens.indices where tokens[index].text == "async" { - let declaration = try parse(at: index, tokens: tokens, characters: characters, path: path) - if declaration.receiver.isEmpty { names.insert(declaration.name) } + let declaration = try parse( + at: index, + tokens: tokens, + characters: characters, + path: path, + nonSendableTypes: markedTypes + ) + declarations.append( + AdaScriptAsyncDeclaration(name: declaration.name, ownerType: declaration.ownerType, line: declaration.line) + ) } - return names + return declarations } - public static func lower(source: String, path: String, globalAsyncNames: Set = []) throws -> String { + public static func lower( + source: String, + path: String, + globalAsyncNames: Set = [], + nonSendableTypes: Set = [] + ) throws -> String { var lexer = Lexer(source: source) let tokens = lexer.lex() let characters = Array(source) + let markedTypes = try nonSendableTypes.union(AdaScriptNonSendableTypes.declared(in: source, path: path)) var declarations: [Declaration] = [] var covered = Set() for index in tokens.indices where tokens[index].text == "async" { guard !covered.contains(index) else { continue } - let declaration = try parse(at: index, tokens: tokens, characters: characters, path: path) + let declaration = try parse( + at: index, + tokens: tokens, + characters: characters, + path: path, + nonSendableTypes: markedTypes + ) declarations.append(declaration) for tokenIndex in index.. Declaration { + private static func parse( + at index: Int, + tokens: [Token], + characters: [Character], + path: String, + nonSendableTypes: Set + ) throws -> Declaration { let token = tokens[index] guard tokens.indices.contains(index + 3), tokens[index + 1].text == "func", tokens[index + 2].kind == .identifier, tokens[index + 3].text == "(", @@ -87,22 +151,23 @@ public enum AdaScriptAsyncLowerer { let closeBody = closing(closeParameters + 1, tokens: tokens, open: "{", close: "}") else { throw error(path, token.line, "expected 'async func name(...) { ... }'") } - let method = try isMethod(at: index, tokens: tokens, path: path) + let ownerType = try enclosingType(at: index, tokens: tokens, path: path) let name = tokens[index + 2].text - if method && ["update", "fixedUpdate", "ready", "event", "body", "destroy"].contains(name) { - throw error(path, token.line, "engine lifecycle method '\(name)' must remain synchronous") + if let ownerType, nonSendableTypes.contains(ownerType) { + throw error(path, token.line, "async method captures @nonsendable type '\(ownerType)'") } - let arguments = try parameterNames(Array(tokens[(index + 4).. index + 2, - tokens[(index + 2).. Bool { - var scopes: [Bool] = [] + private static func enclosingType(at index: Int, tokens: [Token], path: String) throws -> String? { + var scopes: [Scope] = [] var segment = 0 for cursor in 0.. [String] { + private static func parameterDeclarations(_ tokens: [Token], path: String, line: Int) throws -> [Parameter] { guard !tokens.isEmpty else { return [] } - var names: [String] = [] + var parameters: [Parameter] = [] var start = 0 var depth = 0 for cursor in 0...tokens.count { @@ -169,10 +242,12 @@ public enum AdaScriptAsyncLowerer { guard start < cursor, tokens[start].kind == .identifier else { throw error(path, line, "async parameters require named identifiers") } - names.append(tokens[start].text) + let typeIndex = tokens[start.. String { 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..? diff --git a/Sources/AdaScripting/AdaScriptSaveWriter.swift b/Sources/AdaScripting/AdaScriptSaveWriter.swift index 9d5c2aa32..b665d49a0 100644 --- a/Sources/AdaScripting/AdaScriptSaveWriter.swift +++ b/Sources/AdaScripting/AdaScriptSaveWriter.swift @@ -5,7 +5,7 @@ import Gravity @GSExportable("AdaSaveWriter") // VM-owned configuration is published once; cancellation is locked and file // operations are serialized by AdaScriptSaveStreamState. -final class AdaScriptSaveWriter: @unchecked Sendable { +final class AdaScriptSaveWriter: @unchecked Sendable, AdaScriptSuspensionSafeBridge { @GSExportableIgnore private var state: AdaScriptSaveStreamState? 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 index 97b78de1c..e65f7c2bf 100644 --- a/Sources/AdaScripting/AdaScriptTaskRuntime.swift +++ b/Sources/AdaScripting/AdaScriptTaskRuntime.swift @@ -39,6 +39,9 @@ final class AdaScriptTaskRuntime: @unchecked Sendable { @GSExportableIgnore private var reportDiagnostic: @Sendable (String) -> Void = { _ in } + @GSExportableIgnore + private var suspensionPolicy: AdaScriptSuspensionPolicy? + @GSExportableIgnore private let maximumTasks = 1024 @@ -51,14 +54,30 @@ final class AdaScriptTaskRuntime: @unchecked Sendable { @GSExportableIgnore static func make( virtualMachine: GravityVirtualMachine, - reportDiagnostic: @escaping @Sendable (String) -> Void + 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 { @@ -239,6 +258,7 @@ enum AdaScriptTaskPrelude { func complete(value) { if (done || cancelled) { return false; } + if (!__adaTasks.validateCapture([value])) { return false; } self.value = value; done = true; __adaTasks.wake(); @@ -276,6 +296,7 @@ enum AdaScriptTaskPrelude { 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; } diff --git a/Sources/AdaScripting/AdaScriptView.swift b/Sources/AdaScripting/AdaScriptView.swift index f1606ef94..5fa57eb76 100644 --- a/Sources/AdaScripting/AdaScriptView.swift +++ b/Sources/AdaScripting/AdaScriptView.swift @@ -252,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 @@ -265,16 +268,21 @@ final class AdaScriptViewModuleRuntime: @unchecked Sendable { let runtimeBundle = try AdaScriptRuntimeCoordinator.lock.withLock { let virtualMachine = GravityVirtualMachine(settings: .init(), delegate: delegate) - let taskRuntime = AdaScriptTaskRuntime.make(virtualMachine: virtualMachine, reportDiagnostic: delegate.append) + 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 virtualMachine.bindClass(with: AdaScriptAsyncResult.self) - try virtualMachine.bindClass(with: AdaScriptAsyncOperation.self) + try suspensionPolicy.bindSafe(AdaScriptAsyncResult.self, to: virtualMachine) + try suspensionPolicy.bindSafe(AdaScriptAsyncOperation.self, to: virtualMachine) try virtualMachine.bindClass(with: AdaScriptAsyncHost.self) - try virtualMachine.bindClass(with: AdaScriptSaveWriter.self) - try virtualMachine.bindClass(with: AdaScriptViewBridge.self) + try suspensionPolicy.bindSafe(AdaScriptSaveWriter.self, to: virtualMachine) + try suspensionPolicy.bindBorrowed(AdaScriptViewBridge.self, to: virtualMachine) try AdaScriptComponentRuntime.bind( to: virtualMachine, constructors: componentConstructors, 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 e02096cf0..b3edc22c5 100644 --- a/Sources/AdaScripting/AdaScripting.docc/AdaScriptLanguage.md +++ b/Sources/AdaScripting/AdaScripting.docc/AdaScriptLanguage.md @@ -341,6 +341,14 @@ 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. diff --git a/Sources/AdaScripting/AnnotatedGravityQueryView.swift b/Sources/AdaScripting/AnnotatedGravityQueryView.swift index 92ac6257b..84d069397 100644 --- a/Sources/AdaScripting/AnnotatedGravityQueryView.swift +++ b/Sources/AdaScripting/AnnotatedGravityQueryView.swift @@ -7,7 +7,7 @@ final class AnnotatedGravityQueryLease: @unchecked Sendable { } @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 @@ -74,7 +74,7 @@ final class AnnotatedGravityQueryBridge: @unchecked Sendable { } @GSExportable("AdaQueryRow") -final class AnnotatedGravityQueryRow: @unchecked Sendable { +final class AnnotatedGravityQueryRow: @unchecked Sendable, AdaScriptNonSendableBridge { var id: Int { guard lease.isActive else { reportDiagnostic("Query row is no longer valid") @@ -168,7 +168,7 @@ final class AnnotatedGravityQueryRow: @unchecked Sendable { } @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 diff --git a/Sources/AdaScripting/AnnotatedGravityResourceView.swift b/Sources/AdaScripting/AnnotatedGravityResourceView.swift index 3859a7c66..b91a9f96d 100644 --- a/Sources/AdaScripting/AnnotatedGravityResourceView.swift +++ b/Sources/AdaScripting/AnnotatedGravityResourceView.swift @@ -2,7 +2,7 @@ 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 diff --git a/Sources/AdaScripting/AnnotatedGravityScriptPlugin.swift b/Sources/AdaScripting/AnnotatedGravityScriptPlugin.swift index 5b02fb1a4..126a76fbb 100644 --- a/Sources/AdaScripting/AnnotatedGravityScriptPlugin.swift +++ b/Sources/AdaScripting/AnnotatedGravityScriptPlugin.swift @@ -85,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( @@ -639,26 +645,31 @@ private final class AnnotatedGravityRuntime: @unchecked Sendable { let virtualMachine = GravityVirtualMachine(settings: .init(), delegate: delegate) self.virtualMachine = virtualMachine - let taskRuntime = AdaScriptTaskRuntime.make(virtualMachine: virtualMachine, reportDiagnostic: delegate.append) + 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 virtualMachine.bindClass(with: AdaScriptAsyncResult.self) - try virtualMachine.bindClass(with: AdaScriptAsyncOperation.self) + try suspensionPolicy.bindSafe(AdaScriptAsyncResult.self, to: virtualMachine) + try suspensionPolicy.bindSafe(AdaScriptAsyncOperation.self, to: virtualMachine) try virtualMachine.bindClass(with: AdaScriptAsyncHost.self) - try virtualMachine.bindClass(with: AdaScriptSaveWriter.self) - 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) + 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) 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 a953b0993..df4495e0e 100644 --- a/Sources/AdaScripting/GravityScriptModule.swift +++ b/Sources/AdaScripting/GravityScriptModule.swift @@ -48,6 +48,17 @@ 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 { @@ -87,12 +98,31 @@ enum GravityScriptModuleResolver { } var globalAsyncNames = Set() + var nonSendableTypeNames = Set() for path in orderedPaths { guard let source = sourceByPath[path] else { continue } do { - globalAsyncNames.formUnion(try AdaScriptAsyncLowerer.globalFunctionNames(source: source.source, path: path)) + 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 AdaScriptAsyncLowerer.declarations( + in: source.source, + path: path, + nonSendableTypes: nonSendableTypeNames + ) + asyncDeclarations += declarations + globalAsyncNames.formUnion(declarations.compactMap { $0.ownerType == nil ? $0.name : nil }) } catch let error as AdaScriptAsyncSyntaxError { throw AdaScriptError.invalidManifest(error.description) } @@ -116,7 +146,8 @@ enum GravityScriptModuleResolver { loweredAsyncSource = try AdaScriptAsyncLowerer.lower( source: loweredAssetsSource, path: path, - globalAsyncNames: reachablePaths.contains(path) ? globalAsyncNames : [] + globalAsyncNames: reachablePaths.contains(path) ? globalAsyncNames : [], + nonSendableTypes: reachablePaths.contains(path) ? nonSendableTypeNames : [] ) } catch let error as AdaScriptAsyncSyntaxError { throw AdaScriptError.invalidManifest(error.description) @@ -155,7 +186,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/AdaScriptAsyncLowererTests.swift b/Tests/AdaScriptingTests/AdaScriptAsyncLowererTests.swift index 9188c6dd6..b044902cc 100644 --- a/Tests/AdaScriptingTests/AdaScriptAsyncLowererTests.swift +++ b/Tests/AdaScriptingTests/AdaScriptAsyncLowererTests.swift @@ -23,32 +23,36 @@ struct AdaScriptAsyncLowererTests { } } - @Test("Rejects an async lifecycle callback and borrowed context parameter") - func rejectsUnsafeSignatures() { + @Test("Ordinary method and parameter names carry no suspension policy") + func allowsOrdinaryNames() throws { + let lowered = try AdaScriptAsyncLowerer.lower( + source: "class S { async func update(context) { await Tasks.nextFrame(); } }", + path: "Ordinary.ada" + ) + #expect(lowered.contains("__ada_async_impl_update")) + } + + @Test("Rejects async parameters and receivers with @nonsendable types") + func rejectsNonSendableTypes() { #expect(throws: AdaScriptAsyncSyntaxError.self) { - try AdaScriptAsyncLowerer.lower(source: "class S { async func update(context) {} }", path: "Invalid.ada") + try AdaScriptAsyncLowerer.lower(source: """ + @nonsendable class Borrowed {} + async func hold(value: Borrowed) { await Tasks.nextFrame(); } + """, path: "Invalid.ada") } #expect(throws: AdaScriptAsyncSyntaxError.self) { - try AdaScriptAsyncLowerer.lower(source: "async func hold(context) { await Tasks.nextFrame(); }", path: "Invalid.ada") + try AdaScriptAsyncLowerer.lower(source: "@nonsendable class Borrowed { async func work() {} }", path: "Invalid.ada") } } - @Test("Rejects unawaited calls and borrowed arguments") - func rejectsUnsafeCalls() { + @Test("Rejects unawaited calls") + func rejectsUnawaitedCalls() { #expect(throws: AdaScriptAsyncSyntaxError.self) { try AdaScriptAsyncLowerer.lower(source: """ async func work() { return 1; } func start() { work(); } """, path: "Invalid.ada") } - #expect(throws: AdaScriptAsyncSyntaxError.self) { - try AdaScriptAsyncLowerer.lower(source: """ - async func work(value) { return value; } - @system class S { - func update(context) { Tasks.start(work(context)); } - } - """, path: "Invalid.ada") - } } @Test("A typed asset load keeps its type when awaited") diff --git a/Tests/AdaScriptingTests/AdaScriptAsyncTests.swift b/Tests/AdaScriptingTests/AdaScriptAsyncTests.swift index dddba8af6..073eda986 100644 --- a/Tests/AdaScriptingTests/AdaScriptAsyncTests.swift +++ b/Tests/AdaScriptingTests/AdaScriptAsyncTests.swift @@ -505,16 +505,20 @@ struct AdaScriptAsyncTests { AsyncPosition.registerComponent() let plugin = try AdaScriptPlugin(source: """ var started = false; - async func writeLater(row) { + var retained = null; + async func writeLater() { await Tasks.nextFrame(); - row.asyncPosition.value = 9; + retained.asyncPosition.value = 9; } @system class BorrowSystem { @query(AsyncPosition) var positions; func update(context) { if (!started) { started = true; - for (var row in positions) { Tasks.start(writeLater(row)); } + for (var row in positions) { + retained = row; + Tasks.start(writeLater()); + } } } } 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) {} } - } From 861b09e76ff128c362d0cac05a6450c8027d7c39 Mon Sep 17 00:00:00 2001 From: SpectralDragon Date: Thu, 24 Sep 2026 16:14:04 +0300 Subject: [PATCH 3/4] Prepare AdaScript for native Gravity async compiler --- .gitattributes | 3 + ...15-adascript-async-tasks-and-coroutines.md | 9 +- .../patches/gravity-lang-native-async.patch | 475 ++++++++++++++++++ Package.swift | 4 +- .../AdaScriptAsyncDeclarationScanner.swift | 126 +++++ .../AdaScriptAsyncLowerer.swift | 308 ------------ .../AdaScripting/AdaScriptTaskRuntime.swift | 6 +- .../AdaScripting/GravityScriptModule.swift | 18 +- ...daScriptAsyncDeclarationScannerTests.swift | 31 ++ .../AdaScriptAsyncLowererTests.swift | 77 --- 10 files changed, 651 insertions(+), 406 deletions(-) create mode 100644 Documentation/ArchitectureDecisions/patches/gravity-lang-native-async.patch create mode 100644 Sources/AdaScriptCompilerCore/AdaScriptAsyncDeclarationScanner.swift delete mode 100644 Sources/AdaScriptCompilerCore/AdaScriptAsyncLowerer.swift create mode 100644 Tests/AdaScriptingTests/AdaScriptAsyncDeclarationScannerTests.swift delete mode 100644 Tests/AdaScriptingTests/AdaScriptAsyncLowererTests.swift diff --git a/.gitattributes b/.gitattributes index 838c4f501..7fd308b40 100644 --- a/.gitattributes +++ b/.gitattributes @@ -34,3 +34,6 @@ Sources/miniaudio/** linguist-vendored Sources/msdf-atlas-gen/** linguist-vendored Sources/SPIRV-Cross/** linguist-vendored Sources/glslang/** linguist-vendored + +# Embedded upstream patches retain the original context whitespace. +Documentation/ArchitectureDecisions/patches/*.patch whitespace=-trailing-space,-blank-at-eof diff --git a/Documentation/ArchitectureDecisions/0015-adascript-async-tasks-and-coroutines.md b/Documentation/ArchitectureDecisions/0015-adascript-async-tasks-and-coroutines.md index 89b2b2017..b258deb22 100644 --- a/Documentation/ArchitectureDecisions/0015-adascript-async-tasks-and-coroutines.md +++ b/Documentation/ArchitectureDecisions/0015-adascript-async-tasks-and-coroutines.md @@ -25,6 +25,10 @@ Implemented and covered by focused tests in this worktree: 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 the local `gravity-lang` branch. The AdaEngine runtime still owns task + scheduling and suspension policy. Cross-repository publication and the + dependency pin remain pending. Remaining before this ADR is fully implemented: @@ -35,9 +39,8 @@ Remaining before this ADR is fully implemented: - [ ] 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. -- [ ] Move the async syntax and effect representation into a versioned - `gravity-lang` parser/AST release. The Swift source lowerer is an interim - AdaScript adapter; the dependency is currently pinned to `0.9.9`. +- [ ] Publish the tested `gravity-lang` compiler commit and pin AdaEngine to + that revision. The currently published dependency remains `0.9.9`. - [ ] Engine-owned, incremental snapshots of arbitrary ECS data, beyond the available bounded streaming writer. - [ ] A game time-scale resource, complete Editor diagnostics, and platform diff --git a/Documentation/ArchitectureDecisions/patches/gravity-lang-native-async.patch b/Documentation/ArchitectureDecisions/patches/gravity-lang-native-async.patch new file mode 100644 index 000000000..876b01df5 --- /dev/null +++ b/Documentation/ArchitectureDecisions/patches/gravity-lang-native-async.patch @@ -0,0 +1,475 @@ +From f3f4a8f636a692d06e97be4bb5656094ebb9966f Mon Sep 17 00:00:00 2001 +From: SpectralDragon +Date: Thu, 24 Sep 2026 16:04:42 +0300 +Subject: [PATCH] Parse and lower async functions in Gravity AST + +--- + .../GravityVirtualMachineTests.swift | 46 +++++ + src/compiler/gravity_ast.h | 2 + + src/compiler/gravity_parser.c | 170 +++++++++++++++++- + src/compiler/gravity_semacheck2.c | 21 +++ + src/compiler/gravity_token.c | 10 +- + src/compiler/gravity_token.h | 2 +- + 6 files changed, 241 insertions(+), 10 deletions(-) + +diff --git a/Tests/GravityTests/GravityVirtualMachineTests.swift b/Tests/GravityTests/GravityVirtualMachineTests.swift +index 098cbd4..b1676de 100644 +--- a/Tests/GravityTests/GravityVirtualMachineTests.swift ++++ b/Tests/GravityTests/GravityVirtualMachineTests.swift +@@ -3,6 +3,52 @@ import Testing + + @Suite("Gravity virtual machine", .serialized) + struct GravityVirtualMachineTests { ++ @Test("Native async declarations retain method state and nested awaits") ++ func executesNativeAsync() throws { ++ let delegate = TestVirtualMachineDelegate() ++ let virtualMachine = GravityVirtualMachine(settings: .init(), delegate: delegate) ++ let binary = virtualMachine.loadGravityFile(from: """ ++ class __AdaTask { ++ var fiber = null; ++ var value = null; ++ func capture(values) {} ++ func complete(value) { self.value = value; } ++ } ++ class Tasks { static func start(task) { return task; } } ++ func __adaAwait(task) { task.fiber.try(); return task.value; } ++ async func child(value) { return value + 2; } ++ class Counter { ++ var base = 7; ++ async func calculate(value) { return await child(value) + base; } ++ } ++ func main() { ++ var task = Tasks.start(Counter().calculate(3)); ++ task.fiber.try(); ++ return task.value; ++ } ++ """) ++ let result = try #require(virtualMachine.execute(binary)) ++ #expect(delegate.errors.isEmpty) ++ #expect(result.toInteger == 12) ++ } ++ ++ @Test("Native effect checks reject unawaited calls and synchronous awaits") ++ func rejectsInvalidAsyncEffects() { ++ let unawaited = TestVirtualMachineDelegate() ++ let vm1 = GravityVirtualMachine(settings: .init(), delegate: unawaited) ++ _ = vm1.loadGravityFile(from: """ ++ class __AdaTask { var fiber = null; func capture(values) {} func complete(value) {} } ++ async func child() { return 1; } ++ func main() { return child(); } ++ """) ++ #expect(unawaited.errors.contains(where: { $0.contains("requires await or Tasks.start") })) ++ ++ let synchronousAwait = TestVirtualMachineDelegate() ++ let vm2 = GravityVirtualMachine(settings: .init(), delegate: synchronousAwait) ++ _ = vm2.loadGravityFile(from: "func main() { return await 1; }") ++ #expect(synchronousAwait.errors.contains(where: { $0.contains("await requires an async function") })) ++ } ++ + @Test("Executes a script and returns its result") + func executesScript() throws { + let delegate = TestVirtualMachineDelegate() +diff --git a/src/compiler/gravity_ast.h b/src/compiler/gravity_ast.h +index 06b3fe3..fa6683f 100644 +--- a/src/compiler/gravity_ast.h ++++ b/src/compiler/gravity_ast.h +@@ -167,6 +167,7 @@ typedef struct { + uint16_t nparams; // formal parameters counter + bool has_defaults; // flag set if parmas has default values + bool is_closure; // flag to check if function is a closure ++ bool is_async; // declared async; body executes in a task fiber + gupvalue_r *uplist; // list of upvalues used in function (can be empty) + } gnode_function_decl_t; + typedef gnode_function_decl_t gnode_function_expr_t; +@@ -281,6 +282,7 @@ typedef struct { + gnode_t base; // NODE_CALLFUNC_EXPR, NODE_SUBSCRIPT_EXPR, NODE_ACCESS_EXPR + gnode_t *id; // id(...) or id[...] or id. + gnode_r *list; // list of postfix_subexpr ++ bool is_await; // source-level await, lowered to __adaAwait call + } gnode_postfix_expr_t; + + typedef struct { +diff --git a/src/compiler/gravity_parser.c b/src/compiler/gravity_parser.c +index c3702c3..376af90 100644 +--- a/src/compiler/gravity_parser.c ++++ b/src/compiler/gravity_parser.c +@@ -28,6 +28,7 @@ struct gravity_parser_t { + lexer_r *lexer; // stack of lexers (stack used in #include statements) + gnode_r *declarations; // used to keep track of nodes hierarchy + gnode_r *statements; // used to build AST ++ gnode_t *pending_async; // generated body declaration after its public wrapper + gravity_delegate_t *delegate; // compiler delegate + uint16_r vdecl; // to keep track of func expression in variable declaration nondes + +@@ -119,7 +120,7 @@ static gnode_r *parse_optional_parameter_declaration (gravity_parser_t *parser, + static gnode_t *parse_compound_statement (gravity_parser_t *parser); + static gnode_t *parse_expression (gravity_parser_t *parser); + static gnode_t *parse_declaration_statement (gravity_parser_t *parser); +-static gnode_t *parse_function (gravity_parser_t *parser, bool is_declaration, gtoken_t access_specifier, gtoken_t storage_specifier); ++static gnode_t *parse_function (gravity_parser_t *parser, bool is_declaration, gtoken_t access_specifier, gtoken_t storage_specifier, bool is_async); + static gnode_t *adjust_assignment_expression (gravity_parser_t *parser, gtoken_t tok, gnode_t *lnode, gnode_t *rnode); + static gnode_t *parse_literal_expression (gravity_parser_t *parser); + static gnode_t *parse_macro_statement (gravity_parser_t *parser); +@@ -265,7 +266,123 @@ static bool parse_semicolon (gravity_parser_t *parser) { + #endif + } + +-gnode_t *parse_function (gravity_parser_t *parser, bool is_declaration, gtoken_t access_specifier, gtoken_t storage_specifier) { ++static gnode_t *async_identifier(gtoken_s token, const char *name, gnode_t *owner) { ++ return gnode_identifier_expr_create(token, string_dup(name), NULL, owner); ++} ++ ++static gnode_t *async_call(gtoken_s token, const char *name, gnode_r *args, gnode_t *owner) { ++ gnode_r *parts = gnode_array_create(); ++ gnode_array_push(parts, gnode_postfix_subexpr_create(token, NODE_CALL_EXPR, NULL, args, NULL, owner)); ++ return gnode_postfix_expr_create(token, async_identifier(token, name, owner), parts, owner); ++} ++ ++static gnode_t *async_member(gtoken_s token, gnode_t *base, const char *name, gnode_t *owner) { ++ gnode_r *parts = gnode_array_create(); ++ gnode_t *member = async_identifier(token, name, owner); ++ gnode_array_push(parts, gnode_postfix_subexpr_create(token, NODE_ACCESS_EXPR, member, NULL, NULL, owner)); ++ return gnode_postfix_expr_create(token, base, parts, owner); ++} ++ ++static gnode_t *async_invoke(gtoken_s token, gnode_t *target, gnode_r *args, gnode_t *owner) { ++ gnode_r *parts = gnode_array_create(); ++ gnode_array_push(parts, gnode_postfix_subexpr_create(token, NODE_CALL_EXPR, NULL, args, NULL, owner)); ++ if (target->tag == NODE_POSTFIX_EXPR) { ++ gnode_postfix_expr_t *postfix = (gnode_postfix_expr_t *)target; ++ gnode_array_push(postfix->list, gnode_array_get(parts, 0)); ++ gnode_array_free(parts); ++ return target; ++ } ++ return gnode_postfix_expr_create(token, target, parts, owner); ++} ++ ++static gnode_t *async_local(gtoken_s token, const char *name, gnode_t *value, gnode_t *owner) { ++ gnode_r *variables = gnode_array_create(); ++ gnode_array_push(variables, gnode_variable_create(token, string_dup(name), NULL, value, owner, NULL)); ++ return gnode_variable_decl_create(token, TOK_KEY_VAR, 0, 0, variables, owner); ++} ++ ++static gnode_t *async_return(gtoken_s token, gnode_t *value, gnode_t *owner) { ++ token.type = TOK_KEY_RETURN; ++ return gnode_jump_stat_create(token, value, owner); ++} ++ ++static gnode_t *lower_async_function(gravity_parser_t *parser, gnode_function_decl_t *implementation, bool captures_self) { ++ gtoken_s token = implementation->base.token; ++ const char *public_name = implementation->identifier; ++ size_t private_name_size = strlen(public_name) + 48; ++ char *private_name = mem_alloc(NULL, private_name_size); ++ snprintf(private_name, private_name_size, "$ada_async_impl_%s_%u", public_name, ++parser->unique_id); ++ implementation->identifier = private_name; ++ implementation->is_async = false; ++ ++ gnode_function_decl_t *wrapper = (gnode_function_decl_t *)gnode_function_decl_create( ++ token, public_name, implementation->access, implementation->storage, NULL, NULL, implementation->base.decl); ++ wrapper->is_async = true; ++ wrapper->params = gnode_array_create(); ++ for (size_t i = 0; i < gnode_array_size(implementation->params); ++i) { ++ gnode_var_t *param = (gnode_var_t *)gnode_array_get(implementation->params, i); ++ gnode_t *default_value = param->expr ? gnode_duplicate(param->expr, true) : NULL; ++ gnode_array_push(wrapper->params, gnode_variable_create(token, string_dup(param->identifier), ++ param->annotation_type ? string_dup(param->annotation_type) : NULL, default_value, (gnode_t *)wrapper, NULL)); ++ } ++ wrapper->has_defaults = implementation->has_defaults; ++ ++ gnode_r *statements = gnode_array_create(); ++ const char *receiver_name = "__ada_receiver"; ++ if (captures_self) { ++ gnode_array_push(statements, async_local(token, receiver_name, async_identifier(token, SELF_PARAMETER_NAME, (gnode_t *)wrapper), (gnode_t *)wrapper)); ++ } ++ gnode_array_push(statements, async_local(token, "__ada_task", async_call(token, "__AdaTask", gnode_array_create(), (gnode_t *)wrapper), (gnode_t *)wrapper)); ++ ++ gnode_r *captured = gnode_array_create(); ++ if (captures_self) gnode_array_push(captured, async_identifier(token, receiver_name, (gnode_t *)wrapper)); ++ for (size_t i = 1; i < gnode_array_size(wrapper->params); ++i) { ++ gnode_var_t *param = (gnode_var_t *)gnode_array_get(wrapper->params, i); ++ gnode_array_push(captured, async_identifier(token, param->identifier, (gnode_t *)wrapper)); ++ } ++ gnode_r *capture_args = gnode_array_create(); ++ gnode_array_push(capture_args, gnode_list_expr_create(token, captured, NULL, false, (gnode_t *)wrapper)); ++ gnode_t *capture_call = async_invoke(token, ++ async_member(token, async_identifier(token, "__ada_task", (gnode_t *)wrapper), "capture", (gnode_t *)wrapper), ++ capture_args, (gnode_t *)wrapper); ++ gnode_array_push(statements, capture_call); ++ ++ gnode_function_decl_t *fiber_body = (gnode_function_decl_t *)gnode_function_decl_create(token, NULL, 0, 0, NULL, NULL, (gnode_t *)wrapper); ++ fiber_body->is_closure = true; ++ fiber_body->params = gnode_array_create(); ++ gnode_array_push(fiber_body->params, gnode_variable_create(token, string_dup(SELF_PARAMETER_NAME), NULL, NULL, (gnode_t *)fiber_body, NULL)); ++ gnode_r *implementation_args = gnode_array_create(); ++ for (size_t i = 1; i < gnode_array_size(wrapper->params); ++i) { ++ gnode_var_t *param = (gnode_var_t *)gnode_array_get(wrapper->params, i); ++ gnode_array_push(implementation_args, async_identifier(token, param->identifier, (gnode_t *)fiber_body)); ++ } ++ gnode_t *target = captures_self ++ ? async_member(token, async_identifier(token, receiver_name, (gnode_t *)fiber_body), private_name, (gnode_t *)fiber_body) ++ : async_identifier(token, private_name, (gnode_t *)fiber_body); ++ gnode_t *body_call = async_invoke(token, target, implementation_args, (gnode_t *)fiber_body); ++ gnode_r *finish_args = gnode_array_create(); ++ gnode_array_push(finish_args, body_call); ++ gnode_t *finish_call = async_invoke(token, ++ async_member(token, async_identifier(token, "__ada_task", (gnode_t *)fiber_body), "complete", (gnode_t *)fiber_body), ++ finish_args, (gnode_t *)fiber_body); ++ gnode_r *fiber_statements = gnode_array_create(); ++ gnode_array_push(fiber_statements, finish_call); ++ fiber_body->block = (gnode_compound_stmt_t *)gnode_block_stat_create(NODE_COMPOUND_STAT, token, fiber_statements, (gnode_t *)fiber_body, 0); ++ ++ gnode_r *fiber_args = gnode_array_create(); ++ gnode_array_push(fiber_args, (gnode_t *)fiber_body); ++ gnode_t *fiber_create = async_invoke(token, ++ async_member(token, async_identifier(token, "Fiber", (gnode_t *)wrapper), "create", (gnode_t *)wrapper), ++ fiber_args, (gnode_t *)wrapper); ++ gnode_t *fiber_property = async_member(token, async_identifier(token, "__ada_task", (gnode_t *)wrapper), "fiber", (gnode_t *)wrapper); ++ gnode_array_push(statements, gnode_binary_expr_create(TOK_OP_ASSIGN, fiber_property, fiber_create, (gnode_t *)wrapper)); ++ gnode_array_push(statements, async_return(token, async_identifier(token, "__ada_task", (gnode_t *)wrapper), (gnode_t *)wrapper)); ++ wrapper->block = (gnode_compound_stmt_t *)gnode_block_stat_create(NODE_COMPOUND_STAT, token, statements, (gnode_t *)wrapper, 0); ++ parser->pending_async = (gnode_t *)implementation; ++ return (gnode_t *)wrapper; ++} ++ ++static gnode_t *parse_function (gravity_parser_t *parser, bool is_declaration, gtoken_t access_specifier, gtoken_t storage_specifier, bool is_async) { + DECLARE_LEXER; + + // access_specifier? storage_specifier? already parsed +@@ -293,6 +410,7 @@ gnode_t *parse_function (gravity_parser_t *parser, bool is_declaration, gtoken_t + + // create func declaration node + gnode_function_decl_t *func = (gnode_function_decl_t *) gnode_function_decl_create(token, identifier, access_specifier, storage_specifier, NULL, NULL, LAST_DECLARATION()); ++ func->is_async = is_async; + + // check and consume TOK_OP_OPEN_PARENTHESIS + if (!is_implicit) parse_required(parser, TOK_OP_OPEN_PARENTHESIS); +@@ -321,6 +439,7 @@ gnode_t *parse_function (gravity_parser_t *parser, bool is_declaration, gtoken_t + func->has_defaults = has_default_values; + func->params = params; + func->block = compound; ++ if (is_async) return lower_async_function(parser, func, IS_CLASS_ENCLOSED()); + return (gnode_t *)func; + } + +@@ -625,7 +744,7 @@ static gnode_t *parse_function_expression (gravity_parser_t *parser) { + // if it is a func keyword used to refers to + // the current executing function + +- gnode_t *node = parse_function(parser, false, 0, 0); ++ gnode_t *node = parse_function(parser, false, 0, 0, false); + return node; + } + +@@ -1154,6 +1273,23 @@ static gnode_t *parse_unary (gravity_parser_t *parser) { + return gnode_unary_expr_create(tok, node, LAST_DECLARATION()); + } + ++static gnode_t *parse_await_expression (gravity_parser_t *parser) { ++ DECLARE_LEXER; ++ gravity_lexer_next(lexer); ++ gtoken_s token = gravity_lexer_token(lexer); ++ gnode_t *enclosing = get_enclosing(parser, NODE_FUNCTION_DECL); ++ if (!enclosing || !((gnode_function_decl_t *)enclosing)->is_async) { ++ REPORT_ERROR(token, "await requires an async function."); ++ } ++ gnode_t *value = parse_precedence(parser, PREC_UNARY); ++ if (!value) return NULL; ++ gnode_r *args = gnode_array_create(); ++ gnode_array_push(args, value); ++ gnode_postfix_expr_t *call = (gnode_postfix_expr_t *)async_call(token, "__adaAwait", args, LAST_DECLARATION()); ++ call->is_await = true; ++ return (gnode_t *)call; ++} ++ + static gnode_t *parse_infix (gravity_parser_t *parser) { + DEBUG_PARSER("parse_infix"); + +@@ -1228,6 +1364,7 @@ static void init_grammer_rules (void) { + + rules[TOK_OP_OPEN_CURLYBRACE] = PREFIX(PREC_LOWEST, parse_function_expression); + rules[TOK_KEY_FUNC] = PREFIX(PREC_LOWEST, parse_function_expression); ++ rules[TOK_KEY_AWAIT] = PREFIX(PREC_LOWEST, parse_await_expression); + + rules[TOK_IDENTIFIER] = PREFIX(PREC_LOWEST, parse_identifier_expression); + rules[TOK_STRING] = PREFIX(PREC_LOWEST, parse_literal_expression); +@@ -1696,7 +1833,12 @@ static gnode_t *parse_event_declaration (gravity_parser_t *parser, gtoken_t acce + return NULL; + } + +-static gnode_t *parse_function_declaration (gravity_parser_t *parser, gtoken_t access_specifier, gtoken_t storage_specifier) { ++static gnode_t *parse_function_declaration (gravity_parser_t *parser, gtoken_t access_specifier, gtoken_t storage_specifier, bool is_async) { ++ if (is_async && IS_FUNCTION_ENCLOSED()) { ++ DECLARE_LEXER; ++ REPORT_ERROR(gravity_lexer_token(lexer), "Nested async functions are not supported."); ++ return NULL; ++ } + // convert a function declaration within another function to a local variable assignment + // for example: + // +@@ -1713,7 +1855,7 @@ static gnode_t *parse_function_declaration (gravity_parser_t *parser, gtoken_t a + // conversion is performed inside the parser + // so next semantic checks can perform + // identifier uniqueness checks +- gnode_t *node = parse_function(parser, true, access_specifier, storage_specifier); ++ gnode_t *node = parse_function(parser, true, access_specifier, storage_specifier, is_async); + + if (IS_FUNCTION_ENCLOSED()) { + gnode_function_decl_t *func = (gnode_function_decl_t *)node; +@@ -1837,6 +1979,10 @@ static gnode_t *parse_class_declaration (gravity_parser_t *parser, gtoken_t acce + ? parse_special_statement(parser) + : parse_declaration_statement(parser); + if (decl) gnode_array_push(declarations, decl_check_access_specifier(decl)); ++ if (parser->pending_async) { ++ gnode_array_push(declarations, parser->pending_async); ++ parser->pending_async = NULL; ++ } + peek = gravity_lexer_peek(lexer); + } + POP_DECLARATION(); +@@ -2425,7 +2571,15 @@ static gnode_t *parse_declaration_statement (gravity_parser_t *parser) { + + switch (peek) { + case TOK_MACRO: return parse_macro_statement(parser); +- case TOK_KEY_FUNC: return parse_function_declaration(parser, access_specifier, storage_specifier); ++ case TOK_KEY_FUNC: return parse_function_declaration(parser, access_specifier, storage_specifier, false); ++ case TOK_KEY_ASYNC: { ++ gravity_lexer_next(lexer); ++ if (gravity_lexer_peek(lexer) != TOK_KEY_FUNC) { ++ REPORT_ERROR(gravity_lexer_token(lexer), "async must precede func."); ++ return NULL; ++ } ++ return parse_function_declaration(parser, access_specifier, storage_specifier, true); ++ } + case TOK_KEY_ENUM: return parse_enum_declaration(parser, access_specifier, storage_specifier); + case TOK_KEY_MODULE: return parse_module_declaration(parser, access_specifier, storage_specifier); + case TOK_KEY_EVENT: return parse_event_declaration(parser, access_specifier, storage_specifier); +@@ -2779,6 +2933,10 @@ static uint32_t parser_run (gravity_parser_t *parser) { + while (gravity_lexer_peek(CURRENT_LEXER)) { + gnode_t *node = parse_statement(parser); + if (node) gnode_array_push(parser->statements, node); ++ if (parser->pending_async) { ++ gnode_array_push(parser->statements, parser->pending_async); ++ parser->pending_async = NULL; ++ } + } + + // since it is a stack of lexers then check if it is a real EOF +diff --git a/src/compiler/gravity_semacheck2.c b/src/compiler/gravity_semacheck2.c +index 93463b0..be582fe 100644 +--- a/src/compiler/gravity_semacheck2.c ++++ b/src/compiler/gravity_semacheck2.c +@@ -16,6 +16,7 @@ struct semacheck_t { + gnode_r *declarations; // declarations stack + uint16_r statements; // statements stack + uint32_t lasterror; // last error line number to prevent reporting more than one error per line ++ bool allow_async_call; // direct operand of await or Tasks.start + }; + typedef struct semacheck_t semacheck_t; + +@@ -1045,6 +1046,15 @@ static void visit_postfix_expr (gvisitor_t *self, gnode_postfix_expr_t *node) { + if (ISA(target, NODE_VARIABLE)) target = NULL; // a variable does not contain a symbol table + } + ++ semacheck_t *state = (semacheck_t *)self->data; ++ if (ISA(target, NODE_FUNCTION_DECL) && ((gnode_function_decl_t *)target)->is_async && ++ gnode_array_size(node->list) > 0 && ++ ISA(gnode_array_get(node->list, 0), NODE_CALL_EXPR) && !state->allow_async_call) { ++ REPORT_ERROR(node, "Async call to %s requires await or Tasks.start.", ((gnode_function_decl_t *)target)->identifier); ++ } ++ bool was_allowed = state->allow_async_call; ++ state->allow_async_call = false; ++ + // special enum case on list[0] (it is a static case) + if (ISA(target, NODE_ENUM_DECL)) { + // check first expression in the list (in case of enum MUST BE an identifier) +@@ -1127,7 +1137,17 @@ static void visit_postfix_expr (gvisitor_t *self, gnode_postfix_expr_t *node) { + for (size_t j=0; jargs, j); + if (is_expression_assignment(val)) {REPORT_ERROR(val, "Assignment does not have side effects and so cannot be used as function argument."); return;} ++ bool allow_argument = node->is_await && j == 0; ++ if (!allow_argument && i > 0 && j == 0 && ISA(node->id, NODE_IDENTIFIER_EXPR)) { ++ gnode_identifier_expr_t *identifier = (gnode_identifier_expr_t *)node->id; ++ gnode_postfix_subexpr_t *member = (gnode_postfix_subexpr_t *)gnode_array_get(node->list, i - 1); ++ allow_argument = strcmp(identifier->value, "Tasks") == 0 && ++ ISA(member, NODE_ACCESS_EXPR) && ISA(member->expr, NODE_IDENTIFIER_EXPR) && ++ strcmp(((gnode_identifier_expr_t *)member->expr)->value, "start") == 0; ++ } ++ state->allow_async_call = allow_argument; + visit(val); ++ state->allow_async_call = false; + } + continue; + } +@@ -1148,6 +1168,7 @@ static void visit_postfix_expr (gvisitor_t *self, gnode_postfix_expr_t *node) { + DEBUG_SEMA2("UNRECOGNIZED POSTFIX OPTIONAL EXPRESSION"); + assert(0); + } ++ state->allow_async_call = was_allowed; + } + + static void visit_file_expr (gvisitor_t *self, gnode_file_expr_t *node) { +diff --git a/src/compiler/gravity_token.c b/src/compiler/gravity_token.c +index dd794dd..56ba3ff 100644 +--- a/src/compiler/gravity_token.c ++++ b/src/compiler/gravity_token.c +@@ -28,6 +28,8 @@ const char *token_name (gtoken_t token) { + // keywords + case TOK_KEY_FILE: return "file"; + case TOK_KEY_FUNC: return "func"; ++ case TOK_KEY_ASYNC: return "async"; ++ case TOK_KEY_AWAIT: return "await"; + case TOK_KEY_SUPER: return "super"; + case TOK_KEY_DEFAULT: return "default"; + case TOK_KEY_TRUE: return "true"; +@@ -122,7 +124,7 @@ const char *token_name (gtoken_t token) { + + void token_keywords_indexes (uint32_t *idx_start, uint32_t *idx_end) { + *idx_start = (uint32_t)TOK_KEY_FUNC; +- *idx_end = (uint32_t)TOK_KEY_CURRARGS; ++ *idx_end = (uint32_t)TOK_KEY_AWAIT; + }; + + gtoken_t token_special_builtin(gtoken_s *token) { +@@ -195,6 +197,8 @@ gtoken_t token_keyword (const char *buffer, int32_t len) { + break; + + case 5: ++ if (string_casencmp(buffer, "async", len) == 0) return TOK_KEY_ASYNC; ++ if (string_casencmp(buffer, "await", len) == 0) return TOK_KEY_AWAIT; + if (string_casencmp(buffer, "super", len) == 0) return TOK_KEY_SUPER; + if (string_casencmp(buffer, "false", len) == 0) return TOK_KEY_FALSE; + if (string_casencmp(buffer, "break", len) == 0) return TOK_KEY_BREAK; +@@ -311,7 +315,7 @@ bool token_isprimary_expression (gtoken_t token) { + (token == TOK_KEY_FALSE) || (token == TOK_IDENTIFIER) || (token == TOK_KEY_NULL) || + (token == TOK_KEY_SUPER) || (token == TOK_KEY_FUNC) || (token == TOK_KEY_UNDEFINED) || + (token == TOK_OP_OPEN_PARENTHESIS) || (token == TOK_OP_OPEN_SQUAREBRACKET) || +- (token == TOK_OP_OPEN_CURLYBRACE) || (token == TOK_KEY_FILE)); ++ (token == TOK_OP_OPEN_CURLYBRACE) || (token == TOK_KEY_FILE) || (token == TOK_KEY_AWAIT)); + + } + +@@ -355,7 +359,7 @@ bool token_isdeclaration_statement (gtoken_t token) { + // empty_declaration (;) + + return ((token_isaccess_specifier(token) || token_isstorage_specifier(token) || token_isvariable_declaration(token) || +- (token == TOK_KEY_FUNC) || (token == TOK_KEY_CLASS) || (token == TOK_KEY_STRUCT) || (token == TOK_KEY_ENUM) || ++ (token == TOK_KEY_FUNC) || (token == TOK_KEY_ASYNC) || (token == TOK_KEY_CLASS) || (token == TOK_KEY_STRUCT) || (token == TOK_KEY_ENUM) || + (token == TOK_KEY_MODULE) || (token == TOK_KEY_EVENT) || (token == TOK_OP_SEMICOLON))); + } + +diff --git a/src/compiler/gravity_token.h b/src/compiler/gravity_token.h +index 3bfe433..6fa64f7 100644 +--- a/src/compiler/gravity_token.h ++++ b/src/compiler/gravity_token.h +@@ -71,7 +71,7 @@ typedef enum { + TOK_KEY_REPEAT, TOK_KEY_FOR, TOK_KEY_IN, TOK_KEY_ENUM, TOK_KEY_CLASS, TOK_KEY_STRUCT, TOK_KEY_PRIVATE, + TOK_KEY_FILE, TOK_KEY_INTERNAL, TOK_KEY_PUBLIC, TOK_KEY_STATIC, TOK_KEY_EXTERN, TOK_KEY_LAZY, TOK_KEY_CONST, + TOK_KEY_VAR, TOK_KEY_MODULE, TOK_KEY_IMPORT, TOK_KEY_CASE, TOK_KEY_EVENT, TOK_KEY_NULL, TOK_KEY_UNDEFINED, +- TOK_KEY_ISA, TOK_KEY_CURRFUNC, TOK_KEY_CURRARGS, ++ TOK_KEY_ISA, TOK_KEY_CURRFUNC, TOK_KEY_CURRARGS, TOK_KEY_ASYNC, TOK_KEY_AWAIT, + + // Operators (36) + TOK_OP_SHIFT_LEFT, TOK_OP_SHIFT_RIGHT, TOK_OP_MUL, TOK_OP_DIV, TOK_OP_REM, TOK_OP_BIT_AND, TOK_OP_ADD, TOK_OP_SUB, +-- +2.50.1 (Apple Git-155) + diff --git a/Package.swift b/Package.swift index fcfc21685..a9722576d 100644 --- a/Package.swift +++ b/Package.swift @@ -1298,7 +1298,9 @@ let package = Package( ) package.dependencies += [ - .package( + ProcessInfo.processInfo.environment["ADAENGINE_GRAVITY_LOCAL_PATH"].map { + .package(name: "gravity-lang", path: $0) + } ?? .package( url: "https://github.com/AdaEngine/gravity-lang.git", exact: "0.9.9" ), diff --git a/Sources/AdaScriptCompilerCore/AdaScriptAsyncDeclarationScanner.swift b/Sources/AdaScriptCompilerCore/AdaScriptAsyncDeclarationScanner.swift new file mode 100644 index 000000000..beed95060 --- /dev/null +++ b/Sources/AdaScriptCompilerCore/AdaScriptAsyncDeclarationScanner.swift @@ -0,0 +1,126 @@ +public struct AdaScriptAsyncSyntaxError: Error, Sendable, Equatable, CustomStringConvertible { + public let path: String + public let line: Int + public let message: String + + public var description: String { "\(path):\(line): \(message)" } +} + +public struct AdaScriptAsyncDeclaration: Equatable, Sendable { + public let name: String + public let ownerType: String? + public let line: Int +} + +/// Collects AdaEngine callback and suspension metadata. Gravity owns the +/// async grammar, AST lowering, and call effect diagnostics. +public enum AdaScriptAsyncDeclarationScanner { + private enum Scope { + case type(String) + case other + } + + public static func declarations( + in source: String, + path: String, + nonSendableTypes: Set = [] + ) 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/AdaScriptAsyncLowerer.swift b/Sources/AdaScriptCompilerCore/AdaScriptAsyncLowerer.swift deleted file mode 100644 index 05f6175de..000000000 --- a/Sources/AdaScriptCompilerCore/AdaScriptAsyncLowerer.swift +++ /dev/null @@ -1,308 +0,0 @@ -import Foundation - -public struct AdaScriptAsyncSyntaxError: Error, Sendable, Equatable, CustomStringConvertible { - public let path: String - public let line: Int - public let message: String - - public var description: String { "\(path):\(line): \(message)" } -} - -public struct AdaScriptAsyncDeclaration: Equatable, Sendable { - public let name: String - public let ownerType: String? - public let line: Int -} - -/// Interim adapter for the pinned Gravity 0.9.9 parser, which has no async AST. -/// Borrow rules come from `@nonsendable` type metadata and runtime bridge policy; -/// native async syntax/effects belong in a future versioned Gravity release. -public enum AdaScriptAsyncLowerer { - private struct Declaration { - let range: Range - let body: Range - let name: String - let parameters: String - let arguments: [String] - let receiver: String - let ownerType: String? - let line: Int - } - - private struct Parameter { - let name: String - let typeName: String? - } - - private enum Scope { - case type(String) - case other - } - - public static func globalFunctionNames(source: String, path: String) throws -> Set { - Set(try declarations(in: source, path: path).compactMap { $0.ownerType == nil ? $0.name : nil }) - } - - public static func declarations( - in source: String, - path: String, - nonSendableTypes: Set = [] - ) throws -> [AdaScriptAsyncDeclaration] { - var lexer = Lexer(source: source) - let tokens = lexer.lex() - let characters = Array(source) - let markedTypes = try nonSendableTypes.union(AdaScriptNonSendableTypes.declared(in: source, path: path)) - var declarations: [AdaScriptAsyncDeclaration] = [] - for index in tokens.indices where tokens[index].text == "async" { - let declaration = try parse( - at: index, - tokens: tokens, - characters: characters, - path: path, - nonSendableTypes: markedTypes - ) - declarations.append( - AdaScriptAsyncDeclaration(name: declaration.name, ownerType: declaration.ownerType, line: declaration.line) - ) - } - return declarations - } - - public static func lower( - source: String, - path: String, - globalAsyncNames: Set = [], - nonSendableTypes: Set = [] - ) throws -> String { - var lexer = Lexer(source: source) - let tokens = lexer.lex() - let characters = Array(source) - let markedTypes = try nonSendableTypes.union(AdaScriptNonSendableTypes.declared(in: source, path: path)) - var declarations: [Declaration] = [] - var covered = Set() - - for index in tokens.indices where tokens[index].text == "async" { - guard !covered.contains(index) else { continue } - let declaration = try parse( - at: index, - tokens: tokens, - characters: characters, - path: path, - nonSendableTypes: markedTypes - ) - declarations.append(declaration) - for tokenIndex in index.. $1.range.lowerBound }) { - let body = try lowerAwaits( - String(characters[declaration.body]), - path: path, - firstLine: declaration.line - ) - let implementation = "__ada_async_impl_\(declaration.name)_\(declaration.range.lowerBound)" - let call = "\(declaration.receiver)\(implementation)(\(declaration.arguments.joined(separator: ", ")))" - let capturedReceiver = declaration.receiver.isEmpty ? "" : "var __ada_receiver = self;\n " - let capturedValues = (declaration.receiver.isEmpty ? [] : ["__ada_receiver"]) + declaration.arguments - let replacement = """ - func \(declaration.name)(\(declaration.parameters)) { - \(capturedReceiver)var __ada_task = __AdaTask(); - if (!__adaTasks.validateCapture([\(capturedValues.joined(separator: ", "))])) { - __ada_task.cancel(); - return __ada_task; - } - __ada_task.fiber = Fiber.create({ - var __ada_result = \(call); - if (!__adaTasks.validateCapture([__ada_result])) { - __ada_task.cancel(); - return; - } - __ada_task.value = __ada_result; - __ada_task.done = true; - }); - return __ada_task; - } - func \(implementation)(\(declaration.parameters)) \(body) - """ - result.replaceSubrange(declaration.range, with: Array(replacement)) - } - return String(result) - } - - private static func parse( - at index: Int, - tokens: [Token], - characters: [Character], - path: String, - nonSendableTypes: Set - ) throws -> Declaration { - 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 closeParameters = closing(index + 3, tokens: tokens, open: "(", close: ")"), - tokens.indices.contains(closeParameters + 1), tokens[closeParameters + 1].text == "{", - let closeBody = closing(closeParameters + 1, tokens: tokens, open: "{", close: "}") else { - throw error(path, token.line, "expected 'async func name(...) { ... }'") - } - let ownerType = try enclosingType(at: index, tokens: tokens, path: path) - let name = tokens[index + 2].text - if let ownerType, nonSendableTypes.contains(ownerType) { - throw error(path, token.line, "async method captures @nonsendable type '\(ownerType)'") - } - let parameters = try parameterDeclarations(Array(tokens[(index + 4).., - tokens: [Token], - path: String - ) throws { - let globalNames = globalAsyncNames.union(declarations.filter { $0.ownerType == nil }.map(\.name)) - guard !globalNames.isEmpty else { - return - } - for index in tokens.indices where globalNames.contains(tokens[index].text) { - guard tokens.indices.contains(index + 1), tokens[index + 1].text == "(", - index > 0, tokens[index - 1].text != "func", tokens[index - 1].text != "." else { continue } - let awaited = tokens[index - 1].text == "await" - let started = index >= 4 && tokens[index - 1].text == "(" && tokens[index - 2].text == "start" - && tokens[index - 3].text == "." && tokens[index - 4].text == "Tasks" - guard awaited || started else { - throw error(path, tokens[index].line, "async call '\(tokens[index].text)' requires await or Tasks.start") - } - } - } - - private static func enclosingType(at index: Int, tokens: [Token], path: String) throws -> String? { - var scopes: [Scope] = [] - var segment = 0 - for cursor in 0.. [Parameter] { - guard !tokens.isEmpty else { - return [] - } - var parameters: [Parameter] = [] - var start = 0 - var depth = 0 - for cursor in 0...tokens.count { - if cursor < tokens.count { - if ["(", "[", "{"].contains(tokens[cursor].text) { depth += 1 } - if [")", "]", "}"].contains(tokens[cursor].text) { depth -= 1 } - } - guard cursor == tokens.count || (tokens[cursor].text == "," && depth == 0) else { continue } - guard start < cursor, tokens[start].kind == .identifier else { - throw error(path, line, "async parameters require named identifiers") - } - let typeIndex = tokens[start.. String { - var lexer = Lexer(source: source) - let tokens = lexer.lex() - var result = Array(source) - var replacements: [(Range, String)] = [] - for index in tokens.indices where tokens[index].text == "await" { - guard let end = awaitTargetEnd(after: index, tokens: tokens) else { - throw error(path, firstLine + tokens[index].line - 1, "await requires a task expression") - } - if tokens[(index + 1)...end].contains(where: { $0.text == "await" }) { - throw error(path, firstLine + tokens[index].line - 1, "move nested await expressions into separate statements") - } - let target = String(Array(source)[tokens[index + 1].startOffset.. $1.0.lowerBound }) { - result.replaceSubrange(range, with: Array(replacement)) - } - return String(result) - } - - private static func awaitTargetEnd(after index: Int, tokens: [Token]) -> Int? { - var cursor = index + 1 - guard tokens.indices.contains(cursor), tokens[cursor].kind == .identifier else { - return nil - } - while tokens.indices.contains(cursor + 2), tokens[cursor + 1].text == ".", tokens[cursor + 2].kind == .identifier { - cursor += 2 - } - if tokens.indices.contains(cursor + 1), tokens[cursor + 1].text == "(" { - guard let end = closing(cursor + 1, tokens: tokens, open: "(", close: ")") else { - return nil - } - cursor = end - } - return cursor - } - - private static func closing(_ opening: Int, tokens: [Token], open: String, close: String) -> Int? { - var depth = 0 - for index in opening.. AdaScriptAsyncSyntaxError { - .init(path: path, line: line, message: message) - } -} diff --git a/Sources/AdaScripting/AdaScriptTaskRuntime.swift b/Sources/AdaScripting/AdaScriptTaskRuntime.swift index e65f7c2bf..27d93e464 100644 --- a/Sources/AdaScripting/AdaScriptTaskRuntime.swift +++ b/Sources/AdaScripting/AdaScriptTaskRuntime.swift @@ -258,13 +258,17 @@ enum AdaScriptTaskPrelude { func complete(value) { if (done || cancelled) { return false; } - if (!__adaTasks.validateCapture([value])) { 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; diff --git a/Sources/AdaScripting/GravityScriptModule.swift b/Sources/AdaScripting/GravityScriptModule.swift index df4495e0e..3c4a38cab 100644 --- a/Sources/AdaScripting/GravityScriptModule.swift +++ b/Sources/AdaScripting/GravityScriptModule.swift @@ -97,7 +97,6 @@ enum GravityScriptModuleResolver { ) } - var globalAsyncNames = Set() var nonSendableTypeNames = Set() for path in orderedPaths { guard let source = sourceByPath[path] else { @@ -116,19 +115,17 @@ enum GravityScriptModuleResolver { continue } do { - let declarations = try AdaScriptAsyncLowerer.declarations( + let declarations = try AdaScriptAsyncDeclarationScanner.declarations( in: source.source, path: path, nonSendableTypes: nonSendableTypeNames ) asyncDeclarations += declarations - globalAsyncNames.formUnion(declarations.compactMap { $0.ownerType == nil ? $0.name : nil }) } catch let error as AdaScriptAsyncSyntaxError { throw AdaScriptError.invalidManifest(error.description) } } - let reachablePaths = Set(orderedPaths) var parsedSources: [String: ParsedSource] = [:] for path in sortedPaths { guard let source = sourceByPath[path] else { @@ -141,20 +138,9 @@ enum GravityScriptModuleResolver { throw AdaScriptError.invalidManifest(error.description) } let loweredAssetsSource = AdaScriptAssetsLowerer.lower(source: loweredViewSource) - let loweredAsyncSource: String - do { - loweredAsyncSource = try AdaScriptAsyncLowerer.lower( - source: loweredAssetsSource, - path: path, - globalAsyncNames: reachablePaths.contains(path) ? globalAsyncNames : [], - nonSendableTypes: reachablePaths.contains(path) ? nonSendableTypeNames : [] - ) - } catch let error as AdaScriptAsyncSyntaxError { - throw AdaScriptError.invalidManifest(error.description) - } let schemas = try AdaScriptSchemaParser.parse(sources: [source]) let loweredComponentSource = AdaScriptComponentLowerer.lower( - source: loweredAsyncSource, + source: loweredAssetsSource, schemas: schemas ) let loweredSource = AdaScriptNetworkLowerer.lower(source: loweredComponentSource) 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/AdaScriptAsyncLowererTests.swift b/Tests/AdaScriptingTests/AdaScriptAsyncLowererTests.swift deleted file mode 100644 index b044902cc..000000000 --- a/Tests/AdaScriptingTests/AdaScriptAsyncLowererTests.swift +++ /dev/null @@ -1,77 +0,0 @@ -import AdaScriptCompilerCore -import Testing - -@Suite("AdaScript async syntax") -struct AdaScriptAsyncLowererTests { - @Test("Preserves locals and continuation calls") - func lowersAsyncFunctionAndAwait() throws { - let source = try AdaScriptAsyncLowerer.lower(source: """ - async func answer(value) { - var confirmed = await wait_confirmation(value); - return confirmed; - } - """, path: "Answer.ada") - #expect(source.contains("Fiber.create")) - #expect(source.contains("__adaAwait(wait_confirmation(value))")) - #expect(source.contains("__ada_async_impl_answer")) - } - - @Test("Rejects await outside an async function") - func rejectsSynchronousAwait() { - #expect(throws: AdaScriptAsyncSyntaxError.self) { - try AdaScriptAsyncLowerer.lower(source: "func update() { await Tasks.nextFrame(); }", path: "Invalid.ada") - } - } - - @Test("Ordinary method and parameter names carry no suspension policy") - func allowsOrdinaryNames() throws { - let lowered = try AdaScriptAsyncLowerer.lower( - source: "class S { async func update(context) { await Tasks.nextFrame(); } }", - path: "Ordinary.ada" - ) - #expect(lowered.contains("__ada_async_impl_update")) - } - - @Test("Rejects async parameters and receivers with @nonsendable types") - func rejectsNonSendableTypes() { - #expect(throws: AdaScriptAsyncSyntaxError.self) { - try AdaScriptAsyncLowerer.lower(source: """ - @nonsendable class Borrowed {} - async func hold(value: Borrowed) { await Tasks.nextFrame(); } - """, path: "Invalid.ada") - } - #expect(throws: AdaScriptAsyncSyntaxError.self) { - try AdaScriptAsyncLowerer.lower(source: "@nonsendable class Borrowed { async func work() {} }", path: "Invalid.ada") - } - } - - @Test("Rejects unawaited calls") - func rejectsUnawaitedCalls() { - #expect(throws: AdaScriptAsyncSyntaxError.self) { - try AdaScriptAsyncLowerer.lower(source: """ - async func work() { return 1; } - func start() { work(); } - """, path: "Invalid.ada") - } - } - - @Test("A typed asset load keeps its type when awaited") - func lowersTypedAsyncAssetLoad() throws { - let assets = AdaScriptAssetsLowerer.lower(source: "var item: ShopItem = await Assets.loadAsync(\"@res://shop.item\");") - let lowered = try AdaScriptAsyncLowerer.lower( - source: "async func load() { \(assets) }", - path: "Shop.ada" - ) - #expect(lowered.contains("loadTypedAsync")) - #expect(lowered.contains("__adaAwait(__adaTaskFromOperation")) - } - - @Test("Rejects an unawaited async call imported from another source") - func rejectsCrossSourceUnawaitedCall() throws { - let names = try AdaScriptAsyncLowerer.globalFunctionNames(source: "async func loadShop() {}", path: "Shop.ada") - #expect(names == ["loadShop"]) - #expect(throws: AdaScriptAsyncSyntaxError.self) { - try AdaScriptAsyncLowerer.lower(source: "func begin() { loadShop(); }", path: "Main.ada", globalAsyncNames: names) - } - } -} From edb2ffe5f00c3d8147453f808a5ae62fbb83e36d Mon Sep 17 00:00:00 2001 From: SpectralDragon Date: Thu, 24 Sep 2026 16:25:23 +0300 Subject: [PATCH 4/4] Pin published Gravity native async compiler --- .gitattributes | 3 - ...15-adascript-async-tasks-and-coroutines.md | 7 +- .../patches/gravity-lang-native-async.patch | 475 ------------------ Package.resolved | 5 +- Package.swift | 6 +- 5 files changed, 6 insertions(+), 490 deletions(-) delete mode 100644 Documentation/ArchitectureDecisions/patches/gravity-lang-native-async.patch diff --git a/.gitattributes b/.gitattributes index 7fd308b40..838c4f501 100644 --- a/.gitattributes +++ b/.gitattributes @@ -34,6 +34,3 @@ Sources/miniaudio/** linguist-vendored Sources/msdf-atlas-gen/** linguist-vendored Sources/SPIRV-Cross/** linguist-vendored Sources/glslang/** linguist-vendored - -# Embedded upstream patches retain the original context whitespace. -Documentation/ArchitectureDecisions/patches/*.patch whitespace=-trailing-space,-blank-at-eof diff --git a/Documentation/ArchitectureDecisions/0015-adascript-async-tasks-and-coroutines.md b/Documentation/ArchitectureDecisions/0015-adascript-async-tasks-and-coroutines.md index b258deb22..e57a107eb 100644 --- a/Documentation/ArchitectureDecisions/0015-adascript-async-tasks-and-coroutines.md +++ b/Documentation/ArchitectureDecisions/0015-adascript-async-tasks-and-coroutines.md @@ -26,9 +26,8 @@ Implemented and covered by focused tests in this worktree: - [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 the local `gravity-lang` branch. The AdaEngine runtime still owns task - scheduling and suspension policy. Cross-repository publication and the - dependency pin remain pending. + in `gravity-lang` revision `24695757a0ba5638b3633004a2166b7878c116de`. + The AdaEngine runtime still owns task scheduling and suspension policy. Remaining before this ADR is fully implemented: @@ -39,8 +38,6 @@ Remaining before this ADR is fully implemented: - [ ] 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. -- [ ] Publish the tested `gravity-lang` compiler commit and pin AdaEngine to - that revision. The currently published dependency remains `0.9.9`. - [ ] Engine-owned, incremental snapshots of arbitrary ECS data, beyond the available bounded streaming writer. - [ ] A game time-scale resource, complete Editor diagnostics, and platform diff --git a/Documentation/ArchitectureDecisions/patches/gravity-lang-native-async.patch b/Documentation/ArchitectureDecisions/patches/gravity-lang-native-async.patch deleted file mode 100644 index 876b01df5..000000000 --- a/Documentation/ArchitectureDecisions/patches/gravity-lang-native-async.patch +++ /dev/null @@ -1,475 +0,0 @@ -From f3f4a8f636a692d06e97be4bb5656094ebb9966f Mon Sep 17 00:00:00 2001 -From: SpectralDragon -Date: Thu, 24 Sep 2026 16:04:42 +0300 -Subject: [PATCH] Parse and lower async functions in Gravity AST - ---- - .../GravityVirtualMachineTests.swift | 46 +++++ - src/compiler/gravity_ast.h | 2 + - src/compiler/gravity_parser.c | 170 +++++++++++++++++- - src/compiler/gravity_semacheck2.c | 21 +++ - src/compiler/gravity_token.c | 10 +- - src/compiler/gravity_token.h | 2 +- - 6 files changed, 241 insertions(+), 10 deletions(-) - -diff --git a/Tests/GravityTests/GravityVirtualMachineTests.swift b/Tests/GravityTests/GravityVirtualMachineTests.swift -index 098cbd4..b1676de 100644 ---- a/Tests/GravityTests/GravityVirtualMachineTests.swift -+++ b/Tests/GravityTests/GravityVirtualMachineTests.swift -@@ -3,6 +3,52 @@ import Testing - - @Suite("Gravity virtual machine", .serialized) - struct GravityVirtualMachineTests { -+ @Test("Native async declarations retain method state and nested awaits") -+ func executesNativeAsync() throws { -+ let delegate = TestVirtualMachineDelegate() -+ let virtualMachine = GravityVirtualMachine(settings: .init(), delegate: delegate) -+ let binary = virtualMachine.loadGravityFile(from: """ -+ class __AdaTask { -+ var fiber = null; -+ var value = null; -+ func capture(values) {} -+ func complete(value) { self.value = value; } -+ } -+ class Tasks { static func start(task) { return task; } } -+ func __adaAwait(task) { task.fiber.try(); return task.value; } -+ async func child(value) { return value + 2; } -+ class Counter { -+ var base = 7; -+ async func calculate(value) { return await child(value) + base; } -+ } -+ func main() { -+ var task = Tasks.start(Counter().calculate(3)); -+ task.fiber.try(); -+ return task.value; -+ } -+ """) -+ let result = try #require(virtualMachine.execute(binary)) -+ #expect(delegate.errors.isEmpty) -+ #expect(result.toInteger == 12) -+ } -+ -+ @Test("Native effect checks reject unawaited calls and synchronous awaits") -+ func rejectsInvalidAsyncEffects() { -+ let unawaited = TestVirtualMachineDelegate() -+ let vm1 = GravityVirtualMachine(settings: .init(), delegate: unawaited) -+ _ = vm1.loadGravityFile(from: """ -+ class __AdaTask { var fiber = null; func capture(values) {} func complete(value) {} } -+ async func child() { return 1; } -+ func main() { return child(); } -+ """) -+ #expect(unawaited.errors.contains(where: { $0.contains("requires await or Tasks.start") })) -+ -+ let synchronousAwait = TestVirtualMachineDelegate() -+ let vm2 = GravityVirtualMachine(settings: .init(), delegate: synchronousAwait) -+ _ = vm2.loadGravityFile(from: "func main() { return await 1; }") -+ #expect(synchronousAwait.errors.contains(where: { $0.contains("await requires an async function") })) -+ } -+ - @Test("Executes a script and returns its result") - func executesScript() throws { - let delegate = TestVirtualMachineDelegate() -diff --git a/src/compiler/gravity_ast.h b/src/compiler/gravity_ast.h -index 06b3fe3..fa6683f 100644 ---- a/src/compiler/gravity_ast.h -+++ b/src/compiler/gravity_ast.h -@@ -167,6 +167,7 @@ typedef struct { - uint16_t nparams; // formal parameters counter - bool has_defaults; // flag set if parmas has default values - bool is_closure; // flag to check if function is a closure -+ bool is_async; // declared async; body executes in a task fiber - gupvalue_r *uplist; // list of upvalues used in function (can be empty) - } gnode_function_decl_t; - typedef gnode_function_decl_t gnode_function_expr_t; -@@ -281,6 +282,7 @@ typedef struct { - gnode_t base; // NODE_CALLFUNC_EXPR, NODE_SUBSCRIPT_EXPR, NODE_ACCESS_EXPR - gnode_t *id; // id(...) or id[...] or id. - gnode_r *list; // list of postfix_subexpr -+ bool is_await; // source-level await, lowered to __adaAwait call - } gnode_postfix_expr_t; - - typedef struct { -diff --git a/src/compiler/gravity_parser.c b/src/compiler/gravity_parser.c -index c3702c3..376af90 100644 ---- a/src/compiler/gravity_parser.c -+++ b/src/compiler/gravity_parser.c -@@ -28,6 +28,7 @@ struct gravity_parser_t { - lexer_r *lexer; // stack of lexers (stack used in #include statements) - gnode_r *declarations; // used to keep track of nodes hierarchy - gnode_r *statements; // used to build AST -+ gnode_t *pending_async; // generated body declaration after its public wrapper - gravity_delegate_t *delegate; // compiler delegate - uint16_r vdecl; // to keep track of func expression in variable declaration nondes - -@@ -119,7 +120,7 @@ static gnode_r *parse_optional_parameter_declaration (gravity_parser_t *parser, - static gnode_t *parse_compound_statement (gravity_parser_t *parser); - static gnode_t *parse_expression (gravity_parser_t *parser); - static gnode_t *parse_declaration_statement (gravity_parser_t *parser); --static gnode_t *parse_function (gravity_parser_t *parser, bool is_declaration, gtoken_t access_specifier, gtoken_t storage_specifier); -+static gnode_t *parse_function (gravity_parser_t *parser, bool is_declaration, gtoken_t access_specifier, gtoken_t storage_specifier, bool is_async); - static gnode_t *adjust_assignment_expression (gravity_parser_t *parser, gtoken_t tok, gnode_t *lnode, gnode_t *rnode); - static gnode_t *parse_literal_expression (gravity_parser_t *parser); - static gnode_t *parse_macro_statement (gravity_parser_t *parser); -@@ -265,7 +266,123 @@ static bool parse_semicolon (gravity_parser_t *parser) { - #endif - } - --gnode_t *parse_function (gravity_parser_t *parser, bool is_declaration, gtoken_t access_specifier, gtoken_t storage_specifier) { -+static gnode_t *async_identifier(gtoken_s token, const char *name, gnode_t *owner) { -+ return gnode_identifier_expr_create(token, string_dup(name), NULL, owner); -+} -+ -+static gnode_t *async_call(gtoken_s token, const char *name, gnode_r *args, gnode_t *owner) { -+ gnode_r *parts = gnode_array_create(); -+ gnode_array_push(parts, gnode_postfix_subexpr_create(token, NODE_CALL_EXPR, NULL, args, NULL, owner)); -+ return gnode_postfix_expr_create(token, async_identifier(token, name, owner), parts, owner); -+} -+ -+static gnode_t *async_member(gtoken_s token, gnode_t *base, const char *name, gnode_t *owner) { -+ gnode_r *parts = gnode_array_create(); -+ gnode_t *member = async_identifier(token, name, owner); -+ gnode_array_push(parts, gnode_postfix_subexpr_create(token, NODE_ACCESS_EXPR, member, NULL, NULL, owner)); -+ return gnode_postfix_expr_create(token, base, parts, owner); -+} -+ -+static gnode_t *async_invoke(gtoken_s token, gnode_t *target, gnode_r *args, gnode_t *owner) { -+ gnode_r *parts = gnode_array_create(); -+ gnode_array_push(parts, gnode_postfix_subexpr_create(token, NODE_CALL_EXPR, NULL, args, NULL, owner)); -+ if (target->tag == NODE_POSTFIX_EXPR) { -+ gnode_postfix_expr_t *postfix = (gnode_postfix_expr_t *)target; -+ gnode_array_push(postfix->list, gnode_array_get(parts, 0)); -+ gnode_array_free(parts); -+ return target; -+ } -+ return gnode_postfix_expr_create(token, target, parts, owner); -+} -+ -+static gnode_t *async_local(gtoken_s token, const char *name, gnode_t *value, gnode_t *owner) { -+ gnode_r *variables = gnode_array_create(); -+ gnode_array_push(variables, gnode_variable_create(token, string_dup(name), NULL, value, owner, NULL)); -+ return gnode_variable_decl_create(token, TOK_KEY_VAR, 0, 0, variables, owner); -+} -+ -+static gnode_t *async_return(gtoken_s token, gnode_t *value, gnode_t *owner) { -+ token.type = TOK_KEY_RETURN; -+ return gnode_jump_stat_create(token, value, owner); -+} -+ -+static gnode_t *lower_async_function(gravity_parser_t *parser, gnode_function_decl_t *implementation, bool captures_self) { -+ gtoken_s token = implementation->base.token; -+ const char *public_name = implementation->identifier; -+ size_t private_name_size = strlen(public_name) + 48; -+ char *private_name = mem_alloc(NULL, private_name_size); -+ snprintf(private_name, private_name_size, "$ada_async_impl_%s_%u", public_name, ++parser->unique_id); -+ implementation->identifier = private_name; -+ implementation->is_async = false; -+ -+ gnode_function_decl_t *wrapper = (gnode_function_decl_t *)gnode_function_decl_create( -+ token, public_name, implementation->access, implementation->storage, NULL, NULL, implementation->base.decl); -+ wrapper->is_async = true; -+ wrapper->params = gnode_array_create(); -+ for (size_t i = 0; i < gnode_array_size(implementation->params); ++i) { -+ gnode_var_t *param = (gnode_var_t *)gnode_array_get(implementation->params, i); -+ gnode_t *default_value = param->expr ? gnode_duplicate(param->expr, true) : NULL; -+ gnode_array_push(wrapper->params, gnode_variable_create(token, string_dup(param->identifier), -+ param->annotation_type ? string_dup(param->annotation_type) : NULL, default_value, (gnode_t *)wrapper, NULL)); -+ } -+ wrapper->has_defaults = implementation->has_defaults; -+ -+ gnode_r *statements = gnode_array_create(); -+ const char *receiver_name = "__ada_receiver"; -+ if (captures_self) { -+ gnode_array_push(statements, async_local(token, receiver_name, async_identifier(token, SELF_PARAMETER_NAME, (gnode_t *)wrapper), (gnode_t *)wrapper)); -+ } -+ gnode_array_push(statements, async_local(token, "__ada_task", async_call(token, "__AdaTask", gnode_array_create(), (gnode_t *)wrapper), (gnode_t *)wrapper)); -+ -+ gnode_r *captured = gnode_array_create(); -+ if (captures_self) gnode_array_push(captured, async_identifier(token, receiver_name, (gnode_t *)wrapper)); -+ for (size_t i = 1; i < gnode_array_size(wrapper->params); ++i) { -+ gnode_var_t *param = (gnode_var_t *)gnode_array_get(wrapper->params, i); -+ gnode_array_push(captured, async_identifier(token, param->identifier, (gnode_t *)wrapper)); -+ } -+ gnode_r *capture_args = gnode_array_create(); -+ gnode_array_push(capture_args, gnode_list_expr_create(token, captured, NULL, false, (gnode_t *)wrapper)); -+ gnode_t *capture_call = async_invoke(token, -+ async_member(token, async_identifier(token, "__ada_task", (gnode_t *)wrapper), "capture", (gnode_t *)wrapper), -+ capture_args, (gnode_t *)wrapper); -+ gnode_array_push(statements, capture_call); -+ -+ gnode_function_decl_t *fiber_body = (gnode_function_decl_t *)gnode_function_decl_create(token, NULL, 0, 0, NULL, NULL, (gnode_t *)wrapper); -+ fiber_body->is_closure = true; -+ fiber_body->params = gnode_array_create(); -+ gnode_array_push(fiber_body->params, gnode_variable_create(token, string_dup(SELF_PARAMETER_NAME), NULL, NULL, (gnode_t *)fiber_body, NULL)); -+ gnode_r *implementation_args = gnode_array_create(); -+ for (size_t i = 1; i < gnode_array_size(wrapper->params); ++i) { -+ gnode_var_t *param = (gnode_var_t *)gnode_array_get(wrapper->params, i); -+ gnode_array_push(implementation_args, async_identifier(token, param->identifier, (gnode_t *)fiber_body)); -+ } -+ gnode_t *target = captures_self -+ ? async_member(token, async_identifier(token, receiver_name, (gnode_t *)fiber_body), private_name, (gnode_t *)fiber_body) -+ : async_identifier(token, private_name, (gnode_t *)fiber_body); -+ gnode_t *body_call = async_invoke(token, target, implementation_args, (gnode_t *)fiber_body); -+ gnode_r *finish_args = gnode_array_create(); -+ gnode_array_push(finish_args, body_call); -+ gnode_t *finish_call = async_invoke(token, -+ async_member(token, async_identifier(token, "__ada_task", (gnode_t *)fiber_body), "complete", (gnode_t *)fiber_body), -+ finish_args, (gnode_t *)fiber_body); -+ gnode_r *fiber_statements = gnode_array_create(); -+ gnode_array_push(fiber_statements, finish_call); -+ fiber_body->block = (gnode_compound_stmt_t *)gnode_block_stat_create(NODE_COMPOUND_STAT, token, fiber_statements, (gnode_t *)fiber_body, 0); -+ -+ gnode_r *fiber_args = gnode_array_create(); -+ gnode_array_push(fiber_args, (gnode_t *)fiber_body); -+ gnode_t *fiber_create = async_invoke(token, -+ async_member(token, async_identifier(token, "Fiber", (gnode_t *)wrapper), "create", (gnode_t *)wrapper), -+ fiber_args, (gnode_t *)wrapper); -+ gnode_t *fiber_property = async_member(token, async_identifier(token, "__ada_task", (gnode_t *)wrapper), "fiber", (gnode_t *)wrapper); -+ gnode_array_push(statements, gnode_binary_expr_create(TOK_OP_ASSIGN, fiber_property, fiber_create, (gnode_t *)wrapper)); -+ gnode_array_push(statements, async_return(token, async_identifier(token, "__ada_task", (gnode_t *)wrapper), (gnode_t *)wrapper)); -+ wrapper->block = (gnode_compound_stmt_t *)gnode_block_stat_create(NODE_COMPOUND_STAT, token, statements, (gnode_t *)wrapper, 0); -+ parser->pending_async = (gnode_t *)implementation; -+ return (gnode_t *)wrapper; -+} -+ -+static gnode_t *parse_function (gravity_parser_t *parser, bool is_declaration, gtoken_t access_specifier, gtoken_t storage_specifier, bool is_async) { - DECLARE_LEXER; - - // access_specifier? storage_specifier? already parsed -@@ -293,6 +410,7 @@ gnode_t *parse_function (gravity_parser_t *parser, bool is_declaration, gtoken_t - - // create func declaration node - gnode_function_decl_t *func = (gnode_function_decl_t *) gnode_function_decl_create(token, identifier, access_specifier, storage_specifier, NULL, NULL, LAST_DECLARATION()); -+ func->is_async = is_async; - - // check and consume TOK_OP_OPEN_PARENTHESIS - if (!is_implicit) parse_required(parser, TOK_OP_OPEN_PARENTHESIS); -@@ -321,6 +439,7 @@ gnode_t *parse_function (gravity_parser_t *parser, bool is_declaration, gtoken_t - func->has_defaults = has_default_values; - func->params = params; - func->block = compound; -+ if (is_async) return lower_async_function(parser, func, IS_CLASS_ENCLOSED()); - return (gnode_t *)func; - } - -@@ -625,7 +744,7 @@ static gnode_t *parse_function_expression (gravity_parser_t *parser) { - // if it is a func keyword used to refers to - // the current executing function - -- gnode_t *node = parse_function(parser, false, 0, 0); -+ gnode_t *node = parse_function(parser, false, 0, 0, false); - return node; - } - -@@ -1154,6 +1273,23 @@ static gnode_t *parse_unary (gravity_parser_t *parser) { - return gnode_unary_expr_create(tok, node, LAST_DECLARATION()); - } - -+static gnode_t *parse_await_expression (gravity_parser_t *parser) { -+ DECLARE_LEXER; -+ gravity_lexer_next(lexer); -+ gtoken_s token = gravity_lexer_token(lexer); -+ gnode_t *enclosing = get_enclosing(parser, NODE_FUNCTION_DECL); -+ if (!enclosing || !((gnode_function_decl_t *)enclosing)->is_async) { -+ REPORT_ERROR(token, "await requires an async function."); -+ } -+ gnode_t *value = parse_precedence(parser, PREC_UNARY); -+ if (!value) return NULL; -+ gnode_r *args = gnode_array_create(); -+ gnode_array_push(args, value); -+ gnode_postfix_expr_t *call = (gnode_postfix_expr_t *)async_call(token, "__adaAwait", args, LAST_DECLARATION()); -+ call->is_await = true; -+ return (gnode_t *)call; -+} -+ - static gnode_t *parse_infix (gravity_parser_t *parser) { - DEBUG_PARSER("parse_infix"); - -@@ -1228,6 +1364,7 @@ static void init_grammer_rules (void) { - - rules[TOK_OP_OPEN_CURLYBRACE] = PREFIX(PREC_LOWEST, parse_function_expression); - rules[TOK_KEY_FUNC] = PREFIX(PREC_LOWEST, parse_function_expression); -+ rules[TOK_KEY_AWAIT] = PREFIX(PREC_LOWEST, parse_await_expression); - - rules[TOK_IDENTIFIER] = PREFIX(PREC_LOWEST, parse_identifier_expression); - rules[TOK_STRING] = PREFIX(PREC_LOWEST, parse_literal_expression); -@@ -1696,7 +1833,12 @@ static gnode_t *parse_event_declaration (gravity_parser_t *parser, gtoken_t acce - return NULL; - } - --static gnode_t *parse_function_declaration (gravity_parser_t *parser, gtoken_t access_specifier, gtoken_t storage_specifier) { -+static gnode_t *parse_function_declaration (gravity_parser_t *parser, gtoken_t access_specifier, gtoken_t storage_specifier, bool is_async) { -+ if (is_async && IS_FUNCTION_ENCLOSED()) { -+ DECLARE_LEXER; -+ REPORT_ERROR(gravity_lexer_token(lexer), "Nested async functions are not supported."); -+ return NULL; -+ } - // convert a function declaration within another function to a local variable assignment - // for example: - // -@@ -1713,7 +1855,7 @@ static gnode_t *parse_function_declaration (gravity_parser_t *parser, gtoken_t a - // conversion is performed inside the parser - // so next semantic checks can perform - // identifier uniqueness checks -- gnode_t *node = parse_function(parser, true, access_specifier, storage_specifier); -+ gnode_t *node = parse_function(parser, true, access_specifier, storage_specifier, is_async); - - if (IS_FUNCTION_ENCLOSED()) { - gnode_function_decl_t *func = (gnode_function_decl_t *)node; -@@ -1837,6 +1979,10 @@ static gnode_t *parse_class_declaration (gravity_parser_t *parser, gtoken_t acce - ? parse_special_statement(parser) - : parse_declaration_statement(parser); - if (decl) gnode_array_push(declarations, decl_check_access_specifier(decl)); -+ if (parser->pending_async) { -+ gnode_array_push(declarations, parser->pending_async); -+ parser->pending_async = NULL; -+ } - peek = gravity_lexer_peek(lexer); - } - POP_DECLARATION(); -@@ -2425,7 +2571,15 @@ static gnode_t *parse_declaration_statement (gravity_parser_t *parser) { - - switch (peek) { - case TOK_MACRO: return parse_macro_statement(parser); -- case TOK_KEY_FUNC: return parse_function_declaration(parser, access_specifier, storage_specifier); -+ case TOK_KEY_FUNC: return parse_function_declaration(parser, access_specifier, storage_specifier, false); -+ case TOK_KEY_ASYNC: { -+ gravity_lexer_next(lexer); -+ if (gravity_lexer_peek(lexer) != TOK_KEY_FUNC) { -+ REPORT_ERROR(gravity_lexer_token(lexer), "async must precede func."); -+ return NULL; -+ } -+ return parse_function_declaration(parser, access_specifier, storage_specifier, true); -+ } - case TOK_KEY_ENUM: return parse_enum_declaration(parser, access_specifier, storage_specifier); - case TOK_KEY_MODULE: return parse_module_declaration(parser, access_specifier, storage_specifier); - case TOK_KEY_EVENT: return parse_event_declaration(parser, access_specifier, storage_specifier); -@@ -2779,6 +2933,10 @@ static uint32_t parser_run (gravity_parser_t *parser) { - while (gravity_lexer_peek(CURRENT_LEXER)) { - gnode_t *node = parse_statement(parser); - if (node) gnode_array_push(parser->statements, node); -+ if (parser->pending_async) { -+ gnode_array_push(parser->statements, parser->pending_async); -+ parser->pending_async = NULL; -+ } - } - - // since it is a stack of lexers then check if it is a real EOF -diff --git a/src/compiler/gravity_semacheck2.c b/src/compiler/gravity_semacheck2.c -index 93463b0..be582fe 100644 ---- a/src/compiler/gravity_semacheck2.c -+++ b/src/compiler/gravity_semacheck2.c -@@ -16,6 +16,7 @@ struct semacheck_t { - gnode_r *declarations; // declarations stack - uint16_r statements; // statements stack - uint32_t lasterror; // last error line number to prevent reporting more than one error per line -+ bool allow_async_call; // direct operand of await or Tasks.start - }; - typedef struct semacheck_t semacheck_t; - -@@ -1045,6 +1046,15 @@ static void visit_postfix_expr (gvisitor_t *self, gnode_postfix_expr_t *node) { - if (ISA(target, NODE_VARIABLE)) target = NULL; // a variable does not contain a symbol table - } - -+ semacheck_t *state = (semacheck_t *)self->data; -+ if (ISA(target, NODE_FUNCTION_DECL) && ((gnode_function_decl_t *)target)->is_async && -+ gnode_array_size(node->list) > 0 && -+ ISA(gnode_array_get(node->list, 0), NODE_CALL_EXPR) && !state->allow_async_call) { -+ REPORT_ERROR(node, "Async call to %s requires await or Tasks.start.", ((gnode_function_decl_t *)target)->identifier); -+ } -+ bool was_allowed = state->allow_async_call; -+ state->allow_async_call = false; -+ - // special enum case on list[0] (it is a static case) - if (ISA(target, NODE_ENUM_DECL)) { - // check first expression in the list (in case of enum MUST BE an identifier) -@@ -1127,7 +1137,17 @@ static void visit_postfix_expr (gvisitor_t *self, gnode_postfix_expr_t *node) { - for (size_t j=0; jargs, j); - if (is_expression_assignment(val)) {REPORT_ERROR(val, "Assignment does not have side effects and so cannot be used as function argument."); return;} -+ bool allow_argument = node->is_await && j == 0; -+ if (!allow_argument && i > 0 && j == 0 && ISA(node->id, NODE_IDENTIFIER_EXPR)) { -+ gnode_identifier_expr_t *identifier = (gnode_identifier_expr_t *)node->id; -+ gnode_postfix_subexpr_t *member = (gnode_postfix_subexpr_t *)gnode_array_get(node->list, i - 1); -+ allow_argument = strcmp(identifier->value, "Tasks") == 0 && -+ ISA(member, NODE_ACCESS_EXPR) && ISA(member->expr, NODE_IDENTIFIER_EXPR) && -+ strcmp(((gnode_identifier_expr_t *)member->expr)->value, "start") == 0; -+ } -+ state->allow_async_call = allow_argument; - visit(val); -+ state->allow_async_call = false; - } - continue; - } -@@ -1148,6 +1168,7 @@ static void visit_postfix_expr (gvisitor_t *self, gnode_postfix_expr_t *node) { - DEBUG_SEMA2("UNRECOGNIZED POSTFIX OPTIONAL EXPRESSION"); - assert(0); - } -+ state->allow_async_call = was_allowed; - } - - static void visit_file_expr (gvisitor_t *self, gnode_file_expr_t *node) { -diff --git a/src/compiler/gravity_token.c b/src/compiler/gravity_token.c -index dd794dd..56ba3ff 100644 ---- a/src/compiler/gravity_token.c -+++ b/src/compiler/gravity_token.c -@@ -28,6 +28,8 @@ const char *token_name (gtoken_t token) { - // keywords - case TOK_KEY_FILE: return "file"; - case TOK_KEY_FUNC: return "func"; -+ case TOK_KEY_ASYNC: return "async"; -+ case TOK_KEY_AWAIT: return "await"; - case TOK_KEY_SUPER: return "super"; - case TOK_KEY_DEFAULT: return "default"; - case TOK_KEY_TRUE: return "true"; -@@ -122,7 +124,7 @@ const char *token_name (gtoken_t token) { - - void token_keywords_indexes (uint32_t *idx_start, uint32_t *idx_end) { - *idx_start = (uint32_t)TOK_KEY_FUNC; -- *idx_end = (uint32_t)TOK_KEY_CURRARGS; -+ *idx_end = (uint32_t)TOK_KEY_AWAIT; - }; - - gtoken_t token_special_builtin(gtoken_s *token) { -@@ -195,6 +197,8 @@ gtoken_t token_keyword (const char *buffer, int32_t len) { - break; - - case 5: -+ if (string_casencmp(buffer, "async", len) == 0) return TOK_KEY_ASYNC; -+ if (string_casencmp(buffer, "await", len) == 0) return TOK_KEY_AWAIT; - if (string_casencmp(buffer, "super", len) == 0) return TOK_KEY_SUPER; - if (string_casencmp(buffer, "false", len) == 0) return TOK_KEY_FALSE; - if (string_casencmp(buffer, "break", len) == 0) return TOK_KEY_BREAK; -@@ -311,7 +315,7 @@ bool token_isprimary_expression (gtoken_t token) { - (token == TOK_KEY_FALSE) || (token == TOK_IDENTIFIER) || (token == TOK_KEY_NULL) || - (token == TOK_KEY_SUPER) || (token == TOK_KEY_FUNC) || (token == TOK_KEY_UNDEFINED) || - (token == TOK_OP_OPEN_PARENTHESIS) || (token == TOK_OP_OPEN_SQUAREBRACKET) || -- (token == TOK_OP_OPEN_CURLYBRACE) || (token == TOK_KEY_FILE)); -+ (token == TOK_OP_OPEN_CURLYBRACE) || (token == TOK_KEY_FILE) || (token == TOK_KEY_AWAIT)); - - } - -@@ -355,7 +359,7 @@ bool token_isdeclaration_statement (gtoken_t token) { - // empty_declaration (;) - - return ((token_isaccess_specifier(token) || token_isstorage_specifier(token) || token_isvariable_declaration(token) || -- (token == TOK_KEY_FUNC) || (token == TOK_KEY_CLASS) || (token == TOK_KEY_STRUCT) || (token == TOK_KEY_ENUM) || -+ (token == TOK_KEY_FUNC) || (token == TOK_KEY_ASYNC) || (token == TOK_KEY_CLASS) || (token == TOK_KEY_STRUCT) || (token == TOK_KEY_ENUM) || - (token == TOK_KEY_MODULE) || (token == TOK_KEY_EVENT) || (token == TOK_OP_SEMICOLON))); - } - -diff --git a/src/compiler/gravity_token.h b/src/compiler/gravity_token.h -index 3bfe433..6fa64f7 100644 ---- a/src/compiler/gravity_token.h -+++ b/src/compiler/gravity_token.h -@@ -71,7 +71,7 @@ typedef enum { - TOK_KEY_REPEAT, TOK_KEY_FOR, TOK_KEY_IN, TOK_KEY_ENUM, TOK_KEY_CLASS, TOK_KEY_STRUCT, TOK_KEY_PRIVATE, - TOK_KEY_FILE, TOK_KEY_INTERNAL, TOK_KEY_PUBLIC, TOK_KEY_STATIC, TOK_KEY_EXTERN, TOK_KEY_LAZY, TOK_KEY_CONST, - TOK_KEY_VAR, TOK_KEY_MODULE, TOK_KEY_IMPORT, TOK_KEY_CASE, TOK_KEY_EVENT, TOK_KEY_NULL, TOK_KEY_UNDEFINED, -- TOK_KEY_ISA, TOK_KEY_CURRFUNC, TOK_KEY_CURRARGS, -+ TOK_KEY_ISA, TOK_KEY_CURRFUNC, TOK_KEY_CURRARGS, TOK_KEY_ASYNC, TOK_KEY_AWAIT, - - // Operators (36) - TOK_OP_SHIFT_LEFT, TOK_OP_SHIFT_RIGHT, TOK_OP_MUL, TOK_OP_DIV, TOK_OP_REM, TOK_OP_BIT_AND, TOK_OP_ADD, TOK_OP_SUB, --- -2.50.1 (Apple Git-155) - 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 a9722576d..5e0142549 100644 --- a/Package.swift +++ b/Package.swift @@ -1298,11 +1298,9 @@ let package = Package( ) package.dependencies += [ - ProcessInfo.processInfo.environment["ADAENGINE_GRAVITY_LOCAL_PATH"].map { - .package(name: "gravity-lang", path: $0) - } ?? .package( + .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"),