Skip to content
75 changes: 64 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -249,13 +249,15 @@ jobs:
# saved ~7 MB and restored nothing. There is no digest-agnostic
# fallback key: a generation directory from another compiler digest
# is dead weight to restore.
# Retries must be able to rule out a stale analysis/runtime cache.
- name: Restore JIR analysis cache
if: github.run_attempt == 1
uses: actions/cache/restore@v4
with:
path: ${{ runner.temp }}/.cache/jac
key: jac-check-cache-${{ runner.os }}-${{ hashFiles('jac/jaclang/compiler/**', 'jac/jaclang/runtime/**', 'jac/jaclang/lib/**', 'jac/jaclang/jac0core/**', 'jac/jaclang/jac0.py', 'jac/jaclang/meta_importer.py', 'jac/jaclang/bootstrap_manifest.py', 'jac/jaclang/cli/cli_boot.jac', 'jac/jaclang/project/tomlio.jac', 'jac/jaclang/dist/precompile_bytecode.jac') }}-${{ github.sha }}
key: jac-check-cache-v2-${{ runner.os }}-${{ hashFiles('jac/jaclang/compiler/**', 'jac/jaclang/runtime/**', 'jac/jaclang/lib/**', 'jac/jaclang/jac0core/**', 'jac/jaclang/jac0.py', 'jac/jaclang/meta_importer.py', 'jac/jaclang/bootstrap_manifest.py', 'jac/jaclang/cli/cli_boot.jac', 'jac/jaclang/project/tomlio.jac', 'jac/jaclang/dist/precompile_bytecode.jac') }}-${{ github.sha }}
restore-keys: |
jac-check-cache-${{ runner.os }}-${{ hashFiles('jac/jaclang/compiler/**', 'jac/jaclang/runtime/**', 'jac/jaclang/lib/**', 'jac/jaclang/jac0core/**', 'jac/jaclang/jac0.py', 'jac/jaclang/meta_importer.py', 'jac/jaclang/bootstrap_manifest.py', 'jac/jaclang/cli/cli_boot.jac', 'jac/jaclang/project/tomlio.jac', 'jac/jaclang/dist/precompile_bytecode.jac') }}-
jac-check-cache-v2-${{ runner.os }}-${{ hashFiles('jac/jaclang/compiler/**', 'jac/jaclang/runtime/**', 'jac/jaclang/lib/**', 'jac/jaclang/jac0core/**', 'jac/jaclang/jac0.py', 'jac/jaclang/meta_importer.py', 'jac/jaclang/bootstrap_manifest.py', 'jac/jaclang/cli/cli_boot.jac', 'jac/jaclang/project/tomlio.jac', 'jac/jaclang/dist/precompile_bytecode.jac') }}-

- uses: ./.github/actions/jac-kit

Expand Down Expand Up @@ -376,11 +378,11 @@ jobs:
jac check . --ignore $IGNORE_ARGS --nowarn

- name: Save JIR analysis cache
if: always()
if: success()
uses: actions/cache/save@v4
with:
path: ${{ runner.temp }}/.cache/jac
key: jac-check-cache-${{ runner.os }}-${{ hashFiles('jac/jaclang/compiler/**', 'jac/jaclang/runtime/**', 'jac/jaclang/lib/**', 'jac/jaclang/jac0core/**', 'jac/jaclang/jac0.py', 'jac/jaclang/meta_importer.py', 'jac/jaclang/bootstrap_manifest.py', 'jac/jaclang/cli/cli_boot.jac', 'jac/jaclang/project/tomlio.jac', 'jac/jaclang/dist/precompile_bytecode.jac') }}-${{ github.sha }}
key: jac-check-cache-v2-${{ runner.os }}-${{ hashFiles('jac/jaclang/compiler/**', 'jac/jaclang/runtime/**', 'jac/jaclang/lib/**', 'jac/jaclang/jac0core/**', 'jac/jaclang/jac0.py', 'jac/jaclang/meta_importer.py', 'jac/jaclang/bootstrap_manifest.py', 'jac/jaclang/cli/cli_boot.jac', 'jac/jaclang/project/tomlio.jac', 'jac/jaclang/dist/precompile_bytecode.jac') }}-${{ github.sha }}

- name: Error-code breakdown (label "type-error-report")
if: contains(github.event.pull_request.labels.*.name, 'type-error-report')
Expand Down Expand Up @@ -1056,14 +1058,14 @@ jobs:
# test in-process (jaclang.cli.guide_store), so the docs journey stop is
# hermetic with no extra setup.
# The site is a workspace whose service apps colocate in the served
# app's process by default, so `jac run --serve web` is the single
# app's process by default. Exercise the exact quickstart, `jac run`, in the single
# process serving stack. The fleet topology (`--fleet`: one local
# process per service app behind the gateway) gets its own pass over
# the same journey below.
- name: Start site server in background
- name: Start the quickstart with bare jac run
working-directory: site
run: |
timeout 1200 jac run --serve web > /tmp/site-server.log 2>&1 &
timeout 1200 jac run > /tmp/site-server.log 2>&1 &
SERVER_PID=$!
echo "SITE_PID=$SERVER_PID" >> $GITHUB_ENV
echo "site server starting with PID $SERVER_PID..."
Expand Down Expand Up @@ -1099,7 +1101,7 @@ jobs:
run: |
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 http://localhost:8000)
echo "site root endpoint: HTTP $HTTP_CODE"
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 500 ]; then
if [ "$HTTP_CODE" -eq 200 ]; then
echo "site server responding correctly"
else
echo "Unexpected HTTP $HTTP_CODE"
Expand Down Expand Up @@ -1138,11 +1140,19 @@ jobs:
SITE_JOURNEY_ARTIFACTS: /tmp
run: bash scripts/site-browse-journey.sh http://localhost:8000

- name: Verify development mode and no dependency scan errors
run: |
grep -q 'Development (with HMR)' /tmp/site-server.log
if grep -E 'Failed to scan for dependencies|No matching export|Could not start Vite dev server' /tmp/site-server.log; then
echo "::error::the quickstart client failed despite a live server process"
exit 1
fi

- name: Dump server thread stacks on failure
if: failure()
run: |
pip install py-spy >/dev/null 2>&1 || true
for p in $(pgrep -f "jac run --serve"); do
for p in $(pgrep -f "jac run"); do
echo "=== py-spy dump pid $p ==="
sudo env "PATH=$PATH" py-spy dump --pid "$p" --nonblocking || true
done
Expand All @@ -1168,10 +1178,53 @@ jobs:
jac browse close 2>/dev/null || true
if [ -n "${{ env.SITE_PID }}" ]; then
kill ${{ env.SITE_PID }} 2>/dev/null || true
sleep 3
for i in $(seq 1 30); do
kill -0 ${{ env.SITE_PID }} 2>/dev/null || break
sleep 1
done
kill -9 ${{ env.SITE_PID }} 2>/dev/null || true
fi

- name: Build the production artifact
working-directory: site
run: jac build --output dist

- name: Serve the production artifact outside the source project
working-directory: ${{ runner.temp }}
run: |
timeout 1200 jac run --port 8000 "${{ github.workspace }}/site/dist/site.jab" > /tmp/site-artifact.log 2>&1 &
echo "ARTIFACT_PID=$!" >> $GITHUB_ENV

- name: Wait for the production artifact
run: |
for i in $(seq 1 60); do
if ! kill -0 "${{ env.ARTIFACT_PID }}" 2>/dev/null; then
cat /tmp/site-artifact.log
exit 1
fi
if curl -sf --max-time 10 http://localhost:8000/ -o /dev/null; then
exit 0
fi
sleep 5
done
cat /tmp/site-artifact.log
exit 1

- name: Browse the production artifact (same journey)
timeout-minutes: 15
run: bash scripts/site-browse-journey.sh http://localhost:8000

- name: Stop the production artifact
if: always()
run: |
jac browse close 2>/dev/null || true
if [ -n "${{ env.ARTIFACT_PID }}" ]; then
kill "${{ env.ARTIFACT_PID }}" 2>/dev/null || true
sleep 3
kill -9 "${{ env.ARTIFACT_PID }}" 2>/dev/null || true
fi
cat /tmp/site-artifact.log 2>/dev/null || true

# The other topology for the same site: `jac run --fleet` puts the
# gateway in front of one local process per service app, with the
# served app (`web`) as the fleet's root member owning the project
Expand Down Expand Up @@ -1224,7 +1277,7 @@ jobs:
if: failure()
run: |
pip install py-spy >/dev/null 2>&1 || true
for p in $(pgrep -f "jac run --serve"); do
for p in $(pgrep -f "jac run"); do
echo "=== py-spy dump pid $p ==="
sudo env "PATH=$PATH" py-spy dump --pid "$p" --nonblocking || true
done
Expand Down
33 changes: 18 additions & 15 deletions jac/jaclang/cli/commands/impl/execution.impl.jac
Original file line number Diff line number Diff line change
Expand Up @@ -1493,31 +1493,34 @@ impl serve(
return 1;
} except KeyboardInterrupt {
console.print('\nShutting down');
if hot_reloader is not None {
hot_reloader.stop();
}
for started_vite in vite_holder {
started_vite.terminate();
started_vite.`wait(timeout=5);
}
mach.close();
return 0;
} except Exception as e {
import from jaclang.runtime.traceback_render { dump_traceback }
import from jaclang.server.net { PortInUseError }
if hot_reloader is not None {
hot_reloader.stop();
}
for started_vite in vite_holder {
started_vite.terminate();
}
mach.close();
if isinstance(e, PortInUseError) {
raise;
}
console.error(f"Server error: {e}");
console.error(dump_traceback(e));
return 1;
} finally {
try {
if hot_reloader is not None {
hot_reloader.stop();
}
} finally {
import subprocess;
for started_vite in vite_holder {
started_vite.terminate();
try {
started_vite.`wait(timeout=5);
} except subprocess.TimeoutExpired {
started_vite.kill();
started_vite.`wait();
}
}
mach.close();
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion jac/jaclang/client/impl/compiler.impl.jac
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ impl ViteCompiler._emit_wasm_module(self: ViteCompiler, module_path: Path) -> bo
return False;
}
stem = native_wasm_stem(str(module_path));
dist = self.jac_client_compiler._get_client_dir() / 'dist';
dist = self.vite_bundler.output_dir;
dist.mkdir(parents=True, exist_ok=True);
out_path = dist / f"{stem}.wasm";
out_path.write_bytes(wasm);
Expand Down
26 changes: 25 additions & 1 deletion jac/jaclang/client/impl/jac_client_compiler.impl.jac
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,29 @@ impl JacClientCompiler.compile_dependencies_recursively(
artifact = self.jac_compiler.compile_module(module_path);
self._collect_na_modules(artifact);
manifest = artifact.manifest;
for (dependency, names) in manifest.runtime_imports.items() {
if not names {
continue;
}
dependency_path = Path(dependency).resolve();
if native {
dependency_path = self._native_variant(dependency_path);
}
dependency_manifest = self.jac_compiler.compile_module(
dependency_path
).manifest;
exported = set(dependency_manifest.exports + dependency_manifest.globals);
missing = sorted(set(names) - exported);
if missing {
raise ClientBundleError(
f"{module_path}: runtime import(s) {', '.join(
missing
)} are not exported "
f"by {dependency_path}. Use :pub for runtime values; annotation-only "
"references must not produce runtime imports."
);
}
}
exports_list = self.jac_compiler.extract_exports(manifest);
collected_exports.update(exports_list);
non_root_globals: dict[(str, any)] = {};
Expand Down Expand Up @@ -465,7 +488,8 @@ impl JacClientCompiler.compile_runtime_utils(
import importlib;

for _m in re.finditer(
r'from\s+[\'"]((?:\./)?[^\'"]*html_tag_map\.js)[\'"]', runtimeutils_js
r'(?:from|import)\s+[\'"]((?:\./)?[^\'"]*html_tag_map\.js)[\'"]',
runtimeutils_js
) {
rel = _m.group(1);
if rel.startswith('./') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ impl EsastGenPass.exit_import with uni.Import exit {
} else {
return;
}
if resolved_path.endswith(".jac") {
self.module_import_paths[js_import_path] = resolved_path;
}
source = self.sync_loc(es.Literal(value=js_import_path), jac_node=here.from_loc);
specifiers:
list[
Expand Down Expand Up @@ -489,3 +492,65 @@ impl EsastGenPass._import_item_named(name: str) -> (uni.ModuleItem | None) {
}
return None;
}

impl EsastGenPass._finalize_runtime_imports(program: es.Program) -> None {
if not self.module_import_paths {
return;
}
manifest = self.client_manifest;
assert manifest is not None;
value_body = es.Program(
body=[
stmt
for stmt in program.body
if not isinstance(stmt, es.ImportDeclaration)
]
);

import re;
referenced: set[str] = set();
pending: list[any] = [value_body];
while pending {
value = pending.pop();
if isinstance(value, (es.Identifier, es.JsxIdentifier)) {
referenced.update(re.findall(r"[A-Za-z_$][A-Za-z0-9_$]*", value.name));
} elif isinstance(value, es.RawCode) {
referenced.update(re.findall(r"[A-Za-z_$][A-Za-z0-9_$]*", value.code));
} elif isinstance(value, es.Node) {
pending.extend(
child
for (key, child) in vars(value).items()
if key not in ("loc", "type")
);
} elif isinstance(value, (list, tuple)) {
pending.extend(value);
}
}
for stmt in program.body {
if not isinstance(stmt, es.ImportDeclaration) {
continue;
}
source = stmt.source;
assert source is not None;
source_path = self.module_import_paths.get(str(source.value));
if source_path is None {
continue;
}
stmt.specifiers = [
spec
for spec in stmt.specifiers
if spec.local is not None and spec.local.name in referenced
];
names: list[str] = [];
for spec in stmt.specifiers {
if isinstance(spec, es.ImportSpecifier) {
imported = spec.imported;
assert imported is not None;
names.append(imported.name);
} elif isinstance(spec, es.ImportDefaultSpecifier) {
names.append("default");
}
}
manifest.runtime_imports.setdefault(source_path, []).extend(names);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ impl EsastGenPass.exit_module with uni.Module exit {
);
}
program = self.sync_loc(es.Program(body=body), jac_node=here);
self._finalize_runtime_imports(program);
here.gen.es_ast = program;
here.gen.js = es_to_js(here.gen.es_ast);
here.gen.client_manifest = self.client_manifest;
Expand Down
2 changes: 2 additions & 0 deletions jac/jaclang/compiler/backends/es/esast_gen_pass.jac
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ walker EsastGenPass(BaseAstGenPass[es.Statement]) {
_ct_only_names: (set[str] | None) = None,
child_passes: list[EsastGenPass] = [],
imports: list[es.ImportDeclaration] = [],
module_import_paths: dict[str, str] = {},
exports: list[es.ExportNamedDeclaration] = [],
scope_stack: list[ScopeInfo] = [],
scope_map: dict[(uni.UniScopeNode, ScopeInfo)] = {},
Expand Down Expand Up @@ -402,6 +403,7 @@ walker EsastGenPass(BaseAstGenPass[es.Statement]) {
) -> es.MemberExpression;

can exit_module with uni.Module exit;
def _finalize_runtime_imports(program: es.Program) -> None;
def _audit_free_identifiers(nd: uni.Module, program: es.Program) -> None;
def _free_ident_site(
names: list[uni.Name], free: FreeIdentifier
Expand Down
1 change: 1 addition & 0 deletions jac/jaclang/compiler/frontend/codeinfo.jac
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ obj ClientManifest {
globals_values: dict[(str, any)] = field(default_factory=`dict),
has_client: bool = False,
imports: dict[(str, str)] = field(default_factory=`dict),
runtime_imports: dict[str, list[str]] = field(default_factory=`dict),
endpoint_effects: dict[(str, dict)] = field(default_factory=`dict),
tests: list[dict[(str, str)]] = field(default_factory=`list),
server_imports: list[str] = field(default_factory=`list),
Expand Down
17 changes: 13 additions & 4 deletions jac/jaclang/compiler/frontend/impl/codeinfo.impl.jac
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ impl ClientArtifact.to_bytes -> bytes {
import pickle;
return pickle.dumps(
(
2,
self.js,
self.manifest,
list(self.native_import_sources),
Expand All @@ -122,13 +123,21 @@ impl ClientArtifact.to_bytes -> bytes {
impl ClientArtifact.from_bytes(raw: bytes) -> (ClientArtifact | None) {
import pickle;
try {
(js, manifest, native_import_sources, source_map, dep_digests, framework) = (
pickle.loads(raw)
);
(
version,
js,
manifest,
native_import_sources,
source_map,
dep_digests,
framework
) = pickle.loads(raw);
} except Exception {
return None;
}
if not isinstance(js, str) or not isinstance(manifest, ClientManifest) {
if version != 2
or not isinstance(js, str)
or not isinstance(manifest, ClientManifest) {
return None;
}
return ClientArtifact(
Expand Down
Loading
Loading