Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci-janitor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
run: |
set -euo pipefail
# Aggregates (ci-required, golden-path, run-summary) only echo another job's failure.
failed="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/jobs?filter=latest&per_page=100" --paginate \
failed="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/jobs?filter=latest&per_page=10" --paginate \
--jq '.jobs[] | select(.conclusion == "failure") | select(.name | test("^(ci-required|golden-path|run-summary)$") | not) | .id')"
[ -n "$failed" ] || { echo "No primary job failed; nothing to retry."; exit 0; }
for id in $failed; do
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1664,7 +1664,7 @@ jobs:
# with `--jq` or `--template`"), so the flatten of the paginated pages happens here. Paired
# wrongly, the call failed, the guard below wrote `[]`, and every run's timing table was
# header-only — an empty measurement read as a measured one.
if ! gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$GITHUB_RUN_ATTEMPT/jobs?per_page=100" \
if ! gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$GITHUB_RUN_ATTEMPT/jobs?per_page=10" \
| node -e 'let raw = ""; process.stdin.on("data", (chunk) => { raw += chunk; }).on("end", () => { process.stdout.write(JSON.stringify(JSON.parse(raw).flatMap((page) => page.jobs ?? []))); });' \
> "$RUNNER_TEMP/job-timings.json"; then
echo '[]' > "$RUNNER_TEMP/job-timings.json"
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/native-platforms.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1973,6 +1973,10 @@ jobs:
# rcedit rewriting the PE no longer discards it and the container launches the real game.
- name: Verify the relocated release container with the installed verifier
shell: bash
env:
# The Windows runner's software adapter drew 300 frames at 0.6 to 1.3 s each on one tree
# (runs 41279577367, 41288321973), and the default 300 s bound cut it at frame 293.
TN_STARTER_TIMEOUT_MS: ${{ matrix.platform == 'Windows' && '600000' || '300000' }}
run: |
set -euo pipefail
# Same POSIX normalisation as the relocate step, so the verifier sees one path style.
Expand Down Expand Up @@ -2860,7 +2864,7 @@ jobs:
ref: ${{ needs.scope.outputs.candidate_sha || inputs.candidate_sha || github.sha }}
- uses: actions/download-artifact@v7
with:
pattern: ci-native-receipt-${{ github.run_id }}-${{ github.run_attempt }}-*
pattern: ci-native-receipt-${{ github.run_id }}-*
path: artifacts/ci-native-receipts
- id: collect
env:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/native-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ jobs:
fi
printf '{"databaseId":%s,"attempt":1,"status":"%s","conclusion":"%s","event":"%s","headBranch":"%s","headSha":"%s"}\n' \
"$candidate_run" "$WORKFLOW_RUN_STATUS" "$WORKFLOW_RUN_CONCLUSION" "$WORKFLOW_RUN_EVENT" "$WORKFLOW_RUN_HEAD_BRANCH" "$candidate_sha" > "$evidence/ci-run.json"
gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/actions/runs/$candidate_run/jobs?filter=latest&per_page=100" > "$evidence/ci-jobs.json"
gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/actions/runs/$candidate_run/jobs?filter=latest&per_page=10" > "$evidence/ci-jobs.json"
CANDIDATE_RUN_ID="$candidate_run" CANDIDATE_SHA="$candidate_sha" node --input-type=module <<'NODE'
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
Expand Down Expand Up @@ -323,7 +323,7 @@ jobs:
NODE
)"
gh run view "$candidate_run" --repo "$GITHUB_REPOSITORY" --json databaseId,attempt,status,conclusion,event,headBranch,headSha > "$evidence/ci-run.json"
gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/actions/runs/$candidate_run/jobs?filter=latest&per_page=100" > "$evidence/ci-jobs.json"
gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/actions/runs/$candidate_run/jobs?filter=latest&per_page=10" > "$evidence/ci-jobs.json"
CANDIDATE_RUN_ID="$candidate_run" node --input-type=module <<'NODE'
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
Expand Down
12 changes: 6 additions & 6 deletions docs/verification/native-coverage-2026-08-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,26 @@ targets could not be built and are named below.
| Subsystem | Instrumented lines | Covered | Line coverage |
| --- | ---: | ---: | ---: |
| `src/async/` | 73 | 60 | 82.19% |
| `src/audio/` | 1471 | 1296 | 88.10% |
| `src/audio/` | 1471 | 1301 | 88.44% |
| `src/canvas/` | 1334 | 1110 | 83.21% |
| `src/cli/` | 1691 | 1243 | 73.51% |
| `src/engine/` | 41259 | 34857 | 84.48% |
| `src/fs/` | 235 | 189 | 80.43% |
| `src/http/` | 410 | 377 | 91.95% |
| `src/js/` | 2835 | 2227 | 78.55% |
| `src/js/` | 2838 | 2230 | 78.58% |
| `src/platform/` | 1507 | 1047 | 69.48% |
| `src/raytracing/` | 643 | 543 | 84.45% |
| `src/runtime.cpp` | 2410 | 1867 | 77.47% |
| `src/screenshot_gate.cpp` | 27 | 24 | 88.89% |
| `src/storage/` | 370 | 329 | 88.92% |
| `src/utils/` | 0 | 0 | 0.00% |
| `src/vfs/` | 239 | 195 | 81.59% |
| `src/webgpu/` | 10061 | 7719 | 76.72% |
| `src/webgpu/` | 10061 | 7716 | 76.69% |
| `src/webtransport/` | 1391 | 1078 | 77.50% |
| `src/workers/` | 615 | 524 | 85.20% |
| **TOTAL** | **66571** | **54685** | **82.15%** |
| `src/workers/` | 615 | 527 | 85.69% |
| **TOTAL** | **66574** | **54693** | **82.15%** |

Source digest: `sha256:ff9b2800410629b80ec4e8838ebc325ddf0e5f49a2e10ef34760d239e1bd9bcc`
Source digest: `sha256:2bcd635f915fcac09cb8bfccf5fce40a6809afd04f4d5e6496907abe3839a664`

The default `pnpm budgets` gate reads this committed measurement without configuring or compiling
the native host. Any native source, native C++ test, CTest registration, or coverage aggregation
Expand Down
22 changes: 22 additions & 0 deletions packages/core/__tests__/auto-batch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,28 @@ describe("AutoBatch", () => {
expect(batch.census).toMatchObject({ meshes: 40, draws: 2, optedOut: 0, restored: 0 });
});

it("walks the scene under the observer mark, so the bridge census never bills the pass to the game", () => {
// PR #595 run 41279573156: the walk landed on a census frame in collectathon-collect, and its
// __attributeNames re-reads pushed TN_BRIDGE_WARNING to 0.029 (limit 0.02) on a timing flake.
const host = globalThis as { __tnPlaytestObserving?: number };
const scene = new Scene();
const group = new Group();
scene.add(group);
town(4, group);
const seen: (number | undefined)[] = [];
const children = group.children;
Object.defineProperty(group, "children", {
get: () => {
seen.push(host.__tnPlaytestObserving);
return children;
},
});
run(new AutoBatch(scene, { settleFrames: 3 }), 8);
// Only the snapshot passes hold the mark; the merge itself stays billed.
expect(seen.some((mark) => (mark ?? 0) > 0)).toBe(true);
expect(host.__tnPlaytestObserving ?? 0).toBe(0);
});

it("keeps an opted-out subtree separate and reports it", () => {
const scene = new Scene();
const group = new Group();
Expand Down
24 changes: 15 additions & 9 deletions packages/core/src/auto-batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { isEngineRenderHook } from "./engine-render-hook.js";
import type { Registry } from "./entities.js";
import { mergeByMaterial } from "./merge-parts.js";
import { displacesVertices, hasMorphAttributes } from "./projection-plan.js";
import { unbilled } from "./startup-offscreen-bytes.js";

/**
* Static meshes that share a material become one draw, with no call from the game.
Expand Down Expand Up @@ -489,17 +490,22 @@ export class AutoBatch {
return this.#shapeChanges;
}

/** Eligible meshes with the world matrix they were drawn at. */
/**
* Eligible meshes with the world matrix they were drawn at. Runs only while the scene settles, so
* its reads are no game's steady cost and the bridge census leaves them out.
*/
#snapshot(): Map<Mesh, Float64Array> {
this.#scene.updateMatrixWorld(true);
const found = new Map<Mesh, Float64Array>();
this.#optedOut = 0;
this.#held = 0;
const held = heldObjects(this.#entities);
this.#walk(this.#scene, false, held, (mesh) => {
found.set(mesh, Float64Array.from(mesh.matrixWorld.elements));
return unbilled(() => {
this.#scene.updateMatrixWorld(true);
const found = new Map<Mesh, Float64Array>();
this.#optedOut = 0;
this.#held = 0;
const held = heldObjects(this.#entities);
this.#walk(this.#scene, false, held, (mesh) => {
found.set(mesh, Float64Array.from(mesh.matrixWorld.elements));
});
return found;
});
return found;
}

#walk(
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/startup-offscreen-bytes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ function holders(root: Object3D, camera: Camera): Map<string, { shown: boolean;
* A one-off measurement walk, like the playtest observer's, under the same mark: three-native's
* bridge census then never bills its re-reads of every child to the game's frame.
*/
function unbilled<T>(work: () => T): T {
export function unbilled<T>(work: () => T): T {
const host = globalThis as { __tnPlaytestObserving?: number };
host.__tnPlaytestObserving = (host.__tnPlaytestObserving ?? 0) + 1;
try {
Expand Down
9 changes: 9 additions & 0 deletions packages/runtime-native/src/js/v8_engine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#include "mystral/js/engine.h"
#include "mystral/cold_start.h"
#include "mystral/js/module_system.h"
#include <atomic>
#include <deque>
#include <cstdio>
#include <fstream>
Expand Down Expand Up @@ -1414,15 +1415,22 @@ class V8Engine : public Engine {
bool supportsBlockingTaskWait() const override { return true; }

void waitForTask() override {
// processMicrotasks() between the caller's state check and this wait can consume a wake
// task. The flag keeps that wake: without it a terminate() in that gap joins a worker
// that sleeps forever. The waker changes its state before it sets the flag, and the
// caller rechecks that state after this returns, so clearing the flag loses nothing.
if (wakeRequested_.exchange(false)) return;
V8EntryScope entry_scope(isolate_);
v8::Local<v8::Context> context = context_.Get(isolate_);
entry_scope.enterContext(context);
v8::platform::PumpMessageLoop(
g_platform.get(), isolate_, v8::platform::MessageLoopBehavior::kWaitForWork);
wakeRequested_.store(false);
isolate_->PerformMicrotaskCheckpoint();
}

void wakeTaskWait() override {
wakeRequested_.store(true);
g_platform->GetForegroundTaskRunner(isolate_)->PostTask(
std::make_unique<V8WakeTask>());
}
Expand Down Expand Up @@ -2078,6 +2086,7 @@ class V8Engine : public Engine {
std::deque<std::vector<JSValueHandle>> callbackArgsPool_;
std::deque<std::vector<v8::Local<v8::Value>>> callbackLocalsPool_;
bool inFrame_ = false; // True during animation frame execution
std::atomic<bool> wakeRequested_{false}; // A wake waitForTask() has not yet honoured
bool frameTrackingSuspended_ = false; // When true, skip frame tracking for new allocations
int nativeCallbackDepth_ = 0;
bool exceptionFromNativeCallback_ = false;
Expand Down
32 changes: 32 additions & 0 deletions packages/runtime-native/tests/js_engine_contract_test.cpp
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
#include "mystral/js/engine.h"

#include <atomic>
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <thread>

namespace {

Expand Down Expand Up @@ -163,6 +166,30 @@ bool checkNestedNativeCallbacks(mystral::js::Engine& engine) {

} // namespace

// PR #595, macOS desktop core: a worker's loop pumps microtasks between its queue check and its
// blocking wait. A wake posted in that gap was consumed by the pump, the wait then had no task,
// and terminate() joined that worker forever on the main thread. The rescue thread keeps a
// regression a failure with a message, not a hung test.
bool checkConsumedWakeReleasesWait(mystral::js::Engine& engine) {
if (!engine.supportsBlockingTaskWait()) return true;
std::atomic<bool> returned{false};
std::atomic<bool> rescued{false};
std::thread rescue([&] {
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
while (!returned.load() && std::chrono::steady_clock::now() < deadline)
std::this_thread::sleep_for(std::chrono::milliseconds(10));
if (returned.load()) return;
rescued.store(true);
engine.wakeTaskWait();
});
engine.wakeTaskWait();
engine.processMicrotasks();
engine.waitForTask();
returned.store(true);
rescue.join();
return !rescued.load();
}

int main() {
struct EngineCase {
mystral::js::EngineType type;
Expand Down Expand Up @@ -198,6 +225,11 @@ int main() {
<< " nested callback semantics failed" << std::endl;
return EXIT_FAILURE;
}
if (!checkConsumedWakeReleasesWait(*engine)) {
std::cerr << "js-engine-contract: " << engineCase.name
<< " a wake consumed before the wait stranded the wait" << std::endl;
return EXIT_FAILURE;
}
std::cout << "js-engine-contract: engine=" << engineCase.name
<< " property=own-data global=assignment";
if (engineCase.checkNestedCallbacks) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,7 @@ function runReleaseGate({ runs = [candidateRun], detail = candidateRun, jobs = c
case "$1 $2" in
'run list') test "$MOCK_GH_FAILURE" != list || exit 42; printf '%s' "$MOCK_GH_RUNS" ;;
'run view') test "$2" = view || exit 64; test "$MOCK_GH_FAILURE" != view || exit 42; printf '%s' "$MOCK_GH_DETAIL" ;;
'api --paginate') test "$2" = --paginate || exit 64; test "$3" = --slurp || exit 64; test "$4" = 'repos/ThreeNativeHQ/threenative/actions/runs/123/jobs?filter=latest&per_page=100' || exit 64; test "$MOCK_GH_FAILURE" != jobs || exit 42; printf '%s' "$MOCK_GH_PAGES" ;;
'api --paginate') test "$2" = --paginate || exit 64; test "$3" = --slurp || exit 64; test "$4" = 'repos/ThreeNativeHQ/threenative/actions/runs/123/jobs?filter=latest&per_page=10' || exit 64; test "$MOCK_GH_FAILURE" != jobs || exit 42; printf '%s' "$MOCK_GH_PAGES" ;;
*) exit 64 ;;
esac
`);
Expand Down
38 changes: 38 additions & 0 deletions packages/three-native/__tests__/bridge-reads.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,44 @@ describe("the bridge census", () => {
expect(frames(500)).toEqual([]);
});

it("judges a census frame by its own length, so a scene rebuild is not steady waste", () => {
// PR run 41302359332: shooter `debug-restart` failed when the census landed on the restart
// frame, where Play.enter rebuilt the town: 77,840 redundant reads, 16 ms of a frame of seconds,
// judged as 16% of the 98 ms median. The next ordinary census still names a real waster.
host.__THREENATIVE_BRIDGE_CACHE__ = false;
const clock = { ms: 0 };
vi.spyOn(performance, "now").mockImplementation(() => clock.ms);
const warnings: string[] = [];
vi.spyOn(console, "warn").mockImplementation((line: unknown) => {
warnings.push(String(line));
});
const { Light, collect } = scene(clock);
const key = new Light(0xffffff, 3);
const read = () => {
for (let at = 0; at < 500; at++) if (!(key.intensity >= 0)) throw new Error("bad");
};
const census = (safePoint: number) =>
safePoint > WARMUP_FRAMES && safePoint % WINDOW_FRAMES === 0;
let safePoints = 0;
let rebuild = true;
let seen = 0;
const bridgeWarnings = () => warnings.filter((line) => line.startsWith(BRIDGE_WARNING_MARKER));
while (seen < 2) {
collect();
safePoints += 1;
clock.ms += 16;
if (!census(safePoints)) continue;
seen += 1;
read();
if (rebuild) clock.ms += 5000;
rebuild = false;
collect();
safePoints += 1;
if (seen === 1) expect(bridgeWarnings()).toEqual([]);
}
expect(bridgeWarnings()).toHaveLength(1);
});

it("leaves the playtest observer's reads out, which no player run makes", () => {
// Hosted CI's adventure `puzzle` run failed on 45,920 redundant reads, led by the playtest
// sample's own scene walk (countMaterials, Box3.setFromObject) inside the census frame.
Expand Down
9 changes: 6 additions & 3 deletions packages/three-native/src/bridge-meter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
* also takes one stack for each name read redundantly, so the verdict names the caller.
*
* **The rule is derived, never a constant an author revisits.** It fires when the redundant reads,
* at this host's own measured cost per crossing, take more than `REDUNDANT_SHARE` of the frame's
* median interval: the frame's own length, so the guard holds on Xvfb and on a loaded host. The line
* at this host's own measured cost per crossing, take more than `REDUNDANT_SHARE` of the frame: its
* median interval or the census frame's own length, the longer, so the guard holds on Xvfb, on a
* loaded host and across a frame that rebuilds a scene. The line
* is `TN_BRIDGE_WARNING`, which a playtest fails on (`packages/playtest` perf warnings).
*
* What *all* crossings cost is an observation, not a defect: a game that reads 995 distinct
Expand Down Expand Up @@ -161,7 +162,9 @@ export function createBridgeMeter(host: IBridgeMeterHost): IBridgeMeter {
);
}
if (redundant === 0 || intervals.length === 0) return;
const frameMs = medianFrameMs();
// The census frame's own length is the last interval. A scene rebuild reads a lot once in a frame
// of seconds; a steady waster's frame is as long as its median, so the next census names it.
const frameMs = Math.max(medianFrameMs(), intervals[intervals.length - 1] as number);
const crossingUs = costMicros();
const redundantMs = (redundant * crossingUs) / 1000;
if (!(redundantMs > frameMs * REDUNDANT_SHARE)) return;
Expand Down
28 changes: 28 additions & 0 deletions scripts/__tests__/ci-integration-receipts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { makeTempDirSync } from "../../test-support/temp-dir.js";

const scope = await import(new URL("../ci-integration-scope.mjs", import.meta.url).href);
const validate = scope.validateIntegrationReceipts;
const changeReceipts = await import(new URL("../ci-attempt-receipts.mjs", import.meta.url).href);
const changeScope = await import(new URL("../ci-change-scope.mjs", import.meta.url).href);
const fixture = await import(
new URL("../../test-support/ci-integration-fixture.ts", import.meta.url).href
Expand Down Expand Up @@ -144,6 +145,33 @@ describe("required current-attempt integration receipts", () => {
),
).toThrow("CI_INTEGRATION_JOB_IDENTITY");
});
it("reads a carried leg's later-attempt copy as the same run, and a real rerun as a rerun", () => {
// PR #595 run 41262008820: job 124718810548 is listed in attempt 3 as 124750441136, with the
// same start, end and runner. Only the red leg really ran again.
const at = (row: object, started_at: string) => ({
...row,
started_at,
completed_at: `${started_at}:done`,
runner_name: "hosted",
});
const first = [at(job(0, 1, 10), "t1"), at(job(1, 1, 6, "failure"), "t2")];
const second = [at(job(0, 2, 100), "t1"), at(job(1, 2, 11), "t3")];
const merged = changeReceipts.mergeAttemptListings([{ jobs: first }, { jobs: second }]);
const verdict = (listing: { jobs: unknown[] }) =>
validate(expected, [receipt(0, "1"), receipt(1, "2")], {
totalCount: listing.jobs.length,
jobs: listing.jobs,
});
expect(verdict(merged)).toBeUndefined();
expect(() => verdict({ jobs: [...first, ...second] })).toThrow(
"ran again in a later attempt",
);
const rerun = changeReceipts.mergeAttemptListings([
{ jobs: first },
{ jobs: [at(job(0, 2, 100), "t9"), second[1]] },
]);
expect(() => verdict(rerun)).toThrow("ran again in a later attempt");
});
it("rejects a receipt from an attempt later than this run's", () => {
expect(() =>
check([receipt(0, "3"), receipt(1, "2")], [job(0, 3, 10), job(1, 2, 11)]),
Expand Down
2 changes: 1 addition & 1 deletion scripts/__tests__/ci-native-chain.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ describe("actual native receipt writer, collector and protected verdict", () =>
if (!windows) throw new Error("missing fixture Windows job");
windows.run_attempt = 1;
f.publish();
expect(verdict().stderr).toContain("CI_NATIVE_JOB_IDENTITY");
expect(verdict().stderr).toContain("CI_NATIVE_BUILD_IDENTITY");
expect(
f.run("ci-native-receipts.mjs", [
"--collect",
Expand Down
Loading
Loading