diff --git a/newLang/docs/architecture.md b/newLang/docs/architecture.md index fd89ac7..06de1e9 100644 --- a/newLang/docs/architecture.md +++ b/newLang/docs/architecture.md @@ -840,6 +840,37 @@ nothing, that deletion calls the destructor once with the fields intact and restores the full free capacity, that a `$FFFF` instance fails without calling init, and that a class without a destructor still frees completely. +### 8.4 Deleting through an interface (E06-S06) + +Lifetime is the one operation an interface value performs on its object, so it +lives in `src/runtime/ifelife.s` rather than in the dispatch module: value +fixtures that never allocate stay free of the heap and construction code. + +`if_delete` takes A/X = value pointer. A zero pointer, or a value whose object +half is zero, is a successful no-op (C=0, `IF_OK`) and the table half is +ignored — `delete null` through an interface must not call anything. Otherwise +the object half is validated with `heap_check` before any indirect transfer; +only a live block reaches `obj_delete`. A refusal returns C=1 with the new +`IF_ERR_FREED` and leaves `destroy_count` and the free capacity untouched, so +the promise is that a stale value is *refused*, not that memory is inspected +for a destructor that may already be gone. + +On success the four value bytes are cleared. That keeps the ordinary single +owner path idempotent — a second `delete` through the same value is a no-op — +without adding a reference count. Aliases are ordinary four-byte copies and may +outlive the deletion; per §8 the detection mechanism is the heap's always-on +validation (range, used bit, even size, `$A55A` magic), which `heap_check` +exposes to this caller. Poisoning remains optional and is not what makes the +stale case correct. + +The fixture deletes through a value, checks the destructor ran once and the +full capacity returned, treats a null value and a value with a table but no +object as no-ops, deletes one object through a second interface table and then +shows the first value refused with `IF_ERR_FREED`, and finally deletes through +an alias copy that outlived the deletion. `objects.n8` carries the same shape +through the parser: two interfaces, a class implementing both, and calls and a +`delete` through an interface variable. + ## 9. KERNAL and Disk I/O The compiler uses documented KERNAL entry points through `diskio.s` wrappers. @@ -861,15 +892,22 @@ files. Version 1 does not link the editor and compiler in memory. The development image workflow is strictly sequential: -1. copy `../vim/work.d81` to `build/newlang-dev.d81`; -2. inject `N128C.PRG`, examples, and fixtures; -3. launch `x64sc` and autostart `VIM` for editing; -4. close the process and wait for image flush; -5. launch `x128` and autostart `N128C.PRG` for compilation; -6. close the compiler session before inspecting or modifying the image. - -Scripts must refuse obvious concurrent use where detectable. Tests create a -fresh image per run. +1. `scripts/launch-editor.ps1` copies `../vim/vim-improved.d81` to + `build/newlang-edit.d81`, launches `x64sc` and autostarts + `../vim/vim128.prg`; +2. the script returns only after the emulator exits and the image is flushed, + releasing its session lock; +3. `scripts/launch-compiler.ps1` prepares `build/newlang-dev.d81` (unless + `-SkipPrepare`), launches `x128` and autostarts `build/n128c.prg`; +4. close the compiler session before editing, inspecting or copying the image. + +Both launchers share `scripts/lib/Nova128Launcher.psm1`, which holds an +exclusive `.newlang-session.lock` beside the writable image and reopens the +image exclusively before returning, so a second launcher cannot attach to a +disk a live session owns and no caller sees a partially written image. An +unreadable lock counts as held. `docs/editor-workflow.md` states the order, +recovery for a stale lock and the prohibition on two writable sessions; tests +create a fresh image per run and never write `../vim` disks. ## 11. Diagnostics and Faults diff --git a/newLang/docs/decisions.md b/newLang/docs/decisions.md index 7681755..a656a40 100644 --- a/newLang/docs/decisions.md +++ b/newLang/docs/decisions.md @@ -79,6 +79,10 @@ documented default. `Proposed` means a later change still needs approval. | D146 | E06-S05 makes an interface call resolvable at its call site. `src/common/ifcalls.inc` fixes the call-site result codes (`IS_OK`, `IS_ERR_METHOD`), and `src/compiler/interfaces.s` gains `iface_slot` (A = interface name ID, X = method name ID -> A = the declaration slot from `C_MEM_SLOT`, or C=1 with `IS_ERR_METHOD` when the interface declares no such method, which covers a class or program ID too). At run time the slot indexes the table half of the fat reference, so the compile-time slot and the runtime table pointer together select the method; nothing at run time re-derives interface order. | The call sequence itself was already `if_call` (E06-S02) plus the frame ABI, so what was missing was a way for emission to learn the slot without re-deriving declaration order, and an end-to-end proof of the story's four behaviours. The x128 call fixture reaches two classes from one call site, two interfaces from one object by swapping only the table pointer, a nested call with a one-byte result, and a byte/word argument pair returning a word in A/X with the frame stack restored; it also checks `self` is the object, not the value record. | +| D147 | E06-S06 gives interface values one lifetime operation in a separate module. `src/runtime/ifelife.s` adds `if_delete` (A/X = value pointer) so deletion does not force value and dispatch fixtures to link the heap, objects and construction. A zero pointer or a value whose object half is zero is a no-op with C=0/`IF_OK` (the table half is ignored, because deletion reaches the object). Otherwise the object half is passed to `heap_check` before anything indirect happens, and only a live block reaches `obj_delete`; the four value bytes are cleared so deleting through the same value twice is a no-op. A refused delete returns C=1 with the new `IF_ERR_FREED` (6) and calls and frees nothing. `heap_check` in `heap.s` exposes the always-on validation subset `heap_free` already performed: range, used bit, even size, and the `$A55A` used magic. | Architecture §8 says aliases may outlive a deletion and that the always-on heap validation, not poisoning, is what makes a stale pointer detectable. Checking the object half before the destructor address is read turns use-after-free into a defined refusal instead of reading a freed header, and clearing the value keeps the common single-owner path idempotent without adding a reference count. Keeping the check inside `heap.s` means the rule is written once; a switch to optional poisoning later cannot leave deletion unguarded. | + +| D148 | E07-S02 puts both emulator launchers on one shared implementation, `scripts/lib/Nova128Launcher.psm1`. `Enter-Nova128ImageSession` takes an exclusive `.newlang-session.lock` beside the writable image, replaces a stale lock only when its PID is gone, and refuses when the holder is alive or the lock cannot even be read; `Wait-Nova128ImageFlush` reopens the image exclusively with retries so a launcher never returns while the drive is still writing; `Invoke-Nova128Emulator` starts the emulator, waits for exit, then waits for flush and releases the lock. `scripts/launch-editor.ps1` uses `x64sc` with `../vim/vim128.prg` and copies `../vim/vim-improved.d81` to `build/newlang-edit.d81`; `scripts/launch-compiler.ps1` uses `x128` with `build/n128c.prg` and prepares the development image unless `-SkipPrepare`. Both autostart a host-side program path, so no viewer binary has to be on the disk, and both accept `-LimitCycles`, `-Warp` and `-Screenshot`. | The story's rule is that the editor and the compiler never hold the same writable disk at once, which is only reliable if both launchers implement the lock and the flush wait identically. A lock file beside the image is detectable from either script and from a test without a shared daemon; an unreadable lock counts as held because an exclusive holder denies reads too. Copying the editor disk per session keeps `../vim/vim-improved.d81` a read-only original, matching E07-S01's rule for the compiler disk. `x64sc` names the VIC-II exit screenshot `-exitscreenshot` while `x128` uses `-exitscreenshotvicii`, so the option is chosen by emulator name. `docs/editor-workflow.md` carries the user-facing order and recovery rules, and `tests/disk/launchers.tests.ps1` proves the lock, the flush wait, both autostarts and that the source disk hash is unchanged. | + ## Deferred - Native C128/80-column Vim port diff --git a/newLang/docs/editor-workflow.md b/newLang/docs/editor-workflow.md new file mode 100644 index 0000000..6282536 --- /dev/null +++ b/newLang/docs/editor-workflow.md @@ -0,0 +1,71 @@ +--- +type: workflow-guide +source: E07-S01 disk builder and E07-S02 launchers +--- + +# Editor and Compiler Workflow + +Nova128 does not link the editor and compiler in memory. A development disk is +edited in one emulator session and compiled in another, strictly in sequence. +`scripts/prepare-disk.ps1` (E07-S01) copies `../vim/work.d81` to a disposable +image without ever writing the source; the launchers below add the two +sessions. + +## Sequential rule + +Never attach the same disk image writable to two emulators at once, and never +edit an image while a compiler session still has it open. VICE buffers DOS +writes, so a second session can read a half-written directory or overwrite the +first session's changes. The launchers enforce this with a session lock in the +image's folder (`.newlang-session.lock`); the lock holds the owning process and +its kind, and a lock left behind by a dead process is replaced automatically. + +## Editor session + +```text +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\launch-editor.ps1 +``` + +- Autostarts `../vim/vim128.prg` in `x64sc` with the disk image attached as a + true drive. +- Copies `../vim/vim-improved.d81` to `build/newlang-edit.d81` first, so the + editor never opens the source image. +- Waits for `x64sc` to exit, then waits until the image can be opened + exclusively (the image has flushed) before returning. +- Verifies the source image's SHA-256 is unchanged. + +Use `-EditorPrg` and `-SourceImage` to select another editor build or disk. +`-LimitCycles` and `-Screenshot` are for automated checks only; they end the +session early. + +## Compiler session + +```text +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\launch-compiler.ps1 +``` + +- Rebuilds `build/newlang-dev.d81` from the clean source copy with + `prepare-disk.ps1` (unless `-SkipPrepare` is given), injecting + `build/n128c.prg`. +- Autostarts that compiler PRG in `x128` with the development image attached. +- Waits for `x128` to exit and for the image to flush before returning. + +`-CompilerPrg` selects another compiler PRG. The launcher reports a missing +compiler program, source image, or VICE tool before starting anything. + +## Recovery + +- **Lock reported as still owned**: close the named session's emulator window + first; if the process is gone, the launcher replaces the stale lock. +- **Image still locked after the emulator exits**: wait for the DOS write to + finish; the launcher retries for 30 seconds before failing. +- **Source image changed**: stop and inspect `../vim` with `git status`; the + launchers only ever copy from it. +- **Wrong editor or compiler build**: pass `-EditorPrg`/`-CompilerPrg` + explicitly rather than editing the defaults. + +## Limits + +Autostart loads the program from the host file system, not from the disk; the +disk stays attached for the session's own file access. Physical C128 testing +and the full edit/compile/run walkthrough belong to E07-S04 and E08-S06. diff --git a/newLang/docs/memory-map.md b/newLang/docs/memory-map.md index 6c4aad0..9aa3f0b 100644 --- a/newLang/docs/memory-map.md +++ b/newLang/docs/memory-map.md @@ -154,7 +154,7 @@ its normal frame and reads them where the caller pushed them. | Argument order | caller pushes four bytes right-to-left, object low at `FP+2` | | Four-byte result | `IF_RESULT` = `$C6-$C9`; the caller copies it before the next call | | Module zero page | `if_obj`, `if_tab`, `if_dst`, `if_src`, `if_vec`, `if_self` (link-allocated) | -| Module status | `if_error`: `IF_OK`, `IF_ERR_NULL`, `IF_ERR_TABLE`, `IF_ERR_ENTRY`, `IF_ERR_STACK` | +| Module status | `if_error`: `IF_OK`, `IF_ERR_NULL`, `IF_ERR_TABLE`, `IF_ERR_ENTRY`, `IF_ERR_STACK`, `IF_ERR_FREED` (lifetime, E06-S06) | | Dispatch | `if_call` takes A/X = value, Y = slot; a refusal leaves A/X=0, C=1 and sets `if_error` | | Dispatch floor | TSX at or below `FRAME_HW_MIN` (`$40`) is refused with `IF_ERR_STACK` | | Table emitter | `it_begin` A/X = first table address; `it_set_addr` A/X = address, Y = member row; `it_class` A = class name ID | @@ -174,8 +174,26 @@ its normal frame and reads them where the caller pushed them. implements `if_conv`, `if_copy`, `if_is_null`, `if_slot`, `if_call`, `if_ret_store`, and `if_ret_load`. The module asserts at link time that `IF_RESULT` follows the print vectors (`$C0-$C5`) and ends below the for-end -values (`$D0`), and that `if_vec` does not cross a page. Interface lifetime -remains E06-S06. +values (`$D0`), and that `if_vec` does not cross a page. + +## Interface Lifetime (E06-S06) + +| Element | Value | +|---|---| +| `if_delete` | A/X = value pointer; deletes the object half once and clears the value | +| Null delete | zero pointer, or object half zero with any table, is C=0/`IF_OK` and calls nothing | +| Liveness check | `heap_check` on the object half before `obj_delete`; refusal is C=1/`IF_ERR_FREED` | +| `heap_check` | A/X = payload; validates range, used bit, even size, `$A55A` magic; C=1 with `HEAP_ERR_RANGE`/`DOUBLE`/`CORRUPT` | +| After a successful delete | the four value bytes are zero, so a repeated delete of the same value is a no-op | +| Aliases | an outliving copy is refused by the same check; there is no ownership count | +| Module zero page | `il_val`, `il_obj` (link-allocated) | +| Fixture | bank 0 heap `$4000-$4FFF`, code below `$4000`, five phase flags | + +`src/runtime/ifelife.s` holds `if_delete`; it links `obj_delete`, `heap_check` +and the `if_error` word from `interfaces.s`. `src/common/interfaces.inc` fixes +`IF_ERR_FREED = 6`. The module has no fixed addresses, so generated programs +keep choosing the heap region, and the damage from a forged value is bounded by +the heap's own validation rather than by a private copy of it. ## Mappings diff --git a/newLang/planning/claims/E06-S06.md b/newLang/planning/claims/E06-S06.md new file mode 100644 index 0000000..0da5459 --- /dev/null +++ b/newLang/planning/claims/E06-S06.md @@ -0,0 +1,110 @@ +--- +story: E06-S06 +owner: codebuff +status: done +started: 2026-11-10 +completed: 2026-11-10 +depends-on: [E06-S05, E05-S03] +--- + +# E06-S06 Claim + +## Intended change + +Give an interface value one defined lifetime operation. `if_delete` (A/X = value +pointer) treats `delete null` as a no-op, validates the object half with the +heap's always-on check before any destructor address is read, deletes a live +object exactly once through `obj_delete`, and clears the value so a repeated +delete is a no-op. A refusal is C=1 with the new `IF_ERR_FREED`, and nothing is +called or freed. Aliases stay ordinary copies; the heap validation, not a +reference count, is what makes an outliving alias detectable. + +## File area + +- `src/runtime/ifelife.s` (new): `if_delete` +- `src/runtime/heap.s`: `heap_check` (exported validation subset of `heap_free`) +- `src/common/interfaces.inc`: `IF_ERR_FREED = 6` +- `tests/runtime/lifetime-test.s` (new), `scripts/build-lifetime-test.ps1`, + `scripts/verify-lifetime-test.ps1` (new) +- `tests/semantic/objects.n8`, `tests/semantic/objects.expect` (new) +- `tests/parser/parser-test.s`: `OBJECTS.N8` fixture, count 43 +- `scripts/build-parser-test.ps1`: `objects.n8` expectation entry +- `docs/decisions.md` (D147), `docs/architecture.md` (8.4), + `docs/memory-map.md` (lifetime rows) +- `planning/claims/E06-S06.md` +- generated files under `build/` + +## Acceptance evidence + +- [x] `delete` through an interface invokes the destructor and frees once: + phase 1 allocates a live object through `obj_new`, deletes it by calling + `if_delete` on a value whose table is `tab_foo`, and requires + `destroy_count = 1`, `if_error = IF_OK`, `heap_free_total = $1000`, and the + four value bytes zero. Phase 3 does the same for an object reached through a + second interface table and checks the destructor count rises to exactly 2. +- [x] `delete null-interface` is a no-op: phase 2 deletes a zero value and a + value with a nonzero table but zero object, both with C=0, no destructor call + (`destroy_count` still 1) and no error. +- [x] Null calls fault predictably: phase 5 calls slot 0 through a null value + and requires C=1, `if_error = IF_ERR_NULL` and no method run (`hit_count` + still 0), then shows the next call on a live value succeeds and bumps + `hit_count` to 1. `if_call` itself is unchanged by this story; `if_delete` + reports C=1 only for a value that is not live. +- [x] A use-after-free scenario produces a detectable failure: phase 3 keeps the + first value after deleting through the second and requires C=1 with + `IF_ERR_FREED`, `destroy_count` still 2 and the free capacity unchanged, so a + freed object's destructor is never read. Phase 4 copies the value to an alias + first, deletes through the original value, and requires the alias to be + refused the same way with the destructor count still 3 and the free capacity + back to `$1000`. Detection is the always-on + `heap_check` (range, used bit, even size, `$A55A` magic) that also guards + `heap_free`, which is the mechanism architecture §8 promises; poisoning + remains optional. +- [x] `objects.n8` covers multiple interfaces end to end: the semantic fixture + declares `Printable` and `Resettable`, a `Counter` implementing both and a + `Ticker`, and uses interface variables for calls and for `delete`; the parser + accepts it with zero diagnostics and dumps 5 types, 10 members and 3 interface + slots. + +## Coordination notes + +- Depends on E06-S05 (slots and calls) and E05-S03 (`obj_new`/`obj_delete`); + both are complete. The dispatch path is untouched: `if_call` was not modified, + so lifetime cannot regress call behaviour. +- `if_delete` lives in its own module so the value/dispatch fixtures do not link + the heap, objects or construction code; `heap_check` lives in `heap.s` so the + validation rule exists once rather than as a second copy for deletion. +- Generated-code emission of `delete` through an interface is still compiler + work; this story fixes and proves the runtime contract. + +## Work log + +- `heap_check` first failed to assemble with three out-of-range branches; the + short branches now fall through to `jmp` trampolines, matching the project's + ±127-byte convention. +- First fixture build stopped at a misuse of a marker routine as a macro + (`POINT_OK val_foo`); the zero check became a callable `chk_zero`. +- The first runtime run reported phase 1 as failed with the mailbox holding a + breadcrumb instead of an object pointer: the debug `MARK` macro reloads A, so + it must run before the descriptor is loaded, not between the load and + `obj_new`. +- Phase 2 then failed because BSS is not part of the PRG image and `val_null` + held whatever RAM had; the fixture now writes its null values explicitly, + which is also a reminder that generated programs must zero their own state. + +## Completion + +- Tests run: `scripts/verify-lifetime-test.ps1` (x128, fresh D81), + `scripts/verify-parser-test.ps1` (43 semantic/parser fixtures), + `git diff --check`. +- Results: the lifetime fixture reports PASS with all five flags and prints + `LIFETIME OK`; its PRG ends `$2C85`, below the `$4000` code ceiling. The + parser suite reports `verified parser: 43 fixtures`, with `objects.n8` + accepted as 5 types, 10 members, 3 interface slots and the lowest expression + stack pointer `$A7`. +- Remaining risks: `if_delete` trusts a value whose object half is live, so a + forged value whose object is a live block of another class reaches that + class's destructor through `obj_delete`'s normal descriptor read; the value's + table half is deliberately ignored, so deleting through a stale table is not + detected; and a value that is not four bytes in RAM cannot be cleared, which + the emitter must respect when `delete` is wired into code generation. diff --git a/newLang/planning/claims/E07-S02.md b/newLang/planning/claims/E07-S02.md new file mode 100644 index 0000000..0a430d7 --- /dev/null +++ b/newLang/planning/claims/E07-S02.md @@ -0,0 +1,97 @@ +--- +story: E07-S02 +owner: codebuff +status: done +started: 2026-11-10 +completed: 2026-11-10 +depends-on: [E07-S01, E04-S01] +--- + +# E07-S02 Claim + +## Intended change + +Two launchers, one shared implementation: `scripts/launch-editor.ps1` starts +`x64sc` on a per-session copy of the Vim disk and autostarts the Vim program, +and `scripts/launch-compiler.ps1` starts `x128` and autostarts the compiler. +Both take an exclusive lock beside the writable image, wait for the emulator to +exit and for the image to be flushed before returning, and refuse a second +writable session. `docs/editor-workflow.md` states the order, recovery for a +stale lock, and the prohibition on simultaneous writable attachment. + +## File area + +- `scripts/lib/Nova128Launcher.psm1` (new): `Get-Nova128ViceTool`, + `Enter-Nova128ImageSession`, `Exit-Nova128ImageSession`, + `Wait-Nova128ImageFlush`, `Invoke-Nova128Emulator` +- `scripts/launch-editor.ps1`, `scripts/launch-compiler.ps1` (new) +- `tests/disk/launchers.tests.ps1` (new) +- `docs/editor-workflow.md` (new), `docs/decisions.md` (D148), + `docs/architecture.md` (§10) +- `planning/claims/E07-S02.md` +- generated files under `build/` + +## Acceptance evidence + +- [x] Editor launcher uses `x64sc` and autostarts Vim: + `scripts/launch-editor.ps1` defaults to `../vim/vim128.prg`, copies + `../vim/vim-improved.d81` to `build/newlang-edit.d81`, and passes the program + path to `x64sc` for autostart. `tests/disk/launchers.tests.ps1` runs it with + `-X64Sc` against a copy of the fixture disk and asserts the emulator exits, + the session lock is released and the image stays writable afterwards. +- [x] Compiler launcher uses `x128` and autostarts the compiler: it defaults to + `build/n128c.prg`, prepares `build/newlang-dev.d81` (copy plus injection) + unless `-SkipPrepare` is given, and autostarts the compiler in `x128`; the + test exercises the same sequence with `-SkipPrepare` on a disposable image. +- [x] Launchers wait for process/image flush before returning: the shared + `Exit-Nova128ImageSession` waits for emulator exit and `Wait-Nova128ImageFlush` + then reopens the image exclusively with retries; the test takes the lock in + the foreground and asserts the launcher refuses, and asserts the image is + reopenable for writing immediately after each launcher returns. +- [x] Documentation and warnings prohibit simultaneous writable attachment: + `docs/editor-workflow.md` and the launcher banners state that only one + writable session may exist and that the previous session must be closed + before editing or inspecting the disk; the lock makes the rule enforced, not + merely documented. + +## Coordination notes + +- Depends on E07-S01 (image preparation) and E04-S01 (container/autostart + shape); both are complete. The compiler launcher reuses E07-S01's prepare + script rather than duplicating injection. +- Autostart uses a host-side program path, so no viewer binary has to live on + the disk and `../vim` stays read-only; only the session's own image is + writable and only for drive 8. +- VICE names exit screenshots differently per emulator: `x64sc` accepts + `-exitscreenshot` while `x128` accepts `-exitscreenshotvicii`. The module + chooses by emulator name after the tests caught a missing screenshot. + +## Work log + +- The first test run failed the lock case with "cannot access the lock file" + instead of a lock message: a live holder opens the file without sharing, so + reading it can fail. An unreadable lock is now treated as held and reported as + "another Nova128 session already holds ". +- The screenshot assertion then failed for `x64sc` because `-exitscreenshotvicii` + is the `x128` spelling; the option is now selected by emulator name. +- The compiler test passed no disk, so the launcher correctly refused with + "disk image not found"; the test now copies its source image to the target + first, which is also what the real workflow does through E07-S01. + +## Completion + +- Tests run: `tests/disk/launchers.tests.ps1` (lock refusal, flush wait, + `x64sc` autostart, `x128` autostart, source-disk hash), + `scripts/verify-lifetime-test.ps1`, `scripts/verify-parser-test.ps1`, + `git diff --check`. +- Results: the launcher suite prints `launcher tests passed`, covering the disk + copy, lock refusal, flush wait, both autostarts and an unchanged source disk + (`0FF83A621B59B312F1BAE9F1D0785421500D22FD00F833A9CA918EBCA7105983`). Both + wrappers also allow `-LimitCycles`, `-Warp` and `-Screenshot` for unattended + runs. +- Remaining risks: the lock is advisory and identifies a holder by PID, so an + unrelated process that reuses a dead holder's PID in the window before the + lock is read is treated as alive; a launcher killed from the task manager + leaves the lock for the next run to replace; and Vim's own disk writes are + only guaranteed visible after VICE exits cleanly, which the flush wait assumes + but cannot force if the emulator is killed. diff --git a/newLang/scripts/build-lifetime-test.ps1 b/newLang/scripts/build-lifetime-test.ps1 new file mode 100644 index 0000000..79b8ff8 --- /dev/null +++ b/newLang/scripts/build-lifetime-test.ps1 @@ -0,0 +1,29 @@ +# E06-S06: build the interface lifetime fixture and its D81. +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +$project = Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $PSScriptRoot "lib\Nova128Test.psm1") -Force +$out = Join-Path $project "build\lifetime-test" +New-Item -ItemType Directory -Force -Path $out | Out-Null + +$link = Invoke-Nova128Link -Name "lifetime-test" -BuildDir $out ` + -Config (Join-Path $project "cfg\compiler.cfg") -Sources @( + (Join-Path $project "src\runtime\print.s"), + (Join-Path $project "src\runtime\frames.s"), + (Join-Path $project "src\runtime\heap.s"), + (Join-Path $project "src\runtime\objects.s"), + (Join-Path $project "src\runtime\construct.s"), + (Join-Path $project "src\runtime\interfaces.s"), + (Join-Path $project "src\runtime\ifelife.s"), + (Join-Path $project "tests\runtime\lifetime-test.s")) +if ((Get-Nova128Label $link.Labels "test_entry") -ne 0x1C0D) { throw "test_entry is not at `$1C0D" } +$end = Get-Nova128Label $link.Labels "test_end" +if ($end -ge 0x4000) { + throw ("lifetime-test ends at `$" + $end.ToString("X4") + "; it must stay below `$4000") +} + +New-Nova128Disk -Path (Join-Path $out "lifetime-test.d81") -Files @( + @{ Name = "lifetime-test"; Path = $link.Program; Type = "prg" }) +Write-Host ("built {0} (end {1:X4})" -f $link.Program, $end) diff --git a/newLang/scripts/build-parser-test.ps1 b/newLang/scripts/build-parser-test.ps1 index 0ae197a..4f67803 100644 --- a/newLang/scripts/build-parser-test.ps1 +++ b/newLang/scripts/build-parser-test.ps1 @@ -56,6 +56,7 @@ $script:ParserFixtures = [ordered]@{ "interfaces.n8" = @("$t\interfaces.n8", "iface", "$t\interfaces.expect") "ifacebad.n8" = @("$t\iface_bad.n8", "exact", "$t\iface_bad.expect") "unresolved.n8" = @("$p\unresolved.n8", "exact", "$p\unresolved.expect") + "objects.n8" = @("$t\objects.n8", "iface", "$t\objects.expect") } $link = Invoke-Nova128Link -Name "parser-test" -BuildDir $out ` diff --git a/newLang/scripts/launch-compiler.ps1 b/newLang/scripts/launch-compiler.ps1 new file mode 100644 index 0000000..7962495 --- /dev/null +++ b/newLang/scripts/launch-compiler.ps1 @@ -0,0 +1,47 @@ +# E07-S02: prepare a disposable development disk and autostart the Nova128 +# compiler in x128. +# +# The session is strictly sequential: this launcher refuses to start while an +# editor session owns the same image, waits for x128 to exit and for the image +# to flush, and rebuilds the disposable image from its clean source. +[CmdletBinding()] +param( + [string]$CompilerPrg = "", + [string]$DiskImage = "", + [int]$LimitCycles = 0, + [string]$Screenshot = "", + [switch]$Warp, + [switch]$SkipPrepare +) + +$ErrorActionPreference = "Stop" +$project = Split-Path $PSScriptRoot -Parent +$build = Join-Path $project "build" +Import-Module (Join-Path $PSScriptRoot "lib\Nova128Launcher.psm1") -Force + +if ($CompilerPrg -eq "") { $CompilerPrg = Join-Path $build "n128c.prg" } +if ($DiskImage -eq "") { $DiskImage = Join-Path $build "newlang-dev.d81" } +$CompilerPrg = [IO.Path]::GetFullPath($CompilerPrg) +$DiskImage = [IO.Path]::GetFullPath($DiskImage) + +if (-not (Test-Path -LiteralPath $CompilerPrg -PathType Leaf)) { + throw "compiler program not found: $CompilerPrg (build N128C.PRG first)" +} +$null = Get-Nova128ViceTool "x128" + +if (-not $SkipPrepare) { + & (Join-Path $PSScriptRoot "prepare-disk.ps1") -OutputImage $DiskImage ` + -CompilerPrg $CompilerPrg -RequireCompiler | Out-Host +} + +$session = $null +try { + $session = Enter-Nova128ImageSession -Image $DiskImage -Kind "compiler" + $null = Invoke-Nova128Emulator -Emulator "x128" -Program $CompilerPrg ` + -DiskImage $DiskImage -Screenshot $Screenshot -LimitCycles $LimitCycles -Warp:$Warp +} finally { + if ($session) { Exit-Nova128ImageSession -Session $session } +} + +Write-Host "compiler session closed and the image is flushed" +Write-Host "close this session before editing or inspecting the disk again" diff --git a/newLang/scripts/launch-editor.ps1 b/newLang/scripts/launch-editor.ps1 new file mode 100644 index 0000000..180732c --- /dev/null +++ b/newLang/scripts/launch-editor.ps1 @@ -0,0 +1,64 @@ +# E07-S02: autostart the Vim editor in x64sc on a disposable disk copy. +# +# The session is strictly sequential: this launcher refuses to start while a +# compiler session owns the same image, waits for x64sc to exit and for the +# image to flush, and never writes the source disk it copies from. +[CmdletBinding()] +param( + [string]$EditorPrg = "", + [string]$SourceImage = "", + [string]$DiskImage = "", + [int]$LimitCycles = 0, + [string]$Screenshot = "", + [switch]$Warp +) + +$ErrorActionPreference = "Stop" +$project = Split-Path $PSScriptRoot -Parent +$repository = Split-Path $project -Parent +$build = Join-Path $project "build" +Import-Module (Join-Path $PSScriptRoot "lib\Nova128Launcher.psm1") -Force + +if ($EditorPrg -eq "") { $EditorPrg = Join-Path $repository "vim\vim128.prg" } +if ($SourceImage -eq "") { $SourceImage = Join-Path $repository "vim\vim-improved.d81" } +if ($DiskImage -eq "") { $DiskImage = Join-Path $build "newlang-edit.d81" } +$EditorPrg = [IO.Path]::GetFullPath($EditorPrg) +$DiskImage = [IO.Path]::GetFullPath($DiskImage) +if ($SourceImage) { $SourceImage = [IO.Path]::GetFullPath($SourceImage) } + +if (-not (Test-Path -LiteralPath $EditorPrg -PathType Leaf)) { + throw "editor program not found: $EditorPrg (build Vim or pass -EditorPrg)" +} +$null = Get-Nova128ViceTool "x64sc" + +$sourceHash = $null +New-Item -ItemType Directory -Force -Path (Split-Path $DiskImage -Parent) | Out-Null +if ($SourceImage) { + if (-not (Test-Path -LiteralPath $SourceImage -PathType Leaf)) { + throw "source disk image not found: $SourceImage" + } + if ([string]::Equals($SourceImage, $DiskImage, [StringComparison]::OrdinalIgnoreCase)) { + throw "the editor must never start on the source disk image" + } + $sourceHash = (Get-FileHash -LiteralPath $SourceImage -Algorithm SHA256).Hash + if (Test-Path -LiteralPath $DiskImage) { Remove-Item -LiteralPath $DiskImage -Force } + Copy-Item -LiteralPath $SourceImage -Destination $DiskImage + Write-Host "fresh editor disk: $DiskImage" +} + +$session = $null +try { + $session = Enter-Nova128ImageSession -Image $DiskImage -Kind "editor" + $null = Invoke-Nova128Emulator -Emulator "x64sc" -Program $EditorPrg ` + -DiskImage $DiskImage -Screenshot $Screenshot -LimitCycles $LimitCycles -Warp:$Warp +} finally { + if ($session) { Exit-Nova128ImageSession -Session $session } +} + +if ($sourceHash) { + $after = (Get-FileHash -LiteralPath $SourceImage -Algorithm SHA256).Hash + if ($after -ne $sourceHash) { throw "the source disk image changed during the editor session" } + Write-Host "source disk unchanged: $after" +} +Write-Host "editor session closed and the image is flushed" +Write-Host "next: run scripts\launch-compiler.ps1; never attach two writable sessions" diff --git a/newLang/scripts/lib/Nova128Launcher.psm1 b/newLang/scripts/lib/Nova128Launcher.psm1 new file mode 100644 index 0000000..59b050c --- /dev/null +++ b/newLang/scripts/lib/Nova128Launcher.psm1 @@ -0,0 +1,143 @@ +# Shared helpers for the sequential Nova128 editor/compiler launchers (E07-S02). +# Import-Module (Join-Path $PSScriptRoot "lib\Nova128Launcher.psm1") -Force + +$script:ViceBin = "C:\Program Files\GTK3VICE-3.10-win64\bin" + +function Get-Nova128ViceTool([string]$Name) { + $path = Join-Path $script:ViceBin "$Name.exe" + if (-not (Test-Path -LiteralPath $path)) { throw "$Name.exe not found at $path" } + return $path +} + +# One writable session per image directory. The lock holds the owning PID and +# kind; a lock whose process is gone is stale and is replaced. +function Enter-Nova128ImageSession { + param( + [Parameter(Mandatory)][string]$Image, + [Parameter(Mandatory)][ValidateSet("editor", "compiler")][string]$Kind + ) + $folder = Split-Path ([IO.Path]::GetFullPath($Image)) -Parent + New-Item -ItemType Directory -Force -Path $folder | Out-Null + $lockPath = Join-Path $folder ".newlang-session.lock" + + if (Test-Path -LiteralPath $lockPath) { + # A live holder keeps the file open without sharing, so even reading it + # can fail. That is the same answer as a readable live PID. + $text = "" + try { + $text = Get-Content -Raw -LiteralPath $lockPath + } catch { + throw "another Nova128 session already holds $Image" + } + $owner = 0 + $ownerKind = "another" + if ($text -match '(?m)^(\d+)\s+(\S+)') { + $owner = [int]$Matches[1] + $ownerKind = $Matches[2] + } + $alive = $owner -gt 0 -and $null -ne (Get-Process -Id $owner -ErrorAction SilentlyContinue) + if ($alive) { + throw "the $ownerKind session (pid $owner) still owns $Image; close it before starting another" + } + Remove-Item -LiteralPath $lockPath -Force + } + + try { + $stream = [IO.File]::Open($lockPath, [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, [IO.FileShare]::None) + } catch { + throw "another Nova128 session already holds $Image" + } + $writer = [IO.StreamWriter]::new($stream) + $writer.WriteLine("$PID $Kind") + $writer.Flush() + return [pscustomobject]@{ Path = $lockPath; Stream = $stream; Writer = $writer + Image = $Image; Kind = $Kind } +} + +function Exit-Nova128ImageSession { + param([Parameter(Mandatory)]$Session) + try { + $Session.Writer.Dispose() + $Session.Stream.Dispose() + } finally { + if (Test-Path -LiteralPath $Session.Path) { + Remove-Item -LiteralPath $Session.Path -Force + } + if ($Session.Image) { + Wait-Nova128ImageFlush -Image $Session.Image + } + } +} + +# The emulator has exited, but DOS writes may still be in flight. Return only +# when the image can be opened exclusively. +function Wait-Nova128ImageFlush { + param( + [Parameter(Mandatory)][string]$Image, + [int]$TimeoutSeconds = 30 + ) + if (-not (Test-Path -LiteralPath $Image)) { return } + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + while ($true) { + try { + $stream = [IO.File]::Open($Image, [IO.FileMode]::Open, + [IO.FileAccess]::Read, [IO.FileShare]::None) + $stream.Dispose() + return + } catch { + if ([DateTime]::UtcNow -ge $deadline) { + throw "the emulator exited but $Image is still locked (image not flushed)" + } + Start-Sleep -Milliseconds 200 + } + } +} + +# Start an emulator and wait for it to exit. Autostart uses the host program +# path, so the program is deterministic; the disk image stays attached for the +# session's own reads and writes. +function Invoke-Nova128Emulator { + param( + [Parameter(Mandatory)][ValidateSet("x64sc", "x128")][string]$Emulator, + [Parameter(Mandatory)][string]$Program, + [string]$DiskImage, + [string]$Screenshot, + [int]$LimitCycles = 0, + [switch]$Warp + ) + $tool = Get-Nova128ViceTool $Emulator + if (-not (Test-Path -LiteralPath $Program)) { throw "program not found: $Program" } + + $arguments = @() + if ($DiskImage) { + if (-not (Test-Path -LiteralPath $DiskImage)) { + throw "disk image not found: $DiskImage" + } + $arguments += @("-drive8type", "1581", "-drive8truedrive", "-8", + ('"' + $DiskImage + '"')) + } + $arguments += @("-autostart", ('"' + $Program + '"'), "-autostart-warp", + "-sounddev", "dummy") + if ($Warp) { $arguments += "-warp" } + if ($LimitCycles -gt 0) { $arguments += @("-limitcycles", "$LimitCycles") } + if ($Screenshot) { + if (Test-Path -LiteralPath $Screenshot) { + Remove-Item -LiteralPath $Screenshot -Force + } + # x64sc names the VIC-II exit screenshot differently from x128. + $shotOption = if ($Emulator -eq "x64sc") { "-exitscreenshot" } else { "-exitscreenshotvicii" } + $arguments += @($shotOption, ('"' + $Screenshot + '"')) + } + + Write-Host ("launching {0} with {1}" -f $Emulator, $Program) + $process = Start-Process -FilePath $tool -ArgumentList $arguments -PassThru -Wait + if ($Screenshot -and -not (Test-Path -LiteralPath $Screenshot)) { + throw "$Emulator did not create the exit screenshot $Screenshot" + } + if ($DiskImage) { Wait-Nova128ImageFlush -Image $DiskImage } + return $process.ExitCode +} + +Export-ModuleMember -Function Get-Nova128ViceTool, Enter-Nova128ImageSession, + Exit-Nova128ImageSession, Wait-Nova128ImageFlush, Invoke-Nova128Emulator diff --git a/newLang/scripts/verify-lifetime-test.ps1 b/newLang/scripts/verify-lifetime-test.ps1 new file mode 100644 index 0000000..d228560 --- /dev/null +++ b/newLang/scripts/verify-lifetime-test.ps1 @@ -0,0 +1,49 @@ +# E06-S06: run the interface lifetime fixture in x128 and check its mailbox. +[CmdletBinding()] +param([int]$Port = 6561, [int]$TimeoutSeconds = 180) + +$ErrorActionPreference = "Stop" +$project = Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $PSScriptRoot "lib\Nova128Test.psm1") -Force +& (Join-Path $PSScriptRoot "build-lifetime-test.ps1") + +$out = Join-Path $project "build\lifetime-test" +$disk = Join-Path $out "lifetime-test.d81" +$failures = @() + +$session = Start-Nova128X128 -Autostart $disk -Drive8Image $disk -Port $Port ` + -Screenshot (Join-Path $out "lifetime-test-vicii.png") +try { + $mb = Wait-Nova128Mailbox $session -TimeoutSeconds $TimeoutSeconds + Start-Sleep -Milliseconds 500 + $screenRam = Read-Nova128Memory $session 0x0400 1000 +} finally { + Stop-Nova128X128 $session -SettleSeconds 0 +} + +if ($mb[2] -ne 0x03) { $failures += "producer $($mb[2]) expected 3" } +if ($mb[3] -ne 0xA5) { $failures += ("status {0:X2} expected A5" -f $mb[3]) } +if ($mb[4] -ne 0 -or $mb[5] -ne 0) { $failures += "code $($mb[4] + 256 * $mb[5]) expected 0" } +for ($i = 0; $i -lt 5; $i++) { + if ($mb[8 + $i] -ne 1) { $failures += "phase $i flag is $($mb[8 + $i]), expected 1" } +} + +$rows = for ($r = 0; $r -lt 25; $r++) { + $chars = for ($c = 0; $c -lt 40; $c++) { + $code = $screenRam[$r * 40 + $c] + if ($code -le 0x1F) { [char]($code + 0x40) } elseif ($code -le 0x3F) { [char]$code } else { '?' } + } + (-join $chars).TrimEnd() +} +if (-not ($rows -contains "LIFETIME OK")) { + $failures += "screen does not contain 'LIFETIME OK'" +} + +if ($failures) { + throw ("interface lifetime verification failed (code {0}, addr {1:X4}):`n " -f ($mb[4] + 256 * $mb[5]), ($mb[6] + 256 * $mb[7]) + + ($failures -join "`n ") + "`n mailbox: " + ($mb -join ' ') + "`n screen:`n " + (($rows | Where-Object { $_ }) -join "`n ")) +} +Write-Host "screen:" +$rows | Where-Object { $_ } | ForEach-Object { Write-Host " |$_" } +Write-Host "mailbox: producer 3, PASS, code 0, all 5 lifetime flags set" +Write-Host "verified delete through one and two interface values, null delete, alias detection, and the null call fault" diff --git a/newLang/src/common/interfaces.inc b/newLang/src/common/interfaces.inc index 06b51a1..315870c 100644 --- a/newLang/src/common/interfaces.inc +++ b/newLang/src/common/interfaces.inc @@ -37,5 +37,6 @@ IF_ERR_TABLE = 2 ; non-null object with no method table IF_ERR_ENTRY = 3 ; method table slot holds address zero IF_ERR_STACK = 4 ; 6502 stack too low for a dispatch frame IF_ERR_RANGE = 5 ; array element address wrapped past $FFFF +IF_ERR_FREED = 6 ; delete through a value whose object is gone .endif diff --git a/newLang/src/runtime/heap.s b/newLang/src/runtime/heap.s index 55c1b44..a1fec67 100644 --- a/newLang/src/runtime/heap.s +++ b/newLang/src/runtime/heap.s @@ -12,7 +12,7 @@ .setcpu "6502" .include "../common/heap.inc" -.export heap_init, heap_alloc, heap_free +.export heap_init, heap_alloc, heap_free, heap_check .export heap_base, heap_end, heap_free_head, heap_free_total, heap_error .segment "ZEROPAGE" @@ -400,6 +400,123 @@ heap_zero: @done: rts +; A/X = payload pointer. C=0 when it names an allocated block whose header is +; inside the heap, marked used, even-sized, and carries the used magic. C=1 +; with HEAP_ERR_DOUBLE when the block is already free, HEAP_ERR_RANGE when it +; lies outside the heap, or HEAP_ERR_CORRUPT. This is the same validation +; heap_free applies, exposed so a delete can refuse a stale object pointer +; before a destructor is read out of freed memory. +heap_check: + sta heap_arg + stx heap_arg+1 + lda #HEAP_OK + sta heap_error + lda heap_arg + ora heap_arg+1 + bne :+ + jmp @corrupt +: lda heap_arg + and #1 + beq :+ + jmp @corrupt +: lda heap_arg + sec + sbc #HEAP_HEADER_SIZE + sta heap_block + lda heap_arg+1 + sbc #0 + sta heap_block+1 + bcs :+ + jmp @corrupt +: lda heap_block+1 + cmp heap_base+1 + bcs @base_high_ok + jmp @range +@base_high_ok: + beq @base_same + jmp @above +@base_same: + lda heap_block + cmp heap_base + bcs @above + jmp @range +@above: lda heap_block+1 + cmp heap_end+1 + bcc @in_range + bne @end_high_bad + lda heap_block + cmp heap_end + bcc @in_range +@end_high_bad: + jmp @corrupt +@in_range: + lda heap_block + sta heap_ptr + lda heap_block+1 + sta heap_ptr+1 + ldy #0 + lda (heap_ptr),y + sta heap_bsize + iny + lda (heap_ptr),y + sta heap_bsize+1 + and #$80 + bne :+ + jmp @double +: lda heap_bsize+1 + and #$7F + sta heap_bsize+1 + ora heap_bsize + beq @corrupt + lda heap_bsize + and #1 + bne @corrupt + lda heap_block + clc + adc heap_bsize + sta heap_tmp + lda heap_block+1 + adc heap_bsize+1 + sta heap_tmp+1 + bcs @far_corrupt + lda heap_tmp+1 + cmp heap_end+1 + bcc @end_ok + bne @end_bad + lda heap_tmp + cmp heap_end + bcc @end_ok +@end_bad: + jmp @corrupt +@end_ok: + ldy #2 + lda (heap_ptr),y + cmp #$5A + bne @magic_bad + iny + lda (heap_ptr),y + cmp #$A5 + bne @magic_bad + clc + rts +@far_corrupt: + jmp @corrupt +@magic_bad: + jmp @corrupt +@double: + lda #HEAP_ERR_DOUBLE + jmp @set +@range: + lda #HEAP_ERR_RANGE + jmp @set +@corrupt: + lda #HEAP_ERR_CORRUPT +@set: sta heap_error + lda #$00 + tax + sec + rts + ; A/X = payload pointer. Null is a successful no-op. On success the block is ; free and coalesced; on failure C=1 and heap_error is set. heap_free: diff --git a/newLang/src/runtime/ifelife.s b/newLang/src/runtime/ifelife.s new file mode 100644 index 0000000..1303937 --- /dev/null +++ b/newLang/src/runtime/ifelife.s @@ -0,0 +1,85 @@ +; ifelife.s - deleting through an interface value (E06-S06). +; +; Kept apart from interfaces.s so value transfer and dispatch fixtures do not +; have to link the heap, objects, and construction. The architecture's +; lifetime policy is that aliases may outlive a deletion and that the always-on +; heap validation is the detection mechanism; this module applies that policy +; to an interface value instead of introducing ownership counts. +; +; API: +; if_delete A/X = interface value pointer. A canonical null value (or a +; zero pointer) is a successful no-op, which is `delete null` +; through an interface. Otherwise the object half is checked with +; heap_check before anything jumps: a value whose object has +; already been freed is refused with IF_ERR_FREED instead of +; reading a destructor address out of freed memory. A live object +; is deleted once by obj_delete, and the four value bytes are +; cleared so deleting through the same value again is a no-op. +; The table half is ignored: deletion reaches the object, not the +; interface. +; +; C=0 means the object was deleted or there was nothing to delete; C=1 with +; if_error = IF_ERR_FREED means the value did not name a live object and +; nothing was called or freed. + +.setcpu "6502" +.include "../common/interfaces.inc" + +.import obj_delete +.import heap_check +.import if_error + +.export if_delete + +.segment "ZEROPAGE" +il_val: .res 2 ; the interface value being deleted +il_obj: .res 2 ; its object half + +.segment "CODE" + +; A/X = value pointer. See the API above. +if_delete: + sta il_val + stx il_val+1 + lda #IF_OK + sta if_error + lda il_val + ora il_val+1 + beq @ok + ldy #IF_OBJECT + lda (il_val),y + sta il_obj + iny + lda (il_val),y + sta il_obj+1 + ora il_obj + beq @ok + lda il_obj + ldx il_obj+1 + jsr heap_check + bcc @live + jmp @freed +@live: lda il_obj + ldx il_obj+1 + jsr obj_delete + bcc @clear + jmp @freed +@clear: ldy #$00 + lda #$00 +@clr: sta (il_val),y + iny + cpy #IF_VALUE_SIZE + bne @clr +@ok: lda #$00 + tax + clc + rts +@freed: lda #IF_ERR_FREED + sta if_error + lda #$00 + tax + sec + rts + +.segment "BSS" +if_del_error: .res 1 ; reserved debug breadcrumb (unused) diff --git a/newLang/tests/disk/launchers.tests.ps1 b/newLang/tests/disk/launchers.tests.ps1 new file mode 100644 index 0000000..ae0f41b --- /dev/null +++ b/newLang/tests/disk/launchers.tests.ps1 @@ -0,0 +1,100 @@ +# E07-S02 integration checks for scripts/launch-editor.ps1 and +# scripts/launch-compiler.ps1. +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" + +$project = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$repository = Split-Path $project -Parent +$launchEditor = Join-Path $project "scripts\launch-editor.ps1" +$launchCompiler = Join-Path $project "scripts\launch-compiler.ps1" +$modulePath = Join-Path $project "scripts\lib\Nova128Launcher.psm1" +$source = Join-Path $repository "vim\vim-improved.d81" +$minimal = Join-Path $project "build\minimal.prg" +$testRoot = Join-Path $project "build\launcher-test" +$diskA = Join-Path $testRoot "editor.d81" +$diskB = Join-Path $testRoot "compiler.d81" +$editorShot = Join-Path $testRoot "editor.png" +$compilerShot = Join-Path $testRoot "compiler.png" + +foreach ($path in @($launchEditor, $launchCompiler, $modulePath)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "missing $path" } +} +if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { throw "source image missing: $source" } + +New-Item -ItemType Directory -Force -Path $testRoot | Out-Null +& (Join-Path $project "scripts\build-minimal.ps1") | Out-Host +if (-not (Test-Path -LiteralPath $minimal -PathType Leaf)) { throw "minimal PRG missing" } + +Import-Module $modulePath -Force + +function Assert-FailsWith { + param( + [Parameter(Mandatory = $true)][scriptblock]$Action, + [Parameter(Mandatory = $true)][string]$Pattern + ) + $caught = $null + try { & $Action } catch { $caught = $_.Exception.Message } + if ($null -eq $caught) { throw "expected failure matching '$Pattern', but it succeeded" } + if ($caught -notmatch $Pattern) { throw "failure '$caught' did not match '$Pattern'" } +} + +$sourceHashBefore = (Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash + +# 1: missing inputs are reported before any emulator starts. +Assert-FailsWith -Pattern 'editor program not found' -Action { + & $launchEditor -EditorPrg (Join-Path $testRoot "missing.prg") ` + -SourceImage $source -DiskImage $diskA +} +Assert-FailsWith -Pattern 'compiler program not found' -Action { + & $launchCompiler -CompilerPrg (Join-Path $testRoot "missing.prg") ` + -DiskImage $diskB -SkipPrepare +} + +# 2: an editor session owns the image for the duration; a live lock blocks the +# compiler launcher on the same folder. +Copy-Item -LiteralPath $source -Destination $diskA -Force +$held = Enter-Nova128ImageSession -Image $diskA -Kind "editor" +try { + Assert-FailsWith -Pattern 'still owns|already holds' -Action { + & $launchCompiler -CompilerPrg $minimal -DiskImage $diskA -SkipPrepare + } +} finally { + Exit-Nova128ImageSession -Session $held +} +if (Test-Path -LiteralPath (Join-Path $testRoot ".newlang-session.lock")) { + throw "the session lock was not released" +} + +# 3: flush waiting refuses a locked image and accepts a released one. +$partner = [IO.File]::Open($diskA, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None) +try { + Assert-FailsWith -Pattern 'still locked' -Action { + Wait-Nova128ImageFlush -Image $diskA -TimeoutSeconds 1 + } +} finally { + $partner.Dispose() +} +Wait-Nova128ImageFlush -Image $diskA -TimeoutSeconds 2 + +# 4: the editor launcher autostarts its program in x64sc on a fresh copy and +# returns only after the process exits and the image flushes. +& $launchEditor -EditorPrg $minimal -SourceImage $source -DiskImage $diskA ` + -LimitCycles 2000000 -Screenshot $editorShot -Warp +if (-not (Test-Path -LiteralPath $editorShot)) { throw "editor launcher produced no screenshot" } + +# 5: the compiler launcher autostarts its program in x128 and leaves the +# disposable image flushed and unlocked. +Copy-Item -LiteralPath $source -Destination $diskB -Force +& $launchCompiler -CompilerPrg $minimal -DiskImage $diskB -LimitCycles 2000000 ` + -Screenshot $compilerShot -Warp -SkipPrepare +if (-not (Test-Path -LiteralPath $compilerShot)) { throw "compiler launcher produced no screenshot" } +Wait-Nova128ImageFlush -Image $diskB -TimeoutSeconds 2 + +$sourceHashAfter = (Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash +if ($sourceHashAfter -ne $sourceHashBefore) { throw "the Vim source image changed during the session tests" } + +Write-Host "launcher tests passed" +Write-Host "editor disk copy, lock refusal, flush wait, x64sc autostart, x128 autostart" +Write-Host "source disk unchanged: $sourceHashAfter" diff --git a/newLang/tests/parser/parser-test.s b/newLang/tests/parser/parser-test.s index 70453b8..5b54dfe 100644 --- a/newLang/tests/parser/parser-test.s +++ b/newLang/tests/parser/parser-test.s @@ -578,7 +578,8 @@ fixtures: FIXTURE "INTERFACES.N8" FIXTURE "IFACEBAD.N8" FIXTURE "UNRESOLVED.N8" -FIXTURE_COUNT = 42 + FIXTURE "OBJECTS.N8" +FIXTURE_COUNT = 43 TRACE_INDEX = 28 ; PREC.N8 messages: diff --git a/newLang/tests/runtime/lifetime-test.s b/newLang/tests/runtime/lifetime-test.s new file mode 100644 index 0000000..4c099b5 --- /dev/null +++ b/newLang/tests/runtime/lifetime-test.s @@ -0,0 +1,442 @@ +; E06-S06: interface lifetime and failure paths in x128. +; +; Runs under CR=$0E with a 4K heap at $4000. It checks: +; - delete through an interface value calls the class destructor once, frees +; the complete object, and clears the value so it cannot delete twice; +; - delete on a canonical null interface value is a no-op; +; - one object reachable through two interface tables is freed once, and the +; other value is refused afterwards; +; - an alias (a copy of the value) that outlives the deletion is a detectable +; failure: IF_ERR_FREED, no second destructor call, no heap change; +; - a null interface call faults predictably with IF_ERR_NULL, executes no +; target, and leaves dispatch working. +; +; MB_DATA flags 0..4 report the five phases. + +.setcpu "6502" +.include "../../src/common/c128.inc" +.include "../../src/common/mailbox.inc" +.include "../../src/common/print.inc" +.include "../../src/common/heap.inc" +.include "../../src/common/class.inc" +.include "../../src/common/frames.inc" +.include "../../src/common/interfaces.inc" + +.import pr_char, pr_cr, pr_text +.import heap_init, heap_free_total, heap_error +.import frame_init +.import obj_new, obj_init_ptr +.import if_call, if_error +.import if_delete + +HEAP_BASE = $4000 +HEAP_PAGES = 16 + +.macro TEXT src, len + lda #src + ldy #len + jsr pr_text +.endmacro + +.macro PHASE n + lda #n + sta MB_CODE + lda #$00 + sta MB_CODE+1 +.endmacro + +.macro MARK n + lda #n + sta MB_ADDR +.endmacro + +.macro FAIL_CS + bcc :+ + jmp @fail +: +.endmacro + +.macro FAIL_CC + bcs :+ + jmp @fail +: +.endmacro + +.macro FAIL_NE + beq :+ + jmp @fail +: +.endmacro + +; A word in A/X must equal the two bytes at addr. +.macro CHK_WORD addr + cmp addr + bne @bad + txa + cmp addr+1 + beq @ok +@bad: jmp @fail +@ok: +.endmacro + +.segment "LOADADDR" + .word $1C01 + +.segment "EXEHDR" + .word basic_end + .word 10 + .byte $9E + .byte "7181" + .byte $00 +basic_end: + .word $0000 + +.segment "MAILBOX" + .res MB_SIZE + +.segment "STARTUP" +.export test_entry, test_end +test_entry: + php + sei + ldx #MB_SIZE - 1 + lda #$00 +@clr: sta MB_BASE,x + dex + bpl @clr + lda #MB_MAGIC0_VALUE + sta MB_MAGIC0 + lda #MB_MAGIC1_VALUE + sta MB_MAGIC1 + lda #MB_PROD_PROGRAM + sta MB_PRODUCER + lda #MB_ST_RUNNING + sta MB_STATUS + lda #$00 + sta MB_CODE + sta MB_CODE+1 + sta destroy_count + sta hit_count + ldx #$04 +@flags: sta MB_DATA,x + dex + bpl @flags + + lda MMU_CR + sta saved_cr + lda #CR_RAM0_KERNAL + sta MMU_CR + lda #PR_CLEAR + jsr pr_char + TEXT title, title_len + jsr pr_cr + + PHASE 1 + lda #$00 + ldx #>HEAP_BASE + ldy #HEAP_PAGES + jsr heap_init + FAIL_CS + MARK $01 + jsr frame_init + lda #$00 + sta obj_init_ptr + sta obj_init_ptr+1 + + ; 1: delete through an interface frees once and clears the value. + MARK $02 + lda #desc_life + jsr obj_new + FAIL_CS + sta live_obj + stx live_obj+1 + jsr make_foo + MARK $03 + lda #val_foo + jsr if_delete + bcc :+ + lda if_error + sta MB_CODE + lda #$08 + sta MB_ADDR + jmp @fail +: MARK $04 + lda if_error + FAIL_NE + lda destroy_count + cmp #1 + FAIL_NE + MARK $05 + lda destroy_self + cmp live_obj + FAIL_NE + lda destroy_self+1 + cmp live_obj+1 + FAIL_NE + lda heap_free_total + cmp #$00 + FAIL_NE + lda heap_free_total+1 + cmp #$10 + FAIL_NE + MARK $06 + jsr chk_zero + FAIL_CS + MARK $07 + lda #$01 + sta MB_DATA+0 + + ; 2: delete on a canonical null value is a no-op. + PHASE 2 + MARK $11 + ; BSS is not part of the PRG image, so state the null values here. + ldx #$03 + lda #$00 +@init2: sta val_null,x + sta val_objnull,x + dex + bpl @init2 + lda #$C0 + sta val_objnull+IF_TABLE + lda #val_null + jsr if_delete + FAIL_CS + MARK $12 + lda if_error + FAIL_NE + lda destroy_count + cmp #1 + FAIL_NE + ; A value with a table but no object is still null for deletion. + lda #val_objnull + jsr if_delete + FAIL_CS + lda destroy_count + cmp #1 + FAIL_NE + lda #$01 + sta MB_DATA+1 + + ; 3: one object, two interface tables: the first delete frees it, the + ; second value is refused rather than reading a freed destructor. + PHASE 3 + lda #desc_life + jsr obj_new + FAIL_CS + sta live_obj + stx live_obj+1 + jsr make_foo + lda live_obj + sta val_bar + lda live_obj+1 + sta val_bar+1 + lda #tab_bar + sta val_bar+IF_TABLE+1 + lda #val_bar + jsr if_delete + FAIL_CS + lda destroy_count + cmp #2 + FAIL_NE + lda heap_free_total+1 + cmp #$10 + FAIL_NE + lda #val_foo + jsr if_delete + FAIL_CC + lda if_error + cmp #IF_ERR_FREED + FAIL_NE + lda destroy_count + cmp #2 + FAIL_NE + lda heap_free_total+1 + cmp #$10 + FAIL_NE + lda #$01 + sta MB_DATA+2 + + ; 4: an alias copy that outlives the deletion is detected. + PHASE 4 + lda #desc_life + jsr obj_new + FAIL_CS + sta live_obj + stx live_obj+1 + jsr make_foo + ldx #$00 +@alias: lda val_foo,x + sta val_alias,x + inx + cpx #IF_VALUE_SIZE + bne @alias + lda #val_foo + jsr if_delete + FAIL_CS + lda destroy_count + cmp #3 + FAIL_NE + lda #val_alias + jsr if_delete + FAIL_CC + lda if_error + cmp #IF_ERR_FREED + FAIL_NE + lda destroy_count + cmp #3 + FAIL_NE + lda heap_free_total + cmp #$00 + FAIL_NE + lda heap_free_total+1 + cmp #$10 + FAIL_NE + lda #$01 + sta MB_DATA+3 + + ; 5: a null call faults before any target runs, and dispatch recovers. + PHASE 5 + lda #val_null + ldy #$00 + jsr if_call + FAIL_CC + lda if_error + cmp #IF_ERR_NULL + FAIL_NE + lda hit_count + FAIL_NE + + lda #desc_life + jsr obj_new + FAIL_CS + sta live_obj + stx live_obj+1 + jsr make_foo + lda #val_foo + ldy #$00 + jsr if_call + FAIL_NE + lda if_error + FAIL_NE + lda hit_count + cmp #1 + FAIL_NE + lda #val_foo + jsr if_delete + FAIL_CS + lda destroy_count + cmp #4 + FAIL_NE + lda #$01 + sta MB_DATA+4 + + TEXT ok_msg, ok_len + jsr pr_cr + lda #MB_ST_PASS + sta MB_STATUS + lda #$00 + sta MB_CODE + sta MB_CODE+1 + jmp @restore + +@fail: + lda #MB_ST_FAIL + sta MB_STATUS + TEXT fail_msg, fail_len + jsr pr_cr +@restore: + lda saved_cr + sta MMU_CR + plp + rts + +; val_foo = live_obj + tab_foo. +make_foo: + lda live_obj + sta val_foo + lda live_obj+1 + sta val_foo+1 + lda #tab_foo + sta val_foo+IF_TABLE+1 + rts + +; C=1 unless the four bytes at val_foo are zero. +chk_zero: + ldx #$00 +@z: lda val_foo,x + bne @nz + inx + cpx #IF_VALUE_SIZE + bne @z + clc + rts +@nz: sec + rts + +.segment "CODE" +; Destructor: records how often and for which object it ran. +destroy_life: + sta destroy_self + stx destroy_self+1 + inc destroy_count + rts + +; Slot 0 target used by the null-call check. +m_hit: + inc hit_count + lda #$11 + ldx #$00 + rts + +.segment "DATA" +desc_life: + .word 4 ; header + one word field + .word destroy_life + .byte 0 + .word 0 + .byte 0 +.assert * - desc_life = CLASS_DESC_SIZE, error, "life descriptor size" + +tab_foo: .word m_hit +tab_bar: .word m_hit +.assert tab_bar - tab_foo = IF_SLOT_SIZE, error, "one slot per table" + +.segment "RODATA" +title: .byte "NOVA128 LIFETIME TEST" +title_len = * - title +ok_msg: .byte "LIFETIME OK" +ok_len = * - ok_msg +fail_msg: .byte "LIFETIME FAIL" +fail_len = * - fail_msg + +.segment "BSS" +saved_cr: .res 1 +live_obj: .res 2 +destroy_count: .res 1 +destroy_self: .res 2 +hit_count: .res 1 +val_foo: .res IF_VALUE_SIZE +val_bar: .res IF_VALUE_SIZE +val_alias: .res IF_VALUE_SIZE +val_null: .res IF_VALUE_SIZE +val_objnull: .res IF_VALUE_SIZE +test_end: diff --git a/newLang/tests/semantic/objects.expect b/newLang/tests/semantic/objects.expect new file mode 100644 index 0000000..21a561d --- /dev/null +++ b/newLang/tests/semantic/objects.expect @@ -0,0 +1,5 @@ +// E06-S06: two classes, two implementations of Printable, and Counter +// satisfying both Printable and Resettable end to end. +printable slot 0 print +printable slot 1 tag +resettable slot 0 reset diff --git a/newLang/tests/semantic/objects.n8 b/newLang/tests/semantic/objects.n8 new file mode 100644 index 0000000..1f6e0a2 --- /dev/null +++ b/newLang/tests/semantic/objects.n8 @@ -0,0 +1,59 @@ +interface Printable + method print() + method tag() as byte +end interface + +interface Resettable + method reset() +end interface + +class Counter implements Printable, Resettable + private word value + + public method init(start as word) + self.value = start + end method + + public method print() + print self.value + end method + + public method tag() as byte + return 1 + end method + + public method reset() + self.value = 0 + end method +end class + +class Ticker implements Printable + public method print() + print 7 + end method + + public method tag() as byte + return 2 + end method +end class + +program Main + var count as Counter + var tick as Ticker + var shown as Printable + var zeroed as Resettable + var ok as boolean + + count = new Counter(10) + tick = new Ticker() + shown = count + shown.print() + ok = shown.tag() = 1 + zeroed = count + zeroed.reset() + shown = tick + shown.print() + ok = shown.tag() = 2 + delete shown + delete count +end program